Unofficial Warhammer 40,000 fan skin for Claude Code, not affiliated with Games Workshop: themed spinner and turn line, Noosphere telemetry band and panel…

Turn Claude Code into a Tech-Priest's cogitator.
Machine-cult spinner phrases, a telemetry band and side panel, warn-only "heresy" rites, synthesized beeps and spoken lines. An unofficial Warhammer 40,000 fan mod, free forever.
<img src="assets/hero.gif" alt="litany in a Claude Code session: the NOOSPHERE panel docked on the right, the band above the prompt, a heresy warning on rm -rf and a subagent in the servitor roster" width="900">
In Claude Code:
/plugin install litany --marketplace shakespear-dev/litany
From a shell: claude plugin install litany --marketplace shakespear-dev/litany. In the desktop app: + next to the prompt box → Plugins → Add marketplace shakespear-dev/litany → Add plugin.
Everything is on by default. Run /litany to see what's running.
| Spinner | Phrases follow the active tool: Anointing the cogitator for Bash, Sifting the Librarium data-stacks for Read and Grep. | |
| Turn line | Rite concluded in 3s instead of Baked for 3s. | |
| Band | The Noosphere line above the prompt: context, tool calls, branch, cost, 5-hour window, heresies. | |
| Panel | The NOOSPHERE side panel: animated art in falling binharic, gauges, a braille pulse of tool calls per minute, a per-tool tally, a roster of running subagents and the latest tool calls. It docks by itself in terminals 144+ columns wide and in the desktop app. | |
| Voice | A short +++ … +++ opener and closer on chat replies. Never in code, commits, tool inputs or text you'll send to someone else. | |
| Rites | Warns, never blocks, on rm -rf, force-push, reset --hard, `curl \ | sh, DROP TABLE` and friends. |
| Sound | Seven synthesized clips: long turn done, heresy, tool failure, permission waiting, API error, subagent waiting, auto-compaction. | |
| Vox | A short spoken line after each of those, from a formant speech synthesizer built into the mod. No recordings, no network. |
/litany every switch and its state
/litany off | on everything off or on for this session
/litany sound off mute the beeps, keep the rest
/litany panel open or close the panel
/litany theme forge mars · cogitator · forge · claude
/litany vox cogitator default · mars · cogitator · forge · claude · off
/litany volume master 5 master, beeps or vox, 0–10
/litany say forge Hello. speak any English text once
Session commands last until the session ends. For lasting settings, every part has its own row in /config.
| Theme | Look | Voice |
|---|---|---|
mars (default) | red and bronze servo-skull | a tech-priest chanting through a vox-horn |
cogitator | green CRT cog | the console's mechanical woman |
forge | amber tech-priest at a console | the ancient, unstable engine |
claude | your Claude Code colours, best on light backgrounds | the clean one |
pw-play, paplay, aplay or ffplay for sound, plus sh and base64. With no player, or over SSH, it stays silent and everything else still works.Issues and PRs are welcome, AI-assisted ones too, as long as you understand what you submit and keep the description short. See CONTRIBUTING.md.
claude plugin validate .
claude plugin test .
claude --plugin-dir . # try it in a real session
Thank you. The Adeptus Mechanicus is one of the best ideas in science fiction, and this mod exists only because your writers and artists created it. I'm a fan who wanted my terminal to feel like a cogitator.
This is a fun project and nothing more. It is free, will never be sold, and will never carry ads, paid tiers or donation links. It ships no artwork, logos or audio of yours: the art is original ASCII, and the sounds and voices are synthesized from scratch.
If anything here crosses a line, please open an issue or email contact-shakespear@a.bytes-mail.com and it will be changed or taken down right away. No takedown notice needed. Please don't kill it, and sorry for borrowing the vocabulary.
litany is completely unofficial and in no way endorsed by Games Workshop Limited. Warhammer, Warhammer 40,000, Adeptus Mechanicus, Omnissiah, Tech-Priest and all associated marks, names, characters, places and imagery are either ®, ™ and/or © Games Workshop Limited, variably registered around the world. They are used here without permission in a non-commercial fan work. No challenge to their status is intended.
Claude Code is a product of Anthropic. litany is not affiliated with or endorsed by Anthropic.
The software is provided as is, without warranty of any kind. See LICENSE.
The speech synthesizer is derived from formant-tts by Prakhar Saxena (MIT), with fixes.
MIT for the code, the ASCII art and the sounds. The licence grants no rights in Games Workshop's intellectual property.
hooks/register.tsx 706 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { SoundEvent, Switch } from '../types'
5import type { Defaults } from './lore'
6import { anyOn, type Command, SAY_MAX, applyCommand, hasWords, BAND_HEAD, bandSegments, basename, fitBand, heresyText, ignitionText, isOn, lawText, matchHeresy, parseCommand, PAUSE_MS, pickLine, pushLog, resolveTheme, resolveVolumes, resolveVox, volumeGain, volumesText, voxSetting, SEP, spinnerWord, statusText, toolPhrase, turnLine, VOICE, withUsage } from './lore'
7import { ranRite, spawned, turnEnded, turnStarted } from './servitors'
8import { addPulse, LOG_MAX, minuteOf, pulseWindow, ROSTER_HEAD, slate, spiritName, splitWindow } from './slate'
9import { BOOT_TICKS, cellRuns, heroCells, paneSvg, pulseCells, segRuns, themeFor, type TextTone, type Theme } from './themes'
10import { scaleWav } from './vox/dsp'
11import { speak, type VoiceName } from './vox/voices'
12
13const overrides = atom({ plugin: 'litany', key: 'overrides' } as const, { master: null, switches: {} })
14const law = atom({ plugin: 'litany', key: 'law' } as const, 0)
15const tool = atom({ plugin: 'litany', key: 'tool' } as const, null)
16const telemetry = atom({ plugin: 'litany', key: 'telemetry' } as const, { rites: 0, failed: 0, heresies: 0 })
17const heresyLine = atom({ plugin: 'litany', key: 'heresyLine' } as const, null)
18const failedThisTurn = atom({ plugin: 'litany', key: 'failedThisTurn' } as const, false)
19const ignited = atom({ plugin: 'litany', key: 'ignited' } as const, false)
20const riteLog = atom({ plugin: 'litany', key: 'riteLog' } as const, [])
21const heresyLog = atom({ plugin: 'litany', key: 'heresyLog' } as const, [])
22const themeOverride = atom({ plugin: 'litany', key: 'themeOverride' } as const, null)
23const voxOverride = atom({ plugin: 'litany', key: 'voxOverride' } as const, null)
24const volumeOverride = atom({ plugin: 'litany', key: 'volumeOverride' } as const, {})
25const panelOpen = atom({ plugin: 'litany', key: 'panelOpen' } as const, null)
26const pulse = atom({ plugin: 'litany', key: 'pulse' } as const, { last: 0, counts: [] })
27const spirits = atom({ plugin: 'litany', key: 'spirits' } as const, {})
28const agents = atom({ plugin: 'litany', key: 'agents' } as const, {})
29const servitorPulse = atom({ plugin: 'litany', key: 'servitorPulse' } as const, { last: 0, counts: [] })
30
31const BAND_TONE = { success: 'ok', warning: 'warn', error: 'error' } as const
32
33// Helpers that take $ must be top-level declarations (the engine follows $ only into those).
34async function enabled($: EngineInterface, options: Defaults, name: Switch) {
35 return isOn(name, options, await read($, overrides))
36}
37
38async function currentTheme($: EngineInterface, options: Defaults) {
39 return themeFor(resolveTheme(options, await read($, themeOverride)))
40}
41
42// The voice that speaks now, or null while vox is off or the master is.
43async function currentVoice($: EngineInterface, options: Defaults) {
44 const theme = resolveTheme(options, await read($, themeOverride))
45 return resolveVox(options, await read($, voxOverride), theme, (await read($, overrides)).master)
46}
47
48// The clip players a Linux desktop may have, PipeWire's first.
49const PLAYERS: readonly (readonly string[])[] = [['pw-play'], ['paplay'], ['aplay', '-q'], ['ffplay', '-nodisp', '-autoexit', '-loglevel', 'quiet']]
50// $.audio.play sounds through afplay on macOS alone, and elsewhere plays nothing. Undefined until the first sound; 'windows' on Windows; 'engine' on macOS and wherever uname cannot run; else the players still in the running, narrowed to the first that played.
51let players: readonly (readonly string[])[] | 'engine' | 'windows' | undefined
52
53// 'engine' on macOS; 'windows' on Windows; on Linux the player tried first (the one that played, once one has), or null when none played.
54async function player($: EngineInterface): Promise<'engine' | 'windows' | readonly string[] | null> {
55 // Windows is asked first: Git Bash may put a uname on its PATH.
56 if (players === undefined && (await $.env.get('OS').catch(() => undefined)) === 'Windows_NT') players ??= 'windows'
57 if (players === undefined) {
58 const os = await $.process.run(['uname', '-s'], { timeoutMs: 2000 }).catch(() => null)
59 players ??= os?.exitCode === 0 && os.stdout.trim() !== 'Darwin' ? PLAYERS : 'engine'
60 }
61 return typeof players === 'string' ? players : (players[0] ?? null)
62}
63
64// Windows: the WAV's bytes reach PowerShell's SoundPlayer on stdin, so no file is written. ponytail: PowerShell starts in a few hundred ms, so a clip lags that much.
65const PLAY_STDIN = '[System.Media.SoundPlayer]::new([IO.MemoryStream]::new([Convert]::FromBase64String([Console]::In.ReadToEnd()))).PlaySync()'
66
67// By its full path: Windows looks for a bare name in the working directory first, the project's, where a planted powershell.exe would run.
68async function powershell($: EngineInterface) {
69 const root = await $.env.get('SystemRoot').catch(() => undefined)
70 return `${root !== undefined && /^[A-Za-z]:\\[^"<>|?*]*$/.test(root) ? root.replace(/\\+$/, '') : 'C:\\Windows'}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe`
71}
72
73async function playWindows($: EngineInterface, wav: Uint8Array, timeoutMs = 10_000) {
74 await $.process.run([await powershell($), '-NoProfile', '-NonInteractive', '-Command', PLAY_STDIN], { stdin: wav.toBase64(), timeoutMs }).catch(() => {})
75}
76
77// A player that fails sooner than this never started playing; one that ran longer was playing when it ended.
78const PROMPT_MS = 2_000
79
80// Linux: the first player that plays the file keeps the job; a chosen one that fails once is kept. A run killed at `timeoutMs` (or ended later by a signal) ends this file: no other player replays it, and none is blamed.
81async function playWavFile($: EngineInterface, path: string, timeoutMs = 10_000) {
82 if ((await player($)) === null || typeof players === 'string' || players === undefined) return
83 for (const argv of players) {
84 const startedAt = await $.clock.now().catch(() => 0)
85 const r = await $.process.run([...argv, path], { timeoutMs }).catch(() => null)
86 if (r?.exitCode === 0) return void (players = [argv])
87 if ((await $.clock.now().catch(() => 0)) - startedAt >= PROMPT_MS) return
88 }
89 // No player played (none installed, no sound server): the session stays silent.
90 if (players === PLAYERS) players = []
91}
92
93// A folder from the environment counts only when absolute; anything else reads as unset.
94const trimDir = (dir: string | undefined) => (dir?.startsWith('/') ? dir.replace(/\/+$/, '') : '')
95
96// The folders a session's line folder may live in: the per-user runtime folder when there is one, and the temp folder.
97async function voxBases($: EngineInterface) {
98 const runtime = trimDir(await $.env.get('XDG_RUNTIME_DIR').catch(() => undefined))
99 const temp = trimDir(await $.env.get('TMPDIR').catch(() => undefined)) || '/tmp'
100 return { runtime, temp }
101}
102
103// This session's private line folder (mktemp makes it 0700), so no other user can plant a name we write to. A reload forgets it and makes another; the day-old sweep takes the old one.
104let voxDir: Promise<string | null> | undefined
105// Names the files inside it, counting on across folders.
106let tempCount = 0
107
108// Only a folder named as asked, directly in the base, is ours to write to and remove.
109async function makeVoxDir($: EngineInterface): Promise<string | null> {
110 const { runtime, temp } = await voxBases($)
111 const base = runtime || temp
112 const r = await $.process.run(['mktemp', '-d', `${base}/litany-vox.XXXXXXXX`], { timeoutMs: 10_000 }).catch(() => null)
113 const dir = r?.exitCode === 0 ? r.stdout.trim() : ''
114 return dir.startsWith(`${base}/`) && /^litany-vox\.[A-Za-z0-9]{8}$/.test(dir.slice(base.length + 1)) ? dir : null
115}
116
117// Linux: $.fs.write writes only text, so the bytes reach the private folder as base64 on sh's stdin. noclobber (set -C): a path that already exists is never followed or truncated.
118async function writeTemp($: EngineInterface, bytes: Uint8Array): Promise<string | null> {
119 try {
120 const pending = (voxDir ??= makeVoxDir($))
121 const dir = await pending
122 // A folder given up on is made again by the next line, unless a /clear or another line has already moved on.
123 const forget = () => {
124 if (voxDir === pending) voxDir = undefined
125 }
126 if (dir === null) {
127 forget()
128 return null
129 }
130 const path = `${dir}/line-${++tempCount}.wav`
131 const r = await $.process.run(['sh', '-c', 'set -C; base64 -d > "$1"', 'sh', path], { stdin: bytes.toBase64(), timeoutMs: 10_000 })
132 if (r.exitCode === 0) return path
133 // The folder may be gone (swept by another session after a day idle), or no one can say: the next line makes a new one.
134 if (!(await $.fs.exists(dir).catch(() => false))) forget()
135 return null
136 } catch {
137 return null
138 }
139}
140
141// Linux: a WAV the mod made, played from a temp file that goes once the player exits.
142async function playBytes($: EngineInterface, wav: Uint8Array, timeoutMs?: number) {
143 const path = await writeTemp($, wav)
144 if (path === null) return
145 try {
146 await playWavFile($, path, timeoutMs)
147 } finally {
148 await $.process.run(['rm', '-f', path], { timeoutMs: 10_000 }).catch(() => {})
149 }
150}
151
152// A clip's bytes at a gain (below full volume on Linux, any on Windows), by clip and gain: each costs one read a session. A failure is not kept.
153const scaledClips = new Map<string, Promise<Uint8Array | null>>()
154
155async function readScaled($: EngineInterface, file: string, gain: number) {
156 const { base64 } = await $.fs.read(file, { as: 'bytes' })
157 return scaleWav(Uint8Array.fromBase64(base64), gain)
158}
159
160async function playClipAt($: EngineInterface, clip: SoundEvent, gain: number) {
161 const asset = `sounds/${clip}.wav`
162 const p = await player($)
163 if (p === 'engine') return $.audio.play({ asset }, { gain })
164 if (p === null) return
165 const file = `${$.plugin.root}/${asset}`
166 if (p !== 'windows' && gain === 1) return playWavFile($, file)
167 const key = `${clip}@${gain}`
168 const scaled = scaledClips.get(key) ?? readScaled($, file, gain).catch(() => null)
169 scaledClips.set(key, scaled)
170 const wav = await scaled
171 // A read or a header that fails plays nothing, never louder than asked, and is tried again next time. A failed write in playBytes is silence too.
172 if (wav === null) {
173 if (scaledClips.get(key) === scaled) scaledClips.delete(key)
174 return
175 }
176 return p === 'windows' ? playWindows($, wav) : playBytes($, wav)
177}
178
179// What `played` does within `ms`: a hung player frees whoever waits on it.
180async function within($: EngineInterface, played: Promise<unknown>, ms: number) {
181 const stop = new AbortController()
182 try {
183 await Promise.race([played.catch(() => {}), $.clock.sleep(ms, { signal: stop.signal }).catch(() => {})])
184 } finally {
185 stop.abort()
186 }
187}
188
189// Renders a line and plays it. Resolves once it has played or its length + 2 s have passed, so a hung player frees the vox.
190async function sayLine($: EngineInterface, voice: VoiceName, text: string, gain: number) {
191 const p = await player($)
192 if (p === null) return
193 const { wav, durationMs } = speak(voice, text, Math.floor(Math.random() * 2 ** 32), gain)
194 // A Linux or Windows player may run the line's length and 3 s more before it is killed.
195 const played = p === 'engine' ? $.audio.play({ base64: wav.toBase64(), mime: 'audio/wav' }) : p === 'windows' ? playWindows($, wav, durationMs + 3_000) : playBytes($, wav, durationMs + 3_000)
196 await within($, played, durationMs + 2_000)
197}
198
199// One line at a time: a module flag, so a reload frees it. The last line per event keeps a pool from repeating.
200let isSpeaking = false
201const lastLine: Partial<Record<SoundEvent, string>> = {}
202
203// /litany say: claim the vox, then speak detached so the reply returns at once. Returns the reply text.
204async function say($: EngineInterface, options: Defaults, cmd: Extract<Command, { kind: 'say' }>): Promise<string> {
205 // Taken before the first await, like sound(); a busy vox is left as it is.
206 if (isSpeaking) return '+++ The vox is busy; try again in a moment +++'
207 isSpeaking = true
208 let isStarted = false
209 try {
210 if (!hasWords(cmd.text)) return '+++ Nothing to say +++'
211 if ((await read($, overrides)).master === false) return '+++ The Mechanicus is silenced (/litany on to wake it) +++'
212 const volumes = resolveVolumes(options, await read($, volumeOverride))
213 const gain = volumeGain(volumes.master, volumes.vox)
214 if (gain === 0) return `+++ Silent: ${volumes.master === 0 ? 'master' : 'vox'} volume is 0 +++`
215 // Asked for by hand, so it speaks even with vox off: then the theme's voice.
216 const voice = cmd.voice ?? (await currentVoice($, options)) ?? resolveTheme(options, await read($, themeOverride))
217 isStarted = true
218 void (async () => {
219 try {
220 await sayLine($, voice, cmd.text, gain)
221 } catch {
222 // A failed render or play is silence.
223 } finally {
224 isSpeaking = false
225 }
226 })()
227 return [`+++ ${voice} speaks +++`, cmd.text, ...(cmd.isCut ? [`(cut to ${SAY_MAX} characters)`] : [])].join('\n')
228 } finally {
229 if (!isStarted) isSpeaking = false
230 }
231}
232
233// Every event's one entry: its clip, then past the pause its line. Never rejects: the tool call and the turn never notice.
234async function sound($: EngineInterface, options: Defaults, event: SoundEvent) {
235 // Taken before the first await, so of two events in one burst only the first may speak.
236 const isClaimed = !isSpeaking
237 isSpeaking = true
238 try {
239 const volumes = resolveVolumes(options, await read($, volumeOverride))
240 const clipGain = isOn('sound', options, await read($, overrides)) ? volumeGain(volumes.master, volumes.beeps) : 0
241 const voice = isClaimed ? await currentVoice($, options) : null
242 const lineGain = voice === null ? 0 : volumeGain(volumes.master, volumes.vox)
243 if (voice === null || lineGain === 0) {
244 if (clipGain > 0) void playClipAt($, event, clipGain).catch(() => {})
245 return
246 }
247 if (clipGain > 0) {
248 await within($, playClipAt($, event, clipGain), 10_000)
249 await $.clock.sleep(PAUSE_MS)
250 }
251 const text = pickLine(event, lastLine[event], Math.random())
252 lastLine[event] = text
253 await sayLine($, voice, text, lineGain)
254 } catch {
255 // A render, write or play that fails is silence.
256 } finally {
257 if (isClaimed) isSpeaking = false
258 }
259}
260
261// Linux only: line folders a killed session left, ours by uid and a day old, directly in the runtime or temp folder.
262async function sweepVoxDirs($: EngineInterface) {
263 if (typeof (await player($)) === 'string') return
264 const id = await $.process.run(['id', '-u'], { timeoutMs: 2000 })
265 const uid = id.exitCode === 0 ? id.stdout.trim() : ''
266 if (!/^\d+$/.test(uid)) return
267 const { runtime, temp } = await voxBases($)
268 for (const base of new Set([runtime, temp].filter(Boolean))) {
269 const argv = ['find', '-H', base, '-mindepth', '1', '-maxdepth', '1', '-type', 'd', '-name', 'litany-vox.*', '-uid', uid, '-mmin', '+1440']
270 const found = await $.process.run(argv, { timeoutMs: 10_000 }).catch(() => null)
271 if (found?.exitCode !== 0) continue
272 // Only exact matches: a name of ours, directly in `base`.
273 const doomed = found.stdout.split('\n').filter(p => /^litany-vox\.[^/]+$/.test(p.slice(base.length + 1)) && p.startsWith(`${base}/`))
274 if (doomed.length > 0) await $.process.run(['rm', '-rf', '--', ...doomed], { timeoutMs: 10_000 }).catch(() => {})
275 }
276}
277
278// The folder this module made, removed by its exact path; the next line after a /clear makes a new one.
279async function removeVoxDir($: EngineInterface) {
280 const made = voxDir
281 voxDir = undefined
282 const dir = made === undefined ? null : await made
283 if (dir !== null) await $.process.run(['rm', '-rf', '--', dir], { timeoutMs: 10_000 })
284}
285
286// Sanction and servitor sound once per wait; a tool call resolving, or a turn starting or ending, re-arms them.
287const WAIT_SOUNDS: ReadonlyMap<string, SoundEvent> = new Map([
288 ['permission_prompt', 'sanction'],
289 ['elicitation_dialog', 'sanction'],
290 ['elicitation_url_dialog', 'sanction'],
291 ['agent_needs_input', 'servitor'],
292])
293let isWaitAnnounced = false
294
295// session.measure fires when a main-thread turn ends; a long turn's rites read the figures in between.
296async function refreshUsage($: EngineInterface) {
297 const u = await $.session.usage().catch(() => null)
298 if (u) await update($, telemetry, t => withUsage(t, u))
299}
300
301async function warn($: EngineInterface, options: Defaults, command: string) {
302 const heresy = matchHeresy(command)
303 if (!heresy || !(await enabled($, options, 'rites'))) return
304 const text = heresyText(heresy)
305 $.ui.toast(text)
306 await update($, heresyLine, () => text)
307 await update($, telemetry, t => ({ ...t, heresies: t.heresies + 1 }))
308 const at = await $.clock.now().catch(() => 0)
309 await update($, heresyLog, l => pushLog(l, { at, command }, 3))
310 void sound($, options, 'heresy')
311}
312
313async function failed($: EngineInterface, options: Defaults) {
314 await update($, telemetry, t => ({ ...t, failed: t.failed + 1 }))
315 if (!(await enabled($, options, 'rites')) || (await read($, failedThisTurn))) return
316 await update($, failedThisTurn, () => true)
317 $.ui.toast('The Machine Spirit is displeased.')
318 void sound($, options, 'displeased')
319}
320
321// Detached HEAD, no repo, or a git that fails: the forge segment is simply left out.
322async function refreshBranch($: EngineInterface) {
323 const r = await $.process.run(['git', 'branch', '--show-current'], { timeoutMs: 2000 }).catch(() => null)
324 const branch = r?.exitCode === 0 ? r.stdout.trim() || undefined : undefined
325 await update($, telemetry, t => ({ ...t, branch }))
326}
327
328// The pulse at the drawn width: mirrored while `split` is on and a servitor rite is in view.
329async function pulseArt($: EngineInterface, options: Defaults, theme: Theme, inner: number) {
330 const all = await read($, pulse)
331 const now = Math.max(all.last, nowMinute)
332 if (await enabled($, options, 'split')) {
333 const w = splitWindow(all, await read($, servitorPulse), now, inner * 2)
334 if (w.servitors.some(v => v > 0)) return { cells: pulseCells(theme, w.main, inner, w.servitors), isSplit: true }
335 }
336 return { cells: pulseCells(theme, pulseWindow(all, now, inner * 2), inner), isSplit: false }
337}
338
339const PANE = 'litany'
340// Module-level on purpose: a reload drops the timer with them, and session.start opens the pane again.
341let timer: Timer | null = null
342let tick = 0
343let openedAt = -BOOT_TICKS
344let isDrawn = false
345let isWorking = false
346// Set from the surface's viewport while it draws: a pane opened unasked is a sidebar only where this is true.
347let isDockable = false
348// The newest minute this module has seen: a tool call or the timer's minute tick. The pulse scrolls to it.
349let nowMinute = 0
350// What each surface's last drawing of the open pane laid out. The timer animates what any of them shows, and blits only the terminal's rasters, at the width it drew them.
351// isPulseSplit: whether that pulse was mirrored; when the servitors' minutes scroll out, the head note must follow.
352// ponytail: kept until the pane closes, a detached surface's included; it costs a redraw now and then.
353type Shown = { inner: number; isHero: boolean; isPulse: boolean; isRoster: boolean; isPulseSplit: boolean }
354const shown = new Map<string, Shown>()
355
356// ponytail: the timer runs while the pane is open; an unseated or idle tick returns before any $ call.
357async function tickPanel($: EngineInterface, options: Defaults) {
358 tick++
359 const sinceOpen = tick - openedAt
360 const all = [...shown.values()]
361 // A minute is 240 frames at 250 ms; the pulse scrolls on it.
362 const isMinute = all.some(s => s.isPulse) && tick % 240 === 0
363 const isHeroFrame = all.some(s => s.isHero) && (isWorking || sinceOpen < BOOT_TICKS || tick % 4 === 0)
364 // The roster's times move once a second, and a finished row leaves on time.
365 const isRosterTick = all.some(s => s.isRoster) && tick % 4 === 0
366 if (!isDrawn || (!isHeroFrame && !isMinute && !isRosterTick)) return
367 if (isMinute) nowMinute = Math.max(nowMinute, minuteOf(await $.clock.now().catch(() => 0)))
368 // A surface with no Raster (desktop, VS Code, a phone) draws an Svg, which takes no blits: every frame redraws the whole pane (4 a second at most, under a remote surface's 10), the terminal's alongside it.
369 const isSvgPane = [...shown.keys()].some(surface => surface !== 'terminal')
370 if (isRosterTick || isSvgPane) $.ui.invalidate('ui.render')
371 const term = shown.get('terminal')
372 if (isSvgPane || !term) return
373 const theme = await currentTheme($, options)
374 if (isHeroFrame && term.isHero) {
375 const r = await $.ui.blit({ requestId: PANE, key: 'art', cells: heroCells(theme, term.inner, tick, isWorking, sinceOpen) })
376 if (r.deny !== undefined) return void (isDrawn = false)
377 }
378 if (isMinute && term.isPulse) {
379 const art = await pulseArt($, options, theme, term.inner)
380 // The graph changed kind: redraw the pane so the head note matches.
381 if (art.isSplit !== term.isPulseSplit) return void $.ui.invalidate('ui.render')
382 const r = await $.ui.blit({ requestId: PANE, key: 'pulse', cells: art.cells })
383 if (r.deny !== undefined) isDrawn = false
384 }
385}
386
387async function openPanel($: EngineInterface, options: Defaults) {
388 // Before the open: the engine may draw the pane before $.ui.open resolves, and that drawing is the first boot frame.
389 openedAt = tick
390 const opened = await $.ui.open({ id: PANE, title: '+++ NOOSPHERE +++' }).catch(() => null)
391 if (!opened) return
392 await update($, panelOpen, () => true)
393 timer ??= $.clock.every(250, () => void tickPanel($, options).catch(() => {}))
394}
395
396function stopPanel() {
397 timer?.cancel()
398 timer = null
399 isDrawn = false
400 shown.clear()
401}
402
403async function closePanel($: EngineInterface) {
404 stopPanel()
405 await update($, panelOpen, () => false)
406 await $.ui.close({ id: PANE }).catch(() => {})
407}
408
409export const register: Register = (on, options) => {
410 on('session.start', async ($, e, next) => {
411 await $.command.register({
412 name: 'litany',
413 description: 'Show or toggle the Litany skin for this session',
414 argumentHint: '[on|off] | <switch> on|off | theme <name> | vox <voice|default|off> | volume [<which> <0-10>] | panel | say [voice] <text>',
415 })
416 const started = await next(e)
417 await refreshBranch($)
418 const repo = await $.session.repo().catch(() => null)
419 const forge = repo ? (repo.name ?? basename(repo.root)) : undefined
420 await update($, telemetry, t => ({ ...t, forge }))
421 // $.state outlives a hot reload, so the litany is said once per session. Terminal sessions only: a -p or SDK host's stream would carry the notice as a transcript row.
422 if (e.isInteractive && e.surface === 'terminal' && anyOn(options, await read($, overrides), await currentVoice($, options)) && !(await read($, ignited))) {
423 await update($, ignited, () => true)
424 const text = ignitionText(forge, (await read($, telemetry)).branch)
425 await $.session.append({ message: { type: 'system', content: [{ type: 'text', text }] } }).catch(() => {})
426 }
427 // Line folders a killed session left behind: by a person's session only, and never holding up the start.
428 if (e.isInteractive) void sweepVoxDirs($).catch(() => {})
429 // A reload: reopen a pane that was open (its timer died with the old module), or close it if panel went off in /config. Wherever a surface is attached (a desktop session starts non-interactive); one that attaches later reopens it through session.attach, and a -p run never does.
430 if ((await read($, panelOpen)) === true) {
431 if (!(await enabled($, options, 'panel'))) await closePanel($)
432 else if (e.isInteractive || (await $.session.surfaces().catch(() => [])).length > 0) await openPanel($, options)
433 }
434 return started
435 })
436
437 // The desktop app (or a phone) joining: a pane left open in this session opens there again.
438 on('session.attach', async ($, e, next) => {
439 const attached = await next(e)
440 if (timer === null && (await read($, panelOpen)) === true && (await enabled($, options, 'panel'))) await openPanel($, options)
441 return attached
442 })
443
444 on('command.run', { command: 'litany' }, async ($, e) => {
445 const cmd = parseCommand(e.args)
446 if (cmd.kind === 'error') return { text: cmd.text }
447 if (cmd.kind === 'say') return { text: await say($, options, cmd) }
448 if (cmd.kind === 'volumes') {
449 const session = await read($, volumeOverride)
450 return { text: volumesText({ now: resolveVolumes(options, session), session }) }
451 }
452 if (cmd.kind === 'panel') {
453 if (!(await enabled($, options, 'panel'))) return { text: 'The panel is switched off. Turn it on with /litany panel on.' }
454 // Toggle on what the person sees: an open pane the engine holds unseated (narrow window) is shown, not closed.
455 if ((await read($, panelOpen)) === true && isDrawn) await closePanel($)
456 else await openPanel($, options)
457 } else if (cmd.kind === 'theme') await update($, themeOverride, () => cmd.name)
458 else if (cmd.kind === 'vox') await update($, voxOverride, () => cmd.setting)
459 else if (cmd.kind === 'volume') await update($, volumeOverride, v => ({ ...v, [cmd.which]: cmd.level }))
460 else await update($, overrides, o => applyCommand(o, cmd))
461 if (cmd.kind === 'switch' && cmd.name === 'panel' && cmd.on) await openPanel($, options)
462 if (!(await enabled($, options, 'panel')) && (await read($, panelOpen)) === true) await closePanel($)
463 const override = await read($, themeOverride)
464 const theme = { name: resolveTheme(options, override), isSession: override !== null }
465 const o = await read($, overrides)
466 const voxSession = await read($, voxOverride)
467 const vox = { setting: voxSetting(options, voxSession), voice: await currentVoice($, options), isSession: voxSession !== null }
468 const session = await read($, volumeOverride)
469 return { text: statusText(options, o, await read($, law), theme, vox, { now: resolveVolumes(options, session), session }) }
470 })
471
472 // Warn-only: every path ends in next(e); the permission mode stays the only gate. ponytail: one active tool, the main loop's; a subagent's spinner falls back to the mode phrase.
473 on('tool.call', async ($, e, next) => {
474 const { rites } = await read($, telemetry)
475 await update($, telemetry, t => ({ ...t, rites: t.rites + 1 }))
476 nowMinute = Math.max(nowMinute, minuteOf(await $.clock.now().catch(() => 0)))
477 const minute = nowMinute
478 const spirit = spiritName(e.tool)
479 const id = e.agentId
480 // A servitor's call is tagged with its type; an id no spawn recorded is one of the engine's own forks, no servitor.
481 const known = id === undefined ? undefined : (await read($, agents))[id]
482 const agent = id === undefined ? undefined : (known?.type ?? 'fork')
483 await update($, pulse, p => addPulse(p, minute))
484 if (known) await update($, servitorPulse, p => addPulse(p, minute))
485 await update($, spirits, s => ({ ...s, [spirit]: (s[spirit] ?? 0) + 1 }))
486 if (id !== undefined) await update($, agents, a => ranRite(a, id))
487 else await update($, tool, () => ({ name: e.tool, phrase: toolPhrase(e.tool, rites) }))
488 if (e.tool === 'Bash') await warn($, options, e.command)
489 const label = e.tool === 'Bash' ? e.command : e.tool === 'Read' || e.tool === 'Edit' || e.tool === 'Write' ? basename(e.file_path) : ''
490 const ran = await next(e)
491 isWaitAnnounced = false
492 await refreshUsage($)
493 if (e.tool === 'Bash' && /\bgit\b/.test(e.command)) void refreshBranch($).catch(() => {})
494 if (ran.deny === undefined && ran.isError === true) await failed($, options)
495 if (id === undefined) await update($, tool, () => null)
496 if (agent === undefined || (await enabled($, options, 'sublog'))) {
497 const entry = { tool: e.tool, label, ok: ran.deny === undefined && ran.isError !== true }
498 await update($, riteLog, l => pushLog(l, agent === undefined ? entry : { ...entry, agent }, LOG_MAX))
499 }
500 return ran
501 }).catch(($, e, next) => next(e))
502
503 // A servitor is recorded once the engine started it: a denied spawn answers no id.
504 on('agent.spawn', async ($, e, next) => {
505 const r = await next(e)
506 const id = r.agentId
507 // A teammate in a terminal pane of its own runs no loop here (its id is its address): no event of it ever ends it.
508 const isElsewhere = r.teammateId !== undefined && id === r.teammateId
509 if (id !== undefined && !isElsewhere) {
510 const at = await $.clock.now().catch(() => 0)
511 await update($, agents, a => spawned(a, id, e, at))
512 }
513 return r
514 }).catch(($, e, next) => next(e))
515
516 on('turn.start', async ($, e, next) => {
517 isWaitAnnounced = false
518 const id = e.agentId
519 if (id !== undefined) {
520 const at = await $.clock.now().catch(() => 0)
521 await update($, agents, a => turnStarted(a, id, at))
522 }
523 if (!e.agentId) {
524 isWorking = true
525 // ponytail: auto-dock waits for the first turn, when the terminal has said whether it is fullscreen.
526 if (isDockable && (await read($, panelOpen)) === null && (await enabled($, options, 'panel'))) await openPanel($, options)
527 await update($, law, n => n + 1)
528 await update($, heresyLine, () => null)
529 await update($, failedThisTurn, () => false)
530 }
531 return next(e)
532 })
533
534 // Render hooks skin the terminal only; the desktop's spinner words carry real information.
535 on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
536 if (e.surface !== 'terminal' || e.props.message !== null || !(await enabled($, options, 'spinner'))) return next(e)
537 const word = spinnerWord(e.props.mode, await read($, tool), await read($, law))
538 return next({ ...e, props: { ...e.props, word } })
539 })
540
541 on('ui.render', { component: 'TurnDuration' }, async ($, e, next) => {
542 if (e.surface !== 'terminal' || !(await enabled($, options, 'turnline'))) return next(e)
543 const { Text } = $.ui.resolve(e)
544 return <Text dimColor>{turnLine(e.props.durationMs)}</Text>
545 })
546
547 on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
548 if (e.surface !== 'terminal' || !(await enabled($, options, 'hint'))) return next(e)
549 return next({ ...e, props: { ...e.props, tail: ` ${lawText(await read($, law))}` } })
550 })
551
552 on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
553 if (e.surface !== 'terminal' || !anyOn(options, await read($, overrides), await currentVoice($, options))) return next(e)
554 return next({ ...e, props: { ...e.props, modes: [...e.props.modes, '⚙ litany'] } })
555 })
556
557 on('session.measure', async ($, e, next) => {
558 await update($, telemetry, t => withUsage(t, e))
559 return next(e)
560 })
561
562 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
563 if (e.viewport?.isFullscreen !== undefined) isDockable = e.viewport.isFullscreen
564 if (e.props.hasSurvey || !(await enabled($, options, 'band'))) return next(e)
565 const segments = fitBand(bandSegments(await read($, telemetry)), e.props.bodyColumns)
566 const theme = await currentTheme($, options)
567 const line = await read($, heresyLine)
568 const { Box, Text } = $.ui.resolve(e)
569 return (
570 <Box flexDirection="column">
571 <Text>
572 <Text color={theme.text.head} bold>{BAND_HEAD}</Text>
573 {segments.map(s => (
574 <Text key={s.text} {...(s.color ? { color: theme.text[BAND_TONE[s.color]] } : { dimColor: true })}>{SEP + s.text}</Text>
575 ))}
576 </Text>
577 {line && e.props.maxRows > 1 && <Text color={theme.text.error}>{line.slice(0, e.props.bodyColumns)}</Text>}
578 </Box>
579 )
580 })
581
582 on('prompt.compose', async ($, e, next) => {
583 const composed = await next(e)
584 // Chat a person reads, at a terminal or in the desktop app (an SDK host itself): other -p output, SDK hosts and teammates may be parsed by something else.
585 const isHosted = e.surfaces.includes('desktop') || (e.surfaces.includes('terminal') && !e.traits.some(t => t === 'print' || t === 'sdk-preset'))
586 const isChat = isHosted && !e.traits.some(t => t === 'bare' || t === 'teammate')
587 if (!isChat || !(await enabled($, options, 'voice'))) return composed
588 return { sections: [...composed.sections, { id: 'litany-voice', text: VOICE, scope: 'session' }] }
589 })
590
591 on('turn.complete', async ($, e, next) => {
592 const id = e.agentId
593 if (id !== undefined) {
594 const at = await $.clock.now().catch(() => 0)
595 await update($, agents, a => turnEnded(a, id, at, e.reason === 'answer'))
596 }
597 isWaitAnnounced = false
598 // An answer alone is a rite done: a long turn that died is severed, a refusal or an abort says nothing.
599 if (!e.agentId && e.reason === 'answer' && e.durationMs >= 30_000) void sound($, options, 'rite-done')
600 if (!e.agentId && e.reason === 'error') void sound($, options, 'severed')
601 if (!e.agentId) {
602 isWorking = false
603 // isWorking is not state, so nothing else redraws the pane's lamp.
604 $.ui.invalidate('ui.render')
605 }
606 return next(e)
607 })
608
609 // Claude waits on the person: a permission prompt that has waited, an MCP form or URL, a servitor's question.
610 on('classic.Notification', async ($, e, next) => {
611 const event = WAIT_SOUNDS.get(e.notification_type)
612 if (event !== undefined && !isWaitAnnounced) {
613 isWaitAnnounced = true
614 void sound($, options, event)
615 }
616 return next(e)
617 }).catch(($, e, next) => next(e))
618
619 // The engine's own compaction of the main conversation; one the person typed, or a subagent's, stays quiet.
620 on('session.compact', async ($, e, next) => {
621 const compacted = await next(e)
622 if (e.trigger === 'auto' && e.agentId === undefined) void sound($, options, 'engrams')
623 return compacted
624 }).catch(($, e, next) => next(e))
625
626 on('session.end', async ($, e, next) => {
627 await removeVoxDir($).catch(() => {})
628 return next(e)
629 })
630
631 on('ui.close', { id: PANE }, async ($, e, next) => {
632 stopPanel()
633 await update($, panelOpen, () => false)
634 return next(e)
635 })
636
637 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
638 if (!(await enabled($, options, 'panel'))) return next(e)
639 isDrawn = true
640 const theme = await currentTheme($, options)
641 const cols = e.props.bodyColumns
642 const rows = e.props.scroll.bodyRows
643 const inner = Math.max(1, cols - 2)
644 const art = await pulseArt($, options, theme, inner)
645 const layout = slate(
646 {
647 telemetry: await read($, telemetry),
648 rites: await read($, riteLog),
649 heresies: await read($, heresyLog),
650 spirits: await read($, spirits),
651 agents: await read($, agents),
652 now: await $.clock.now().catch(() => 0),
653 law: await read($, law),
654 isWorking,
655 isPulseSplit: art.isSplit,
656 show: {
657 pulse: await enabled($, options, 'pulse'),
658 spirits: await enabled($, options, 'spirits'),
659 roster: await enabled($, options, 'roster'),
660 sublog: await enabled($, options, 'sublog'),
661 },
662 },
663 cols,
664 rows,
665 )
666 shown.set(e.surface, {
667 inner,
668 isHero: layout.some(r => r.kind === 'art'),
669 isPulse: layout.some(r => r.kind === 'pulse'),
670 isRoster: layout.some(r => r.kind === 'text' && r.segs[0]?.text === ROSTER_HEAD),
671 isPulseSplit: art.isSplit,
672 })
673 if (e.surface !== 'terminal') {
674 // The same rows as the terminal's, as one terminal-looking Svg.
675 const lines = layout.flatMap(r =>
676 r.kind === 'art' ? cellRuns(heroCells(theme, inner, tick, isWorking, tick - openedAt), inner, r.rows) : r.kind === 'pulse' ? cellRuns(art.cells, inner, r.rows) : [segRuns(theme, r.segs)],
677 )
678 const { Svg } = $.ui.resolve(e)
679 return <Svg source={paneSvg(lines, cols, rows)} alt="NOOSPHERE: the Litany telemetry panel" />
680 }
681 const color = (tone: TextTone) => (theme.text[tone] ? { color: theme.text[tone] } : {})
682 const { Box, Text, Raster } = $.ui.resolve(e)
683 return (
684 <Box flexDirection="column" height={rows} paddingX={1}>
685 {layout.map((r, i) =>
686 r.kind === 'art' ? (
687 <Raster key="art" columns={inner} rows={r.rows} cells={heroCells(theme, inner, tick, isWorking, tick - openedAt)} />
688 ) : r.kind === 'pulse' ? (
689 <Raster key="pulse" columns={inner} rows={r.rows} cells={art.cells} />
690 ) : (
691 <Text key={String(i)} wrap="truncate-end">
692 {r.segs.length
693 ? r.segs.map((s, j) => (
694 <Text key={String(j)} bold={s.bold === true} {...color(s.tone)}>
695 {s.text}
696 </Text>
697 ))
698 : ' '}
699 </Text>
700 ),
701 )}
702 </Box>
703 )
704 })
705}
706hooks/lore.ts 357 lines1import type { SessionUsage } from 'claude-code'
2
3import type { ActiveTool, Overrides, SoundEvent, Switch, Telemetry, Volumes, VoxSetting } from '../types'
4import { isThemeName, THEME_NAMES, type ThemeName } from './themes'
5import { tokenize } from './vox/lexicon'
6import { VOX_LINES } from './vox/lines'
7import { VOICE_NAMES, type VoiceName } from './vox/voices'
8
9export const SWITCHES: readonly Switch[] = ['spinner', 'turnline', 'hint', 'band', 'voice', 'rites', 'sound', 'panel', 'pulse', 'spirits', 'roster', 'sublog', 'split']
10
11// `register`'s options: the userConfig values, defaults filled in.
12export type Defaults = Readonly<Record<string, unknown>>
13
14export function isOn(name: Switch, defaults: Defaults, o: Overrides): boolean {
15 if (o.master !== null) return o.master
16 return o.switches[name] ?? defaults[name] !== false
17}
18
19// Sections of the panel, not layers: with the eight layers off the mod is silent.
20export const PANEL_ONLY: readonly Switch[] = ['pulse', 'spirits', 'roster', 'sublog', 'split']
21// A speaking voice keeps the footer too, so vox alone still shows the mod is awake.
22export const anyOn = (defaults: Defaults, o: Overrides, vox: VoiceName | null) =>
23 vox !== null || SWITCHES.some(s => !PANEL_ONLY.includes(s) && isOn(s, defaults, o))
24
25// Pause between a clip and its spoken line.
26export const PAUSE_MS = 350
27
28const VOX_SETTINGS: readonly VoxSetting[] = ['default', ...VOICE_NAMES, 'off']
29const isVoxSetting = (v: unknown): v is VoxSetting => VOX_SETTINGS.includes(v as VoxSetting)
30const VOLUME_KEYS: readonly (keyof Volumes)[] = ['master', 'beeps', 'vox']
31
32// Session override, then the config picker; anything else reads as default.
33export const voxSetting = (defaults: Defaults, override: VoxSetting | null): VoxSetting => override ?? (isVoxSetting(defaults.vox) ? defaults.vox : 'default')
34
35// Master off is silent.
36export function resolveVox(defaults: Defaults, override: VoxSetting | null, theme: ThemeName, master: boolean | null): VoiceName | null {
37 if (master === false) return null
38 const setting = voxSetting(defaults, override)
39 if (setting === 'off') return null
40 return setting === 'default' ? theme : setting
41}
42
43// Only the strings "0"…"10" count: the picker stores strings, anything hand-edited falls back to full.
44const storedVolume = (v: unknown) => (typeof v === 'string' && /^(10|[0-9])$/.test(v) ? Number(v) : 10)
45
46export function resolveVolumes(defaults: Defaults, override: Partial<Volumes>): Volumes {
47 return {
48 master: override.master ?? storedVolume(defaults.volumeMaster),
49 beeps: override.beeps ?? storedVolume(defaults.volumeBeeps),
50 vox: override.vox ?? storedVolume(defaults.volumeVox),
51 }
52}
53
54// Perceived loudness is steep, so the two levels multiply and the product is squared into amplitude.
55export const volumeGain = (master: number, part: number) => ((master / 10) * (part / 10)) ** 2
56
57// r in [0, 1). Drawing from the pool without `last` keeps one line from repeating back to back.
58export function pickLine(event: SoundEvent, last: string | undefined, r: number): string {
59 const pool = VOX_LINES[event]
60 const rest = pool.length > 1 ? pool.filter(l => l !== last) : pool
61 return rest[Math.floor(r * rest.length)]!
62}
63
64export type Command =
65 | { kind: 'status' }
66 | { kind: 'all'; on: boolean }
67 | { kind: 'switch'; name: Switch; on: boolean }
68 | { kind: 'theme'; name: ThemeName }
69 | { kind: 'vox'; setting: VoxSetting }
70 | { kind: 'volume'; which: keyof Volumes; level: number }
71 | { kind: 'volumes' }
72 | { kind: 'panel' }
73 | { kind: 'say'; voice: VoiceName | null; text: string; isCut: boolean }
74 | { kind: 'error'; text: string }
75
76const USAGE = `Usage: /litany [on|off] or /litany <${SWITCHES.join('|')}> on|off or /litany theme <${THEME_NAMES.join('|')}> or /litany vox <${VOX_SETTINGS.join('|')}|on> or /litany volume [<${VOLUME_KEYS.join('|')}> 0-10] or /litany panel or /litany say [${VOICE_NAMES.join('|')}] <text>`
77
78export const SAY_MAX = 200
79
80// One pair of surrounding quotes off, spaces collapsed, printable ASCII only (the synth reads English), a run of marks down to its first (each mark is a pause); long text is cut at a word.
81export function sayText(raw: string): { text: string; isCut: boolean } | null {
82 let t = raw.trim()
83 if (t.length >= 2 && (t[0] === '"' || t[0] === "'") && t.at(-1) === t[0]) t = t.slice(1, -1)
84 t = t.replace(/[^\x20-\x7e\s]/g, '').replace(/\s+/g, ' ').replace(/([.,;:!?])[.,;:!?]+/g, '$1').trim()
85 if (t === '') return null
86 if (t.length <= SAY_MAX) return { text: t, isCut: false }
87 const space = t.lastIndexOf(' ', SAY_MAX)
88 return { text: t.slice(0, space > 0 ? space : SAY_MAX).trimEnd(), isCut: true }
89}
90
91// Whether the synth would say a word of it, not just pauses.
92export const hasWords = (text: string) => tokenize(text).some(t => 'word' in t && /[a-z0-9]/.test(t.word))
93
94export function parseCommand(args: string): Command {
95 // say keeps the text's case, so it reads the raw args before the lowercasing below.
96 const say = /^\s*say(?:\s+(.*))?$/is.exec(args)
97 if (say) {
98 let rest = say[1] ?? ''
99 const first = /^(\S+)(?:\s+(.*))?$/s.exec(rest)
100 const named = VOICE_NAMES.find(v => v === first?.[1]?.toLowerCase())
101 if (named) rest = first?.[2] ?? ''
102 const spoken = sayText(rest)
103 return spoken ? { kind: 'say', voice: named ?? null, ...spoken } : { kind: 'error', text: USAGE }
104 }
105 const words = args.trim().toLowerCase().split(/\s+/).filter(Boolean)
106 const [a, b] = words
107 if (words.length === 0) return { kind: 'status' }
108 if (words.length === 1 && (a === 'on' || a === 'off')) return { kind: 'all', on: a === 'on' }
109 if (words.length === 2 && SWITCHES.includes(a as Switch) && (b === 'on' || b === 'off'))
110 return { kind: 'switch', name: a as Switch, on: b === 'on' }
111 if (words.length === 1 && a === 'panel') return { kind: 'panel' }
112 if (words.length === 2 && a === 'theme' && isThemeName(b)) return { kind: 'theme', name: b }
113 if (words.length === 2 && a === 'vox' && b === 'on') return { kind: 'vox', setting: 'default' }
114 if (words.length === 2 && a === 'vox' && isVoxSetting(b)) return { kind: 'vox', setting: b }
115 if (words.length === 1 && a === 'volume') return { kind: 'volumes' }
116 if (words.length === 3 && a === 'volume' && VOLUME_KEYS.includes(b as keyof Volumes) && /^(10|[0-9])$/.test(words[2]!))
117 return { kind: 'volume', which: b as keyof Volumes, level: Number(words[2]) }
118 return { kind: 'error', text: USAGE }
119}
120
121// A switch command after /litany on|off keeps every other switch where the master put it.
122export function applyCommand(o: Overrides, cmd: Command): Overrides {
123 if (cmd.kind === 'all') return { master: cmd.on, switches: {} }
124 if (cmd.kind !== 'switch') return o
125 const base = o.master === null ? o.switches : Object.fromEntries(SWITCHES.map(s => [s, o.master]))
126 return { master: null, switches: { ...base, [cmd.name]: cmd.on } }
127}
128
129export const LAWS: readonly string[] = [
130 'Life is directed motion.',
131 'The spirit is the spark of life.',
132 'Sentience is the ability to learn the value of knowledge.',
133 'Intellect is the understanding of knowledge.',
134 'Sentience is the basest form of Intellect.',
135 'Understanding is the True Path to Comprehension.',
136 'Comprehension is the key to all things.',
137 'The Omnissiah knows all, comprehends all.',
138 'The alien mechanism is a perversion of the true path.',
139 'The soul is the conscience of sentience.',
140 'A soul can be bestowed only by the Omnissiah.',
141 'The soulless sentience is the enemy of all.',
142 'The knowledge of the ancients stands beyond question.',
143 'The machine spirit guards the knowledge of the ancients.',
144 'Flesh is fallible, but ritual honours the machine spirit.',
145 'To break with ritual is to break with faith.',
146]
147const ROMAN = ['I', 'II', 'III', 'IV', 'V', 'VI', 'VII', 'VIII', 'IX', 'X', 'XI', 'XII', 'XIII', 'XIV', 'XV', 'XVI']
148
149export const lawText = (i: number) => `Law ${ROMAN[i % 16]!}: ${LAWS[i % 16]!}`
150export const lawNumeral = (i: number) => ROMAN[i % 16]!
151
152export function statusText(
153 defaults: Defaults,
154 o: Overrides,
155 law: number,
156 theme: { name: ThemeName; isSession: boolean },
157 vox: { setting: VoxSetting; voice: VoiceName | null; isSession: boolean },
158 volumes: { now: Volumes; session: Partial<Volumes> },
159): string {
160 const rows = SWITCHES.map(s => {
161 const isSession = o.master !== null || o.switches[s] !== undefined
162 return `${s.padEnd(10)}${isOn(s, defaults, o) ? 'on' : 'off'}${isSession ? ' (this session)' : ''}`
163 })
164 rows.push(`${'theme'.padEnd(10)}${theme.name}${theme.isSession ? ' (this session)' : ''}`)
165 const { master, beeps, vox: v } = volumes.now
166 // What would speak: a silenced master, or a master or vox volume of 0, reads silent, not the voice it would have been.
167 const isSilent = vox.voice === null || volumeGain(master, v) === 0
168 const named = vox.setting === 'off' ? 'off' : vox.setting === 'default' ? `default (${isSilent ? 'silent' : vox.voice})` : `${vox.setting}${isSilent ? ' (silent)' : ''}`
169 rows.push(`${'vox'.padEnd(10)}${named}${vox.isSession ? ' (this session)' : ''}`)
170 rows.push(`${'volume'.padEnd(10)}master ${master} beeps ${beeps} vox ${v}${Object.keys(volumes.session).length ? ' (this session)' : ''}`)
171 return ['+++ Litany +++', ...rows, '', lawText(law)].join('\n')
172}
173
174// The three levels and what they come to: a part's share is master x part.
175export function volumesText(volumes: { now: Volumes; session: Partial<Volumes> }): string {
176 const { now, session } = volumes
177 const mark = (k: keyof Volumes) => (session[k] !== undefined ? ' (this session)' : '')
178 const share = (k: 'beeps' | 'vox', what: string) =>
179 `${k.padEnd(10)}${String(now[k]).padEnd(6)}${what} at ${Math.round((now.master / 10) * (now[k] / 10) * 100)} %${mark(k)}`
180 return ['+++ Litany volume +++', `${'master'.padEnd(10)}${now.master}${mark('master')}`, share('beeps', 'clips'), share('vox', 'lines')].join('\n')
181}
182
183// Session override, then the config picker; anything else (a typo, a theme of another version) is mars.
184export function resolveTheme(defaults: Defaults, override: string | null): ThemeName {
185 if (isThemeName(override)) return override
186 return isThemeName(defaults.theme) ? defaults.theme : 'mars'
187}
188
189export const pushLog = <T>(log: readonly T[], entry: T, max: number): T[] => [...log, entry].slice(-max)
190
191// A backslash separates only in a Windows path (a drive, a UNC share or a rooted path), with either slash after the drive; elsewhere it is a legal character of a name.
192export const basename = (path: string) => path.slice(Math.max(path.lastIndexOf('/'), /^([A-Za-z]:|\\)/.test(path) ? path.lastIndexOf('\\') : -1) + 1)
193
194export type SpinnerMode = 'requesting' | 'responding' | 'thinking' | 'tool-input' | 'tool-use'
195
196const THINKING = ['Consulting the Noosphere', 'Communing with the Machine Spirit', "Parsing the Omnissiah's will"]
197const RESPONDING = ['Transcribing binharic canticles', 'Inscribing the data-scroll']
198const BASH = ['Anointing the cogitator', 'Intoning the Litany of Ignition']
199const READ = ['Sifting the Librarium data-stacks', 'Consulting the archives']
200const EDIT = ['Inscribing the sacred code-scrolls', 'Rewriting the rites']
201const AGENT = ['Dispatching a servitor', 'Awakening a skitarius']
202const WEB = ['Querying the astropathic relay']
203
204const from = (pool: readonly string[], n: number) => pool[n % pool.length]!
205
206function poolFor(tool: string): readonly string[] | null {
207 if (tool === 'Bash') return BASH
208 if (tool === 'Read' || tool === 'Grep' || tool === 'Glob') return READ
209 if (tool === 'Edit' || tool === 'Write' || tool === 'NotebookEdit') return EDIT
210 if (tool === 'Agent') return AGENT
211 if (tool === 'WebSearch' || tool === 'WebFetch' || tool.startsWith('mcp__parallel__')) return WEB
212 return null
213}
214
215// n is a counter the caller already keeps (tool calls, turns), so a phrase holds still between redraws.
216export function toolPhrase(tool: string, n: number): string {
217 const pool = poolFor(tool)
218 if (pool) return from(pool, n)
219 const server = /^mcp__(.+?)__/.exec(tool)?.[1]
220 return server ? `Petitioning the forge-shrine ${server}` : from(THINKING, n)
221}
222
223export function spinnerWord(mode: SpinnerMode, tool: ActiveTool | null, n: number): string {
224 if ((mode === 'tool-use' || mode === 'tool-input') && tool) return tool.phrase
225 return from(mode === 'responding' ? RESPONDING : THINKING, n)
226}
227
228export function formatDuration(ms: number): string {
229 const s = Math.round(ms / 1000)
230 if (s < 60) return `${s}s`
231 const m = Math.floor(s / 60)
232 return m < 60 ? `${m}m ${s % 60}s` : `${Math.floor(m / 60)}h ${m % 60}m`
233}
234
235const TURN_LINES = [
236 (d: string) => `Rite concluded in ${d}`,
237 (d: string) => `Canticle sung for ${d}`,
238 (d: string) => `Machine Spirit appeased in ${d}`,
239]
240
241// Picked by the duration itself, so one line never changes wording when it redraws.
242export const turnLine = (ms: number) => `⚙ ${TURN_LINES[Math.floor(ms / 1000) % 3]!(formatDuration(ms))}`
243
244export type Heresy = { rite: string; law: number }
245
246const PIPE_TO_SHELL = /\b(curl|wget)\b[^|;&]*\|\s*(sudo\s+)?(ba|z)?sh\b/
247// ponytail: TRUNCATE only with TABLE, so `rg truncate src` (a Tailwind class) stays quiet; bare `TRUNCATE users` is missed.
248const SQL_DESTROY = /\b(drop\s+(table|database|schema)|truncate\s+table)\b/i
249// SQL counts only where a SQL client runs it, so `rg "DROP TABLE" src/` and commit messages stay quiet.
250const SQL_CLIENT = /\b(psql|mysql|mariadb|sqlite3|duckdb|clickhouse-client)\b/
251// Quoted text is words, not a pipeline: `git commit -m "revert curl | sh"` is no heresy.
252const unquoted = (command: string) => command.replace(/'[^']*'|"[^"]*"/g, '""')
253
254// Short flag letters of a command, so -rf, -fr and -r -f all read as "rf".
255const shortFlags = (words: string[]) => words.filter(w => /^-[a-zA-Z]+$/.test(w)).join('')
256
257// ponytail: one shell segment at a time, sudo skipped; `bash -c "…"` and `git -C dir push -f` are not unwrapped.
258function segmentRite(segment: string): string | null {
259 const words = segment.trim().split(/\s+/).filter(w => w && w !== 'sudo')
260 const [cmd = '', sub] = words
261 const flags = shortFlags(words)
262 const has = (w: string) => words.includes(w)
263 if (cmd === 'rm' && (/r/i.test(flags) || has('--recursive')) && (flags.includes('f') || has('--force'))) return 'rm -rf'
264 if (cmd === 'git' && sub === 'push' && (has('--force') || flags.includes('f'))) return 'git push --force'
265 if (cmd === 'git' && sub === 'reset' && has('--hard')) return 'git reset --hard'
266 if (cmd === 'git' && sub === 'clean' && flags.includes('f') && flags.includes('d')) return 'git clean -fd'
267 if (cmd === 'chmod' && flags.includes('R') && has('777')) return 'chmod -R 777'
268 if (/^mkfs(\.\w+)?$/.test(cmd)) return 'mkfs'
269 if (cmd === 'dd' && words.some(w => w.startsWith('of=/dev/') && !/^of=\/dev\/(null|zero|stdout|stderr|full)$/.test(w))) return 'dd of=/dev/'
270 return null
271}
272
273export function matchHeresy(command: string): Heresy | null {
274 if (PIPE_TO_SHELL.test(unquoted(command))) return { rite: 'curl | sh', law: 8 }
275 if (SQL_CLIENT.test(command) && SQL_DESTROY.test(command)) return { rite: 'DROP / TRUNCATE', law: 13 }
276 for (const segment of command.split(/&&|\|\||;|\n/)) {
277 const rite = segmentRite(segment)
278 if (rite) return { rite, law: 14 }
279 }
280 return null
281}
282
283export const heresyText = (h: Heresy) => `TECH-HERESY: ${h.rite} — ${lawText(h.law)}`
284
285export type Tone = 'success' | 'warning' | 'error'
286export type Segment = { text: string; color?: Tone }
287
288export const contextColor = (pct: number): Tone => (pct < 60 ? 'success' : pct < 85 ? 'warning' : 'error')
289
290// The engine's figures over the last ones: a unit it has no reading for keeps the old one.
291export function withUsage(t: Telemetry, u: Pick<SessionUsage, 'context' | 'rateLimits' | 'cost'>): Telemetry {
292 const fiveHour = u.rateLimits.find(r => r.kind === 'five_hour')
293 return {
294 ...t,
295 contextPct: u.context.percent ?? t.contextPct,
296 costUsd: u.cost?.usd ?? t.costUsd,
297 fiveHourPct: fiveHour?.percentUsed ?? t.fiveHourPct,
298 }
299}
300
301// A reading the engine has not reported yet is left out, never shown as zero.
302export function bandSegments(t: Telemetry): Segment[] {
303 const segs: Segment[] = []
304 if (t.contextPct !== undefined) segs.push({ text: `cogitator ${Math.round(t.contextPct)}%`, color: contextColor(t.contextPct) })
305 segs.push({ text: `rites ${t.rites}${t.failed ? ` · ${t.failed} failed` : ''}` })
306 if (t.branch) segs.push({ text: `forge ${t.branch}` })
307 if (t.costUsd !== undefined) segs.push({ text: `tithe $${t.costUsd.toFixed(2)}` })
308 if (t.fiveHourPct !== undefined) segs.push({ text: `5h ${Math.round(t.fiveHourPct)}%`, color: contextColor(t.fiveHourPct) })
309 segs.push({ text: `heresy ${t.heresies}`, ...(t.heresies ? { color: 'error' as const } : {}) })
310 return segs
311}
312
313export const BAND_HEAD = '⚙ NOOSPHERE'
314export const SEP = ' │ '
315
316// ponytail: width by UTF-16 length; every glyph used here is one cell wide.
317export function fitBand(segments: Segment[], columns: number): Segment[] {
318 if (BAND_HEAD.length > columns) return []
319 const out = [...segments]
320 while (out.length && [BAND_HEAD, ...out.map(s => s.text)].join(SEP).length > columns) out.pop()
321 return out
322}
323
324export const VOICE = [
325 '# Mechanicus voice',
326 '',
327 'The user turned on a cosmetic Adeptus Mechanicus (Warhammer 40k) theme for this chat. Follow these rules:',
328 '',
329 '- In conversational replies, open with one short line in the form `+++ … +++` (for example `+++ Query received. The Machine Spirit stirs. +++`) and close with one (for example `+++ Praise the Omnissiah. Rite concluded. +++`). Between them, write exactly as you would without the theme: the same precision, structure and length. Use a lore term (Machine Spirit, cogitator, Omnissiah, rite) only occasionally, and never where it makes a technical statement less clear.',
330 '- Never apply the theme to code, code comments, commit messages, file contents, tool inputs or terminal commands.',
331 '- When the main content of a reply is text the user will send or publish to someone else (an email, Slack or Teams message, Linear or Jira ticket, document, translation, or wording options), do not add the opener or the closer anywhere in that reply.',
332 "- Write the opener and the closer in the language of the user's message, whatever language that is.",
333].join('\n')
334
335export function ignitionText(forge?: string, branch?: string): string {
336 const where = forge ? ` Forge: ${forge}${branch ? ` @ ${branch}` : ''}.` : ''
337 return `+++ Litany of Ignition +++ Cogitator awakened.${where} The Omnissiah protects.`
338}
339
340export function hhmm(at: number): string {
341 const d = new Date(at)
342 return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
343}
344
345export function wrap(text: string, width: number): string[] {
346 const lines: string[] = []
347 let line = ''
348 for (const word of text.split(' ')) {
349 if (line && line.length + 1 + word.length > width) {
350 lines.push(line)
351 line = word
352 } else line = line ? `${line} ${word}` : word
353 }
354 if (line) lines.push(line)
355 return lines.map(l => l.slice(0, width))
356}
357hooks/servitors.ts 64 lines1import type { Servitor } from '../types'
2
3// The servitors: every agent other than the main loop, kept by agent id. Pure; register.tsx feeds it from the events.
4
5export const KEEP_FINISHED = 30
6export const LINGER_MS = 60_000
7export const ROSTER_MAX = 5
8
9export type Agents = Readonly<Record<string, Servitor>>
10type Spawn = { subagentType: string; description: string; prompt: string; workflow?: unknown }
11
12const firstWords = (text: string) => text.replace(/\s+/g, ' ').trim().split(' ').slice(0, 6).join(' ')
13
14// Live agents stay; finished ones beyond the newest KEEP_FINISHED go.
15function trim(agents: Record<string, Servitor>): Record<string, Servitor> {
16 const done = Object.entries(agents).filter(([, s]) => s.endedAt !== undefined)
17 if (done.length <= KEEP_FINISHED) return agents
18 const drop = new Set(done.sort((a, b) => b[1].endedAt! - a[1].endedAt!).slice(KEEP_FINISHED).map(([id]) => id))
19 return Object.fromEntries(Object.entries(agents).filter(([id]) => !drop.has(id)))
20}
21
22// A new agent, or a resumed one going live again. The engine ignores a workflow agent's description, so its prompt stands in.
23export function spawned(agents: Agents, id: string, e: Spawn, at: number): Record<string, Servitor> {
24 const isWorkflow = e.workflow !== undefined
25 const prev = agents[id]
26 const type = prev?.type ?? (isWorkflow ? 'workflow' : e.subagentType || 'agent')
27 const description = prev?.description || (isWorkflow ? '' : e.description) || firstWords(e.prompt)
28 return trim({ ...agents, [id]: { type, description, startedAt: at, rites: prev?.rites ?? 0 } })
29}
30
31export function ranRite(agents: Agents, id: string): Agents {
32 const s = agents[id]
33 return s ? { ...agents, [id]: { ...s, rites: s.rites + 1 } } : agents
34}
35
36// A resumed subagent or a teammate that woke: live again, its clock restarted.
37export function turnStarted(agents: Agents, id: string, at: number): Agents {
38 const s = agents[id]
39 if (!s) return agents
40 const { type, description, rites } = s
41 return { ...agents, [id]: { type, description, rites, startedAt: at } }
42}
43
44export function turnEnded(agents: Agents, id: string, at: number, ok: boolean): Agents {
45 const s = agents[id]
46 return s ? trim({ ...agents, [id]: { ...s, endedAt: at, ok } }) : agents
47}
48
49// What the roster lists at `now`: live agents oldest first, then those finished within LINGER_MS, newest first.
50export function roster(agents: Agents, now: number): Servitor[] {
51 const all = Object.values(agents)
52 const live = all.filter(s => s.endedAt === undefined).sort((a, b) => a.startedAt - b.startedAt)
53 const done = all.filter(s => s.endedAt !== undefined && now - s.endedAt < LINGER_MS).sort((a, b) => b.endedAt! - a.endedAt!)
54 return [...live, ...done]
55}
56
57export function elapsed(ms: number): string {
58 const s = Math.max(0, Math.floor(ms / 1000))
59 const h = Math.floor(s / 3600)
60 const m = Math.floor((s % 3600) / 60)
61 const ss = String(s % 60).padStart(2, '0')
62 return h ? `${h}:${String(m).padStart(2, '0')}:${ss}` : `${m}:${ss}`
63}
64hooks/slate.ts 219 lines1import type { HeresyEntry, Pulse, RiteEntry, Servitor, Telemetry } from '../types'
2import { contextColor, hhmm, lawNumeral, LAWS, wrap } from './lore'
3import { type Agents, elapsed, roster, ROSTER_MAX } from './servitors'
4import { ART_COLS, ART_ROWS, PULSE_ROWS, type TextTone } from './themes'
5
6// The Dataslate: the panel's rows for a pane `cols × rows`, top to bottom. Pure; the Pane render maps them onto elements.
7
8export const LOG_MAX = 30
9// Two minutes per raster cell, and a Raster is at most 512 columns: the widest pane's pulse fills edge to edge.
10export const PULSE_MINUTES = 1024
11const LOG_MIN = 3
12
13export type Seg = { text: string; tone: TextTone; bold?: boolean }
14export type SlateRow = { kind: 'text'; segs: Seg[] } | { kind: 'art'; rows: number } | { kind: 'pulse'; rows: number }
15export type SlateData = {
16 telemetry: Telemetry
17 rites: readonly RiteEntry[]
18 heresies: readonly HeresyEntry[]
19 spirits: Readonly<Record<string, number>>
20 agents: Agents
21 // ms on $.clock: the roster's times and which finished rows still show.
22 now: number
23 law: number
24 isWorking: boolean
25 // The pulse raster is mirrored (split on and a servitor rite in view): the note says so.
26 isPulseSplit: boolean
27 // sublog false: servitor rows already in the log are hidden too; absent counts as on.
28 show: { pulse: boolean; spirits: boolean; roster: boolean; sublog?: boolean }
29}
30
31export const ROSTER_HEAD = 'SERVITORS'
32
33const TONE = { success: 'ok', warning: 'warn', error: 'error' } as const
34const BLANK: SlateRow = { kind: 'text', segs: [] }
35const row = (...segs: Seg[]): SlateRow => ({ kind: 'text', segs })
36const seg = (text: string, tone: TextTone, bold = false): Seg => (bold ? { text, tone, bold } : { text, tone })
37const len = (segs: readonly Seg[]) => segs.reduce((n, s) => n + s.text.length, 0)
38
39export const height = (rows: readonly SlateRow[]) => rows.reduce((n, r) => n + (r.kind === 'text' ? 1 : r.rows), 0)
40
41// ponytail: width by UTF-16 length; a label with emoji or CJK can still overrun its row. Count cells if that bites. Cuts a row to the width, ending in … when anything was cut; newlines read as spaces.
42export function fit(segs: readonly Seg[], width: number): Seg[] {
43 const flat = segs.map(s => ({ ...s, text: s.text.replace(/\s*\n\s*/g, ' ') }))
44 if (len(flat) <= width) return flat
45 const out: Seg[] = []
46 let left = width - 1
47 for (const s of flat) {
48 if (left <= 0) break
49 const text = s.text.slice(0, left)
50 out.push({ ...s, text })
51 left -= text.length
52 }
53 const last = out.at(-1)
54 if (last) out[out.length - 1] = { ...last, text: `${last.text}…` }
55 else out.push(seg('…', flat[0]?.tone ?? 'dim'))
56 return out
57}
58
59// Items two spaces apart, wrapped onto further lines rather than clipped.
60function flow(items: readonly Seg[][], width: number): Seg[][] {
61 const lines: Seg[][] = []
62 let line: Seg[] = []
63 for (const item of items) {
64 if (line.length && len(line) + 2 + len(item) > width) {
65 lines.push(line)
66 line = []
67 }
68 line = line.length ? [...line, seg(' ', 'dim'), ...item] : [...item]
69 }
70 if (line.length) lines.push(line)
71 return lines.map(l => fit(l, width))
72}
73
74// NAME ········ note: the note is dropped when the head would not fit.
75function head(name: string, note: string, width: number, tone: TextTone = 'head'): SlateRow {
76 const room = width - name.length - 1
77 const shown = note && room >= note.length + 2 ? note : ''
78 const dots = Math.max(0, room - (shown ? shown.length + 1 : 0))
79 return row(...fit([seg(name, tone, true), seg(` ${'·'.repeat(dots)}`, 'dim'), ...(shown ? [seg(` ${shown}`, 'dim')] : [])], width))
80}
81
82// Left and right parts of one row; the right part wins when the width runs out.
83function spread(left: Seg[], right: Seg[], width: number): SlateRow {
84 if (len(right) >= width) return row(...fit(right, width))
85 const gap = width - len(left) - len(right)
86 if (gap >= 1) return row(...left, seg(' '.repeat(gap), 'dim'), ...right)
87 return row(...fit([...left, seg(' ', 'dim')], width - len(right)), ...right)
88}
89
90function gauge(label: string, pct: number, width: number): SlateRow[] {
91 const tone = TONE[contextColor(pct)]
92 const n = Math.max(0, Math.min(width, Math.round((pct / 100) * width)))
93 return [spread([seg(label, 'head', true)], [seg(`${Math.round(pct)}%`, tone)], width), row(seg('▰'.repeat(n), tone), seg('▱'.repeat(width - n), 'dim'))]
94}
95
96export const spiritName = (tool: string) => /^mcp__(.+?)__/.exec(tool)?.[1] ?? tool
97
98function spiritLines(spirits: Readonly<Record<string, number>>, width: number): Seg[][] {
99 const tally = Object.entries(spirits).sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
100 const top = tally[0]?.[1] ?? 0
101 const items = tally.map(([name, n]) => [seg(name, 'body'), seg(` ${'▮'.repeat(Math.max(1, Math.round((5 * n) / top)))}`, 'head'), seg(` ${n}`, 'dim')])
102 return flow(items, width).slice(0, 3)
103}
104
105// A servitor's call sits under a ↳ with its type on the right; the type goes before the label is cut.
106function riteRow(r: RiteEntry, width: number): SlateRow {
107 const mark = [seg(r.ok ? '+' : '✗', r.ok ? 'ok' : 'error'), seg(` ${r.tool.padEnd(6)}`, r.ok ? 'body' : 'error'), seg(r.label, r.ok ? 'dim' : 'error')]
108 if (r.agent === undefined) return row(...fit([seg(' ', 'dim'), ...mark], width))
109 const left = fit([seg(' ', 'dim'), seg('↳ ', 'warn'), ...mark], width)
110 return len(left) + 1 + r.agent.length <= width ? spread(left, [seg(r.agent, 'warn')], width) : row(...left)
111}
112
113// ● type task ··· NN rites m:ss; a finished row is dim but for its ✓ or ✗.
114function rosterRow(s: Servitor, now: number, width: number): SlateRow {
115 const isLive = s.endedAt === undefined
116 const tone = (t: TextTone): TextTone => (isLive ? t : 'dim')
117 const lamp = isLive ? seg('●', 'warn') : s.ok ? seg('✓', 'ok') : seg('✗', 'error')
118 const type = s.type.length > 8 ? `${s.type.slice(0, 7)}…` : s.type
119 const left = [seg(' ', 'dim'), lamp, seg(` ${type.padEnd(9)}`, tone('body'))]
120 const right = [seg(String(s.rites).padStart(2), tone('body')), seg(' rites ', 'dim'), seg(elapsed((s.endedAt ?? now) - s.startedAt), tone('body'))]
121 const room = width - len(left) - len(right) - 1
122 if (room < 1) return spread(left, right, width)
123 return spread([...left, ...fit([seg(s.description, 'dim')], room)], right, width)
124}
125
126const heresyRow = (h: HeresyEntry, width: number) => row(...fit([seg(` ! ${hhmm(h.at)} ${h.command}`, 'error')], width))
127
128export function slate(d: SlateData, cols: number, rows: number): SlateRow[] {
129 const w = Math.max(1, cols - 2)
130 const t = d.telemetry
131 const forge = t.forge && t.branch ? `${t.forge} @ ${t.branch}` : (t.forge ?? t.branch ?? '')
132 const lamp = d.isWorking ? seg('● WORKING', 'warn') : seg('○ DORMANT', 'dim')
133 const header = spread(forge ? [seg(forge, 'head', true)] : [], [lamp], w)
134 const gauges = [...(t.contextPct !== undefined ? gauge('COGITATOR', t.contextPct, w) : []), ...(t.fiveHourPct !== undefined ? gauge('5H WINDOW', t.fiveHourPct, w) : [])]
135 const items: Seg[][] = []
136 if (t.costUsd !== undefined) items.push([seg(`$${t.costUsd.toFixed(2)}`, 'body'), seg(' tithe', 'dim')])
137 items.push([seg(String(d.law), 'body'), seg(' turns', 'dim')])
138 items.push([seg(String(t.rites), 'body'), seg(' rites', 'dim')])
139 if (t.failed) items.push([seg(String(t.failed), 'error'), seg(' failed', 'dim')])
140 const stats = flow(items, w).map(l => row(...l))
141 const top = (hero: boolean): SlateRow[] => [header, BLANK, ...(hero ? [{ kind: 'art', rows: ART_ROWS } as const, BLANK] : []), row(seg('─'.repeat(w), 'head')), ...gauges, ...stats, BLANK]
142 const pulse: SlateRow[] = [head('PULSE', d.isPulseSplit ? 'main ▲ servitors ▼' : 'rites / min', w), { kind: 'pulse', rows: PULSE_ROWS }, BLANK]
143 const tally = spiritLines(d.spirits, w - 2)
144 const spirits: SlateRow[] = tally.length ? [head('MACHINE SPIRITS', 'rites by tool', w), ...tally.map(l => row(...fit([seg(' ', 'dim'), ...l], w))), BLANK] : []
145 const listed = d.show.roster ? roster(d.agents, d.now) : []
146 const live = listed.filter(s => s.endedAt === undefined).length
147 const more = listed.length - ROSTER_MAX
148 const servitors: SlateRow[] = listed.length
149 ? [head(ROSTER_HEAD, `${live} live${more > 0 ? ` · +${more}` : ''}`, w), ...listed.slice(0, ROSTER_MAX).map(s => rosterRow(s, d.now, w)), BLANK]
150 : []
151 const heresy: SlateRow[] = d.heresies.length ? [head('HERESY', String(t.heresies), w, 'error'), ...d.heresies.map(h => heresyRow(h, w)), BLANK] : []
152 const law = [row(...fit([seg(`⚙ LAW ${lawNumeral(d.law)}`, 'head', true)], w)), ...wrap(LAWS[d.law % 16]!, w - 2).map(l => row(...fit([seg(` ${l}`, 'body')], w)))]
153 const bottom = [...heresy, ...law]
154
155 // Folding: spirits first, then servitors, then pulse, then the hero, while the log would get fewer than LOG_MIN rows.
156 let hasHero = w >= ART_COLS
157 let hasPulse = d.show.pulse
158 let hasSpirits = d.show.spirits
159 let hasRoster = servitors.length > 0
160 // The log's own head and the blank row under it are the 2.
161 const fixed = () =>
162 height(top(hasHero)) + (hasPulse ? height(pulse) : 0) + (hasSpirits ? height(spirits) : 0) + (hasRoster ? height(servitors) : 0) + height(bottom) + 2
163 if (fixed() + LOG_MIN > rows) hasSpirits = false
164 if (fixed() + LOG_MIN > rows) hasRoster = false
165 if (fixed() + LOG_MIN > rows) hasPulse = false
166 if (fixed() + LOG_MIN > rows) hasHero = false
167 const logRows = Math.max(0, rows - fixed())
168 const rites = d.show.sublog === false ? d.rites.filter(r => r.agent === undefined) : d.rites
169 const shown = logRows ? rites.slice(-logRows) : []
170 const log: SlateRow[] = [
171 head('RITES', shown.length ? `last ${shown.length}` : '', w),
172 ...shown.map(r => riteRow(r, w)),
173 ...Array.from({ length: logRows - shown.length }, () => BLANK),
174 BLANK,
175 ]
176 // A pane too small for the fixed rows keeps what fits from the top (the rasters have folded by then).
177 const out: SlateRow[] = []
178 let used = 0
179 for (const r of [...top(hasHero), ...(hasPulse ? pulse : []), ...(hasSpirits ? spirits : []), ...(hasRoster ? servitors : []), ...log, ...bottom]) {
180 const h = r.kind === 'text' ? 1 : r.rows
181 if (used + h > rows) break
182 out.push(r)
183 used += h
184 }
185 return out
186}
187
188export const minuteOf = (ms: number) => Math.floor(ms / 60_000)
189
190// One rite at `minute`; a clock that steps back counts in the newest minute.
191export function addPulse(p: Pulse, minute: number): Pulse {
192 if (!p.counts.length) return { last: minute, counts: [1] }
193 if (minute <= p.last) return { last: p.last, counts: [...p.counts.slice(0, -1), p.counts.at(-1)! + 1] }
194 const gap = Math.min(minute - p.last - 1, PULSE_MINUTES)
195 return { last: minute, counts: [...p.counts, ...Array<number>(gap).fill(0), 1].slice(-PULSE_MINUTES) }
196}
197
198// The rites of the n minutes ending at `now`, oldest first.
199export function pulseCounts(p: Pulse, now: number, n: number): number[] {
200 const at = (m: number) => (m > p.last || m <= p.last - p.counts.length ? 0 : p.counts[p.counts.length - 1 - (p.last - m)]!)
201 return Array.from({ length: n }, (_, i) => at(now - n + 1 + i))
202}
203
204const scaled = (counts: readonly number[], max: number) => counts.map(c => (max ? c / max : 0))
205
206// The n minutes ending at `now`, oldest first, scaled to the busiest of them.
207export function pulseWindow(p: Pulse, now: number, n: number): number[] {
208 const counts = pulseCounts(p, now, n)
209 return scaled(counts, Math.max(0, ...counts))
210}
211
212// The main loop's and the servitors' rites in the window, on one scale so the two halves compare.
213export function splitWindow(all: Pulse, servitors: Pulse, now: number, n: number): { main: number[]; servitors: number[] } {
214 const theirs = pulseCounts(servitors, now, n)
215 const main = pulseCounts(all, now, n).map((c, i) => Math.max(0, c - theirs[i]!))
216 const max = Math.max(0, ...main, ...theirs)
217 return { main: scaled(main, max), servitors: scaled(theirs, max) }
218}
219hooks/themes.ts 379 lines1import type { Color } from 'claude-code'
2
3// The engine's Uint8Array has these (RasterProps' own example); TypeScript 5.6's lib does not declare them yet.
4declare global {
5 interface Uint8Array {
6 toBase64(): string
7 }
8 interface Uint8ArrayConstructor {
9 fromBase64(base64: string): Uint8Array
10 }
11}
12
13export const THEME_NAMES = ['mars', 'cogitator', 'forge', 'claude'] as const
14export type ThemeName = (typeof THEME_NAMES)[number]
15export const isThemeName = (s: unknown): s is ThemeName => (THEME_NAMES as readonly unknown[]).includes(s)
16
17export const ART_COLS = 24
18export const ART_ROWS = 11
19// Frames of boot litany after the pane opens, at 4 frames a second.
20export const BOOT_TICKS = 4
21// The art's own width inside the canvas; the boot litany uses all ART_COLS.
22export const ART_SPAN = 17
23export const PULSE_ROWS = 2
24
25// Paint codes: ' ' the terminal's default colour, 'b' body, 'e' eye or screen, 'a' accent, 't' the hero band's rain.
26export type Paint = 'b' | 'e' | 'a' | 't'
27export type Frame = { rows: string[]; paint: string[] }
28export type TextTone = 'head' | 'body' | 'ok' | 'warn' | 'error' | 'dim'
29
30export type Theme = {
31 name: ThemeName
32 // undefined: the terminal's own text colour
33 text: Record<TextTone, Color | undefined>
34 // 0x00RRGGBB, or DEFAULT for the terminal's own colour
35 art: Record<Paint, number>
36 // The servitors' half of the mirrored pulse, 0x00RRGGBB.
37 servitor: number
38 boot: readonly string[]
39 frame: (tick: number, isWorking: boolean) => Frame
40}
41
42const DEFAULT = 0x01000000
43
44function canvas() {
45 const rows = Array.from({ length: ART_ROWS }, () => Array<string>(ART_COLS).fill(' '))
46 const paint = Array.from({ length: ART_ROWS }, () => Array<string>(ART_COLS).fill(' '))
47 const put = (row: number, col: number, text: string, code: Paint) => {
48 for (let i = 0; i < text.length; i++) {
49 const c = col + i
50 if (row < 0 || row >= ART_ROWS || c < 0 || c >= ART_COLS) continue
51 rows[row]![c] = text[i]!
52 paint[row]![c] = text[i] === ' ' ? ' ' : code
53 }
54 }
55 const done = (): Frame => ({ rows: rows.map(r => r.join('')), paint: paint.map(r => r.join('')) })
56 return { put, done }
57}
58
59type Put = ReturnType<typeof canvas>['put']
60
61// A stable pseudo-random number per (a, b), so every frame is repeatable in tests.
62const hash = (a: number, b: number) => Math.imul((a * 73856093) ^ (b * 19349663), 2654435761) >>> 0
63
64const SWEEP = [0, 1, 2, 1]
65const ANTENNA = ['|', ':', '¦', ':']
66
67// The servo-skull hovers, one row up or down every 2 s. Working: the eye sweeps the lens and the antenna sparks.
68export function servoSkull(tick: number, isWorking: boolean): Frame {
69 const { put, done } = canvas()
70 const y = Math.floor(tick / 8) % 2
71 const isBlink = !isWorking && tick % 32 === 0
72 if (isWorking && tick % 2 === 0) {
73 put(y, 6, '·', 'a')
74 put(y, 12, '·', 'a')
75 }
76 put(y, 9, isWorking ? ANTENNA[tick % 4]! : '|', 'a')
77 put(y + 1, 4, '.-"""""""-.', 'b')
78 put(y + 2, 3, '/ .-----. \\', 'b')
79 put(y + 3, 2, '| | | o |', 'b')
80 put(y + 3, 7 + (isWorking ? SWEEP[tick % 4]! : 1), isBlink ? '(-)' : '(@)', 'e')
81 put(y + 3, 14, isWorking && tick % 2 === 1 ? '.' : 'o', 'a')
82 put(y + 4, 2, "| '-----' |", 'b')
83 put(y + 5, 3, '\\ _/^\\_ /', 'b')
84 put(y + 6, 4, "'.|vvvvv|.'", 'b')
85 put(y + 7, 6, "'-----'", 'b')
86 put(y + 8, 4, '==[]=:=[]==', 'b')
87 put(y + 9, 7, isWorking && tick % 2 === 1 ? '≈~≈~≈' : '~≈~≈~', 'a')
88 return done()
89}
90
91const TEETH_A = ' _ _ _ _ _ '
92const TEETH_B = ' _ _ _ _ '
93const HUB = ['|', '/', '-', '\\']
94
95// The cog's teeth step between two phases and its hub turns while working; idle, the hub flickers every ~8 s.
96export function cog(tick: number, isWorking: boolean): Frame {
97 const { put, done } = canvas()
98 const teeth = isWorking && tick % 2 === 1 ? TEETH_B : TEETH_A
99 put(1, 0, teeth, 'a')
100 put(2, 0, ' _|_________|_ ', 'b')
101 put(3, 0, ' | .-----. | ', 'b')
102 put(4, 0, '_| / \\ |_', 'b')
103 put(5, 0, '| | | |', 'b')
104 put(5, 8, isWorking ? HUB[tick % 4]! : tick % 32 === 0 ? 'x' : '+', 'e')
105 put(6, 0, '|_ \\ / _|', 'b')
106 put(7, 0, " | '-----' | ", 'b')
107 put(8, 0, ' -|---------|- ', 'b')
108 put(9, 0, teeth.replaceAll('_', '-'), 'a')
109 return done()
110}
111
112// One line of the console: four bits, a gap, two bits, stable per line number.
113function screenLine(n: number): string {
114 const h = hash(n, 7)
115 const bit = (i: number) => String((h >>> i) & 1)
116 return `${[0, 1, 2, 3].map(bit).join('')} ${[8, 9].map(bit).join('')}`
117}
118
119// The tech-priest at its console: working, binharic scrolls up the screen, the eyes flare and a mechadendrite twitches.
120export function techPriest(tick: number, isWorking: boolean): Frame {
121 const { put, done } = canvas()
122 const t = isWorking ? tick : 0
123 const eyes = !isWorking && tick % 32 === 0 ? '- -' : isWorking && tick % 4 === 0 ? 'O O' : 'o o'
124 put(0, 0, ' .---. ', 'b')
125 put(1, 0, ' / \\ ', 'b')
126 put(2, 0, ' | |~~. ', 'b')
127 put(2, 7, eyes, 'e')
128 put(3, 0, ' \\ = / \\', 'b')
129 put(4, 0, ' __/| |\\__ |', 'b')
130 put(5, 0, ' / | # | \\ ', 'b')
131 put(5, isWorking && tick % 2 === 1 ? 15 : 16, '@', 'a')
132 put(6, 0, ' /____|_#_|____\\ ', 'b')
133 put(7, 0, ' |[===========]| ', 'b')
134 put(8, 0, ' || || ', 'b')
135 put(9, 0, ' || || ', 'b')
136 put(8, 5, screenLine(t), 'e')
137 put(9, 5, screenLine(t + 1), 'e')
138 put(10, 0, ' |[===========]| ', 'b')
139 return done()
140}
141
142// The litany types in over BOOT_TICKS frames, a quarter of its characters per frame.
143export function bootFrame(lines: readonly string[], step: number): Frame {
144 const { put, done } = canvas()
145 const total = lines.reduce((n, l) => n + l.length, 0)
146 let left = Math.ceil(((step + 1) / BOOT_TICKS) * total)
147 lines.forEach((line, i) => {
148 put(3 + i * 2, 0, line.slice(0, Math.max(0, left)), 'a')
149 left -= line.length
150 })
151 return done()
152}
153
154// The hero band: the art (or the boot litany) centred in `cols` columns, binharic rain on both flanks. The rain falls one row per working frame and holds still idle; a column of air is kept each side of the art.
155export function heroCells(theme: Theme, cols: number, tick: number, isWorking: boolean, sinceOpen: number): string {
156 const isBooting = sinceOpen >= 0 && sinceOpen < BOOT_TICKS
157 const f = isBooting ? bootFrame(theme.boot, sinceOpen) : theme.frame(tick, isWorking)
158 const span = isBooting ? ART_COLS : ART_SPAN
159 const left = Math.max(0, Math.floor((cols - span) / 2))
160 const t = isWorking ? tick : 0
161 const words = new Uint32Array(cols * ART_ROWS * 3)
162 for (let r = 0; r < ART_ROWS; r++)
163 for (let c = 0; c < cols; c++) {
164 const j = c - left
165 let ch = ' '
166 let fg = DEFAULT
167 if (j >= 0 && j < span) {
168 const code = f.paint[r]![j]!
169 ch = f.rows[r]![j]!
170 fg = code === ' ' ? DEFAULT : theme.art[code as Paint]
171 } else if (!isBooting && j !== -1 && j !== span && c % 2 === 0) {
172 const h = hash(r - t, c)
173 if (h % 4 === 0) {
174 ch = String((h >>> 9) & 1)
175 fg = theme.art.t
176 }
177 }
178 const i = (r * cols + c) * 3
179 words[i] = ch.charCodeAt(0)
180 words[i + 1] = fg
181 words[i + 2] = DEFAULT
182 }
183 return new Uint8Array(words.buffer).toBase64()
184}
185
186// Braille dot bits, bottom row first: dots 7, 3, 2, 1 on the left and 8, 6, 5, 4 on the right.
187const LEFT = [0x40, 0x04, 0x02, 0x01]
188const RIGHT = [0x80, 0x20, 0x10, 0x08]
189
190// PULSE_ROWS of braille, two samples (0 to 1) per cell; a sample above 0 shows at least one dot. Without `below`: one graph filled from the bottom. With it: `values` rise from the middle line, `below` hangs from it.
191export function pulseCells(theme: Theme, values: readonly number[], cols: number, below?: readonly number[]): string {
192 const words = new Uint32Array(cols * PULSE_ROWS * 3)
193 const put = (y: number, x: number, bits: number, fg: number) => {
194 const i = (y * cols + x) * 3
195 words[i] = 0x2800 + bits
196 words[i + 1] = fg
197 words[i + 2] = DEFAULT
198 }
199 const dotsOf = (v: number, dots: number) => (v > 0 ? Math.max(1, Math.round(v * dots)) : 0)
200 for (let x = 0; x < cols; x++) {
201 const pair = (vs: readonly number[]) => [[LEFT, vs[2 * x] ?? 0], [RIGHT, vs[2 * x + 1] ?? 0]] as const
202 if (!below) {
203 for (let y = 0; y < PULSE_ROWS; y++) {
204 let bits = 0
205 for (const [side, v] of pair(values)) {
206 const h = dotsOf(v, PULSE_ROWS * 4)
207 for (let d = 0; d < 4; d++) if ((PULSE_ROWS - 1 - y) * 4 + d < h) bits |= side[d]!
208 }
209 put(y, x, bits, theme.art.a)
210 }
211 continue
212 }
213 let up = 0
214 let down = 0
215 for (const [side, v] of pair(values)) for (let d = 0; d < dotsOf(v, 4); d++) up |= side[d]!
216 for (const [side, v] of pair(below)) for (let d = 0; d < dotsOf(v, 4); d++) down |= side[3 - d]!
217 put(0, x, up, theme.art.a)
218 put(1, x, down, theme.servitor)
219 }
220 return new Uint8Array(words.buffer).toBase64()
221}
222
223const MARS_BOOT = ['+++ NOOSPHERE LINK +++', 'Machine Spirit: stirring', 'Servo-skull aloft.']
224
225export const THEMES: Record<ThemeName, Theme> = {
226 mars: {
227 name: 'mars',
228 text: { head: '#C0392B', body: '#D8C7A4', ok: '#6FAF5F', warn: '#D9A441', error: '#FF4136', dim: '#8C7A5B' },
229 art: { b: 0xb08d57, e: 0xff3b30, a: 0xc0392b, t: 0x6e5b3e },
230 servitor: 0xd9a441,
231 boot: MARS_BOOT,
232 frame: servoSkull,
233 },
234 cogitator: {
235 name: 'cogitator',
236 text: { head: '#33FF66', body: '#9CFFB4', ok: '#33FF66', warn: '#FFB000', error: '#FF5555', dim: '#1F7A3A' },
237 art: { b: 0x33ff66, e: 0x9cffb4, a: 0x1f9e40, t: 0x1f7a3a },
238 servitor: 0xffb000,
239 boot: ['+++ COGITATOR ONLINE +++', 'Rotae engaged.', 'Binharic stream: open'],
240 frame: cog,
241 },
242 forge: {
243 name: 'forge',
244 text: { head: '#FFB000', body: '#FFE4A0', ok: '#FFD27F', warn: '#FF8C00', error: '#FF5555', dim: '#8A6A2A' },
245 art: { b: 0xffb000, e: 0xffe4a0, a: 0xb37a00, t: 0x8a6a2a },
246 servitor: 0xff8c00,
247 boot: ['+++ FORGE LITANY +++', 'Enginseer at console.', 'Data-psalm loaded.'],
248 frame: techPriest,
249 },
250 claude: {
251 name: 'claude',
252 text: { head: 'claude', body: undefined, ok: 'success', warn: 'warning', error: 'error', dim: 'inactive' },
253 art: { b: DEFAULT, e: 0xd97757, a: DEFAULT, t: DEFAULT },
254 servitor: 0xd97757,
255 boot: MARS_BOOT,
256 frame: servoSkull,
257 },
258}
259
260export const themeFor = (name: ThemeName) => THEMES[name]
261
262// Surfaces with no Raster (desktop, VS Code, mobile) get the pane as one Svg: the same cells on a terminal's dark ground.
263// Such a surface counts the pane's cells in its code font, then draws the Svg scaled to the pane's width, its height following.
264// So a cell has the desktop's code-font shape, 2.4 high to 1 wide, and is drawn twice the size of any surface's own: the Svg is only ever scaled down, to the pane's width, its height following.
265// ponytail: the shape is read off one desktop screenshot (2.17 left a strip of 11%; 2.5 scrolled). A taller cell than the surface's scrolls the pane, a shorter one leaves a strip below: tune CELL_H.
266export const CELL_W = 24
267export const CELL_H = 57
268const FONT_PX = 40
269const TERM_BG = '#141414'
270const TERM_FG = '#d4d4d4'
271// The claude theme's named colours as Claude Code's dark theme paints them. ponytail: fixed dark values, as the ground is always dark.
272const NAMED: Readonly<Record<string, string>> = { claude: '#d77757', success: '#4eba65', warning: '#ffc107', error: '#ff6b80', inactive: '#999999' }
273
274// One stretch of a row in one colour, starting at `col`.
275export type Run = { col: number; text: string; fg: string; bold?: boolean }
276
277const hex = (rgb: number) => (rgb === DEFAULT ? TERM_FG : `#${rgb.toString(16).padStart(6, '0')}`)
278
279export function toneHex(theme: Theme, tone: TextTone) {
280 const c = theme.text[tone]
281 return c === undefined ? TERM_FG : c.startsWith('#') ? c : (NAMED[c] ?? TERM_FG)
282}
283
284// A Raster's packed cells as runs, row by row. A blank joins the run it sits in, so the rain's gaps cost nothing. ponytail: backgrounds are dropped; this mod paints none.
285export function cellRuns(cells: string, cols: number, rows: number): Run[][] {
286 const w = new Uint32Array(Uint8Array.fromBase64(cells).buffer)
287 return Array.from({ length: rows }, (_, r) => {
288 const runs: Run[] = []
289 let run: Run | undefined
290 for (let c = 0; c < cols; c++) {
291 const i = (r * cols + c) * 3
292 const ch = String.fromCharCode(w[i] ?? 32)
293 const fg = hex(w[i + 1] ?? DEFAULT)
294 if (ch === ' ') {
295 if (run) run.text += ch
296 continue
297 }
298 if (run?.fg !== fg) {
299 run = { col: c, text: '', fg }
300 runs.push(run)
301 }
302 run.text += ch
303 }
304 return runs
305 })
306}
307
308// East Asian wide and fullwidth BMP characters take two terminal cells, as a surrogate pair's two units do (an emoji); every other character one. ponytail: the common wide blocks, not the full Unicode width table.
309const WIDE = /[ᄀ-ᅟ⺀-〾ぁ-㏿㐀-䶿一-鿿ꀀ-가-힣豈-︰-﹏-⦆¢-₩]/
310const cellsOf = (ch: string) => (WIDE.test(ch) ? 2 : ch.length)
311const widthOf = (s: string) => [...s].reduce((n, ch) => n + cellsOf(ch), 0)
312
313// A text row's segments as runs, side by side from column 0.
314export function segRuns(theme: Theme, segs: readonly { text: string; tone: TextTone; bold?: boolean }[]): Run[] {
315 let col = 0
316 return segs.map(s => {
317 const run: Run = { col, text: s.text, fg: toneHex(theme, s.tone), ...(s.bold ? { bold: true } : {}) }
318 col += widthOf(s.text)
319 return run
320 })
321}
322
323// A run's words, each at the cell it starts in and the cells it spans, cut where the next character would cross `inner`. Characters are walked whole, so a cut never splits a pair; one XML forbids is a blank.
324function words(run: Run, inner: number) {
325 const out: { col: number; text: string; span: number }[] = []
326 let col = run.col
327 for (const ch of run.text.replace(XML_BAD, ' ')) {
328 const w = cellsOf(ch)
329 if (col + w > inner) break
330 const last = out.at(-1)
331 if (/\s/.test(ch)) {
332 // A blank only advances the column.
333 } else if (last && last.col + last.span === col) {
334 last.text += ch
335 last.span += w
336 } else out.push({ col, text: ch, span: w })
337 col += w
338 }
339 return out
340}
341
342// What XML forbids would make the whole SVG invalid: control characters, U+FFFE and U+FFFF, and a surrogate left alone (a Bash command's broken emoji).
343// The two noncharacters by code: as literals they are invisible, and some tools rewrite \u escapes into literals.
344const XML_BAD = new RegExp(`[\\x00-\\x1f\\x7f${String.fromCharCode(0xfffe, 0xffff)}]|[\\ud800-\\udbff](?![\\udc00-\\udfff])|(?<![\\ud800-\\udbff])[\\udc00-\\udfff]`, 'g')
345const esc = (s: string) =>
346 s
347 .replace(XML_BAD, ' ')
348 .replace(/&/g, '&')
349 .replace(/</g, '<')
350 .replace(/>/g, '>')
351
352// An Svg's source is refused past 131072 characters, and the engine then closes the pane.
353const SVG_MAX = 131_072
354
355// The pane at `cols × rows` cells: each row's runs one cell in (the terminal's paddingX), cut at the far padding. Every word is placed at its own cell and stretched to its cells, so neither a renderer's whitespace rules nor a fallback font moves the grid. Columns count terminal cells: two for a wide character or an emoji.
356export function paneSvg(lines: readonly (readonly Run[])[], cols: number, rows: number): string {
357 const inner = Math.max(1, cols - 2)
358 const w = cols * CELL_W
359 const h = rows * CELL_H
360 const head = `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}" font-family="Menlo, Consolas, 'DejaVu Sans Mono', monospace" font-size="${FONT_PX}"><rect width="100%" height="100%" fill="${TERM_BG}"/>`
361 const tail = '</svg>'
362 let size = head.length + tail.length
363 const texts: string[] = []
364 // ponytail: past the budget the lowest rows go undrawn; only a pane far wider and taller than a desktop's reaches it.
365 rows: for (const [r, runs] of lines.slice(0, rows).entries())
366 for (const run of runs) {
367 const bold = run.bold ? ' font-weight="bold"' : ''
368 for (const word of words(run, inner)) {
369 // One narrow glyph needs no stretching: its x already pins it to its cell.
370 const fit = word.span > 1 ? ` textLength="${word.span * CELL_W}" lengthAdjust="spacingAndGlyphs"` : ''
371 const text = `<text x="${(word.col + 1) * CELL_W}" y="${r * CELL_H + (CELL_H + FONT_PX) / 2 + 1}"${fit} fill="${run.fg}"${bold}>${esc(word.text)}</text>`
372 if (size + text.length > SVG_MAX) break rows
373 size += text.length
374 texts.push(text)
375 }
376 }
377 return head + texts.join('') + tail
378}
379hooks/vox/dsp.ts 136 lines1// The voices' effects, each exactly as tuned in the prototype, and the WAV writer. Float32Array in and out; every stage returns a new array except fade, which works in place as the prototype's did.
2
3import { SR } from './synth'
4
5// 2nd-order RBJ low- or high-pass, Q 0.707. y1 and y2 keep the full precision; only the output is rounded to float.
6function rbj(x: Float32Array, isLow: boolean, f: number): Float32Array {
7 const w = (2 * Math.PI * f) / SR, c = Math.cos(w), a = Math.sin(w) / (2 * 0.707)
8 const b0 = isLow ? (1 - c) / 2 : (1 + c) / 2, b1 = isLow ? 1 - c : -(1 + c), b2 = b0
9 const a0 = 1 + a, a1 = -2 * c, a2 = 1 - a
10 const y = new Float32Array(x.length)
11 let x1 = 0, x2 = 0, y1 = 0, y2 = 0
12 for (let i = 0; i < x.length; i++) {
13 const v = (b0 * x[i]! + b1 * x1 + b2 * x2 - a1 * y1 - a2 * y2) / a0
14 x2 = x1, x1 = x[i]!, y2 = y1, y1 = v, y[i] = v
15 }
16 return y
17}
18export const lowpass = (x: Float32Array, f: number) => rbj(x, true, f)
19export const highpass = (x: Float32Array, f: number) => rbj(x, false, f)
20export const bandpass = (x: Float32Array, lo: number, hi: number) => lowpass(highpass(x, lo), hi)
21// 4th order, last in every chain, so nothing sharp reaches the ear.
22export const tame = (x: Float32Array, f: number) => lowpass(lowpass(x, f), f)
23
24export function ringmod(x: Float32Array, hz: number, wet: number): Float32Array {
25 const y = new Float32Array(x.length)
26 for (let i = 0; i < x.length; i++) y[i] = x[i]! * (1 - wet + wet * Math.sin((2 * Math.PI * hz * i) / SR))
27 return y
28}
29
30// Quantize to `bits`, sample-and-hold down to `holdHz`.
31export function crush(x: Float32Array, bits: number, holdHz: number): Float32Array {
32 const q = Math.pow(2, bits - 1), step = SR / holdHz, y = new Float32Array(x.length)
33 let held = 0, acc = step
34 for (let i = 0; i < x.length; i++) {
35 if (acc >= step) (held = Math.round(x[i]! * q) / q), (acc -= step)
36 acc++
37 y[i] = held
38 }
39 return y
40}
41
42export function drive(x: Float32Array, k: number): Float32Array {
43 const y = new Float32Array(x.length), n = Math.tanh(k)
44 for (let i = 0; i < x.length; i++) y[i] = Math.tanh(k * x[i]!) / n
45 return y
46}
47
48// Short feedback comb: the metal tube, the vox grille.
49export function comb(x: Float32Array, ms: number, fb: number): Float32Array {
50 const d = Math.round((SR * ms) / 1000), y = new Float32Array(x.length)
51 for (let i = 0; i < x.length; i++) y[i] = x[i]! + (i >= d ? fb * y[i - d]! : 0)
52 return y
53}
54
55export function flanger(x: Float32Array, depthMs: number, rateHz: number, fb: number): Float32Array {
56 const y = new Float32Array(x.length), max = Math.ceil((SR * (depthMs + 1)) / 1000)
57 for (let i = 0; i < x.length; i++) {
58 const d = ((1 + depthMs * (0.5 + 0.5 * Math.sin((2 * Math.PI * rateHz * i) / SR))) * SR) / 1000
59 const j = i - d, j0 = Math.floor(j), fr = j - j0
60 const del = j0 >= 1 ? y[j0]! * (1 - fr) + y[j0 + 1]! * fr : 0
61 y[i] = i < max ? x[i]! : x[i]! + fb * del
62 }
63 return y
64}
65
66// Grille hiss, band-limited and gated by a 60 ms peak follower so it breathes with the words. The follower is rounded to float where it is used, as the spike's Float32Array envelope was.
67export function hiss(x: Float32Array, gain: number, lo: number, hi: number): Float32Array {
68 let s = 7
69 const n = new Float32Array(x.length)
70 for (let i = 0; i < x.length; i++) n[i] = ((s = (s * 1664525 + 1013904223) >>> 0) / 4294967296) * 2 - 1
71 const b = bandpass(n, lo, hi), a = Math.exp(-1 / ((SR * 60) / 1000)), y = new Float32Array(x.length)
72 let e = 0
73 for (let i = 0; i < x.length; i++) {
74 e = Math.max(Math.abs(x[i]!), e * a)
75 y[i] = x[i]! + b[i]! * gain * (0.25 + Math.fround(e))
76 }
77 return y
78}
79
80// [signal, gain] pairs, as long as the longest.
81export function mix(...parts: [Float32Array, number][]): Float32Array {
82 const y = new Float32Array(Math.max(...parts.map(([s]) => s.length)))
83 for (const [s, g] of parts) for (let i = 0; i < s.length; i++) y[i]! += s[i]! * g
84 return y
85}
86
87// Loudness, not peak: the voiced 20 ms windows to targetDb RMS, then a soft knee keeps every peak under the ceiling. Peak-normalizing let one sharp transient turn a whole line down (a 15 dB spread between lines).
88export function level(x: Float32Array, targetDb = -18, knee = 0.6, ceiling = 0.9): Float32Array {
89 const win = Math.round(SR * 0.02), rms: number[] = []
90 for (let i = 0; i + win <= x.length; i += win) {
91 let e = 0
92 for (let j = i; j < i + win; j++) e += x[j]! * x[j]!
93 rms.push(Math.sqrt(e / win))
94 }
95 const top = Math.max(...rms), voiced = rms.filter((r) => r > top * 0.05)
96 const now = Math.sqrt(voiced.reduce((a, r) => a + r * r, 0) / voiced.length)
97 const g = now > 0 ? Math.pow(10, targetDb / 20) / now : 1, span = ceiling - knee, y = new Float32Array(x.length)
98 for (let i = 0; i < x.length; i++) {
99 const v = x[i]! * g, a = Math.abs(v)
100 y[i] = a <= knee ? v : Math.sign(v) * (knee + span * Math.tanh((a - knee) / span))
101 }
102 return y
103}
104
105export function fade(x: Float32Array, ms = 15): Float32Array {
106 const n = Math.round((SR * ms) / 1000)
107 for (let i = 0; i < n && i < x.length; i++) (x[i]! *= i / n), (x[x.length - 1 - i]! *= i / n)
108 return x
109}
110
111// 22,050 Hz mono 16-bit PCM behind a 44-byte header, as formant-tts's toWav wrote it.
112export function toWav(x: Float32Array): Uint8Array {
113 const bytes = new Uint8Array(44 + x.length * 2), v = new DataView(bytes.buffer)
114 const str = (at: number, s: string) => { for (let i = 0; i < s.length; i++) v.setUint8(at + i, s.charCodeAt(i)) }
115 str(0, 'RIFF'), v.setUint32(4, 36 + x.length * 2, true), str(8, 'WAVE')
116 str(12, 'fmt '), v.setUint32(16, 16, true), v.setUint16(20, 1, true), v.setUint16(22, 1, true)
117 v.setUint32(24, SR, true), v.setUint32(28, SR * 2, true), v.setUint16(32, 2, true), v.setUint16(34, 16, true)
118 str(36, 'data'), v.setUint32(40, x.length * 2, true)
119 for (let i = 0; i < x.length; i++) v.setInt16(44 + i * 2, Math.max(-1, Math.min(1, x[i]!)) * 32767, true)
120 return bytes
121}
122
123// A copy with its samples times gain; null unless the header is toWav's own (the clips and the lines both are).
124export function scaleWav(wav: Uint8Array, gain: number): Uint8Array | null {
125 if (wav.length < 44) return null
126 const v = new DataView(wav.buffer, wav.byteOffset, wav.byteLength)
127 const tag = (at: number) => String.fromCharCode(...wav.subarray(at, at + 4))
128 const n = v.getUint32(40, true)
129 const isOurs = tag(0) === 'RIFF' && tag(8) === 'WAVE' && tag(12) === 'fmt ' && tag(36) === 'data' && v.getUint32(16, true) === 16 &&
130 v.getUint16(20, true) === 1 && v.getUint16(22, true) === 1 && v.getUint32(24, true) === SR && v.getUint16(34, true) === 16 && 44 + n <= wav.length
131 if (!isOurs) return null
132 const out = wav.slice(), o = new DataView(out.buffer)
133 for (let i = 44; i + 1 < 44 + n; i += 2) o.setInt16(i, Math.max(-32768, Math.min(32767, Math.round(v.getInt16(i, true) * gain))), true)
134 return out
135}
136hooks/vox/voices.ts 80 lines1// The four voices, one per theme, and the takes that make each utterance a little different. Every value was tuned by ear in a prototype before the mod was built.
2
3import { bandpass, comb, crush, drive, fade, flanger, hiss, level, lowpass, mix, ringmod, tame, toWav } from './dsp'
4import { mulberry32, type Segment, SR, synth, type SynthOptions } from './synth'
5
6export type VoiceName = 'mars' | 'cogitator' | 'forge' | 'claude'
7export const VOICE_NAMES: readonly VoiceName[] = ['mars', 'cogitator', 'forge', 'claude']
8
9// One random variation per utterance, shared by every layer of its voice so the layers stay sample-aligned.
10export type Take = { seed: number; pitch: number; rate: number; stretch: number; decl: number; fscale: number; driftHz: number; driftPh: number }
11
12export function newTake(seed: number): Take {
13 const r = mulberry32(seed), u = (lo: number, hi: number) => lo + (hi - lo) * r()
14 return { seed, pitch: u(0.91, 1.09), rate: u(0.9, 1.1), stretch: u(0.85, 1.2), decl: u(0.04, 0.3), fscale: u(0.96, 1.04), driftHz: u(0.3, 1.2), driftPh: u(0, 6.28) }
15}
16
17// A layer's options before the take; the take scales them. Each render draws the stressed syllables afresh from the same seed, so every layer gets the same lifts and lengths.
18type Layer = Omit<SynthOptions, 'seed' | 'syl' | 'decl' | 'finalStretch' | 'commaStretch' | 'block'>
19
20function render(text: string, k: Take, o: Layer, block: number | undefined) {
21 const r = mulberry32(k.seed ^ 0x9e3779b9), base = o.f0mod
22 return synth(text, {
23 ...o,
24 pitch: o.pitch * k.pitch, rate: (o.rate || 1) * k.rate, finalStretch: 1.6 * k.stretch, commaStretch: 1.25 * k.stretch,
25 seed: k.seed, decl: k.decl, fscale: k.fscale * (o.fscale || 1),
26 syl: () => ({ lift: 1.03 + 0.2 * r(), len: 0.85 + 0.33 * r() }),
27 f0mod: (t) => (base ? base(t) : 1) * (1 + 0.03 * Math.sin((2 * Math.PI * k.driftHz * t) / 1000 + k.driftPh)),
28 block,
29 })
30}
31
32const st = (n: number) => Math.pow(2, n / 12)
33const wobble = (hz: number, semis: number) => (t: number) => st(semis * Math.sin((2 * Math.PI * hz * t) / 1000))
34
35// Each voice renders its layers through `say`, the words first, and returns them mixed to mono.
36const VOICES: Record<VoiceName, (say: (o: Layer) => Float32Array) => Float32Array> = {
37 // A chanting tech-priest through a vox-horn: semitone steps, a choir of the words, an octave below and a quiet fifth above (only the words hiss; at 74 Hz the vowels carry less, so their hiss comes down too), a hint of 30 Hz ring, a brass horn.
38 mars: (say) => {
39 const o = { rate: 1.15, fscale: 0.89, quant: 1, fricGain: 0.046 }
40 const a = say({ ...o, pitch: 74 }), sub = say({ ...o, pitch: 74 / st(12), fricGain: 0 }), fifth = say({ ...o, pitch: 74 * st(7), fricGain: 0 })
41 const choir = mix([a, 0.8], [lowpass(drive(lowpass(sub, 1400), 1.6), 1800), 0.6], [bandpass(fifth, 300, 2600), 0.22])
42 const horn = bandpass(crush(ringmod(choir, 30, 0.25), 10, 16000), 150, 3600)
43 return tame(comb(hiss(horn, 0.015, 900, 2800), 4.5, 0.45), 3600)
44 },
45 // The console's machine-spirit, a mechanical woman: a short tract, minor-third steps, the mouth stepping every 25 ms, a firm buzz, 9-bit at 11 kHz out of a small tinny speaker.
46 cogitator: (say) => {
47 const a = say({ pitch: 205, rate: 1.2, fscale: 1.17, quant: 3, hold: 5, tp: 0.3, tn: 0.08, fricGain: 0.17 })
48 return tame(comb(bandpass(crush(a, 9, 11025), 350, 3800), 2, 0.22), 3800)
49 },
50 // An ancient, unstable engine: a 2 Hz wobble, ring-modulated words over an overdriven, flanged twin a fifth down (which flattens the vowels, so the breath comes down further).
51 forge: (say) => {
52 const o = { rate: 1.0, f0mod: wobble(2, 0.8), aspGain: 0.15 }
53 const a = say({ ...o, pitch: 72 }), b = say({ ...o, pitch: 72 / st(7) })
54 const A = bandpass(ringmod(a, 52, 0.45), 120, 2800)
55 const B = lowpass(flanger(drive(lowpass(b, 1800), 3.5), 3, 0.35, 0.55), 2200)
56 return tame(comb(hiss(mix([A, 0.6], [B, 0.7]), 0.025, 600, 2500), 9, 0.4), 3200)
57 },
58 // The clean one: a light band-limit and a faint metal ring, words first.
59 claude: (say) => tame(comb(bandpass(say({ pitch: 112, rate: 1.17 }), 180, 5200), 4, 0.22), 4500),
60}
61
62// Every layer of the voice under one take, mixed, before levelling; the segments are the words layer's.
63export function voiceSamples(voice: VoiceName, text: string, seed: number, block?: number): { samples: Float32Array; segments: Segment[] } {
64 const k = newTake(seed)
65 let segments: Segment[] | undefined
66 const samples = VOICES[voice]((o) => {
67 const r = render(text, k, o, block)
68 segments ??= r.segments
69 return r.samples
70 })
71 return { samples, segments: segments! }
72}
73
74// A line ready to play: levelled by loudness, faded, at `gain` (the volumes' share, after the levelling).
75export function speak(voice: VoiceName, text: string, seed: number, gain = 1): { wav: Uint8Array; durationMs: number } {
76 const x = fade(level(voiceSamples(voice, text, seed).samples))
77 if (gain !== 1) for (let i = 0; i < x.length; i++) x[i]! *= gain
78 return { wav: toWav(x), durationMs: Math.round((x.length * 1000) / SR) }
79}
80hooks/vox/lexicon.ts 828 lines1// English front end: text to words of ARPAbet phones (stress digits on vowels) and pauses. Derived from formant-tts, https://github.com/PrakharSaxena24/formant-tts, tts.js at commit 87261ce6f322048903d17b1064a2605f9ea7aa37. MIT licensed. formant-tts's notice: "MIT licensed. No dependencies, no build step, no network access." Cut down to the English letter-to-sound rules, the exception lexicon (without the game's place names) and the tokenizer; the lore words the rules get wrong are merged over the exceptions, a hyphen reads as a space, and the rule table is built on the first call.
2
3export type Token = { word: string; phones: string[] } | { pause: number; isTerminal?: boolean; isQuestion?: boolean }
4
5// Words the letter-to-sound rules get wrong, kept as spelled: the lore, and the pool words the spike heard mangled.
6const LORE: Record<string, string[]> = {
7 omnissiah: ['aa0', 'm', 'n', 'ih0', 's', 'ay1', 'ax0'],
8 mechanicus: ['m', 'ax0', 'k', 'ae1', 'n', 'ih0', 'k', 'ax0', 's'],
9 heresy: ['h', 'eh1', 'r', 'ax0', 's', 'iy0'],
10 tech: ['t', 'eh1', 'k'],
11 spirit: ['s', 'p', 'ih1', 'r', 'ih0', 't'],
12 appeased: ['ax0', 'p', 'iy1', 'z', 'd'],
13 displeased: ['d', 'ih0', 's', 'p', 'l', 'iy1', 'z', 'd'],
14 servitor: ['s', 'er1', 'v', 'ih0', 't', 'er0'],
15 sanction: ['s', 'ae1', 'ng', 'k', 'sh', 'ax0', 'n'],
16 magos: ['m', 'ey1', 'g', 'aa0', 's'],
17 noosphere: ['n', 'uw1', 'ax0', 's', 'f', 'ih0', 'r'],
18 severed: ['s', 'eh1', 'v', 'er0', 'd'],
19 engrams: ['eh1', 'n', 'g', 'r', 'ae0', 'm', 'z'],
20 purged: ['p', 'er1', 'jh', 'd'],
21 awaits: ['ax0', 'w', 'ey1', 't', 's'],
22 required: ['r', 'ih0', 'k', 'w', 'ay1', 'er0', 'd'],
23 concluded: ['k', 'ax0', 'n', 'k', 'l', 'uw1', 'd', 'ih0', 'd'],
24 rite: ['r', 'ay1', 't'],
25 forbidden: ['f', 'er0', 'b', 'ih1', 'd', 'ax0', 'n'],
26 faltered: ['f', 'ao1', 'l', 't', 'er0', 'd'],
27 sacred: ['s', 'ey1', 'k', 'r', 'ih0', 'd'],
28 unguents: ['ah1', 'ng', 'g', 'w', 'ax0', 'n', 't', 's'],
29 silent: ['s', 'ay1', 'l', 'ax0', 'n', 't'],
30 communion: ['k', 'ax0', 'm', 'y', 'uw1', 'n', 'y', 'ax0', 'n'],
31 guidance: ['g', 'ay1', 'd', 'ax0', 'n', 's'],
32 consolidated: ['k', 'ax0', 'n', 's', 'aa1', 'l', 'ih0', 'd', 'ey0', 't', 'ih0', 'd'],
33 data: ['d', 'ey1', 't', 'ax0'],
34}
35
36const EXCEPTIONS: Record<string, string[]> = {
37 // --- articles, pronouns, auxiliaries, high-frequency function words ---
38 a: ['ax0'],
39 i: ['ay1'],
40 the: ['dh', 'ax0'],
41 of: ['ah1', 'v'],
42 to: ['t', 'uw1'],
43 do: ['d', 'uw1'],
44 into: ['ih1', 'n', 't', 'uw0'],
45 is: ['ih1', 'z'],
46 are: ['aa1', 'r'],
47 was: ['w', 'ah1', 'z'],
48 were: ['w', 'er1'],
49 been: ['b', 'ih1', 'n'],
50 have: ['h', 'ae1', 'v'],
51 has: ['h', 'ae1', 'z'],
52 had: ['h', 'ae1', 'd'],
53 from: ['f', 'r', 'ah1', 'm'],
54 you: ['y', 'uw1'],
55 your: ['y', 'ao1', 'r'],
56 yours: ['y', 'ao1', 'r', 'z'],
57 that: ['dh', 'ae1', 't'],
58 this: ['dh', 'ih1', 's'],
59 these: ['dh', 'iy1', 'z'],
60 those: ['dh', 'ow1', 'z'],
61 then: ['dh', 'eh1', 'n'],
62 them: ['dh', 'eh1', 'm'],
63 than: ['dh', 'ae1', 'n'],
64 thus: ['dh', 'ah1', 's'],
65 with: ['w', 'ih1', 'dh'],
66 where: ['w', 'eh1', 'r'],
67 guy: ['g', 'ay1'],
68
69 // --- numbers, dates, time ---
70 one: ['w', 'ah1', 'n'],
71 two: ['t', 'uw1'],
72 four: ['f', 'ao1', 'r'],
73 zero: ['z', 'ih1', 'r', 'ow0'],
74 seven: ['s', 'eh1', 'v', 'ax0', 'n'],
75 hundred: ['h', 'ah1', 'n', 'd', 'r', 'ax0', 'd'],
76 minute: ['m', 'ih1', 'n', 'ax0', 't'],
77 second: ['s', 'eh1', 'k', 'ax0', 'n', 'd'],
78 hour: ['aw1', 'er0'],
79 year: ['y', 'ih1', 'r'],
80 tuesday: ['t', 'uw1', 'z', 'd', 'ey0'],
81 wednesday: ['w', 'eh1', 'n', 'z', 'd', 'ey0'],
82 february: ['f', 'eh1', 'b', 'r', 'uw0', 'er0', 'iy0'],
83 "o'clock": ['ax0', 'k', 'l', 'aa1', 'k'],
84
85 // --- irregular spellings: ou / ough / augh ---
86 through: ['th', 'r', 'uw1'],
87 though: ['dh', 'ow1'],
88 although: ['ao0', 'l', 'dh', 'ow1'],
89 thought: ['th', 'ao1', 't'],
90 enough: ['ih0', 'n', 'ah1', 'f'],
91 tough: ['t', 'ah1', 'f'],
92 rough: ['r', 'ah1', 'f'],
93 cough: ['k', 'ao1', 'f'],
94 laugh: ['l', 'ae1', 'f'],
95 daughter: ['d', 'ao1', 't', 'er0'],
96 would: ['w', 'uh1', 'd'],
97 could: ['k', 'uh1', 'd'],
98 should: ['sh', 'uh1', 'd'],
99 young: ['y', 'ah1', 'ng'],
100 country: ['k', 'ah1', 'n', 't', 'r', 'iy0'],
101 couple: ['k', 'ah1', 'p', 'ax0', 'l'],
102 double: ['d', 'ah1', 'b', 'ax0', 'l'],
103 trouble: ['t', 'r', 'ah1', 'b', 'ax0', 'l'],
104 touch: ['t', 'ah1', 'ch'],
105 cousin: ['k', 'ah1', 'z', 'ax0', 'n'],
106 journey: ['jh', 'er1', 'n', 'iy0'],
107 house: ['h', 'aw1', 's'],
108 now: ['n', 'aw1'],
109 how: ['h', 'aw1'],
110 down: ['d', 'aw1', 'n'],
111 town: ['t', 'aw1', 'n'],
112 cow: ['k', 'aw1'],
113 allow: ['ax0', 'l', 'aw1'],
114 power: ['p', 'aw1', 'er0'],
115 flower: ['f', 'l', 'aw1', 'er0'],
116 crowd: ['k', 'r', 'aw1', 'd'],
117
118 // --- silent letters ---
119 know: ['n', 'ow1'],
120 knee: ['n', 'iy1'],
121 knife: ['n', 'ay1', 'f'],
122 write: ['r', 'ay1', 't'],
123 wrong: ['r', 'ao1', 'ng'],
124 half: ['h', 'ae1', 'f'],
125 calm: ['k', 'aa1', 'm'],
126 palm: ['p', 'aa1', 'm'],
127 talk: ['t', 'ao1', 'k'],
128 walk: ['w', 'ao1', 'k'],
129 sword: ['s', 'ao1', 'r', 'd'],
130 island: ['ay1', 'l', 'ax0', 'n', 'd'],
131 answer: ['ae1', 'n', 's', 'er0'],
132 listen: ['l', 'ih1', 's', 'ax0', 'n'],
133 often: ['ao1', 'f', 'ax0', 'n'],
134 castle: ['k', 'ae1', 's', 'ax0', 'l'],
135 colonel: ['k', 'er1', 'n', 'ax0', 'l'],
136 iron: ['ay1', 'er0', 'n'],
137 debt: ['d', 'eh1', 't'],
138 doubt: ['d', 'aw1', 't'],
139 oh: ['ow1'],
140 comb: ['k', 'ow1', 'm'],
141 climb: ['k', 'l', 'ay1', 'm'],
142 build: ['b', 'ih1', 'l', 'd'],
143 built: ['b', 'ih1', 'l', 't'],
144
145 // --- o = ah, u = uh, and other short-vowel oddities ---
146 come: ['k', 'ah1', 'm'],
147 some: ['s', 'ah1', 'm'],
148 done: ['d', 'ah1', 'n'],
149 gone: ['g', 'ao1', 'n'],
150 none: ['n', 'ah1', 'n'],
151 once: ['w', 'ah1', 'n', 's'],
152 love: ['l', 'ah1', 'v'],
153 above: ['ax0', 'b', 'ah1', 'v'],
154 move: ['m', 'uw1', 'v'],
155 prove: ['p', 'r', 'uw1', 'v'],
156 live: ['l', 'ih1', 'v'],
157 give: ['g', 'ih1', 'v'],
158 month: ['m', 'ah1', 'n', 'th'],
159 money: ['m', 'ah1', 'n', 'iy0'],
160 monkey: ['m', 'ah1', 'ng', 'k', 'iy0'],
161 front: ['f', 'r', 'ah1', 'n', 't'],
162 son: ['s', 'ah1', 'n'],
163 ton: ['t', 'ah1', 'n'],
164 won: ['w', 'ah1', 'n'],
165 wonder: ['w', 'ah1', 'n', 'd', 'er0'],
166 among: ['ax0', 'm', 'ah1', 'ng'],
167 honey: ['h', 'ah1', 'n', 'iy0'],
168 dozen: ['d', 'ah1', 'z', 'ax0', 'n'],
169 oven: ['ah1', 'v', 'ax0', 'n'],
170 cover: ['k', 'ah1', 'v', 'er0'],
171 lover: ['l', 'ah1', 'v', 'er0'],
172 discover: ['d', 'ih0', 's', 'k', 'ah1', 'v', 'er0'],
173 put: ['p', 'uh1', 't'],
174 push: ['p', 'uh1', 'sh'],
175 pull: ['p', 'uh1', 'l'],
176 full: ['f', 'uh1', 'l'],
177 bush: ['b', 'uh1', 'sh'],
178
179 // --- vowel digraphs that break the rules ---
180 said: ['s', 'eh1', 'd'],
181 says: ['s', 'eh1', 'z'],
182 does: ['d', 'ah1', 'z'],
183 friend: ['f', 'r', 'eh1', 'n', 'd'],
184 great: ['g', 'r', 'ey1', 't'],
185 break: ['b', 'r', 'ey1', 'k'],
186 steak: ['s', 't', 'ey1', 'k'],
187 heart: ['h', 'aa1', 'r', 't'],
188 bear: ['b', 'eh1', 'r'],
189 pear: ['p', 'eh1', 'r'],
190 wear: ['w', 'eh1', 'r'],
191 tear: ['t', 'eh1', 'r'],
192 there: ['dh', 'eh1', 'r'],
193 their: ['dh', 'eh1', 'r'],
194 they: ['dh', 'ey1'],
195 eight: ['ey1', 't'],
196 weight: ['w', 'ey1', 't'],
197 height: ['h', 'ay1', 't'],
198 neither: ['n', 'iy1', 'dh', 'er0'],
199 either: ['iy1', 'dh', 'er0'],
200 people: ['p', 'iy1', 'p', 'ax0', 'l'],
201 pretty: ['p', 'r', 'ih1', 't', 'iy0'],
202 many: ['m', 'eh1', 'n', 'iy0'],
203 any: ['eh1', 'n', 'iy0'],
204 again: ['ax0', 'g', 'eh1', 'n'],
205 against: ['ax0', 'g', 'eh1', 'n', 's', 't'],
206 because: ['b', 'ih0', 'k', 'ao1', 'z'],
207 before: ['b', 'ih0', 'f', 'ao1', 'r'],
208 behind: ['b', 'ih0', 'h', 'ay1', 'n', 'd'],
209 eye: ['ay1'],
210 buy: ['b', 'ay1'],
211 busy: ['b', 'ih1', 'z', 'iy0'],
212 business: ['b', 'ih1', 'z', 'n', 'ax0', 's'],
213 women: ['w', 'ih1', 'm', 'ih0', 'n'],
214 woman: ['w', 'uh1', 'm', 'ax0', 'n'],
215 ready: ['r', 'eh1', 'd', 'iy0'],
216 already: ['ao0', 'l', 'r', 'eh1', 'd', 'iy0'],
217 even: ['iy1', 'v', 'ax0', 'n'],
218 ever: ['eh1', 'v', 'er0'],
219 never: ['n', 'eh1', 'v', 'er0'],
220 river: ['r', 'ih1', 'v', 'er0'],
221 over: ['ow1', 'v', 'er0'],
222 other: ['ah1', 'dh', 'er0'],
223 mother: ['m', 'ah1', 'dh', 'er0'],
224 father: ['f', 'aa1', 'dh', 'er0'],
225 brother: ['b', 'r', 'ah1', 'dh', 'er0'],
226 only: ['ow1', 'n', 'l', 'iy0'],
227 nothing: ['n', 'ah1', 'th', 'ih0', 'ng'],
228 something: ['s', 'ah1', 'm', 'th', 'ih0', 'ng'],
229 everything: ['eh1', 'v', 'r', 'iy0', 'th', 'ih0', 'ng'],
230 doing: ['d', 'uw1', 'ih0', 'ng'],
231 going: ['g', 'ow1', 'ih0', 'ng'],
232
233 // --- consonant oddities ---
234 who: ['h', 'uw1'],
235 whose: ['h', 'uw1', 'z'],
236 what: ['w', 'ah1', 't'],
237 want: ['w', 'aa1', 'n', 't'],
238 water: ['w', 'ao1', 't', 'er0'],
239 work: ['w', 'er1', 'k'],
240 word: ['w', 'er1', 'd'],
241 world: ['w', 'er1', 'l', 'd'],
242 machine: ['m', 'ax0', 'sh', 'iy1', 'n'],
243 ocean: ['ow1', 'sh', 'ax0', 'n'],
244 sure: ['sh', 'uh1', 'r'],
245 sugar: ['sh', 'uh1', 'g', 'er0'],
246 character: ['k', 'eh1', 'r', 'ax0', 'k', 't', 'er0'],
247 school: ['s', 'k', 'uw1', 'l'],
248 danger: ['d', 'ey1', 'n', 'jh', 'er0'],
249 stranger: ['s', 't', 'r', 'ey1', 'n', 'jh', 'er0'],
250 finger: ['f', 'ih1', 'ng', 'g', 'er0'],
251 anger: ['ae1', 'ng', 'g', 'er0'],
252 measure: ['m', 'eh1', 'zh', 'er0'],
253 pleasure: ['p', 'l', 'eh1', 'zh', 'er0'],
254 treasure: ['t', 'r', 'eh1', 'zh', 'er0'],
255 case: ['k', 'ey1', 's'],
256 base: ['b', 'ey1', 's'],
257 purpose: ['p', 'er1', 'p', 'ax0', 's'],
258 promise: ['p', 'r', 'aa1', 'm', 'ax0', 's'],
259 future: ['f', 'y', 'uw1', 'ch', 'er0'],
260
261 // --- stress traps (the prefix heuristic would misfire) ---
262 hello: ['h', 'ax0', 'l', 'ow1'],
263 today: ['t', 'ax0', 'd', 'ey1'],
264 tonight: ['t', 'ax0', 'n', 'ay1', 't'],
265 together: ['t', 'ax0', 'g', 'eh1', 'dh', 'er0'],
266 tomorrow: ['t', 'ax0', 'm', 'aa1', 'r', 'ow0'],
267 tomato: ['t', 'ax0', 'm', 'ey1', 't', 'ow0'],
268 toward: ['t', 'ax0', 'w', 'ao1', 'r', 'd'],
269 area: ['eh1', 'r', 'iy0', 'ax0'],
270 animal: ['ae1', 'n', 'ax0', 'm', 'ax0', 'l'],
271 under: ['ah1', 'n', 'd', 'er0'],
272 understand: ['ah0', 'n', 'd', 'er0', 's', 't', 'ae1', 'n', 'd'],
273 uncle: ['ah1', 'ng', 'k', 'ax0', 'l'],
274 unit: ['y', 'uw1', 'n', 'ax0', 't'],
275 company: ['k', 'ah1', 'm', 'p', 'ax0', 'n', 'iy0'],
276 common: ['k', 'aa1', 'm', 'ax0', 'n'],
277 comment: ['k', 'aa1', 'm', 'eh0', 'n', 't'],
278 contact: ['k', 'aa1', 'n', 't', 'ae0', 'k', 't'],
279 conference: ['k', 'aa1', 'n', 'f', 'r', 'ax0', 'n', 's'],
280 concert: ['k', 'aa1', 'n', 's', 'er0', 't'],
281 computer: ['k', 'ax0', 'm', 'p', 'y', 'uw1', 't', 'er0'],
282 person: ['p', 'er1', 's', 'ax0', 'n'],
283 personal: ['p', 'er1', 's', 'ax0', 'n', 'ax0', 'l'],
284 perfect: ['p', 'er1', 'f', 'ih0', 'k', 't'],
285 exercise: ['eh1', 'k', 's', 'er0', 's', 'ay0', 'z'],
286 expert: ['eh1', 'k', 's', 'p', 'er0', 't'],
287 extra: ['eh1', 'k', 's', 't', 'r', 'ax0'],
288 exit: ['eh1', 'k', 's', 'ax0', 't'],
289 excellent: ['eh1', 'k', 's', 'ax0', 'l', 'ax0', 'n', 't'],
290 index: ['ih1', 'n', 'd', 'eh0', 'k', 's'],
291 industry: ['ih1', 'n', 'd', 'ax0', 's', 't', 'r', 'iy0'],
292 interest: ['ih1', 'n', 't', 'r', 'ax0', 's', 't'],
293 interesting: ['ih1', 'n', 't', 'r', 'ax0', 's', 't', 'ih0', 'ng'],
294 after: ['ae1', 'f', 't', 'er0'],
295 afternoon: ['ae1', 'f', 't', 'er0', 'n', 'uw1', 'n'],
296 always: ['ao1', 'l', 'w', 'ey0', 'z'],
297 almost: ['ao1', 'l', 'm', 'ow0', 's', 't'],
298 also: ['ao1', 'l', 's', 'ow0'],
299 angry: ['ae1', 'ng', 'g', 'r', 'iy0'],
300 army: ['aa1', 'r', 'm', 'iy0'],
301 active: ['ae1', 'k', 't', 'ih0', 'v'],
302 actually: ['ae1', 'k', 'ch', 'uw0', 'ax0', 'l', 'iy0'],
303 accident: ['ae1', 'k', 's', 'ax0', 'd', 'ax0', 'n', 't'],
304 accent: ['ae1', 'k', 's', 'eh0', 'n', 't'],
305 arrow: ['ae1', 'r', 'ow0'],
306 ankle: ['ae1', 'ng', 'k', 'ax0', 'l'],
307 april: ['ey1', 'p', 'r', 'ax0', 'l'],
308 august: ['ao1', 'g', 'ax0', 's', 't'],
309 problem: ['p', 'r', 'aa1', 'b', 'l', 'ax0', 'm'],
310 program: ['p', 'r', 'ow1', 'g', 'r', 'ae0', 'm'],
311 project: ['p', 'r', 'aa1', 'jh', 'eh0', 'k', 't'],
312 product: ['p', 'r', 'aa1', 'd', 'ax0', 'k', 't'],
313 process: ['p', 'r', 'aa1', 's', 'eh0', 's'],
314 progress: ['p', 'r', 'aa1', 'g', 'r', 'eh0', 's'],
315 property: ['p', 'r', 'aa1', 'p', 'er0', 't', 'iy0'],
316 distance: ['d', 'ih1', 's', 't', 'ax0', 'n', 's'],
317 distant: ['d', 'ih1', 's', 't', 'ax0', 'n', 't'],
318 district: ['d', 'ih1', 's', 't', 'r', 'ih0', 'k', 't'],
319 enter: ['eh1', 'n', 't', 'er0'],
320 energy: ['eh1', 'n', 'er0', 'jh', 'iy0'],
321 empty: ['eh1', 'm', 'p', 't', 'iy0'],
322 enemy: ['eh1', 'n', 'ax0', 'm', 'iy0'],
323 begin: ['b', 'ih0', 'g', 'ih1', 'n'],
324 began: ['b', 'ih0', 'g', 'ae1', 'n'],
325 beginning: ['b', 'ih0', 'g', 'ih1', 'n', 'ih0', 'ng'],
326 important: ['ih0', 'm', 'p', 'ao1', 'r', 't', 'ax0', 'n', 't'],
327 hotel: ['h', 'ow0', 't', 'eh1', 'l'],
328 student: ['s', 't', 'uw1', 'd', 'ax0', 'n', 't'],
329 library: ['l', 'ay1', 'b', 'r', 'eh0', 'r', 'iy0'],
330 movie: ['m', 'uw1', 'v', 'iy0'],
331 reply: ['r', 'ih0', 'p', 'l', 'ay1'],
332 apply: ['ax0', 'p', 'l', 'ay1'],
333 supply: ['s', 'ax0', 'p', 'l', 'ay1'],
334 imply: ['ih0', 'm', 'p', 'l', 'ay1'],
335 july: ['jh', 'uh0', 'l', 'ay1'],
336 beautiful: ['b', 'y', 'uw1', 't', 'ax0', 'f', 'ax0', 'l'],
337 beauty: ['b', 'y', 'uw1', 't', 'iy0'],
338
339 // --- contractions ---
340 "don't": ['d', 'ow1', 'n', 't'],
341 "can't": ['k', 'ae1', 'n', 't'],
342 "won't": ['w', 'ow1', 'n', 't'],
343 "isn't": ['ih1', 'z', 'ax0', 'n', 't'],
344 "didn't": ['d', 'ih1', 'd', 'ax0', 'n', 't'],
345 "doesn't": ['d', 'ah1', 'z', 'ax0', 'n', 't'],
346 "haven't": ['h', 'ae1', 'v', 'ax0', 'n', 't'],
347 "i'm": ['ay1', 'm'],
348 "i've": ['ay1', 'v'],
349 "i'll": ['ay1', 'l'],
350 "it's": ['ih1', 't', 's'],
351 "that's": ['dh', 'ae1', 't', 's'],
352 "you're": ['y', 'uh1', 'r'],
353 "we're": ['w', 'ih1', 'r'],
354 "they're": ['dh', 'eh1', 'r'],
355 ...LORE,
356}
357
358const VOWELS: Record<string, 1> = { iy: 1, ih: 1, ey: 1, eh: 1, ae: 1, aa: 1, ao: 1, ow: 1, uh: 1, uw: 1, ah: 1, ax: 1, er: 1, ay: 1, aw: 1, oy: 1 }
359const REDUCIBLE: Record<string, 1> = { ih: 1, eh: 1, ae: 1, aa: 1, ah: 1, ao: 1, uh: 1, ax: 1 }
360const VOICELESS: Record<string, 1> = { p: 1, t: 1, k: 1, f: 1, th: 1, s: 1, sh: 1, ch: 1 }
361const SIBILANT: Record<string, 1> = { s: 1, z: 1, sh: 1, zh: 1, ch: 1, jh: 1 }
362const DIGITS = ['zero', 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine']
363const LETTERS: Record<string, string> = {
364 b: 'bee',
365 c: 'see',
366 d: 'dee',
367 e: 'ee',
368 f: 'ef',
369 g: 'jee',
370 h: 'aitch',
371 j: 'jay',
372 k: 'kay',
373 l: 'el',
374 m: 'em',
375 n: 'en',
376 o: 'oh',
377 p: 'pee',
378 q: 'cue',
379 r: 'ar',
380 s: 'ess',
381 t: 'tee',
382 u: 'you',
383 v: 'vee',
384 w: 'double',
385 x: 'ex',
386 y: 'why',
387 z: 'zee',
388}
389
390type Rule = { s: string; l: RegExp | null; r: RegExp | null; o: string[]; m: number }
391
392// s = grapheme, l = left context, r = right context, o = output, m = 1 requires a "magic e" (silent final e) context. Left contexts are anchored to the end of the text already consumed unless prefixed with '~'; right contexts are anchored to the start of the text still to come. Within each letter the list is ordered longest / most specific pattern first.
393function rr(s: string, l: string | 0, r: string | 0, o: string[], m?: 1): Rule {
394 return {
395 s,
396 l: l ? new RegExp(l.charAt(0) === '~' ? l.substring(1) : '(?:' + l + ')$') : null,
397 r: r ? new RegExp('^(?:' + r + ')') : null,
398 o,
399 m: m ? 1 : 0,
400 }
401}
402
403function buildRules(): Record<string, Rule[]> {
404 const C = 'bcdfghjklmnpqrstvwxz'
405 const T: Record<string, Rule[]> = {}
406
407 T.a = [
408 rr('ature', 0, '$', ['ey', 'ch', 'er']),
409 rr('aigh', 0, 0, ['ey']),
410 rr('augh', 0, 0, ['ao']),
411 rr('alk', 0, 0, ['ao', 'k']),
412 rr('all', 0, '[^aeiouy]|$', ['ao', 'l']),
413 rr('are', 0, '$', ['eh', 'r']),
414 rr('age', '~[aeiouy].*[^aeiouy]$', '$', ['ax', 'jh']),
415 rr('ai', 0, 0, ['ey']),
416 rr('ay', 0, 0, ['ey']),
417 rr('au', 0, 0, ['ao']),
418 rr('aw', 0, '[^aeiou]|$', ['ao']),
419 rr('ar', '~.', '[aeiouy]', ['eh', 'r']),
420 rr('ar', 0, '[^r]|$', ['aa', 'r']),
421 rr('a', 0, 0, ['ey'], 1),
422 rr('a', 0, 'tion|sion', ['ey']),
423 rr('a', 'w', 'tch|sh|t|s|n|d|ll', ['aa']),
424 rr('a', 0, '$', ['ax']),
425 rr('a', 0, 0, ['ae']),
426 ]
427 T.b = [rr('bb', 0, 0, ['b']), rr('b', 'm', '$', []), rr('b', 0, 't$', []), rr('b', 0, 0, ['b'])]
428 T.c = [
429 rr('chr', 0, 0, ['k', 'r']),
430 rr('ch', 0, 0, ['ch']),
431 rr('ck', 0, 0, ['k']),
432 rr('cc', 0, '[eiy]', ['k', 's']),
433 rr('cc', 0, 0, ['k']),
434 rr('ci', 0, '[aou]', ['sh']),
435 rr('c', 0, '[eiy]', ['s']),
436 rr('c', 0, 0, ['k']),
437 ]
438 T.d = [rr('dge', 0, 0, ['jh']), rr('dg', 0, '[eiy]', ['jh']), rr('dd', 0, 0, ['d']), rr('d', 0, 0, ['d'])]
439 T.e = [
440 rr('eather', 0, 0, ['eh', 'dh', 'er']),
441 rr('easure', 0, 0, ['eh', 'zh', 'er']),
442 rr('ealth', 0, 0, ['eh', 'l', 'th']),
443 rr('eady', 0, 0, ['eh', 'd', 'iy']),
444 rr('eigh', 0, 0, ['ey']),
445 rr('eath', 'br|d|w|f|l', '[^e]|$', ['eh', 'th']),
446 rr('ead', 'br|h|d|spr|thr|st|tr|dr|inst', '[^aeiouy]|$', ['eh', 'd']),
447 rr('ere', 0, '$', ['ih', 'r']),
448 rr('ear', 0, '[^aeiouy]', ['er']),
449 rr('ear', 0, 0, ['iy', 'er']),
450 rr('ee', 0, 0, ['iy']),
451 rr('ea', 0, 0, ['iy']),
452 rr('ei', 'c', 0, ['iy']),
453 rr('ei', 0, 0, ['ey']),
454 rr('eu', 0, 0, ['y', 'uw']),
455 rr('ew', 0, 0, ['uw']),
456 rr('ey', 0, '$', ['iy']),
457 rr('er', 0, '[aeiouy]', ['eh', 'r']),
458 rr('er', 0, '[^r]|$', ['er']),
459 rr('e', 0, 0, ['iy'], 1),
460 rr('e', '~[aeiouy]', '$', []),
461 rr('e', 0, '$', ['iy']),
462 rr('e', 0, 0, ['eh']),
463 ]
464 T.f = [rr('ff', 0, 0, ['f']), rr('f', 0, 0, ['f'])]
465 T.g = [
466 rr('gh', '~^$', 0, ['g']),
467 rr('gh', 0, 0, []),
468 rr('gn', 0, '$', ['n']),
469 rr('gn', '~^$', 0, ['n']),
470 rr('gg', 0, 0, ['g']),
471 rr('gu', 0, '[eia]', ['g']),
472 rr('g', 0, 'et|i[rvft]', ['g']),
473 rr('g', 0, '[eiy]', ['jh']),
474 rr('g', 0, 0, ['g']),
475 ]
476 T.h = [rr('h', 'r', 0, []), rr('h', 0, '$', []), rr('h', 0, 0, ['h'])]
477 T.i = [
478 rr('ious', 0, 0, ['iy', 'ax', 's']),
479 rr('ind', 0, '$', ['ay', 'n', 'd']),
480 rr('ild', 0, '$', ['ay', 'l', 'd']),
481 rr('igh', 0, 0, ['ay']),
482 rr('ign', 0, '$', ['ay', 'n']),
483 rr('ire', 0, '$', ['ay', 'r']),
484 rr('ive', '[ts]', '$', ['ih', 'v']),
485 rr('ie', '~^[' + C + ']$', '$', ['ay']),
486 rr('ie', 0, 0, ['iy']),
487 rr('ir', 0, '[^aeiouyr]|$', ['er']),
488 rr('ir', 0, '[^r]|$', ['ih', 'r']),
489 rr('i', 0, 0, ['ay'], 1),
490 rr('i', 0, '[aeou]', ['iy']),
491 rr('i', 0, '$', ['iy']),
492 rr('i', 0, 0, ['ih']),
493 ]
494 T.j = [rr('j', 0, 0, ['jh'])]
495 T.k = [rr('kn', '~^$', 0, ['n']), rr('kk', 0, 0, ['k']), rr('k', 0, 0, ['k'])]
496 T.l = [rr('le', '[^aeiouy]', '$', ['ax', 'l']), rr('ll', 0, 0, ['l']), rr('l', 0, 0, ['l'])]
497 T.m = [rr('mb', 0, '$', ['m']), rr('mn', 0, '$', ['m']), rr('mm', 0, 0, ['m']), rr('m', 0, 0, ['m'])]
498 T.n = [
499 rr('nge', 0, '$', ['n', 'jh']),
500 rr('ngi', 0, 0, ['n', 'jh', 'ih']),
501 rr('ng', 0, 0, ['ng']),
502 rr('nk', 0, 0, ['ng', 'k']),
503 rr('nn', 0, 0, ['n']),
504 rr('n', 0, 0, ['n']),
505 ]
506 T.o = [
507 rr('ough', 0, 0, ['ao']),
508 rr('ous', 0, '$', ['ax', 's']),
509 rr('oor', 0, 0, ['ao', 'r']),
510 rr('our', 0, '[^aeiouy]|$', ['ao', 'r']),
511 rr('own', 'd|t|br|cl|cr|fr|g|dr', 0, ['aw', 'n']),
512 rr('ore', 0, '$', ['ao', 'r']),
513 rr('old', 0, '$', ['ow', 'l', 'd']),
514 rr('oa', 0, 0, ['ow']),
515 rr('oe', 0, '$', ['ow']),
516 rr('oi', 0, 0, ['oy']),
517 rr('oy', 0, 0, ['oy']),
518 rr('oo', 0, '[kd]', ['uh']),
519 rr('oo', 0, 0, ['uw']),
520 rr('ou', 0, 0, ['aw']),
521 rr('or', '~^..', '$', ['er']),
522 rr('or', 0, '[^r]|$', ['ao', 'r']),
523 rr('ow', 0, 0, ['ow']),
524 rr('o', 0, 0, ['ow'], 1),
525 rr('o', 0, '$', ['ow']),
526 rr('o', 0, 0, ['aa']),
527 ]
528 T.p = [rr('ph', 0, 0, ['f']), rr('pp', 0, 0, ['p']), rr('p', 0, 0, ['p'])]
529 T.q = [rr('qu', 0, 0, ['k', 'w']), rr('q', 0, 0, ['k'])]
530 T.r = [rr('re', '[^aeiouy]', '$', ['er']), rr('rr', 0, 0, ['r']), rr('r', 0, 0, ['r'])]
531 T.s = [
532 rr('shion', 0, 0, ['sh', 'ax', 'n']),
533 rr('ssion', 0, 0, ['sh', 'ax', 'n']),
534 rr('stion', 0, 0, ['s', 'ch', 'ax', 'n']),
535 rr('ssure', 0, 0, ['sh', 'er']),
536 rr('sion', '[aeiouy]', 0, ['zh', 'ax', 'n']),
537 rr('sion', 0, 0, ['sh', 'ax', 'n']),
538 rr('sure', '[aeiouy]', 0, ['zh', 'er']),
539 rr('sure', 0, 0, ['sh', 'er']),
540 rr('sch', 0, 0, ['s', 'k']),
541 rr('sh', 0, 0, ['sh']),
542 rr('ss', 0, 0, ['s']),
543 rr('s', '[aeiouy]', '[aeiouy]', ['z']),
544 rr('s', 0, 0, ['s']),
545 ]
546 T.t = [
547 rr('tion', 0, 0, ['sh', 'ax', 'n']),
548 rr('tial', 0, 0, ['sh', 'ax', 'l']),
549 rr('ture', 0, '$', ['ch', 'er']),
550 rr('tch', 0, 0, ['ch']),
551 rr('the', 0, '$', ['dh']),
552 rr('th', '~^$', 'e$|em$|en$|ese$|ey$|eir$|ere$|erefore|at$|is$|an$|ose$|us$|ough', ['dh']),
553 rr('th', '[aeiouy]', 'er', ['dh']),
554 rr('th', 0, 0, ['th']),
555 rr('tt', 0, 0, ['t']),
556 rr('t', 0, 0, ['t']),
557 ]
558 T.u = [
559 rr('ure', 'c|p', '$', ['y', 'er']),
560 rr('ure', 0, '$', ['er']),
561 rr('ull', 'f|p|b', 0, ['uh', 'l']),
562 rr('ui', 0, 0, ['uw']),
563 rr('ue', 0, '$', ['uw']),
564 rr('ur', 0, '[^r]|$', ['er']),
565 rr('u', 0, 0, ['uw'], 1),
566 rr('u', 'p|b|f', 'sh$|ll', ['uh']),
567 rr('u', 0, '$', ['uw']),
568 rr('u', 0, 0, ['ah']),
569 ]
570 T.v = [rr('v', 0, 0, ['v'])]
571 T.w = [rr('wr', '~^$', 0, ['r']), rr('wh', 0, 'o', ['h']), rr('wh', 0, 0, ['w']), rr('w', '[aeiou]', '$', []), rr('w', 0, 0, ['w'])]
572 T.x = [rr('x', '~^$', 0, ['z']), rr('x', 0, 0, ['k', 's'])]
573 T.y = [
574 rr('y', '~^$', 0, ['y']),
575 rr('y', 0, 0, ['ay'], 1),
576 rr('y', '~[aeiou]', '$', ['iy']),
577 rr('y', 0, '$', ['ay']),
578 rr('y', 0, '[aeiou]', ['y']),
579 rr('y', 0, 0, ['ih']),
580 ]
581 T.z = [rr('zz', 0, 0, ['z']), rr('z', 0, 0, ['z'])]
582 return T
583}
584
585// Built on the first word, not at load: nothing is built before the session's first line.
586let rules: Record<string, Rule[]> | null = null
587
588function own(k: string): string[] | null {
589 return Object.prototype.hasOwnProperty.call(EXCEPTIONS, k) ? [...EXCEPTIONS[k]!] : null
590}
591
592function hasVowelLetter(s: string): boolean {
593 return /[aeiouy]/.test(s)
594}
595
596function vowelGroups(s: string): number {
597 return s.match(/[aeiouy]+/g)?.length ?? 0
598}
599
600// silent-e / open-syllable context after the vowel that starts at i
601function magicE(w: string, i: number, len: number): boolean {
602 const rest = w.substring(i + len)
603 if (/^(?:[bcdfgklmnprstvz]|th)e$/.test(rest)) return true
604 const first = !/[aeiouy]/.test(w.substring(0, i))
605 if (first && /^(?:[bcdfgklmnprstvz]|th)le$/.test(rest)) return true
606 if (first && w.charAt(i) !== 'e' && /^(?:[bcdfgklmnprstvz]|th)(?:er|en)$/.test(rest)) return true
607 return false
608}
609
610function applyRules(w: string): string[] {
611 const out: string[] = []
612 let i = 0
613 let guard = 0
614 while (i < w.length && guard++ < 200) {
615 const list = (rules ??= buildRules())[w.charAt(i)]
616 let hit = false
617 for (const rl of list ?? []) {
618 if (w.substr(i, rl.s.length) !== rl.s) continue
619 if (rl.l && !rl.l.test(w.substring(0, i))) continue
620 if (rl.r && !rl.r.test(w.substring(i + rl.s.length))) continue
621 if (rl.m && !magicE(w, i, rl.s.length)) continue
622 out.push(...rl.o)
623 i += rl.s.length
624 hit = true
625 break
626 }
627 if (!hit) i++
628 }
629 return out
630}
631
632// --- stress
633function prefixShift(s: string): boolean {
634 const m = /^(?:con|com|pre|pro|sub|dis|per|ad|be|de|re|in|un|ex|en|em|a)/.exec(s)
635 if (!m) return false
636 const p = m[0]
637 const rest = s.substring(p.length)
638 if (rest.length < 3) return false
639 const c0 = rest.charAt(0)
640 if (/[aeiou]$/.test(p) && 'aeiouy'.indexOf(c0) >= 0) return false
641 // a doubled consonant closes the first syllable (bet-ter, ap-ple) -- except after 'a', where a+CC words are nearly all end-stressed
642 if (p !== 'a' && c0 === rest.charAt(1)) return false
643 return true
644}
645
646function stressAndReduce(toks: string[], spelling: string): string[] {
647 const vi: number[] = []
648 toks.forEach((t, k) => {
649 if (VOWELS[t]) vi.push(k)
650 })
651 const nv = vi.length
652 if (nv === 0) return toks
653 const at = (n: number) => vi[n]!
654 if (nv === 1) {
655 toks[at(0)] += '1'
656 return toks
657 }
658
659 const pfx = prefixShift(spelling)
660 let s: number
661 if (/(?:tion|sion|cian)s?$/.test(spelling)) s = nv - 2
662 else if (/(?:ity|ities|ical|ically)$/.test(spelling)) s = nv - 3
663 else if (pfx) s = 1
664 else s = 0
665 if (s < 0) s = 0
666 if (s > nv - 1) s = nv - 1
667 // never put the accent on a schwa
668 while (s < nv && toks[at(s)] === 'ax') s++
669 if (s >= nv) {
670 s = 0
671 while (s < nv && toks[at(s)] === 'ax') s++
672 }
673 if (s >= nv) s = 0
674
675 for (let k = 0; k < nv; k++) {
676 let t = toks[at(k)]!
677 if (k === s) {
678 toks[at(k)] = t + '1'
679 continue
680 }
681 if (k > 0 && k < nv - 1 && REDUCIBLE[t]) t = 'ax'
682 else if (k === 0 && pfx) t = t === 'ih' || t === 'iy' || t === 'eh' ? 'ih' : REDUCIBLE[t] ? 'ax' : t
683 else if (k === nv - 1 && (t === 'ah' || t === 'ae' || t === 'eh' || t === 'aa')) t = 'ax'
684 toks[at(k)] = t + '0'
685 }
686 return toks
687}
688
689// --- suffixes
690function endingFor(kind: 'S' | 'ED', core: string[]): string[] {
691 const last = (core[core.length - 1] ?? '').replace(/[01]$/, '')
692 if (kind === 'S') {
693 if (SIBILANT[last]) return ['ih0', 'z']
694 return [VOICELESS[last] ? 's' : 'z']
695 }
696 if (last === 't' || last === 'd') return ['ih0', 'd']
697 return [VOICELESS[last] ? 't' : 'd']
698}
699
700function convert(w: string): string[] {
701 let hit = own(w)
702 if (hit) return hit
703
704 // possessive / contracted forms
705 const m = /^(.+)'s$/.exec(w)
706 if (m) {
707 const b = convert(m[1]!)
708 return b.concat(endingFor('S', b))
709 }
710 w = w.replace(/'/g, '')
711 hit = own(w)
712 if (hit) return hit
713 if (!w) return ['ax0']
714 if (w.length === 1) {
715 const name = LETTERS[w]
716 if (!name) return ['ax0']
717 if (w === 'w') return convert('double').concat(convert('you'))
718 return convert(name)
719 }
720
721 // --- peel one inflectional suffix
722 let core = w
723 let kind: 'S' | 'ED' | null = null
724 let tail: string[] | null = null
725 let stripped = false
726 if (/ing$/.test(w) && w.length >= 5 && hasVowelLetter(w.slice(0, -3)) && w.slice(0, -3).length >= 2) {
727 core = w.slice(0, -3)
728 tail = ['ih0', 'ng']
729 stripped = true
730 } else if (/ed$/.test(w) && w.length >= 4 && !/[aeou]ed$/.test(w) && hasVowelLetter(w.slice(0, -2)) && w.slice(0, -2).length >= 3) {
731 core = w.slice(0, -2)
732 kind = 'ED'
733 stripped = true
734 } else if (/[^s]s$/.test(w) && w.length >= 4 && !/(?:us|is|as|ss)$/.test(w)) {
735 core = w.slice(0, -1)
736 kind = 'S'
737 stripped = true
738 } else if (/ly$/.test(w) && w.length >= 6) {
739 core = w.slice(0, -2)
740 tail = ['l', 'iy0']
741 stripped = true
742 }
743 if (stripped && !hasVowelLetter(core)) {
744 core = w
745 kind = null
746 tail = null
747 stripped = false
748 }
749 // tries -> try
750 if (stripped && kind === 'S' && /ie$/.test(core) && core.length >= 3) core = core.slice(0, -2) + 'y'
751 if (stripped && (tail || kind === 'ED')) {
752 // tried -> try, studied -> study
753 if (/i$/.test(core) && core.length >= 3) core = core.slice(0, -1) + 'y'
754 // driving -> drive, hoped -> hope (single vowel + single consonant)
755 else if (kind === 'ED' || tail?.[0] === 'ih0') {
756 if (vowelGroups(core) === 1 && /(?:^|[^aeiouy])[aeiou][bcdfgklmnprstvz]$/.test(core)) core += 'e'
757 }
758 }
759
760 let body = own(core)
761 if (!body) {
762 body = applyRules(core)
763 if (!body.length) {
764 body = applyRules(w)
765 core = w
766 }
767 body = stressAndReduce(body, core)
768 }
769 if (kind) tail = endingFor(kind, body)
770 if (tail?.length) {
771 if (body.length && body[body.length - 1] === tail[0]) tail = tail.slice(1)
772 body = body.concat(tail)
773 }
774 return body
775}
776
777// ARPAbet for one word, stress digits on the vowels, never empty.
778export function wordPhones(word: string): string[] {
779 const raw = String(word ?? '')
780 try {
781 const w = raw.toLowerCase().replace(/[^a-z0-9']/g, '')
782 if (!w) return raw.length ? ['ax0'] : []
783 if (/[0-9]/.test(w)) {
784 let acc: string[] = []
785 for (const ch of w) if (ch >= '0' && ch <= '9') acc = acc.concat(convert(DIGITS[+ch]!))
786 const letters = w.replace(/[0-9]/g, '')
787 if (letters) acc = acc.concat(convert(letters))
788 return acc.length ? acc : ['ax0']
789 }
790 const res = convert(w)
791 let syl = 0
792 // safety: every vowel must carry a stress digit, nothing may be empty
793 for (let z = 0; z < res.length; z++) {
794 if (VOWELS[res[z]!]) res[z] += '0'
795 if (/[01]$/.test(res[z]!)) syl++
796 }
797 if (!res.length) return ['ax0']
798 if (!syl) res.splice(res.length > 1 ? 1 : res.length, 0, 'ax0')
799 return res
800 } catch {
801 return ['ax0']
802 }
803}
804
805export const LEXICON: Readonly<Record<string, readonly string[]>> = EXCEPTIONS
806
807// Lower-cases; `,` `;` `:` pause 190 ms, `.` `!` `?` 330 ms and terminal, whitespace 26 ms; a hyphen is whitespace ("data-stacks" as the spike said it).
808export function tokenize(text: string): Token[] {
809 const out: Token[] = []
810 for (const p of String(text || '')
811 .toLowerCase()
812 .split(/([.,;:!?]|[\s-]+)/)) {
813 if (!p || /^[\s-]+$/.test(p)) {
814 const last = out[out.length - 1]
815 if (last && 'word' in last) out.push({ pause: 26 })
816 continue
817 }
818 if (p === ',' || p === ';' || p === ':') out.push({ pause: 190 })
819 else if (p === '.' || p === '!' || p === '?') out.push({ pause: 330, isTerminal: true, isQuestion: p === '?' })
820 else {
821 // Digits stay: wordPhones reads them one by one.
822 const word = p.replace(/[^a-z0-9']/g, '')
823 if (word) out.push({ word, phones: wordPhones(word) })
824 }
825 }
826 return out
827}
828hooks/vox/lines.ts 13 lines1import type { SoundEvent } from '../../types'
2
3// What the mod says on each sound event. One pool per event; English whatever the person writes in.
4export const VOX_LINES: Readonly<Record<SoundEvent, readonly string[]>> = {
5 'rite-done': ['Rite concluded.', 'Praise the Omnissiah.', 'The Machine Spirit is appeased.', 'The Omnissiah protects.', 'The rite is complete.'],
6 heresy: ['Tech heresy detected.', 'Heresy. Proceed with caution, Magos.', 'This rite is forbidden.', 'The Machine Spirit recoils.'],
7 displeased: ['The Machine Spirit is displeased.', 'The rite has faltered.', 'Apply the sacred unguents.'],
8 sanction: ['Your sanction is required, Magos.', 'The rite awaits your blessing.', 'Awaiting your command, Magos.'],
9 severed: ['Noosphere link severed.', 'The vox is silent.', 'Communion with the Noosphere has failed.'],
10 servitor: ['A servitor awaits your command.', 'A servitor requests guidance.'],
11 engrams: ['Memory engrams consolidated.', 'The data-stacks are compressed.'],
12}
13hooks/vox/synth.ts 350 lines1// Klatt-style cascade formant synthesizer: English text to 22,050 Hz mono samples and their timed segments. Derived from formant-tts, https://github.com/PrakharSaxena24/formant-tts, tts.js at commit 87261ce6f322048903d17b1064a2605f9ea7aa37. MIT licensed. formant-tts's notice: "MIT licensed. No dependencies, no build step, no network access." Cut down to the English path (no Japanese, no browser playback, no WAV), with the fixes the spike heard: frication after the radiation stage under its own gain, breath under its own gain, softer sibilants and bursts, phrase-final lengthening, per-syllable lift and length, and the voices' pitch, tract and pulse controls.
2
3import { type Token, tokenize } from './lexicon'
4
5export const SR = 22050
6const FRAME_MS = 5
7const FRAME_LEN = Math.round((SR * FRAME_MS) / 1000)
8// Segment times are in frame ms: formant-tts's 5 ms frame is 110 samples (not 110.25), so 1 ms is 22 samples. A segment spans samples [startMs * SAMPLES_PER_MS, (startMs + durMs) * SAMPLES_PER_MS); SR / 1000 would drift ~7 ms by a line's end.
9export const SAMPLES_PER_MS = FRAME_LEN / FRAME_MS
10
11// A stretch of the utterance, timed in frame ms (× SAMPLES_PER_MS for samples): a vowel's phone is its ARPAbet id without the stress digit, and isAccent marks the stressed ones.
12export type Segment = { kind: 'pause' | 'vowel' | 'closure' | 'burst' | 'aspiration' | 'fric' | 'nasal' | 'glide'; phone?: string; startMs: number; durMs: number; isAccent?: boolean }
13
14// pitch in Hz, rate, the noise seed, the frication and breath gains, the final and comma lengthening; declination, a formant scale, pitch steps in semitones, a tract hold in frames, the glottal pulse's opening and closing fractions; a pitch curve over ms, the stressed vowels' lift and length; and the samples per coefficient update (1 is formant-tts's per sample).
15export type SynthOptions = {
16 pitch: number; rate?: number; seed?: number; fricGain?: number; aspGain?: number; finalStretch?: number; commaStretch?: number
17 decl?: number; fscale?: number; quant?: number; hold?: number; tp?: number; tn?: number
18 f0mod?: (ms: number) => number; syl?: () => { lift: number; len: number }; block?: number
19}
20
21export function mulberry32(seed: number): () => number {
22 let a = seed >>> 0
23 return () => {
24 a |= 0
25 a = (a + 0x6d2b79f5) | 0
26 let t = Math.imul(a ^ (a >>> 15), 1 | a)
27 t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
28 return ((t ^ (t >>> 14)) >>> 0) / 4294967296
29 }
30}
31const clamp = (v: number, lo: number, hi: number) => (v < lo ? lo : v > hi ? hi : v)
32const lerp = (a: number, b: number, t: number) => a + (b - a) * t
33
34// Two-pole resonator, unity gain at DC (Klatt): y[n] = a x[n] + b y[n-1] + c y[n-2].
35class Resonator {
36 y1 = 0
37 y2 = 0
38 a = 1
39 b = 0
40 c = 0
41 set(freq: number, bw: number) {
42 const r = Math.exp((-Math.PI * bw) / SR)
43 this.c = -r * r
44 this.b = 2 * r * Math.cos((2 * Math.PI * freq) / SR)
45 this.a = 1 - this.b - this.c
46 }
47 run(x: number) {
48 const y = this.a * x + this.b * this.y1 + this.c * this.y2
49 this.y2 = this.y1
50 this.y1 = y
51 return y
52 }
53}
54
55// A zero pair, the nasal notch.
56class AntiResonator {
57 x1 = 0
58 x2 = 0
59 a = 1
60 b = 0
61 c = 0
62 set(freq: number, bw: number) {
63 const r = Math.exp((-Math.PI * bw) / SR)
64 const c = -r * r, b = 2 * r * Math.cos((2 * Math.PI * freq) / SR), a = 1 - b - c
65 this.a = 1 / a
66 this.b = -b / a
67 this.c = -c / a
68 }
69 run(x: number) {
70 const y = this.a * x + this.b * this.x1 + this.c * this.x2
71 this.x2 = this.x1
72 this.x1 = x
73 return y
74 }
75}
76
77type Formants = readonly number[]
78// Diphthongs carry a second target `g`; the frames glide between the two.
79const VOWELS: Record<string, { f: Formants; g?: Formants }> = {
80 iy: { f: [280, 2250, 2890, 3400, 4500] },
81 ih: { f: [400, 1920, 2560, 3400, 4500] },
82 eh: { f: [550, 1770, 2490, 3400, 4500] },
83 ae: { f: [690, 1660, 2490, 3400, 4500] },
84 aa: { f: [730, 1090, 2440, 3400, 4500] },
85 ao: { f: [570, 840, 2410, 3400, 4500] },
86 uh: { f: [440, 1020, 2240, 3400, 4500] },
87 uw: { f: [310, 870, 2250, 3400, 4500] },
88 ah: { f: [640, 1190, 2390, 3400, 4500] },
89 ax: { f: [500, 1500, 2500, 3400, 4500] },
90 er: { f: [490, 1350, 1690, 3400, 4500] },
91 ey: { f: [480, 1800, 2530, 3400, 4500], g: [340, 2200, 2800, 3400, 4500] },
92 ay: { f: [730, 1090, 2440, 3400, 4500], g: [320, 2150, 2800, 3400, 4500] },
93 oy: { f: [570, 840, 2410, 3400, 4500], g: [330, 2100, 2800, 3400, 4500] },
94 aw: { f: [730, 1090, 2440, 3400, 4500], g: [330, 900, 2300, 3400, 4500] },
95 ow: { f: [500, 900, 2500, 3400, 4500], g: [360, 800, 2400, 3400, 4500] },
96}
97
98// Consonant place: the formant locus the neighbouring vowel moves out of.
99const PLACE: Record<string, Formants> = {
100 labial: [250, 800, 2100, 3300, 4500],
101 labiodental: [280, 1100, 2300, 3300, 4500],
102 dental: [300, 1600, 2600, 3400, 4500],
103 alveolar: [300, 1750, 2600, 3400, 4500],
104 palatal: [280, 2100, 2900, 3500, 4600],
105 velarFront: [300, 2050, 2450, 3400, 4500],
106 velarBack: [300, 1150, 2200, 3300, 4500],
107 lateral: [360, 1300, 2700, 3400, 4500],
108 retroflex: [310, 1060, 1380, 3300, 4500],
109 nasalLabial: [250, 900, 2100, 3300, 4500],
110 nasalAlveolar: [250, 1600, 2600, 3400, 4500],
111 nasalVelar: [250, 1200, 2300, 3300, 4500],
112}
113
114type Noise = { f: number; bw: number; amp: number }
115type Cons = { manner: 'stop' | 'fric' | 'affr' | 'asp' | 'nasal' | 'glide'; voiced: boolean; place: string; vot?: number; burst?: Noise; noise?: Noise; zero?: number }
116// formant-tts's English consonants, with the spike's levels: noise amps cut (the raw "s" sat 15 dB over the vowels), t k p bursts at 0.3 of formant-tts's and d g b at 0.5.
117const CONS: Record<string, Cons> = {
118 p: { manner: 'stop', voiced: false, place: 'labial', vot: 48, burst: { f: 900, bw: 1200, amp: 0.12 } },
119 b: { manner: 'stop', voiced: true, place: 'labial', vot: 12, burst: { f: 850, bw: 1200, amp: 0.125 } },
120 t: { manner: 'stop', voiced: false, place: 'alveolar', vot: 52, burst: { f: 3800, bw: 1200, amp: 0.165 } },
121 d: { manner: 'stop', voiced: true, place: 'alveolar', vot: 14, burst: { f: 3600, bw: 1200, amp: 0.15 } },
122 k: { manner: 'stop', voiced: false, place: 'velar', vot: 58, burst: { f: 2100, bw: 1000, amp: 0.15 } },
123 g: { manner: 'stop', voiced: true, place: 'velar', vot: 16, burst: { f: 2000, bw: 1000, amp: 0.14 } },
124 f: { manner: 'fric', voiced: false, place: 'labiodental', noise: { f: 4200, bw: 2200, amp: 0.12 } },
125 v: { manner: 'fric', voiced: true, place: 'labiodental', noise: { f: 3800, bw: 2200, amp: 0.07 } },
126 th: { manner: 'fric', voiced: false, place: 'dental', noise: { f: 5600, bw: 2400, amp: 0.09 } },
127 dh: { manner: 'fric', voiced: true, place: 'dental', noise: { f: 4600, bw: 2400, amp: 0.05 } },
128 s: { manner: 'fric', voiced: false, place: 'alveolar', noise: { f: 6200, bw: 1100, amp: 0.16 } },
129 z: { manner: 'fric', voiced: true, place: 'alveolar', noise: { f: 5200, bw: 1200, amp: 0.09 } },
130 sh: { manner: 'fric', voiced: false, place: 'palatal', noise: { f: 2900, bw: 900, amp: 0.2 } },
131 zh: { manner: 'fric', voiced: true, place: 'palatal', noise: { f: 2650, bw: 900, amp: 0.1 } },
132 h: { manner: 'asp', voiced: false, place: 'vowelish' },
133 ch: { manner: 'affr', voiced: false, place: 'palatal', noise: { f: 3000, bw: 900, amp: 0.22 } },
134 jh: { manner: 'affr', voiced: true, place: 'palatal', noise: { f: 2700, bw: 900, amp: 0.12 } },
135 m: { manner: 'nasal', voiced: true, place: 'nasalLabial', zero: 1000 },
136 n: { manner: 'nasal', voiced: true, place: 'nasalAlveolar', zero: 1600 },
137 ng: { manner: 'nasal', voiced: true, place: 'nasalVelar', zero: 2000 },
138 l: { manner: 'glide', voiced: true, place: 'lateral' },
139 r: { manner: 'glide', voiced: true, place: 'retroflex' },
140 w: { manner: 'glide', voiced: true, place: 'labial' },
141 y: { manner: 'glide', voiced: true, place: 'palatal' },
142}
143
144const isVowel = (ph: string) => /[01]$/.test(ph)
145const vowelId = (ph: string) => ph.replace(/[01]$/, '')
146
147function locus(def: Cons, ctx: string): Formants {
148 if (def.place === 'velar') return ['iy', 'ih', 'ey', 'eh', 'ae'].includes(ctx) ? PLACE.velarFront! : PLACE.velarBack!
149 if (def.place === 'vowelish') return (VOWELS[ctx] ?? VOWELS.ah!).f
150 return PLACE[def.place] ?? PLACE.alveolar!
151}
152
153// formant-tts's segment: the public one plus what the frames need (consonant, formant targets, pitch lift, pause kind).
154type Seg = {
155 kind: Segment['kind']; dur: number; startMs: number; phone?: string; def?: Cons; f?: Formants; g?: Formants
156 accent?: boolean; lift?: number; voiced?: boolean; zero?: number; terminal?: boolean; question?: boolean
157}
158
159function segmentsOf(tokens: Token[], o: SynthOptions): Seg[] {
160 const rate = o.rate || 1, final = o.finalStretch ?? 1.6, comma = o.commaStretch ?? 1.25
161 const segs: Seg[] = [{ kind: 'pause', dur: 60, startMs: 0 }]
162 const push = (s: Omit<Seg, 'startMs'>) => segs.push({ ...s, startMs: 0 })
163 for (let i = 0; i < tokens.length; i++) {
164 const tok = tokens[i]!
165 if ('pause' in tok) {
166 push({ kind: 'pause', dur: tok.pause / rate, terminal: !!tok.isTerminal, question: !!tok.isQuestion })
167 continue
168 }
169 // Phrase-final lengthening: the stressed vowel of the last word before a stop (or, less, a comma) is held longer.
170 let stretch = 1, q = i + 1
171 for (; q < tokens.length; q++) {
172 const next = tokens[q]!
173 if ('word' in next) break
174 if (next.pause > 26) {
175 stretch = next.isTerminal ? final : comma
176 break
177 }
178 }
179 if (q >= tokens.length) stretch = final
180 const ph = tok.phones
181 for (let j = 0; j < ph.length; j++) {
182 const t = ph[j]!
183 if (isVowel(t)) {
184 const id = vowelId(t), stressed = t.endsWith('1'), spec = VOWELS[id] ?? VOWELS.ax!
185 const dur = ((stressed ? 148 : id === 'ax' ? 58 : 80) + (spec.g ? 45 : 0)) * (stressed ? stretch : 1)
186 // Every vowel draws, so a take's draws stay in step whatever the stress pattern.
187 const sv = o.syl ? o.syl() : { lift: 1.1, len: 1 }
188 push({ kind: 'vowel', phone: id, f: spec.f, g: spec.g, accent: stressed, lift: sv.lift, dur: (dur * (stressed ? sv.len : 1)) / rate })
189 continue
190 }
191 const def = CONS[t]
192 if (!def) continue
193 // Context vowel: the next vowel in the word, else the previous one.
194 let ctx = 'ah'
195 for (let k = j + 1; k < ph.length; k++) if (isVowel(ph[k]!)) { ctx = vowelId(ph[k]!); break }
196 if (ctx === 'ah') for (let k = j - 1; k >= 0; k--) if (isVowel(ph[k]!)) { ctx = vowelId(ph[k]!); break }
197 const f = locus(def, ctx), c = { phone: t, def, f }
198 if (def.manner === 'stop') {
199 push({ kind: 'closure', dur: (def.voiced ? 45 : 52) / rate, voiced: def.voiced, ...c })
200 push({ kind: 'burst', dur: 8 / rate, ...c })
201 push({ kind: 'aspiration', dur: def.vot! / rate, ...c })
202 } else if (def.manner === 'fric') {
203 push({ kind: 'fric', dur: (t === 's' || t === 'z' || t === 'sh' || t === 'zh' ? 95 : 68) / rate, ...c })
204 } else if (def.manner === 'affr') {
205 push({ kind: 'closure', dur: 40 / rate, voiced: def.voiced, ...c })
206 push({ kind: 'fric', dur: 55 / rate, ...c })
207 } else if (def.manner === 'asp') push({ kind: 'aspiration', dur: 55 / rate, ...c })
208 else if (def.manner === 'nasal') push({ kind: 'nasal', dur: 68 / rate, zero: def.zero, ...c })
209 else push({ kind: 'glide', dur: 60 / rate, ...c })
210 }
211 }
212 push({ kind: 'pause', dur: 130, terminal: true })
213 return segs
214}
215
216type Frame = { f0: number; av: number; ah: number; af: number; F: Formants; nasal: number; zero: number; noiseF: number; noiseBW: number }
217type Knot = { t: number; f: Formants; nasal: number; zero: number }
218
219// Formant knots per segment, linearly interpolated: the consonant-vowel transitions, not isolated steady states.
220function framesOf(segs: Seg[], o: SynthOptions): Frame[] {
221 let total = 0
222 for (const s of segs) (s.startMs = total), (total += s.dur)
223 const knots: Knot[] = []
224 for (const s of segs) {
225 if (!s.f) continue
226 const nasal = s.kind === 'nasal' ? 1 : 0, zero = s.zero || 0
227 if (s.g) {
228 knots.push({ t: s.startMs + s.dur * 0.25, f: s.f, nasal, zero })
229 knots.push({ t: s.startMs + s.dur * 0.88, f: s.g, nasal, zero })
230 } else knots.push({ t: s.startMs + s.dur / 2, f: s.f, nasal, zero })
231 }
232 if (!knots.length) knots.push({ t: 0, f: [750, 1200, 2550, 3400, 4500], nasal: 0, zero: 0 }) // no words: formant-tts's open "a"
233 const first = knots[0]!, last = knots[knots.length - 1]!
234 const knotAt = (t: number): Knot => {
235 if (t <= first.t) return first
236 if (t >= last.t) return last
237 for (let k = 0; k < knots.length - 1; k++) {
238 const a = knots[k]!, b = knots[k + 1]!
239 if (t >= a.t && t <= b.t) {
240 const p = (t - a.t) / (b.t - a.t || 1), f: number[] = []
241 for (let n = 0; n < 5; n++) f[n] = lerp(a.f[n]!, b.f[n]!, p)
242 return { t, f, nasal: lerp(a.nasal, b.nasal, p), zero: a.zero || b.zero || 0 }
243 }
244 }
245 return last
246 }
247
248 const base = o.pitch || 118, decl = o.decl ?? 0.16, frames: Frame[] = []
249 const count = Math.max(1, Math.ceil(total / FRAME_MS))
250 let si = 0
251 for (let fi = 0; fi < count; fi++) {
252 const t = fi * FRAME_MS
253 while (si < segs.length - 1 && t >= segs[si]!.startMs + segs[si]!.dur) si++
254 const seg = segs[si]!, pos = seg.dur ? (t - seg.startMs) / seg.dur : 0, k = knotAt(t)
255
256 let f0 = base * (1 - decl * (t / total))
257 if (seg.accent) f0 *= seg.lift || 1.1
258 if (seg.question) f0 *= 1.25
259 if (seg.terminal && !seg.question) f0 *= 0.86
260 f0 *= 1 + 0.012 * Math.sin(t / 190)
261 if (o.f0mod) f0 *= o.f0mod(t)
262 if (o.quant) f0 = base * Math.pow(2, (Math.round((12 * Math.log2(f0 / base)) / o.quant) * o.quant) / 12)
263
264 let av = 0, ah = 0, af = 0, noise: Noise | undefined
265 const def = seg.def
266 if (seg.kind === 'vowel') av = 1
267 else if (seg.kind === 'glide') av = 0.85
268 else if (seg.kind === 'nasal') av = 0.72
269 else if (seg.kind === 'closure') av = seg.voiced ? 0.12 : 0
270 else if (seg.kind === 'burst') (noise = def?.burst), (af = noise ? noise.amp : 0.4), (av = def?.voiced ? 0.2 : 0)
271 else if (seg.kind === 'aspiration') (ah = seg.phone === 'h' ? 0.5 : 0.34), (av = def?.voiced ? 0.35 : 0)
272 else if (seg.kind === 'fric') (noise = def?.noise ?? { f: 5000, bw: 1200, amp: 0.4 }), (af = noise.amp), (av = def?.voiced ? 0.4 : 0)
273 // Edge tapers stop clicks at the joins.
274 if (seg.kind === 'vowel') av *= Math.min(1, clamp(pos / 0.12, 0, 1)) * Math.min(1, clamp((1 - pos) / 0.16, 0, 1)) * 0.85 + 0.15
275
276 const fs = o.fscale
277 frames.push({
278 f0, av, ah, af,
279 F: fs ? k.f.map((v) => v * fs) : k.f,
280 nasal: k.nasal, zero: k.zero,
281 noiseF: noise ? noise.f : 0, noiseBW: noise ? noise.bw : 1000,
282 })
283 }
284 return frames
285}
286
287const BW = [90, 110, 170, 250, 300]
288
289function samplesOf(frames: Frame[], o: SynthOptions): Float32Array {
290 const rnd = mulberry32(o.seed || 20260802)
291 const out = new Float32Array(frames.length * FRAME_LEN)
292 const cascade = BW.map(() => new Resonator())
293 const nasalPole = new Resonator(), nasalZero = new AntiResonator(), fricRes = new Resonator()
294 const tp = o.tp || 0.42, tn = o.tn || 0.18, fricGain = o.fricGain ?? 0.09, aspGain = o.aspGain ?? 0.22, hold = o.hold || 0
295 const block = Math.max(1, Math.round(o.block ?? 32))
296 let phase = 0, prevOut = 0, dcX = 0, dcY = 0, idx = 0
297 nasalPole.set(270, 120)
298 for (let fi = 0; fi < frames.length; fi++) {
299 // A held tract steps every `hold` frames with no glide between.
300 const cur = frames[hold ? fi - (fi % hold) : fi]!, nxt = hold ? cur : frames[Math.min(fi + 1, frames.length - 1)]!
301 // The nasal zero and the frication filter depend on the frame alone, so one set per frame equals formant-tts's per-sample one.
302 nasalZero.set(cur.zero || nxt.zero || 1500, 250)
303 fricRes.set(cur.noiseF || nxt.noiseF || 4000, cur.noiseBW || 1000)
304 for (let s = 0; s < FRAME_LEN; s++) {
305 const p = s / FRAME_LEN
306 const f0 = lerp(cur.f0, nxt.f0, p), av = lerp(cur.av, nxt.av, p), ah = lerp(cur.ah, nxt.ah, p), af = lerp(cur.af, nxt.af, p)
307 const nasalAmt = lerp(cur.nasal, nxt.nasal, p)
308 // The tract's coefficients (ten exp and cos) once per block, taken at its middle; block 1 is formant-tts's per sample.
309 if (s % block === 0) {
310 const pc = (s + (Math.min(block, FRAME_LEN - s) - 1) / 2) / FRAME_LEN, widen = 1 + 0.6 * lerp(cur.nasal, nxt.nasal, pc)
311 for (let r = 0; r < 5; r++) cascade[r]!.set(lerp(cur.F[r]!, nxt.F[r]!, pc), BW[r]! * widen)
312 }
313
314 // Rosenberg glottal pulse with light jitter.
315 phase += (f0 * (1 + (rnd() - 0.5) * 0.008)) / SR
316 if (phase >= 1) phase -= 1
317 let g = 0
318 if (phase < tp) g = 0.5 * (1 - Math.cos((Math.PI * phase) / tp))
319 else if (phase < tp + tn) g = Math.cos((Math.PI * (phase - tp)) / (2 * tn))
320
321 const white = rnd() * 2 - 1
322 let y = g * av + white * ah * 0.5 * aspGain
323 for (let c = 0; c < 5; c++) y = cascade[c]!.run(y)
324 if (nasalAmt > 0.01) y = lerp(y, nasalZero.run(nasalPole.run(y)) * 1.4, nasalAmt)
325
326 // Frication joins after the radiation stage: before it, the noise took the +6 dB/oct lip tilt.
327 const fric = af > 0.001 ? fricRes.run(white) * af * 0.9 * fricGain : 0
328 const radiated = y - prevOut * 0.94 + fric
329 prevOut = y
330 dcY = 0.995 * (dcY + radiated - dcX)
331 dcX = radiated
332 out[idx++] = dcY
333 }
334 }
335 let peak = 0
336 for (const v of out) if (Math.abs(v) > peak) peak = Math.abs(v)
337 const gain = peak > 0 ? 0.82 / peak : 1
338 for (let i = 0; i < out.length; i++) out[i] = clamp(out[i]! * gain, -1, 1)
339 return out
340}
341
342export function synth(text: string, o: SynthOptions): { samples: Float32Array; segments: Segment[] } {
343 const segs = segmentsOf(tokenize(text), o)
344 const samples = samplesOf(framesOf(segs, o), o)
345 const segments = segs.map(
346 (s): Segment => ({ kind: s.kind, phone: s.phone, startMs: s.startMs, durMs: s.dur, ...(s.kind === 'vowel' ? { isAccent: !!s.accent } : {}) }),
347 )
348 return { samples, segments }
349}
350types/index.d.ts 49 lines1export type Switch = 'spinner' | 'turnline' | 'hint' | 'band' | 'voice' | 'rites' | 'sound' | 'panel' | 'pulse' | 'spirits' | 'roster' | 'sublog' | 'split'
2// The seven events that sound a clip and, with vox on, speak a line.
3export type SoundEvent = 'rite-done' | 'heresy' | 'displeased' | 'sanction' | 'severed' | 'servitor' | 'engrams'
4export type VoxSetting = 'default' | 'mars' | 'cogitator' | 'forge' | 'claude' | 'off'
5export type Volumes = { master: number; beeps: number; vox: number }
6// `agent`: the servitor's type when a servitor made the call (`fork` for an id no spawn recorded); absent for the main loop.
7export type RiteEntry = { tool: string; label: string; ok: boolean; agent?: string }
8export type HeresyEntry = { at: number; command: string }
9export type Overrides = { master: boolean | null; switches: Partial<Record<Switch, boolean>> }
10export type ActiveTool = { name: string; phrase: string }
11// Rites per minute: `counts` ends at minute `last` (minutes since the epoch of $.clock.now()).
12export type Pulse = { last: number; counts: number[] }
13// One agent other than the main loop, by its agent id. No `endedAt`: it is live.
14export type Servitor = { type: string; description: string; startedAt: number; endedAt?: number; ok?: boolean; rites: number }
15export type Telemetry = {
16 forge?: string
17 contextPct?: number
18 costUsd?: number
19 fiveHourPct?: number
20 branch?: string
21 rites: number
22 failed: number
23 heresies: number
24}
25
26declare module 'claude-code' {
27 interface PluginState {
28 litany: {
29 overrides: Overrides
30 tool: ActiveTool | null
31 law: number
32 telemetry: Telemetry
33 heresyLine: string | null
34 failedThisTurn: boolean
35 ignited: boolean
36 riteLog: RiteEntry[]
37 heresyLog: HeresyEntry[]
38 pulse: Pulse
39 spirits: Record<string, number>
40 agents: Record<string, Servitor>
41 servitorPulse: Pulse
42 panelOpen: boolean | null
43 themeOverride: string | null
44 voxOverride: VoxSetting | null
45 volumeOverride: Partial<Volumes>
46 }
47 }
48}
49