forked from Chatbot/v3-api
first commit v3 initialiazed a temporary repository for darmanet client
This commit is contained in:
216
src/audio/audio-normalization.service.ts
Normal file
216
src/audio/audio-normalization.service.ts
Normal file
@@ -0,0 +1,216 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import { stat, unlink } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { AudioNormalizationError } from './audio-normalization.errors';
|
||||
|
||||
/**
|
||||
* Normalizes arbitrary browser voice captures to **AAC-LC in MP4** (`.m4a`, `audio/mp4`).
|
||||
*
|
||||
* Rationale: AAC-LC in an MP4 container is natively supported by iOS Safari, Android Chrome,
|
||||
* and desktop browsers; `-movflags +faststart` moves metadata to the head of the file for
|
||||
* faster start/seek over HTTP. Mono + 96k is a strong voice default (size vs quality).
|
||||
*/
|
||||
@Injectable()
|
||||
export class AudioNormalizationService {
|
||||
private readonly logger = new Logger(AudioNormalizationService.name);
|
||||
private readonly ffmpegPath: string;
|
||||
private readonly ffprobePath: string;
|
||||
private readonly transcodeTimeoutMs: number;
|
||||
|
||||
constructor(private readonly config: ConfigService) {
|
||||
this.ffmpegPath = this.config.get<string>('AUDIO_FFMPEG_PATH')?.trim() || 'ffmpeg';
|
||||
this.ffprobePath = this.config.get<string>('AUDIO_FFPROBE_PATH')?.trim() || 'ffprobe';
|
||||
const raw = this.config.get<string>('AUDIO_TRANSCODE_TIMEOUT_MS');
|
||||
const n = raw != null ? Number(raw) : NaN;
|
||||
this.transcodeTimeoutMs = Number.isFinite(n) && n > 0 ? n : 120_000;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transcode any input FFmpeg can demux to single-channel AAC in MP4 (`.m4a`).
|
||||
* Caller must delete `outputAbsolutePath` and the input temp file after upload.
|
||||
*/
|
||||
async transcodeIncomingVoiceToM4a(
|
||||
inputAbsolutePath: string,
|
||||
): Promise<{ outputAbsolutePath: string; durationSec?: number }> {
|
||||
const outputAbsolutePath = join(tmpdir(), `voice-norm-${randomUUID()}.m4a`);
|
||||
|
||||
const args = [
|
||||
'-nostdin',
|
||||
'-hide_banner',
|
||||
'-loglevel',
|
||||
'error',
|
||||
'-y',
|
||||
'-i',
|
||||
inputAbsolutePath,
|
||||
'-vn',
|
||||
'-sn',
|
||||
'-dn',
|
||||
'-ac',
|
||||
'1',
|
||||
'-ar',
|
||||
'48000',
|
||||
'-c:a',
|
||||
'aac',
|
||||
'-b:a',
|
||||
'96k',
|
||||
'-profile:a',
|
||||
'aac_low',
|
||||
'-movflags',
|
||||
'+faststart',
|
||||
outputAbsolutePath,
|
||||
];
|
||||
|
||||
try {
|
||||
await this.runProcess(this.ffmpegPath, args, 'ffmpeg');
|
||||
} catch (e: unknown) {
|
||||
await unlink(outputAbsolutePath).catch(() => {});
|
||||
throw e;
|
||||
}
|
||||
|
||||
try {
|
||||
const st = await stat(outputAbsolutePath);
|
||||
if (!st.size) {
|
||||
throw new AudioNormalizationError('normalized_audio_empty');
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
await unlink(outputAbsolutePath).catch(() => {});
|
||||
throw e;
|
||||
}
|
||||
|
||||
let durationSec: number | undefined;
|
||||
try {
|
||||
durationSec = await this.probeDurationSec(outputAbsolutePath);
|
||||
} catch (err: unknown) {
|
||||
this.logger.debug(`ffprobe duration skipped: ${(err as Error)?.message}`);
|
||||
}
|
||||
|
||||
return { outputAbsolutePath, durationSec };
|
||||
}
|
||||
|
||||
private async probeDurationSec(filePath: string): Promise<number | undefined> {
|
||||
const args = [
|
||||
'-v',
|
||||
'error',
|
||||
'-show_entries',
|
||||
'format=duration',
|
||||
'-of',
|
||||
'default=noprint_wrappers=1:nokey=1',
|
||||
filePath,
|
||||
];
|
||||
const stdout = await this.runProcessCaptureStdout(this.ffprobePath, args, 'ffprobe');
|
||||
const n = parseFloat(String(stdout).trim());
|
||||
return Number.isFinite(n) ? n : undefined;
|
||||
}
|
||||
|
||||
private runProcess(
|
||||
command: string,
|
||||
args: string[],
|
||||
label: string,
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(command, args, {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
windowsHide: true,
|
||||
});
|
||||
|
||||
let stderr = '';
|
||||
const onData = (c: Buffer) => {
|
||||
stderr += c.toString();
|
||||
if (stderr.length > 64_000) stderr = stderr.slice(-32_000);
|
||||
};
|
||||
child.stderr?.on('data', onData);
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
child.kill('SIGKILL');
|
||||
reject(
|
||||
new AudioNormalizationError(`${label}_timeout_${this.transcodeTimeoutMs}ms`),
|
||||
);
|
||||
}, this.transcodeTimeoutMs);
|
||||
|
||||
child.on('error', (err: NodeJS.ErrnoException) => {
|
||||
clearTimeout(timer);
|
||||
if (err?.code === 'ENOENT') {
|
||||
reject(
|
||||
new AudioNormalizationError(
|
||||
`${label}_binary_not_found_install_ffmpeg_package`,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
|
||||
child.on('close', (code) => {
|
||||
clearTimeout(timer);
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
const tail = stderr.trim().slice(-800);
|
||||
reject(
|
||||
new AudioNormalizationError(
|
||||
`${label}_failed_exit_${code}${tail ? `:${tail}` : ''}`,
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private runProcessCaptureStdout(
|
||||
command: string,
|
||||
args: string[],
|
||||
label: string,
|
||||
): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(command, args, {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
windowsHide: true,
|
||||
});
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
child.stdout?.on('data', (c: Buffer) => {
|
||||
stdout += c.toString();
|
||||
});
|
||||
child.stderr?.on('data', (c: Buffer) => {
|
||||
stderr += c.toString();
|
||||
});
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
child.kill('SIGKILL');
|
||||
reject(
|
||||
new AudioNormalizationError(`${label}_timeout_${this.transcodeTimeoutMs}ms`),
|
||||
);
|
||||
}, this.transcodeTimeoutMs);
|
||||
|
||||
child.on('error', (err: NodeJS.ErrnoException) => {
|
||||
clearTimeout(timer);
|
||||
if (err?.code === 'ENOENT') {
|
||||
reject(
|
||||
new AudioNormalizationError(
|
||||
`${label}_binary_not_found_install_ffmpeg_package`,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
|
||||
child.on('close', (code) => {
|
||||
clearTimeout(timer);
|
||||
if (code === 0) {
|
||||
resolve(stdout);
|
||||
return;
|
||||
}
|
||||
reject(
|
||||
new AudioNormalizationError(
|
||||
`${label}_failed_exit_${code}:${stderr.trim().slice(-400)}`,
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user