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.

<img src="docs/miod.png" alt="miod logo: a honey-amber music note with code brackets" width="200">
[!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.
A Claude Code mod (MIDI + mod) that writes music on the spot while Claude works. No music files, no library, no AI model.

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.
/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
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 |
|---|---|---|
| thinking | no tool | calm |
| reading | Read, Grep, Glob, MCP tools | airy |
| exploring | WebSearch, WebFetch | curious (lydian) |
| planning | TodoWrite, plan mode | hopeful (major pentatonic) |
| asking | AskUserQuestion | waiting, almost silent |
| editing | Edit, Write | bright (major) |
| running | Bash | driving beat |
| installing | npm, bun, pip, brew… install | patient (minor pentatonic) |
| testing | test, check, lint, tsc | focused (dorian), busy beat |
| shipping | git commit, push, merge, gh pr | triumphant (mixolydian) |
| deleting | rm -r, git reset --hard, git clean | ominous (phrygian), low drone |
| delegating | Agent | one harmony voice per subagent |
| failing | a Bash command that failed | tense (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).
MOODS in hooks/moods.ts.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.FLAVOURS in hooks/flavours.ts. Each field nudges every mood: brighter or darker scale, busier or calmer, drums up or down, swing, bass line.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.PROGRESSIONS in hooks/theory.ts.hooks/waves/ that exports a WaveLook (how many rows, and a draw function), then list it in WAVES in hooks/waves/index.ts.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>.
afplay.hooks/register.tsx 315 lines1import { 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}
315hooks/activity.ts 85 lines1export 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}
85hooks/band.tsx 33 lines1import 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}
33hooks/compose.ts 213 lines1import { 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}
213hooks/moods.ts 203 lines1import 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}
203hooks/random.ts 45 lines1export 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}
45hooks/synth.ts 132 lines1import 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}
132hooks/task.ts 56 lines1import { 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}
56hooks/waves/index.ts 23 lines1import { 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}
23hooks/theory.ts 76 lines1import { 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}
76hooks/wave.tsx 39 lines1import 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
39hooks/flavours.ts 53 lines1import 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