Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions test/tui-constellation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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)
Expand Down
45 changes: 35 additions & 10 deletions tui/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof setInterval> | 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<PoseName, Buffer[]>()
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 (
<box
Expand Down
9 changes: 8 additions & 1 deletion tui/src/constellation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,13 @@ function clamp01(value: number): number {
return Number.isFinite(value) ? Math.min(Math.max(value, 0), 1) : 0
}

// The spectrum mirrored from the center out: bin 0 (bass, almost always
// breathing) rises at the arc's middle and the treble frays toward both arms —
// the wave blooms outward instead of marching left-to-right.
export function waveBinAt(span: number, count: number): number {
return Math.min(Math.floor(Math.abs(span) * count), count - 1)
}

// mulberry32 — one small seeded PRNG so the field is stable per seed.
function prng(seed: number): () => number {
let state = seed >>> 0
Expand Down Expand Up @@ -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
Expand Down
Loading