Archilyzer · Source

archilyzer

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

commit 1676f41fd7046a4671b4ec532483208f2ad5da14
parent 168885368d627353545ee7017a766f46d9a96ac3
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Tue, 18 Aug 2026 10:06:26 -0400

Give the phrase index a console

/browse/find. Type a word or a phrase, get every occurrence across the
300-episode corpus with the episode, the second, a line of context and an
instant audition.

A STATIC segment under app/browse/, so it wins over app/browse/[song]/ -- the
same trap /browse/decisions documents, and worth an assertion of its own
because when it regresses the page still renders perfectly in isolation and
only the URL stops working.

Server-rendered over `?q=`, via a plain <form method="get">. Not
live-as-you-type: nothing else here fetches a list from the client, and a URL
that can be pasted into a note and still mean the same thing in six weeks is
worth more than saving a keypress. The filters are links for the same reason,
and the box carries them in hidden fields so submitting it does not silently
drop them. No `q` means no index -- a bare readdir draws the corpus line,
because building 105 MB of blob to show an empty search box would be the whole
cost of the feature paid for nothing.

One island for all 50 rows, not one per row: 50 rows of HTML plus 50 sets of
serialised props is the page sent twice, which is the doubling ProvenancePanel
warns about. It fetches nothing for the list.

The preview reuses /api/clip/[key]/{audio,video} verbatim -- `<video>@<start>`
is a media ADDRESS, never the hit's identity, which stays the word index. Two
properties fall out of that: `from` is LEAD_PAD before the word so the late
parakeet timestamp cannot clip the onset, and the audio route's fixed gain
reference (candStart ±1s, parsed from after the @) lands on the hit itself, so
the level does not jump between the tight and the wide audition. The playhead
reads the window the route ECHOED rather than the one requested, because a hit
at 0.28s asks for -0.07 and is clamped.

THE PICTURE NEVER LOADS ON PAGE LOAD. That route spawns an ffmpeg per window,
so 50 mounted players would be 50 processes on a navigation; it mounts for the
focused hit, after `v`, and only when media/ holds the episode.

Colour keeps meaning what it means: conf is `meter` because it is a READING and
a low one is often the interesting token, and `flagged source` is `dirty` and
says source rather than moment, because the data is per-episode.

/api/phrases is the mechanism surface for the suite and for scripts, sharing
parsePhraseQuery with the page so the two cannot answer differently. The nav is
at nine items and says so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Diffstat:
Aumtool/app/api/phrases/route.ts | 31+++++++++++++++++++++++++++++++
Aumtool/app/browse/find/page.tsx | 238+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mumtool/components/AppNav.tsx | 6++++++
Aumtool/components/PhraseConsole.tsx | 317+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
4 files changed, 592 insertions(+), 0 deletions(-)

diff --git a/umtool/app/api/phrases/route.ts b/umtool/app/api/phrases/route.ts @@ -0,0 +1,31 @@ +import { isEpisode, parsePhraseQuery, searchPhrases, MAX_TERMS } from "@/lib/phrases"; + +export const dynamic = "force-dynamic"; + +// The MECHANISM surface. /browse/find calls searchPhrases() directly and never +// touches this route -- a page that fetched its own API would serialise a +// render behind a round trip to itself for nothing. +// +// This exists so the e2e suite and a script can ask the same question the page +// asks, and get the same answer from the same parser. If the two ever drifted, +// the suite would be testing a second implementation instead of the one that +// ships; parsePhraseQuery is shared precisely so they cannot. + +export async function GET(request: Request) { + const url = new URL(request.url); + const { query, errors } = parsePhraseQuery(url.searchParams); + + if (!query.q.trim()) { + return Response.json({ error: "q is required" }, { status: 400 }); + } + if (errors.length) { + return Response.json({ error: errors[0].message, errors, maxTerms: MAX_TERMS }, { status: 400 }); + } + if (query.video && !(await isEpisode(query.video))) { + return Response.json({ error: `no episode called ${query.video}` }, { status: 404 }); + } + + return Response.json(await searchPhrases(query), { + headers: { "cache-control": "no-store" }, + }); +} diff --git a/umtool/app/browse/find/page.tsx b/umtool/app/browse/find/page.tsx @@ -0,0 +1,238 @@ +import Link from "next/link"; +import BrowseHeader from "@/components/BrowseHeader"; +import PhraseConsole from "@/components/PhraseConsole"; +import { + episodeIds, + isEpisode, + parsePhraseQuery, + searchPhrases, + MAX_TERMS, + PAGE_SIZE, + type PhraseResult, +} from "@/lib/phrases"; + +export const dynamic = "force-dynamic"; + +// THE PHRASE CONSOLE: every occurrence of a word or phrase across 300 episodes +// of word-level ASR, with the timestamp that addresses both the sound and the +// picture. +// +// A STATIC segment under app/browse/, so it wins over app/browse/[song]/ -- +// which would otherwise catch /browse/find, fail readSong() and 404. The same +// trap /browse/decisions documents, and the e2e suite asserts it here too, +// because when this regresses the page still renders perfectly in isolation +// and only the URL stops working. +// +// SEARCH IS `?q=` AND SERVER-RENDERED, over a plain <form method="get">. +// Not live-as-you-type: nothing else in this codebase fetches a list from the +// client, and a URL that can be pasted into a note and still mean the same +// thing six weeks later is worth more than saving a keypress. The filters are +// links for the same reason. + +type Params = Record<string, string | string[] | undefined>; + +const toSearchParams = (p: Params): URLSearchParams => { + const sp = new URLSearchParams(); + for (const [k, v] of Object.entries(p)) { + const one = Array.isArray(v) ? v[0] : v; + if (one != null) sp.set(k, one); + } + return sp; +}; + +export default async function FindPage({ searchParams }: { searchParams: Promise<Params> }) { + const raw = toSearchParams(await searchParams); + const { query, errors } = parsePhraseQuery(raw); + + // NO `q` MEANS NO INDEX. Building 105 MB of blob to draw an empty search box + // would be the entire cost of the feature paid for nothing -- the same + // restraint listSongs() shows by never probing. A bare readdir is enough to + // say how big the corpus is. + const episodes = await episodeIds(); + const unknownVideo = query.video !== null && !(await isEpisode(query.video)); + + let result: PhraseResult | null = null; + if (query.terms.length && !unknownVideo) result = await searchPhrases(query); + + const notes = [ + ...errors.map((e) => e.message), + ...(unknownVideo ? [`no episode called ${query.video}`] : []), + ...(result?.notes ?? []), + ]; + + // Every filter is a link that changes searchParams. `page` resets on any + // change but its own: page 7 of a different filter is a different list. + const href = (next: Partial<Record<string, string>>) => { + const p = new URLSearchParams(raw); + for (const [k, v] of Object.entries(next)) { + if (v) p.set(k, v); + else p.delete(k); + } + if (!("page" in next)) p.delete("page"); + const s = p.toString(); + return `/browse/find${s ? `?${s}` : ""}`; + }; + + const pages = result ? Math.max(1, Math.ceil(result.total / query.per)) : 1; + const shown = result?.hits.length ?? 0; + const filler = result ? result.notes.some((n) => n.includes("ACOUSTICALLY")) : false; + + return ( + <div className="flex h-full flex-col"> + <BrowseHeader + active="find" + crumbs={[{ href: "/browse", label: "songs" }, { label: "find" }]} + note={`${episodes.length} episodes${result ? ` · ${result.corpus.words.toLocaleString()} words · ${result.ms}ms` : ""}`} + /> + + <main className="deck-main flex-1 p-4"> + <form method="get" action="/browse/find" className="mb-3 flex flex-wrap items-center gap-2"> + <input + id="phrase-q" + name="q" + defaultValue={query.q} + autoFocus + placeholder="a word or a phrase, as it was said" + aria-label="phrase" + className="w-[26rem] max-w-full rounded border border-[var(--color-line)] bg-[var(--color-panel)] px-2 py-1 font-mono text-[13px] text-[var(--color-text)] outline-none focus:border-[var(--color-sel)]" + /> + {/* Carried so submitting the box does not silently drop the filters + already applied. A GET form sends only its own fields. */} + {(["video", "conf", "clean", "dupes", "order", "per"] as const).map((k) => + raw.get(k) ? <input key={k} type="hidden" name={k} value={raw.get(k) as string} /> : null, + )} + <button + type="submit" + className="rounded border border-[var(--color-sel)] px-2.5 py-1 font-mono text-[12px] text-[var(--color-sel)]" + > + find + </button> + <span className="micro"> + strictly adjacent · up to {MAX_TERMS} terms · <kbd>/</kbd> to focus + </span> + </form> + + {notes.length > 0 && ( + <div className="mb-3 space-y-1.5 rounded border border-[var(--color-line)] bg-[var(--color-panel)] px-3 py-2"> + {notes.map((n, i) => ( + <p key={i} className="text-[12px] text-[var(--color-dim)]" data-note> + {n} + </p> + ))} + {/* Trap 1, said out loud and with somewhere to go. Without this the + first search anyone runs returns two rows and the console reads + as broken. */} + {filler && ( + <p className="text-[12px]"> + <Link href="/sort" className="text-[var(--color-sel)] hover:underline"> + The fillers are in /sort + </Link> + <span className="text-[var(--color-dim)]"> + {" "} + — mined from the audio, not from the transcript. + </span> + </p> + )} + </div> + )} + + {result && ( + <> + <div className="mb-3 flex flex-wrap items-center gap-1.5"> + <Chip href={href({ dupes: query.dupes ? "0" : undefined })} on={query.dupes} + label={`fold chunk twins${result.collapsed ? ` ${result.collapsed}` : ""}`} /> + <Chip href={href({ clean: query.clean ? undefined : "1" })} on={query.clean} + label="hide flagged sources" /> + + <span className="micro ml-2">conf</span> + <Chip href={href({ conf: undefined })} on={query.minConf === null} label="any" /> + {[0.4, 0.9].map((c) => ( + <Chip key={c} href={href({ conf: String(c) })} on={query.minConf === c} label={`≥ ${c}`} /> + ))} + + <span className="micro ml-2">order</span> + <Chip href={href({ order: undefined })} on={query.order === "time"} label="corpus" /> + <Chip href={href({ order: "conf" })} on={query.order === "conf"} label="least certain first" /> + + {query.video && ( + <Chip href={href({ video: undefined })} on={false} label={`${query.video} ✕`} /> + )} + </div> + + <section + data-find + data-total={result.total} + data-shown={shown} + data-collapsed={result.collapsed} + data-suppressed={result.suppressed} + data-videos={result.videos} + data-corpus-built={result.corpus.videos} + data-page={query.page} + > + <p className="mb-2 text-[12px] text-[var(--color-dim)]"> + <span className="num text-[var(--color-text)]">{result.total.toLocaleString()}</span>{" "} + {result.total === 1 ? "hit" : "hits"} across{" "} + <span className="num">{result.videos}</span>{" "} + {result.videos === 1 ? "episode" : "episodes"} · showing{" "} + <span className="num">{shown}</span> · page{" "} + <span className="num">{query.page}</span> of <span className="num">{pages}</span> + </p> + + <PhraseConsole hits={result.hits} /> + + {result.total === 0 && ( + <p className="text-[12px] text-[var(--color-dim)]"> + Nothing said that, strictly adjacent, anywhere in the corpus. + </p> + )} + + {pages > 1 && ( + <nav className="mt-3 flex items-center gap-2" aria-label="pages"> + {query.page > 1 && ( + <Link href={href({ page: String(query.page - 1) })} + className="rounded border border-[var(--color-line)] px-2 py-0.5 font-mono text-[11px] text-[var(--color-sel)]"> + ← previous + </Link> + )} + {query.page < pages && ( + <Link href={href({ page: String(query.page + 1) })} data-next + className="rounded border border-[var(--color-line)] px-2 py-0.5 font-mono text-[11px] text-[var(--color-sel)]"> + next → + </Link> + )} + <span className="micro">{PAGE_SIZE} to a page</span> + </nav> + )} + </section> + </> + )} + + {!result && ( + <section data-find data-total="0" data-shown="0" data-collapsed="0" data-corpus-built="0"> + <p className="text-[12px] text-[var(--color-dim)]"> + {episodes.length} episodes of word-level ASR, unindexed until something is asked of + them. Type a phrase: every occurrence comes back with the second it was said at, and + that second addresses the sound and the picture alike. + </p> + </section> + )} + </main> + </div> + ); +} + +function Chip({ href, on, label }: { href: string; on: boolean; label: string }) { + return ( + <Link + href={href} + aria-current={on ? "true" : undefined} + className={`rounded border px-1.5 py-0.5 font-mono text-[11px] ${ + on + ? "border-[var(--color-sel)] text-[var(--color-sel)]" + : "border-[var(--color-line)] text-[var(--color-dim)] hover:text-[var(--color-text)]" + }`} + > + {label} + </Link> + ); +} diff --git a/umtool/components/AppNav.tsx b/umtool/components/AppNav.tsx @@ -17,7 +17,13 @@ export default function AppNav({ active }: { active: string }) { { href: "/browse/decisions", label: "decisions" }, // Where the palette comes from, across every song at once. { href: "/browse/sources", label: "sources" }, + // Every occurrence of a word across the corpus. It sits with browse because + // what it retrieves is raw material for a build, not a pile to judge. + { href: "/browse/find", label: "find" }, ]; + // NINE, and that is the limit. A tenth wraps the header on a laptop, and a + // nav that wraps stops reading as one row of places and starts reading as a + // list. The next tool goes UNDER one of these, not beside them. return ( <nav className="flex gap-1"> {items.map((it) => { diff --git a/umtool/components/PhraseConsole.tsx b/umtool/components/PhraseConsole.tsx @@ -0,0 +1,317 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { previewWindow, type PhraseHit } from "@/lib/phrase-types"; + +// The rows, and ONE island for all of them. +// +// The obvious shape is a component per row. It is also the doubling +// ProvenancePanel warns about: 50 rows of HTML plus 50 sets of serialised +// island props is the page sent twice, and every row carries a title, two +// context lines and an archive URL. So this takes the array once. +// +// It FETCHES NOTHING for the list. Ordering, filtering, paging and totals are +// server decisions, arrived at over a URL that is shareable and back-button +// correct. The only network this component does is the audio and picture for +// the one hit being auditioned. + +const fmtTime = (t: number) => { + const s = Math.floor(t); + return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`; +}; + +export default function PhraseConsole({ hits }: { hits: PhraseHit[] }) { + const [cursor, setCursor] = useState(0); + const [playing, setPlaying] = useState<string | null>(null); + const [playhead, setPlayhead] = useState<number | null>(null); + // The id whose picture is mounted. Never set on load -- see below. + const [picture, setPicture] = useState<string | null>(null); + const [error, setError] = useState<string | null>(null); + + const audio = useRef<HTMLAudioElement | null>(null); + const objectUrl = useRef<string | null>(null); + const origin = useRef(0); + const raf = useRef<number | null>(null); + const rows = useRef<(HTMLLIElement | null)[]>([]); + + const stop = useCallback(() => { + audio.current?.pause(); + audio.current = null; + if (objectUrl.current) URL.revokeObjectURL(objectUrl.current); + objectUrl.current = null; + if (raf.current !== null) cancelAnimationFrame(raf.current); + raf.current = null; + setPlaying(null); + setPlayhead(null); + }, []); + + // THE PREVIEW, and it reuses the clip routes verbatim rather than growing a + // media route of its own. + // + // const key = `${h.video}@${h.start.toFixed(2)}` + // + // That key is a media ADDRESS, not the hit's identity -- the identity is + // `<video>#<wordIndex>`, because about one pair per 100k words shares a + // start. Two properties come free from addressing it this way: + // + // * `from` is LEAD_PAD before the word, so the late parakeet timestamp + // cannot clip the first phoneme. + // * the audio route takes its gain from a FIXED reference (candStart ±1s, + // parsed from after the @), and that reference lands on the hit itself -- + // so the level does not jump between the tight and the wide audition. + const play = useCallback( + async (h: PhraseHit, wide: boolean) => { + stop(); + setError(null); + const { from, to } = previewWindow(h, wide); + const key = `${h.video}@${h.start.toFixed(2)}`; + try { + const res = await fetch( + `/api/clip/${encodeURIComponent(key)}/audio?from=${from}&to=${to}`, + { cache: "no-store" }, + ); + if (!res.ok) { + setError(res.status === 404 ? "no audio for that episode" : "could not read that window"); + return; + } + // The playhead's origin is the window the route ECHOED, not the one + // asked for. A hit at 0.28s asks for -0.07 and is clamped, and a + // playhead measured from the request would run 70ms ahead of the sound. + const hdr = res.headers.get("x-window")?.split(",").map(Number); + origin.current = hdr && hdr.length === 2 && Number.isFinite(hdr[0]) ? hdr[0] : from; + const url = URL.createObjectURL(await res.blob()); + objectUrl.current = url; + const el = new Audio(url); + audio.current = el; + const tick = () => { + if (!audio.current) return; + setPlayhead(origin.current + audio.current.currentTime); + raf.current = requestAnimationFrame(tick); + }; + el.addEventListener("ended", stop); + setPlaying(h.id); + await el.play(); + tick(); + } catch { + setError("could not play that window"); + } + }, + [stop], + ); + + useEffect(() => stop, [stop]); + + // A new page of results is a new list. Anything held about the old one -- + // which row was focused, what was mid-play -- is about hits that are gone. + useEffect(() => { + setCursor(0); + setPicture(null); + stop(); + }, [hits, stop]); + + const move = useCallback( + (d: number) => { + setCursor((c) => { + const next = Math.max(0, Math.min(hits.length - 1, c + d)); + rows.current[next]?.scrollIntoView({ block: "nearest" }); + return next; + }); + }, + [hits.length], + ); + + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + // Typing in the search box or a note is typing, not driving. WordSelector + // stops propagation at its own inputs for the same reason; this is the + // other half of that contract, for the keys that live on window. + const t = e.target as HTMLElement | null; + if (t && (t.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName))) return; + const h = hits[cursor]; + if (e.key === "/") { + e.preventDefault(); + const box = document.getElementById("phrase-q") as HTMLInputElement | null; + box?.focus(); + box?.select(); + return; + } + if (!h) return; + if (e.key === "j" || e.key === "ArrowDown") { + e.preventDefault(); + move(1); + } else if (e.key === "k" || e.key === "ArrowUp") { + e.preventDefault(); + move(-1); + } else if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + void play(h, false); + } else if (e.key === "x") { + e.preventDefault(); + void play(h, true); + } else if (e.key === "v") { + e.preventDefault(); + if (h.hasVideo) setPicture((p) => (p === h.id ? null : h.id)); + } else if (e.key === "Escape") { + e.preventDefault(); + stop(); + } + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [hits, cursor, move, play, stop]); + + if (!hits.length) return null; + + return ( + <> + {error && ( + <p className="mb-2 text-[12px] text-[var(--color-bad)]" role="status"> + {error} + </p> + )} + <ul className="space-y-1" data-console> + {hits.map((h, i) => { + const on = i === cursor; + const wide = previewWindow(h, true); + return ( + <li + key={h.id} + ref={(el) => { + rows.current[i] = el; + }} + data-hit={h.id} + data-video={h.video} + data-start={h.start} + data-from={h.from} + data-to={h.to} + data-conf={h.confMin ?? ""} + data-suspect={h.suspect ? "1" : "0"} + data-dupes={h.dupes} + onClick={() => setCursor(i)} + className={`rounded border px-3 py-2 ${ + on + ? "border-[var(--color-sel)] bg-[var(--color-panel-2)]" + : "border-[var(--color-line)] bg-[var(--color-panel)]" + }`} + > + <div className="flex flex-wrap items-baseline gap-2"> + <button + type="button" + onClick={() => { + setCursor(i); + void play(h, false); + }} + aria-label={`play ${h.text}`} + className="num rounded border border-[var(--color-line)] px-1.5 py-0.5 font-mono text-[11px] text-[var(--color-sel)] hover:border-[var(--color-sel)]" + > + {fmtTime(h.start)} + </button> + <button + type="button" + onClick={() => { + setCursor(i); + void play(h, true); + }} + title={`the line around it — ${wide.from.toFixed(2)}s to ${wide.to.toFixed(2)}s`} + className="rounded border border-[var(--color-line)] px-1.5 py-0.5 font-mono text-[11px] text-[var(--color-dim)] hover:text-[var(--color-text)]" + > + wide + </button> + <span className="truncate text-[12px] text-[var(--color-dim)]" title={h.video}> + {h.title} + </span> + {h.date && <span className="num micro">{h.date}</span>} + + <span className="num ml-auto flex items-baseline gap-2 text-[11px]"> + {/* A READING, in the measurement colour. Never a verdict hue: + a low confidence is often the interesting token, not a bad + one. */} + <span className="text-[var(--color-meter)]"> + {h.confMin === null ? "conf ?" : `conf ${h.confMin.toFixed(2)}`} + </span> + {h.dupes > 0 && ( + <span className="micro" title="chunk twins folded into this hit"> + +{h.dupes} twin + </span> + )} + {h.stutter && ( + <span className="micro" title="a real repeat, outside any chunk seam"> + stutter + </span> + )} + {h.suspect && ( + <span + className="text-[11px] text-[var(--color-dirty)]" + title="a clip somewhere in this episode was judged another speaker — per-episode, not per-moment" + > + flagged source + </span> + )} + </span> + </div> + + <p className="mt-1 text-[13px] leading-relaxed"> + <span className="text-[var(--color-dim)]">{h.before} </span> + <span className="font-mono font-medium tracking-wide text-[var(--color-text)]"> + {h.text} + </span> + <span className="text-[var(--color-dim)]"> {h.after}</span> + </p> + + {on && playing === h.id && playhead !== null && ( + <p className="num mt-1 text-[11px] text-[var(--color-meter)]" data-playhead> + {playhead.toFixed(2)}s + </p> + )} + + {on && ( + <div className="mt-1 flex flex-wrap items-center gap-2"> + {h.hasVideo ? ( + <button + type="button" + onClick={() => setPicture((p) => (p === h.id ? null : h.id))} + className="rounded border border-[var(--color-line)] px-1.5 py-0.5 font-mono text-[11px] text-[var(--color-dim)] hover:text-[var(--color-text)]" + > + {picture === h.id ? "hide picture" : "picture"} <kbd>v</kbd> + </button> + ) : ( + <span className="micro">no media/ for this episode</span> + )} + {h.archive && ( + <a + href={h.archive} + target="_blank" + rel="noreferrer" + className="font-mono text-[11px] text-[var(--color-sel)] hover:underline" + > + in the archive + </a> + )} + <span className="micro"> + word {h.i} + {h.n > 1 ? `–${h.i + h.n - 1}` : ""} · {h.from.toFixed(2)}–{h.to.toFixed(2)}s + </span> + </div> + )} + + {/* THE PICTURE NEVER LOADS ON PAGE LOAD. That route spawns an + ffmpeg per window, so 50 mounted players would be 50 processes + on a navigation. Focused hit only, after `v` only, and only + when media/ actually holds the episode. */} + {picture === h.id && h.hasVideo && ( + <video + key={h.id} + src={`/api/clip/${encodeURIComponent(`${h.video}@${h.start.toFixed(2)}`)}/video?from=${wide.from}&to=${wide.to}`} + controls + autoPlay + data-picture={h.id} + className="mt-2 w-[360px] max-w-full rounded bg-[var(--color-panel-2)]" + /> + )} + </li> + ); + })} + </ul> + </> + ); +}