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_HLS_SEGMENT_DURATION_SECONDS=4
|
||||||
VIDEO_PROCESSING_THUMBNAIL_WIDTH=720
|
VIDEO_PROCESSING_THUMBNAIL_WIDTH=720
|
||||||
AUDIO_PROCESSING_ENABLED=false
|
AUDIO_PROCESSING_ENABLED=false
|
||||||
AUDIO_WAVEFORM_PEAKS=48
|
AUDIO_WAVEFORM_PEAKS=100
|
||||||
|
|
||||||
GOOGLE_CLIENT_ID=your_google_client_id
|
GOOGLE_CLIENT_ID=your_google_client_id
|
||||||
GOOGLE_CLIENT_SECRET=your_google_client_secret
|
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 { PostType } from '../enums/post-type.enum';
|
||||||
|
import { normalizeWaveformPeaks } from './waveform.util';
|
||||||
|
|
||||||
type VariantSet = {
|
type VariantSet = {
|
||||||
originalUrl?: string;
|
originalUrl?: string;
|
||||||
@@ -87,6 +88,8 @@ export const buildPostMediaResponse = (post: PostMediaInput) => {
|
|||||||
: mediaType === PostType.VIDEO || mediaType === PostType.AUDIO
|
: mediaType === PostType.VIDEO || mediaType === PostType.AUDIO
|
||||||
? thumbnailUrl
|
? thumbnailUrl
|
||||||
: '';
|
: '';
|
||||||
|
const waveformPeaks =
|
||||||
|
mediaType === PostType.AUDIO ? normalizeWaveformPeaks(post.waveformPeaks ?? []) : [];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
mediaType,
|
mediaType,
|
||||||
@@ -99,7 +102,7 @@ export const buildPostMediaResponse = (post: PostMediaInput) => {
|
|||||||
audioUrl: post.audioUrl ?? '',
|
audioUrl: post.audioUrl ?? '',
|
||||||
images,
|
images,
|
||||||
durationSeconds: post.durationSeconds ?? null,
|
durationSeconds: post.durationSeconds ?? null,
|
||||||
waveformPeaks: Array.isArray(post.waveformPeaks) ? post.waveformPeaks : [],
|
waveformPeaks,
|
||||||
isPlayable: mediaType === PostType.VIDEO ? !!preferredPlaybackUrl : mediaType === PostType.AUDIO ? !!post.audioUrl : false,
|
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[] => {
|
const scaleToRange = (values: number[]): number[] => {
|
||||||
if (!values.length) {
|
if (!values.length) {
|
||||||
@@ -7,52 +31,66 @@ const scaleToRange = (values: number[]): number[] => {
|
|||||||
|
|
||||||
const max = Math.max(...values);
|
const max = Math.max(...values);
|
||||||
if (max <= 0) {
|
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 = (
|
export const normalizeWaveformPeaks = (
|
||||||
input: number[] | undefined,
|
input: unknown[] | undefined,
|
||||||
maxSamples = DEFAULT_SAMPLES,
|
targetCount = getDefaultWaveformPeakCount(),
|
||||||
): number[] => {
|
): number[] => {
|
||||||
|
const samples =
|
||||||
|
Number.isInteger(targetCount) &&
|
||||||
|
targetCount >= MIN_DISPLAY_WAVEFORM_PEAKS &&
|
||||||
|
targetCount <= MAX_DISPLAY_WAVEFORM_PEAKS
|
||||||
|
? targetCount
|
||||||
|
: DEFAULT_WAVEFORM_PEAK_COUNT;
|
||||||
|
|
||||||
if (!input?.length) {
|
if (!input?.length) {
|
||||||
return [];
|
return fallbackWaveform(samples);
|
||||||
}
|
}
|
||||||
|
|
||||||
const cleaned = input
|
const cleaned = input
|
||||||
.map((value) => (Number.isFinite(value) ? Math.abs(value) : 0))
|
.map((value) => (typeof value === 'number' ? value : Number(value)))
|
||||||
.filter((value) => value > 0);
|
.filter((value) => Number.isFinite(value))
|
||||||
|
.map((value) => clampPeak(Math.abs(value)));
|
||||||
|
|
||||||
if (!cleaned.length) {
|
if (!cleaned.length) {
|
||||||
return [];
|
return fallbackWaveform(samples);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cleaned.length <= maxSamples) {
|
if (cleaned.length === samples) {
|
||||||
return scaleToRange(cleaned);
|
return scaleToRange(cleaned);
|
||||||
}
|
}
|
||||||
|
|
||||||
const windowSize = cleaned.length / maxSamples;
|
if (cleaned.length === 1) {
|
||||||
const compressed: number[] = [];
|
return Array.from({ length: samples }, () => cleaned[0]);
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 = (
|
export const generateWaveformPeaksFromBuffer = (
|
||||||
buffer: Buffer,
|
buffer: Buffer,
|
||||||
samples = DEFAULT_SAMPLES,
|
samples = getDefaultWaveformPeakCount(),
|
||||||
): number[] => {
|
): number[] => {
|
||||||
if (!buffer.length) {
|
if (!buffer.length) {
|
||||||
return [];
|
return normalizeWaveformPeaks([], samples);
|
||||||
}
|
}
|
||||||
|
|
||||||
const chunkSize = Math.max(1, Math.ceil(buffer.length / samples));
|
const chunkSize = Math.max(1, Math.ceil(buffer.length / samples));
|
||||||
@@ -78,7 +116,7 @@ export const generateWaveformPeaksFromBuffer = (
|
|||||||
|
|
||||||
export const generateWaveformPeaksFromSeed = (
|
export const generateWaveformPeaksFromSeed = (
|
||||||
seed: string,
|
seed: string,
|
||||||
samples = DEFAULT_SAMPLES,
|
samples = getDefaultWaveformPeakCount(),
|
||||||
): number[] => {
|
): number[] => {
|
||||||
const source = seed.trim() || 'audio';
|
const source = seed.trim() || 'audio';
|
||||||
let hash = 2166136261;
|
let hash = 2166136261;
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ export default () => ({
|
|||||||
},
|
},
|
||||||
audioProcessing: {
|
audioProcessing: {
|
||||||
enabled: (process.env.AUDIO_PROCESSING_ENABLED ?? 'false').toLowerCase() === 'true',
|
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: {
|
logging: {
|
||||||
level: process.env.LOG_LEVEL ?? 'log',
|
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_HLS_SEGMENT_DURATION_SECONDS: Joi.number().min(2).max(20).default(4),
|
||||||
VIDEO_PROCESSING_THUMBNAIL_WIDTH: Joi.number().min(160).max(1920).default(720),
|
VIDEO_PROCESSING_THUMBNAIL_WIDTH: Joi.number().min(160).max(1920).default(720),
|
||||||
AUDIO_PROCESSING_ENABLED: Joi.boolean().truthy('true').falsy('false').default(false),
|
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'),
|
LOG_LEVEL: Joi.string().valid('error', 'warn', 'log', 'debug', 'verbose').default('log'),
|
||||||
REQUEST_LOGGING_ENABLED: Joi.boolean().truthy('true').falsy('false').default(true),
|
REQUEST_LOGGING_ENABLED: Joi.boolean().truthy('true').falsy('false').default(true),
|
||||||
FEED_CACHE_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,
|
resolveManagedFileUrlRecords,
|
||||||
resolveManagedFileUrls,
|
resolveManagedFileUrls,
|
||||||
} from '../../../common/utils/public-url.util';
|
} from '../../../common/utils/public-url.util';
|
||||||
|
import { normalizeWaveformPeaks } from '../../../common/utils/waveform.util';
|
||||||
import { User } from '../../users/schemas/user.schema';
|
import { User } from '../../users/schemas/user.schema';
|
||||||
|
|
||||||
export type PostDocument = HydratedDocument<Post>;
|
export type PostDocument = HydratedDocument<Post>;
|
||||||
@@ -237,6 +238,12 @@ const transformManagedPostFiles = (_doc: unknown, ret: any) => {
|
|||||||
ret.audioUrl = resolveManagedFileUrl(ret.audioUrl);
|
ret.audioUrl = resolveManagedFileUrl(ret.audioUrl);
|
||||||
ret.thumbnailUrl = resolveManagedFileUrl(ret.thumbnailUrl);
|
ret.thumbnailUrl = resolveManagedFileUrl(ret.thumbnailUrl);
|
||||||
ret.thumbnailVariants = resolveManagedFileUrlRecord(ret.thumbnailVariants);
|
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.processingStatus = ret.processingStatus ?? ProcessingStatus.READY;
|
||||||
ret.media = buildPostMediaResponse(ret);
|
ret.media = buildPostMediaResponse(ret);
|
||||||
return ret;
|
return ret;
|
||||||
|
|||||||
المرجع في مشكلة جديدة
حظر مستخدم