Recently Written · git

subplz-web

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

Log | Files | Refs


frontend/engine/job.js (10841 bytes)

1 /* One conversion, start to finish, in this tab.
2  *
3  *   audio --ffmpeg--> two-minute chunks --whisper--> rough transcript
4  *   book  ----------> paragraphs
5  *   transcript + paragraphs --align--> subtitles (.srt)
6  *   cover + audio (+ subtitles) --ffmpeg--> video, on request
7  *
8  * Nothing leaves the machine. The transcript is saved to IndexedDB after every
9  * chunk, because transcribing a book takes hours and tabs get closed: opening
10  * the same files again carries on from the last chunk, and aligning the same
11  * audio against a different edition of the book costs seconds.
12  */
13 import { syncedEpub } from './epub.js';
14 import { Media, quietestPoint, SAMPLE_RATE } from './media.js';
15 import { Recogniser } from './asr.js';
16 import { readBook } from './book.js';
17 
18 const CHUNK_SECONDS = 120;
19 
20 export class Cancelled extends Error { constructor() { super('Stopped.'); } }
21 
22 /* ------------------------------------------------------------ saved progress */
23 
24 const db = () => new Promise((resolve, reject) => {
25   const open = indexedDB.open('subread', 1);
26   open.onupgradeneeded = () => open.result.createObjectStore('transcripts');
27   open.onsuccess = () => resolve(open.result);
28   open.onerror = () => reject(open.error);
29 });
30 
31 async function store(mode, fn) {
32   const d = await db();
33   return new Promise((resolve, reject) => {
34     const tx = d.transaction('transcripts', mode);
35     const req = fn(tx.objectStore('transcripts'));
36     tx.oncomplete = () => resolve(req?.result);
37     tx.onerror = () => reject(tx.error);
38   });
39 }
40 
41 /** The same files picked again resume, whatever route they were picked by. */
42 const keyFor = (files, language) => files.map((f) => `${f.name}|${f.size}`).join('//') + `#${language}`;
43 
44 export async function savedProgress(files, language) {
45   try { return (await store('readonly', (s) => s.get(keyFor(files, language)))) ?? null; } catch { return null; }
46 }
47 
48 /* ------------------------------------------------------------------- the job */
49 
50 export class Job {
51   #media = null;
52   #paths = [];
53   #cancelled = false;
54   #book = null;
55 
56   constructor({ audio, book, language, onStatus }) {
57     this.audio = audio;          // File[], in playback order
58     this.bookFile = book;        // File
59     this.language = language;    // Whisper code; always explicit, see asr.js
60     this.onStatus = onStatus ?? (() => {});
61     this.result = null;
62   }
63 
64   cancel() { this.#cancelled = true; }
65   #check() { if (this.#cancelled) throw new Cancelled(); }
66   #status(phase, detail, fraction, extra = {}) { this.onStatus({ phase, detail, fraction, ...extra }); }
67 
68   async run() {
69     this.#status('preparing', 'Reading the book', 0);
70     this.#book = await readBook(this.bookFile);
71     if (!this.#book.paragraphs.length) throw new Error(`No text could be read from ${this.bookFile.name}.`);
72 
73     this.#status('preparing', 'Starting the audio decoder', 0);
74     this.#media = await Media.open();
75     const parts = [];
76     for (const [i, file] of this.audio.entries()) {
77       const path = await this.#media.mount(file, `/in${i}`);
78       parts.push({ path, ...(await this.#media.probe(path)) });
79       this.#paths.push(path);
80     }
81     const total = parts.reduce((n, p) => n + p.duration, 0);
82 
83     const transcript = await this.#transcribe(parts, total);
84     this.#check();
85 
86     this.#status('aligning', 'Matching the book to the narration', 0.97);
87     const aligned = await alignInWorker(transcript, this.#book.paragraphs, this.language);
88     const stem = (this.audio.length > 1 ? this.bookFile : this.audio[0]).name.replace(/\.[^.]+$/, '');
89     this.result = {
90       ...aligned, stem, duration: total, parts,
91       srtName: `${stem}.${this.language}.srt`,
92       segments: transcript.length,
93     };
94     this.#status('done', 'Done', 1);
95     return this.result;
96   }
97 
98   async #transcribe(parts, total) {
99     const key = keyFor(this.audio, this.language);
100     const saved = (await savedProgress(this.audio, this.language)) ?? { doneUntil: 0, segments: [], complete: false };
101     if (saved.complete) return saved.segments;
102 
103     this.#status('preparing', 'Loading the speech model', 0, { indeterminate: true });
104     const asr = await Recogniser.open((p) => {
105       if (p.total) this.#status('preparing', 'Downloading the speech model (once)', p.loaded / p.total * 0.02);
106     });
107     this.device = asr.device;
108 
109     const resumedAt = saved.doneUntil, started = performance.now();
110     let base = 0;   // where the current part starts on the whole book's clock
111     for (const part of parts) {
112       let pos = Math.max(0, saved.doneUntil - base);
113       while (pos < part.duration - 0.05) {
114         this.#check();
115         let pcm = await this.#media.pcm(part.path, pos, CHUNK_SECONDS);
116         if (!pcm.length) break;
117         const last = pos + pcm.length / SAMPLE_RATE >= part.duration - 0.5;
118         // Cut where the narrator pauses, so no word straddles two chunks.
119         if (!last) pcm = pcm.subarray(0, quietestPoint(pcm));
120 
121         const at = base + pos;
122         saved.segments.push(...await asr.transcribe(pcm, this.language, at));
123         pos += pcm.length / SAMPLE_RATE;
124         saved.doneUntil = base + pos;
125         await store('readwrite', (s) => s.put(saved, key)).catch(() => {});
126 
127         const elapsed = (performance.now() - started) / 1000;
128         const speed = (saved.doneUntil - resumedAt) / elapsed;
129         this.#status('transcribing', `Listening: ${clock(saved.doneUntil)} of ${clock(total)}`,
130           0.02 + 0.94 * saved.doneUntil / total,
131           { eta: elapsed > 10 ? (total - saved.doneUntil) / speed : null, speed: elapsed > 10 ? speed : null });
132       }
133       base += part.duration;
134     }
135     saved.complete = true;
136     await store('readwrite', (s) => s.put(saved, key)).catch(() => {});
137     return saved.segments;
138   }
139 
140   /**
141    * The video, made on request: a still image over the audio.
142    * kind "mkv" carries the subtitles inside the file (MPV, VLC);
143    * kind "mp4" is clean, for YouTube, where the .srt is uploaded alongside.
144    *
145    * A still picture does not need encoding for ten hours. One minute of it is
146    * encoded once and then repeated by stream copy, so the cost is that of
147    * copying the audio - seconds, not an hour.
148    */
149   async video(kind, { coverFile, onProgress } = {}) {
150     const m = this.#media, r = this.result;
151     const still = await canvasPng(coverFile ?? this.#book.cover);
152     await m.write('canvas.png', still);
153     await m.run(['-loop', '1', '-framerate', '1', '-i', 'canvas.png', '-t', '60',
154       '-c:v', 'libx264', '-preset', 'veryfast', '-tune', 'stillimage', '-pix_fmt', 'yuv420p',
155       '-r', '1', '-g', '60', 'still.mp4']);
156 
157     let audioIn;
158     if (this.#paths.length === 1) audioIn = ['-i', this.#paths[0]];
159     else {
160       const list = this.#paths.map((p) => `file '${p.replaceAll("'", "'\\''")}'`).join('\n');
161       await m.write('parts.txt', new TextEncoder().encode(list));
162       audioIn = ['-f', 'concat', '-safe', '0', '-i', 'parts.txt'];
163     }
164     // Copy the audio when the container can hold it; re-encoding a whole book
165     // in WebAssembly is the one slow thing here, so only when there is no choice.
166     const copyable = kind === 'mkv'
167       ? ['aac', 'mp3', 'opus', 'vorbis', 'flac', 'ac3', 'alac']
168       : ['aac', 'mp3', 'ac3', 'alac'];
169     const codecs = new Set(r.parts.map((p) => p.codec));
170     const audioCodec = codecs.size === 1 && copyable.includes([...codecs][0]) ? ['-c:a', 'copy'] : ['-c:a', 'aac', '-b:a', '128k'];
171 
172     const out = `out.${kind}`;
173     const args = ['-stream_loop', '-1', '-i', 'still.mp4', ...audioIn];
174     if (kind === 'mkv') {
175       await m.write('subs.srt', new TextEncoder().encode(r.srt));
176       args.push('-f', 'srt', '-i', 'subs.srt', '-map', '0:v', '-map', '1:a:0', '-map', '2:s',
177         '-c:v', 'copy', ...audioCodec, '-c:s', 'srt', '-disposition:s:0', 'default',
178         '-metadata:s:s:0', 'title=Aligned subtitles');
179     } else {
180       args.push('-map', '0:v', '-map', '1:a:0', '-c:v', 'copy', ...audioCodec);
181     }
182     args.push('-map_chapters', '1', '-t', r.duration.toFixed(3), out);
183 
184     try {
185       await m.run(args, onProgress);
186       const bytes = await m.read(out);
187       return new File([bytes], `${r.stem}.${this.language}.${kind}`,
188         { type: kind === 'mkv' ? 'video/x-matroska' : 'video/mp4' });
189     } finally {
190       for (const f of [out, 'still.mp4', 'canvas.png', 'subs.srt', 'parts.txt']) await m.remove(f);
191     }
192   }
193 
194   /**
195    * The book with the narration inside it (EPUB 3 Media Overlays): a reader
196    * such as Thorium or Storyteller plays it and highlights each line.
197    */
198   async epub() {
199     const r = this.result;
200     if (!/\.epub$/i.test(this.bookFile.name)) throw new Error('A read-along book is made from an epub; this book is not one.');
201     const parts = r.parts.map((p, i) => {
202       const file = this.audio[i];
203       // AAC counts only inside an MP4 file; a bare .aac stream is not audio an epub may hold.
204       const codec = p.codec === 'aac' && !/\.(m4a|m4b|mp4)$/i.test(file.name) ? 'aac (not in an m4a file)' : p.codec;
205       return { file, duration: p.duration, codec };
206     });
207     return syncedEpub({ book: this.bookFile, cues: r.cues, parts, stem: r.stem });
208   }
209 
210   close() { this.#media?.close(); }
211 }
212 
213 function alignInWorker(transcript, paragraphs, language) {
214   return new Promise((resolve, reject) => {
215     const worker = new Worker(new URL('./align.worker.js', import.meta.url), { type: 'module' });
216     worker.onmessage = ({ data }) => {
217       worker.terminate();
218       if (data.ok) resolve(data); else reject(new Error(data.error));
219     };
220     worker.onerror = (e) => { worker.terminate(); reject(new Error(e.message || 'The aligner crashed.')); };
221     worker.postMessage({ transcript, paragraphs, language });
222   });
223 }
224 
225 /** The cover, letterboxed onto a 1920x1080 card; a plain dark card when there is none. */
226 async function canvasPng(cover) {
227   const W = 1920, H = 1080;
228   const canvas = new OffscreenCanvas(W, H);
229   const ctx = canvas.getContext('2d');
230   ctx.fillStyle = '#16161a';
231   ctx.fillRect(0, 0, W, H);
232   if (cover) {
233     try {
234       const blob = cover instanceof Blob ? cover : new Blob([cover.bytes]);
235       const img = await createImageBitmap(blob);
236       const scale = Math.min(W / img.width, H / img.height);
237       const w = img.width * scale, h = img.height * scale;
238       ctx.fillStyle = '#000';
239       ctx.fillRect(0, 0, W, H);
240       ctx.drawImage(img, (W - w) / 2, (H - h) / 2, w, h);
241     } catch { /* an image the browser cannot decode: keep the plain card */ }
242   }
243   const png = await canvas.convertToBlob({ type: 'image/png' });
244   return new Uint8Array(await png.arrayBuffer());
245 }
246 
247 export function clock(seconds) {
248   const s = Math.max(0, Math.floor(seconds)), pad = (v) => String(v).padStart(2, '0');
249   return s >= 3600 ? `${Math.floor(s / 3600)}:${pad(Math.floor(s / 60) % 60)}:${pad(s % 60)}`
250     : `${Math.floor(s / 60)}:${pad(s % 60)}`;
251 }