SLOPSHOPPER

miod

Generative music for Claude Code. Every task gets its own tune, and the mood follows what the agent is doing and how fast it spends tokens.

newbandguardcommandtoasttimer
★ 3v0.1.0MITupdated 2026-10-08delexw/miod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · miod
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /miod ⎿ miod: on. Use /miod on, /miod off, or /miod wave <name>. ♪ fixed ▣ client module ./wave.tsx ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
♪ fixed ▣ client module ./wave.tsx ⟨Claude Code's own drawing⟩
README

<img src="docs/miod.png" alt="miod logo: a honey-amber music note with code brackets" width="200">

miod

[!IMPORTANT] Only tested on macOS. Not sure yet how it behaves on Linux or Windows.

I'm an engineer, but these days I solve the boring problems entirely by vibe coding. Somewhere along the way I felt I had lost the creativity and curiosity I used to have when writing code myself.

So I started thinking about how to make vibe coding fun again. miod is the first try: while the agent does the work, you get to hear it think, read, edit, fail and fix, with a little piece of music no one has heard before.

Feel free to add more moods, flavours and styles. Each mood is one row in hooks/moods.ts, each flavour one row in hooks/flavours.ts, each sound style one entry in hooks/styles.ts, and each wave look one file in hooks/waves/.

Feeling it's a bit too noisy? 🙉 No hard feelings. Just turn it off with /miod off.

What it is

A Claude Code mod (MIDI + mod) that writes music on the spot while Claude works. No music files, no library, no AI model.

  • Every task picks its own flavour (lo-fi, chiptune, ambient, jazzy and 8 more), key, chord progression, tempo, sound and theme.
  • The mood follows what the agent is doing, and the mood picks the scale (16 of them) from its own weighted list: failing leans on minor and harmonic minor, shipping on mixolydian and major. Every mood has 10 variants, and a new variant and scale are picked as the task goes: whenever the activity changes, and every 4 phrases. The task's flavour bends it too, so editing in a lo-fi task sounds nothing like editing in a chiptune one.
  • The faster it spends tokens, the busier the music.
  • Above the prompt, one line shows the mood, flavour, key, tempo and energy, with a wave under it that scrolls with the music, coloured by mood:

miod's status line and moving wave

  • Tasks flow into each other: the next task fades in, in a related key. If no new task comes, a closing chord fades out at the end of the phrase.

Install

Needs Claude Code 2.1.290 or newer, on macOS.

claude plugin marketplace add delexw/miod
claude plugin install miod@miod

Then start a new session.

Usage

/miod        is it on or off?
/miod off    stop the music
/miod on     play again from the next task
/miod wave   list the wave looks: bars, line, mirror, dots, pulse
/miod wave dots   switch the wave to the dots look

How the mood is picked

Each row shows the mood's first variant and its most likely scale. The variants are in MOODS and the scale weights in MOOD_SCALES, both in hooks/moods.ts.

The agent is…When it uses…Sounds
thinkingno toolcalm
readingRead, Grep, Glob, MCP toolsairy
exploringWebSearch, WebFetchcurious (lydian)
planningTodoWrite, plan modehopeful (major pentatonic)
askingAskUserQuestionwaiting, almost silent
editingEdit, Writebright (major)
runningBashdriving beat
installingnpm, bun, pip, brew… installpatient (minor pentatonic)
testingtest, check, lint, tscfocused (dorian), busy beat
shippinggit commit, push, merge, gh prtriumphant (mixolydian)
deletingrm -r, git reset --hard, git cleanominous (phrygian), low drone
delegatingAgentone harmony voice per subagent
failinga Bash command that failedtense (minor), until a command passes

When several happen in one 8-beat phrase, the one higher in STRONGEST_FIRST (hooks/activity.ts) wins. A filling context window lifts the melody and, past 80%, adds a low drone.

The scales follow how musicians describe each mode (musical-u, Jiang et al. 2024).

Add a mood or style

  • Mood variant: add a row to that mood's list in MOODS in hooks/moods.ts.
  • New mood: add the name to Activity and STRONGEST_FIRST in hooks/activity.ts, map tools or commands to it there, give it a list of at least 10 variants in MOODS and a row of scale weights in MOOD_SCALES.
  • Flavour: add a row to FLAVOURS in hooks/flavours.ts. Each field nudges every mood: brighter or darker scale, busier or calmer, drums up or down, swing, bass line.
  • Scale: add it to SCALES in hooks/theory.ts, then give it a weight in at least one mood's MOOD_SCALES row. A test fails if a scale belongs to no mood.
  • Chords: add to PROGRESSIONS in hooks/theory.ts.
  • Wave look: add a file to hooks/waves/ that exports a WaveLook (how many rows, and a draw function), then list it in WAVES in hooks/waves/index.ts.
  • Style: add a wave function to STYLES in hooks/styles.ts. It must return values between -1 and 1.

Then check it:

claude plugin validate .
claude plugin test .

Run a checkout without installing it with claude --plugin-dir <path to this repo>.

Limitations

  • Only tested on macOS, where Claude Code plays the audio through afplay.
  • The sound is simple chiptune-style tones.
  • miod only hears the session it's loaded in.
Source 20 files
hooks/register.tsx 315 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import { activityForCommand, activityForTool, strongestActivity } from './activity'
5import type { Activity } from './activity'
6import { drawBand } from './band'
7import { composeFinish, composeFixed, composePhrase, energyFromRate, soundingScale } from './compose'
8import type { Moment, Note } from './compose'
9import { nextMood } from './moods'
10import type { MoodChoice } from './moods'
11import { seededRandom } from './random'
12import { renderClip, toBase64, WAVE_POINTS_PER_SECOND } from './synth'
13import type { Fade } from './synth'
14import { describeTask, phraseSeconds, startTask } from './task'
15import { DEFAULT_WAVE, isWaveName, WAVE_NAMES } from './waves'
16import type { Task } from './task'
17import type { ScaleName } from './theory'
18import type { NowPlaying } from '../types'
19
20const isEnabled = atom({ plugin: 'miod', key: 'isEnabled' } as const, true)
21const nowPlaying = atom({ plugin: 'miod', key: 'nowPlaying' } as const, null)
22const chosenWave = atom({ plugin: 'miod', key: 'wave' } as const, DEFAULT_WAVE)
23
24const RATE_WINDOW_MS = 60_000
25const GAIN = 0.1
26const FINISH_LOOK: Look = { label: 'finished', color: '#F5A623' }
27const FIXED_LOOK: Look = { label: 'fixed', color: '#81C784' }
28
29type Spend = { at: number; tokens: number }
30
31type Look = { label: string; color: string }
32
33type PlayOptions = { fade?: Fade }
34
35type Session = {
36  task: Task | null
37  turnId: string | null
38  phraseNumber: number
39  ticker: Timer | null
40  playing: Set<AbortController>
41  spends: Spend[]
42  toolCalls: number
43  contextFill: number
44  seenThisPhrase: Activity[]
45  isFailing: boolean
46  helpers: number
47  clipCount: number
48  lastTask: Task | null
49  moodChoice: MoodChoice | null
50  phraseEndsAt: number
51  finishTimer: Timer | null
52}
53
54const session: Session = {
55  task: null,
56  turnId: null,
57  phraseNumber: 0,
58  ticker: null,
59  playing: new Set(),
60  spends: [],
61  toolCalls: 0,
62  contextFill: 0,
63  seenThisPhrase: [],
64  isFailing: false,
65  helpers: 0,
66  clipCount: 0,
67  lastTask: null,
68  moodChoice: null,
69  phraseEndsAt: 0,
70  finishTimer: null,
71}
72
73function play($: EngineInterface, task: Task, notes: Note[], seconds: number, look: Look, options: PlayOptions = {}) {
74  const controller = new AbortController()
75  session.playing.add(controller)
76  const clip = renderClip(task.style, notes, seconds, task.seed, options.fade)
77  session.clipCount += 1
78  showWave($, { clipId: session.clipCount, ...look, levels: clip.levels, pointsPerSecond: WAVE_POINTS_PER_SECOND })
79  $.audio
80    .play({ base64: toBase64(clip.wav), mime: 'audio/wav' }, { gain: GAIN, signal: controller.signal })
81    .catch(() => $.ui.toast('miod: could not play audio'))
82    .finally(() => session.playing.delete(controller))
83}
84
85function silence() {
86  session.playing.forEach(controller => controller.abort())
87  session.playing.clear()
88}
89
90function showWave($: EngineInterface, clip: NowPlaying | null) {
91  update($, nowPlaying, () => clip).catch(() => undefined)
92}
93
94async function readMoment($: EngineInterface, now: number): Promise<Moment> {
95  session.spends = session.spends.filter(spend => now - spend.at < RATE_WINDOW_MS)
96  const tokens = session.spends.reduce((sum, spend) => sum + spend.tokens, 0)
97  const elapsed = Math.max(10_000, now - (session.spends[0]?.at ?? now))
98  const seen = session.isFailing ? ['failing' as const] : session.seenThisPhrase
99
100  const task = session.task
101  const random = seededRandom((task?.seed ?? 0) + session.phraseNumber * 7919)
102  session.moodChoice = nextMood(session.moodChoice, strongestActivity(seen), random)
103  const moment: Moment = {
104    mood: session.moodChoice.mood,
105    scale: session.moodChoice.scale,
106    energy: energyFromRate((tokens * 60_000) / elapsed),
107    contextFill: session.contextFill,
108    toolCalls: session.toolCalls,
109    helpers: session.helpers,
110  }
111  session.toolCalls = 0
112  session.seenThisPhrase = []
113  return moment
114}
115
116async function playNextPhrase($: EngineInterface) {
117  const task = session.task
118  if (!task) {
119    return
120  }
121  const now = await $.clock.now()
122  const moment = await readMoment($, now)
123  const mood = moment.mood
124  const seconds = phraseSeconds(task)
125  const isFirst = session.phraseNumber === 0
126  const look = { label: `${mood.feel} · ${describeTask(task, soundingScale(task, moment.scale))} · energy ${Math.round(moment.energy * 100)}%`, color: mood.color }
127  play($, task, composePhrase(task, session.phraseNumber, moment), seconds, look, {
128    fade: isFirst ? { inSeconds: seconds / 2 } : undefined,
129  })
130  session.phraseNumber += 1
131  session.phraseEndsAt = now + seconds * 1000
132}
133
134async function startMusic($: EngineInterface, prompt: string, turnId: string) {
135  session.finishTimer?.cancel()
136  session.finishTimer = null
137  const task = startTask(prompt, await $.clock.now(), session.lastTask ?? undefined)
138  Object.assign(session, {
139    task,
140    turnId,
141    phraseNumber: 0,
142    spends: [],
143    toolCalls: 0,
144    seenThisPhrase: [],
145    isFailing: false,
146    helpers: 0,
147    moodChoice: null,
148  })
149  await playNextPhrase($)
150  session.ticker = $.clock.every(Math.round(phraseSeconds(task) * 1000), () => {
151    playNextPhrase($).catch(() => undefined)
152  })
153}
154
155async function stopMusic($: EngineInterface, shouldPlayFinish: boolean) {
156  session.ticker?.cancel()
157  session.ticker = null
158  session.finishTimer?.cancel()
159  session.finishTimer = null
160  const task = session.task
161  const scale = session.moodChoice?.scale
162  if (task && scale && shouldPlayFinish) {
163    session.lastTask = task
164    const wait = Math.max(0, session.phraseEndsAt - (await $.clock.now()))
165    session.finishTimer = $.clock.after(Math.round(wait), () => playFinish($, task, scale))
166  } else {
167    silence()
168    showWave($, null)
169  }
170  session.task = null
171  session.turnId = null
172}
173
174function playFinish($: EngineInterface, task: Task, scale: ScaleName) {
175  session.finishTimer = null
176  if (session.task) {
177    return
178  }
179  const seconds = phraseSeconds(task)
180  play($, task, composeFinish(task, soundingScale(task, scale)), seconds, FINISH_LOOK, { fade: { outSeconds: seconds / 2 } })
181  session.finishTimer = $.clock.after(Math.round(seconds * 1000), () => {
182    session.finishTimer = null
183    if (!session.task) {
184      showWave($, null)
185    }
186  })
187}
188
189function noteActivity(activity: Activity) {
190  if (session.task) {
191    session.seenThisPhrase.push(activity)
192  }
193}
194
195function noteCommandResult($: EngineInterface, hasFailed: boolean) {
196  const task = session.task
197  if (!task) {
198    return
199  }
200  if (hasFailed) {
201    session.isFailing = true
202    return
203  }
204  if (session.isFailing) {
205    session.isFailing = false
206    play($, task, composeFixed(task), phraseSeconds(task) / 2, FIXED_LOOK)
207  }
208}
209
210export const register: Register = on => {
211  on('session.start', async ($, e, next) => {
212    await $.command.register({
213      name: 'miod',
214      description: 'Turn the task music on or off.',
215      argumentHint: '[on|off|wave <name>]',
216      immediate: true,
217    })
218    return next(e)
219  })
220
221  on('command.run', { command: 'miod' }, async ($, e) => {
222    const [choice = '', name = ''] = e.args.trim().toLowerCase().split(/\s+/)
223    if (choice === 'wave') {
224      if (!isWaveName(name)) {
225        return { text: `waves: ${WAVE_NAMES.join(', ')}. Now: ${await read($, chosenWave)}. Use /miod wave <name>.` }
226      }
227      await update($, chosenWave, () => name)
228      return { text: `wave is ${name}.` }
229    }
230    if (choice === 'on' || choice === 'off') {
231      await update($, isEnabled, () => choice === 'on')
232      if (choice === 'off') {
233        await stopMusic($, false)
234      }
235      return { text: `${choice}.` }
236    }
237    const state = (await read($, isEnabled)) ? 'on' : 'off'
238    return { text: `${state}. Use /miod on, /miod off, or /miod wave <name>.` }
239  })
240
241  on('turn.start', async ($, e, next) => {
242    const result = await next(e)
243    if (!session.task && (await read($, isEnabled))) {
244      await startMusic($, e.text, e.turnId)
245    }
246    return result
247  })
248
249  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
250    noteActivity(activityForCommand(e.command))
251    const ran = await next(e)
252    noteCommandResult($, ran.deny === undefined && ran.isError === true)
253    return ran
254  }).catch(($, e, next) => next(e))
255
256  on('tool.call', async ($, e, next) => {
257    if (e.tool === 'Bash') {
258      return next(e)
259    }
260    const activity = activityForTool(e.tool)
261    noteActivity(activity)
262    if (activity !== 'delegating') {
263      return next(e)
264    }
265    session.helpers += 1
266    try {
267      return await next(e)
268    } finally {
269      session.helpers = Math.max(0, session.helpers - 1)
270    }
271  }).catch(($, e, next) => next(e))
272
273  on('turn.step', async function* ($, e, next) {
274    const result = yield* next(e)
275    if (session.task && result.usage) {
276      const { input_tokens, output_tokens, cache_creation_input_tokens } = result.usage
277      session.spends.push({
278        at: await $.clock.now(),
279        tokens: input_tokens + output_tokens + cache_creation_input_tokens,
280      })
281      session.toolCalls += result.toolUses.length
282    }
283    return result
284  })
285
286  on('session.measure', ($, e, next) => {
287    if (e.context.percent !== undefined) {
288      session.contextFill = e.context.percent / 100
289    }
290    return next(e)
291  })
292
293  on('turn.complete', async ($, e, next) => {
294    const result = await next(e)
295    if (session.task && e.turnId === session.turnId) {
296      await stopMusic($, e.reason === 'answer')
297    }
298    return result
299  })
300
301  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
302    const below = await next(e)
303    const clip = await read($, nowPlaying)
304    if (!clip || e.props.hasSurvey) {
305      return below
306    }
307    return drawBand($.ui.resolve(e), clip, await read($, chosenWave), e.props.bodyColumns, below)
308  })
309
310  on('session.end', async ($, e, next) => {
311    await stopMusic($, false)
312    return next(e)
313  })
314}
315
hooks/activity.ts 85 lines
1export type Activity =
2  | 'thinking'
3  | 'reading'
4  | 'exploring'
5  | 'planning'
6  | 'asking'
7  | 'editing'
8  | 'running'
9  | 'installing'
10  | 'testing'
11  | 'shipping'
12  | 'deleting'
13  | 'delegating'
14  | 'failing'
15
16const READING_TOOLS = ['Read', 'Grep', 'Glob', 'LS', 'NotebookRead']
17const EXPLORING_TOOLS = ['WebFetch', 'WebSearch']
18const PLANNING_TOOLS = ['TodoWrite', 'TaskCreate', 'TaskUpdate', 'EnterPlanMode', 'ExitPlanMode']
19const ASKING_TOOLS = ['AskUserQuestion']
20const EDITING_TOOLS = ['Edit', 'Write', 'MultiEdit', 'NotebookEdit']
21const DELEGATING_TOOLS = ['Agent', 'Task']
22
23const SHIPPING_COMMAND = /\bgit (commit|push|merge|tag)\b|\bgh pr (create|merge)\b/
24const DELETING_COMMAND = /\brm -[a-z]*r|\bgit (reset --hard|clean)\b|\bdrop (table|database)\b/i
25const INSTALLING_COMMAND = /\b(npm|yarn|pnpm|bun) (install|add|i)\b|\b(pip|brew|gem) install\b|\bbundle install\b/
26const TEST_COMMAND = /\b(test|tests|spec|jest|vitest|pytest|rspec|mocha|check|lint|tsc)\b/
27
28export function activityForTool(tool: string): Activity {
29  if (READING_TOOLS.includes(tool) || tool.startsWith('mcp__')) {
30    return 'reading'
31  }
32  if (EXPLORING_TOOLS.includes(tool)) {
33    return 'exploring'
34  }
35  if (PLANNING_TOOLS.includes(tool)) {
36    return 'planning'
37  }
38  if (ASKING_TOOLS.includes(tool)) {
39    return 'asking'
40  }
41  if (EDITING_TOOLS.includes(tool)) {
42    return 'editing'
43  }
44  if (DELEGATING_TOOLS.includes(tool)) {
45    return 'delegating'
46  }
47  return 'running'
48}
49
50export function activityForCommand(command: string): Activity {
51  if (SHIPPING_COMMAND.test(command)) {
52    return 'shipping'
53  }
54  if (DELETING_COMMAND.test(command)) {
55    return 'deleting'
56  }
57  if (INSTALLING_COMMAND.test(command)) {
58    return 'installing'
59  }
60  if (TEST_COMMAND.test(command)) {
61    return 'testing'
62  }
63  return 'running'
64}
65
66export const STRONGEST_FIRST: readonly Activity[] = [
67  'failing',
68  'shipping',
69  'deleting',
70  'testing',
71  'editing',
72  'installing',
73  'running',
74  'delegating',
75  'planning',
76  'exploring',
77  'asking',
78  'reading',
79  'thinking',
80]
81
82export function strongestActivity(seen: readonly Activity[]): Activity {
83  return STRONGEST_FIRST.find(activity => seen.includes(activity)) ?? 'thinking'
84}
85
hooks/band.tsx 33 lines
1import type { ElementTable, RenderElement } from 'claude-code'
2
3import type { NowPlaying } from '../types'
4import { waveLook } from './waves'
5
6const WAVE_MAX_COLUMNS = 48
7const WAVE_MIN_COLUMNS = 12
8
9export function drawBand(elements: ElementTable, clip: NowPlaying, waveName: string, columns: number, below: RenderElement): RenderElement {
10  const { Box, Text } = elements
11  const waveColumns = Math.min(WAVE_MAX_COLUMNS, columns)
12  const waveElement =
13    'Client' in elements && waveColumns >= WAVE_MIN_COLUMNS ? (
14      <elements.Client
15        key="miod-wave"
16        module="./wave.tsx"
17        props={{ ...clip, wave: waveName }}
18        width={waveColumns}
19        height={waveLook(waveName).rows}
20      />
21    ) : null
22
23  return (
24    <Box flexDirection="column">
25      <Text color={clip.color} wrap="truncate-end">
26        ♪ {clip.label}
27      </Text>
28      {waveElement}
29      {below}
30    </Box>
31  )
32}
33
hooks/compose.ts 213 lines
1import { flavourMood } from './flavours'
2import type { Bass } from './flavours'
3import type { Drums, Mood } from './moods'
4import { at, seededRandom } from './random'
5import { BEATS_PER_PHRASE } from './task'
6import type { Task } from './task'
7import { brighten, midiToFrequency, scaleStepToMidi } from './theory'
8import type { ScaleName } from './theory'
9
10export type Moment = {
11  mood: Mood
12  scale: ScaleName
13  energy: number
14  contextFill: number
15  toolCalls: number
16  helpers: number
17}
18
19export type Voice = 'lead' | 'harmony' | 'bass' | 'pad' | 'hat'
20
21export type Note = {
22  start: number
23  length: number
24  frequency: number
25  gain: number
26  voice: Voice
27}
28
29const HARMONY_STEPS = [2, 4, 7]
30const HAT_PATTERN: Record<Drums, number> = { none: 0, light: 0.5, steady: 1, busy: 2 }
31
32export function energyFromRate(tokensPerMinute: number): number {
33  if (tokensPerMinute <= 0) {
34    return 0
35  }
36  return Math.min(1, Math.log10(1 + tokensPerMinute) / Math.log10(1 + 20000))
37}
38
39export function composePhrase(task: Task, phraseNumber: number, moment: Moment): Note[] {
40  const mood = flavourMood(moment.mood, task.flavour)
41  const random = seededRandom(task.seed ^ Math.imul(phraseNumber + 1, 0x9e3779b1))
42  const scale = soundingScale(task, moment.scale)
43  const octave = mood.octave + (moment.contextFill > 0.6 ? 1 : 0)
44  const notesPerBeat = busierWhenSpendingFast(mood.notesPerBeat, moment.energy)
45  const fill = Math.min(0.95, mood.fill + moment.energy * 0.3)
46  const beat = 60 / task.tempo
47
48  const melody = writeMelody(task, scale, phraseNumber, octave, notesPerBeat, fill, beat, random)
49  const notes: Note[] = [...melody, ...writeBass(task, scale, task.flavour.bass, moment.energy, beat)]
50
51  if (mood.pad) {
52    notes.push(...writePad(task, scale, beat))
53  }
54  notes.push(...writeHarmonies(melody, scale, moment.helpers))
55  notes.push(...writeDrums(mood.drums, moment.toolCalls, notesPerBeat, beat, random))
56  if (mood.clash || moment.contextFill > 0.8) {
57    notes.push(writeUneasyDrone(task, beat))
58  }
59
60  return notes
61}
62
63export function soundingScale(task: Task, scale: ScaleName): ScaleName {
64  return brighten(scale, task.flavour.brightness)
65}
66
67export function composeFinish(task: Task, scale: ScaleName): Note[] {
68  return arpeggio(task, scale, [0, 2, 4, 7], 0.5, BEATS_PER_PHRASE)
69}
70
71export function composeFixed(task: Task): Note[] {
72  return arpeggio(task, 'major', [0, 2, 4, 7, 9], 0.25, 2)
73}
74
75function busierWhenSpendingFast(notesPerBeat: 1 | 2 | 4, energy: number): 1 | 2 | 4 {
76  if (energy < 0.66) {
77    return notesPerBeat
78  }
79  return notesPerBeat === 1 ? 2 : 4
80}
81
82function writeMelody(
83  task: Task,
84  scale: ScaleName,
85  phraseNumber: number,
86  octave: number,
87  notesPerBeat: number,
88  fill: number,
89  beat: number,
90  random: () => number,
91): Note[] {
92  const slot = beat / notesPerBeat
93  const swing = notesPerBeat > 1 ? slot * task.flavour.swing : 0
94  const notes: Note[] = []
95  let step = 0
96
97  for (let i = 0; i < BEATS_PER_PHRASE * notesPerBeat; i++) {
98    const isOnBeat = i % notesPerBeat === 0
99    const beatNumber = i / notesPerBeat
100    step = isOnBeat ? at(task.theme, phraseNumber + beatNumber) + chordAt(task, beatNumber) : step + Math.floor(random() * 5) - 2
101    if (isOnBeat || random() < fill) {
102      notes.push({
103        start: i * slot + (i % 2 === 1 ? swing : 0),
104        length: slot * (isOnBeat ? 1.6 : 0.9),
105        frequency: midiToFrequency(scaleStepToMidi(task.root, scale, step) + octave * 12),
106        gain: isOnBeat ? 0.32 : 0.22,
107        voice: 'lead',
108      })
109    }
110  }
111
112  return notes
113}
114
115const BASS_LINES: Record<Bass, { calm: readonly number[]; busy: readonly number[]; hold: number }> = {
116  roots: { calm: [0, 0], busy: [0, 4, 0, 4], hold: 0.95 },
117  walking: { calm: [0, 2, 4, 5], busy: [0, 2, 4, 5, 4, 2, 1, 0], hold: 0.9 },
118  pulse: { calm: [0, 0, 0, 0, 0, 0, 0, 0], busy: [0, 0, 4, 4, 0, 0, 4, 4], hold: 0.4 },
119}
120
121function writeBass(task: Task, scale: ScaleName, bass: Bass, energy: number, beat: number): Note[] {
122  const line = BASS_LINES[bass]
123  const steps = energy > 0.5 ? line.busy : line.calm
124  const length = (BEATS_PER_PHRASE * beat) / steps.length
125
126  return steps.map((step, i) => ({
127    start: i * length,
128    length: length * line.hold,
129    frequency: midiToFrequency(scaleStepToMidi(task.root, scale, step + chordAt(task, (i * length) / beat)) - 24),
130    gain: 0.35,
131    voice: 'bass',
132  }))
133}
134
135function writePad(task: Task, scale: ScaleName, beat: number): Note[] {
136  const beatsPerChord = BEATS_PER_PHRASE / task.progression.length
137
138  return task.progression.flatMap((chord, i) =>
139    [0, 2, 4].map(step => ({
140      start: i * beatsPerChord * beat,
141      length: beatsPerChord * beat,
142      frequency: midiToFrequency(scaleStepToMidi(task.root, scale, chord + step) - 12),
143      gain: 0.07,
144      voice: 'pad' as const,
145    })),
146  )
147}
148
149function chordAt(task: Task, beatNumber: number): number {
150  const beatsPerChord = BEATS_PER_PHRASE / task.progression.length
151  return at(task.progression, Math.floor(beatNumber / beatsPerChord))
152}
153
154function writeHarmonies(melody: readonly Note[], scale: ScaleName, helpers: number): Note[] {
155  const voices = HARMONY_STEPS.slice(0, Math.min(helpers, HARMONY_STEPS.length))
156
157  return voices.flatMap(step =>
158    melody
159      .filter((_, i) => i % 2 === 0)
160      .map(note => ({
161        ...note,
162        frequency: note.frequency * 2 ** (scaleStepToMidi(0, scale, step) / 12),
163        gain: note.gain * 0.45,
164        voice: 'harmony' as const,
165      })),
166  )
167}
168
169function writeDrums(drums: Drums, toolCalls: number, notesPerBeat: number, beat: number, random: () => number): Note[] {
170  const hitsPerBeat = HAT_PATTERN[drums]
171  const notes: Note[] = []
172
173  if (hitsPerBeat > 0) {
174    const gap = beat / hitsPerBeat
175    for (let i = 0; i < BEATS_PER_PHRASE * hitsPerBeat; i++) {
176      notes.push(hat(i * gap, i % hitsPerBeat === 0 ? 0.14 : 0.08))
177    }
178  }
179
180  const slot = beat / notesPerBeat
181  for (let i = 0; i < Math.min(16, toolCalls * 2); i++) {
182    notes.push(hat(Math.floor(random() * BEATS_PER_PHRASE * notesPerBeat) * slot, 0.12))
183  }
184
185  return notes
186}
187
188function writeUneasyDrone(task: Task, beat: number): Note {
189  return {
190    start: 0,
191    length: BEATS_PER_PHRASE * beat,
192    frequency: midiToFrequency(task.root - 18),
193    gain: 0.08,
194    voice: 'bass',
195  }
196}
197
198function arpeggio(task: Task, scale: ScaleName, steps: readonly number[], gapBeats: number, holdBeats: number): Note[] {
199  const beat = 60 / task.tempo
200
201  return steps.map((step, i) => ({
202    start: i * beat * gapBeats,
203    length: beat * holdBeats,
204    frequency: midiToFrequency(scaleStepToMidi(task.root, scale, step)),
205    gain: 0.25,
206    voice: 'lead',
207  }))
208}
209
210function hat(start: number, gain: number): Note {
211  return { start, length: 0.05, frequency: 0, gain, voice: 'hat' }
212}
213
hooks/moods.ts 203 lines
1import type { Activity } from './activity'
2import { pick, pickWeighted } from './random'
3import type { ScaleName } from './theory'
4
5export type Drums = 'none' | 'light' | 'steady' | 'busy'
6
7export type Mood = {
8  feel: string
9  color: string
10  notesPerBeat: 1 | 2 | 4
11  fill: number
12  octave: number
13  drums: Drums
14  pad: boolean
15  clash: boolean
16}
17
18export const MOODS: Record<Activity, readonly Mood[]> = {
19  thinking: [
20    { feel: 'calm', color: '#8FA3BF', notesPerBeat: 1, fill: 0.1, octave: 0, drums: 'none', pad: true, clash: false },
21    { feel: 'pondering', color: '#94A7C2', notesPerBeat: 1, fill: 0.2, octave: 0, drums: 'none', pad: true, clash: false },
22    { feel: 'still', color: '#8A9DB8', notesPerBeat: 1, fill: 0.05, octave: 0, drums: 'none', pad: true, clash: false },
23    { feel: 'hazy', color: '#9AA9C4', notesPerBeat: 1, fill: 0.15, octave: 1, drums: 'none', pad: true, clash: false },
24    { feel: 'wondering', color: '#8FA8C9', notesPerBeat: 1, fill: 0.2, octave: 1, drums: 'none', pad: true, clash: false },
25    { feel: 'quiet', color: '#8796B0', notesPerBeat: 1, fill: 0.05, octave: -1, drums: 'none', pad: true, clash: false },
26    { feel: 'mulling', color: '#93A2BB', notesPerBeat: 1, fill: 0.25, octave: 0, drums: 'light', pad: true, clash: false },
27    { feel: 'daydream', color: '#A0AECB', notesPerBeat: 1, fill: 0.15, octave: 1, drums: 'none', pad: true, clash: false },
28    { feel: 'patient', color: '#8C9CB6', notesPerBeat: 2, fill: 0.1, octave: 0, drums: 'none', pad: true, clash: false },
29    { feel: 'humming', color: '#97A6C0', notesPerBeat: 1, fill: 0.3, octave: 0, drums: 'none', pad: false, clash: false },
30  ],
31  reading: [
32    { feel: 'airy', color: '#7FB3D5', notesPerBeat: 1, fill: 0.5, octave: 1, drums: 'none', pad: true, clash: false },
33    { feel: 'browsing', color: '#86B8D8', notesPerBeat: 1, fill: 0.4, octave: 1, drums: 'none', pad: true, clash: false },
34    { feel: 'studious', color: '#78ADD1', notesPerBeat: 1, fill: 0.45, octave: 0, drums: 'light', pad: true, clash: false },
35    { feel: 'flipping pages', color: '#7DB6DA', notesPerBeat: 2, fill: 0.3, octave: 1, drums: 'none', pad: false, clash: false },
36    { feel: 'absorbed', color: '#73A8CC', notesPerBeat: 1, fill: 0.55, octave: 0, drums: 'none', pad: true, clash: false },
37    { feel: 'scanning', color: '#82B5D6', notesPerBeat: 2, fill: 0.4, octave: 1, drums: 'light', pad: false, clash: false },
38    { feel: 'library', color: '#7AAFD2', notesPerBeat: 1, fill: 0.35, octave: 0, drums: 'none', pad: true, clash: false },
39    { feel: 'learning', color: '#88BADB', notesPerBeat: 1, fill: 0.5, octave: 1, drums: 'none', pad: true, clash: false },
40    { feel: 'careful', color: '#76AACE', notesPerBeat: 1, fill: 0.3, octave: 0, drums: 'light', pad: true, clash: false },
41    { feel: 'soaking in', color: '#80B2D4', notesPerBeat: 1, fill: 0.6, octave: 1, drums: 'none', pad: true, clash: false },
42  ],
43  exploring: [
44    { feel: 'curious', color: '#A78BFA', notesPerBeat: 2, fill: 0.5, octave: 1, drums: 'none', pad: true, clash: false },
45    { feel: 'wandering', color: '#AE93FB', notesPerBeat: 1, fill: 0.5, octave: 1, drums: 'none', pad: true, clash: false },
46    { feel: 'adventurous', color: '#A083F7', notesPerBeat: 2, fill: 0.6, octave: 1, drums: 'light', pad: false, clash: false },
47    { feel: 'far away', color: '#B39BFC', notesPerBeat: 1, fill: 0.4, octave: 1, drums: 'none', pad: true, clash: false },
48    { feel: 'searching', color: '#9C80F5', notesPerBeat: 2, fill: 0.45, octave: 0, drums: 'light', pad: true, clash: false },
49    { feel: 'wide-eyed', color: '#B8A0FC', notesPerBeat: 1, fill: 0.6, octave: 2, drums: 'none', pad: true, clash: false },
50    { feel: 'trekking', color: '#A589F9', notesPerBeat: 2, fill: 0.5, octave: 0, drums: 'steady', pad: false, clash: false },
51    { feel: 'foreign', color: '#9F86F6', notesPerBeat: 1, fill: 0.5, octave: 1, drums: 'none', pad: true, clash: false },
52    { feel: 'mapping', color: '#AA8EFA', notesPerBeat: 2, fill: 0.4, octave: 1, drums: 'light', pad: true, clash: false },
53    { feel: 'discovering', color: '#B496FB', notesPerBeat: 2, fill: 0.7, octave: 1, drums: 'light', pad: true, clash: false },
54  ],
55  planning: [
56    { feel: 'hopeful', color: '#F5C26B', notesPerBeat: 1, fill: 0.4, octave: 0, drums: 'light', pad: true, clash: false },
57    { feel: 'organised', color: '#F2BD62', notesPerBeat: 2, fill: 0.4, octave: 0, drums: 'light', pad: false, clash: false },
58    { feel: 'sketching', color: '#F7C878', notesPerBeat: 1, fill: 0.35, octave: 1, drums: 'none', pad: true, clash: false },
59    { feel: 'optimistic', color: '#F8CA7D', notesPerBeat: 1, fill: 0.5, octave: 1, drums: 'light', pad: true, clash: false },
60    { feel: 'mapping out', color: '#F0B95C', notesPerBeat: 1, fill: 0.4, octave: 0, drums: 'light', pad: true, clash: false },
61    { feel: 'listing', color: '#F4C067', notesPerBeat: 2, fill: 0.3, octave: 0, drums: 'steady', pad: false, clash: false },
62    { feel: 'blueprint', color: '#F6C471', notesPerBeat: 1, fill: 0.45, octave: 0, drums: 'none', pad: true, clash: false },
63    { feel: 'focused', color: '#EFB656', notesPerBeat: 1, fill: 0.3, octave: 0, drums: 'light', pad: true, clash: false },
64    { feel: 'daybreak', color: '#F9CE85', notesPerBeat: 1, fill: 0.5, octave: 1, drums: 'none', pad: true, clash: false },
65    { feel: 'step by step', color: '#F3BF65', notesPerBeat: 2, fill: 0.35, octave: 0, drums: 'light', pad: false, clash: false },
66  ],
67  asking: [
68    { feel: 'waiting', color: '#B0BEC5', notesPerBeat: 1, fill: 0.1, octave: 0, drums: 'none', pad: true, clash: false },
69    { feel: 'expectant', color: '#B5C2C8', notesPerBeat: 1, fill: 0.15, octave: 1, drums: 'none', pad: true, clash: false },
70    { feel: 'holding', color: '#A9B8BF', notesPerBeat: 1, fill: 0.05, octave: 0, drums: 'none', pad: true, clash: false },
71    { feel: 'listening', color: '#B8C5CB', notesPerBeat: 1, fill: 0.1, octave: 1, drums: 'none', pad: true, clash: false },
72    { feel: 'hovering', color: '#ACBAC1', notesPerBeat: 1, fill: 0.1, octave: 1, drums: 'none', pad: true, clash: false },
73    { feel: 'polite', color: '#B3C0C6', notesPerBeat: 1, fill: 0.15, octave: 0, drums: 'none', pad: true, clash: false },
74    { feel: 'paused', color: '#A6B5BC', notesPerBeat: 1, fill: 0.0, octave: 0, drums: 'none', pad: true, clash: false },
75    { feel: 'gentle nudge', color: '#BAC7CD', notesPerBeat: 1, fill: 0.2, octave: 0, drums: 'light', pad: true, clash: false },
76    { feel: 'question mark', color: '#AFBDC4', notesPerBeat: 2, fill: 0.1, octave: 1, drums: 'none', pad: false, clash: false },
77    { feel: 'on hold', color: '#A4B3BA', notesPerBeat: 1, fill: 0.1, octave: 0, drums: 'none', pad: true, clash: false },
78  ],
79  editing: [
80    { feel: 'bright', color: '#F5A623', notesPerBeat: 2, fill: 0.7, octave: 1, drums: 'light', pad: false, clash: false },
81    { feel: 'crafting', color: '#F2A01A', notesPerBeat: 2, fill: 0.6, octave: 0, drums: 'light', pad: true, clash: false },
82    { feel: 'sparkling', color: '#F8B03A', notesPerBeat: 2, fill: 0.7, octave: 1, drums: 'light', pad: false, clash: false },
83    { feel: 'busy hands', color: '#F09A12', notesPerBeat: 4, fill: 0.5, octave: 0, drums: 'steady', pad: false, clash: false },
84    { feel: 'sculpting', color: '#F4A92E', notesPerBeat: 2, fill: 0.6, octave: 0, drums: 'light', pad: true, clash: false },
85    { feel: 'playful', color: '#F9B546', notesPerBeat: 2, fill: 0.8, octave: 1, drums: 'light', pad: false, clash: false },
86    { feel: 'tidy', color: '#EE9810', notesPerBeat: 2, fill: 0.5, octave: 0, drums: 'steady', pad: false, clash: false },
87    { feel: 'in the zone', color: '#F3A527', notesPerBeat: 4, fill: 0.4, octave: 0, drums: 'light', pad: false, clash: false },
88    { feel: 'polishing', color: '#F7AD35', notesPerBeat: 1, fill: 0.6, octave: 1, drums: 'none', pad: true, clash: false },
89    { feel: 'building', color: '#F1A21F', notesPerBeat: 2, fill: 0.65, octave: 0, drums: 'steady', pad: true, clash: false },
90  ],
91  running: [
92    { feel: 'driving', color: '#4FC3F7', notesPerBeat: 2, fill: 0.5, octave: 0, drums: 'steady', pad: false, clash: false },
93    { feel: 'rolling', color: '#56C7F8', notesPerBeat: 2, fill: 0.5, octave: 0, drums: 'steady', pad: false, clash: false },
94    { feel: 'machinery', color: '#48BEF5', notesPerBeat: 4, fill: 0.3, octave: -1, drums: 'steady', pad: false, clash: false },
95    { feel: 'cruising', color: '#5CCAF8', notesPerBeat: 2, fill: 0.4, octave: 0, drums: 'light', pad: false, clash: false },
96    { feel: 'motoring', color: '#43BAF3', notesPerBeat: 2, fill: 0.55, octave: 0, drums: 'busy', pad: false, clash: false },
97    { feel: 'humming along', color: '#60CCF9', notesPerBeat: 1, fill: 0.5, octave: 0, drums: 'steady', pad: true, clash: false },
98    { feel: 'on the move', color: '#4CC1F6', notesPerBeat: 4, fill: 0.4, octave: 0, drums: 'steady', pad: false, clash: false },
99    { feel: 'pistons', color: '#40B7F2', notesPerBeat: 2, fill: 0.45, octave: -1, drums: 'busy', pad: false, clash: false },
100    { feel: 'conveyor', color: '#52C5F7', notesPerBeat: 2, fill: 0.35, octave: 0, drums: 'steady', pad: false, clash: false },
101    { feel: 'chugging', color: '#3DB4F0', notesPerBeat: 2, fill: 0.5, octave: -1, drums: 'steady', pad: false, clash: false },
102  ],
103  installing: [
104    { feel: 'patient', color: '#90A4AE', notesPerBeat: 2, fill: 0.3, octave: 0, drums: 'steady', pad: false, clash: false },
105    { feel: 'unpacking', color: '#96A9B3', notesPerBeat: 2, fill: 0.35, octave: 0, drums: 'light', pad: false, clash: false },
106    { feel: 'downloading', color: '#8A9FA9', notesPerBeat: 4, fill: 0.2, octave: 0, drums: 'steady', pad: false, clash: false },
107    { feel: 'assembling', color: '#9AADB6', notesPerBeat: 2, fill: 0.3, octave: 0, drums: 'steady', pad: false, clash: false },
108    { feel: 'loading bar', color: '#859BA5', notesPerBeat: 2, fill: 0.25, octave: 1, drums: 'light', pad: false, clash: false },
109    { feel: 'setting up', color: '#93A6B0', notesPerBeat: 1, fill: 0.35, octave: 0, drums: 'steady', pad: true, clash: false },
110    { feel: 'fetching', color: '#8CA1AB', notesPerBeat: 2, fill: 0.3, octave: 0, drums: 'light', pad: false, clash: false },
111    { feel: 'wiring', color: '#879CA6', notesPerBeat: 2, fill: 0.3, octave: -1, drums: 'steady', pad: false, clash: false },
112    { feel: 'stacking boxes', color: '#9FB1BA', notesPerBeat: 2, fill: 0.35, octave: 0, drums: 'steady', pad: false, clash: false },
113    { feel: 'waiting in line', color: '#8399A3', notesPerBeat: 1, fill: 0.2, octave: 0, drums: 'light', pad: true, clash: false },
114  ],
115  testing: [
116    { feel: 'focused', color: '#26C6DA', notesPerBeat: 2, fill: 0.6, octave: 0, drums: 'busy', pad: false, clash: false },
117    { feel: 'checking', color: '#2DCADD', notesPerBeat: 2, fill: 0.5, octave: 0, drums: 'busy', pad: false, clash: false },
118    { feel: 'precise', color: '#1FC2D7', notesPerBeat: 4, fill: 0.4, octave: 0, drums: 'busy', pad: false, clash: false },
119    { feel: 'ticking', color: '#33CDE0', notesPerBeat: 2, fill: 0.45, octave: 0, drums: 'steady', pad: false, clash: false },
120    { feel: 'nervous', color: '#1ABED4', notesPerBeat: 2, fill: 0.55, octave: 0, drums: 'busy', pad: false, clash: false },
121    { feel: 'lab work', color: '#38D0E2', notesPerBeat: 2, fill: 0.4, octave: 1, drums: 'steady', pad: false, clash: false },
122    { feel: 'counting', color: '#22C4D9', notesPerBeat: 4, fill: 0.35, octave: 0, drums: 'steady', pad: false, clash: false },
123    { feel: 'suspense', color: '#15BAD1', notesPerBeat: 2, fill: 0.5, octave: 0, drums: 'busy', pad: true, clash: false },
124    { feel: 'verifying', color: '#2BC8DC', notesPerBeat: 2, fill: 0.55, octave: 0, drums: 'busy', pad: false, clash: false },
125    { feel: 'drumroll', color: '#30CBDE', notesPerBeat: 1, fill: 0.4, octave: 0, drums: 'busy', pad: false, clash: false },
126  ],
127  shipping: [
128    { feel: 'triumphant', color: '#FFD54F', notesPerBeat: 4, fill: 0.6, octave: 1, drums: 'busy', pad: false, clash: false },
129    { feel: 'victory lap', color: '#FFD95C', notesPerBeat: 4, fill: 0.7, octave: 1, drums: 'busy', pad: false, clash: false },
130    { feel: 'anthem', color: '#FFD142', notesPerBeat: 2, fill: 0.7, octave: 1, drums: 'busy', pad: true, clash: false },
131    { feel: 'celebrating', color: '#FFDD69', notesPerBeat: 4, fill: 0.8, octave: 1, drums: 'busy', pad: false, clash: false },
132    { feel: 'launch', color: '#FFCD36', notesPerBeat: 4, fill: 0.6, octave: 2, drums: 'busy', pad: false, clash: false },
133    { feel: 'confetti', color: '#FFE176', notesPerBeat: 4, fill: 0.9, octave: 2, drums: 'steady', pad: false, clash: false },
134    { feel: 'fanfare', color: '#FFC92A', notesPerBeat: 2, fill: 0.6, octave: 1, drums: 'busy', pad: false, clash: false },
135    { feel: 'proud', color: '#FFD34A', notesPerBeat: 2, fill: 0.6, octave: 0, drums: 'steady', pad: true, clash: false },
136    { feel: 'home run', color: '#FFDB62', notesPerBeat: 4, fill: 0.7, octave: 1, drums: 'busy', pad: false, clash: false },
137    { feel: 'shipped', color: '#FFCF3C', notesPerBeat: 4, fill: 0.6, octave: 1, drums: 'busy', pad: false, clash: false },
138  ],
139  deleting: [
140    { feel: 'ominous', color: '#E57373', notesPerBeat: 1, fill: 0.4, octave: -1, drums: 'light', pad: false, clash: true },
141    { feel: 'careful', color: '#E37070', notesPerBeat: 1, fill: 0.3, octave: -1, drums: 'light', pad: false, clash: true },
142    { feel: 'demolition', color: '#E06A6A', notesPerBeat: 2, fill: 0.4, octave: -1, drums: 'steady', pad: false, clash: true },
143    { feel: 'shadowy', color: '#E77777', notesPerBeat: 1, fill: 0.35, octave: -1, drums: 'none', pad: true, clash: true },
144    { feel: 'vanishing', color: '#E87B7B', notesPerBeat: 1, fill: 0.3, octave: -1, drums: 'none', pad: true, clash: true },
145    { feel: 'serious', color: '#DD6565', notesPerBeat: 1, fill: 0.4, octave: -1, drums: 'light', pad: false, clash: true },
146    { feel: 'sweeping', color: '#EA8080', notesPerBeat: 2, fill: 0.3, octave: -1, drums: 'light', pad: false, clash: true },
147    { feel: 'dramatic', color: '#DB6161', notesPerBeat: 1, fill: 0.45, octave: -1, drums: 'steady', pad: true, clash: true },
148    { feel: 'clean slate', color: '#EC8484', notesPerBeat: 1, fill: 0.25, octave: 0, drums: 'none', pad: true, clash: true },
149    { feel: 'thunder', color: '#D95D5D', notesPerBeat: 2, fill: 0.5, octave: -2, drums: 'busy', pad: false, clash: true },
150  ],
151  delegating: [
152    { feel: 'layered', color: '#81C784', notesPerBeat: 2, fill: 0.5, octave: 0, drums: 'light', pad: true, clash: false },
153    { feel: 'choir', color: '#87CB89', notesPerBeat: 1, fill: 0.5, octave: 0, drums: 'none', pad: true, clash: false },
154    { feel: 'teamwork', color: '#7BC37E', notesPerBeat: 2, fill: 0.55, octave: 0, drums: 'steady', pad: true, clash: false },
155    { feel: 'chatter', color: '#8DCF8F', notesPerBeat: 4, fill: 0.4, octave: 1, drums: 'light', pad: false, clash: false },
156    { feel: 'ensemble', color: '#75BF78', notesPerBeat: 2, fill: 0.5, octave: 0, drums: 'light', pad: true, clash: false },
157    { feel: 'crowd', color: '#92D294', notesPerBeat: 2, fill: 0.6, octave: 0, drums: 'steady', pad: false, clash: false },
158    { feel: 'round', color: '#70BB73', notesPerBeat: 2, fill: 0.45, octave: 1, drums: 'light', pad: true, clash: false },
159    { feel: 'hive', color: '#98D69A', notesPerBeat: 4, fill: 0.35, octave: 0, drums: 'steady', pad: false, clash: false },
160    { feel: 'orchestra', color: '#6BB76E', notesPerBeat: 2, fill: 0.5, octave: 0, drums: 'light', pad: true, clash: false },
161    { feel: 'relay', color: '#9DD99F', notesPerBeat: 2, fill: 0.5, octave: 0, drums: 'steady', pad: false, clash: false },
162  ],
163  failing: [
164    { feel: 'tense', color: '#EF5350', notesPerBeat: 2, fill: 0.5, octave: -1, drums: 'steady', pad: false, clash: true },
165    { feel: 'worried', color: '#ED4F4C', notesPerBeat: 2, fill: 0.4, octave: -1, drums: 'steady', pad: false, clash: true },
166    { feel: 'alarm', color: '#F15855', notesPerBeat: 4, fill: 0.4, octave: 0, drums: 'busy', pad: false, clash: true },
167    { feel: 'stuck', color: '#EB4A47', notesPerBeat: 1, fill: 0.3, octave: -1, drums: 'light', pad: true, clash: true },
168    { feel: 'frustrated', color: '#F35D5A', notesPerBeat: 2, fill: 0.55, octave: -1, drums: 'busy', pad: false, clash: true },
169    { feel: 'uh-oh', color: '#E94643', notesPerBeat: 2, fill: 0.45, octave: 0, drums: 'steady', pad: false, clash: true },
170    { feel: 'stormy', color: '#F5625F', notesPerBeat: 2, fill: 0.5, octave: -1, drums: 'busy', pad: true, clash: true },
171    { feel: 'wobbly', color: '#E7423F', notesPerBeat: 2, fill: 0.4, octave: -1, drums: 'light', pad: false, clash: true },
172    { feel: 'red light', color: '#F76764', notesPerBeat: 1, fill: 0.4, octave: -1, drums: 'steady', pad: false, clash: true },
173    { feel: 'troubleshooting', color: '#E53E3B', notesPerBeat: 2, fill: 0.5, octave: 0, drums: 'steady', pad: false, clash: true },
174  ],
175}
176
177export const MOOD_SCALES: Record<Activity, Partial<Record<ScaleName, number>>> = {
178  thinking: { dorian: 3, 'major pentatonic': 3, lydian: 2, egyptian: 2, 'minor pentatonic': 1, 'whole tone': 1 },
179  reading: { 'major pentatonic': 3, dorian: 3, major: 2, lydian: 2, 'minor pentatonic': 1, egyptian: 1 },
180  exploring: { lydian: 4, 'whole tone': 2, mixolydian: 2, hirajoshi: 1, 'in sen': 1, egyptian: 1, dorian: 1 },
181  planning: { 'major pentatonic': 4, major: 3, lydian: 2, mixolydian: 2, dorian: 1 },
182  asking: { 'major pentatonic': 3, lydian: 2, dorian: 2, egyptian: 1, 'whole tone': 1 },
183  editing: { major: 4, mixolydian: 2, 'major pentatonic': 2, lydian: 2, dorian: 1 },
184  running: { mixolydian: 3, dorian: 3, 'minor pentatonic': 2, blues: 2, minor: 1 },
185  installing: { 'minor pentatonic': 3, dorian: 2, 'major pentatonic': 2, egyptian: 2, mixolydian: 1, blues: 1 },
186  testing: { dorian: 4, minor: 2, 'melodic minor': 2, 'harmonic minor': 1, 'minor pentatonic': 1 },
187  shipping: { mixolydian: 4, major: 3, lydian: 2, 'major pentatonic': 2 },
188  deleting: { phrygian: 4, 'harmonic minor': 2, 'in sen': 2, minor: 2, 'double harmonic': 1 },
189  delegating: { major: 3, mixolydian: 2, dorian: 2, lydian: 2, 'major pentatonic': 1 },
190  failing: { minor: 3, 'harmonic minor': 3, phrygian: 2, blues: 1, 'double harmonic': 1, 'melodic minor': 1 },
191}
192
193export const PHRASES_PER_VARIANT = 4
194
195export type MoodChoice = { activity: Activity; mood: Mood; scale: ScaleName; phrases: number }
196
197export function nextMood(previous: MoodChoice | null, activity: Activity, random: () => number): MoodChoice {
198  if (previous && previous.activity === activity && previous.phrases < PHRASES_PER_VARIANT) {
199    return { ...previous, phrases: previous.phrases + 1 }
200  }
201  return { activity, mood: pick(random, MOODS[activity]), scale: pickWeighted(random, MOOD_SCALES[activity]), phrases: 1 }
202}
203
hooks/random.ts 45 lines
1export function hashText(text: string): number {
2  let hash = 0x811c9dc5
3  for (let i = 0; i < text.length; i++) {
4    hash ^= text.charCodeAt(i)
5    hash = Math.imul(hash, 0x01000193)
6  }
7  return hash >>> 0
8}
9
10export function seededRandom(seed: number): () => number {
11  let state = seed >>> 0
12  return () => {
13    state = (state + 0x6d2b79f5) >>> 0
14    let t = state
15    t = Math.imul(t ^ (t >>> 15), t | 1)
16    t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
17    return ((t ^ (t >>> 14)) >>> 0) / 4294967296
18  }
19}
20
21export function at<T>(items: readonly T[], index: number): T {
22  const item = items[((index % items.length) + items.length) % items.length]
23  if (item === undefined) {
24    throw new Error('miod: picked from an empty list')
25  }
26  return item
27}
28
29export function pick<T>(random: () => number, items: readonly T[]): T {
30  return at(items, Math.floor(random() * items.length))
31}
32
33export function pickWeighted<K extends string>(random: () => number, weights: Partial<Record<K, number>>): K {
34  const entries = Object.entries(weights) as [K, number][]
35  const total = entries.reduce((sum, [, weight]) => sum + weight, 0)
36  let roll = random() * total
37  for (const [key, weight] of entries) {
38    roll -= weight
39    if (roll < 0) {
40      return key
41    }
42  }
43  return at(entries, -1)[0]
44}
45
hooks/synth.ts 132 lines
1import type { Note, Voice } from './compose'
2import { seededRandom } from './random'
3import { STYLES } from './styles'
4import type { Style } from './styles'
5
6export const SAMPLE_RATE = 22050
7export const WAVE_POINTS_PER_SECOND = 20
8export const TAIL_SECONDS = 0.8
9
10const PEAK = 0.7
11const EDGE_IN_SECONDS = 0.015
12const EDGE_OUT_SECONDS = 0.25
13
14type VoiceSound = { attackSeconds: number; fadePerSecond: number; releaseSeconds: number }
15
16const VOICE_SOUND: Record<Voice, VoiceSound> = {
17  lead: { attackSeconds: 0.01, fadePerSecond: 3, releaseSeconds: 0.12 },
18  harmony: { attackSeconds: 0.03, fadePerSecond: 3, releaseSeconds: 0.15 },
19  bass: { attackSeconds: 0.02, fadePerSecond: 1.5, releaseSeconds: 0.15 },
20  pad: { attackSeconds: 0.5, fadePerSecond: 0, releaseSeconds: 0.6 },
21  hat: { attackSeconds: 0.001, fadePerSecond: 60, releaseSeconds: 0.02 },
22}
23
24export type Clip = { wav: Uint8Array; levels: number[] }
25
26export type Fade = { inSeconds?: number; outSeconds?: number }
27
28export function renderClip(style: Style, notes: readonly Note[], seconds: number, noiseSeed: number, fade: Fade = {}): Clip {
29  const samples = mix(style, notes, Math.ceil((seconds + TAIL_SECONDS) * SAMPLE_RATE), seededRandom(noiseSeed))
30  keepUnderPeak(samples)
31  applyFade(samples, { inSeconds: EDGE_IN_SECONDS, outSeconds: EDGE_OUT_SECONDS })
32  applyFade(samples, fade)
33  const levels = loudness(samples).slice(0, Math.ceil(seconds * WAVE_POINTS_PER_SECOND))
34  return { wav: encodeWav(samples), levels }
35}
36
37function keepUnderPeak(samples: Float32Array) {
38  let peak = 0
39  for (const sample of samples) {
40    peak = Math.max(peak, Math.abs(sample))
41  }
42  if (peak > PEAK) {
43    const scale = PEAK / peak
44    samples.forEach((sample, i) => {
45      samples[i] = sample * scale
46    })
47  }
48}
49
50function applyFade(samples: Float32Array, { inSeconds = 0, outSeconds = 0 }: Fade) {
51  const fadeIn = Math.floor(inSeconds * SAMPLE_RATE)
52  const fadeOut = Math.floor(outSeconds * SAMPLE_RATE)
53  for (let i = 0; i < samples.length; i++) {
54    const rise = fadeIn > 0 ? Math.min(1, i / fadeIn) : 1
55    const fall = fadeOut > 0 ? Math.min(1, (samples.length - i) / fadeOut) : 1
56    samples[i] = (samples[i] ?? 0) * rise * fall
57  }
58}
59
60export function loudness(samples: Float32Array): number[] {
61  const size = SAMPLE_RATE / WAVE_POINTS_PER_SECOND
62  const levels: number[] = []
63  for (let point = 0; Math.round(point * size) < samples.length; point++) {
64    const start = Math.round(point * size)
65    const end = Math.min(samples.length, Math.round((point + 1) * size))
66    let sum = 0
67    for (let i = start; i < end; i++) {
68      sum += (samples[i] ?? 0) ** 2
69    }
70    levels.push(Math.min(1, (Math.sqrt(sum / (end - start)) / PEAK) * 1.6))
71  }
72  return levels
73}
74
75export function toBase64(bytes: Uint8Array): string {
76  let binary = ''
77  for (let i = 0; i < bytes.length; i += 0x8000) {
78    binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000))
79  }
80  return btoa(binary)
81}
82
83function mix(style: Style, notes: readonly Note[], length: number, noise: () => number): Float32Array {
84  const samples = new Float32Array(length)
85
86  for (const note of notes) {
87    const { attackSeconds, fadePerSecond, releaseSeconds } = VOICE_SOUND[note.voice]
88    const wave = STYLES[note.voice === 'lead' || note.voice === 'harmony' ? style : 'sine']
89    const first = Math.floor(note.start * SAMPLE_RATE)
90    const count = Math.min(length - first, Math.floor(note.length * SAMPLE_RATE))
91
92    for (let i = 0; i < count; i++) {
93      const t = i / SAMPLE_RATE
94      const fadeIn = Math.min(1, t / attackSeconds)
95      const fadeOut = Math.min(1, (count - i) / (SAMPLE_RATE * releaseSeconds))
96      const volume = fadeIn * fadeOut * Math.exp(-fadePerSecond * t) * note.gain
97      const value = note.voice === 'hat' ? noise() * 2 - 1 : wave(note.frequency * t)
98      samples[first + i] = (samples[first + i] ?? 0) + value * volume
99    }
100  }
101
102  return samples
103}
104
105
106function encodeWav(samples: Float32Array): Uint8Array {
107  const bytes = new Uint8Array(44 + samples.length * 2)
108  const view = new DataView(bytes.buffer)
109  const writeText = (offset: number, text: string) => {
110    for (let i = 0; i < text.length; i++) {
111      view.setUint8(offset + i, text.charCodeAt(i))
112    }
113  }
114
115  writeText(0, 'RIFF')
116  view.setUint32(4, 36 + samples.length * 2, true)
117  writeText(8, 'WAVE')
118  writeText(12, 'fmt ')
119  view.setUint32(16, 16, true)
120  view.setUint16(20, 1, true)
121  view.setUint16(22, 1, true)
122  view.setUint32(24, SAMPLE_RATE, true)
123  view.setUint32(28, SAMPLE_RATE * 2, true)
124  view.setUint16(32, 2, true)
125  view.setUint16(34, 16, true)
126  writeText(36, 'data')
127  view.setUint32(40, samples.length * 2, true)
128  samples.forEach((sample, i) => view.setInt16(44 + i * 2, Math.round(Math.max(-1, Math.min(1, sample)) * 32767), true))
129
130  return bytes
131}
132
hooks/task.ts 56 lines
1import { FLAVOURS } from './flavours'
2import type { Flavour } from './flavours'
3import { hashText, pick, seededRandom } from './random'
4import { STYLE_NAMES } from './styles'
5import type { Style } from './styles'
6import { noteName, PROGRESSIONS, RELATED_KEY_STEPS } from './theory'
7import type { ScaleName } from './theory'
8
9export type Task = {
10  seed: number
11  root: number
12  tempo: number
13  style: Style
14  flavour: Flavour
15  progression: readonly number[]
16  theme: readonly number[]
17}
18
19export const BEATS_PER_PHRASE = 8
20
21const LOWEST_ROOT = 57
22
23export function startTask(prompt: string, startedAt: number, previous?: Task): Task {
24  const seed = hashText(`${startedAt}:${prompt}`)
25  const random = seededRandom(seed)
26  const freshRoot = LOWEST_ROOT + Math.floor(random() * 12)
27  const root = previous ? keyNear(previous.root + pick(random, RELATED_KEY_STEPS)) : freshRoot
28  const tempo = 84 + Math.floor(random() * 48)
29  const style = pick(random, STYLE_NAMES)
30  const theme = Array.from({ length: 8 }, () => Math.floor(random() * 9) - 2)
31  const flavour = pick(random, FLAVOURS)
32  const progression = pick(random, PROGRESSIONS)
33
34  return {
35    seed,
36    root,
37    tempo: Math.round(tempo * flavour.tempo),
38    style: flavour.style === 'task' ? style : flavour.style,
39    flavour,
40    progression,
41    theme,
42  }
43}
44
45function keyNear(midi: number): number {
46  return LOWEST_ROOT + (((midi - LOWEST_ROOT) % 12) + 12) % 12
47}
48
49export function describeTask(task: Task, scale: ScaleName): string {
50  return `${task.flavour.name} · ${noteName(task.root)} ${scale} · ${task.tempo} bpm`
51}
52
53export function phraseSeconds(task: Task): number {
54  return (BEATS_PER_PHRASE * 60) / task.tempo
55}
56
hooks/waves/index.ts 23 lines
1import { bars } from './bars'
2import { dots } from './dots'
3import { line } from './line'
4import { mirror } from './mirror'
5import { pulse } from './pulse'
6import type { WaveLook } from './shared'
7
8export const WAVES = { bars, line, mirror, dots, pulse } satisfies Record<string, WaveLook>
9
10export type WaveName = keyof typeof WAVES
11
12export const WAVE_NAMES = Object.keys(WAVES) as WaveName[]
13
14export const DEFAULT_WAVE: WaveName = 'bars'
15
16export function isWaveName(name: string): name is WaveName {
17  return (WAVE_NAMES as string[]).includes(name)
18}
19
20export function waveLook(name: string): WaveLook {
21  return isWaveName(name) ? WAVES[name] : WAVES[DEFAULT_WAVE]
22}
23
hooks/theory.ts 76 lines
1import { at } from './random'
2
3export const SCALES = {
4  major: [0, 2, 4, 5, 7, 9, 11],
5  minor: [0, 2, 3, 5, 7, 8, 10],
6  dorian: [0, 2, 3, 5, 7, 9, 10],
7  mixolydian: [0, 2, 4, 5, 7, 9, 10],
8  lydian: [0, 2, 4, 6, 7, 9, 11],
9  phrygian: [0, 1, 3, 5, 7, 8, 10],
10  'major pentatonic': [0, 2, 4, 7, 9],
11  'minor pentatonic': [0, 3, 5, 7, 10],
12  'harmonic minor': [0, 2, 3, 5, 7, 8, 11],
13  'melodic minor': [0, 2, 3, 5, 7, 9, 11],
14  blues: [0, 3, 5, 6, 7, 10],
15  'whole tone': [0, 2, 4, 6, 8, 10],
16  hirajoshi: [0, 2, 3, 7, 8],
17  'in sen': [0, 1, 5, 7, 10],
18  'double harmonic': [0, 1, 4, 5, 7, 8, 11],
19  egyptian: [0, 2, 5, 7, 10],
20} as const
21
22export type ScaleName = keyof typeof SCALES
23
24export const SCALE_NAMES = Object.keys(SCALES) as ScaleName[]
25
26export const PROGRESSIONS: readonly (readonly number[])[] = [
27  [0, 4, 5, 3],
28  [0, 5, 3, 4],
29  [5, 3, 0, 4],
30  [0, 3, 4, 3],
31  [0, 6, 5, 4],
32  [0, 3, 0, 4],
33  [1, 4, 0, 0],
34  [0, 2, 5, 4],
35  [0, 0, 3, 3],
36  [0, 5, 1, 4],
37]
38
39export const RELATED_KEY_STEPS = [0, 5, 7, -5, -7]
40
41const DARK_TO_BRIGHT: readonly ScaleName[] = [
42  'phrygian',
43  'minor',
44  'minor pentatonic',
45  'dorian',
46  'mixolydian',
47  'major pentatonic',
48  'major',
49  'lydian',
50]
51
52export function brighten(scale: ScaleName, steps: number): ScaleName {
53  const position = DARK_TO_BRIGHT.indexOf(scale)
54  if (position === -1) {
55    return scale
56  }
57  const index = Math.max(0, Math.min(DARK_TO_BRIGHT.length - 1, position + steps))
58  return DARK_TO_BRIGHT[index] ?? scale
59}
60
61const NOTE_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']
62
63export function noteName(midi: number): string {
64  return at(NOTE_NAMES, midi)
65}
66
67export function midiToFrequency(midi: number): number {
68  return 440 * 2 ** ((midi - 69) / 12)
69}
70
71export function scaleStepToMidi(root: number, scale: ScaleName, step: number): number {
72  const notes = SCALES[scale]
73  const octave = Math.floor(step / notes.length)
74  return root + octave * 12 + at(notes, step)
75}
76
hooks/wave.tsx 39 lines
1import type { ClientModule } from 'claude-code'
2
3import type { WaveProps } from '../types'
4import { waveLook } from './waves'
5
6type WaveState = { clipId: number; frame: number }
7
8const Wave: ClientModule<WaveProps, WaveState> = (clip, surface) => {
9  const { Box, Text } = surface.elements
10
11  if (surface.state === undefined) {
12    surface.setState({ clipId: clip.clipId, frame: 0 })
13    surface.every(1000 / clip.pointsPerSecond, () => {
14      const state = surface.state
15      if (state) {
16        surface.setState({ ...state, frame: state.frame + 1 })
17      }
18    })
19  } else if (surface.state.clipId !== clip.clipId) {
20    surface.setState({ clipId: clip.clipId, frame: 0 })
21  }
22
23  const frame = surface.state?.clipId === clip.clipId ? surface.state.frame : 0
24  const rows = waveLook(clip.wave).draw(clip.levels, surface.columns, frame)
25
26  return (
27    <Box flexDirection="column">
28      {rows.map(({ done, ahead }) => (
29        <Box>
30          <Text color={clip.color}>{done}</Text>
31          <Text dimColor>{ahead}</Text>
32        </Box>
33      ))}
34    </Box>
35  )
36}
37
38export default Wave
39
hooks/flavours.ts 53 lines
1import type { Drums, Mood } from './moods'
2import type { Style } from './styles'
3
4export type Bass = 'roots' | 'walking' | 'pulse'
5
6export type Flavour = {
7  name: string
8  style: Style | 'task'
9  tempo: number
10  brightness: number
11  busier: number
12  fill: number
13  octave: number
14  drums: number
15  pad: 'mood' | 'always' | 'never'
16  swing: number
17  bass: Bass
18}
19
20export const FLAVOURS: readonly Flavour[] = [
21  { name: 'lo-fi', style: 'triangle', tempo: 0.8, brightness: 0, busier: -1, fill: -0.1, octave: 0, drums: 0, pad: 'always', swing: 0.25, bass: 'roots' },
22  { name: 'chiptune', style: 'soft-square', tempo: 1.15, brightness: 1, busier: 1, fill: 0.1, octave: 1, drums: 1, pad: 'never', swing: 0, bass: 'pulse' },
23  { name: 'ambient', style: 'sine', tempo: 0.7, brightness: 1, busier: -1, fill: -0.2, octave: 1, drums: -2, pad: 'always', swing: 0, bass: 'roots' },
24  { name: 'jazzy', style: 'organ', tempo: 1, brightness: 0, busier: 0, fill: 0.1, octave: 0, drums: 0, pad: 'mood', swing: 0.33, bass: 'walking' },
25  { name: 'marching', style: 'soft-square', tempo: 1.1, brightness: 0, busier: 0, fill: 0, octave: 0, drums: 1, pad: 'never', swing: 0, bass: 'pulse' },
26  { name: 'dreamy', style: 'sine', tempo: 0.85, brightness: 1, busier: 0, fill: 0, octave: 1, drums: -1, pad: 'always', swing: 0.1, bass: 'roots' },
27  { name: 'funky', style: 'soft-saw', tempo: 1.05, brightness: 0, busier: 1, fill: 0.15, octave: 0, drums: 1, pad: 'never', swing: 0.2, bass: 'walking' },
28  { name: 'minimal', style: 'sine', tempo: 1, brightness: 0, busier: -1, fill: -0.25, octave: 0, drums: -1, pad: 'never', swing: 0, bass: 'roots' },
29  { name: 'epic', style: 'soft-saw', tempo: 0.95, brightness: -1, busier: 0, fill: 0.1, octave: -1, drums: 1, pad: 'always', swing: 0, bass: 'pulse' },
30  { name: 'music box', style: 'triangle', tempo: 1, brightness: 1, busier: 0, fill: 0, octave: 2, drums: -2, pad: 'never', swing: 0, bass: 'roots' },
31  { name: 'sleepy', style: 'sine', tempo: 0.75, brightness: -1, busier: -1, fill: -0.15, octave: -1, drums: -1, pad: 'always', swing: 0.15, bass: 'roots' },
32  { name: 'bouncy', style: 'task', tempo: 1.2, brightness: 1, busier: 0, fill: 0.1, octave: 1, drums: 0, pad: 'mood', swing: 0.15, bass: 'walking' },
33]
34
35const NOTES_PER_BEAT = [1, 2, 4] as const
36const DRUMS: readonly Drums[] = ['none', 'light', 'steady', 'busy']
37
38export function flavourMood(mood: Mood, flavour: Flavour): Mood {
39  return {
40    ...mood,
41    notesPerBeat: stepAlong(NOTES_PER_BEAT, mood.notesPerBeat, flavour.busier),
42    fill: Math.max(0, Math.min(0.95, mood.fill + flavour.fill)),
43    octave: Math.max(-2, Math.min(2, mood.octave + flavour.octave)),
44    drums: stepAlong(DRUMS, mood.drums, flavour.drums),
45    pad: flavour.pad === 'mood' ? mood.pad : flavour.pad === 'always',
46  }
47}
48
49function stepAlong<T>(ladder: readonly T[], value: T, steps: number): T {
50  const index = Math.max(0, Math.min(ladder.length - 1, ladder.indexOf(value) + steps))
51  return ladder[index] ?? value
52}
53