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('AUDIO_FFMPEG_PATH')?.trim() || 'ffmpeg'; this.ffprobePath = this.config.get('AUDIO_FFPROBE_PATH')?.trim() || 'ffprobe'; const raw = this.config.get('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 { 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 { 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 { 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)}`, ), ); }); }); } }