Live TV play-by-play of your Claude Code session, spoken aloud with crowd effects

Live TV play-by-play of your Claude Code session, spoken aloud with crowd effects.

Play-by-play commentary on the session, spoken aloud with generated crowd audio (cheers on passing tests, groans on fouls). /caster booth opens the booth pane. Loud. You have been warned.
In a Claude Code session (2.1.287 or later):
/plugin marketplace add OneWave-AI/claude-code-mods
/plugin install sportscaster@claude-code-mods
Or load this folder for one session: claude --plugin-dir ./sportscaster
| Mod | Network | Runs processes | Files | Calls a model | Sends data anywhere |
|---|---|---|---|---|---|
| sportscaster | No | No | No | Yes, a summary of recent tool calls | Only to your Claude model |
Run claude plugin validate ./sportscaster to list every event it hooks and every call it makes. See the repository README for the full security notes.
Part of claude-code-mods by OneWave AI. MIT licensed.
hooks/register.tsx 291 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Fx, Mic, Score, Ticker } from '../types'
5import { gameClock, head, moodOf, revealed, wrap } from './art'
6import { ANNOUNCER, Booth, DEMO, Slot, announcerPrompt, speakable, turnPlay } from './logic'
7import type { Play } from './logic'
8import { fxClip } from './wav'
9
10const VOICE = 'Daniel'
11const GOLD = '#c2a87e'
12const INK = '#0B1121'
13const PAPER = '#E8E2D6'
14const RED = '#e5484d'
15const PANE = 'sportscaster'
16const FEED_KEEP = 40
17const MOUTH_MS = 110
18
19const ticker = atom({ plugin: 'sportscaster', key: 'ticker' } as const, null as Ticker | null)
20const isLive = atom({ plugin: 'sportscaster', key: 'isLive' } as const, false)
21const feed = atom({ plugin: 'sportscaster', key: 'feed' } as const, [] as Ticker[])
22const mic = atom({ plugin: 'sportscaster', key: 'mic' } as const, { isTalking: false, startedAt: 0, frame: -1 } as Mic)
23const score = atom({ plugin: 'sportscaster', key: 'score' } as const, { plays: 0, fouls: 0, startedAt: 0 } as Score)
24
25let mouth: { cancel: () => void } | null = null
26
27const openBooth = ($: EngineInterface) => $.ui.open({ id: PANE, title: 'The Booth' })
28
29/** Puts a call on the air: the ticker, the top of the play-by-play, and the scoreboard. */
30const post = async ($: EngineInterface, line: string, at: number, fx: Fx) => {
31 const now = await $.clock.now()
32 await update($, score, s => ({ plays: s.plays + 1, fouls: s.fouls + (fx === 'boo' ? 1 : 0), startedAt: s.startedAt || now }))
33 const s = await read($, score)
34 const entry: Ticker = { line, at: now - s.startedAt, fx }
35 await update($, ticker, () => ({ line, at, fx }))
36 await update($, feed, list => [entry, ...list].slice(0, FEED_KEEP))
37}
38
39const resetGame = async ($: EngineInterface) => {
40 await update($, feed, () => [])
41 await update($, score, () => ({ plays: 0, fouls: 0, startedAt: 0 }))
42}
43
44const booth = new Booth()
45const slot = new Slot()
46const recent: string[] = []
47let isOnAir = false
48let isDemo = false
49let voice: string | undefined = VOICE
50
51const fieldsOf = (e: unknown) => e as Record<string, unknown>
52
53const say = async ($: EngineInterface, line: string) => {
54 // The mouth flaps for as long as the voice is reading the line.
55 mouth?.cancel()
56 await update($, mic, () => ({ isTalking: true, startedAt: 0, frame: 0 }))
57 const startedAt = await $.clock.now()
58 await update($, mic, m => ({ ...m, startedAt }))
59 const timer = $.clock.every(MOUTH_MS, () => void update($, mic, m => ({ ...m, frame: m.frame + 1 })).catch(() => undefined))
60 mouth = timer
61 try {
62 await speak($, line)
63 } finally {
64 timer.cancel()
65 if (mouth === timer) {
66 mouth = null
67 await update($, mic, m => ({ ...m, isTalking: false, frame: -1 }))
68 }
69 }
70}
71
72const speak = async ($: EngineInterface, line: string) => {
73 try {
74 await $.audio.speak(line, voice ? { voice } : undefined)
75 } catch {
76 // The voice is not installed here: fall back to the system default once.
77 if (!voice) return
78 voice = undefined
79 try {
80 await $.audio.speak(line)
81 } catch {
82 // no voice at all: stay silent
83 }
84 }
85}
86
87const crowd = async ($: EngineInterface, fx: Fx) => {
88 if (!fx) return
89 try {
90 await $.audio.play({ base64: fxClip(fx), mime: 'audio/wav' }, { gain: fx === 'cheer' ? 0.7 : 0.6 })
91 } catch {
92 // no audio device: the play-by-play still shows
93 }
94}
95
96const call = async ($: EngineInterface, play: Play) => {
97 let line = ''
98 try {
99 const reply = await $.model.complete({
100 model: 'haiku',
101 system: ANNOUNCER,
102 prompt: announcerPrompt(play, recent.slice(-4)),
103 maxTokens: 60,
104 effort: 'low',
105 })
106 if (reply.isAnswered) line = speakable(reply.text)
107 } catch {
108 // the model is unavailable: fall back to the scripted line
109 }
110 return line || play.fallback
111}
112
113/** One utterance at a time; the slot holds at most one play behind it. */
114const onAir = async ($: EngineInterface) => {
115 if (isOnAir || isDemo || slot.isEmpty) return
116 isOnAir = true
117 try {
118 const play = slot.take(await $.clock.now())
119 if (!play) return
120 const line = await call($, play)
121 recent.push(line)
122 if (recent.length > 8) recent.shift()
123 await post($, line, play.at, play.fx)
124 void crowd($, play.fx)
125 await say($, line)
126 } finally {
127 isOnAir = false
128 }
129}
130
131const runDemo = async ($: EngineInterface) => {
132 isDemo = true
133 await resetGame($)
134 let wait = 0
135 DEMO.forEach(([delay, line, fx], i) => {
136 wait += delay
137 $.clock.after(wait, () => {
138 void (async () => {
139 await post($, line, wait, fx)
140 void crowd($, fx)
141 await say($, line)
142 if (i === DEMO.length - 1) isDemo = false
143 })()
144 })
145 })
146}
147
148export const register: Register = on => {
149 on('session.start', async ($, e, next) => {
150 await $.command.register({
151 name: 'caster',
152 description: 'Live sportscaster commentary: /caster on | off | demo | booth',
153 })
154 const wasLive = (await $.store.get('isLive')) === true
155 await update($, isLive, () => wasLive)
156 $.clock.every(350, () => void onAir($).catch(() => undefined))
157
158 return next(e)
159 })
160
161 on('command.run', { command: 'caster' }, async ($, e) => {
162 const arg = e.args.trim().toLowerCase()
163 if (arg === 'booth' || arg === 'pane') {
164 await openBooth($)
165 return { text: 'Sportscaster: the booth is open.' }
166 }
167 if (arg === 'demo') {
168 void openBooth($)
169 await runDemo($)
170 return { text: 'Sportscaster: demo broadcast starting. Sound on.' }
171 }
172 const turnOn = arg === 'on' || (arg === '' && !(await read($, isLive)))
173 await $.store.set('isLive', turnOn)
174 await update($, isLive, () => turnOn)
175 if (!turnOn) {
176 await update($, ticker, () => null)
177 return { text: 'Sportscaster off the air.' }
178 }
179 await resetGame($)
180 void openBooth($)
181 slot.offer({ text: 'The broadcast begins, Claude takes the field', weight: 3, fx: 'cheer', fallback: 'Welcome to the terminal! Claude takes the field!', at: await $.clock.now() })
182 return { text: 'Sportscaster on the air. /caster off to stop.' }
183 })
184
185 on('prompt.submit', async ($, e, next) => {
186 if (await read($, isLive)) {
187 booth.scoutStreak = 0
188 slot.offer({ text: 'A new play is called in from the sideline', weight: 1, fx: null, fallback: 'New play from the sideline. Here we go.', at: await $.clock.now() })
189 }
190 return next(e)
191 })
192
193 on('tool.call', async ($, e, next) => {
194 // A subagent's own calls are its game, not this one.
195 if (e.agentId || !(await read($, isLive))) return next(e)
196 const info = { tool: String(e.tool), input: fieldsOf(e) }
197 slot.offer(booth.before(info, await $.clock.now()))
198 const ran = await next(e)
199 if (ran.deny === undefined) {
200 slot.offer(booth.after(info, { isError: ran.isError === true, text: ran.text ?? '' }, await $.clock.now()))
201 }
202 return ran
203 })
204
205 on('turn.complete', async ($, e, next) => {
206 if (!e.agentId && (await read($, isLive))) slot.offer(turnPlay(e.durationMs, e.isAborted, await $.clock.now()))
207 return next(e)
208 })
209
210 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
211 const now = await read($, ticker)
212 if (e.props.hasSurvey || !now || (!(await read($, isLive)) && !isDemo)) return next(e)
213 const { Box, Text } = $.ui.resolve(e)
214 const width = Math.max(20, (e.props.bodyColumns || e.viewport?.columns || 80) - 12)
215 const line = now.line.length > width ? `${now.line.slice(0, width - 3)}...` : now.line
216
217 return (
218 <Box gap={1}>
219 <Text backgroundColor={GOLD} color={INK} bold> LIVE </Text>
220 <Text color={now.fx === 'cheer' ? GOLD : PAPER} bold={now.fx === 'cheer'}>{line}</Text>
221 </Box>
222 )
223 })
224
225 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
226 const { Box, Text } = $.ui.resolve(e)
227 const [now, list, m, sc, live] = await Promise.all([read($, ticker), read($, feed), read($, mic), read($, score), read($, isLive)])
228 const clockNow = await $.clock.now()
229 const width = Math.max(24, (e.props.bodyColumns || e.viewport?.columns || 40) - 2)
230 const rows = e.viewport?.rows ?? 30
231 const mood = moodOf(now?.fx ?? null)
232 const isOn = live || isDemo
233 const current = now ? revealed(now.line, clockNow - m.startedAt, m.isTalking) : 'Waiting for kickoff. /caster on to go live.'
234 const caption = wrap(current, width - 2)
235 const captionBg = now?.fx === 'boo' ? RED : GOLD
236 const clockLine = sc.startedAt ? gameClock(clockNow - sc.startedAt) : '00:00'
237 // Head (6) + header + score + caption + gaps take about 14 rows; each call gets up to 3.
238 const room = Math.max(1, Math.floor((rows - 14 - caption.length) / 3))
239
240 return (
241 <Box flexDirection="column" gap={1}>
242 <Box justifyContent="space-between">
243 <Text color={isOn ? RED : PAPER} bold>{isOn ? '● ON AIR' : '○ OFF AIR'}</Text>
244 <Text dimColor>{clockLine}</Text>
245 </Box>
246 <Box gap={2} alignItems="center">
247 <Box flexDirection="column">
248 {head(m.isTalking ? m.frame : -1, mood).map(([l, face, r]) => (
249 <Text>
250 <Text color={GOLD}>{l}</Text>
251 <Text color={PAPER}>{face}</Text>
252 <Text color={GOLD}>{r}</Text>
253 </Text>
254 ))}
255 </Box>
256 <Box flexDirection="column">
257 <Text color={PAPER} bold>THE BOOTH</Text>
258 <Text dimColor>{m.isTalking ? 'on the mic' : 'listening'}</Text>
259 <Text>
260 <Text dimColor>plays </Text>
261 <Text color={PAPER} bold>{String(sc.plays)}</Text>
262 <Text dimColor> fouls </Text>
263 <Text color={sc.fouls ? RED : PAPER} bold>{String(sc.fouls)}</Text>
264 </Text>
265 </Box>
266 </Box>
267 <Box flexDirection="column" backgroundColor={captionBg} paddingX={1}>
268 {caption.map(row => (
269 <Text color={INK} bold>{row}</Text>
270 ))}
271 </Box>
272 <Box flexDirection="column">
273 <Text dimColor bold>PLAY-BY-PLAY</Text>
274 {list.length === 0 && <Text dimColor>No plays yet.</Text>}
275 {list.slice(0, room).map(item => (
276 <Box gap={1}>
277 <Text dimColor>{gameClock(item.at)}</Text>
278 <Box flexDirection="column" flexShrink={1}>
279 <Text color={item.fx === 'cheer' ? GOLD : item.fx === 'boo' ? RED : PAPER} bold={item.fx === 'cheer'} wrap="wrap">
280 {item.line}
281 </Text>
282 {item.fx && item.fx !== 'gasp' && <Text dimColor>{item.fx === 'cheer' ? 'crowd: cheering' : 'crowd: booing'}</Text>}
283 </Box>
284 </Box>
285 ))}
286 </Box>
287 </Box>
288 )
289 })
290}
291hooks/art.ts 58 lines1// The commentator in the booth pane: a headset head whose mouth flaps while a line is read.
2
3export type Mood = 'calm' | 'hype' | 'groan'
4
5export const MOUTHS = ['───', '─o─', ' O ', '─o─'] as const
6
7const EYES: Record<Mood, string> = { calm: '● ●', hype: '◉ ◉', groan: '✖ ✖' }
8
9/** Rows of the head, each split into [headset, face, headset] so the headset can take its own color. */
10export const head = (frame: number, mood: Mood): Array<[string, string, string]> => {
11 const mouth = frame < 0 ? MOUTHS[0] : MOUTHS[frame % MOUTHS.length]!
12 return [
13 [' ╭───────╮', '', ''],
14 ['╭┤', ' ', '├╮'],
15 ['│', `│ ${EYES[mood]} │`, '│'],
16 ['╰┤', ' ▿ ', '├╯'],
17 ['', ` │ ${mouth} │`, '─◉'],
18 ['', ' ╰───────╯', ''],
19 ]
20}
21
22export const moodOf = (fx: string | null): Mood => (fx === 'cheer' ? 'hype' : fx === 'boo' ? 'groan' : 'calm')
23
24const CHAR_MS = 38
25
26/** How much of a line has been read out after `elapsedMs`; the whole line once speech has ended. */
27export const revealed = (line: string, elapsedMs: number, isTalking: boolean) =>
28 isTalking ? line.slice(0, Math.max(1, Math.floor(elapsedMs / CHAR_MS))) : line
29
30/** A game clock from the start of the broadcast: `03:07`, then `1:03:07` past an hour. */
31export const gameClock = (ms: number) => {
32 const s = Math.max(0, Math.floor(ms / 1000))
33 const mm = String(Math.floor(s / 60) % 60).padStart(2, '0')
34 const ss = String(s % 60).padStart(2, '0')
35 return s >= 3600 ? `${Math.floor(s / 3600)}:${mm}:${ss}` : `${mm}:${ss}`
36}
37
38/** Splits text into lines no wider than `width`, breaking on spaces where it can. */
39export const wrap = (text: string, width: number): string[] => {
40 const w = Math.max(8, width)
41 const out: string[] = []
42 let cur = ''
43 for (const word of text.split(/\s+/).filter(Boolean)) {
44 if (!cur) cur = word
45 else if (cur.length + 1 + word.length <= w) cur += ` ${word}`
46 else {
47 out.push(cur)
48 cur = word
49 }
50 while (cur.length > w) {
51 out.push(cur.slice(0, w))
52 cur = cur.slice(w)
53 }
54 }
55 if (cur) out.push(cur)
56 return out
57}
58hooks/logic.ts 222 lines1/** Pure play-by-play logic: what happened, how big it was, and what to say when the model is out. */
2
3import type { Fx } from '../types'
4
5/** 0 skip, 1 routine, 2 notable, 3 big moment. */
6export type Weight = 0 | 1 | 2 | 3
7
8export type Play = {
9 /** What happened, plainly, for the announcer prompt. */
10 text: string
11 weight: Weight
12 fx: Fx
13 /** Said when the model call fails. */
14 fallback: string
15 at: number
16}
17
18const SCOUTING = new Set(['Read', 'Grep', 'Glob', 'LS', 'NotebookRead'])
19const TEST_RUN = /\b(npm (run )?test|pnpm (run )?test|yarn test|bun test|vitest|jest|pytest|go test|cargo test|claude plugin test|playwright test|rspec|phpunit)\b/
20
21const base = (path: string) => path.split('/').filter(Boolean).pop() ?? path
22
23const clip = (text: string, words: number) => {
24 const list = text.trim().split(/\s+/).filter(Boolean)
25 return list.length > words ? `${list.slice(0, words).join(' ')}...` : list.join(' ')
26}
27
28const pick = <T>(list: readonly T[], seed: number) => list[Math.abs(Math.floor(seed)) % list.length]!
29
30const countLines = (text: unknown) => (typeof text === 'string' && text ? text.split('\n').length : 0)
31
32export type CallInfo = { tool: string; input: Record<string, unknown> }
33
34/** Tracks streaks and test state across calls; one per session. */
35export class Booth {
36 scoutStreak = 0
37 hasFailingTests = false
38
39 /** Called as a tool call starts. Big outcomes come from `after`. */
40 before(call: CallInfo, at: number): Play | null {
41 const { tool, input } = call
42 if (SCOUTING.has(tool)) {
43 this.scoutStreak += 1
44 return this.scoutStreak === 1
45 ? { text: `Claude starts reading through the codebase (${tool})`, weight: 1, fx: null, fallback: pick(SCOUT_LINES, at), at }
46 : null
47 }
48 this.scoutStreak = 0
49
50 if (tool === 'Edit' || tool === 'MultiEdit') {
51 const file = base(String(input.file_path ?? 'a file'))
52 const size = countLines(input.new_string)
53 return {
54 text: `Claude edits ${file}, ${size} line${size === 1 ? '' : 's'} going in`,
55 weight: size > 40 ? 2 : 1,
56 fx: null,
57 fallback: size > 40 ? `A massive ${size}-line edit to ${file}! Bold play!` : `Quick edit on ${file}. Clean footwork.`,
58 at,
59 }
60 }
61 if (tool === 'Write') {
62 const file = base(String(input.file_path ?? 'a file'))
63 const size = countLines(input.content)
64 return {
65 text: `Claude writes a whole file from scratch: ${file}, ${size} lines`,
66 weight: 2,
67 fx: null,
68 fallback: `Brand new file, ${file}, ${size} lines! From scratch!`,
69 at,
70 }
71 }
72 if (tool === 'Agent' || tool === 'Task') {
73 return {
74 text: `Claude sends a subagent off the bench: ${clip(String(input.description ?? 'a helper'), 8)}`,
75 weight: 2,
76 fx: 'cheer',
77 fallback: 'Substitution! A fresh subagent comes off the bench!',
78 at,
79 }
80 }
81 if (tool === 'Bash') {
82 const command = String(input.command ?? '')
83 if (/\brm\s+-[a-z]*r[a-z]*f|\brm\s+-[a-z]*f[a-z]*r/.test(command)) {
84 return { text: `Claude goes for rm -rf: ${clip(command, 6)}`, weight: 3, fx: 'gasp', fallback: 'Oh no. He is going for rm dash r f. Hide the children!', at }
85 }
86 if (/\bgit (revert|reset --hard)|\bgit checkout -- |\bgit restore\b/.test(command)) {
87 return { text: 'Claude throws the play away and reverts the changes', weight: 3, fx: 'boo', fallback: 'He is reverting! The whole play, wiped off the board!', at }
88 }
89 if (/\bgit push\b/.test(command)) {
90 return { text: `Claude pushes to the remote${/--force|-f\b/.test(command) ? ' WITH FORCE' : ''}`, weight: 3, fx: /--force|-f\b/.test(command) ? 'gasp' : null, fallback: /--force/.test(command) ? 'A FORCE PUSH! The crowd cannot believe it!' : 'He pushes! It is heading to the remote!', at }
91 }
92 if (/\bgit commit\b/.test(command)) {
93 return { text: 'Claude commits the work', weight: 2, fx: null, fallback: 'And he commits! That one is on the record!', at }
94 }
95 if (TEST_RUN.test(command)) {
96 return { text: 'Claude runs the test suite. Everyone holds their breath', weight: 2, fx: null, fallback: 'Here come the tests. You could hear a pin drop.', at }
97 }
98 return { text: `Claude runs a shell command: ${clip(command, 6)}`, weight: 1, fx: null, fallback: pick(SHELL_LINES, at), at }
99 }
100 if (tool === 'WebSearch' || tool === 'WebFetch') {
101 return { text: 'Claude goes to the web for research', weight: 1, fx: null, fallback: 'He is checking the scouting report online.', at }
102 }
103 if (tool.startsWith('mcp__')) {
104 const server = tool.split('__')[1] ?? 'a server'
105 return { text: `Claude calls in a play from ${server.replace(/_/g, ' ')}`, weight: 1, fx: null, fallback: `Calling in help from ${server.replace(/_/g, ' ')}.`, at }
106 }
107 return null
108 }
109
110 /** Called with the call's outcome. */
111 after(call: CallInfo, outcome: { isError: boolean; text: string }, at: number): Play | null {
112 if (call.tool !== 'Bash') {
113 return outcome.isError && (call.tool === 'Edit' || call.tool === 'Write')
114 ? { text: `Claude's ${call.tool} on ${base(String(call.input.file_path ?? 'the file'))} bounces off`, weight: 2, fx: 'boo', fallback: 'Off the post! The edit does not land!', at }
115 : null
116 }
117 const command = String(call.input.command ?? '')
118 if (!TEST_RUN.test(command)) {
119 return outcome.isError ? { text: 'The shell command fails', weight: 1, fx: null, fallback: 'Fumble! The command comes back red.', at } : null
120 }
121 const isFailing = outcome.isError || /\b[1-9]\d* (failed|failing|failures?)\b|\bFAIL\b/.test(outcome.text)
122 const wasFailing = this.hasFailingTests
123 this.hasFailingTests = isFailing
124 if (isFailing) {
125 return { text: 'The tests FAIL', weight: 2, fx: 'boo', fallback: pick(FAIL_LINES, at), at }
126 }
127 return wasFailing
128 ? { text: 'COMEBACK: the tests were failing and now they ALL PASS', weight: 3, fx: 'cheer', fallback: 'THE TESTS PASS! WHAT A COMEBACK! UNBELIEVABLE!', at }
129 : { text: 'The tests pass, all green', weight: 2, fx: 'cheer', fallback: 'All green! The tests pass!', at }
130 }
131}
132
133/** The end of a turn: long ones are the headline. */
134export const turnPlay = (durationMs: number, isAborted: boolean, at: number): Play | null => {
135 const seconds = Math.round(durationMs / 1000)
136 if (isAborted) {
137 return { text: 'The user interrupts and blows the whistle', weight: 3, fx: 'boo', fallback: 'Whistle! The user stops play!', at }
138 }
139 if (seconds >= 90) {
140 const minutes = Math.round(seconds / 60)
141 return { text: `Claude finishes a marathon ${minutes}-minute turn`, weight: 3, fx: 'cheer', fallback: `${minutes} minutes! He goes the distance! What a performance!`, at }
142 }
143 if (seconds >= 20) {
144 return { text: `Claude wraps the turn after ${seconds} seconds`, weight: 1, fx: null, fallback: 'And that is the end of the drive.', at }
145 }
146 return null
147}
148
149/**
150 * One pending play at a time: a newer play takes the slot unless the one waiting
151 * is bigger. Stale plays expire so commentary never lags the action.
152 */
153export class Slot {
154 private play: Play | null = null
155
156 constructor(private readonly maxAgeMs = 8000) {}
157
158 offer(play: Play | null) {
159 if (!play || play.weight === 0) return
160 if (this.play && this.play.weight > play.weight) return
161 this.play = play
162 }
163
164 take(now: number): Play | null {
165 const play = this.play
166 this.play = null
167 if (!play) return null
168 const maxAge = play.weight >= 3 ? this.maxAgeMs * 2 : this.maxAgeMs
169 return now - play.at > maxAge ? null : play
170 }
171
172 get isEmpty() {
173 return this.play === null
174 }
175}
176
177export const ANNOUNCER = [
178 'You are a hyped, witty TV sports play-by-play announcer calling a live coding session as if it were a championship game.',
179 'The player is Claude, an AI coding agent. Call the play you are given in ONE line of at most 14 words.',
180 'Plain spoken text only: no emojis, no hashtags, no quotes, no stage directions, no markdown.',
181 'Match the energy to the moment: routine plays are dry, big moments are explosive.',
182 'Never insult the human user. Do not repeat your earlier lines.',
183].join(' ')
184
185export const announcerPrompt = (play: Play, recent: readonly string[]) =>
186 [
187 recent.length ? `Your last calls (do not repeat them):\n${recent.map(l => `- ${l}`).join('\n')}\n` : '',
188 `Moment size: ${['', 'routine', 'notable', 'BIG MOMENT'][play.weight]}`,
189 `The play: ${play.text}`,
190 ].join('\n')
191
192/** Cleans a model reply into one speakable line. */
193export const speakable = (reply: string) => {
194 const line = (reply.split('\n').find(l => l.trim()) ?? '')
195 .replace(/[*_#`"“”]/g, '')
196 .replace(/^\s*[-–]\s*/, '')
197 .replace(/[^\x20-\x7E’']/g, '')
198 .trim()
199 return clip(line, 16)
200}
201
202const SCOUT_LINES = [
203 'He is scouting the codebase. Studying the tape.',
204 'Reading the defense. Looking for an opening.',
205 'Claude is in the film room right now.',
206]
207const SHELL_LINES = ['Down to the terminal he goes.', 'A quick shell command. Keeping it moving.', 'Straight to the command line.']
208const FAIL_LINES = ['The tests come back red! That is going to sting.', 'Failures on the board! The crowd groans!', 'Red across the board. He needs a new game plan.']
209
210/** The scripted broadcast for /caster demo: [delay after the previous line, line, fx]. */
211export const DEMO: ReadonlyArray<readonly [number, string, Fx]> = [
212 [0, 'Good evening and welcome to the terminal. Claude takes the field.', 'cheer'],
213 [4200, 'He is scouting the codebase. Studying the tape.', null],
214 [3600, 'Edit on auth dot t s. Four hundred lines. Bold play.', null],
215 [4200, 'Here come the tests. You could hear a pin drop.', null],
216 [3400, 'Red across the board! Three failures! The crowd groans!', 'boo'],
217 [4300, 'He is back in the file. Something is cooking.', null],
218 [3400, 'Tests again. This is it.', null],
219 [2600, 'THEY PASS! ALL GREEN! WHAT A COMEBACK!', 'cheer'],
220 [4400, 'He commits! He pushes! That is game!', 'cheer'],
221]
222hooks/wav.ts 134 lines1/** Crowd effects synthesized in code: 16-bit mono PCM WAVs, base64 for $.audio.play. */
2
3import type { Fx } from '../types'
4
5export const RATE = 22050
6
7const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
8
9export const toBase64 = (bytes: Uint8Array) => {
10 let out = ''
11 for (let i = 0; i < bytes.length; i += 3) {
12 const a = bytes[i] ?? 0
13 const b = bytes[i + 1] ?? 0
14 const c = bytes[i + 2] ?? 0
15 const n = (a << 16) | (b << 8) | c
16 out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]!
17 out += i + 1 < bytes.length ? B64[(n >> 6) & 63]! : '='
18 out += i + 2 < bytes.length ? B64[n & 63]! : '='
19 }
20 return out
21}
22
23/** Wraps samples in -1..1 as a WAV file. */
24export const wav = (samples: Float32Array) => {
25 const bytes = new Uint8Array(44 + samples.length * 2)
26 const view = new DataView(bytes.buffer)
27 const text = (at: number, s: string) => [...s].forEach((ch, i) => view.setUint8(at + i, ch.charCodeAt(0)))
28 text(0, 'RIFF')
29 view.setUint32(4, 36 + samples.length * 2, true)
30 text(8, 'WAVE')
31 text(12, 'fmt ')
32 view.setUint32(16, 16, true)
33 view.setUint16(20, 1, true) // PCM
34 view.setUint16(22, 1, true) // mono
35 view.setUint32(24, RATE, true)
36 view.setUint32(28, RATE * 2, true)
37 view.setUint16(32, 2, true)
38 view.setUint16(34, 16, true)
39 text(36, 'data')
40 view.setUint32(40, samples.length * 2, true)
41 for (let i = 0; i < samples.length; i++) {
42 const s = Math.max(-1, Math.min(1, samples[i]!))
43 view.setInt16(44 + i * 2, Math.round(s * 32767), true)
44 }
45 return bytes
46}
47
48/** A seeded generator so the effects are the same every time. */
49const noise = (seed: number) => {
50 let x = seed >>> 0 || 1
51 return () => {
52 x ^= x << 13
53 x ^= x >>> 17
54 x ^= x << 5
55 return ((x >>> 0) / 0xffffffff) * 2 - 1
56 }
57}
58
59/** Stadium roar: band-limited noise that swells and fades, with scattered claps. */
60export const cheer = (seconds = 2.6) => {
61 const n = Math.floor(RATE * seconds)
62 const out = new Float32Array(n)
63 const rand = noise(7)
64 let low = 0
65 let lower = 0
66 let clap = 0
67 for (let i = 0; i < n; i++) {
68 const t = i / n
69 const env = Math.min(1, t / 0.18) * Math.pow(1 - t, 1.4)
70 const r = rand()
71 low += (r - low) * 0.35 // lowpass ~ 2.5 kHz
72 lower += (low - lower) * 0.04 // strip the rumble below
73 if (rand() > 0.9993) clap = 1
74 clap *= 0.992
75 const wobble = 0.75 + 0.25 * Math.sin(i * 0.0021) * Math.sin(i * 0.00057)
76 out[i] = ((low - lower) * 1.6 * wobble + r * clap * 0.35) * env * 0.8
77 }
78 return out
79}
80
81/** A crowd boo: detuned low voices on "oo", gliding down. */
82export const boo = (seconds = 2.0) => {
83 const n = Math.floor(RATE * seconds)
84 const out = new Float32Array(n)
85 const rand = noise(11)
86 const voices = [98, 103, 110, 117, 124, 131]
87 const phase = voices.map(() => 0)
88 let breath = 0
89 for (let i = 0; i < n; i++) {
90 const t = i / n
91 const env = Math.min(1, t / 0.12) * Math.pow(1 - t, 0.9)
92 let s = 0
93 voices.forEach((f, v) => {
94 const freq = f * (1 - 0.12 * t) * (1 + 0.004 * Math.sin(i * 0.0007 * (v + 1)))
95 phase[v] = (phase[v]! + freq / RATE) % 1
96 const p = phase[v]!
97 s += Math.sin(2 * Math.PI * p) + 0.35 * Math.sin(4 * Math.PI * p) + 0.12 * Math.sin(6 * Math.PI * p)
98 })
99 breath += (rand() - breath) * 0.08
100 out[i] = (s / voices.length * 0.7 + breath * 0.25) * env * 0.75
101 }
102 return out
103}
104
105/** A sharp crowd intake of breath: a fast noise rise that cuts off. */
106export const gasp = (seconds = 0.9) => {
107 const n = Math.floor(RATE * seconds)
108 const out = new Float32Array(n)
109 const rand = noise(23)
110 let low = 0
111 let lower = 0
112 for (let i = 0; i < n; i++) {
113 const t = i / n
114 const env = Math.pow(Math.min(1, t / 0.55), 2) * (t > 0.7 ? Math.max(0, 1 - (t - 0.7) / 0.3) : 1)
115 const r = rand()
116 low += (r - low) * 0.5
117 lower += (low - lower) * 0.08
118 out[i] = (low - lower) * env * 1.4
119 }
120 return out
121}
122
123const cache = new Map<Exclude<Fx, null>, string>()
124
125/** The effect as base64 WAV, synthesized once. */
126export const fxClip = (fx: Exclude<Fx, null>) => {
127 let clip = cache.get(fx)
128 if (!clip) {
129 clip = toBase64(wav(fx === 'cheer' ? cheer() : fx === 'boo' ? boo() : gasp()))
130 cache.set(fx, clip)
131 }
132 return clip
133}
134types/index.d.ts 15 lines1export type Fx = 'cheer' | 'boo' | 'gasp' | null
2
3export type Ticker = { line: string; at: number; fx: Fx }
4
5/** The booth pane: who is talking, since when, and the frame of the mouth. */
6export type Mic = { isTalking: boolean; startedAt: number; frame: number }
7
8export type Score = { plays: number; fouls: number; startedAt: number }
9
10declare module 'claude-code' {
11 interface PluginState {
12 sportscaster: { ticker: Ticker | null; isLive: boolean; feed: Ticker[]; mic: Mic; score: Score }
13 }
14}
15