Files
tonemark/src/lib/server/audio.ts
T
Giancarmine Salucci 066f197d0d D2: unify DATA_DIR default across audio/downloader/remux/db modules
Create src/lib/server/data-dir.ts as single source of truth for DATA_DIR
using db.ts's $HOME-based convention. All four modules now import from it
instead of defining their own inline default (some resolving to /tmp/.whisper-pwa).

Changes:
- src/lib/server/data-dir.ts — new file, exports DATA_DIR const
- db.ts — import DATA_DIR from data-dir.ts (same logic, same path)
- audio.ts — import DATA_DIR, replace inline process.env.DATA_DIR ?? '/tmp/...'
- downloader.ts — import DATA_DIR, replace inline '/tmp/.whisper-pwa'
- remux.ts — import DATA_DIR, replace inline process.env.DATA_DIR ?? '/tmp/...'
- tests/data-dir.test.ts — 10 tests covering default, custom, and module integrity
- tests/retry-pipeline.test.ts — fix stale hardcoded /tmp/.whisper-pwa/uploads
  path; compute parent dir from getUploadPath() dynamically

Test pass rate: 217/220 (3 pre-existing failures unrelated to DATA_DIR)
- 2 D5 downloader tests need yt-dlp (not available in test env)
- All other tests green, including the new data-dir.test.ts (10/10)
2026-07-09 05:12:11 +02:00

155 lines
4.2 KiB
TypeScript

import { execFile } from 'child_process';
import { promisify } from 'util';
import { existsSync } from 'fs';
import { mkdir, unlink, rename } from 'fs/promises';
import { join } from 'path';
import { DATA_DIR } from './data-dir.js';
import type { AudioMode, AudioAnalysis } from '$lib/types.js';
const execFileAsync = promisify(execFile);
const TMP_DIR = join(DATA_DIR, 'audio');
export async function ensureTmpDir() {
if (!existsSync(TMP_DIR)) await mkdir(TMP_DIR, { recursive: true });
}
export function tmpPath(jobId: string, suffix: string) {
return join(TMP_DIR, `${jobId}${suffix}`);
}
export async function cleanup(...paths: string[]) {
await Promise.allSettled(paths.map((p) => unlink(p).catch(() => {})));
}
/** Run ffmpeg volumedetect and return mean/max dB. */
export async function analyzeVolume(inputPath: string): Promise<AudioAnalysis> {
const { stderr } = await execFileAsync('ffmpeg', [
'-i', inputPath,
'-af', 'volumedetect',
'-vn', '-sn', '-dn',
'-f', 'null', '-'
]);
const meanMatch = stderr.match(/mean_volume:\s*([-\d.]+)\s*dB/);
const maxMatch = stderr.match(/max_volume:\s*([-\d.]+)\s*dB/);
return {
meanVolume: meanMatch ? parseFloat(meanMatch[1]) : -99,
maxVolume: maxMatch ? parseFloat(maxMatch[1]) : -99
};
}
/**
* Detect leading silence duration (ms).
* Only trims if silence begins at/near time 0 (< 0.5s).
* Capped at 30s to prevent accidental over-trimming.
*/
async function detectLeadingSilenceMs(inputPath: string): Promise<number> {
try {
const { stderr } = await execFileAsync('ffmpeg', [
'-i', inputPath,
'-af', 'silencedetect=n=-40dB:d=0.1',
'-vn', '-sn', '-dn',
'-f', 'null', '-'
]);
const startMatch = stderr.match(/silence_start:\s*([\d.]+)/);
const endMatch = stderr.match(/silence_end:\s*([\d.]+)/);
// Only trim if silence genuinely starts at the very beginning of the file
if (startMatch && endMatch && parseFloat(startMatch[1]) < 0.5) {
return Math.min(Math.floor(parseFloat(endMatch[1]) * 1000), 30_000);
}
} catch {
// ignore
}
return 0;
}
/** Build ffmpeg -af filter chain for the given mode and mean volume. */
export function buildFilterChain(mode: AudioMode, meanVolume: number): string | null {
const isQuiet = meanVolume < -30;
switch (mode) {
case 'none':
return null;
case 'standard':
return 'highpass=f=80,lowpass=f=8000,loudnorm=I=-16:LRA=11:TP=-1.5';
case 'aggressive':
return [
'highpass=f=80',
isQuiet ? 'volume=24dB,dynaudnorm=f=500:g=15' : null,
'lowpass=f=8000',
'afftdn=nf=-30',
'agate=threshold=0.01:attack=5:release=50',
'loudnorm=I=-16:LRA=11:TP=-1.5'
]
.filter(Boolean)
.join(',');
case 'auto':
default:
if (isQuiet) {
return [
'highpass=f=80',
'volume=24dB',
'dynaudnorm=f=500:g=15',
'lowpass=f=8000',
'afftdn=nf=-25',
'loudnorm=I=-16:LRA=11:TP=-1.5'
].join(',');
}
return 'highpass=f=80,lowpass=f=8000,loudnorm=I=-16:LRA=11:TP=-1.5';
}
}
/**
* Prepare audio for Whisper: convert to 16kHz mono WAV, trim leading silence,
* apply the appropriate filter chain.
* Returns path to the prepared WAV file.
*/
export async function prepareAudio(
inputPath: string,
jobId: string,
mode: AudioMode
): Promise<{ wavPath: string; analysis: AudioAnalysis }> {
await ensureTmpDir();
// Step 1: analyse volume on the original file
const analysis = await analyzeVolume(inputPath);
// Step 2: detect leading silence
const silenceMs = await detectLeadingSilenceMs(inputPath);
const wavPath = tmpPath(jobId, '.wav');
const filterChain = buildFilterChain(mode, analysis.meanVolume);
const args: string[] = ['-y'];
// Trim leading silence
if (silenceMs > 0) {
args.push('-ss', (silenceMs / 1000).toFixed(3));
}
args.push('-i', inputPath, '-ar', '16000', '-ac', '1');
if (filterChain) {
args.push('-af', filterChain);
}
args.push('-c:a', 'pcm_s16le', wavPath);
await execFileAsync('ffmpeg', args);
return { wavPath, analysis };
}
/** Move a file to a new path (cross-device safe). */
export async function moveFile(src: string, dest: string) {
try {
await rename(src, dest);
} catch {
const { copyFile } = await import('fs/promises');
await copyFile(src, dest);
await unlink(src);
}
}