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 }