Normalize audio waveform peaks for Flutter
فشلت بعض الفحوصات
Deploy To Ghaymah / deploy (push) Has been cancelled
فشلت بعض الفحوصات
Deploy To Ghaymah / deploy (push) Has been cancelled
هذا الالتزام موجود في:
@@ -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
|
||||
|
||||
30
src/common/utils/post-media-response.util.spec.ts
Normal file
30
src/common/utils/post-media-response.util.spec.ts
Normal file
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
70
src/common/utils/waveform.util.spec.ts
Normal file
70
src/common/utils/waveform.util.spec.ts
Normal file
@@ -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;
|
||||
|
||||
المرجع في مشكلة جديدة
حظر مستخدم