From 8a3414db726d6c6e6061e5b630403debf5b2aa2f Mon Sep 17 00:00:00 2001 From: boutmoun123 Date: Mon, 8 Jun 2026 00:45:39 +0300 Subject: [PATCH] Normalize audio waveform peaks for Flutter --- .env.example | 2 +- .../utils/post-media-response.util.spec.ts | 30 +++++++ src/common/utils/post-media-response.util.ts | 5 +- src/common/utils/waveform.util.spec.ts | 70 ++++++++++++++++ src/common/utils/waveform.util.ts | 84 ++++++++++++++----- src/config/configuration.ts | 2 +- src/config/validation.schema.ts | 2 +- src/modules/posts/schemas/post.schema.ts | 7 ++ 8 files changed, 175 insertions(+), 27 deletions(-) create mode 100644 src/common/utils/post-media-response.util.spec.ts create mode 100644 src/common/utils/waveform.util.spec.ts diff --git a/.env.example b/.env.example index 41aac03..da05707 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/src/common/utils/post-media-response.util.spec.ts b/src/common/utils/post-media-response.util.spec.ts new file mode 100644 index 0000000..a49eedb --- /dev/null +++ b/src/common/utils/post-media-response.util.spec.ts @@ -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([]); + }); +}); diff --git a/src/common/utils/post-media-response.util.ts b/src/common/utils/post-media-response.util.ts index e7a010a..0db9d31 100644 --- a/src/common/utils/post-media-response.util.ts +++ b/src/common/utils/post-media-response.util.ts @@ -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, }; }; diff --git a/src/common/utils/waveform.util.spec.ts b/src/common/utils/waveform.util.spec.ts new file mode 100644 index 0000000..8fa90d6 --- /dev/null +++ b/src/common/utils/waveform.util.spec.ts @@ -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')); + }); +}); diff --git a/src/common/utils/waveform.util.ts b/src/common/utils/waveform.util.ts index ab0bda8..448f332 100644 --- a/src/common/utils/waveform.util.ts +++ b/src/common/utils/waveform.util.ts @@ -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; diff --git a/src/config/configuration.ts b/src/config/configuration.ts index fe51f9b..f28e52a 100644 --- a/src/config/configuration.ts +++ b/src/config/configuration.ts @@ -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', diff --git a/src/config/validation.schema.ts b/src/config/validation.schema.ts index d973607..95974bb 100644 --- a/src/config/validation.schema.ts +++ b/src/config/validation.schema.ts @@ -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), diff --git a/src/modules/posts/schemas/post.schema.ts b/src/modules/posts/schemas/post.schema.ts index de9b607..a40cd7e 100644 --- a/src/modules/posts/schemas/post.schema.ts +++ b/src/modules/posts/schemas/post.schema.ts @@ -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; @@ -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;