frontend/engine/media.js (4526 bytes)
1 /* Reading and writing media in the browser, with ffmpeg compiled to WebAssembly. 2 * 3 * The visitor's file is never copied anywhere: it is mounted read-only into 4 * ffmpeg's filesystem (WORKERFS), which reads straight from the File on disk. 5 * That is what makes a 600 MB audiobook workable - the only audio ever held in 6 * memory is the couple of minutes being transcribed. 7 * 8 * ffmpeg runs in its own worker; everything here is async message-passing. 9 */ 10 import { FFmpeg } from '/vendor/ffmpeg/index.js'; 11 12 const CORE = new URL('/vendor/ffmpeg/ffmpeg-core.js', import.meta.url).href; 13 const RATE = 16000; 14 15 export class Media { 16 #ffmpeg = new FFmpeg(); 17 #log = []; 18 #mounted = []; 19 20 /** Bytes fetched so far while the 32 MB core downloads, for a progress bar. */ 21 static async open(onLog) { 22 const m = new Media(); 23 m.#ffmpeg.on('log', ({ message }) => { 24 m.#log.push(message); 25 if (m.#log.length > 400) m.#log.shift(); 26 onLog?.(message); 27 }); 28 await m.#ffmpeg.load({ coreURL: CORE }); 29 return m; 30 } 31 32 /** Make `file` readable by ffmpeg at the returned path, without copying it. */ 33 async mount(file, dir) { 34 await this.#ffmpeg.createDir(dir).catch(() => {}); 35 await this.#ffmpeg.mount('WORKERFS', { files: [file] }, dir); 36 this.#mounted.push(dir); 37 return `${dir}/${file.name}`; 38 } 39 40 /** Duration in seconds and chapter marks, read from ffmpeg's own banner. */ 41 async probe(path) { 42 this.#log.length = 0; 43 await this.#ffmpeg.exec(['-hide_banner', '-i', path]); // "fails": no output given. Expected. 44 const text = this.#log.join('\n'); 45 const d = /Duration:\s*(\d+):(\d+):(\d+(?:\.\d+)?)/.exec(text); 46 if (!d) throw new Error('This file does not look like audio ffmpeg can read.'); 47 const audio = /Stream #\d+:\d+.*?: Audio: ([a-z0-9_]+)/i.exec(text); 48 return { 49 duration: (+d[1]) * 3600 + (+d[2]) * 60 + (+d[3]), 50 codec: audio ? audio[1].toLowerCase() : null, 51 chapters: [...text.matchAll(/Chapter #\d+:\d+: start ([\d.]+), end ([\d.]+)/g)] 52 .map((c) => ({ start: +c[1], end: +c[2] })), 53 }; 54 } 55 56 /** `seconds` of audio from `start`, as the 16 kHz mono float PCM the speech model takes. */ 57 async pcm(path, start, seconds) { 58 const out = 'chunk.f32'; 59 const code = await this.#ffmpeg.exec([ 60 '-hide_banner', '-v', 'error', 61 // Tolerate damaged files: drop what cannot be decoded, keep going. 62 '-err_detect', 'ignore_err', '-fflags', '+discardcorrupt', 63 '-ss', String(start), '-t', String(seconds), '-i', path, 64 '-map', '0:a:0', '-vn', '-sn', '-ac', '1', '-ar', String(RATE), '-f', 'f32le', out, 65 ]); 66 if (code !== 0) throw new Error(`Could not decode the audio at ${Math.round(start)}s.`); 67 const bytes = await this.#ffmpeg.readFile(out); 68 await this.#ffmpeg.deleteFile(out); 69 return new Float32Array(bytes.buffer, bytes.byteOffset, bytes.byteLength >> 2); 70 } 71 72 async write(name, data) { await this.#ffmpeg.writeFile(name, data); } 73 async read(name) { return this.#ffmpeg.readFile(name); } 74 async remove(name) { await this.#ffmpeg.deleteFile(name).catch(() => {}); } 75 76 /** Run ffmpeg; throws with the tail of its log when it fails. */ 77 async run(args, onProgress) { 78 this.#log.length = 0; 79 const handler = onProgress && (({ progress }) => onProgress(Math.min(Math.max(progress, 0), 1))); 80 if (handler) this.#ffmpeg.on('progress', handler); 81 try { 82 const code = await this.#ffmpeg.exec(['-hide_banner', '-y', ...args]); 83 if (code !== 0) throw new Error(this.#log.slice(-3).join(' | ') || `ffmpeg exited with ${code}`); 84 } finally { 85 if (handler) this.#ffmpeg.off('progress', handler); 86 } 87 } 88 89 close() { this.#ffmpeg.terminate(); } 90 } 91 92 /** 93 * Where to cut a chunk: the quietest quarter second in its last few seconds, 94 * so that no word is split across two transcriptions. 95 * Returns a sample index into `pcm`. 96 */ 97 export function quietestPoint(pcm, searchSeconds = 12, windowSeconds = 0.25) { 98 const window = Math.floor(windowSeconds * RATE); 99 const from = Math.max(0, pcm.length - searchSeconds * RATE); 100 if (pcm.length - from <= window * 2) return pcm.length; 101 let energy = 0; 102 for (let i = from; i < from + window; i++) energy += Math.abs(pcm[i]); 103 let best = energy, bestAt = from; 104 for (let i = from; i + window < pcm.length; i++) { 105 energy += Math.abs(pcm[i + window]) - Math.abs(pcm[i]); 106 if (energy < best) { best = energy; bestAt = i + 1; } 107 } 108 return bestAt + (window >> 1); 109 } 110 111 export const SAMPLE_RATE = RATE;