Files
v3-api/src/audio/audio-normalization.service.ts

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)}`,
),
);
});
});
}
}