Skip to content

Version 1.3.20 - #651

Merged
maboa merged 22 commits into
mainfrom
release/1.3.20
Sep 18, 2026
Merged

maboa merged 22 commits into
mainfrom
release/1.3.20

Conversation

@maboa

@maboa maboa commented Sep 18, 2026

Copy link
Copy Markdown
Member

Speaker-coloured caption exports, and a run of poster and transcription fixes found in real use.

Fixes #643
Fixes #644
Fixes #645
Fixes #646
Fixes #647
Fixes #648
Fixes #649
Fixes #650

Gate: 139 unit; 423 e2e passed, 1 skipped (the known WebKit OPFS skip).

A setting under Settings → Captions, off by default, that gives the .vtt and
.srt downloads a colour per speaker. Nothing else changes: the live <track>,
the saved captions, burn-in and the interactive-transcript sidecar stay plain.

Export only, and deliberately. The caption editor is the source of truth and a
caption row is four <input> values, so a STYLE block would be erased by the
first caption edit, a <v Name> tag would show up literally in a text box and
count against the reading-rate limit, burn-in would paint the tag as words,
and our own VTT import would chop it into fabricated word spans. A downloaded
file is a leaf, so the colour is added at the moment of writing.

The speaker is recorded when the captions are generated, not looked up when
the file is downloaded. Captions diverge from the transcript on the first
edit, and a lookup at download time would let a later transcript edit silently
recolour captions the user had already finished. caption.js starts a new
segment on every speaker change, so each cue sits inside one speaker's run; it
drops the name and it is vendored, so the run is re-derived from the same
struck-word-free transcript copy the cues were built from, and lands on the
row as data-speaker. Insert inherits from the row above, merge keeps the
survivor's, the row cache carries it, and the project saves the list beside
the captions file rather than inside it. A saved list that does not match the
cue count is dropped rather than applied to the wrong lines.

WebVTT gets a STYLE block and voice tags, SRT gets font tags. Measured across
engines: Chromium and WebKit colour both voice and class selectors and hide
the voice tag itself; Firefox ignores every mechanism and renders plain white.
The setting's hint says so, SRT's player dependence included.

Also fixes a flake in the 1.3.19 tab test: it measured the modal with a
bounding rect, which reports the opening transform rather than the layout.
The .vtt and .srt links took their address from the two caption writers alone,
and those run only on transcription, caption-editor entry, regenerate and
sanitise. None runs at boot — the boot pass builds the track straight from the
generator — and the project-restore and VTT-import routes wrote at most the
WebVTT link. So a freshly opened page handed you an empty .vtt and .srt, and a
reloaded project an empty .srt, with nothing on screen to say so. The captions
were never missing: the live track has had them all along.

One helper now writes both links from whatever VTT its caller holds, with the
SRT coming from media-export's existing cue serialiser rather than a second
one. Restore and import call it with the file they were handed; a capture-phase
click backstop fills anything still empty from the live track, early enough
that the speaker colouring reads the real file later in the same click.

That backstop made the colour setting reachable on a page whose captions have
never been built, where nothing has recorded a speaker. With no caption rows
there have been no caption edits either, so the transcript is still the whole
truth and the speakers are derived for that one export. Only for that case: a
recorded list always wins, which is what keeps a later transcript edit from
recolouring captions someone had already finished.
Transcribing a URL produced a project with the previously open project's name
AND its media: the new transcript was filed against the old recording, and the
player was left pointing at it.

hyperaudio-save captures what is being transcribed at setTranscriptBusy(true)
— the player's src and the File behind it — so a completion arriving after the
user has switched away is still filed against the right project, and so a
recovered birth cannot steal the identity of whatever is on screen (#529,
#612). The contract is that the engine puts the transcription's media on the
player first. All three cloud engines raised the busy signal before assigning
player.src, so the capture took the OUTGOING project's media and File, named
the new project after them, and — because the captured src is restored when
the transcript lands — actively overwrote the real URL on the player.

Deepgram, AssemblyAI and Parakeet (HuggingFace) now signal busy after the
media is on the player. Local files were never affected: the file picker's own
change handler puts them on the player when the file is chosen, long before
TRANSCRIBE is pressed, which is why this only showed with a URL. The local
Whisper and Parakeet engines already had the order right.

Two regression tests: a Deepgram URL run end to end against a stubbed API,
which now names the project after its own media and leaves that media on the
player; and the contract itself, checked for all three cloud engines by
observing the player at the moment each signals busy. Both fail without the
reorder, the first with exactly the reported symptom.

Fixes #643
A cloud recording is still downloading while it is transcribed, so the player
never decodes a frame from it. The picture frozen at loadstart to cover the
loading gap — the PREVIOUS project's — then stayed for good: URL media stores
no file, so the capture pipeline has nothing to read and applyStoredPoster can
never answer for it, and the handler that would swap in a real picture gave up
without a fallback. The project wore the last recording's frame while Recents
drew its glyph beside it, which is the same disagreement #603 fixed for audio.

The glyph is the fallback here too, so the two agree. Only a picture belonging
to ANOTHER medium gives way: this medium's own frame, when it managed to draw
one, is the better answer and stays, which is what #619 and #582 put there.
Telling them apart needs the load token each was made under, since the stale
freeze and the honest own-frame are both data: JPEGs.

The handler also stopped gating on videoWidth. A medium still arriving reports
none, which is exactly the case this exists for; waiting for a capture stays
gated on it, because there is nothing to grab without a frame (#603).

Two tests: a cloud recording whose bytes arrive only after the transcription is
over, which must end on its own glyph rather than the previous project's frame;
and one that does decode, which must keep its own frame at its own size. The
first fails without the fix.
Every colour now clears 9.7:1 against black where the weakest was 6.9:1, and
the set averages 12.6:1 rather than 11.2:1, measured as relative luminance.
Captions are painted on a translucent black ground, so brightness is what
carries them.

The hues are spread as before and it is still not red-then-green, which the
commonest colour blindness would make the hardest first pair to tell apart.
The pale yellow that sat next to the amber is gone — two yellows out of eight
was a wasted slot — and a periwinkle takes its place at the far end, so a
conversation has to reach eight speakers before the two blues meet.
Downloading the word-level ("karaoke") WebVTT while the caption editor was
open produced a file with the header and nothing under it. The exporter always
read #hypertranscript straight out of the document, and in caption mode the
transcript is not there — the caption rows have the screen and the transcript
is held in the cache — so it found no words and wrote a bare WEBVTT line. No
error, no empty-file warning: just a seven-byte download.

editor-core now answers where the transcript is, by the same rule
captionSourceWithoutStruckWords already follows, and the exporter asks rather
than assumes. A caller that names its own source still gets exactly that one:
the burn-in renderer passes a re-timed copy and is untouched.

Test: the same file from both views, which fails without the fix with the
caption-editor download empty.
The earlier fix caught only one of the ways a previous project's picture stays
over a new recording. It asked whether the poster was a STAND-IN, which is
true of a frozen frame and false of a stored capture — so a project opened
from Recents, wearing its own saved capture, lent that capture straight to the
next cloud transcription. Reported from real use, and the case my reproduction
had missed.

Worse, the picture was not merely left in place, it was re-applied. The
library announces a change before library.currentId() moves on, so
applyStoredPoster ran with the id of the project the user had been on, fetched
ITS capture, and put it on the new recording.

Every poster this module writes is now recorded with the load it was written
for, the project it was fetched for, and whether a real capture could still
improve on it. The question the handler asks became "is this picture this
medium's and this project's?" — which a capture, a frozen frame, a first frame
and a glyph cannot be told apart by any other means. A capture fetched for the
outgoing project no longer counts as this one's, so the next change asks again
and lands on the glyph, the picture Recents draws.

Marking captures final and everything else provisional keeps #619's upgrade:
a stand-in still gives way to a real capture when one arrives, and a picture
belonging to the medium on the player is never traded down for a glyph.

Test: a local project reopened from Recents, then a cloud recording still
downloading, which must end on its own glyph rather than the reopened
project's capture. It fails against the previous fix.
hyperaudioPosterDebug() in the console prints what the poster is, what this
module last wrote and for which load and project, whether the loader owns the
screen, and for every project whether the player is showing its glyph or its
stored capture. The state is closed over, so a stale picture cannot be
diagnosed from the element alone — and the long console snippets that would
otherwise do this keep wrapping into syntax errors when pasted.
The other half of #643. That fix put the URL on the player before the busy
signal, so the new project got the right NAME and the right src — but the
previous project's File still sat in the session through the birth and was
written as the new project's media: a copy of the last recording inside a
project that plays from a link. The poster pipeline then captured that file,
so the new project wore the previous recording's picture in the player and in
Recents alike. No poster-side rule could catch it, because by every record it
WAS the new project's own capture. Diagnosed from a real session with
hyperaudioPosterDebug(): current project, showing its own capture, and the
capture a frame of the recording before.

Three places, one rule — a URL on the player is URL-mode and the URL wins,
which currentMediaDescriptor already said: the identity captured at the busy
signal carries no File for a URL transcription; the birth drops a File that is
not this medium's; and writeMediaOnce refuses one as a backstop. The one
exception stays: a file fetched from this very URL is this project's media.

The two earlier poster commits on this branch stand. They close the other
paths — a frozen frame or a stand-in left behind by a recording that never
decodes — and this closes the one that had made them look ineffective.

Test: a local-file project, then a URL transcription from a video that loads
fully. The new project must store no media and no poster, and wear its own
glyph rather than the local clip's capture. Fails without the fix with the
previous project's file in the new project's media directory.

Fixes #644
A link project stores no file, so it had no picture of its own. It wore a
soundwave whether it was audio or video, and no frame could be read from the
player because the server had not allowed a cross-origin read.

The library entry now records whether the medium has a picture. Metadata for a
remote recording often lands after the birth committed the entry, so the save
module also records it the moment the player knows, for the project that owns
the medium and never while a transcription has the screen — reading before
writing, because the index updater writes and notifies unconditionally and an
unconditional write on the notification looped thousands of times a second.
A link video draws as a strip of film on the same pastel ground; audio, and
entries from before the field existed, keep the wave. A glyph drawn before the
entry knew is redrawn when the entry changes.

The poster pipeline asks the server for a CORS-readable copy, on the detached
capture element only and never on the live player: a server that refuses makes
that element fail, which resolves null and costs nothing, where on the player
it would have stopped the video. A refusal is remembered per URL for the
session and audio links are not asked at all — without both, the intro's mp3
was asked, and refused, on every library change. The five-second stand-in
during the loader phase asks the same way.

Where the server allows it, a link project now earns a real capture and wears
it in the player and in Recents; where it does not, the film glyph. Tests run
against a real second-origin server, because a routed response is fulfilled
with permissive headers and would have let the "refusing" server allow the
capture. The media-leak test (#644) now accepts a poster the project captured
from its own URL and keeps asserting what matters: no leaked file, and never
the previous project's picture.
…ntred

A screen recording of switching between projects showed the markup logo for
about seven tenths of a second, and a glyph for a frame, before the right
picture arrived. Every poster write during real switches was traced to its
line; three writers were at fault.

The reveal fallback treated any data: poster as another project's picture and
replaced it with the markup poster. By now a data: poster can be this
project's own glyph, so it painted the logo over the glyph for the length of a
slow load — and the logo is branding, not the project's picture (#603). A
foreign picture now gives way to this project's glyph, and its own is left
alone.

The audio re-seed handler acted on library changes before metadata, when a
video reports no width and looks like audio, and re-seeded a glyph over a
video project's capture for one frame at every switch. It waits for metadata
now; settleAudio does the job properly once the medium is known.

A slow link showed the outgoing project's capture for the length of its load,
because the loadstart freeze could not read a cross-origin frame and left what
was up. A project change is now covered at loadstart by the incoming project's
own picture: its capture if one has been read this session, synchronously
from the poster cache, else its glyph, provisional, so a capture or a frame
replaces it the moment either exists. Within one project the outgoing frame
still covers the gap.

The film glyph is redrawn as a short length of film centred on the wave's
footprint, so the play badge sits over it the same way.

Test: a local project and a slow, uncacheable, CORS-refusing link, switched
between five times with every poster write recorded and judged — never the
logo, never another project's picture, never a soundwave on a video.
…cents

A glyph in the player was a flash of colour on every project switch before
the capture landed, and for a link the server will not let a canvas read it
was the picture for good. Black is what an empty player looks like and is what
a frame arrives over, so it is less jarring both as a cover and as an end
state. The film glyph stays where a picture is wanted for its own sake: the
Recents rollover.

The poster attribute is never removed to get there. #575 measured what
happens in WebKit when a posterless element paints a frame: its display mode
advances and never comes back, and every project opened afterwards in that
tab paints nothing. Black is a poster like any other.

At loadstart the cover for an incoming project is its capture if one has been
read this session, else black for video and the wave for audio, decided from
the library as last announced so nothing waits on a read. Audio keeps the
wave throughout: settleAudio and the audio re-seed are untouched.

The switch test now judges every poster write with black as an allowed cover
and no glyph of any kind allowed in the player.
The parser walked two lists in lockstep: the word array, which carries the
timings and the speakers, and the formatted transcript split on spaces, which
supplied the visible text — matched by position alone, with nothing checking
they were the same length.

smart_format, which we ask for, is exactly what breaks that. It rewrites
spoken numbers and the like into forms with a different token count, so from
the first mismatch every word after it is drawn from the wrong slot: the text
slides against the timings, and a speaker label, placed where the word array
says the speaker changed, lands against whatever text has drifted into that
position. Reported as speaker names appearing mid-sentence.

smart_format already puts the formatted text on each word as punctuated_word.
Taking it from there puts text, timing and speaker together on one object, the
way the AssemblyAI parser has always done it — which is why AssemblyAI does
not show this, and why its being clean did not settle where the fault was.

Whether this was the whole of the reported problem is now answerable by
looking: if labels still land mid-sentence, the speaker changed there and it
is Deepgram's diarization.

Test: a response whose formatted transcript is two tokens shorter than its
word array, as a spoken year makes it. Against the old parser the last two
words render as "undefined" and the label moves; now every word carries its
own text and the label sits on the word the speaker changed on.
Taking each word's text from the word itself did not settle the misplaced
labels, so the question is now whether Deepgram's flags really do flip where a
label appears. Only their raw flags answer that, and the response is not kept
anywhere a report can reach.

The response is kept for the session, and hyperaudioSpeakerDebug() prints, for
each change: the words either side, the gap before it, whether the previous
word ended a sentence, and how many words the new speaker holds before
changing back. Above that, the counts that decide it — how many changes fall
mid-sentence, and how many are one-word turns, which is the signature of
diarization noise rather than a turn.

Enough to say whose fault it is without passing a whole transcript around.
`diarize` tags every word on its own, with no regard for what was being said,
so a boundary lands a few words short of where the turn actually ended and the
next speaker is credited with the tail of the previous one's sentence. That is
the reported fault: the label reads as mid-sentence. Measured on a six-way
debate, eight or nine of about thirty changes fell inside a sentence, against
essentially none from AssemblyAI on the same audio — which is not a matter of
quality but of what each returns. AssemblyAI gives speaker-tagged utterances.
We were asking Deepgram only for per-word flags.

Deepgram has the same thing behind `utterances`: segments cut on pauses and
turns, each with one speaker. The request asks for them, and a word takes the
speaker of the utterance its own start time falls inside, so nothing depends
on the two lists being the same length or in step. A word in no utterance
keeps the flag it had rather than being swept into the nearest segment.

Best-effort throughout: a response without utterances leaves every per-word
flag exactly as it was, so an older account or a model that does not support
them transcribes as before. The single-word heuristic stays for that case —
it patches the same fault a word at a time.

This does not recover a change Deepgram missed outright, and there were some:
two speakers merged into one block where AssemblyAI separated them. Nothing on
our side can.

Four tests: the boundary moving to the end of the turn; a response with no
utterances behaving as before; a word in a gap keeping its flag; and the
report naming its source. hyperaudioSpeakerDebug() now says whether the
speakers came from utterances or from per-word flags.
Taking each word's speaker from the utterance it fell in was wrong, and the
debate audio showed it plainly: 374 words re-assigned, and whole turns merged
— the host swallowed into the answer before it, two speakers the per-word
flags had kept apart reduced to one. Deepgram's utterance segmentation runs
straight through speaker changes when the pauses are short, which a six-way
debate with crosstalk supplies constantly. It is coarser than their own
per-word flags, so trusting it loses distinctions those flags had.

What the segments are good for is WHERE a change belongs, not WHO is
speaking. Every word-level change is kept, exactly as many as before, and each
is nudged onto the nearest utterance edge within three seconds — an edge being
a pause or a turn, so the label lands at the start of what someone said rather
than four words in. A change with no edge near it stays put, and none may
cross its neighbours: this can move a boundary, never remove one, and never
merge two speakers.

Tests cover the case that broke it: a single segment spanning both turns, from
which both speakers must still emerge. The others fix the position of a change
onto a pause, leave a change alone when no edge is near, and keep a response
without utterances behaving exactly as before.
Both attempts to correct Deepgram's mid-sentence speaker placement failed
against the audio that showed the fault, so the parser draws their boundaries
exactly as they report them, as it did before this line of work.

Taking each word's speaker from the `utterances` segment it falls in merged
distinct people: 374 words moved and the host was swallowed into the answer
before it. Their segmentation runs through a speaker change when the pause is
short, so it is coarser than their own per-word flags.

Moving each change onto the nearest utterance edge instead, keeping every
change and correcting only its position, moved 16 of about 30 and changed
nothing anyone could see. At roughly one utterance every nine seconds the
edges are too sparse to land on, and where one is near it is generally
mid-sentence too, being cut from the same diarization.

`utterances` is no longer requested, since nothing reads it. The narrow
single-word repair stays: it predates this and addresses a signature that is
demonstrably theirs. What survives from the work is the real bug found on the
way — each word's text now comes off the word itself — and
hyperaudioSpeakerDebug(), which reports what their flags say.

A test pins the decision, so anything that starts editing their speakers again
has to come past it.
@maboa
maboa merged commit 9aa3cdb into main Sep 18, 2026
1 check failed
@maboa
maboa deleted the release/1.3.20 branch September 18, 2026 16:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment