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)
155 lines
4.2 KiB
TypeScript
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);
|
|
}
|
|
}
|