Skip to content

Commit b6bc652

Browse files
committed
web: show a progress bar during ffmpeg.wasm audio extraction
Syncing against a video/audio reference decodes it to PCM with ffmpeg.wasm first — the slow part — but the UI only showed a static "decoding audio…" line. Surface ffmpeg's existing 0–1 progress event as a themed progress bar shown during decode: indeterminate while ffmpeg.wasm loads, then a live percentage while decoding, hidden once decode completes (the VAD/align phase has no measurable progress and keeps the text status). Decode runs on the main thread, so the callback updates the DOM directly — no new worker plumbing. Non-finite/out-of-range fractions keep the bar indeterminate rather than showing a bogus percentage.
1 parent c33e10b commit b6bc652

3 files changed

Lines changed: 88 additions & 3 deletions

File tree

web/index.html

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,40 @@
6868
button:disabled { opacity: .5; cursor: not-allowed; }
6969
#status { color: var(--muted); font-size: .9rem; margin-top: .75rem; min-height: 1.4em; }
7070
#status.error { color: var(--error); white-space: pre-wrap; }
71+
.progress { display: flex; align-items: center; gap: .6rem; margin-top: .6rem; }
72+
.progress-track {
73+
flex: 1;
74+
height: 8px;
75+
border-radius: 999px;
76+
overflow: hidden;
77+
background: color-mix(in srgb, var(--fg) 12%, transparent);
78+
}
79+
.progress-fill {
80+
height: 100%;
81+
width: 0%;
82+
background: var(--accent);
83+
border-radius: 999px;
84+
transition: width .15s ease;
85+
}
86+
.progress-pct {
87+
font-variant-numeric: tabular-nums;
88+
font-size: .8rem;
89+
color: var(--muted);
90+
min-width: 3.5ch;
91+
text-align: right;
92+
}
93+
.progress.indeterminate .progress-fill {
94+
width: 40%;
95+
animation: progress-slide 1.1s ease-in-out infinite;
96+
}
97+
.progress.indeterminate .progress-pct { visibility: hidden; }
98+
@keyframes progress-slide {
99+
0% { margin-left: -40%; }
100+
100% { margin-left: 100%; }
101+
}
102+
@media (prefers-reduced-motion: reduce) {
103+
.progress.indeterminate .progress-fill { animation: none; }
104+
}
71105
#result { margin-top: 1rem; }
72106
#offset { font-variant-numeric: tabular-nums; font-weight: 600; }
73107
a#download {
@@ -125,6 +159,11 @@ <h1>ffsubsync <span class="hint">in the browser</span></h1>
125159

126160
<button id="sync-btn" disabled>Sync subtitles</button>
127161
<div id="status"></div>
162+
<div id="progress" class="progress" role="progressbar"
163+
aria-valuemin="0" aria-valuemax="100" hidden>
164+
<div class="progress-track"><div class="progress-fill" id="progress-fill"></div></div>
165+
<span class="progress-pct" id="progress-pct"></span>
166+
</div>
128167

129168
<div id="result" class="card" hidden>
130169
<div><span id="offset"></span></div>

web/src/ffmpeg_decode.mjs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ async function getFfmpeg(cfg, status) {
2929
return _ffmpegPromise;
3030
}
3131

32-
export async function decodeAudioToPcm(cfg, file, { status, stream } = {}) {
32+
export async function decodeAudioToPcm(cfg, file, { status, stream, onProgress } = {}) {
3333
const ffmpeg = await getFfmpeg(cfg, status);
3434
const frameRate = cfg.frameRate || 16000;
3535
const mountDir = "/mnt";
@@ -38,6 +38,10 @@ export async function decodeAudioToPcm(cfg, file, { status, stream } = {}) {
3838
const mounted = `${mountDir}/${file.name}`;
3939

4040
status && status("decoding audio (this can take a bit on long videos)…");
41+
// ffmpeg reports decode progress as a 0–1 fraction. The instance is memoized in
42+
// getFfmpeg, so the listener MUST be removed after this decode.
43+
const onP = onProgress ? ({ progress }) => onProgress(progress) : null;
44+
if (onP) ffmpeg.on("progress", onP);
4145
try {
4246
try {
4347
await ffmpeg.createDir(mountDir);
@@ -62,6 +66,7 @@ export async function decodeAudioToPcm(cfg, file, { status, stream } = {}) {
6266
const data = await ffmpeg.readFile(outPath); // Uint8Array
6367
return data;
6468
} finally {
69+
if (onP) ffmpeg.off("progress", onP);
6570
try {
6671
await ffmpeg.deleteFile(outPath);
6772
} catch {

web/src/main.js

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ const els = {
1010
gss: document.getElementById("gss"),
1111
syncBtn: document.getElementById("sync-btn"),
1212
status: document.getElementById("status"),
13+
progress: document.getElementById("progress"),
14+
progressFill: document.getElementById("progress-fill"),
15+
progressPct: document.getElementById("progress-pct"),
1316
result: document.getElementById("result"),
1417
offset: document.getElementById("offset"),
1518
download: document.getElementById("download"),
@@ -121,6 +124,7 @@ async function onSync() {
121124
// lazily via WORKERFS — never fully read into memory — then only the decoded
122125
// PCM is transferred to the Pyodide worker for VAD + alignment.
123126
let pcm;
127+
showProgress();
124128
try {
125129
const { decodeAudioToPcm } = await import(
126130
withV(new URL("./ffmpeg_decode.mjs", import.meta.url).href)
@@ -131,13 +135,20 @@ async function onSync() {
131135
module: withV(new URL(ffmpegConfig.module, document.baseURI).href),
132136
util: withV(new URL(ffmpegConfig.util, document.baseURI).href),
133137
};
134-
pcm = await decodeAudioToPcm(ff, refFile, { status: setStatus });
138+
pcm = await decodeAudioToPcm(ff, refFile, {
139+
status: setStatus,
140+
onProgress: setProgress,
141+
});
135142
} catch (e) {
136143
console.error(e);
144+
hideProgress();
137145
setStatus("audio decode failed: " + (e && e.message || e), true);
138146
setBusy(false);
139147
return;
140148
}
149+
// Decode done; the VAD/align phase has no measurable progress, so drop back
150+
// to the text status.
151+
hideProgress();
141152
worker.postMessage(
142153
{
143154
type: "syncAudioPcm",
@@ -198,8 +209,38 @@ function setStatus(text, isError = false) {
198209
els.status.classList.toggle("error", isError);
199210
}
200211

212+
// Show the decode progress bar, starting indeterminate (ffmpeg.wasm loads before
213+
// any progress events arrive).
214+
function showProgress() {
215+
els.progress.hidden = false;
216+
els.progress.classList.add("indeterminate");
217+
els.progressFill.style.width = "0%";
218+
els.progressPct.textContent = "";
219+
els.progress.removeAttribute("aria-valuenow");
220+
}
221+
222+
// Drive the bar from ffmpeg's 0–1 progress. A non-finite or out-of-range value
223+
// (duration unknown) keeps the bar indeterminate rather than showing a bogus %.
224+
function setProgress(fraction) {
225+
if (Number.isFinite(fraction) && fraction > 0 && fraction <= 1) {
226+
const pct = Math.round(fraction * 100);
227+
els.progress.classList.remove("indeterminate");
228+
els.progressFill.style.width = pct + "%";
229+
els.progressPct.textContent = pct + "%";
230+
els.progress.setAttribute("aria-valuenow", String(pct));
231+
}
232+
}
233+
234+
function hideProgress() {
235+
els.progress.hidden = true;
236+
els.progress.classList.remove("indeterminate");
237+
}
238+
201239
function setBusy(busy) {
202240
els.syncBtn.disabled = busy;
203241
els.syncBtn.textContent = busy ? "Syncing…" : "Sync subtitles";
204-
if (!busy) refreshButton();
242+
if (!busy) {
243+
hideProgress();
244+
refreshButton();
245+
}
205246
}

0 commit comments

Comments
 (0)