Recently Written · git

subplz-web

git clone https://github.com/equwal/subplz-web

Log | Files | Refs


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;