1// Plan website transcription chunks from the audio the browser ACTUALLY extracted 2// (PRD 41 T04, F8/F9), and probe a file's length with ffmpeg when the browser 3// cannot read it. 4// 5// The backend rejects a chunk whose decoded length differs from its declared 6// duration by more than 0.15 s. The widgets used to plan from the selected range, 7// so a file whose real audio is shorter than the range (an ALAC .m4a Chrome cannot 8// read â 60-minute default range; a truncated MP3 whose header claims more than 9// decodes) declared chunks that did not exist and every one came back 422. 10// 11// Pure (Node-testable): the browser-only pieces (probeMediaFile, terminating the 12// ffmpeg instance) are injected by the widgets. 13 14import { planAudioChunks } from "./audio-chunking.mjs"; 15 16// How long the file-pick probe may take before the widget gives up and keeps the 17// "we couldn't read this file's length" note (risk R10: huge unreadable files). 18export const DURATION_PROBE_BUDGET_MS = 20_000; 19 20function positiveOrNull(value) { 21 return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : null; 22} 23 24function roundSeconds(value) { 25 return Math.round(value * 1000) / 1000; 26} 27 28/** 29 * effective = min(selected, measured); a missing/unusable measurement falls back 30 * to the selection (today's behaviour). 31 */ 32export function effectiveSelectionDuration(selectedDurationSeconds, measuredDurationSeconds) { 33 const selected = positiveOrNull(selectedDurationSeconds); 34 const measured = positiveOrNull(measuredDurationSeconds); 35 if (selected == null) return measured; 36 if (measured == null) return selected; 37 return Math.min(selected, roundSeconds(measured)); 38} 39 40/** 41 * Chunk plan + the selection to declare to the backend for one extraction. 42 * 43 * @returns {{effectiveDurationSeconds:number, endSeconds:number, measured:boolean, 44 * plan:Array<{index:number,startSeconds:number,endSeconds:number,durationSeconds:number}>}} 45 */ 46export function planFromMeasuredAudio({ startSeconds, selectedDurationSeconds, measuredDurationSeconds }) { 47 const effective = effectiveSelectionDuration(selectedDurationSeconds, measuredDurationSeconds); 48 const measured = positiveOrNull(measuredDurationSeconds) != null; 49 if (!measured) { 50 console.warn("[real-length] extracted audio length unknown; planning from the selected range"); 51 } 52 return { 53 effectiveDurationSeconds: effective, 54 endSeconds: roundSeconds((Number(startSeconds) || 0) + effective), 55 measured, 56 plan: planAudioChunks(effective), 57 }; 58} 59 60/** 61 * The effective length of a cached audio slot: the stored value, else the end of 62 * its last chunk (chunk spans are selection-relative), else the selection. 63 */ 64export function cachedEffectiveDuration(cachedEffectiveSeconds, cachedChunks, selectedDurationSeconds) { 65 const stored = positiveOrNull(cachedEffectiveSeconds); 66 if (stored != null) return effectiveSelectionDuration(selectedDurationSeconds, stored); 67 const lastEnd = Array.isArray(cachedChunks) && cachedChunks.length > 0 68 ? positiveOrNull(cachedChunks[cachedChunks.length - 1]?.endSeconds) 69 : null; 70 return effectiveSelectionDuration(selectedDurationSeconds, lastEnd); 71} 72 73/** 74 * Probe a file the browser could not read the length of. Resolves (never rejects) 75 * to the probed duration in seconds, or null when the probe failed, found no 76 * duration, or ran past the budget. On a timeout `onTimeout` is called so the 77 * caller can stop the stuck ffmpeg run (it would otherwise keep the source mounted 78 * under the extraction's mount point). 79 */ 80export async function probeUnknownDuration(file, { 81 probe, 82 timeoutMs = DURATION_PROBE_BUDGET_MS, 83 onTimeout, 84 setTimeoutFn = setTimeout, 85 clearTimeoutFn = clearTimeout, 86} = {}) { 87 if (!file || typeof probe !== "function") return null; 88 let timer = null; 89 const timeout = new Promise((resolve) => { 90 timer = setTimeoutFn(() => resolve({ timedOut: true }), timeoutMs); 91 }); 92 const probing = Promise.resolve() 93 .then(() => probe(file)) 94 .then((result) => ({ result })) 95 .catch((error) => ({ error })); 96 const outcome = await Promise.race([probing, timeout]); 97 clearTimeoutFn(timer); 98 if (outcome.timedOut) { 99 console.warn("[duration-probe] ffmpeg probe exceeded its budget; keeping the unknown-length note"); 100 try { 101 await onTimeout?.(); 102 } catch { 103 /* best effort */ 104 } 105 return null; 106 } 107 if (outcome.error) { 108 console.warn("[duration-probe] ffmpeg probe failed", outcome.error); 109 return null; 110 } 111 return positiveOrNull(outcome.result?.probe?.durationSeconds); 112}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.