SLOPSHOPPER

litany

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

newpanebandspinnerguardcommand
v0.3.0MITupdated 2026-10-22shakespear-dev/litany
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · litany
│ ┃ litany ✕ › fix the failing auth test and╭───────────────────────────────────╮ │ ┃ app @ feat/auth-refresh … │ litany │ │ ┃ ⏺ Read(src/auth.ts) │ The Machine Spirit is displeased. │ │ ┃ ─────────────────────────────────────────… ⎿ Read 6 lines ╰───────────────────────────────────╯ │ ┃ COGITATOR … ⏺ Update(src/auth.ts) ╭────────────────────────────────────────────╮ │ ┃ ▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱… ⎿ Added 2 lines, re│ litany │ │ ┃ 5H WINDOW … ⏺ Bash(bun test) │ TECH-HERESY: rm -rf — Law XV: Flesh is │ │ ┃ ▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱… ⎿ 3 pass, 1 fail │ fallible, but ritual honours the machine │ │ ┃ $0.42 tithe 1 turns 9 rites 1 failed │ spirit. │ │ ┃ ● Done. refresh now re╰────────────────────────────────────────────╯ │ ┃ RITES ···································… │ ┃ + Read auth.ts ⚙ Rite concluded in 42s │ ┃ + Grep │ ┃ + Edit auth.ts › /litany │ ┃ + Write audit.ts ⎿ litany: +++ Litany +++ │ ┃ + Write cache.ts ⎿ litany: spinner on │ ┃ ✗ Bash bun test ⎿ litany: turnline on │ ┃ + Bash git status --porcelain ⎿ litany: hint on │ ┃ + Bash rm -rf build && git push --forc… ⎿ litany: band on │ ┃ + Bash cat .env ⎿ litany: voice on │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ ✻ Communing with the Machine Spirit… ⚙ NOOSPHERE │ cogitator 49% │ rites 9 · 1 failed │ forge feat/auth-refresh │ tithe $0.42 │ 5h 31% TECH-HERESY: rm -rf — Law XV: Flesh is fallible, but ritual honours the machine spirit. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⚙ NOOSPHERE │ cogitator 49% │ rites 9 · 1 failed │ forge feat/auth-refresh │ tithe $0.42 │ 5h 31% TECH-HERESY: rm -rf — Law XV: Flesh is fallible, but ritual honours the machine spirit.
Pane · litany
app @ feat/auth-refresh ○ DORMANT ────────────────────────────────────────────────────── COGITATOR 49% ▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱ 5H WINDOW 31% ▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱ $0.42 tithe 1 turns 9 rites 1 failed RITES ········································· last 9 + Read auth.ts + Grep + Edit auth.ts + Write audit.ts + Write cache.ts ✗ Bash bun test + Bash git status --porcelain + Bash rm -rf build && git push --force origin main + Bash cat .env HERESY ············································· 1 ! 08:53 rm -rf build && git push --force origin ma… ⚙ LAW II The spirit is the spark of life.
Turn line
⚙ Rite concluded in 42s
README

⚙ litany

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.

ci License: MIT Claude Code mod macOS | Linux | Windows (untested)-555)

<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">

Install

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.

What you get

SpinnerPhrases follow the active tool: Anointing the cogitator for Bash, Sifting the Librarium data-stacks for Read and Grep.
Turn lineRite concluded in 3s instead of Baked for 3s.
BandThe Noosphere line above the prompt: context, tool calls, branch, cost, 5-hour window, heresies.
PanelThe 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.
VoiceA short +++ … +++ opener and closer on chat replies. Never in code, commits, tool inputs or text you'll send to someone else.
RitesWarns, never blocks, on rm -rf, force-push, reset --hard, `curl \sh, DROP TABLE` and friends.
SoundSeven synthesized clips: long turn done, heresy, tool failure, permission waiting, API error, subagent waiting, auto-compaction.
VoxA short spoken line after each of those, from a formant speech synthesizer built into the mod. No recordings, no network.

Commands

/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.

Themes and voices

ThemeLookVoice
mars (default)red and bronze servo-skulla tech-priest chanting through a vox-horn
cogitatorgreen CRT cogthe console's mechanical woman
forgeamber tech-priest at a consolethe ancient, unstable engine
claudeyour Claude Code colours, best on light backgroundsthe clean one

Requirements

  • Claude Code with mods, in a terminal or the desktop app.
  • macOS: nothing else.
  • Windows (untested): nothing else; sound should play through the built-in PowerShell. Nobody has tried it on Windows yet.
  • Linux: one of 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.

Contributing

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

A note to Games Workshop

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.

Disclaimer

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.

Credits

The speech synthesizer is derived from formant-tts by Prakhar Saxena (MIT), with fixes.

License

MIT for the code, the ASCII art and the sounds. The licence grants no rights in Games Workshop's intellectual property.

Source 11 files
hooks/register.tsx 706 lines
1import { 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}
706
hooks/lore.ts 357 lines
1import 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}
357
hooks/servitors.ts 64 lines
1import 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}
64
hooks/slate.ts 219 lines
1import 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}
219
hooks/themes.ts 379 lines
1import 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, '&amp;')
349    .replace(/</g, '&lt;')
350    .replace(/>/g, '&gt;')
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}
379
hooks/vox/dsp.ts 136 lines
1// 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}
136
hooks/vox/voices.ts 80 lines
1// 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}
80
hooks/vox/lexicon.ts 828 lines
1// 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}
828
hooks/vox/lines.ts 13 lines
1import 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}
13
hooks/vox/synth.ts 350 lines
1// 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}
350
types/index.d.ts 49 lines
1export 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