From 5157ae1b58ed61a730e9f8897ad378a149f5bf3e Mon Sep 17 00:00:00 2001 From: Zach Guo Date: Mon, 10 Aug 2026 12:30:20 +0800 Subject: [PATCH] fix(tui): the wave blooms from the center, the figure stops blinking [spec 10] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two by-eye reports from the first real Ghostty session after #115: - The spectrum was pinned bin 0 -> left arm, so the ever-breathing bass made the wave read as marching left-to-right. waveBinAt now mirrors the bins from the arc's middle outward — bass at the center, treble fraying toward both arms (unit-tested for symmetry and for a bass-only frame lighting the center alone). - The raster figure blanked for over half a second at the first spoken line: the placement effect depended on the pose, so every state change ran deleteFigures and waited out the 600ms resettle. The pose now rides a ref and swaps the next transmitted frame in place under the same image id; only a real relayout tears the placement down. Verified in the pty harness: 73 in-place retransmissions across a run spanning the greeting->talk transition, zero deletes before shutdown. Peer review (codex gpt-5.5 xhigh): 0 findings. Co-Authored-By: Claude Fable 5 --- test/tui-constellation.test.ts | 23 +++++++++++++++++ tui/src/app.tsx | 45 ++++++++++++++++++++++++++-------- tui/src/constellation.ts | 9 ++++++- 3 files changed, 66 insertions(+), 11 deletions(-) diff --git a/test/tui-constellation.test.ts b/test/tui-constellation.test.ts index 8080b5c..3dff5f4 100644 --- a/test/tui-constellation.test.ts +++ b/test/tui-constellation.test.ts @@ -17,6 +17,7 @@ import { panelWidth, seedStars, STAR_RINGS, + waveBinAt, WIDE_MIN, } from '../tui/src/constellation.ts' import { accentFor, INK, WARM } from '../tui/src/palette.ts' @@ -96,6 +97,28 @@ describe('Constellation (§6.1: ring of stars, radial wave, square pixels)', () expect(loud).toBeGreaterThan(quiet) }) + it('mirrors the spectrum from the center out — bass at the middle, treble at the arms', () => { + expect(waveBinAt(0, 8)).toBe(0) + expect(waveBinAt(0.99, 8)).toBe(7) + expect(waveBinAt(-0.99, 8)).toBe(7) + for (const span of [0.2, 0.5, 0.8]) { + expect(waveBinAt(-span, 8)).toBe(waveBinAt(span, 8)) + } + }) + + it('lights both arms alike on a bass-only frame — the wave grows outward, not left-first', () => { + const silence = new Constellation(48, 24, 7).frame([], ACCENT, null) + // Bass alone: only the center of the arc may rise; the left arm must not + // light up ahead of the right the way an edge-anchored mapping does. + const rows = new Constellation(48, 24, 7).frame([1, 0, 0, 0, 0, 0, 0, 0], ACCENT, null) + const left = waveDepth(rows, silence, 2, 12) + const right = waveDepth(rows, silence, 36, 46) + const center = waveDepth(rows, silence, 20, 28) + expect(center).toBeGreaterThan(-1) + expect(left).toBe(-1) + expect(right).toBe(-1) + }) + it('rides the circle: center columns bottom out deeper than the arms', () => { const levels = Array.from({ length: 24 }, () => 0.7) const silence = new Constellation(48, 24, 7).frame([], ACCENT, null) diff --git a/tui/src/app.tsx b/tui/src/app.tsx index 4d0a94e..b92b50d 100644 --- a/tui/src/app.tsx +++ b/tui/src/app.tsx @@ -369,35 +369,60 @@ export function App({ subscribe, wire }: { subscribe: Subscribe; wire: Wire }): const figMode = figurePen(process.env) const renderer = useRenderer() const rawOut = renderer as unknown as { writeOut(data: string): void } + // The pose rides a ref so a pose change swaps the NEXT transmitted frame in + // place (same image id) instead of tearing the effect down: delete + resettle + // blanked the figure for over half a second on every state change — the + // start-of-broadcast flash. Only a real relayout re-runs the effect. + const poseRef = useRef(pose) + poseRef.current = pose useEffect(() => { if (skyWidth === null || figMode !== 'image' || !band.pet) return let loop: ReturnType | undefined const settle = setTimeout(() => { const cell = cellSizeFrom(renderer.resolution, dims.width, dims.height) - const frames = POSES[pose] - const spriteCols = frames[0]![0]!.length + const spriteCols = POSES.idle[0]![0]!.length const scale = figureScale(cell?.width ?? 0, spriteCols) - const fade = pose === 'doze' ? DOZE_FADE : 0 - const pngs = frames.map((frame) => encodeFigurePng(frame, scale, fade)) + // Every pose shares the sprite grid, so geometry is computed once and a + // pose's PNGs are encoded on first use. + const pngCache = new Map() + const pngsFor = (name: PoseName): Buffer[] => { + let pngs = pngCache.get(name) + if (pngs === undefined) { + const fade = name === 'doze' ? DOZE_FADE : 0 + pngs = POSES[name].map((frame) => encodeFigurePng(frame, scale, fade)) + pngCache.set(name, pngs) + } + return pngs + } const imgCols = Math.ceil((spriteCols * scale) / (cell?.width ?? 8)) - const imgRows = Math.ceil((frames[0]!.length * scale) / (cell?.height ?? 16)) + const imgRows = Math.ceil((POSES.idle[0]!.length * scale) / (cell?.height ?? 16)) const centerRow = 2 + circleOf((skyWidth - 1) * 2, skyRows * 4).cy / 4 const panelLeft = gutter + cols - skyWidth const col = Math.max(1, Math.round(panelLeft + (skyWidth - 1) / 2 - imgCols / 2) + 1) const row = Math.max(1, Math.round(centerRow - imgRows / 2) + 1) // Retransmitting under one id replaces the frame in place — the pose - // loop is a stream of tiny PNGs at the pose's own rate. - let at = 0 - const paint = (): void => rawOut.writeOut(placeFigure(pngs[at++ % pngs.length]!, row, col, 1)) + // loop is a stream of tiny PNGs, each pose advancing at its own rate + // against one shared clock. + const started = performance.now() + let shown = '' + const paint = (): void => { + const name = poseRef.current + const pngs = pngsFor(name) + const at = Math.floor(((performance.now() - started) / 1000) * POSE_FPS[name]) + const key = `${name}:${at % pngs.length}` + if (key === shown) return + shown = key + rawOut.writeOut(placeFigure(pngs[at % pngs.length]!, row, col, 1)) + } paint() - if (pngs.length > 1) loop = setInterval(paint, 1000 / POSE_FPS[pose]) + loop = setInterval(paint, 1000 / Math.max(...Object.values(POSE_FPS))) }, 600) return () => { clearTimeout(settle) clearInterval(loop) rawOut.writeOut(deleteFigures()) } - }, [skyWidth, cols, gutter, skyRows, figMode, band.pet, pose, renderer, dims.width, dims.height]) + }, [skyWidth, cols, gutter, skyRows, figMode, band.pet, renderer, dims.width, dims.height]) return ( number { let state = seed >>> 0 @@ -228,7 +235,7 @@ export class Constellation { for (let x = 0; x < this.subCols; x += 2) { const span = (x - cx) / radius if (Math.abs(span) > 0.98 || levels.length === 0) continue - const level = clamp01(levels[Math.floor(((span + 1) / 2) * levels.length)]!) + const level = clamp01(levels[waveBinAt(span, levels.length)]!) if (level === 0) continue const base = cy + Math.sqrt(Math.max(radius * radius - (x - cx) * (x - cx), 0)) const climb = level * (base - cy) * CLIMB