SLOPSHOPPER

Sportscaster

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

newpanebandguardcommandprompt
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · sportscaster
│ ┃ The Booth ✕ › fix the failing auth test and add an audit log call │ ┃ ● ON AIR 00:00 │ ┃ ⏺ Read(src/auth.ts) │ ┃ ╭───────╮ THE BOOTH ⎿ Read 6 lines │ ┃ ╭┤ ├╮ listening ⏺ Update(src/auth.ts) │ ┃ ││ ● ● ││ plays 0 fouls 0 ⎿ Added 2 lines, removed 1 line │ ┃ ╰┤ ▿ ├╯ ⏺ Bash(bun test) │ ┃ │ ─── │─◉ ⎿ 3 pass, 1 fail │ ┃ ╰───────╯ │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ Waiting for kickoff. /caster on to go │ ┃ live. ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ PLAY-BY-PLAY › /caster │ ┃ No plays yet. ⎿ sportscaster: Sportscaster on the air. /caster off to stop. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · The Booth
● ON AIR 00:00 ╭───────╮ THE BOOTH ╭┤ ├╮ listening ││ ● ● ││ plays 0 fouls 0 ╰┤ ▿ ├╯ │ ─── │─◉ ╰───────╯ Waiting for kickoff. /caster on to go live. PLAY-BY-PLAY No plays yet.
README

sportscaster

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

sportscaster

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.

Install

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

What it reaches

ModNetworkRuns processesFilesCalls a modelSends data anywhere
sportscasterNoNoNoYes, a summary of recent tool callsOnly 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.

Source 5 files
hooks/register.tsx 291 lines
1import { 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}
291
hooks/art.ts 58 lines
1// 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}
58
hooks/logic.ts 222 lines
1/** 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]
222
hooks/wav.ts 134 lines
1/** 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}
134
types/index.d.ts 15 lines
1export 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