From 6009de44c5f4b65cde69606c179d5a578594b736 Mon Sep 17 00:00:00 2001 From: wine-fall <62830944+wine-fall@users.noreply.github.com> Date: Sun, 20 Sep 2026 11:31:37 +0800 Subject: [PATCH] fix(tui): the clock stops counting past the end of the song [spec 10] A coda riding the outro (spec 04 3.3) keeps presence on music after the audio has ended, and the strip advanced its readout on unbounded wall clock: a 2:54 track printed 3:03 while the rail, which clamps, sat full. The two now read the same length. Co-Authored-By: Claude Opus 5 (1M context) --- test/tui-progress.test.ts | 29 ++++++++++++++++++++++++++++- tui/src/app.tsx | 4 ++-- tui/src/progress.ts | 11 +++++++++++ 3 files changed, 41 insertions(+), 3 deletions(-) diff --git a/test/tui-progress.test.ts b/test/tui-progress.test.ts index 107a792..d015309 100644 --- a/test/tui-progress.test.ts +++ b/test/tui-progress.test.ts @@ -5,7 +5,7 @@ import { describe, expect, it } from 'vitest' -import { cells, clock, fit, progressBar } from '../tui/src/progress.ts' +import { cells, clock, fit, playedS, progressBar } from '../tui/src/progress.ts' // Wide glyphs are the SUBJECT of these cases, so they ride in as code points: // the source-language gate (DESIGN 0) keeps CJK out of v1 sources, and a test @@ -79,6 +79,33 @@ describe('progressBar', () => { }) }) +describe('playedS', () => { + const started = 1_700_000_000_000 + + it('advances on the clock the front-end keeps itself', () => { + expect(playedS(started, 174, started)).toBe(0) + expect(playedS(started, 174, started + 63_500)).toBe(63.5) + }) + + it('stops at the length of the track instead of outrunning it', () => { + // A coda riding the outro (spec 04 3.3) keeps the state on music after the + // song itself has ended, and the wall clock would otherwise print 3:03 of + // a 2:54 track. The rail already clamps; the readout has to agree with it. + expect(playedS(started, 174, started + 189_000)).toBe(174) + expect(progressBar(playedS(started, 174, started + 189_000), 174, 10).played).toHaveLength(10) + }) + + it('reads an unknown length as no ceiling at all', () => { + // durationS absent is "no rail, just the title" (spec 10 3.3): nothing to + // clamp to, and a zero ceiling would freeze the clock at 0:00. + expect(playedS(started, 0, started + 42_000)).toBe(42) + }) + + it('never reads back before the start', () => { + expect(playedS(started, 174, started - 5_000)).toBe(0) + }) +}) + describe('cells', () => { it('counts terminal cells, not code points — CJK is twice as wide', () => { expect(cells('Calgary')).toBe(7) diff --git a/tui/src/app.tsx b/tui/src/app.tsx index 4b922e8..24c4dbe 100644 --- a/tui/src/app.tsx +++ b/tui/src/app.tsx @@ -64,7 +64,7 @@ import { encodeWavePng, waveGeomFor, waveRowsFor, WAVE_FPS } from './wave-image. import { IDENT_LINE, identSize, TAGLINE, WORDMARK } from './logo.ts' import { busyLine, COMPOSER_KEYS, composerRows, floorFace } from './floor.ts' import { accentFor, CARD, CARD_INK, CHIP, EMBER, hush, INK, mix, PERIWINKLE, QUIET, WARM, type Accent } from './palette.ts' -import { cells, clock, fit, progressBar } from './progress.ts' +import { cells, clock, fit, playedS, progressBar } from './progress.ts' import { adjust, languagePatch, paneFacts, paneItems } from './settings-pane.ts' import { awayGreeting, @@ -769,7 +769,7 @@ export function App({ subscribe, wire }: { subscribe: Subscribe; wire: Wire }): // The identity of the TRACK, not of the object: a re-emit during the song // (a typed line refreshing presence) must not restart the interval. }, [track?.startedAt, track?.durationS]) - const elapsedS = track === null ? 0 : (now - track.startedAt) / 1000 + const elapsedS = track === null ? 0 : playedS(track.startedAt, track.durationS, now) // The alive band's composition follows the live pet setting (spec 12 §3.7), // with the env override resolved inside bandLayout. diff --git a/tui/src/progress.ts b/tui/src/progress.ts index 9d26aee..748e26e 100644 --- a/tui/src/progress.ts +++ b/tui/src/progress.ts @@ -27,6 +27,17 @@ export function clock(seconds: number): string { return `${h > 0 ? `${h}:` : ''}${mm}:${String(s).padStart(2, '0')}` } +// How far into the track the strip should read, on the clock the front-end keeps +// (spec 10 3.3). Bounded by the length, because the state stays on music after +// the audio has ended -- a coda riding the outro (spec 04 3.3) holds it there +// for as long as it takes to say, and unbounded wall clock prints 3:03 of a +// 2:54 song. A length of 0 is "unknown" (no rail), so there is nothing to bound +// it with. +export function playedS(startedAt: number, durationS: number, now: number): number { + const played = Math.max(0, (now - startedAt) / 1000) + return durationS > 0 ? Math.min(played, durationS) : played +} + // The rail split in two so the caller can ink them differently: what has played // (full cells plus the eighth-cell leading edge) and what has not. The two // always add up to exactly `width` cells.