forked from Chatbot/v3-api
217 lines
6.1 KiB
TypeScript
217 lines
6.1 KiB
TypeScript
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)}`,
|
|
),
|
|
);
|
|
});
|
|
});
|
|
}
|
|
}
|