Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

commit 0e57df6e47a5270244ae1dcbb8c82167c4122296
parent 485ce9904d31b7f85ba0373e0bdd628b3b3f7a3b
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Sat, 13 Jun 2026 19:48:20 -0400

parakeet ETA and task timer

Diffstat:
Mcommon/jobs/progressParsers.ts | 11++++++++---
Meditor/CHANGELOG.md | 2++
Meditor/app/jobs/active/buildActiveJobs.ts | 1+
Meditor/app/jobs/components/RunningJobsList.tsx | 37++++++++++++++++++++++++++++++++-----
Meditor/e2e/fixtures/bin/fake-parakeet-stitch.mjs | 6+++++-
Meditor/e2e/jobs-batch-tasks-drain.spec.ts | 13+++++++++++++
Mscripts/parakeet-stitch.mjs | 31+++++++++++++++++++++++++++++--
7 files changed, 90 insertions(+), 11 deletions(-)

diff --git a/common/jobs/progressParsers.ts b/common/jobs/progressParsers.ts @@ -203,8 +203,10 @@ export function createChoughProgressParser(): { // parakeet-stitch.mjs (the overlapping-segment wrapper) prints one stderr line // per window, e.g. -// parakeet-stitch: segment 3/12 @360s — transcribing -// Progress = completed segment index / total segments. +// parakeet-stitch: segment 3/12 @360s — transcribing (avg 41.2s/seg, ETA 6:10) +// Progress = completed segment index / total segments. Once a segment has +// finished the line carries a per-video ETA (avg measured time per segment × +// remaining segments), which we surface verbatim in the detail. export function createParakeetProgressParser(): { feed: (line: string) => ProgressUpdate | null; } { @@ -215,7 +217,10 @@ export function createParakeetProgressParser(): { const i = Number.parseInt(m[1], 10); const n = Number.parseInt(m[2], 10); if (!Number.isFinite(n) || n <= 0) return null; - return { fraction: clamp01(i / n), detail: `segment ${i}/${n}` }; + let detail = `segment ${i}/${n}`; + const eta = line.match(/ETA\s+([\d:]+)/)?.[1]; + if (eta) detail += ` · ETA ${eta}`; + return { fraction: clamp01(i / n), detail }; }, }; } diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md @@ -1,6 +1,8 @@ # Changelog ## [Unreleased] +- **Parakeet transcriptions show a per-video ETA.** While a video is being transcribed with the parakeet app, its per-task progress bar on `/jobs/active` now shows an estimate of how long that single video has left (e.g. `segment 3/12 · ETA 6:10`), alongside the existing segment count and percent. The wrapper (`scripts/parakeet-stitch.mjs`) measures each segment's real transcription wall-time and projects the remaining time as **average time per completed segment × remaining segments**, emitting it on its progress lines; the parser surfaces that ETA in the task detail. It appears from the second segment onward (the first segment has no average to project from yet). This is distinct from the batch-level "~4:30 left" estimate across all videos. +- **Each active sub-operation shows how long it has been running.** Every per-task bar on `/jobs/active` (each in-flight download or transcription) now leads with a live `m:ss` timer of its own elapsed wall-time, ticking once a second — e.g. `1:42 · 25% · segment 3/12 · ETA 6:10`. The start time comes from the job registry's per-task `startedAt`, so the timer survives page reloads and reflects the real runtime, not time-since-open. - **The single-video download pipeline reconstructs the source URL when a video has no `metadata.info.json`.** Running the per-video **Download** / **Audio + Whisper** action resolves the video's URL by reading its `metadata.info.json` (`webpage_url`), then by matching the canonical id against the channel's stored `playlist`. When a video directory exists but has neither — e.g. a manually-placed video, or a channel whose playlist was never stored — the pipeline previously gave up with "Could not determine the video URL". It now re-creates the URL from the video's canonical id (its directory name) and the channel's platform (from `config.platform`, falling back to detecting it from `config.url`), so the download proceeds. Reconstruction is a last resort — the exact URL from metadata or the playlist always wins when present — and only fires when the platform is known (no blind guess); note a reconstructed Odysee URL drops the channel-name prefix, so it may not resolve. - **New transcription app: parakeet.cpp with overlapping-segment stitching.** A third **App** option (alongside whisper.cpp and chough) in **Settings → Transcription** transcribes via `parakeet-cli`, working around its two long-audio limitations: it only accepts a single WAV, and a >4GB PCM stream silently yields an empty transcript (dr_wav's data-chunk size is 32-bit). A standalone wrapper (`scripts/parakeet-stitch.mjs`) — usable both as the app's binary and directly on the CLI — slices the source into **overlapping** 16kHz-mono windows (the format parakeet-cli resamples to internally anyway), runs `parakeet-cli --json --timestamps` on each, then stitches the per-window word timestamps back into one transcript. The overlap guarantees a word clipped at one window's boundary is captured whole by the neighbour; a cut point in the middle of each overlap region decides which window owns each word, so nothing is dropped or duplicated across seams. Output is chough-native JSON (seconds-based `chunk_data`), so it flows through the existing format-aware normalize/index path with no downstream changes and is tagged `chough-json` in `transcript.cues.json`. The settings fields are **Model** (the `.gguf` path) and **Chunk size** (per-window length, default 480s); the underlying `parakeet-cli` binary and default model resolve from `PARAKEET_CLI` / `PARAKEET_MODEL` (overlap is tunable via `PARAKEET_OVERLAP_SEC`). Run on the CLI with `scripts/parakeet-stitch.mjs --model <gguf> <audio> [out.json]` (prints JSON to stdout when no output path is given). Per-window progress drives the job progress bars. - **Batch jobs estimate the time remaining.** A running batch's overall progress bar on `/jobs/active` now shows an estimate of how long is left (e.g. `~4:30 left`), alongside the existing `Transcripts: 12 / 50` count. The estimate is **remaining tasks × the measured average time per task**: each download/transcription's real wall-clock duration is folded into per-job running totals as it finishes, and that average is converted to a wall-clock figure using the parallelism observed so far (so a 4-way parallel transcribe batch isn't estimated as if it ran one at a time). It reads as **estimating…** until the first sub-operation completes (no average yet), and disappears once no work remains. Jobs without per-task tracking (e.g. storing a playlist) show no estimate. diff --git a/editor/app/jobs/active/buildActiveJobs.ts b/editor/app/jobs/active/buildActiveJobs.ts @@ -109,6 +109,7 @@ export async function buildActiveJobsPayload(): Promise<ActiveJobsPayload> { kind: t.kind, fraction: t.fraction, detail: t.detail, + startedAt: t.startedAt, })), draining: j.draining === true, drainable: diff --git a/editor/app/jobs/components/RunningJobsList.tsx b/editor/app/jobs/components/RunningJobsList.tsx @@ -1,7 +1,7 @@ "use client"; import Link from "next/link"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { formatDuration } from "yt-dlp-transcript-common/lib/format"; import { JobLogTail } from "../[id]/components/JobLogTail"; import { DrainJobButton } from "./DrainJobButton"; @@ -13,6 +13,8 @@ export type RunningJobsTask = { kind: "download" | "transcribe"; fraction?: number; detail?: string; + // Epoch ms when this sub-operation started, for the live "running for" timer. + startedAt: number; }; export type RunningJobsListItem = { @@ -151,6 +153,19 @@ function JobRow({ ); } +// Live wall-clock that re-renders once a second on the client. Starts null so +// the SSR and first client render agree (no hydration mismatch from Date.now()); +// the elapsed timers fill in right after mount. +function useNow(): number | null { + const [now, setNow] = useState<number | null>(null); + useEffect(() => { + setNow(Date.now()); + const id = setInterval(() => setNow(Date.now()), 1000); + return () => clearInterval(id); + }, []); + return now; +} + function TaskProgressBar({ task }: { task: RunningJobsTask }) { const verb = task.kind === "download" ? "Downloading" : "Transcribing"; const hasFraction = typeof task.fraction === "number"; @@ -159,6 +174,21 @@ function TaskProgressBar({ task }: { task: RunningJobsTask }) { task.kind === "download" ? "bg-emerald-300 dark:bg-emerald-700" : "bg-emerald-500"; + // How long this task has been running. Null until mounted (see useNow); + // formatDuration returns "" for 0, so the just-started case shows "0:00". + const now = useNow(); + const elapsed = + now === null + ? null + : formatDuration(Math.max(0, Math.round((now - task.startedAt) / 1000))) || + "0:00"; + const meta = [ + elapsed, + hasFraction ? `${pct}%` : null, + task.detail, + ] + .filter(Boolean) + .join(" · "); return ( <div className="flex flex-col gap-0.5"> <div className="flex items-baseline justify-between gap-2 text-xs"> @@ -166,10 +196,7 @@ function TaskProgressBar({ task }: { task: RunningJobsTask }) { <span className="text-zinc-500">{verb} </span> <span className="font-mono">{task.label}</span> </span> - <span className="font-mono text-zinc-500 shrink-0"> - {hasFraction ? `${pct}%` : ""} - {task.detail ? ` ${task.detail}` : ""} - </span> + <span className="font-mono text-zinc-500 shrink-0">{meta}</span> </div> <div role="progressbar" diff --git a/editor/e2e/fixtures/bin/fake-parakeet-stitch.mjs b/editor/e2e/fixtures/bin/fake-parakeet-stitch.mjs @@ -22,9 +22,13 @@ if (!out) { } // Two synthetic windows so the stitched output and progress lines are exercised. +// Segment 1 has no ETA (no average yet); segment 2 carries the per-video ETA +// (avg time per segment × remaining segments), matching the real wrapper. process.stderr.write(`parakeet-stitch: ${audio}: 10s -> 2 window(s)\n`); process.stderr.write(`parakeet-stitch: segment 1/2 @0s — transcribing\n`); -process.stderr.write(`parakeet-stitch: segment 2/2 @5s — transcribing\n`); +process.stderr.write( + `parakeet-stitch: segment 2/2 @5s — transcribing (avg 1s/seg, ETA 0:01)\n`, +); const doc = { duration_seconds: 10, diff --git a/editor/e2e/jobs-batch-tasks-drain.spec.ts b/editor/e2e/jobs-batch-tasks-drain.spec.ts @@ -75,6 +75,19 @@ test("active jobs shows a per-operation progress bar that advances", async ({ { timeout: 20_000 }, ) .toBeGreaterThan(0); + + // The task leads with a live "running for" timer (m:ss) that ticks up. Read + // the leading time token off the task row and assert it advances. + const taskRow = taskBar.locator( + "xpath=ancestor::div[contains(@class,'gap-0.5')][1]", + ); + const elapsedSeconds = async () => { + const m = (await taskRow.innerText()).match(/(\d+):(\d{2})/); + return m ? Number(m[1]) * 60 + Number(m[2]) : -1; + }; + const first = await elapsedSeconds(); + expect(first).toBeGreaterThanOrEqual(0); + await expect.poll(elapsedSeconds, { timeout: 10_000 }).toBeGreaterThan(first); }); test("active jobs shows an estimated time remaining once a task completes", async ({ diff --git a/scripts/parakeet-stitch.mjs b/scripts/parakeet-stitch.mjs @@ -123,6 +123,27 @@ function numEnv(v, dflt) { const round3 = (n) => Math.round(n * 1000) / 1000; +// Seconds -> m:ss (or h:mm:ss). Used for the per-video ETA in progress lines. +function formatClock(totalSeconds) { + const s = Math.max(0, Math.floor(totalSeconds)); + const hh = Math.floor(s / 3600); + const mm = Math.floor((s % 3600) / 60); + const ss = s % 60; + const pad = (n) => String(n).padStart(2, "0"); + return hh > 0 ? `${hh}:${pad(mm)}:${pad(ss)}` : `${mm}:${pad(ss)}`; +} + +// Per-video ETA suffix for a segment progress line. `times` holds the measured +// transcription wall-time (ms) of each completed segment; `remaining` is the +// count of segments still to transcribe (the current one included). Returns "" +// until at least one segment has completed (no average to project from yet). +function etaSuffix(times, remaining) { + if (times.length === 0 || remaining <= 0) return ""; + const avgMs = times.reduce((a, b) => a + b, 0) / times.length; + const etaSec = (avgMs * remaining) / 1000; + return ` (avg ${round3(avgMs / 1000)}s/seg, ETA ${formatClock(etaSec)})`; +} + // Locate ffprobe: explicit flag/env wins, else swap a trailing "ffmpeg" in the // ffmpeg path for "ffprobe" (handles absolute paths), else fall back to PATH. function resolveFfprobe(opts) { @@ -224,6 +245,8 @@ async function main() { const tmp = await mkdtemp(path.join(os.tmpdir(), "parakeet-stitch-")); // Per-window absolute-timestamped word lists. const windowWords = []; + // Measured transcription wall-time (ms) of each completed segment, for ETA. + const segTimes = []; try { for (let i = 0; i < N; i += 1) { const start = offsets[i]; @@ -231,10 +254,14 @@ async function main() { // Last window runs to EOF (no -t) so we never miss a trailing fragment. const len = isLast ? undefined : opts.segment; const wav = path.join(tmp, `win-${String(i).padStart(4, "0")}.wav`); - progress(`segment ${i + 1}/${N} @${round3(start)}s — slicing`); + // remaining = this segment + the ones not yet started. + const eta = etaSuffix(segTimes, N - i); + progress(`segment ${i + 1}/${N} @${round3(start)}s — slicing${eta}`); await sliceWav(opts.ffmpeg, opts.audio, start, len, wav); - progress(`segment ${i + 1}/${N} @${round3(start)}s — transcribing`); + progress(`segment ${i + 1}/${N} @${round3(start)}s — transcribing${eta}`); + const t0 = Date.now(); const doc = await transcribeWindow(opts, wav); + segTimes.push(Date.now() - t0); const words = Array.isArray(doc.words) ? doc.words : []; // Shift window-relative times into absolute timeline. windowWords.push(