PageSourceSearch

https://video-to-notes.com/extract-audio-widget.mjs

js video-to-notes.com collected 2026-09-24 14:56:16 UTC 34,195 bytes, 774 lines download raw bytes

1// extract-audio-widget.mjs — Extract the audio track from a video (or audio)
2// file and download it as MP3, M4A, or WAV. 100% client-side.
3//
4// Reuses the site's existing in-browser ffmpeg.wasm machinery (the same engine
5// the notes widget uses): the file never leaves the browser and nothing is sent
6// to a server — the audio is decoded, the selected range is re-encoded, and the
7// result is offered as a direct download.
8//
9// Flow:
10//   1. Pick/drop a file → read its duration.
11//   2. Choose a range (two time boxes) + an output format (MP3 / M4A / WAV).
12//   3. Extract + re-encode that range with ffmpeg.wasm.
13//   4. Preview the audio and download it.
14
15import { ensureFfmpeg, extractAudioToFormat, AudioExtractionError } from "./lib/ffmpeg-extractor.mjs";
16import { mountRangeSelector } from "./lib/media-range-selector.mjs";
17import {
18  AUDIO_EXPORT_FORMATS,
19  getExportFormat,
20  DEFAULT_AUDIO_EXPORT_FORMAT_ID,
21} from "./lib/audio-export-formats.mjs";
22import {
23  h,
24  looksLikeMedia,
25  readMediaDuration,
26  parseTimecode,
27  formatTimestamp,
28  slugify,
29  triggerDownload,
30  formatBytes,
31} from "./lib/media-ui.mjs";
32import { resolveWidgetLocale, pickStrings, applyUnlimitedRangeCopy } from "./lib/widget-i18n.mjs";
33
34// Fallback range end when browser metadata cannot reveal the file duration.
35const DEFAULT_INITIAL_RANGE_SECONDS = 10 * 60;
36
37const STRINGS_BY_LOCALE = {
38  en: {
39    dropTitle: "Drop a video here, or browse",
40    dropHint:
41      "Pick a video or audio file — MP4, MOV, WebM, MKV, MP3, M4A. You'll choose a range and a format. Your file never leaves your browser.",
42    browse: "Choose a file",
43    rangeHeading: "Choose the range and format",
44    formatLabel: "Audio format",
45    rangeStartLabel: "Start",
46    rangeEndLabel: "End",
47    rangeHint: "Up to 10 minutes per extraction. Times are mm:ss (or h:mm:ss).",
48    rangeLongNote:
49      "This file is longer than 10 minutes — we'll extract the selected range. Extract additional parts one range at a time.",
50    durationUnknownNote:
51      "We couldn't read this file's length, so the end defaults to 10:00 — adjust it if you need less.",
52    extract: "Extract audio",
53    changeFile: "Choose a different file",
54    rangeInvalid: "Enter a valid start and end — end must be after start.",
55    rangeTooLong: "Please select a range of 10 minutes or less.",
56    tooLarge: "That file is too large (over 500 MB). Try a shorter clip.",
57    notMedia: "That doesn't look like a video or audio file. Pick an MP4, MOV, WebM, MKV, MP3, or M4A.",
58    loadingEngine: "Loading the audio engine (first run downloads it once)…",
59    extracting: "Extracting audio…",
60    genericError: "Something went wrong extracting audio from this file. Please try again.",
61    tryAgain: "Adjust & retry",
62    startOver: "Start over",
63    resultHeading: "Your audio is ready",
64    download: "Download audio",
65    extractAnother: "Extract another range",
66    privacyNote: "Everything runs in your browser — your file is never uploaded.",
67    ariaChooseVideoFile: "Choose a video file",
68    fileNameFallback: "Your file",
69    lengthPrefix: "Length",
70    lengthUnknown: "Length unknown",
71  },
72  de: {
73    dropTitle: "Lege hier ein Video ab oder durchsuche deine Dateien",
74    dropHint:
75      "Wähle eine Video- oder Audiodatei — MP4, MOV, WebM, MKV, MP3, M4A. Du legst einen Bereich und ein Format fest. Deine Datei verlässt nie deinen Browser.",
76    browse: "Datei auswählen",
77    rangeHeading: "Wähle Bereich und Format",
78    formatLabel: "Audioformat",
79    rangeStartLabel: "Start",
80    rangeEndLabel: "Ende",
81    rangeHint: "Bis zu 10 Minuten pro Extraktion. Zeiten im Format mm:ss (oder h:mm:ss).",
82    rangeLongNote:
83      "Diese Datei ist länger als 10 Minuten — wir extrahieren den ausgewählten Bereich. Extrahiere weitere Teile jeweils einen Bereich nach dem anderen.",
84    durationUnknownNote:
85      "Wir konnten die Länge dieser Datei nicht lesen, daher steht das Ende standardmäßig auf 10:00 — passe es an, wenn du weniger brauchst.",
86    extract: "Audio extrahieren",
87    changeFile: "Eine andere Datei auswählen",
88    rangeInvalid: "Gib einen gültigen Start und ein gültiges Ende ein — das Ende muss nach dem Start liegen.",
89    rangeTooLong: "Bitte wähle einen Bereich von 10 Minuten oder weniger.",
90    tooLarge: "Diese Datei ist zu groß (über 500 MB). Versuche einen kürzeren Clip.",
91    notMedia: "Das sieht nicht nach einer Video- oder Audiodatei aus. Wähle eine MP4, MOV, WebM, MKV, MP3 oder M4A.",
92    loadingEngine: "Audio-Engine wird geladen (beim ersten Mal wird sie einmalig heruntergeladen)…",
93    extracting: "Audio wird extrahiert…",
94    genericError: "Beim Extrahieren des Audios aus dieser Datei ist etwas schiefgelaufen. Bitte versuche es erneut.",
95    tryAgain: "Anpassen & erneut versuchen",
96    startOver: "Von vorn beginnen",
97    resultHeading: "Dein Audio ist fertig",
98    download: "Audio herunterladen",
99    extractAnother: "Einen weiteren Bereich extrahieren",
100    privacyNote: "Alles läuft in deinem Browser — deine Datei wird nie hochgeladen.",
101    ariaChooseVideoFile: "Eine Videodatei auswählen",
102    fileNameFallback: "Deine Datei",
103    lengthPrefix: "Länge",
104    lengthUnknown: "Länge unbekannt",
105  },
106  fr: {
107    dropTitle: "Déposez une vidéo ici, ou parcourez",
108    dropHint:
109      "Choisissez un fichier vidéo ou audio — MP4, MOV, WebM, MKV, MP3, M4A. Vous choisirez une plage et un format. Votre fichier ne quitte jamais votre navigateur.",
110    browse: "Choisir un fichier",
111    rangeHeading: "Choisissez la plage et le format",
112    formatLabel: "Format audio",
113    rangeStartLabel: "Début",
114    rangeEndLabel: "Fin",
115    rangeHint: "Jusqu'à 10 minutes par extraction. Les temps sont au format mm:ss (ou h:mm:ss).",
116    rangeLongNote:
117      "Ce fichier dure plus de 10 minutes — nous extrairons la plage sélectionnée. Extrayez d'autres parties une plage à la fois.",
118    durationUnknownNote:
119      "Nous n'avons pas pu lire la durée de ce fichier ; la fin est donc fixée par défaut à 10:00 — ajustez-la si besoin.",
120    extract: "Extraire l'audio",
121    changeFile: "Choisir un autre fichier",
122    rangeInvalid: "Saisissez un début et une fin valides — la fin doit être après le début.",
123    rangeTooLong: "Veuillez sélectionner une plage de 10 minutes maximum.",
124    tooLarge: "Ce fichier est trop volumineux (plus de 500 MB). Essayez un extrait plus court.",
125    notMedia: "Cela ne ressemble pas à un fichier vidéo ou audio. Choisissez un MP4, MOV, WebM, MKV, MP3 ou M4A.",
126    loadingEngine: "Chargement du moteur audio (téléchargé une seule fois au premier lancement)…",
127    extracting: "Extraction de l'audio…",
128    genericError: "Une erreur s'est produite lors de l'extraction de l'audio de ce fichier. Veuillez réessayer.",
129    tryAgain: "Ajuster et réessayer",
130    startOver: "Recommencer",
131    resultHeading: "Votre audio est prêt",
132    download: "Télécharger l'audio",
133    extractAnother: "Extraire une autre plage",
134    privacyNote: "Tout s'exécute dans votre navigateur — votre fichier n'est jamais téléversé.",
135    ariaChooseVideoFile: "Choisir un fichier vidéo",
136    fileNameFallback: "Votre fichier",
137    lengthPrefix: "Durée",
138    lengthUnknown: "Durée inconnue",
139  },
140  es: {
141    dropTitle: "Arrastra un vídeo aquí o búscalo",
142    dropHint:
143      "Elige un archivo de vídeo o audio — MP4, MOV, WebM, MKV, MP3, M4A. Elegirás un fragmento y un formato. Tu archivo nunca sale de tu navegador.",
144    browse: "Elegir un archivo",
145    rangeHeading: "Elige el fragmento y el formato",
146    formatLabel: "Formato de audio",
147    rangeStartLabel: "Inicio",
148    rangeEndLabel: "Final",
149    rangeHint: "Hasta 10 minutos por extracción. Los tiempos son mm:ss (o h:mm:ss).",
150    rangeLongNote:
151      "Este archivo dura más de 10 minutos — extraeremos el fragmento seleccionado. Extrae más partes de una en una.",
152    durationUnknownNote:
153      "No pudimos leer la duración de este archivo, así que el final se establece en 10:00 — ajústalo si necesitas menos.",
154    extract: "Extraer audio",
155    changeFile: "Elegir otro archivo",
156    rangeInvalid: "Introduce un inicio y un final válidos — el final debe ser posterior al inicio.",
157    rangeTooLong: "Selecciona un fragmento de 10 minutos o menos.",
158    tooLarge: "Ese archivo es demasiado grande (más de 500 MB). Prueba con un clip más corto.",
159    notMedia: "Eso no parece un archivo de vídeo o audio. Elige un MP4, MOV, WebM, MKV, MP3 o M4A.",
160    loadingEngine: "Cargando el motor de audio (la primera vez se descarga una sola vez)…",
161    extracting: "Extrayendo el audio…",
162    genericError: "Algo salió mal al extraer el audio de este archivo. Inténtalo de nuevo.",
163    tryAgain: "Ajustar y reintentar",
164    startOver: "Empezar de nuevo",
165    resultHeading: "Tu audio está listo",
166    download: "Descargar audio",
167    extractAnother: "Extraer otro fragmento",
168    privacyNote: "Todo se ejecuta en tu navegador — tu archivo nunca se sube.",
169    ariaChooseVideoFile: "Elegir un archivo de vídeo",
170    fileNameFallback: "Tu archivo",
171    lengthPrefix: "Duración",
172    lengthUnknown: "Duración desconocida",
173  },
174  it: {
175    dropTitle: "Trascina qui un video, oppure scegline uno",
176    dropHint:
177      "Scegli un file video o audio — MP4, MOV, WebM, MKV, MP3, M4A. Sceglierai un intervallo e un formato. Il tuo file non lascia mai il browser.",
178    browse: "Scegli un file",
179    rangeHeading: "Scegli l'intervallo e il formato",
180    formatLabel: "Formato audio",
181    rangeStartLabel: "Inizio",
182    rangeEndLabel: "Fine",
183    rangeHint: "Fino a 10 minuti per estrazione. I tempi sono in mm:ss (o h:mm:ss).",
184    rangeLongNote:
185      "Questo file dura più di 10 minuti — estrarremo l'intervallo selezionato. Estrai altre parti un intervallo alla volta.",
186    durationUnknownNote:
187      "Non siamo riusciti a leggere la durata di questo file, quindi la fine è impostata su 10:00 — modificala se ti serve meno.",
188    extract: "Estrai audio",
189    changeFile: "Scegli un altro file",
190    rangeInvalid: "Inserisci un inizio e una fine validi — la fine deve essere dopo l'inizio.",
191    rangeTooLong: "Seleziona un intervallo di 10 minuti o meno.",
192    tooLarge: "Questo file è troppo grande (oltre 500 MB). Prova una clip più breve.",
193    notMedia: "Questo non sembra un file video o audio. Scegli un MP4, MOV, WebM, MKV, MP3 o M4A.",
194    loadingEngine: "Caricamento del motore audio (alla prima esecuzione viene scaricato una volta)…",
195    extracting: "Estrazione dell'audio…",
196    genericError: "Qualcosa è andato storto nell'estrarre l'audio da questo file. Riprova.",
197    tryAgain: "Modifica e riprova",
198    startOver: "Ricomincia",
199    resultHeading: "Il tuo audio è pronto",
200    download: "Scarica l'audio",
201    extractAnother: "Estrai un altro intervallo",
202    privacyNote: "Tutto avviene nel tuo browser — il tuo file non viene mai caricato.",
203    ariaChooseVideoFile: "Scegli un file video",
204    fileNameFallback: "Il tuo file",
205    lengthPrefix: "Durata",
206    lengthUnknown: "Durata sconosciuta",
207  },
208  nl: {
209    dropTitle: "Sleep hier een video, of blader",
210    dropHint:
211      "Kies een video- of audiobestand — MP4, MOV, WebM, MKV, MP3, M4A. Je kiest een bereik en een formaat. Je bestand verlaat je browser nooit.",
212    browse: "Kies een bestand",
213    rangeHeading: "Kies het bereik en het formaat",
214    formatLabel: "Audioformaat",
215    rangeStartLabel: "Begin",
216    rangeEndLabel: "Einde",
217    rangeHint: "Maximaal 10 minuten per extractie. Tijden zijn mm:ss (of h:mm:ss).",
218    rangeLongNote:
219      "Dit bestand is langer dan 10 minuten — we extraheren het geselecteerde bereik. Extraheer extra delen één bereik per keer.",
220    durationUnknownNote:
221      "We konden de lengte van dit bestand niet lezen, dus het einde staat standaard op 10:00 — pas het aan als je minder nodig hebt.",
222    extract: "Audio extraheren",
223    changeFile: "Kies een ander bestand",
224    rangeInvalid: "Voer een geldig begin en einde in — het einde moet na het begin liggen.",
225    rangeTooLong: "Selecteer een bereik van 10 minuten of minder.",
226    tooLarge: "Dat bestand is te groot (meer dan 500 MB). Probeer een korter fragment.",
227    notMedia: "Dat lijkt geen video- of audiobestand. Kies een MP4, MOV, WebM, MKV, MP3 of M4A.",
228    loadingEngine: "De audio-engine laden (de eerste keer wordt die eenmalig gedownload)…",
229    extracting: "Audio extraheren…",
230    genericError: "Er ging iets mis bij het extraheren van audio uit dit bestand. Probeer het opnieuw.",
231    tryAgain: "Aanpassen en opnieuw",
232    startOver: "Opnieuw beginnen",
233    resultHeading: "Je audio is klaar",
234    download: "Audio downloaden",
235    extractAnother: "Nog een bereik extraheren",
236    privacyNote: "Alles draait in je browser — je bestand wordt nooit geüpload.",
237    ariaChooseVideoFile: "Kies een videobestand",
238    fileNameFallback: "Je bestand",
239    lengthPrefix: "Lengte",
240    lengthUnknown: "Lengte onbekend",
241  },
242  sv: {
243    dropTitle: "Släpp en video här, eller bläddra",
244    dropHint:
245      "Välj en video- eller ljudfil — MP4, MOV, WebM, MKV, MP3, M4A. Du väljer ett intervall och ett format. Din fil lämnar aldrig din webbläsare.",
246    browse: "Välj en fil",
247    rangeHeading: "Välj intervall och format",
248    formatLabel: "Ljudformat",
249    rangeStartLabel: "Start",
250    rangeEndLabel: "Slut",
251    rangeHint: "Upp till 10 minuter per extrahering. Tider anges som mm:ss (eller h:mm:ss).",
252    rangeLongNote:
253      "Den här filen är längre än 10 minuter — vi extraherar det valda intervallet. Extrahera fler delar ett intervall i taget.",
254    durationUnknownNote:
255      "Vi kunde inte läsa filens längd, så slutet sätts som standard till 10:00 — justera det om du behöver mindre.",
256    extract: "Extrahera ljud",
257    changeFile: "Välj en annan fil",
258    rangeInvalid: "Ange en giltig start och ett giltigt slut — slutet måste vara efter starten.",
259    rangeTooLong: "Välj ett intervall på 10 minuter eller mindre.",
260    tooLarge: "Den filen är för stor (över 500 MB). Prova ett kortare klipp.",
261    notMedia: "Det där ser inte ut som en video- eller ljudfil. Välj en MP4, MOV, WebM, MKV, MP3 eller M4A.",
262    loadingEngine: "Laddar ljudmotorn (första gången laddas den ner en gång)…",
263    extracting: "Extraherar ljud…",
264    genericError: "Något gick fel när ljud skulle extraheras från den här filen. Försök igen.",
265    tryAgain: "Justera & försök igen",
266    startOver: "Börja om",
267    resultHeading: "Ditt ljud är klart",
268    download: "Ladda ner ljud",
269    extractAnother: "Extrahera ett annat intervall",
270    privacyNote: "Allt körs i din webbläsare — din fil laddas aldrig upp.",
271    ariaChooseVideoFile: "Välj en videofil",
272    fileNameFallback: "Din fil",
273    lengthPrefix: "Längd",
274    lengthUnknown: "Okänd längd",
275  },
276  da: {
277    dropTitle: "Slip en video her, eller gennemse",
278    dropHint:
279      "Vælg en video- eller lydfil — MP4, MOV, WebM, MKV, MP3, M4A. Du vælger et interval og et format. Din fil forlader aldrig din browser.",
280    browse: "Vælg en fil",
281    rangeHeading: "Vælg interval og format",
282    formatLabel: "Lydformat",
283    rangeStartLabel: "Start",
284    rangeEndLabel: "Slut",
285    rangeHint: "Op til 10 minutter pr. udtræk. Tidspunkter er mm:ss (eller h:mm:ss).",
286    rangeLongNote:
287      "Denne fil er længere end 10 minutter — vi udtrækker det valgte interval. Udtræk flere dele ét interval ad gangen.",
288    durationUnknownNote:
289      "Vi kunne ikke læse filens længde, så slut er som standard 10:00 — justér det, hvis du har brug for mindre.",
290    extract: "Udtræk lyd",
291    changeFile: "Vælg en anden fil",
292    rangeInvalid: "Angiv en gyldig start og slut — slut skal være efter start.",
293    rangeTooLong: "Vælg et interval på 10 minutter eller mindre.",
294    tooLarge: "Den fil er for stor (over 500 MB). Prøv et kortere klip.",
295    notMedia: "Det ligner ikke en video- eller lydfil. Vælg en MP4, MOV, WebM, MKV, MP3 eller M4A.",
296    loadingEngine: "Indlæser lydmotoren (første kørsel henter den én gang)…",
297    extracting: "Udtrækker lyd…",
298    genericError: "Noget gik galt under udtrækningen af lyd fra denne fil. Prøv igen.",
299    tryAgain: "Justér og prøv igen",
300    startOver: "Start forfra",
301    resultHeading: "Din lyd er klar",
302    download: "Download lyd",
303    extractAnother: "Udtræk et andet interval",
304    privacyNote: "Alt kører i din browser — din fil uploades aldrig.",
305    ariaChooseVideoFile: "Vælg en videofil",
306    fileNameFallback: "Din fil",
307    lengthPrefix: "Længde",
308    lengthUnknown: "Ukendt længde",
309  },
310  no: {
311    dropTitle: "Slipp en video her, eller bla gjennom",
312    dropHint:
313      "Velg en video- eller lydfil — MP4, MOV, WebM, MKV, MP3, M4A. Du velger et område og et format. Filen din forlater aldri nettleseren din.",
314    browse: "Velg en fil",
315    rangeHeading: "Velg område og format",
316    formatLabel: "Lydformat",
317    rangeStartLabel: "Start",
318    rangeEndLabel: "Slutt",
319    rangeHint: "Opptil 10 minutter per uttrekk. Tider er mm:ss (eller h:mm:ss).",
320    rangeLongNote:
321      "Denne filen er lengre enn 10 minutter — vi trekker ut det valgte området. Trekk ut flere deler ett område om gangen.",
322    durationUnknownNote:
323      "Vi klarte ikke å lese lengden på denne filen, så slutten settes som standard til 10:00 — juster den hvis du trenger mindre.",
324    extract: "Trekk ut lyd",
325    changeFile: "Velg en annen fil",
326    rangeInvalid: "Skriv inn en gyldig start og slutt — slutten må være etter starten.",
327    rangeTooLong: "Velg et område på 10 minutter eller mindre.",
328    tooLarge: "Den filen er for stor (over 500 MB). Prøv et kortere klipp.",
329    notMedia: "Dette ser ikke ut som en video- eller lydfil. Velg en MP4, MOV, WebM, MKV, MP3 eller M4A.",
330    loadingEngine: "Laster lydmotoren (lastes ned én gang ved første kjøring)…",
331    extracting: "Trekker ut lyd…",
332    genericError: "Noe gikk galt under uttrekk av lyd fra denne filen. Prøv igjen.",
333    tryAgain: "Juster og prøv igjen",
334    startOver: "Start på nytt",
335    resultHeading: "Lyden din er klar",
336    download: "Last ned lyd",
337    extractAnother: "Trekk ut et nytt område",
338    privacyNote: "Alt kjører i nettleseren din — filen din lastes aldri opp.",
339    ariaChooseVideoFile: "Velg en videofil",
340    fileNameFallback: "Filen din",
341    lengthPrefix: "Lengde",
342    lengthUnknown: "Ukjent lengde",
343  },
344  fi: {
345    dropTitle: "Pudota video tähän tai selaa",
346    dropHint:
347      "Valitse video- tai äänitiedosto — MP4, MOV, WebM, MKV, MP3, M4A. Valitset jakson ja muodon. Tiedostosi ei koskaan poistu selaimestasi.",
348    browse: "Valitse tiedosto",
349    rangeHeading: "Valitse jakso ja muoto",
350    formatLabel: "Äänimuoto",
351    rangeStartLabel: "Alku",
352    rangeEndLabel: "Loppu",
353    rangeHint: "Enintään 10 minuuttia poimintaa kohti. Ajat ovat muodossa mm:ss (tai h:mm:ss).",
354    rangeLongNote:
355      "Tämä tiedosto on yli 10 minuuttia pitkä — poimimme valitun jakson. Poimi lisää osia yksi jakso kerrallaan.",
356    durationUnknownNote:
357      "Emme pystyneet lukemaan tämän tiedoston kestoa, joten loppu on oletuksena 10:00 — säädä sitä, jos tarvitset lyhyemmän.",
358    extract: "Poimi ääni",
359    changeFile: "Valitse toinen tiedosto",
360    rangeInvalid: "Anna kelvollinen alku ja loppu — lopun on oltava alun jälkeen.",
361    rangeTooLong: "Valitse enintään 10 minuutin jakso.",
362    tooLarge: "Tiedosto on liian suuri (yli 500 MB). Kokeile lyhyempää leikettä.",
363    notMedia: "Tämä ei näytä video- tai äänitiedostolta. Valitse MP4, MOV, WebM, MKV, MP3 tai M4A.",
364    loadingEngine: "Ladataan äänimoottoria (ensimmäisellä kerralla se ladataan kerran)…",
365    extracting: "Poimitaan ääntä…",
366    genericError: "Äänen poimimisessa tästä tiedostosta tapahtui virhe. Yritä uudelleen.",
367    tryAgain: "Säädä ja yritä uudelleen",
368    startOver: "Aloita alusta",
369    resultHeading: "Äänesi on valmis",
370    download: "Lataa ääni",
371    extractAnother: "Poimi toinen jakso",
372    privacyNote: "Kaikki tapahtuu selaimessasi — tiedostoasi ei koskaan ladata palvelimelle.",
373    ariaChooseVideoFile: "Valitse videotiedosto",
374    fileNameFallback: "Tiedostosi",
375    lengthPrefix: "Kesto",
376    lengthUnknown: "Kesto tuntematon",
377  },
378};
379
380// ─── widget mount ────────────────────────────────────────────────────────────
381
382export function mountExtractAudioWidget(containerEl, config = {}) {
383  const STRINGS = applyUnlimitedRangeCopy(pickStrings(STRINGS_BY_LOCALE, resolveWidgetLocale()), resolveWidgetLocale());
384  const initialRangeEndSeconds =
385    Number(config.defaultRangeEndSeconds) || Number(config.maxUploadDurationSeconds) || DEFAULT_INITIAL_RANGE_SECONDS;
386
387  let phase = "idle"; // idle | range | working | result | error
388  let statusLabel = "";
389  let statusPercent = null; // 0-100 or null for indeterminate
390  let activeRun = 0; // guards against stale renders after Start over
391  let lastFile = null;
392  let fileDuration = null; // seconds, or null if unknown
393  let rangeStart = 0;
394  let rangeEnd = initialRangeEndSeconds;
395  let rangeError = null;
396  let selectedFormatId = DEFAULT_AUDIO_EXPORT_FORMAT_ID;
397  let resultPayload = null; // { url, byteSize, filename, format, durationSeconds }
398  let errorState = null; // { message, retryable }
399  let rangeSelector = null; // live media-range-selector instance (range step only)
400
401  function render() {
402    // Always tear down the range selector before re-rendering: this revokes its
403    // object URL, disconnects the ResizeObserver, and removes its listeners, so
404    // no preview/slider resource leaks across phase changes.
405    if (rangeSelector) {
406      rangeSelector.destroy();
407      rangeSelector = null;
408    }
409    containerEl.innerHTML = "";
410    let node;
411    if (phase === "idle") node = renderIdle();
412    else if (phase === "range") node = renderRange();
413    else if (phase === "working") node = renderWorking();
414    else if (phase === "result") node = renderResult();
415    else if (phase === "error") node = renderError();
416    containerEl.appendChild(node);
417  }
418
419  function setStatus(percent, label) {
420    statusPercent = percent;
421    if (label) statusLabel = label;
422    if (phase === "working") render();
423  }
424
425  // Revoke any object URL we created for a previous result so the blob can be
426  // garbage-collected; a single page session may extract many files.
427  function releaseResult() {
428    if (resultPayload && resultPayload.url) {
429      URL.revokeObjectURL(resultPayload.url);
430    }
431    resultPayload = null;
432  }
433
434  function reset() {
435    releaseResult();
436    phase = "idle";
437    statusLabel = "";
438    statusPercent = null;
439    errorState = null;
440    rangeError = null;
441    lastFile = null;
442    fileDuration = null;
443    activeRun += 1; // cancel any late callbacks from a previous run
444    render();
445  }
446
447  // ── file pick → range step ────────────────────────────────────────────────
448
449  async function onFile(file) {
450    if (!looksLikeMedia(file)) {
451      errorState = { message: STRINGS.notMedia, retryable: false };
452      phase = "error";
453      render();
454      return;
455    }
456
457    releaseResult();
458    lastFile = file;
459    const runId = ++activeRun;
460    phase = "working";
461    setStatus(null, STRINGS.loadingEngine);
462
463    const duration = await readMediaDuration(file);
464    // A newer file pick or a reset may have superseded us while we read the
465    // metadata; bail so a late duration can't clobber the current run.
466    if (runId !== activeRun) return;
467    fileDuration = duration;
468    rangeStart = 0;
469    rangeEnd = fileDuration || initialRangeEndSeconds;
470    rangeError = null;
471    phase = "range";
472    render();
473  }
474
475  function onExtract(startValue, endValue, formatId) {
476    const start = parseTimecode(startValue);
477    let end = parseTimecode(endValue);
478    if (start == null || end == null || start < 0 || end <= start) {
479      rangeError = STRINGS.rangeInvalid;
480      render();
481      return;
482    }
483    if (fileDuration) end = Math.min(end, fileDuration);
484    rangeStart = start;
485    rangeEnd = end;
486    selectedFormatId = formatId || selectedFormatId;
487    rangeError = null;
488    const runId = ++activeRun;
489    phase = "working";
490    setStatus(null, STRINGS.loadingEngine);
491    runExtraction(runId, start, end, getExportFormat(selectedFormatId)).catch((err) => {
492      if (runId !== activeRun) return;
493      console.error("[extract-audio-widget] extraction failed", err);
494      const message = err instanceof AudioExtractionError ? err.message : 
494STRINGS.genericError;
495      errorState = { message, retryable: !(err instanceof AudioExtractionError) || err.retryable };
496      phase = "error";
497      render();
498    });
499  }
500
501  async function runExtraction(runId, startSeconds, endSeconds, format) {
502    const file = lastFile;
503    const selectedDuration = endSeconds - startSeconds;
504
505    setStatus(null, STRINGS.loadingEngine);
506    await ensureFfmpeg();
507    if (runId !== activeRun) return;
508
509    const fileBuffer = await file.arrayBuffer();
510    if (runId !== activeRun) return;
511
512    setStatus(0, STRINGS.extracting);
513    const audio = await extractAudioToFormat({
514      fileBuffer,
515      sourceFileName: file.name || "video",
516      format,
517      startSeconds,
518      durationSeconds: selectedDuration,
519      onProgress: (percent, label) => {
520        if (runId === activeRun) setStatus(percent, label || STRINGS.extracting);
521      },
522    });
523    if (runId !== activeRun) return;
524
525    releaseResult();
526    const baseName = slugify(stripExtension(file.name) || "audio", "audio");
527    resultPayload = {
528      blob: audio.blob,
529      url: URL.createObjectURL(audio.blob),
530      byteSize: audio.byteSize,
531      mimeType: audio.mimeType,
532      filename: `${baseName}.${audio.extension}`,
533      format,
534      durationSeconds: selectedDuration,
535    };
536    phase = "result";
537    render();
538  }
539
540  // ── renderers ────────────────────────────────────────────────────────────────
541
542  function renderIdle() {
543    const input = h("input", {
544      type: "file",
545      className: "upload-file-input",
546      accept: "video/*,audio/*",
547      "aria-label": STRINGS.ariaChooseVideoFile,
548    });
549    input.addEventListener("change", () => {
550      const file = input.files && input.files[0];
551      if (file) onFile(file);
552    });
553
554    const dropzone = h(
555      "div",
556      { className: "upload-dropzone", role: "button", tabindex: "0" },
557      h("p", { className: "upload-drop-title" }, STRINGS.dropTitle),
558      h("p", { className: "upload-drop-hint" }, STRINGS.dropHint),
559      h(
560        "button",
561        {
562          type: "button",
563          className: "button button-primary upload-browse-btn",
564          onClick: () => input.click(),
565        },
566        STRINGS.browse,
567      ),
568      input,
569    );
570
571    dropzone.addEventListener("keydown", (e) => {
572      if (e.key === "Enter" || e.key === " ") {
573        e.preventDefault();
574        input.click();
575      }
576    });
577    dropzone.addEventListener("dragover", (e) => {
578      e.preventDefault();
579      dropzone.classList.add("is-dragover");
580    });
581    dropzone.addEventListener("dragleave", () => dropzone.classList.remove("is-dragover"));
582    dropzone.addEventListener("drop", (e) => {
583      e.preventDefault();
584      dropzone.classList.remove("is-dragover");
585      const file = e.dataTransfer?.files && e.dataTransfer.files[0];
586      if (file) onFile(file);
587    });
588
589    return h("div", { className: "upload-widget-idle" }, dropzone);
590  }
591
592  function renderRange() {
593    const fileName = (lastFile && lastFile.name) || STRINGS.fileNameFallback;
594    const durationLabel =
595      fileDuration != null ? `${STRINGS.lengthPrefix} ${formatTimestamp(fileDuration)}` : STRINGS.lengthUnknown;
596
597    // Format chips (native radios styled as chips so selection is accessible and
598    // needs no re-render). Read the checked value at submit time.
599    const formatInputs = [];
600    const formatChips = AUDIO_EXPORT_FORMATS.map((format) => {
601      const radio = h("input", {
602        type: "radio",
603        name: "extract-format",
604        value: format.id,
605        className: "format-chip-input",
606        ...(format.id === selectedFormatId ? { checked: "checked" } : {}),
607      });
608      formatInputs.push(radio);
609      return h(
610        "label",
611        { className: "format-chip", title: format.description },
612        radio,
613        h("span", { className: "format-chip-label" }, format.label),
614        h("span", { className: "format-chip-desc" }, format.description),
615      );
616    });
617
618    const selectedFormatId2 = () =>
619      (formatInputs.find((r) => r.checked) || {}).value || selectedFormatId;
620    const submit = () => {
621      const { startSeconds, endSeconds } = rangeSelector.getRange();
622      onExtract(formatTimestamp(startSeconds), formatTimestamp(endSeconds), selectedFormatId2());
623    };
624
625    // Preview (<audio> for audio sources, <video> otherwise) + dual-handle slider
626    // + synced mm:ss Start/End fields. The selector learns the duration from the
627    // media's loadedmetadata if fileDuration was unknown and builds its slider then.
628    rangeSelector = mountRangeSelector({
629      file: lastFile,
630      durationSeconds: fileDuration,
631      startSeconds: rangeStart,
632      endSeconds: rangeEnd,
633      maxDurationSeconds: null,
634      startLabel: STRINGS.rangeStartLabel,
635      endLabel: STRINGS.rangeEndLabel,
636      onSubmit: submit, // Enter in a field triggers an extract
637    });
638
639    const card = h(
640      "div",
641      { className: "range-card" },
642      h("h3", { className: "range-heading" }, STRINGS.rangeHeading),
643      h("p", { className: "range-source" }, `${fileName} · ${durationLabel}`),
644      rangeSelector.element,
645      h("p", { className: "range-hint" }, STRINGS.rangeHint),
646      h("p", { className: "format-group-label" }, STRINGS.formatLabel),
647      h("div", { className: "format-chip-row", role: "radiogroup", "aria-label": STRINGS.formatLabel }, ...formatChips),
648    );
649    if (fileDuration == null) {
650      card.appendChild(h("p", { className: "range-long-note" }, STRINGS.durationUnknownNote));
651    }
652    if (rangeError) card.appendChild(h("p", { className: "notes-input-error" }, rangeError));
653    card.appendChild(
654      h(
655        "div",
656        { className: "notes-result-actions" },
657        h("button", { type: "button", className: "button button-primary", onClick: submit }, STRINGS.extract),
658        h("button", { type: "button", className: "button button-ghost", onClick: reset }, STRINGS.changeFile),
659      ),
660    );
661    return card;
662  }
663
664  function renderWorking() {
665    const bar = h(
666      "div",
667      { className: "upload-progress-track", "aria-hidden": "true" },
668      h("div", {
669        className: statusPercent == null ? "upload-progress-fill is-indeterminate" : "upload-progress-fill",
670        style: statusPercent == null ? null : `width:${Math.max(2, Math.min(100, statusPercent))}%`,
671      }),
672    );
673    return h(
674      "div",
675      { className: "notes-status-area" },
676      h("span", { className: "notes-spinner", "aria-hidden": "true" }),
677      h("p", { className: "notes-status-label" }, statusLabel || STRINGS.extracting),
678      bar,
679      h("p", { className: "notes-status-hint" }, STRINGS.privacyNote),
680    );
681  }
682
683  function renderError() {
684    const actions = h(
685      "div",
686      { className: "notes-result-actions" },
687      errorState?.retryable && lastFile
688        ? h(
689            "button",
690            {
691              type: "button",
692              className: "button button-primary",
693              onClick: () => {
694                rangeError = null;
695                phase = "range";
696                render();
697              },
698            },
699            STRINGS.tryAgain,
700          )
701        : null,
702      h("button", { type: "button", className: "button button-ghost", onClick: reset }, STRINGS.startOver),
703    );
704    return h(
705      "div",
706      { className: "notes-error-area" },
707      h("p", { className: "notes-error-msg" }, errorState?.message || STRINGS.genericError),
708      actions,
709    );
710  }
711
712  function renderResult() {
713    const data = resultPayload;
714    const meta = `${data.format.label} · ${formatBytes(data.byteSize)} · ${formatTimestamp(data.durationSeconds)}`;
715
716    const player = h("audio", {
717      className: "audio-result-player",
718      controls: "controls",
719      src: data.url,
720      preload: "metadata",
721    });
722
723    const download = h(
724      "button",
725      {
726        type: "button",
727        className: "button button-primary",
728        onClick: () => triggerDownload(data.blob, data.filename, data.mimeType),
729      },
730      STRINGS.download,
731    );
732    const extractAnother = h(
733      "button",
734      {
735        type: "button",
736        className: "button button-secondary",
737        onClick: () => {
738          rangeError = null;
739          phase = "range";
740          render();
741        },
742      },
743      STRINGS.extractAnother,
744    );
745    const startOver = h(
746      "button",
747      { type: "button", className: "button button-ghost", onClick: reset },
748      STRINGS.startOver,
749    );
750
751    return h(
752      "div",
753      { className: "notes-result audio-result" },
754      h(
755        "div",
756        { className: "notes-result-head" },
757        h("h3", { className: "notes-result-title" }, STRINGS.resultHeading),
758        h("p", { className: "notes-result-source" }, `${data.filename} · ${meta}`),
759      ),
760      player,
761      h("div", { className: "notes-result-actions" }, download, extractAnother, startOver),
762    );
763  }
764
765  render();
766}
767
768// ── helpers ────────────────────────────────────────────────────────────────────
769
770function stripExtension(name) {
771  if (!name || typeof name !== "string") return "";
772  const dot = name.lastIndexOf(".");
773  return dot > 0 ? name.slice(0, dot) : name;
774}

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.