Normalize audio waveform peaks for Flutter
فشلت بعض الفحوصات
Deploy To Ghaymah / deploy (push) Has been cancelled

هذا الالتزام موجود في:
boutmoun123
2026-06-08 00:45:39 +03:00
الأصل c93296ba9d
التزام 8a3414db72
8 ملفات معدلة مع 175 إضافات و27 حذوفات

عرض الملف

@@ -82,7 +82,7 @@ VIDEO_PROCESSING_GENERATE_HLS=true
VIDEO_PROCESSING_HLS_SEGMENT_DURATION_SECONDS=4
VIDEO_PROCESSING_THUMBNAIL_WIDTH=720
AUDIO_PROCESSING_ENABLED=false
AUDIO_WAVEFORM_PEAKS=48
AUDIO_WAVEFORM_PEAKS=100
GOOGLE_CLIENT_ID=your_google_client_id
GOOGLE_CLIENT_SECRET=your_google_client_secret

عرض الملف

@@ -0,0 +1,30 @@
import { PostType } from '../enums/post-type.enum';
import { buildPostMediaResponse } from './post-media-response.util';
describe('post media response util', () => {
it('returns display-safe waveformPeaks for audio media', () => {
const media = buildPostMediaResponse({
postType: PostType.AUDIO,
audioUrl: '/uploads/posts/audios/audio.mp3',
durationSeconds: 42,
waveformPeaks: [1, 12, 30, 45, 20, 6],
});
expect(media.mediaType).toBe(PostType.AUDIO);
expect(media.durationSeconds).toBe(42);
expect(media.waveformPeaks).toHaveLength(100);
expect(media.waveformPeaks.every((value) => Number.isInteger(value))).toBe(true);
expect(media.waveformPeaks.every((value) => value >= 0 && value <= 100)).toBe(true);
});
it('does not attach waveformPeaks to non-audio media', () => {
const media = buildPostMediaResponse({
postType: PostType.IMAGE,
imageUrls: ['/uploads/posts/images/image.jpg'],
waveformPeaks: [10, 20, 30],
});
expect(media.mediaType).toBe(PostType.IMAGE);
expect(media.waveformPeaks).toEqual([]);
});
});

عرض الملف

@@ -1,4 +1,5 @@
import { PostType } from '../enums/post-type.enum';
import { normalizeWaveformPeaks } from './waveform.util';
type VariantSet = {
originalUrl?: string;
@@ -87,6 +88,8 @@ export const buildPostMediaResponse = (post: PostMediaInput) => {
: mediaType === PostType.VIDEO || mediaType === PostType.AUDIO
? thumbnailUrl
: '';
const waveformPeaks =
mediaType === PostType.AUDIO ? normalizeWaveformPeaks(post.waveformPeaks ?? []) : [];
return {
mediaType,
@@ -99,7 +102,7 @@ export const buildPostMediaResponse = (post: PostMediaInput) => {
audioUrl: post.audioUrl ?? '',
images,
durationSeconds: post.durationSeconds ?? null,
waveformPeaks: Array.isArray(post.waveformPeaks) ? post.waveformPeaks : [],
waveformPeaks,
isPlayable: mediaType === PostType.VIDEO ? !!preferredPlaybackUrl : mediaType === PostType.AUDIO ? !!post.audioUrl : false,
};
};

عرض الملف

@@ -0,0 +1,70 @@
import {
generateWaveformPeaksFromBuffer,
generateWaveformPeaksFromSeed,
normalizeWaveformPeaks,
} from './waveform.util';
const expectDisplaySafeWaveform = (peaks: number[], expectedLength = 100) => {
expect(peaks).toHaveLength(expectedLength);
expect(peaks.every((value) => Number.isInteger(value))).toBe(true);
expect(peaks.every((value) => value >= 0 && value <= 100)).toBe(true);
};
describe('waveform util', () => {
const originalAudioWaveformPeaks = process.env.AUDIO_WAVEFORM_PEAKS;
beforeEach(() => {
delete process.env.AUDIO_WAVEFORM_PEAKS;
});
afterAll(() => {
if (typeof originalAudioWaveformPeaks === 'undefined') {
delete process.env.AUDIO_WAVEFORM_PEAKS;
return;
}
process.env.AUDIO_WAVEFORM_PEAKS = originalAudioWaveformPeaks;
});
it('returns exactly 100 values by default', () => {
expectDisplaySafeWaveform(normalizeWaveformPeaks([10, 20, 30, 40]));
});
it('clamps values between 0 and 100', () => {
const peaks = normalizeWaveformPeaks([-20, 10.4, 55.7, 250, Number.NaN]);
expectDisplaySafeWaveform(peaks);
});
it('resamples short input to 100 values', () => {
const peaks = normalizeWaveformPeaks([5, 20, 60, 90, 30, 10]);
expectDisplaySafeWaveform(peaks);
});
it('downsamples long input to 100 values', () => {
const input = Array.from({ length: 200 }, (_, index) => index + 1);
expectDisplaySafeWaveform(normalizeWaveformPeaks(input));
});
it('returns fallback 100 values for empty input', () => {
const peaks = normalizeWaveformPeaks([]);
expectDisplaySafeWaveform(peaks);
expect(new Set(peaks).size).toBeGreaterThan(1);
});
it('uses configured values only within the Flutter display-safe range', () => {
process.env.AUDIO_WAVEFORM_PEAKS = '120';
expectDisplaySafeWaveform(normalizeWaveformPeaks([1, 2, 3]), 120);
process.env.AUDIO_WAVEFORM_PEAKS = '48';
expectDisplaySafeWaveform(normalizeWaveformPeaks([1, 2, 3]), 100);
});
it('generates display-safe peaks from audio buffers and seeds', () => {
expectDisplaySafeWaveform(generateWaveformPeaksFromBuffer(Buffer.from('fake audio bytes')));
expectDisplaySafeWaveform(generateWaveformPeaksFromSeed('audio-url'));
});
});

عرض الملف

@@ -1,4 +1,28 @@
const DEFAULT_SAMPLES = 48;
export const DEFAULT_WAVEFORM_PEAK_COUNT = 100;
const MIN_DISPLAY_WAVEFORM_PEAKS = 80;
const MAX_DISPLAY_WAVEFORM_PEAKS = 120;
export const getDefaultWaveformPeakCount = (): number => {
const configured = Number(process.env.AUDIO_WAVEFORM_PEAKS);
if (
Number.isInteger(configured) &&
configured >= MIN_DISPLAY_WAVEFORM_PEAKS &&
configured <= MAX_DISPLAY_WAVEFORM_PEAKS
) {
return configured;
}
return DEFAULT_WAVEFORM_PEAK_COUNT;
};
const clampPeak = (value: number): number => Math.max(0, Math.min(100, Math.round(value)));
const fallbackWaveform = (samples: number): number[] =>
Array.from({ length: samples }, (_, index) => {
const wave = Math.sin((index / Math.max(1, samples - 1)) * Math.PI * 6);
const accent = index % 9 === 0 ? 18 : index % 5 === 0 ? 8 : 0;
return clampPeak(34 + wave * 18 + accent);
});
const scaleToRange = (values: number[]): number[] => {
if (!values.length) {
@@ -7,52 +31,66 @@ const scaleToRange = (values: number[]): number[] => {
const max = Math.max(...values);
if (max <= 0) {
return values.map(() => 0);
return values.map((value) => clampPeak(value));
}
return values.map((value) => Math.max(0, Math.min(100, Math.round((value / max) * 100))));
return values.map((value) => clampPeak((value / max) * 100));
};
export const normalizeWaveformPeaks = (
input: number[] | undefined,
maxSamples = DEFAULT_SAMPLES,
input: unknown[] | undefined,
targetCount = getDefaultWaveformPeakCount(),
): number[] => {
const samples =
Number.isInteger(targetCount) &&
targetCount >= MIN_DISPLAY_WAVEFORM_PEAKS &&
targetCount <= MAX_DISPLAY_WAVEFORM_PEAKS
? targetCount
: DEFAULT_WAVEFORM_PEAK_COUNT;
if (!input?.length) {
return [];
return fallbackWaveform(samples);
}
const cleaned = input
.map((value) => (Number.isFinite(value) ? Math.abs(value) : 0))
.filter((value) => value > 0);
.map((value) => (typeof value === 'number' ? value : Number(value)))
.filter((value) => Number.isFinite(value))
.map((value) => clampPeak(Math.abs(value)));
if (!cleaned.length) {
return [];
return fallbackWaveform(samples);
}
if (cleaned.length <= maxSamples) {
if (cleaned.length === samples) {
return scaleToRange(cleaned);
}
const windowSize = cleaned.length / maxSamples;
const compressed: number[] = [];
for (let i = 0; i < maxSamples; i += 1) {
const start = Math.floor(i * windowSize);
const end = Math.min(cleaned.length, Math.floor((i + 1) * windowSize));
const slice = cleaned.slice(start, Math.max(start + 1, end));
const peak = Math.max(...slice);
compressed.push(peak);
if (cleaned.length === 1) {
return Array.from({ length: samples }, () => cleaned[0]);
}
return scaleToRange(compressed);
const resampled: number[] = [];
const sourceMaxIndex = cleaned.length - 1;
const targetMaxIndex = samples - 1;
for (let i = 0; i < samples; i += 1) {
const sourceIndex = targetMaxIndex === 0 ? 0 : (i / targetMaxIndex) * sourceMaxIndex;
const leftIndex = Math.floor(sourceIndex);
const rightIndex = Math.min(sourceMaxIndex, Math.ceil(sourceIndex));
const ratio = sourceIndex - leftIndex;
const interpolated = cleaned[leftIndex] * (1 - ratio) + cleaned[rightIndex] * ratio;
resampled.push(interpolated);
}
return scaleToRange(resampled);
};
export const generateWaveformPeaksFromBuffer = (
buffer: Buffer,
samples = DEFAULT_SAMPLES,
samples = getDefaultWaveformPeakCount(),
): number[] => {
if (!buffer.length) {
return [];
return normalizeWaveformPeaks([], samples);
}
const chunkSize = Math.max(1, Math.ceil(buffer.length / samples));
@@ -78,7 +116,7 @@ export const generateWaveformPeaksFromBuffer = (
export const generateWaveformPeaksFromSeed = (
seed: string,
samples = DEFAULT_SAMPLES,
samples = getDefaultWaveformPeakCount(),
): number[] => {
const source = seed.trim() || 'audio';
let hash = 2166136261;

عرض الملف

@@ -126,7 +126,7 @@ export default () => ({
},
audioProcessing: {
enabled: (process.env.AUDIO_PROCESSING_ENABLED ?? 'false').toLowerCase() === 'true',
waveformPeaks: Number(process.env.AUDIO_WAVEFORM_PEAKS ?? 48),
waveformPeaks: Number(process.env.AUDIO_WAVEFORM_PEAKS ?? 100),
},
logging: {
level: process.env.LOG_LEVEL ?? 'log',

عرض الملف

@@ -83,7 +83,7 @@ export const validationSchema = Joi.object({
VIDEO_PROCESSING_HLS_SEGMENT_DURATION_SECONDS: Joi.number().min(2).max(20).default(4),
VIDEO_PROCESSING_THUMBNAIL_WIDTH: Joi.number().min(160).max(1920).default(720),
AUDIO_PROCESSING_ENABLED: Joi.boolean().truthy('true').falsy('false').default(false),
AUDIO_WAVEFORM_PEAKS: Joi.number().min(16).max(256).default(48),
AUDIO_WAVEFORM_PEAKS: Joi.number().min(16).max(256).default(100),
LOG_LEVEL: Joi.string().valid('error', 'warn', 'log', 'debug', 'verbose').default('log'),
REQUEST_LOGGING_ENABLED: Joi.boolean().truthy('true').falsy('false').default(true),
FEED_CACHE_ENABLED: Joi.boolean().truthy('true').falsy('false').default(true),

عرض الملف

@@ -11,6 +11,7 @@ import {
resolveManagedFileUrlRecords,
resolveManagedFileUrls,
} from '../../../common/utils/public-url.util';
import { normalizeWaveformPeaks } from '../../../common/utils/waveform.util';
import { User } from '../../users/schemas/user.schema';
export type PostDocument = HydratedDocument<Post>;
@@ -237,6 +238,12 @@ const transformManagedPostFiles = (_doc: unknown, ret: any) => {
ret.audioUrl = resolveManagedFileUrl(ret.audioUrl);
ret.thumbnailUrl = resolveManagedFileUrl(ret.thumbnailUrl);
ret.thumbnailVariants = resolveManagedFileUrlRecord(ret.thumbnailVariants);
ret.waveformPeaks =
ret.postType === PostType.AUDIO || ret.audioUrl
? normalizeWaveformPeaks(ret.waveformPeaks)
: Array.isArray(ret.waveformPeaks)
? ret.waveformPeaks
: [];
ret.processingStatus = ret.processingStatus ?? ProcessingStatus.READY;
ret.media = buildPostMediaResponse(ret);
return ret;