A long techno set above your chat box: three drops, auto mixing into the next track, favorites, Mac media keys. Claude changes it from chat.

https://github.com/user-attachments/assets/46ea094d-1790-4cb2-94a0-cd0940f2e1a9
A Claude Code mod that plays a real techno set in a band above your chat box.
[!IMPORTANT] You need a Mac (macOS 12+), Claude Code 2.1.286+ and Node 20+ (
brew install node).
1. Add the marketplace, in Terminal:
claude plugin marketplace add ruslanshchuchkin/claude-techno-mod
2. Install the mod:
claude plugin install techno@techno-mod
3. Open a new chat (terminal or the desktop app's Code tab) and grow your first track. Any phrase works:
/techno late night deploy
Press play. 🔊
[!TIP] Both apps share the same plugins, so one install covers the CLI and the desktop app. Already inside a
claudesession? Type/plugin marketplace add ruslanshchuchkin/claude-techno-mod, then/plugin install techno@techno-mod.
The band in the video above, part by part:
| On the deck | What it does |
|---|---|
| ♡ / share | Keep the track in favorites / copy its share line |
| vol − 80% + | The volume, in steps of 20%. vol mutes. Or ask Claude: "quieter" |
| ▰▰▱▱ drop 1 | The whole song, one block per 15 seconds, and where you are: intro, groove, build, drop, break, outro |
| the grid | The loop that plays now. Click a cell to add a hit, click a name to mute it |
| auto | On: it plays the whole set and moves on by itself. Off: it stays on one part |
| mood | sad, mysterious or dark: the scale, the tempo and the energy in one click. The bpm sits beside it |
| + new song | Type any name, or press a number: one of your recent chats or a classic |
| edit | The studio: instruments, patterns, notes, grit, filters, tempo, and the song's parts |
| <kbd>⏯</kbd> <kbd>⏭</kbd> <kbd>⏮</kbd> | The Mac media keys work too, and the track shows in Control Center |
The free macOS voices say the name of the track: Whisper in the second breakdown, Daniel in the last drop, pitched down and drowned in reverb.
A robot whispering "fix flaky login test" over a riser at 133 bpm never gets old.
When a track ends, auto plays your previous chat. It walks back through your Claude Code chats, newest first, and each chat's title becomes a song. Every fourth song is a classic, so it never runs dry.
now: anonymous season statistics by country
up next: mrr 10k 50k project opportunities
then: fae court quiz business design and seo
then: segfault on sunday ← a classic
The mod reads the titles on your Mac, from the chats Claude Code already keeps there, and uploads them nowhere. (The chat you're in does hear what's playing, so Claude can change it.)
A whole track, with your edits, is one line. Press share and it's on your clipboard:
/techno late-night-deploy@133m0e4k2a5
A friend pastes it into their chat and hears the same track, because the code is the track. Want audio? edit → ↓ mp3 saves a minute to ~/Music/techno.
Every sound is math in plain JavaScript (hooks/engine.js). Nothing is sampled or downloaded.
| Sound | How it's made |
|---|---|
| kick | A sine wave that falls in pitch, with a click on top |
| bass | A sine sub under two detuned saws, through a filter |
| hats | Filtered noise |
| pads | Dark minor chords, never bright ones |
[!NOTE] 🎧 Mixed for long sessions, not one loud minute. About −14 LUFS (the level Spotify plays at), no clipping, and the harsh 2 to 5 kHz range sits about 20 dB under the bass.
The music does not belong to one chat. A small background player makes it, and every Claude Code chat is a remote control for it, terminal and desktop alike. Three chats: one track, three remotes. Quit the last Claude Code and the music stops with it.
chat ─┐
chat ─┼─ socket ──▶ techno player (node) ──▶ TechnoPlayer.app ──▶ 🔊
chat ─┘ renders bars ahead gapless audio,
Control Center, media keys
The music never touches the network. The one request: when a chat starts, it reads the version number on GitHub to tell you an update is ready. The helper app ships prebuilt (one universal binary for Apple silicon and Intel), so you do not need Xcode.
When a new version is out, the deck says techno 0.x.y is ready. Press update, then open a new chat. That's it.
Want it fully automatic? /plugin → Marketplaces → techno-mod → Enable auto-update. It's off by default, and that's a good default: with it on, whatever lands in this repo runs on your Mac without you looking. The button lets you pick the moment.
Or by hand:
claude plugin marketplace update techno-mod
claude plugin update techno@techno-mod
To remove it: claude plugin uninstall techno@techno-mod. Then delete ~/Library/Application Support/techno (your favorites) and ~/Library/Caches/techno (the player's cache).
| You see | Do this |
|---|---|
| The band says the player did not start | Run node -v. If it's missing or older than 20, install Node and press play again |
/techno does not exist | Open a new chat. A chat that was open before the install keeps its old plugins |
| "hooks modules are turned off" | Run claude in a terminal once, run /login, then open a new chat |
| The media keys control Spotify | macOS gives them to the app that played last. Press play in the deck once |
| Still stuck | Open an issue with the last lines of ~/Library/Caches/techno/player.log |
git clone https://github.com/ruslanshchuchkin/claude-techno-mod && cd claude-techno-mod
claude --plugin-dir .
It reloads when you save. Listen without Claude Code at all:
node scripts/set.mjs "ship it" "warehouse 4am" set.wav && afplay set.wav
| File | What it is |
|---|---|
hooks/engine.js | The synth, the set plan, transitions, share codes |
hooks/conductor.js | What each loop plays, and how auto moves on |
hooks/register.js | The chat side: /techno, the jam tool, the remote control |
hooks/views.js | The deck, the edit card, favorites |
hooks/words.js | Which words of a prompt become a track |
player/techno.mjs | The background player |
player/TechnoPlayer.swift | The helper app. After a change, run node scripts/build-helper.mjs |
Checks: node scripts/smoke.mjs, claude plugin validate ., claude plugin test.
MIT. Make some noise. 🖤
⭐ Star it if it played you through a long night.
hooks/register.js 483 lines1// techno: a long techno set that plays while you work with Claude. One
2// background player (player/techno.mjs) makes the music for every chat; this
3// module is a remote control for it: the band above the chat box, the
4// /techno command, and the mcp__techno__jam tool Claude uses to change the
5// music. The Mac's play, next and previous keys work too (the player's
6// TechnoPlayer.app is in Control Center as "Now Playing").
7//
8// /techno show or hide the app
9// /techno <phrase> a new track from the phrase (or from a share code)
10// /techno stop | save | code
11import { parseCode, encodeCode, describe, render, toWav, grid, activeLayers, cleanTrack, soundInfo, planOf, planAdds, LAYERS, GRID_LAYERS, PLAN, VIBES, BPM_MIN, BPM_MAX, trackFor, moodName } from './engine.js'
12import { appView, miniView } from './views.js'
13import { keyWords } from './words.js'
14import { CLASSICS } from './classics.js'
15
16const TOOL = 'mcp__techno__jam'
17// The plugin version: in the jam tool's answer, and the player of an older
18// version is replaced by this one. Keep it equal to plugin.json (a test checks).
19const VERSION = '0.9.11'
20// [accent, normal] fill of a hit in the step grid
21const LAYER_COLORS = {
22 kick: ['#e85a5a', '#c94040'],
23 hats: ['#3fb6d9', '#2a93b5'],
24 bass: ['#e8b03a', '#c99320'],
25 perc: ['#7b93f0', '#5b77e0'],
26 clap: ['#e070c0', '#c0509f'],
27 ride: ['#4cc4a0', '#2fa583'],
28 acid: ['#8fdc50', '#6fbf30'],
29 stab: ['#b88ae8', '#9a6ad0'],
30}
31// Where a new version is announced: the plugin.json on GitHub's main branch.
32const LATEST = 'https://raw.githubusercontent.com/ruslanshchuchkin/claude-techno-mod/main/.claude-plugin/plugin.json'
33// Runs the update with the Claude Code that runs this chat (the desktop
34// app's own copy, or the CLI), found among the shell's parents.
35const UPDATE_SH = 'p=$PPID; c=; for i in 1 2 3 4; do x=$(ps -o comm= -p "$p"); case "$x" in */MacOS/claude|*/claude/versions/*|*/bin/claude) c=$x; break;; esac; p=$(ps -o ppid= -p "$p" | tr -d " "); done; ' +
36 '[ -n "$c" ] || c=$(sh -lc "command -v claude"); [ -n "$c" ] || { echo "claude not found" >&2; exit 2; }; ' +
37 '"$c" plugin marketplace update techno-mod && "$c" plugin update techno@techno-mod'
38
39// Module state. The player holds the music state; this is the chat's view of it.
40const s = {
41 home: '',
42 sock: '',
43 ps: null, // the player's last answer to GET /state
44 down: '', // why the player is not there, for the pane
45 starting: null,
46 isOpen: false,
47 screen: 'deck', // deck | favorites | crate
48 editOpen: false,
49 editTab: null,
50 used: false,
51 barClosed: false,
52 repo: [],
53 lastPrompt: '',
54 you: '',
55 status: '',
56 pollTimer: null,
57 grids: new Map(),
58 classicTurn: Math.floor(Math.random() * 9), // which four classics the new song screen shows
59 update: null, // { latest, state: 'ready' | 'working' | 'done' | 'failed', why }
60 debug: [], // dev only: TECHNO_DEBUG=crate,update with the mod loaded from a folder (--plugin-dir)
61}
62
63const shareLine = (code) => '/techno ' + code
64const versionAtLeast = (a, b) => {
65 const x = String(a).split('.').map(Number), y = String(b).split('.').map(Number)
66 for (let i = 0; i < 3; i++) if ((x[i] ?? 0) !== (y[i] ?? 0)) return (x[i] ?? 0) > (y[i] ?? 0)
67 return true
68}
69
70// ---------- the player ----------
71
72async function ask($, body) {
73 const r = await $.http.fetch('http://techno' + (body ? '/cmd' : '/state'), body ? { method: 'POST', body: JSON.stringify(body), socketPath: s.sock } : { socketPath: s.sock })
74 if (!r.ok) throw new Error('player answered ' + r.status)
75 return JSON.parse(r.text)
76}
77
78// Starts the player when no player (or an older one) answers. It runs in its
79// own process group, so it outlives this chat; it quits when no Claude Code
80// process is left.
81async function ensurePlayer($) {
82 if (s.starting) return s.starting
83 s.starting = (async () => {
84 try {
85 const ps = await ask($).catch(() => null)
86 if (ps && versionAtLeast(ps.version, VERSION)) { s.ps = ps; s.down = ''; return true }
87 if (ps) { await ask($, { op: 'quit' }).catch(() => {}); await $.clock.sleep(400) }
88 // Homebrew's node first, then the one the login shell finds (18 or later)
89 const node = await $.process.run(['sh', '-lc', 'for n in /opt/homebrew/bin/node "$(command -v node)" /usr/local/bin/node; do [ -x "$n" ] && echo "$n" && break; done'], { timeoutMs: 10000 })
90 const bin = node.stdout.trim().split('\n')[0]
91 if (!bin) { s.down = 'techno needs node (brew install node)'; return false }
92 const log = s.home + '/Library/Caches/techno/daemon.out'
93 // what the player says from here on: a line starting "techno: " is why it quit
94 const from = Number((await $.process.run(['sh', '-c', 'wc -c < "$1" 2>/dev/null || echo 0', 'sh', log], { timeoutMs: 5000 })).stdout.trim()) || 0
95 const said = async () => (await $.process.run(['sh', '-c', 'tail -c +"$2" "$1" 2>/dev/null | grep "^techno: " | tail -n 1', 'sh', log, String(from + 1)], { timeoutMs: 5000 })).stdout.trim().replace(/^techno: /, '')
96 await $.process.run(['sh', '-c', 'mkdir -p "$(dirname "$3")"; perl -MPOSIX -e "POSIX::setsid(); exec @ARGV" "$1" "$2" </dev/null >>"$3" 2>&1 &', 'sh', bin, $.plugin.root + '/player/techno.mjs', log], { timeoutMs: 10000 })
97 // the first start compiles TechnoPlayer.app (swiftc), which takes a while
98 for (let i = 0; i < 120; i++) {
99 await $.clock.sleep(500)
100 const up = await ask($).catch(() => null)
101 if (up) { s.ps = up; s.down = ''; return true }
102 if (i % 4 === 3) { const why = await said(); if (why) { s.down = why; return false } }
103 }
104 s.down = (await said()) || 'the player did not start: see ~/Library/Caches/techno/daemon.out'
105 return false
106 } finally {
107 s.starting = null
108 }
109 })()
110 return s.starting
111}
112
113// Sends a command; starts the player first when it is not there.
114async function cmd($, body) {
115 try {
116 s.ps = await ask($, body)
117 } catch {
118 if (!(await ensurePlayer($))) { $.ui.invalidate('ui.render'); return null }
119 s.ps = await ask($, body).catch(() => s.ps)
120 }
121 markUsed($)
122 $.ui.invalidate('ui.render')
123 return s.ps
124}
125
126async function poll($) {
127 const before = s.ps?.rev
128 try {
129 s.ps = await ask($)
130 s.down = ''
131 } catch {
132 if (s.ps) { s.ps = null; s.down = 'the player stopped' }
133 }
134 if (s.ps?.rev !== before || s.ps?.playing) $.ui.invalidate('ui.render')
135}
136
137// ---------- saving ----------
138
139// Writes one minute (four loops) of a track to ~/Music/techno as an mp3
140// (or a wav without ffmpeg), shows it in Finder.
141async function save($, code) {
142 const t = parseCode(code)
143 if (!t) return 'Nothing to save yet.'
144 const dir = s.home + '/Music/techno'
145 const name = code.replace(/[@+*]/g, '_')
146 const tmp = dir + '/.' + name + '.wav'
147 s.status = 'saving…'
148 $.ui.invalidate('ui.render')
149 try {
150 const audio = render(t)
151 await $.process.run(['mkdir', '-p', dir])
152 const dec = await $.process.run(['sh', '-c', 'base64 --decode > "$1"', 'sh', tmp], { stdin: toWav(audio).toBase64(), timeoutMs: 30000 })
153 if (dec.exitCode !== 0) throw new Error(dec.stderr.trim() || 'base64 failed')
154 let out = dir + '/' + name + '.mp3'
155 const ff = await $.process.run(['ffmpeg', '-y', '-loglevel', 'error', '-stream_loop', '3', '-i', tmp, '-af', 'afade=t=in:d=0.5,afade=t=out:st=' + (audio.seconds * 4 - 4).toFixed(2) + ':d=4', '-b:a', '192k', out], { timeoutMs: 120000 }).catch(() => ({ exitCode: 1 }))
156 if (ff.exitCode === 0) await $.process.run(['rm', '-f', tmp])
157 else { out = dir + '/' + name + '.wav'; await $.process.run(['mv', tmp, out]) }
158 await $.process.run(['open', '-R', out], { timeoutMs: 5000 }).catch(() => {})
159 s.status = 'saved ' + out.replace(s.home, '~')
160 return 'Saved ' + out
161 } catch (err) {
162 s.status = 'save failed: ' + String(err?.message ?? err)
163 return s.status
164 } finally {
165 $.ui.invalidate('ui.render')
166 }
167}
168
169async function copyLine($, code) {
170 let ok = false
171 try { ok = (await $.ui.copy({ text: shareLine(code) }))?.isCopied !== false } catch {
172 try { ok = (await $.process.run(['pbcopy'], { stdin: shareLine(code), timeoutMs: 5000 })).exitCode === 0 } catch { /* no clipboard */ }
173 }
174 s.status = ok ? '✓ copied: ' + shareLine(code) : shareLine(code)
175 if (!s.isOpen) $.ui.toast(s.status)
176 $.ui.invalidate('ui.render')
177}
178
179// Share: one minute as an mp3, shown in Finder, and the play line copied.
180async function share($, code) {
181 await save($, code)
182 const where = s.status
183 await copyLine($, code)
184 if (where.startsWith('saved')) s.status = where + ' · play line copied'
185 $.ui.invalidate('ui.render')
186}
187
188// ---------- updates ----------
189
190// Ruslan, 2026-10-08: auto-update runs a new push without a look, so the
191// mod only says "update ready" and the person presses update.
192async function checkUpdate($) {
193 if (s.debug.includes('update')) { s.update = { latest: '9.9.9', state: 'ready' }; $.ui.invalidate('ui.render'); return }
194 try {
195 const r = await $.http.fetch(LATEST)
196 const latest = r.ok ? JSON.parse(r.text).version : null
197 if (latest && !versionAtLeast(VERSION, latest)) { s.update = { latest, state: 'ready' }; $.ui.invalidate('ui.render') }
198 } catch { /* offline: no notice */ }
199}
200
201async function runUpdate($) {
202 if (!s.update || s.update.state === 'working') return
203 s.update = { ...s.update, state: 'working' }
204 $.ui.invalidate('ui.render')
205 try {
206 // the debug run finds claude and stops there
207 const r = await $.process.run(['sh', '-c', s.debug.includes('update') ? UPDATE_SH.replace(/; "\$c" plugin.*$/, '; echo "$c"') : UPDATE_SH], { timeoutMs: 180000 })
208 s.update = r.exitCode === 0 ? { ...s.update, state: 'done' } : { ...s.update, state: 'failed', why: (r.stderr || r.stdout).trim().split('\n').pop()?.slice(0, 120) }
209 } catch (err) {
210 s.update = { ...s.update, state: 'failed', why: String(err?.message ?? err).slice(0, 120) }
211 }
212 $.ui.invalidate('ui.render')
213}
214
215// ---------- the app ----------
216
217function markUsed($) {
218 if (s.used && !s.barClosed) return
219 s.used = true
220 s.barClosed = false
221 $.store.set('bar', true)
222}
223
224function gridFor(code) {
225 if (!s.grids.has(code)) {
226 if (s.grids.size > 64) s.grids.clear()
227 const t = parseCode(code)
228 s.grids.set(code, t ? grid({ ...t, layers: Object.fromEntries(LAYERS.map((n) => [n, true])) }, 0) : {})
229 }
230 return s.grids.get(code)
231}
232
233// Reads the session's repo: the project, its branch and recent commits. The
234// player mixes these names into the tracks auto picks.
235async function loadRepo($) {
236 try {
237 const cwd = await $.session.cwd()
238 const out = [cwd.split('/').filter(Boolean).pop() ?? '']
239 const branch = await $.process.run(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], { timeoutMs: 5000 })
240 if (branch.exitCode === 0 && branch.stdout.trim()) out.push(branch.stdout.trim())
241 const log = await $.process.run(['git', 'log', '-3', '--pretty=%s'], { timeoutMs: 5000 })
242 if (log.exitCode === 0) for (const line of log.stdout.split('\n').map((l) => l.trim()).filter(Boolean)) out.push(line.slice(0, 40))
243 s.repo = out.filter((x, i, all) => x && !x.startsWith('scratch') && all.indexOf(x) === i)
244 } catch {
245 s.repo = []
246 }
247}
248
249function describeNow() {
250 const v = s.ps?.view
251 if (!v) return 'nothing'
252 const t = parseCode(v.code)
253 return `"${v.phrase}", ${t ? describe(t) : v.mood}, ${v.section === 'handover' ? 'mixing in from ' + v.from : v.name + ` (part ${(v.at ?? v.part) + 1} of ${(v.plan ?? PLAN).length})`}`
254}
255
256// ---------- hooks ----------
257
258export function register(on) {
259 on('session.start', async ($, e, next) => {
260 s.home = (await $.env.get('HOME')) ?? ''
261 s.sock = s.home + '/Library/Caches/techno/techno.sock'
262 if ((await $.store.get('bar')) === true) s.used = true
263 // never from an installed copy: those live in the plugin cache
264 if (!$.plugin.root.includes('/.claude/plugins/')) s.debug = String((await $.env.get('TECHNO_DEBUG')) ?? '').split(',').filter(Boolean)
265 if (s.debug.includes('crate')) s.screen = 'crate'
266 await $.tool.register({
267 name: 'jam',
268 description:
269 "Change the techno music in the user's techno pane (the techno mod). Use it when the user asks to change the music: darker or brighter, more or less energy, faster or slower, add or drop a layer, new patterns, the next part, the next or previous track, a new phrase, auto on or off, play or stop. Pass only what changes. " +
270 'mood: sad (minor), mysterious (hijaz, arabic) or dark (phrygian); it sets the sound, the scale and the tempo together. energy: 0 minimal, 1 rolling, 2 driving, 3 peak, 4 rave. ' +
271 'A track is a long set: intro, groove, build, three drops with breakdowns between them, an outro that mixes into the next track. next: true moves to the next part. track: "next" mixes into the next track now, "previous" goes back to the one before. ' +
272 'layers (kick, bass, hats, clap, perc, ride, pad, rumble, acid, voice, stab): true forces a layer on, false off, "auto" gives it back to the arrangement. dice: true rolls new patterns. phrase: a new phrase starts a new track. favorite: true adds the track to the favorites. volume: 0 mute .. 5 loudest, 4 is the default (each step about 6 dB); for "quieter" or "louder" move it one step. ' +
273 'note: a few words for the pane that say what you changed. Reply to the user in one short line.',
274 inputSchema: {
275 type: 'object',
276 properties: {
277 phrase: { type: 'string', description: 'A new phrase. It seeds a whole new track; its letters become the riff.' },
278 next: { type: 'boolean', description: 'Move to the next part of the track' },
279 track: { enum: ['next', 'previous'], description: 'Mix into the next track now, or go back to the previous one' },
280 bpm: { type: 'integer', minimum: BPM_MIN, maximum: BPM_MAX },
281 mood: { enum: VIBES.map((v) => v.name) },
282 energy: { type: 'integer', minimum: 0, maximum: 4 },
283 swing: { type: 'integer', minimum: 0, maximum: 3, description: '0 straight, 3 most shuffle on hats and percussion' },
284 transpose: { type: 'integer', minimum: 0, maximum: 11, description: 'Semitones up from the phrase key' },
285 dice: { type: 'boolean', description: 'Roll new patterns for the same phrase' },
286 layers: { type: 'object', properties: Object.fromEntries(LAYERS.map((n) => [n, { enum: [true, false, 'auto'] }])), additionalProperties: false },
287 favorite: { type: 'boolean', description: 'true adds the playing track to the favorites, false removes it' },
288 play: { type: 'boolean', description: 'true starts the music, false stops it' },
289 volume: { type: 'integer', minimum: 0, maximum: 5, description: '0 mute, 4 the default, 5 loudest' },
290 auto: { type: 'boolean', description: 'true lets the set play by itself (parts, drops, then the next track); false stays on a part until next' },
291 note: { type: 'string', description: 'A few words for the pane, like "darker, acid on"' },
292 },
293 },
294 })
295 await $.command.register({ name: 'techno', description: 'Show or hide the techno app', argumentHint: '[phrase or share code]', immediate: true })
296 // The player may take a while (its first start compiles the helper app):
297 // the command and the tool are there at once, the player comes up behind.
298 void (async () => {
299 await loadRepo($)
300 void checkUpdate($)
301 // the first chat on 0.8 hands the kept tracks and the last track to the player
302 const kept = await $.store.get('saved')
303 const track = await $.store.get('track')
304 if (await ensurePlayer($)) await cmd($, { op: 'hello', repo: s.repo, saved: Array.isArray(kept) ? kept : [], track: track && typeof track === 'object' ? encodeCode(cleanTrack(track)) : null })
305 if (!s.pollTimer) s.pollTimer = $.clock.every(1000, () => { poll($).catch(() => {}) })
306 $.ui.invalidate('ui.render')
307 })().catch(() => {})
308 return next(e)
309 })
310
311 on('command.run', { command: 'techno' }, async ($, e) => {
312 const arg = String(e.args ?? '').trim()
313 const word = arg.toLowerCase()
314 if (word === 'stop') { await cmd($, { op: 'pause' }); return { text: 'Stopped.' } }
315 if (word === 'save') return { text: s.ps ? await save($, s.ps.view.code) : 'Nothing plays yet.' }
316 if (word === 'code') return { text: s.ps ? shareLine(s.ps.view.code) : 'No track yet. Start with /techno <phrase>.' }
317 if (word === 'play') await cmd($, { op: 'play' })
318 else if (arg) { await cmd($, { op: 'pick', code: arg }); s.screen = 'deck' }
319 else if (s.isOpen) { s.isOpen = false; $.ui.invalidate('ui.render'); return {} }
320 s.isOpen = true
321 markUsed($)
322 $.ui.invalidate('ui.render')
323 return {}
324 })
325
326 on('tool.call', { tool: TOOL }, async ($, e) => {
327 const done = []
328 if (e.phrase) { await cmd($, { op: 'pick', code: e.phrase }); done.push('new track ' + e.phrase) }
329 if (e.track === 'next') { await cmd($, { op: 'skip' }); done.push('mixing into the next track') }
330 if (e.track === 'previous') { await cmd($, { op: 'prev' }); done.push('back to the previous track') }
331 if (e.next) { await cmd($, { op: 'next' }); done.push('next part') }
332 const jam = { op: 'jam', note: e.note }
333 for (const k of ['mood', 'bpm', 'energy', 'swing', 'transpose', 'dice', 'layers']) if (e[k] !== undefined) jam[k] = e[k]
334 if (Object.keys(jam).length > 2) { await cmd($, jam); done.push('changed ' + Object.keys(jam).filter((k) => k !== 'op' && k !== 'note').join(', ')) }
335 if (typeof e.auto === 'boolean') { await cmd($, { op: 'auto', on: e.auto }); done.push('auto ' + (e.auto ? 'on' : 'off')) }
336 if (typeof e.favorite === 'boolean' && s.ps && s.ps.fav !== e.favorite) { await cmd($, { op: 'fav' }); done.push(e.favorite ? 'added to favorites' : 'removed from favorites') }
337 if (Number.isInteger(e.volume)) { await cmd($, { op: 'volume', level: e.volume }); done.push('volume ' + e.volume) }
338 if (e.play === true) await cmd($, { op: 'play' })
339 if (e.play === false) await cmd($, { op: 'pause' })
340 else if (!s.ps?.playing && (e.phrase || e.track)) await cmd($, { op: 'play' })
341 if (!s.ps) return { result: 'The techno player is not running: ' + (s.down || 'unknown reason') }
342 s.you = s.lastPrompt
343 s.isOpen = true
344 s.screen = 'deck'
345 $.ui.invalidate('ui.render')
346 return { result: `${done.join('; ') || 'no change'}. Now: ${describeNow()}. Auto ${s.ps.auto ? 'on' : 'off'}, ${s.ps.playing ? 'playing' : 'stopped'}, volume ${s.ps.volume ?? 4} of 5. Share line: ${shareLine(s.ps.view.code)} (techno v${VERSION})` }
347 })
348
349 // Remember what the user asked, for the pane. While music plays, tell Claude about it.
350 on('prompt.submit', async ($, e, next) => {
351 s.lastPrompt = String(e.text ?? '').replace(/\s+/g, ' ').trim().slice(0, 80)
352 // the prompt's key words join the tracks auto picks; never starts the player
353 const words = keyWords(e.text)
354 if (words) await ask($, { op: 'words', phrase: words }).catch(() => {})
355 if (!s.ps?.playing) return next(e)
356 const note = `The techno mod is playing in the user's techno pane: ${describeNow()}, auto ${s.ps.auto ? 'on' : 'off'}. If this prompt is about the music, change it with the ${TOOL} tool and reply in one short line.`
357 return next({ ...e, context: [...(e.context ?? []), note] })
358 })
359
360 // The app, drawn in the band above the chat box. Hidden: a one-line bar,
361 // once techno was used, until its ×.
362 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
363 if (!s.isOpen && !(s.used && !s.barClosed && (s.ps || s.down))) return next(e)
364 const ui = $.ui.resolve(e)
365 const ps = s.ps
366 const v = ps?.view
367 const t = v ? parseCode(v.code) : null
368 const now = await $.clock.now()
369 const heard = t ? activeLayers(t) : {}
370 const all = v ? gridFor(v.code) : {}
371 // the rows: every layer that plays, plus the next one as a dashed row
372 const rows = t ? GRID_LAYERS.filter((n) => heard[n] || t.layers[n] === false).map((n) => [n, all[n] ?? '.'.repeat(16), LAYER_COLORS[n], !!heard[n], t.layers[n] === false]) : []
373 if (v?.next?.layer && GRID_LAYERS.includes(v.next.layer) && !rows.some((r) => r[0] === v.next.layer)) rows.push([v.next.layer, all[v.next.layer] ?? '.'.repeat(16), LAYER_COLORS[v.next.layer], false, false, true])
374 const looping = !!(ps?.playing && ps.barStartedAt)
375 const sinceBar = looping ? Math.max(0, now - ps.barStartedAt) : 0
376 const step = looping ? (v.bar * 16 + Math.min(15, Math.floor(sinceBar / ps.stepMs))) % 128 : 0
377 const nextIn = ps?.nextInMs !== null && ps?.nextInMs !== undefined ? Math.max(0, ps.nextInMs - (now - ps.now)) : null
378 // where the set is, for the section words: the parts of the plan and
379 // how far in the set it plays (in loops)
380 let where = null
381 if (v) {
382 const barMs = ps.barMs || 1800
383 const plan = v.plan ?? PLAN.map((_, k) => k)
384 const loops = plan.map((k) => PLAN[k].loops)
385 const totalLoops = loops.reduce((n, x) => n + x, 0)
386 const at = v.part === null ? plan.length : v.at ?? Math.max(0, plan.indexOf(v.part))
387 const before = loops.slice(0, at).reduce((n, x) => n + x, 0)
388 const inPart = v.part === null ? 0 : Math.max(0, loops[at] - v.loopsLeft)
389 const pos = Math.min(totalLoops, before + inPart + (v.part === null ? 0 : (v.bar + Math.min(1, sinceBar / barMs)) / 8))
390 where = { parts: plan.map((k) => ({ name: PLAN[k].name, section: PLAN[k].section, loops: PLAN[k].loops })), pos, loopMs: 8 * barMs, elapsed: pos * 8 * barMs, total: totalLoops * 8 * barMs }
391 }
392 // the edit card opens on the bass (or the first sound that plays)
393 const editTab = s.editTab ?? (heard.bass ? 'bass' : ['kick', 'hats', 'acid'].find((n) => heard[n]) ?? 'kick')
394 const vm = {
395 surface: e.surface,
396 screen: s.screen,
397 down: s.down,
398 ps,
399 view: v,
400 playing: !!ps?.playing,
401 auto: !!ps?.auto,
402 fav: !!ps?.fav,
403 volume: ps?.volume ?? 4,
404 nextIn,
405 where,
406 editOpen: s.editOpen,
407 editTab,
408 heard,
409 info: t && editTab && !['song', 'master', 'add'].includes(editTab) ? soundInfo(t, editTab) : null,
410 master: t?.master ?? [0, 0, 0],
411 plan: t ? planOf(t).map((k, i) => ({ k: i, name: PLAN[k].name, now: v.part !== null && i === (v.at ?? -1) })) : [],
412 planAdds: t ? planAdds(t) : [],
413 planEdited: !!t?.plan,
414 favorites: ps?.favorites ?? [],
415 recent: ps?.recent ?? [],
416 playlist: ps?.playlist ?? null,
417 canUndo: !!ps?.canUndo,
418 said: ps?.said ?? '',
419 you: s.you,
420 status: s.status,
421 crate: (() => {
422 const classics = Array.from({ length: 4 }, (_, i) => CLASSICS[(s.classicTurn * 4 + i) % CLASSICS.length])
423 const ex = trackFor(classics[0])
424 return { chats: ps?.chats ?? [], classics, example: { phrase: ex.phrase, mood: moodName(ex), bpm: ex.bpm }, now }
425 })(),
426 update: s.update,
427 gridEl: rows.length ? ui.Client({ key: 'grid', module: './grid.client.js', props: { rows, step, stepMs: ps?.stepMs || 115, playing: looping, stamp: ps?.barStartedAt ?? 0 } }) : null,
428 }
429 const go = (body) => () => cmd($, body)
430 const act = {
431 play: go({ op: 'play' }),
432 stop: go({ op: 'pause' }),
433 auto: go({ op: 'auto', on: !ps?.auto }),
434 next: go({ op: 'next' }),
435 skip: go({ op: 'skip' }),
436 prev: go({ op: 'prev' }),
437 fav: go({ op: 'fav' }),
438 volume: (level) => cmd($, { op: 'volume', level }),
439 toggleFav: (code) => cmd($, { op: 'fav', code }),
440 mood: (name) => cmd($, { op: 'jam', mood: name }),
441 dice: go({ op: 'dice' }),
442 undo: go({ op: 'undo' }),
443 bpm: (d) => cmd($, { op: 'jam', bpm: Math.max(BPM_MIN, Math.min(BPM_MAX, (v?.bpm ?? 128) + d)) }),
444 pick: (code) => { s.screen = 'deck'; return cmd($, { op: 'pick', code, playlist: null }) },
445 playFavorite: (code) => { s.screen = 'deck'; return cmd($, { op: 'pick', code, playlist: 'favorites' }) },
446 playFavorites: () => { s.screen = 'deck'; return cmd($, { op: 'playlist', name: 'favorites' }) },
447 phrase: (text) => (String(text).trim() ? act.pick(String(text).trim()) : undefined),
448 share: (code) => share($, code ?? v.code),
449 copy: (code) => copyLine($, code ?? v.code),
450 screen: (id) => { s.screen = id; $.ui.invalidate('ui.render') },
451 edit: () => { s.editOpen = !s.editOpen; $.ui.invalidate('ui.render') },
452 editTab: (id) => { s.editTab = id; $.ui.invalidate('ui.render') },
453 tweak: (e) => cmd($, { op: 'edit', ...e }),
454 layer: (name) => cmd($, { op: 'layer', name }),
455 open: () => { s.isOpen = true; s.screen = 'deck'; $.ui.invalidate('ui.render') },
456 close: () => { s.isOpen = false; $.ui.invalidate('ui.render') },
457 closeBar: () => { s.barClosed = true; $.store.set('bar', false); $.ui.invalidate('ui.render') },
458 retry: () => ensurePlayer($).then(() => $.ui.invalidate('ui.render')),
459 moreClassics: () => { s.classicTurn++; $.ui.invalidate('ui.render') },
460 update: () => runUpdate($),
461 }
462 const theirs = await next(e)
463 return ui.Box({ flexDirection: 'column', children: [s.isOpen ? appView(ui, vm, act) : miniView(ui, vm, act), theirs] })
464 })
465
466 // The grid posts { toggle: layer } for a click on a row name, and
467 // { step, layer } for a click on a cell.
468 on('ui.message', async ($, e, next) => {
469 const d = e.element === 'grid' ? e.data : null
470 if (d && typeof d.toggle === 'string' && LAYERS.includes(d.toggle)) { await cmd($, { op: 'layer', name: d.toggle }); return {} }
471 if (d && typeof d.layer === 'string' && GRID_LAYERS.includes(d.layer) && Number.isInteger(d.step)) { await cmd($, { op: 'step', layer: d.layer, i: d.step }); return {} }
472 return next(e)
473 })
474
475 // The player plays on for the other chats; it stops when the last Claude
476 // Code process ends.
477 on('session.end', async ($, e, next) => {
478 if (s.pollTimer) s.pollTimer.cancel()
479 s.pollTimer = null
480 return next(e)
481 })
482}
483hooks/engine.js 1458 lines1// Techno loop synth. Plain JavaScript with no Node or browser APIs, so the
2// same file runs inside the mod, in node (scripts/render.mjs), and in a page.
3//
4// A track is a small state object. The phrase seeds everything: the key, the
5// patterns, and the riff, whose notes are the phrase's own letters.
6
7export const LAYERS = ['kick', 'bass', 'hats', 'clap', 'perc', 'acid', 'stab', 'rumble', 'voice', 'ride', 'pad']
8export const MOODS = ['pitch black', 'dark', 'deep', 'warm', 'bright']
9export const ENERGIES = ['minimal', 'rolling', 'driving', 'peak', 'rave']
10const NOTE_NAMES = ['c', 'c#', 'd', 'd#', 'e', 'f', 'f#', 'g', 'g#', 'a', 'a#', 'b']
11// Low keys only: the sub root lands on e1..a1 (41..55 Hz), where techno sits.
12const KEY_CLASSES = [4, 5, 6, 7, 9, 5, 7, 9] // e, f, f#, g, a, f, g, a
13// The scales you can pick. Without a pick, the mood picks one:
14// pitch black phrygian, dark and deep minor, warm and bright dorian.
15export const SCALES = [
16 { name: 'minor', steps: [0, 2, 3, 5, 7, 8, 10] }, // sad, the classic
17 { name: 'dorian', steps: [0, 2, 3, 5, 7, 9, 10] }, // cool, a little hope
18 { name: 'phrygian', steps: [0, 1, 3, 5, 7, 8, 10] }, // dark, tense
19 { name: 'hijaz', steps: [0, 1, 4, 5, 7, 8, 10] }, // arabic
20 { name: 'harmonic', steps: [0, 2, 3, 5, 7, 8, 11] }, // dramatic
21]
22// The three moods you pick from. Each sets the sound (the old mood number:
23// filters, rumble, room) and the scale together, so they never disagree.
24// Old codes keep their own m and k; a pair that is no preset shows its nearest.
25// A mood also sets the tempo and lifts or lowers the energy of every part.
26export const VIBES = [
27 { name: 'sad', mood: 2, scale: 0, bpm: 122, lift: -1 }, // minor, a little more air, slower
28 { name: 'mysterious', mood: 1, scale: 3, bpm: 127, lift: 0 }, // hijaz, the arabic one
29 { name: 'dark', mood: 0, scale: 2, bpm: 132, lift: 1 }, // phrygian, pitch black, harder
30]
31export function vibeOf(t) {
32 const exact = VIBES.findIndex((v) => v.mood === t.mood && v.scale === scaleOf(t))
33 if (exact >= 0) return exact
34 return t.mood >= 2 ? 0 : scaleOf(t) === 3 ? 1 : t.mood === 0 ? 2 : 1
35}
36export const moodName = (t) => VIBES[vibeOf(t)].name
37
38// The track in another mood: its sound, scale, tempo, and the energy of its part.
39export function withVibe(input, name) {
40 const t = cleanTrack(input)
41 const v = VIBES.find((it) => it.name === name)
42 if (!v) return t
43 const energy = t.part === null || t.part === undefined ? t.energy : clamp(PLAN[t.part].energy + v.lift, 0, 4)
44 return { ...t, mood: v.mood, scale: v.scale, bpm: v.bpm, energy }
45}
46const scaleOf = (t) => t.scale ?? (t.mood === 0 ? 2 : t.mood >= 3 ? 1 : 0)
47const CHORDS = [[0, 3, 7, 12], [0, 3, 7, 10], [0, 3, 7, 10], [0, 3, 7, 10, 14], [0, 5, 7, 10, 14]]
48export const BARS = 8
49export const STEPS = 16
50export const BPM_MIN = 110
51export const BPM_MAX = 150
52
53// The layers the step grid shows (rumble has no steps of its own: it is the
54// kick's tail; the pad is one long swell; the voice says the name once).
55export const GRID_LAYERS = ['kick', 'hats', 'bass', 'perc', 'clap', 'ride', 'acid', 'stab']
56
57// The edit card (Ruslan, 2026-10-04, layout B "focus card"): per sound, its
58// instrument, its pattern, its notes and its tone; for the whole track, the
59// master filters. Index 0 is always the sound the phrase gives (for perc,
60// the phrase's own pick of rim, tom or bell).
61export const INSTRUMENTS = {
62 kick: ['deep', 'punchy', 'boom', 'hard'],
63 bass: ['rolling', 'sub', 'growl', 'acid'],
64 hats: ['tight', 'long', 'metal', 'dusty'],
65 clap: ['room', 'snap', 'snare'],
66 perc: ['phrase', 'rim', 'tom', 'bell'],
67 acid: ['acid', 'square', 'pluck', 'soft'],
68}
69// Patterns to try, one bar of 16 steps. A pick becomes step edits, so it
70// rides in the share code like a click on the grid.
71export const PATTERNS = {
72 kick: { 'four on the floor': 'x...x...x...x...', broken: 'x...x...x..x..x.', 'half time': 'x.......x.......', skip: 'x...x...x...x.x.' },
73 bass: { offbeat: '..x...x...x...x.', rolling: '.xx..xx..xx..xx.', gallop: '.xxx.xxx.xxx.xxx', sparse: '..x.......x.....' },
74 hats: { offbeat: '..x...x...x...x.', '8ths': 'x.x.x.x.x.x.x.x.', '16ths': 'xxxxxxxxxxxxxxxx', shuffle: '..x..xx...x..xx.' },
75 clap: { '2 and 4': '....x.......x...', 'on 4': '............x...', push: '....x.....x.x...' },
76 perc: { skip: '.x..x.....x..x..', shuffle: '...x..x....x..x.', steady: 'x..x..x..x..x...', broken: '.x.x...x.x...x..' },
77 ride: { '8ths': 'x.x.x.x.x.x.x.x.', offbeat: '..x...x...x...x.', '16ths': 'xxxxxxxxxxxxxxxx' },
78 acid: { '16ths': 'xxxxxxxxxxxxxxxx', sparse: 'x..x..x...x..x..', offbeat: '..x...x...x...x.', rolling: 'x.xx.xx.x.xx.xx.' },
79}
80// Sounds with notes you can set: one scale degree per step (0..14, written
81// 0-9a-e; '.' keeps the pattern's note), the same in every bar.
82export const NOTE_LAYERS = ['bass', 'acid']
83// Sounds with a tone: [cut -3..3 (dark .. thin), grit 0..3].
84export const TONE_LAYERS = ['kick', 'bass', 'hats', 'clap', 'perc', 'acid', 'ride', 'pad', 'rumble']
85const NOTE_RE = /^[.0-9a-e]{16}$/
86
87// The build: a track starts with the kick alone and NEXT walks it through a
88// long set, one part at a time. `part` in a track is an index here; null means
89// the old full-track behaviour where mood and energy pick the layers.
90export const SECTIONS = ['intro', 'groove', 'build', 'drop', 'break', 'outro']
91// Minimal on purpose: music to work to. About seven minutes with auto: a long
92// intro, three drops with a breakdown between them, and an outro that hands
93// over to the next track layer by layer. No chord stabs (Ruslan, 2026-10-03:
94// "they make the track less serious"); the ride and the dark pad take their place.
95// `name` is what the part brings, for the pane ("rumble in 0:12"); `adds` is
96// true when that is a new sound and not a new section. `layer` is the one new
97// layer, for the dashed "next" row in the grid. `loops` is how long auto plays
98// it. `rise` puts a riser on its last loop (a drop comes next). The voice says
99// the track's name: a whisper in the second breakdown, a deep voice on drop 3.
100const FULL = ['kick', 'bass', 'hats', 'rumble', 'perc', 'clap']
101export const PLAN = [
102 { section: 'intro', energy: 0, loops: 2, layers: ['kick'], name: 'kick', adds: true, layer: 'kick', go: 'start', tip: 'Just the kick' },
103 { section: 'intro', energy: 0, loops: 2, layers: ['kick', 'bass'], name: 'deep bass', adds: true, layer: 'bass', go: 'add the deep bass', tip: 'Kick and a deep sub' },
104 { section: 'groove', energy: 1, loops: 2, layers: ['kick', 'bass', 'hats'], name: 'hats', adds: true, layer: 'hats', go: 'add hats', tip: 'Offbeat hats, the groove rolls' },
105 { section: 'groove', energy: 1, loops: 2, layers: ['kick', 'bass', 'hats', 'rumble'], name: 'rumble', adds: true, layer: 'rumble', go: 'add the rumble', tip: 'The rumble fills the low end' },
106 { section: 'build', energy: 2, loops: 2, rise: true, layers: ['kick', 'bass', 'hats', 'rumble', 'perc'], name: 'percussion', adds: true, layer: 'perc', go: 'add percussion', tip: 'Percussion, then a rise' },
107 { section: 'drop', energy: 3, loops: 4, layers: FULL, name: 'drop 1', adds: true, layer: 'clap', go: 'drop 1', tip: 'Drop 1: the clap comes in' },
108 { section: 'break', energy: 1, loops: 2, rise: true, layers: ['hats', 'perc', 'pad'], name: 'breakdown', adds: true, layer: 'pad', go: 'the breakdown', tip: 'The kick drops out. A dark pad' },
109 { section: 'drop', energy: 3, loops: 4, layers: [...FULL, 'ride'], name: 'drop 2', adds: true, layer: 'ride', go: 'drop 2', tip: 'Drop 2: the ride on top' },
110 { section: 'break', energy: 1, loops: 2, rise: true, layers: ['perc', 'pad', 'voice'], name: 'breakdown 2', adds: false, go: 'the second breakdown', tip: 'Only the pad and a whisper' },
111 { section: 'drop', energy: 3, loops: 4, layers: [...FULL, 'ride', 'voice'], name: 'drop 3', adds: false, go: 'drop 3', tip: 'Drop 3: everything, the last peak' },
112 { section: 'outro', energy: 1, loops: 1, layers: ['kick', 'bass', 'hats', 'rumble', 'perc'], name: 'outro', adds: false, go: 'the outro', tip: 'The next track comes in' },
113]
114// A track's own order of parts (Ruslan, 2026-10-04: "remove a part like
115// groove or add a new part ... with something recommended"): PLAN indexes,
116// null for PLAN as it is. Auto walks it; a part can come twice.
117export const planOf = (t) => t?.plan ?? PLAN.map((_, i) => i)
118const sameAsPlan = (p) => p.length === PLAN.length && p.every((x, i) => x === i)
119
120// What the song tab offers to add, the first fitting one recommended.
121// drop a breakdown and a drop before the outro
122// groove one more groove part after the groove
123// build one more build before the first drop
124// break a breakdown before the last drop
125export function planAdds(input) {
126 const plan = planOf(input)
127 const drops = plan.filter((i) => PLAN[i].section === 'drop').length
128 const grooves = plan.filter((i) => PLAN[i].section === 'groove').length
129 const best = drops < 3 ? 'drop' : grooves < 2 ? 'groove' : drops < 5 ? 'drop' : 'build'
130 return [['drop', 'breakdown and a drop'], ['groove', 'longer groove'], ['build', 'longer build'], ['break', 'a breakdown']].map(([id, label]) => ({ id, label, best: id === best }))
131}
132
133// Inserts a suggestion into the plan: { plan, at, count } (where it went).
134export function planAdd(input, id) {
135 const plan = [...planOf(input)]
136 const lastIndex = (f) => { for (let k = plan.length - 1; k >= 0; k--) if (f(PLAN[plan[k]])) return k; return -1 }
137 const firstIndex = (f) => plan.findIndex((i) => f(PLAN[i]))
138 let at, parts
139 if (id === 'drop') { const o = lastIndex((p) => p.section === 'outro'); at = o < 0 ? plan.length : o; parts = [6, 9] }
140 else if (id === 'groove') { const g = lastIndex((p) => p.section === 'groove'); at = g < 0 ? Math.min(1, plan.length) : g + 1; parts = [3] }
141 else if (id === 'build') { const d = firstIndex((p) => p.section === 'drop'); at = d < 0 ? plan.length : d; parts = [4] }
142 else if (id === 'break') { const d = lastIndex((p) => p.section === 'drop'); at = d < 0 ? plan.length : d; parts = [6] }
143 else return { plan, at: 0, count: 0 }
144 if (plan.length + parts.length > 24) return { plan, at: 0, count: 0 }
145 plan.splice(at, 0, ...parts)
146 return { plan, at, count: parts.length }
147}
148
149// Old codes (p0..p9) named parts of the old ten-part build; `parseCode` moves
150// them to the same section here. New codes use a<n>.
151const OLD_PARTS = [0, 1, 2, 3, 4, 5, 7, 6, 9, 10]
152
153// The handover (auto, after the outro): the old track takes its layers out
154// while the new one comes in, and the new song starts from its beginning
155// (Ruslan, 2026-10-04: "start the next song from the kick or hihats").
156// Two shapes, picked by `transitionsOf(old).land`:
157// kick the old track thins to its kick and hats, then the new song starts
158// from its own kick (part 0)
159// hats the new song's hats come in over the old low end, then its kick and
160// bass land under them (part 2, "hats")
161// Each step plays HANDOVER_LOOPS loops. The last loop builds up, the landing
162// gets a crash and a boom.
163export const HANDOVERS = {
164 kick: { to: 0, steps: [{ from: ['kick', 'bass', 'hats', 'rumble', 'perc'], to: [] }, { from: ['kick', 'hats'], to: [] }] },
165 hats: { to: 2, steps: [{ from: ['kick', 'bass', 'rumble', 'perc'], to: ['hats'] }, { from: ['kick', 'bass'], to: ['hats'] }] },
166}
167export const HANDOVER_LOOPS = 2
168
169// The transitions auto plays (Ruslan, 2026-10-04: "i love all 3 ... do it
170// randomly ... but so that it always fits the song"). The track's phrase and
171// dice seed every pick, so a share code always plays the same transitions.
172// build: the loop before each drop (parts with `rise`)
173// fall: the last loop of each drop, into the breakdown or the outro
174// swell: a short reversed cymbal into a new sound in the intro and groove
175// swap: the build-up before the low end swaps in the handover
176// land: where the next song starts after the handover, `kick` or `hats`
177// The rules that make a pick fit: the mood weights the packs (sad leans on
178// the riser and the filter, dark on the filter, echo and stutter), a song
179// never plays the same build or fall twice in a row, and the last drop gets
180// one of the two biggest builds.
181export const BUILDS = ['riser', 'filter', 'echo', 'stutter']
182export const FALLS = ['tapestop', 'washout', 'downlifter']
183const BUILD_WEIGHTS = { sad: [3, 3, 2, 1], mysterious: [2, 2, 3, 2], dark: [1, 3, 3, 3] }
184const FALL_WEIGHTS = { sad: [1, 3, 2], mysterious: [2, 2, 2], dark: [3, 1, 2] }
185const LAND_WEIGHTS = { sad: [2, 1], mysterious: [1, 1], dark: [1, 2] }
186const weighted = (r, names, weights, not = []) => {
187 const ok = names.map((n, i) => (not.includes(n) ? 0 : weights[i]))
188 let x = r() * ok.reduce((a, b) => a + b, 0)
189 for (let i = 0; i < names.length; i++) if ((x -= ok[i]) < 0) return names[i]
190 return names.find((n) => !not.includes(n))
191}
192export function transitionsOf(input) {
193 const t = cleanTrack(input)
194 const r = rng(hash32('fx:' + t.phrase + ':' + t.dice))
195 const mood = moodName(t)
196 const plan = planOf(t)
197 const at = (k) => PLAN[plan[k]]
198 // keyed by the position in the track's plan; a build goes on the part
199 // right before a drop, a fall on the last loop of a drop
200 const out = { build: {}, fall: {}, swell: {}, swap: null, land: 'kick' }
201 let last = null
202 const rises = plan.map((_, k) => (at(k).section !== 'drop' && at(k + 1)?.section === 'drop' ? k : -1)).filter((k) => k >= 0)
203 for (const k of rises) {
204 const final = k === rises[rises.length - 1]
205 last = out.build[k] = weighted(r, BUILDS, BUILD_WEIGHTS[mood], [last, ...(final ? ['echo', 'stutter'] : [])])
206 }
207 last = null
208 plan.forEach((_, k) => { if (at(k).section === 'drop') last = out.fall[k] = weighted(r, FALLS, FALL_WEIGHTS[mood], [last]) })
209 plan.forEach((_, k) => { if (at(k + 1)?.adds && !rises.includes(k) && at(k).section !== 'drop' && at(k).section !== 'break') out.swell[k] = r() < 0.6 })
210 out.swap = weighted(r, BUILDS, BUILD_WEIGHTS[mood], [out.build[rises[rises.length - 1]]])
211 out.land = weighted(r, ['kick', 'hats'], LAND_WEIGHTS[mood])
212 return out
213}
214
215// ---------- seed helpers ----------
216
217export function normalizePhrase(text) {
218 return String(text ?? '')
219 .toLowerCase()
220 .normalize('NFKD')
221 .replace(/[̀-ͯ]/g, '')
222 .replace(/[^a-z0-9]+/g, ' ')
223 .trim()
224 .slice(0, 60)
225 .trim()
226}
227
228function hash32(str) {
229 let h = 0x811c9dc5
230 for (let i = 0; i < str.length; i++) {
231 h ^= str.charCodeAt(i)
232 h = Math.imul(h, 0x01000193)
233 }
234 return h >>> 0
235}
236
237function rng(seed) {
238 let a = seed >>> 0
239 return () => {
240 a = (a + 0x6d2b79f5) >>> 0
241 let t = a
242 t = Math.imul(t ^ (t >>> 15), t | 1)
243 t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
244 return ((t ^ (t >>> 14)) >>> 0) / 4294967296
245 }
246}
247
248const pick = (r, list) => list[Math.floor(r() * list.length)]
249const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v))
250const toInt = (v, lo, hi, fallback) => (Number.isFinite(Number(v)) ? clamp(Math.round(Number(v)), lo, hi) : fallback)
251
252// ---------- the track state ----------
253
254// The default track for a phrase. Everything not given here comes from the phrase.
255export function trackFor(phrase) {
256 const p = normalizePhrase(phrase) || 'techno'
257 const r = rng(hash32('track:' + p))
258 const vibe = VIBES[Math.floor(r() * VIBES.length)]
259 const bpm = vibe.bpm + Math.floor(r() * 3) - 1
260 return { phrase: p, bpm, mood: vibe.mood, energy: 2, dice: 0, swing: 0, transpose: 0, scale: vibe.scale, part: null, layers: {}, steps: {}, inst: {}, notes: {}, tone: {}, master: [0, 0, 0] }
261}
262
263// The track at a part of the build: the plan's energy, the plan's layers
264// (explicit mutes cleared), your own step edits kept.
265export function atPart(input, part) {
266 const t = cleanTrack(input)
267 const p = clamp(part, 0, PLAN.length - 1)
268 return { ...t, part: p, energy: clamp(PLAN[p].energy + VIBES[vibeOf(t)].lift, 0, 4), layers: {} }
269}
270
271// Fills gaps and clamps every field, so a state from a code or a tool call is safe.
272export function cleanTrack(t) {
273 const base = trackFor(t?.phrase)
274 const layers = {}
275 for (const name of LAYERS) {
276 const v = t?.layers?.[name]
277 if (v === true || v === false) layers[name] = v
278 }
279 const steps = {}
280 for (const name of GRID_LAYERS) {
281 const v = t?.steps?.[name]
282 if (!Array.isArray(v)) continue
283 const on = toInt(v[0], 0, 0xffff, 0), off = toInt(v[1], 0, 0xffff, 0) & ~on
284 if (on || off) steps[name] = [on, off]
285 }
286 const part = t?.part === null || t?.part === undefined ? null : toInt(t.part, 0, PLAN.length - 1, null)
287 const inst = {}
288 for (const [name, list] of Object.entries(INSTRUMENTS)) { const v = toInt(t?.inst?.[name], 0, list.length - 1, 0); if (v) inst[name] = v }
289 const notes = {}
290 for (const name of NOTE_LAYERS) { const v = t?.notes?.[name]; if (typeof v === 'string' && NOTE_RE.test(v) && /[^.]/.test(v)) notes[name] = v }
291 const tone = {}
292 for (const name of TONE_LAYERS) {
293 const v = t?.tone?.[name]
294 if (!Array.isArray(v)) continue
295 const cut = toInt(v[0], -3, 3, 0), grit = toInt(v[1], 0, 3, 0)
296 if (cut || grit) tone[name] = [cut, grit]
297 }
298 let plan = Array.isArray(t?.plan) && t.plan.length >= 2 && t.plan.length <= 24 ? t.plan.map((x) => toInt(x, 0, PLAN.length - 1, 0)) : null
299 if (plan && sameAsPlan(plan)) plan = null
300 const mv = Array.isArray(t?.master) ? t.master : []
301 const master = [toInt(mv[0], -3, 0, 0), toInt(mv[1], 0, 3, 0), toInt(mv[2], -2, 2, 0)]
302 return {
303 phrase: base.phrase,
304 bpm: toInt(t?.bpm, BPM_MIN, BPM_MAX, base.bpm),
305 mood: toInt(t?.mood, 0, 4, base.mood),
306 energy: toInt(t?.energy, 0, 4, base.energy),
307 dice: toInt(t?.dice, 0, 999, 0),
308 swing: toInt(t?.swing, 0, 3, 0),
309 transpose: toInt(t?.transpose, 0, 11, 0),
310 scale: t?.scale === null ? null : t?.scale === undefined ? base.scale : toInt(t.scale, 0, SCALES.length - 1, null),
311 part,
312 layers,
313 steps,
314 inst,
315 notes,
316 tone,
317 master,
318 plan,
319 }
320}
321
322function character(t) {
323 const r = rng(hash32('char:' + t.phrase))
324 return { flavor: r() < 0.5 ? 'acid' : 'dub', keyClass: pick(r, KEY_CLASSES) }
325}
326
327// Which layers play: an explicit on/off wins, otherwise mood and energy decide.
328export function activeLayers(t) {
329 if (t.part !== null && t.part !== undefined) {
330 const plan = PLAN[t.part].layers
331 const out = {}
332 for (const name of LAYERS) out[name] = t.layers[name] ?? (plan.includes(name) && (name !== 'rumble' || t.mood <= 2))
333 return out
334 }
335 const auto = {
336 kick: true,
337 bass: true,
338 hats: true,
339 clap: t.energy >= 1,
340 perc: t.energy >= 2,
341 acid: false,
342 stab: false,
343 rumble: t.mood <= 1 && t.energy >= 1,
344 voice: false,
345 ride: t.energy >= 4,
346 pad: false,
347 }
348 const out = {}
349 for (const name of LAYERS) out[name] = t.layers[name] ?? auto[name]
350 return out
351}
352
353export function noteName(t) {
354 return NOTE_NAMES[(character(t).keyClass + t.transpose) % 12]
355}
356
357export function scaleName(t) {
358 return SCALES[scaleOf(t)].name
359}
360
361export function keyName(t) {
362 return noteName(t) + ' ' + scaleName(t)
363}
364
365export function describe(t) {
366 return `${t.bpm} bpm · key ${keyName(t)} · mood ${moodName(t)} · energy ${ENERGIES[t.energy]}`
367}
368
369// ---------- share codes ----------
370// late-night-deploy@128m1e2d3s1t5p4+acid-perc*kick00040000
371// a<n> is the part of the build (p<n> in old codes); *<layer><on hex4><off hex4> are step edits.
372
373const hex4 = (n) => n.toString(16).padStart(4, '0')
374
375export function encodeCode(t) {
376 let code = t.phrase.replace(/ /g, '-') + '@' + t.bpm + 'm' + t.mood + 'e' + t.energy
377 if (t.dice) code += 'd' + t.dice
378 if (t.swing) code += 's' + t.swing
379 if (t.transpose) code += 't' + t.transpose
380 if (t.scale !== null && t.scale !== undefined) code += 'k' + t.scale
381 if (t.part !== null && t.part !== undefined) code += 'a' + t.part
382 for (const name of LAYERS) if (name in t.layers) code += (t.layers[name] ? '+' : '-') + name
383 for (const name of GRID_LAYERS) if (t.steps?.[name]) code += '*' + name + hex4(t.steps[name][0]) + hex4(t.steps[name][1])
384 for (const name of Object.keys(INSTRUMENTS)) if (t.inst?.[name]) code += '%' + name + t.inst[name]
385 for (const name of NOTE_LAYERS) if (t.notes?.[name]) code += '~' + name + t.notes[name]
386 for (const name of TONE_LAYERS) if (t.tone?.[name]) code += '^' + name + (t.tone[name][0] + 3) + t.tone[name][1]
387 const m = t.master ?? [0, 0, 0]
388 if (m[0] || m[1] || m[2]) code += '!' + (m[0] + 3) + m[1] + (m[2] + 2)
389 if (t.plan) code += '/' + t.plan.map((i) => i.toString(16)).join('')
390 return code
391}
392const alt = (list) => '(?:' + list.join('|') + ')'
393const CODE_RE = new RegExp('^(\\d{2,3})(?:m([0-4]))?(?:e([0-4]))?(?:d(\\d{1,3}))?(?:s([0-3]))?(?:t(\\d{1,2}))?(?:k([0-4]))?(?:p(\\d))?(?:a(\\d{1,2}))?((?:[+-][a-z]+)*)((?:\\*[a-z]+[0-9a-f]{8})*)' +
394 '((?:%' + alt(Object.keys(INSTRUMENTS)) + '\\d)*)((?:~' + alt(NOTE_LAYERS) + '[.0-9a-e]{16})*)((?:\\^' + alt(TONE_LAYERS) + '[0-6][0-3])*)(?:!([0-3][0-3][0-4]))?(?:/([0-9a]{2,24}))?$')
395
396export function parseCode(text) {
397 const s = String(text ?? '').trim().toLowerCase().replace(/^\/?techno\s+/, '')
398 const at = s.lastIndexOf('@')
399 if (at <= 0) return null
400 const m = s.slice(at + 1).match(CODE_RE)
401 if (!m) return null
402 const layers = {}
403 for (const flag of m[10].match(/[+-][a-z]+/g) ?? []) {
404 const name = flag.slice(1)
405 if (!LAYERS.includes(name)) return null
406 layers[name] = flag[0] === '+'
407 }
408 const steps = {}
409 for (const edit of m[11].match(/\*[a-z]+[0-9a-f]{8}/g) ?? []) {
410 const name = edit.slice(1, -8)
411 if (!GRID_LAYERS.includes(name)) return null
412 steps[name] = [parseInt(edit.slice(-8, -4), 16), parseInt(edit.slice(-4), 16)]
413 }
414 const inst = {}, notes = {}, tone = {}
415 for (const x of m[12].match(/%[a-z]+\d/g) ?? []) inst[x.slice(1, -1)] = Number(x.slice(-1))
416 for (const x of m[13].match(/~[a-z]+[.0-9a-e]{16}/g) ?? []) notes[x.slice(1, -16)] = x.slice(-16)
417 for (const x of m[14].match(/\^[a-z]+\d\d/g) ?? []) tone[x.slice(1, -2)] = [Number(x.slice(-2, -1)) - 3, Number(x.slice(-1))]
418 const master = m[15] ? [Number(m[15][0]) - 3, Number(m[15][1]), Number(m[15][2]) - 2] : [0, 0, 0]
419 return cleanTrack({ phrase: s.slice(0, at), bpm: m[1], mood: m[2], energy: m[3], dice: m[4], swing: m[5], transpose: m[6], scale: m[7] ?? null, part: m[9] ?? (m[8] === undefined ? null : OLD_PARTS[m[8]]), layers, steps, inst, notes, tone, master, plan: m[16] ? [...m[16]].map((c) => parseInt(c, 16)) : null })
420}
421
422// ---------- the arrangement: what plays on each of the 128 steps ----------
423
424const BASS_PATTERNS = ['..x...x...x...x.', '.xx..xx..xx..xx.', '..xx..xx..xx..xx', '.xxx.xxx.xxx.xxx', '..x..xx...x..xx.', '...x..x...x..x.x']
425const PERC_PATTERNS = ['.x..x.....x..x..', '...x..x....x..x.', 'x..x..x..x..x...', '.x.x...x.x...x..', '..x....x..x...x.']
426const STAB_PATTERNS = ['..x.......x.....', '...x......x.....', '..x..x....x.....', '.......x..x....x']
427const ACID_PALETTE = [0, 7, 0, 4, 0, 2, 0, 6, 0, 9, 3, 0, 4, 7, 0, 1, 0, 5, 0, 4, 2, 0, 7, 0, 6, 0, 3, 4, 0, 7, 2, 0, 9, 0, 4, 5]
428
429function charCode(ch) {
430 const c = ch.charCodeAt(0)
431 return c >= 97 ? c - 97 : 26 + (c - 48)
432}
433
434export function arrange(input) {
435 const t = cleanTrack(input)
436 const { keyClass } = character(t)
437 const on = activeLayers(t)
438 const r = rng(hash32('arr:' + t.phrase + ':' + t.dice))
439 const scale = SCALES[scaleOf(t)].steps
440 // the sub's root, e1..d#2: low enough to feel, the growl sits an octave up
441 const root = 28 + (((keyClass + t.transpose) % 12) + 8) % 12
442 // without a picked scale the mood keeps its old chord; a picked scale stacks its own
443 // Chords stay dark: a minor triad and the octave where the scale has a minor
444 // third, and root, fifth, octave where it has none (hijaz, the mysterious
445 // mood). A major third or a seventh sounded bright and bluesy there
446 // (Ruslan, 2026-10-04: "they destroy the vibe, something is off").
447 const minorThird = scale[2] === 3
448 const chordShape = t.scale === null ? CHORDS[t.mood] : minorThird ? [0, 3, 7, 12] : [0, 7, 12]
449 const degree = (d) => scale[((d % 7) + 7) % 7] + 12 * Math.floor(d / 7)
450
451 const bassPattern = t.energy === 0 ? BASS_PATTERNS[0] : pick(r, BASS_PATTERNS)
452 const bassIntervals = [0, 0, 12, 0, 0, 7, 0, 0].map((v) => (scale[1] === 1 && v === 7 ? 1 : v))
453 const percPattern = pick(r, PERC_PATTERNS)
454 const percPick = pick(r, ['rim', 'tom', 'bell'])
455 const percKind = t.inst.perc ? INSTRUMENTS.perc[t.inst.perc] : percPick
456 const stabSlot = Math.floor(r() * STAB_PATTERNS.length)
457 const kickGhost = t.energy >= 4 && r() < 0.6
458
459 // The riff: one phrase character per step, so the phrase is the melody.
460 // A space is a rest; a vowel is an accent.
461 const chars = [...t.phrase]
462 const offset = (t.dice * 3) % Math.max(1, chars.length)
463 const riff = []
464 for (let i = 0; i < STEPS; i++) {
465 const ch = chars[(i + offset) % chars.length] ?? ' '
466 if (ch === ' ') { riff.push(null); continue }
467 const c = charCode(ch)
468 const next = chars[(i + offset + 1) % chars.length] ?? ' '
469 riff.push({
470 note: root + 24 + degree(ACID_PALETTE[(c * 7 + t.dice * 5) % ACID_PALETTE.length]),
471 accent: 'aeiou'.includes(ch),
472 slide: next !== ' ' && (c * 13 + i) % 5 === 0,
473 })
474 }
475
476 const ev = { kick: [], bass: [], hats: [], clap: [], perc: [], acid: [], stab: [], ride: [], pad: [] }
477 for (let bar = 0; bar < BARS; bar++) {
478 const last = bar === BARS - 1
479 for (let s = 0; s < STEPS; s++) {
480 const step = bar * STEPS + s
481 // kick: four on the floor, dropped on the last beat of the loop for a lift
482 if (s % 4 === 0 && !(last && s === 12 && t.energy <= 2)) ev.kick.push({ step, vel: 1 })
483 if (kickGhost && s === 14 && bar % 2 === 1) ev.kick.push({ step, vel: 0.55 })
484 // bass
485 if (bassPattern[s] === 'x') ev.bass.push({ step, note: root + bassIntervals[(s + bar) % bassIntervals.length], vel: s % 4 === 2 ? 1 : 0.8 })
486 // hats: closed on the offbeat, then 8ths, then a ghost 16th, then 16ths; open hats from peak energy
487 const open = (t.energy >= 3 || t.mood >= 4) && s % 4 === 2
488 if (open) ev.hats.push({ step, vel: 0.9, open: true })
489 else if (t.energy === 0 ? s % 4 === 2 : t.energy === 1 ? s % 2 === 0 : t.energy === 2 ? s % 2 === 0 || s % 4 === 3 : true) ev.hats.push({ step, vel: s % 4 === 2 ? 1 : s % 2 === 0 ? 0.55 : 0.3, open: false })
490 // clap on 2 and 4, with a roll into the loop point
491 if (s === 4 || s === 12) ev.clap.push({ step, vel: 1 })
492 if (last && t.energy >= 3 && s >= 13) ev.clap.push({ step, vel: 0.35 + 0.2 * (s - 13) })
493 // perc, with a fill in bar 4
494 if (percPattern[s] === 'x' || (bar === 3 && s >= 12 && s % 2 === 1)) ev.perc.push({ step, vel: s % 3 === 0 ? 1 : 0.7, kind: percKind })
495 // acid
496 const n = riff[s]
497 if (n) ev.acid.push({ step, ...n })
498 // stab: the pattern shifts every other bar so the chords breathe
499 const stabPattern = STAB_PATTERNS[(stabSlot + (bar % 2)) % STAB_PATTERNS.length]
500 if (stabPattern[s] === 'x') ev.stab.push({ step, notes: chordShape.map((i) => root + 24 + i), vel: 1 })
501 // ride: every 8th, the offbeat ones ring out
502 if (s % 2 === 0) ev.ride.push({ step, vel: s % 4 === 2 ? 1 : 0.55 })
503 }
504 }
505 // the pad: one long swell over the loop, the root, the fifth, and the minor third above (or the octave)
506 ev.pad.push({ step: 0, notes: minorThird ? [root + 12, root + 19, root + 27] : [root + 12, root + 19, root + 24] })
507 // your step edits: the same on/off mask in every bar of the loop
508 const chord = chordShape.map((i) => root + 24 + i)
509 const fresh = { kick: () => ({ vel: 1 }), hats: () => ({ vel: 0.8, open: false }), bass: () => ({ note: root, vel: 0.9 }), perc: () => ({ vel: 0.85, kind: percKind }), clap: () => ({ vel: 1 }), acid: () => ({ note: root + 24, accent: false, slide: false }), stab: () => ({ notes: chord, vel: 1 }), ride: () => ({ vel: 0.8 }) }
510 for (const [name, [onMask, offMask]] of Object.entries(t.steps)) {
511 ev[name] = ev[name].filter((e) => !(offMask & (1 << (e.step % STEPS))))
512 for (let bar = 0; bar < BARS; bar++) {
513 for (let s = 0; s < STEPS; s++) {
514 const step = bar * STEPS + s
515 if (onMask & (1 << s) && !ev[name].some((e) => e.step === step)) ev[name].push({ step, ...fresh[name]() })
516 }
517 }
518 ev[name].sort((x, y) => x.step - y.step)
519 }
520 // your notes: a scale degree per step, over the bass root (or the riff's octave)
521 for (const name of NOTE_LAYERS) {
522 const line = t.notes[name]
523 if (!line) continue
524 const base = name === 'bass' ? root : root + 24
525 for (const e of ev[name]) { const c = line[e.step % STEPS]; if (c !== '.') e.note = base + degree(parseInt(c, 36)) }
526 }
527 for (const name of Object.keys(ev)) if (!on[name]) ev[name] = []
528 return { track: t, on, root, scale, events: ev, stepSeconds: 60 / t.bpm / 4, steps: BARS * STEPS, percKind, percPick }
529}
530
531// Adds or removes the hit at step `i` (0..15) of one layer, in every bar.
532// An edit that puts the step back the way the pattern has it is dropped, so
533// the share code stays short.
534export function toggleStep(input, layer, i) {
535 const t = cleanTrack(input)
536 if (!GRID_LAYERS.includes(layer) || i < 0 || i >= STEPS) return t
537 const all = { ...t, layers: Object.fromEntries(LAYERS.map((n) => [n, true])) }
538 const want = grid(all)[layer][i] === '.'
539 const rest = { ...t.steps }
540 delete rest[layer]
541 const base = grid({ ...all, steps: rest })[layer][i] !== '.'
542 let [onMask, offMask] = t.steps[layer] ?? [0, 0]
543 const bit = 1 << i
544 onMask &= ~bit; offMask &= ~bit
545 if (want !== base) { if (want) onMask |= bit; else offMask |= bit }
546 return cleanTrack({ ...t, steps: { ...rest, ...(onMask || offMask ? { [layer]: [onMask, offMask] } : {}) } })
547}
548
549// Sets one bar of a layer to a pattern (16 chars, 'x' a hit) as step edits
550// against the phrase's own pattern, so the code stays short.
551export function setPattern(input, layer, pattern) {
552 const t = cleanTrack(input)
553 if (!GRID_LAYERS.includes(layer) || !/^[x.]{16}$/.test(pattern)) return t
554 const rest = { ...t.steps }
555 delete rest[layer]
556 const base = grid({ ...t, steps: rest, layers: Object.fromEntries(LAYERS.map((n) => [n, true])) })[layer]
557 let on = 0, off = 0
558 for (let i = 0; i < STEPS; i++) {
559 if (pattern[i] === 'x' && base[i] === '.') on |= 1 << i
560 if (pattern[i] === '.' && base[i] !== '.') off |= 1 << i
561 }
562 return cleanTrack({ ...t, steps: { ...rest, ...(on || off ? { [layer]: [on, off] } : {}) } })
563}
564
565// The scale degree (0..14) a note sits on over `base`, the nearest one.
566function degreeOf(scale, base, note) {
567 let best = 0, gap = 99
568 for (let d = 0; d < 15; d++) {
569 const semis = scale[d % 7] + 12 * Math.floor(d / 7)
570 if (Math.abs(base + semis - note) < gap) { gap = Math.abs(base + semis - note); best = d }
571 }
572 return best
573}
574
575// A new note line that fits the scale: the bass leans on the root, the fifth
576// and the octave; the melody walks the scale and leaps now and then.
577export function suggestNotes(input, layer, seed) {
578 const t = cleanTrack(input)
579 const r = rng(hash32('notes:' + t.phrase + ':' + layer + ':' + seed))
580 let line = ''
581 if (layer === 'bass') {
582 const pool = [0, 0, 0, 0, 0, 0, 7, 7, 4, 4, 2, 5]
583 for (let i = 0; i < STEPS; i++) line += (i === 0 ? 0 : pool[Math.floor(r() * pool.length)]).toString(36)
584 } else {
585 let d = r() < 0.5 ? 0 : 4
586 for (let i = 0; i < STEPS; i++) {
587 line += d.toString(36)
588 d = r() < 0.15 ? (d + 7) % 12 : clamp(d + Math.floor(r() * 5) - 2, 0, 11)
589 }
590 }
591 return cleanTrack({ ...t, notes: { ...t.notes, [layer]: line } })
592}
593
594// The edit card's actions, as data: { kind, layer, ... }. Pure, so the
595// player, the tests and Claude's jam tool all go through the same door.
596export function editTrack(input, e) {
597 const t = cleanTrack(input)
598 const L = e?.layer
599 switch (e?.kind) {
600 case 'inst': return INSTRUMENTS[L] ? cleanTrack({ ...t, inst: { ...t.inst, [L]: toInt(e.value, 0, INSTRUMENTS[L].length - 1, 0) } }) : t
601 case 'pattern': return PATTERNS[L]?.[e.name] ? setPattern(t, L, PATTERNS[L][e.name]) : t
602 case 'reset-pattern': { const steps = { ...t.steps }; delete steps[L]; return cleanTrack({ ...t, steps }) }
603 case 'note': {
604 if (!NOTE_LAYERS.includes(L) || !(e.i >= 0 && e.i < STEPS)) return t
605 const a = arrange({ ...t, layers: { ...t.layers, [L]: true } })
606 const base = L === 'bass' ? a.root : a.root + 24
607 const line = [...(t.notes[L] ?? '.'.repeat(STEPS))]
608 const hit = a.events[L].find((x) => x.step === e.i)
609 const now = line[e.i] !== '.' ? parseInt(line[e.i], 36) : hit ? degreeOf(a.scale, base, hit.note) : 0
610 line[e.i] = (((now + (e.dir ?? 1)) % 8) + 8) % 8 + ''
611 return cleanTrack({ ...t, notes: { ...t.notes, [L]: line.join('') } })
612 }
613 case 'suggest-notes': return NOTE_LAYERS.includes(L) ? suggestNotes(t, L, e.seed ?? 0) : t
614 case 'reset-notes': { const notes = { ...t.notes }; delete notes[L]; return cleanTrack({ ...t, notes }) }
615 case 'tone': {
616 if (!TONE_LAYERS.includes(L)) return t
617 const [cut, grit] = t.tone[L] ?? [0, 0]
618 return cleanTrack({ ...t, tone: { ...t.tone, [L]: [clamp(cut + (e.cut ?? 0), -3, 3), clamp(grit + (e.grit ?? 0), 0, 3)] } })
619 }
620 case 'master': {
621 const m = [...t.master]
622 if (e.lp) m[0] = clamp(m[0] + e.lp, -3, 0)
623 if (e.hp) m[1] = clamp(m[1] + e.hp, 0, 3)
624 if (e.space) m[2] = clamp(m[2] + e.space, -2, 2)
625 if (e.reset) m.fill(0)
626 return cleanTrack({ ...t, master: m })
627 }
628 default: return t
629 }
630}
631
632// What the edit card shows for one sound: its instruments, the pattern to
633// try (and which one plays), its notes per step (names, or null on a rest),
634// the scale's notes, and its tone.
635export function soundInfo(input, layer) {
636 const t = cleanTrack(input)
637 const a = arrange({ ...t, layers: { ...t.layers, [layer]: true } })
638 const row = GRID_LAYERS.includes(layer) ? grid({ ...t, layers: { ...t.layers, [layer]: true } })[layer] ?? '.'.repeat(STEPS) : null
639 const insts = INSTRUMENTS[layer] ? INSTRUMENTS[layer].map((name, i) => ({ name: layer === 'perc' && i === 0 ? null : name, value: i })).filter((x) => x.name) : []
640 const inst = layer === 'perc' ? a.percKind : INSTRUMENTS[layer]?.[t.inst[layer] ?? 0] ?? null
641 const presets = Object.entries(PATTERNS[layer] ?? {}).map(([name, p]) => ({ name, on: !!row && [...p].every((c, i) => (c === 'x') === (row[i] !== '.')) }))
642 let notes = null
643 if (NOTE_LAYERS.includes(layer)) {
644 notes = Array(STEPS).fill(null)
645 for (const e of a.events[layer]) if (e.step < STEPS) notes[e.step] = NOTE_NAMES[e.note % 12]
646 }
647 const root = a.root % 12
648 return {
649 layer,
650 insts,
651 inst,
652 presets,
653 edited: !!t.steps[layer],
654 notes,
655 notesEdited: !!t.notes[layer],
656 scale: a.scale.map((x) => NOTE_NAMES[(root + x) % 12]),
657 tone: t.tone[layer] ?? [0, 0],
658 hasTone: TONE_LAYERS.includes(layer),
659 }
660}
661
662// One bar of the arrangement as text, for the pane: 'x' a hit, 'X' an accent.
663export function grid(input, bar = 0) {
664 const a = arrange(input)
665 const out = {}
666 for (const name of GRID_LAYERS) {
667 if (!a.on[name]) continue
668 const row = Array(STEPS).fill('.')
669 for (const e of a.events[name]) {
670 if (Math.floor(e.step / STEPS) !== bar) continue
671 row[e.step % STEPS] = e.accent || e.open ? 'X' : 'x'
672 }
673 out[name] = row.join('')
674 }
675 return out
676}
677
678// ---------- DSP ----------
679
680const TAU = Math.PI * 2
681const mtof = (m) => 440 * Math.pow(2, (m - 69) / 12)
682
683function soft(x) {
684 if (x > 3) return 1
685 if (x < -3) return -1
686 const x2 = x * x
687 return (x * (27 + x2)) / (27 + 9 * x2)
688}
689
690function polyblep(t, dt) {
691 if (t < dt) { t /= dt; return t + t - t * t - 1 }
692 if (t > 1 - dt) { t = (t - 1) / dt; return t * t + t + t + 1 }
693 return 0
694}
695
696// State-variable filter (Simper's trapezoidal form). mode: 0 low, 1 band, 2 high.
697function svf() {
698 let ic1 = 0, ic2 = 0, a1 = 0, a2 = 0, a3 = 0, k = 1
699 return {
700 set(fc, sr, q) {
701 const g = Math.tan(Math.PI * Math.min(fc, sr * 0.45) / sr)
702 k = 1 / q
703 a1 = 1 / (1 + g * (g + k)); a2 = g * a1; a3 = g * a2
704 },
705 run(v0, mode) {
706 const v3 = v0 - ic2
707 const v1 = a1 * ic1 + a2 * v3
708 const v2 = ic2 + a2 * ic1 + a3 * v3
709 ic1 = 2 * v1 - ic1; ic2 = 2 * v2 - ic2
710 return mode === 0 ? v2 : mode === 1 ? v1 : v0 - k * v1 - v2
711 },
712 }
713}
714
715function noiseSource(seed) {
716 let s = seed >>> 0 || 1
717 return () => {
718 s ^= s << 13; s ^= s >>> 17; s ^= s << 5
719 return ((s >>> 0) / 4294967296) * 2 - 1
720 }
721}
722
723// The kick: a sine that falls from a click to a deep tail, driven into a
724// soft clipper, with a short filtered-noise transient on top.
725function renderKick(buf, sr, at, vel, k, noise) {
726 const start = Math.round(at * sr)
727 const len = Math.min(Math.round(Math.max(0.55, k.ampDecay * 2.4) * sr), buf.length - start)
728 const fade = Math.round(0.04 * sr)
729 const hp = svf(); hp.set(3500, sr, 0.7)
730 let ph = 0
731 for (let i = 0; i < len; i++) {
732 const t = i / sr
733 const f = k.f1 + (k.f0 - k.f1) * Math.exp(-t / k.pitchDecay) + 260 * Math.exp(-t / 0.0045)
734 ph += (TAU * f) / sr
735 let amp = Math.exp(-t / k.ampDecay) * (0.75 + 0.25 * Math.exp(-t / 0.04))
736 if (i > len - fade) amp *= (len - i) / fade
737 let s = soft(Math.sin(ph) * amp * k.drive) * 0.95
738 if (t < 0.006) s += hp.run(noise(), 2) * 0.45 * (1 - t / 0.006)
739 buf[start + i] += s * vel
740 }
741}
742
743// The bass: a driven sine sub at the note (e1..d#2, felt more than heard)
744// under two detuned saws an octave up through a 4-pole low-pass, so it rolls
745// and growls and still reads on laptop speakers. Saturated at the end.
746function renderBass(buf, sr, at, dur, midi, vel, cutoff, grit, noise, q = 0.8) {
747 const start = Math.round(at * sr)
748 const len = Math.min(Math.round(dur * sr), buf.length - start)
749 const f = mtof(midi)
750 const d1 = (f * 2.012) / sr, d2 = (f * 1.988) / sr, d0 = f / sr
751 const lp1 = svf(), lp2 = svf()
752 let p0 = 0, p1 = noise() * 0.5 + 0.5, p2 = noise() * 0.5 + 0.5
753 const rel = Math.min(len, Math.round(0.008 * sr))
754 for (let i = 0; i < len; i++) {
755 const t = i / sr
756 p0 += d0; if (p0 >= 1) p0 -= 1
757 p1 += d1; if (p1 >= 1) p1 -= 1
758 p2 += d2; if (p2 >= 1) p2 -= 1
759 const saws = (2 * p1 - 1 - polyblep(p1, d1)) + (2 * p2 - 1 - polyblep(p2, d2))
760 if ((i & 15) === 0) {
761 const fc = cutoff * (1 + 2.4 * Math.exp(-t / 0.045))
762 lp1.set(fc, sr, q); lp2.set(fc, sr, q + 0.1)
763 }
764 const body = lp2.run(lp1.run(saws * 0.45, 0), 0)
765 const sub = soft(Math.sin(TAU * p0) * 1.7)
766 const env = Math.min(1, t / 0.004) * (0.7 + 0.3 * Math.exp(-t / 0.12)) * (i > len - rel ? (len - i) / rel : 1)
767 buf[start + i] += soft((body * grit * 0.85 + sub) * 1.35) * 0.8 * env * vel
768 }
769}
770
771// Hats: mostly high-passed noise with a little metal, so they hiss like a
772// real 909 instead of buzzing like a chip.
773const HAT_FREQS = [205.3, 304.4, 369.6, 522.7, 540, 800]
774// tight, long, metal, dusty
775const HAT_KINDS = [{ decay: 1, metal: 0.035, hp: 8200 }, { decay: 2.4, metal: 0.035, hp: 7600 }, { decay: 1.3, metal: 0.28, hp: 6500 }, { decay: 1.1, metal: 0.02, hp: 4200 }]
776
777function renderHat(buf, sr, at, vel, open, tone, noise, v = HAT_KINDS[0]) {
778 const start = Math.round(at * sr)
779 const decay = (open ? 0.2 : 0.026) * v.decay
780 const len = Math.min(Math.round(decay * 6 * sr), buf.length - start)
781 const hp = svf(); hp.set(v.hp, sr, 0.7)
782 const bp = svf(); bp.set(11500, sr, 0.6)
783 const phases = HAT_FREQS.map(() => 0)
784 for (let i = 0; i < len; i++) {
785 const t = i / sr
786 let metal = 0
787 for (let j = 0; j < 6; j++) {
788 phases[j] += (HAT_FREQS[j] * tone * 2) / sr
789 if (phases[j] >= 1) phases[j] -= 1
790 metal += phases[j] < 0.5 ? 1 : -1
791 }
792 const x = metal * v.metal + noise() * 0.9
793 const env = Math.min(1, t / 0.0008) * Math.exp(-t / decay)
794 const y = hp.run(x, 2)
795 buf[start + i] += (y * 0.6 + bp.run(y, 1) * 0.8) * env * vel
796 }
797}
798
799// room (the 909-ish clap), snap (short and higher), snare (a body under the noise)
800function renderClap(buf, sr, at, vel, noise, kind = 0) {
801 const start = Math.round(at * sr)
802 const len = Math.min(Math.round(0.35 * sr), buf.length - start)
803 const bp = svf(); bp.set(kind === 1 ? 1700 : kind === 2 ? 1900 : 1050, sr, kind === 2 ? 0.8 : 1.5)
804 const tail = kind === 1 ? 0.04 : kind === 2 ? 0.13 : 0.11
805 let ph = 0
806 for (let i = 0; i < len; i++) {
807 const t = i / sr
808 let env = 0
809 for (const o of kind === 2 ? [0] : [0, 0.011, 0.022]) if (t >= o) env = Math.max(env, Math.exp(-(t - o) / 0.0045))
810 if (t >= 0.03 || kind === 2) env = Math.max(env, 0.55 * Math.exp(-Math.max(0, t - (kind === 2 ? 0.004 : 0.03)) / tail))
811 let x = bp.run(noise(), 1) * env * 2.2
812 if (kind === 2) { ph += (185 * (1 + 0.5 * Math.exp(-t / 0.01))) / sr; x += Math.sin(TAU * ph) * Math.exp(-t / 0.07) * 0.8 }
813 buf[start + i] += x * vel
814 }
815}
816
817function renderPerc(buf, sr, at, vel, kind, pitch, noise) {
818 const start = Math.round(at * sr)
819 const len = Math.min(Math.round((kind === 'tom' ? 0.3 : 0.12) * sr), buf.length - start)
820 let ph = 0, ph2 = 0
821 for (let i = 0; i < len; i++) {
822 const t = i / sr
823 let s
824 if (kind === 'tom') {
825 const f = pitch * (1 + 0.8 * Math.exp(-t / 0.02))
826 ph += f / sr
827 s = Math.sin(TAU * ph) * Math.exp(-t / 0.09)
828 } else if (kind === 'bell') {
829 ph += (pitch * 4) / sr; ph2 += (pitch * 4 * 1.48) / sr
830 s = Math.sin(TAU * ph + 1.5 * Math.sin(TAU * ph2)) * Math.exp(-t / 0.045) * 0.7
831 } else {
832 ph += (pitch * 6) / sr
833 s = (Math.sin(TAU * ph) * 0.6 + noise() * 0.4) * Math.exp(-t / 0.012)
834 }
835 buf[start + i] += s * vel
836 }
837}
838
839function renderStab(buf, sr, at, notes, vel, cutoff, noise) {
840 const start = Math.round(at * sr)
841 const len = Math.min(Math.round(0.4 * sr), buf.length - start)
842 const flt = svf()
843 const osc = []
844 for (const n of notes) for (const d of [-0.12, 0, 0.12]) osc.push({ dt: mtof(n + d) / sr, ph: noise() * 0.5 + 0.5 })
845 const gain = 0.5 / Math.sqrt(osc.length)
846 for (let i = 0; i < len; i++) {
847 const t = i / sr
848 let x = 0
849 for (const o of osc) {
850 o.ph += o.dt; if (o.ph >= 1) o.ph -= 1
851 x += 2 * o.ph - 1 - polyblep(o.ph, o.dt)
852 }
853 if ((i & 15) === 0) flt.set(cutoff * (1 + 1.5 * Math.exp(-t / 0.05)), sr, 1.4)
854 const env = Math.min(1, t / 0.003) * Math.exp(-t / 0.11)
855 buf[start + i] += flt.run(x * gain, 0) * env * vel
856 }
857}
858
859// The acid line is one monophonic voice through the whole loop, so notes can
860// slide into each other the way a TB-303 does.
861function renderAcid(buf, sr, a, total, sweep) {
862 const { stepSeconds, track: t } = a
863 const byStep = new Map(a.events.acid.map((e) => [e.step, e]))
864 const stepLen = stepSeconds * sr
865 let ph = 0, freq = 110, target = 110, env = 0, amp = 0, accentAmt = 0
866 let y1 = 0, y2 = 0, y3 = 0, y4 = 0
867 // acid, square, pluck, soft
868 const kind = t.inst?.acid ?? 0
869 const res = (0.55 + 0.08 * t.energy + (t.mood <= 1 ? 0.08 : 0)) * [1, 0.85, 0.6, 0.3][kind]
870 const fb = res * 3.6
871 const decay = (0.12 + 0.05 * t.mood) * [1, 1, 0.35, 1.6][kind]
872 const glide = Math.exp(-1 / (0.035 * sr))
873 let gateEnd = -1
874 for (let i = 0; i < total; i++) {
875 const stepF = i / stepLen
876 const step = Math.floor(stepF) % a.steps
877 if (i === Math.round(Math.floor(stepF) * stepLen)) {
878 const e = byStep.get(step)
879 const prev = byStep.get((step + a.steps - 1) % a.steps)
880 if (e) {
881 target = mtof(e.note)
882 if (!(prev && prev.slide)) { env = 1; accentAmt = e.accent ? 1 : 0; freq = target }
883 gateEnd = i + Math.round(stepLen * (e.slide ? 1.05 : 0.55))
884 }
885 }
886 freq = target + (freq - target) * glide
887 const gate = i < gateEnd ? 1 : 0
888 amp += ((gate ? 1 : 0) - amp) * (gate ? 0.02 : 0.004)
889 env *= Math.exp(-1 / (decay * sr))
890 const dt = freq / sr
891 ph += dt; if (ph >= 1) ph -= 1
892 const saw = kind === 1 ? (ph < 0.5 ? 1 : -1) + polyblep(ph, dt) - polyblep((ph + 0.5) % 1, dt) : 2 * ph - 1 - polyblep(ph, dt)
893 const fc = clamp(sweep(i) * (kind === 3 ? 0.55 : 1) * (1 + (3.5 + 3 * accentAmt) * env), 60, 9000)
894 const g = 1 - Math.exp((-TAU * fc) / sr)
895 const x = soft(saw - fb * y4)
896 y1 += g * (x - y1); y2 += g * (y1 - y2); y3 += g * (y2 - y3); y4 += g * (y3 - y4)
897 buf[i] += soft(y4 * (2.2 + fb * 0.5)) * amp * (0.75 + 0.35 * accentAmt * env)
898 }
899}
900
901// Freeverb: 8 combs and 4 allpasses per side.
902function reverbSide(input, sr, room, damp, spread) {
903 const scale = sr / 44100
904 const combT = [1116, 1188, 1277, 1356, 1422, 1491, 1557, 1617]
905 const apT = [556, 441, 341, 225]
906 {
907 const combs = combT.map((n) => ({ b: new Float32Array(Math.round((n + spread) * scale)), i: 0, f: 0 }))
908 const aps = apT.map((n) => ({ b: new Float32Array(Math.round((n + spread) * scale)), i: 0 }))
909 const out = new Float32Array(input.length)
910 for (let n = 0; n < input.length; n++) {
911 const x = input[n] * 0.015
912 let y = 0
913 for (const c of combs) {
914 const o = c.b[c.i]
915 c.f = o * (1 - damp) + c.f * damp
916 c.b[c.i] = x + c.f * room
917 if (++c.i >= c.b.length) c.i = 0
918 y += o
919 }
920 for (const a of aps) {
921 const o = a.b[a.i]
922 a.b[a.i] = y + o * 0.5
923 if (++a.i >= a.b.length) a.i = 0
924 y = o - y
925 }
926 out[n] = y
927 }
928 return out
929 }
930}
931
932function reverb(inL, inR, sr, room, damp) {
933 return [reverbSide(inL, sr, room, damp, 0), reverbSide(inR, sr, room, damp, 23)]
934}
935
936function pingPong(input, sr, delaySeconds, feedback) {
937 const n = Math.round(delaySeconds * sr)
938 const bl = new Float32Array(n), br = new Float32Array(n)
939 const outL = new Float32Array(input.length), outR = new Float32Array(input.length)
940 let i = 0, lpL = 0, lpR = 0, hpL = 0, hpR = 0
941 const lp = 1 - Math.exp((-TAU * 2600) / sr)
942 const hp = 1 - Math.exp((-TAU * 280) / sr)
943 for (let s = 0; s < input.length; s++) {
944 const dl = bl[i], dr = br[i]
945 lpL += lp * (dl - lpL); lpR += lp * (dr - lpR)
946 hpL += hp * (lpL - hpL); hpR += hp * (lpR - hpR)
947 bl[i] = input[s] + (lpR - hpR) * feedback
948 br[i] = (lpL - hpL) * feedback
949 if (++i >= n) i = 0
950 outL[s] = dl; outR[s] = dr
951 }
952 return [outL, outR]
953}
954
955// ---------- the voice ----------
956// The mod records the track's name with a free macOS voice (`say`) and hands
957// the clips in; the engine stays pure. A clip is { data: Float32Array, rate }.
958
959// A 16-bit PCM WAV (as `say --file-format=WAVE` writes it) to a mono clip at
960// peak 1. Walks the chunks, since `say` puts an FLLR chunk before the data.
961export function decodeWav(bytes) {
962 const v = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
963 const tag = (o) => String.fromCharCode(bytes[o], bytes[o + 1], bytes[o + 2], bytes[o + 3])
964 if (bytes.length < 44 || tag(0) !== 'RIFF' || tag(8) !== 'WAVE') return null
965 let o = 12, rate = 0, channels = 1, bits = 16
966 while (o + 8 <= bytes.length) {
967 const id = tag(o), size = v.getUint32(o + 4, true)
968 if (id === 'fmt ') { channels = v.getUint16(o + 10, true); rate = v.getUint32(o + 12, true); bits = v.getUint16(o + 22, true) }
969 if (id === 'data') {
970 if (bits !== 16 || !rate) return null
971 const n = Math.floor(Math.min(size, bytes.length - o - 8) / 2 / channels)
972 const data = new Float32Array(n)
973 let peak = 0
974 for (let i = 0; i < n; i++) { data[i] = v.getInt16(o + 8 + i * 2 * channels, true) / 32768; peak = Math.max(peak, Math.abs(data[i])) }
975 if (peak > 0) for (let i = 0; i < n; i++) data[i] /= peak
976 return { data, rate }
977 }
978 o += 8 + size + (size & 1)
979 }
980 return null
981}
982
983// Pitched down (the whisper three semitones, the deep voice four), band-passed
984// like an old radio and driven a little. The echo and the room come from the sends.
985function renderVoice(buf, sr, at, clip, pitch, gain) {
986 const start = Math.round(at * sr)
987 const inc = (clip.rate / sr) * pitch
988 const n = Math.min(Math.floor((clip.data.length - 1) / inc), buf.length - start)
989 const hp = svf(); hp.set(190, sr, 0.7)
990 const lp = svf(); lp.set(4800, sr, 0.8)
991 for (let i = 0; i < n; i++) {
992 const p = i * inc, k = Math.floor(p), f = p - k
993 const x = clip.data[k] * (1 - f) + clip.data[k + 1] * f
994 buf[start + i] += soft(lp.run(hp.run(x, 2), 0) * 1.8) * gain
995 }
996}
997
998// ---------- render ----------
999
1000// Renders one hit into its own buffer, so a drum is synthesized once per
1001// render and then copied to each step.
1002function template(sr, seconds, draw) {
1003 const buf = new Float32Array(Math.round(seconds * sr))
1004 draw(buf)
1005 return buf
1006}
1007
1008function stamp(buf, tpl, at, vel, sr) {
1009 const start = Math.round(at * sr)
1010 const n = Math.min(tpl.length, buf.length - start)
1011 for (let i = 0; i < n; i++) buf[start + i] += tpl[i] * vel
1012}
1013
1014// The ride: brighter, longer metal than the hats, so a drop opens up on top.
1015function renderRide(buf, sr, at, vel, tone, noise) {
1016 const start = Math.round(at * sr)
1017 const len = Math.min(Math.round(1.4 * sr), buf.length - start)
1018 const bp = svf(); bp.set(6800, sr, 0.9)
1019 const hp = svf(); hp.set(4200, sr, 0.7)
1020 const phases = HAT_FREQS.map(() => 0)
1021 for (let i = 0; i < len; i++) {
1022 const t = i / sr
1023 let metal = 0
1024 for (let j = 0; j < 6; j++) {
1025 phases[j] += (HAT_FREQS[j] * tone * 3.1) / sr
1026 if (phases[j] >= 1) phases[j] -= 1
1027 metal += phases[j] < 0.5 ? 1 : -1
1028 }
1029 const env = Math.min(1, t / 0.001) * (0.35 * Math.exp(-t / 0.02) + 0.65 * Math.exp(-t / 0.42))
1030 const x = metal * 0.12 + noise() * 0.5
1031 buf[start + i] += (bp.run(x, 1) * 0.9 + hp.run(noise(), 2) * 0.12) * env * vel
1032 }
1033}
1034
1035// The pad: one dark swell over the loop, three detuned saws a note through a
1036// slow low-pass. It fades in over the first half and out over the last bars,
1037// so it breathes once per loop and the loop has no seam.
1038function renderPad(buf, sr, len, notes, cutoff, noise) {
1039 const osc = []
1040 for (const n of notes) for (const d of [-0.09, 0, 0.09]) osc.push({ dt: mtof(n + d) / sr, ph: noise() * 0.5 + 0.5 })
1041 const gain = 0.6 / Math.sqrt(osc.length)
1042 const lp1 = svf(), lp2 = svf()
1043 const smooth = (x) => (x <= 0 ? 0 : x >= 1 ? 1 : x * x * (3 - 2 * x))
1044 for (let i = 0; i < len && i < buf.length; i++) {
1045 const u = i / len
1046 let x = 0
1047 for (const o of osc) {
1048 o.ph += o.dt; if (o.ph >= 1) o.ph -= 1
1049 x += 2 * o.ph - 1 - polyblep(o.ph, o.dt)
1050 }
1051 if ((i & 31) === 0) {
1052 const fc = cutoff * (0.6 + 0.9 * Math.sin(Math.PI * u))
1053 lp1.set(fc, sr, 0.7); lp2.set(fc, sr, 0.9)
1054 }
1055 const env = smooth(u / 0.45) * smooth((1 - u) / 0.22)
1056 buf[i] += lp2.run(lp1.run(x * gain, 0), 0) * env
1057 }
1058}
1059
1060// The riser: noise through a band-pass that climbs over the last four bars of
1061// the loop before a drop, louder as it goes.
1062function renderRiser(buf, sr, from, len, noise) {
1063 const bp = svf()
1064 for (let i = 0; i < len && from + i < buf.length; i++) {
1065 const u = i / len
1066 if ((i & 15) === 0) bp.set(250 * Math.pow(32, u), sr, 2.2)
1067 buf[from + i] += bp.run(noise(), 1) * u * u * 1.1
1068 }
1069}
1070
1071// The boom under a drop's first beat: a sine that falls to the sub, driven.
1072function renderBoom(buf, sr, at, f1) {
1073 const start = Math.round(at * sr)
1074 const len = Math.min(Math.round(1.8 * sr), buf.length - start)
1075 let ph = 0
1076 for (let i = 0; i < len; i++) {
1077 const t = i / sr
1078 ph += (f1 + (95 - f1) * Math.exp(-t / 0.09)) / sr
1079 buf[start + i] += Math.tanh(Math.sin(TAU * ph) * 1.8) * Math.min(1, t / 0.003) * Math.exp(-t / 0.55)
1080 }
1081}
1082
1083// A crash played backwards: it swells into the drop and ends on the loop point.
1084function renderReverseCrash(buf, sr, end, len, noise, gain) {
1085 const tmp = new Float32Array(len)
1086 renderCrash(tmp, sr, 0, noise)
1087 const from = end - len
1088 for (let i = 0; i < len; i++) if (from + i >= 0 && from + i < buf.length) buf[from + i] += tmp[len - 1 - i] * gain
1089}
1090
1091// The downlifter: noise through a band-pass that falls, loud at the start.
1092function renderDownlifter(buf, sr, from, len, noise) {
1093 const bp = svf()
1094 for (let i = 0; i < len && from + i < buf.length; i++) {
1095 const u = i / len
1096 if ((i & 15) === 0) bp.set(7000 * Math.pow(150 / 7000, Math.sqrt(u)), sr, 1.8)
1097 buf[from + i] += bp.run(noise(), 1) * Math.min(1, i / (0.03 * sr)) * Math.pow(1 - u, 1.6) * 1.3
1098 }
1099}
1100
1101// The crash on the first beat of a drop.
1102function renderCrash(buf, sr, at, noise) {
1103 const start = Math.round(at * sr)
1104 const len = Math.min(Math.round(2.2 * sr), buf.length - start)
1105 const hp = svf(); hp.set(3800, sr, 0.7)
1106 const phases = HAT_FREQS.map(() => 0)
1107 for (let i = 0; i < len; i++) {
1108 const t = i / sr
1109 let metal = 0
1110 for (let j = 0; j < 6; j++) {
1111 phases[j] += (HAT_FREQS[j] * 4.3) / sr
1112 if (phases[j] >= 1) phases[j] -= 1
1113 metal += phases[j] < 0.5 ? 1 : -1
1114 }
1115 buf[start + i] += hp.run(noise() * 0.8 + metal * 0.06, 2) * Math.min(1, t / 0.002) * Math.exp(-t / 0.7)
1116 }
1117}
1118
1119// The level into the master's soft clip. Fixed, so a breakdown is quieter
1120// than a drop, as in a real set (it was a per-loop peak before 0.8).
1121const MASTER_GAIN = 0.65
1122const TAIL_SECONDS = 2.5
1123
1124// The whole loop before the master: every bus mixed to stereo, with the
1125// tail (reverb, delay, the last hits) after the loop still separate.
1126// opts.rise: a riser over the last four bars and no kick in the last bar.
1127// opts.impact: a crash on the first beat.
1128// opts.build: how the loop before a drop builds up (null: the 0.8 riser).
1129// 'riser' a riser over all 8 bars and a clap roll that speeds up
1130// 'filter' the whole mix closes into a low-pass, then one beat of silence
1131// 'echo' the bass fades out, the last bar is one clap thrown into the
1132// echo, and a reversed crash swells into the drop
1133// 'stutter' a 4-bar riser, then the last bar repeats its first beat in
1134// ever shorter slices while a high-pass opens
1135// With `impact`, each of them puts a boom under the drop that follows.
1136// opts.fall: how a drop ends (its last loop), see FALLS.
1137// 'tapestop' the last two beats slow down to a stop, like a tape
1138// 'washout' the last bar fades into a big reverb and echo
1139// 'downlifter' noise that falls over the last bar, into the next part
1140// opts.swell: a short reversed cymbal into the next part.
1141export function mixdown(input, { sampleRate = 44100, voices = null, rise = false, impact = false, build = null, fall = null, swell = false } = {}) {
1142 const a = arrange(input)
1143 const t = a.track
1144 const sr = sampleRate
1145 const loop = Math.round(a.steps * a.stepSeconds * sr)
1146 const total = loop + Math.round(TAIL_SECONDS * sr)
1147 const noise = noiseSource(hash32('noise:' + t.phrase + ':' + t.dice))
1148 const r = rng(hash32('sound:' + t.phrase))
1149 const stepAt = (step, swingable) => step * a.stepSeconds + (swingable && step % 2 === 1 ? t.swing * 0.045 * a.stepSeconds : 0)
1150 const bus = () => new Float32Array(total)
1151 const kick = bus(), bass = bus(), hats = bus(), clap = bus(), perc = bus(), acid = bus(), stab = bus(), voice = bus(), ride = bus(), pad = bus(), fx = bus()
1152 const lastBar = (BARS - 1) * STEPS
1153 const kicks = rise && build !== 'stutter' ? a.events.kick.filter((e) => e.step < lastBar) : a.events.kick
1154
1155 // the kick's tail sits on the key's root, so kick and sub are one note
1156 let tail = mtof(a.root)
1157 while (tail >= 56) tail /= 2
1158 const k0 = { f0: 140 + r() * 40, f1: tail, pitchDecay: 0.04 + r() * 0.015, ampDecay: 0.24 + r() * 0.06, drive: 1.9 + t.energy * 0.3 }
1159 // deep, punchy, boom, hard
1160 const k = [k0, { ...k0, f0: k0.f0 * 1.25, ampDecay: k0.ampDecay * 0.6, drive: k0.drive + 0.6 }, { ...k0, ampDecay: k0.ampDecay * 1.7, pitchDecay: k0.pitchDecay * 1.5, drive: k0.drive * 0.85 }, { ...k0, drive: k0.drive * 2, ampDecay: k0.ampDecay * 0.85 }][t.inst.kick ?? 0]
1161 const kickHit = template(sr, Math.max(0.55, k.ampDecay * 2.4), (b) => renderKick(b, sr, 0, 1, k, noise))
1162 for (const e of kicks) stamp(kick, kickHit, stepAt(e.step, false), e.vel, sr)
1163
1164 const bassCut = 160 + t.mood * 70 + t.energy * 45
1165 const bassGrit = 0.8 + 0.1 * t.energy - 0.1 * Math.min(t.mood, 2)
1166 // a bass note holds until the next one, at most two steps
1167 const bassAt = a.events.bass.map((e) => e.step)
1168 a.events.bass.forEach((e, i) => {
1169 const gap = ((bassAt[(i + 1) % bassAt.length] - e.step + a.steps - 1) % a.steps) + 1
1170 // rolling, sub, growl, acid
1171 const bv = [[1, 1, 0.8], [1, 0.12, 0.7], [1.6, 1.7, 0.8], [2.3, 1.1, 2.6]][t.inst.bass ?? 0]
1172 renderBass(bass, sr, stepAt(e.step, false), a.stepSeconds * Math.min(2, gap) * 0.9, e.note, e.vel, bassCut * bv[0], bassGrit * bv[1], noise, bv[2])
1173 })
1174
1175 const hatTone = 1.1 + r() * 0.5
1176 // two closed hats that alternate, so a run of 16ths does not sound like a machine gun
1177 const hatKind = HAT_KINDS[t.inst.hats ?? 0]
1178 const closedHats = [0, 1].map(() => template(sr, 0.16 * hatKind.decay, (b) => renderHat(b, sr, 0, 1, false, hatTone, noise, hatKind)))
1179 const openHat = template(sr, 1.2 * hatKind.decay, (b) => renderHat(b, sr, 0, 1, true, hatTone, noise, hatKind))
1180 for (const e of a.events.hats) stamp(hats, e.open ? openHat : closedHats[e.step % 2], stepAt(e.step, true), e.vel, sr)
1181 const clapHit = template(sr, 0.35, (b) => renderClap(b, sr, 0, 1, noise, t.inst.clap ?? 0))
1182 for (const e of a.events.clap) stamp(clap, clapHit, stepAt(e.step, false), e.vel, sr)
1183 // a clap roll into the drop: the 'riser' build speeds it up over four bars
1184 if (rise && build === 'riser') {
1185 for (let s = 4 * STEPS; s < BARS * STEPS; s++) {
1186 const every = s < 6 * STEPS ? 4 : s < lastBar ? 2 : 1
1187 if (s % every === 0) stamp(clap, clapHit, stepAt(s, false), 0.18 + 0.55 * ((s - 4 * STEPS) / (4 * STEPS)), sr)
1188 }
1189 } else if (rise && build !== 'echo' && a.on.clap) for (let s = 8; s < STEPS; s++) stamp(clap, clapHit, stepAt(lastBar + s, false), 0.25 + 0.06 * (s - 8), sr)
1190 const percPitch = 140 + r() * 120
1191 const percHit = template(sr, 0.3, (b) => renderPerc(b, sr, 0, 1, a.percKind, percPitch, noise))
1192 for (const e of a.events.perc) stamp(perc, percHit, stepAt(e.step, true), e.vel, sr)
1193 if (a.events.ride.length) {
1194 const rideHit = template(sr, 1.4, (b) => renderRide(b, sr, 0, 1, hatTone, noise))
1195 for (const e of a.events.ride) stamp(ride, rideHit, stepAt(e.step, true), e.vel, sr)
1196 }
1197
1198 const loopSeconds = loop / sr
1199 if (a.events.acid.length) {
1200 const sweepBase = 260 + t.mood * 120hooks/views.js 350 lines1// The app's screens, drawn in the band above the chat box. Pure functions:
2// they get the element table (`ui`), a view model (`vm`) and the click
3// handlers (`act`), and return a tree. No mods API here; register.js owns
4// the calls to the player.
5//
6// Layout A with the part bar (Ruslan, 2026-10-04): one line on top (previous,
7// play and next track side by side, the track, favorite, share, the volume;
8// auto and hide on the right), the part bar with the section, the mood and
9// the tempo, the grid, one line under it (mood, tempo, next part, edit, favorites), and the edit card
10// when edit is open.
11// Three screens: the deck, the favorites (with the tracks you heard), and new
12// tracks.
13//
14// A selected thing (the mood, auto on) is a filled primary button, not
15// brighter text: dim against normal text cannot be read in the light theme.
16
17import { sectionLabel } from './conductor.js'
18
19const row = (ui, children, extra = {}) => ui.Box({ flexDirection: 'row', flexWrap: 'wrap', columnGap: 1, alignItems: 'center', children: children.filter(Boolean), ...extra })
20const col = (ui, children, extra = {}) => ui.Box({ flexDirection: 'column', children: children.filter(Boolean), ...extra })
21const dim = (ui, text, extra = {}) => ui.Text({ dimColor: true, children: [text], ...extra })
22const spread = (ui, left, right, key) => ui.Box({ key, flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', columnGap: 2, children: [row(ui, left), row(ui, right)] })
23
24// A button that is on or off: on is the filled primary button.
25const toggle = (ui, key, label, isOn, onPress, extra = {}) => ui.Button({ key, label, onPress, ...(isOn ? { variant: 'primary' } : {}), ...extra })
26const link = (ui, key, label, onPress) => ui.Button({ key, label, plain: true, dimColor: true, onPress })
27const clock = (ms) => { const s = Math.floor(ms / 1000); return Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0') }
28
29// The transport, shared by the deck and the bar: previous, play or stop,
30// next track, then the track, its heart and share, and the volume (Ruslan,
31// 2026-10-05, option B: "remove dark and 132 bpm ... move it down"; the mood
32// and the tempo sit in the bottom line now).
33function transport(ui, vm, act, prefix) {
34 const v = vm.view
35 return [
36 vm.recent.length ? ui.Button({ key: prefix + 'prev', label: '⏮', onPress: () => act.prev() }) : null,
37 toggle(ui, prefix + 'play', vm.playing ? '■ stop' : '▶ play', !vm.playing, () => (vm.playing ? act.stop() : act.play()), prefix ? {} : { hotkey: 'p' }),
38 ui.Button({ key: prefix + 'skip', label: '⏭', onPress: () => act.skip() }),
39 ui.Text({ bold: true, wrap: 'truncate-end', children: [v.phrase] }),
40 ui.Button({ key: prefix + 'fav', label: vm.fav ? '♥' : '♡', plain: true, onPress: () => act.fav() }),
41 // the word, not an arrow (Ruslan, 2026-10-05, option B of arrow / word / under the grid)
42 ui.Button({ key: prefix + 'share', label: 'share', onPress: () => act.copy() }),
43 volume(ui, vm, act, prefix),
44 ]
45}
46
47// The volume as one value (Ruslan, 2026-10-05, option B of digit, percent,
48// one bar: "let's keep volume as %"): `vol − 80% +`, 0 to 100 in steps of 20.
49// − and + step it; muted reads `off`; a click on `vol` mutes and brings back
50// the default.
51function volume(ui, vm, act, prefix) {
52 const level = vm.volume ?? 4
53 return ui.Box({ key: prefix + 'volume', flexDirection: 'row', flexShrink: 0, columnGap: 1, children: [
54 ui.Button({ key: prefix + 'vol-mute', label: 'vol', plain: true, dimColor: true, onPress: () => act.volume(level ? 0 : 4) }),
55 ui.Button({ key: prefix + 'vol-down', label: '−', plain: true, dimColor: !level, onPress: () => act.volume(Math.max(0, level - 1)) }),
56 ui.Text({ key: prefix + 'vol-value', bold: !!level, dimColor: !level, children: [level ? level * 20 + '%' : 'off'] }),
57 ui.Button({ key: prefix + 'vol-up', label: '+', plain: true, dimColor: level === 5, onPress: () => act.volume(Math.min(5, level + 1)) }),
58 ] })
59}
60
61function topLine(ui, vm, act) {
62 return spread(ui, transport(ui, vm, act, ''), [
63 toggle(ui, 'auto', vm.auto ? '● auto' : '○ auto', vm.auto, () => act.auto(), { hotkey: 'a' }),
64 ui.Button({ key: 'close', label: 'hide', role: 'dismiss', plain: true, dimColor: true, onPress: () => act.close() }),
65 ], 'top')
66}
67
68// Where the song is, option A (Ruslan, 2026-10-04: "inline bar, one line,
69// the part name after the bar"; the section words were "too many words"):
70// one block per loop (about 15 s), so a block is the same time everywhere and
71// the bar agrees with the clock. The part that plays is in the accent, ▰ for
72// what of it played and ▱ for what is left of it (how long until the next
73// part); played parts ▰, the rest ▱ dim. Then the part's name and the time.
74export function partBar(parts, pos) {
75 let from = 0
76 return parts.flatMap((p) => {
77 const cells = Array.from({ length: p.loops }, (_, i) => {
78 const part = pos >= from + p.loops ? 'played' : pos >= from ? 'now' : 'next'
79 return part === 'now' ? (pos >= from + i + 1 ? 'now-played' : 'now-left') : part
80 })
81 from += p.loops
82 return cells
83 })
84}
85
86function whereLine(ui, vm) {
87 const v = vm.view, w = vm.where
88 if (!w) return null
89 const cells = partBar(w.parts, w.pos)
90 const runs = []
91 for (const c of cells) { const last = runs[runs.length - 1]; if (last && last.c === c) last.n++; else runs.push({ c, n: 1 }) }
92 const look = {
93 played: (n) => ui.Text({ children: ['▰'.repeat(n)] }),
94 'now-played': (n) => ui.Text({ color: 'claude', bold: true, children: ['▰'.repeat(n)] }),
95 'now-left': (n) => ui.Text({ color: 'claude', children: ['▱'.repeat(n)] }),
96 next: (n) => dim(ui, '▱'.repeat(n)),
97 }
98 return row(ui, [
99 ui.Box({ key: 'bar', flexDirection: 'row', flexShrink: 0, children: runs.map((r, i) => ({ ...look[r.c](r.n), key: 'run-' + i })) }),
100 ui.Text({ color: 'claude', bold: true, children: [sectionLabel(v)] }),
101 v.from ? dim(ui, '← from ' + v.from, { wrap: 'truncate-end' }) : null,
102 dim(ui, '· ' + clock(w.elapsed) + ' / ' + clock(w.total)),
103 ], { key: 'where', columnGap: 1 })
104}
105
106// The mood and the tempo, under the song's progress and over the grid
107// (Ruslan, 2026-10-05: "progress of the song ... under it the mood, then
108// the grid").
109function moodLine(ui, vm, act) {
110 return row(ui, [
111 dim(ui, 'mood'),
112 ...['sad', 'mysterious', 'dark'].map((name) => toggle(ui, 'mood-' + name, name, vm.view.mood === name, () => act.mood(name))),
113 dim(ui, vm.view.bpm + ' bpm', { key: 'bpm' }),
114 ], { key: 'mood' })
115}
116
117function bottomLine(ui, vm, act) {
118 return spread(ui, [
119 ui.Button({ key: 'new-song', label: '+ new song', onPress: () => act.screen('crate') }),
120 ], [
121 vm.auto ? null : ui.Button({ key: 'next', label: 'next part ›', onPress: () => act.next(), hotkey: 'n' }),
122 toggle(ui, 'edit', 'edit', vm.editOpen, () => act.edit(), { hotkey: 'e' }),
123 ui.Button({ key: 'favorites', label: '♥ favorites ' + vm.favorites.length, onPress: () => act.screen('favorites') }),
124 ], 'bottom')
125}
126
127// The edit card, layout B (Ruslan, 2026-10-04): tabs for the sounds that
128// play, + sound, song and master; one short card for the tab you are on.
129// Every change goes to the player as { op: 'edit', kind, ... } (editTrack).
130const SOUND_TABS = ['kick', 'bass', 'hats', 'rumble', 'clap', 'perc', 'ride', 'pad', 'acid']
131const tabName = (n) => (n === 'acid' ? 'melody' : n)
132const CUT_WORDS = ['muffled', 'dark', 'warm', 'as is', 'clear', 'bright', 'thin']
133const GRIT_WORDS = ['clean', 'warm', 'driven', 'crushed']
134const LP_WORDS = ['closed', 'dark', 'soft', 'open']
135const HP_WORDS = ['full', 'lighter', 'thin', 'tiny']
136const SPACE_WORDS = ['dry', 'tight', 'as is', 'wide', 'huge']
137const label = (ui, text) => ui.Box({ width: 8, flexShrink: 0, children: [dim(ui, text)] })
138const line = (ui, key, name, children) => ui.Box({ key, flexDirection: 'row', alignItems: 'center', columnGap: 1, children: [label(ui, name), row(ui, children)] })
139const stepper = (ui, key, words, i, onMinus, onPlus) => [
140 ui.Button({ key: key + '-down', label: '−', onPress: onMinus }),
141 ui.Box({ width: Math.max(...words.map((w) => w.length)), justifyContent: 'center', children: [ui.Text({ bold: true, children: [words[i]] })] }),
142 ui.Button({ key: key + '-up', label: '+', onPress: onPlus }),
143]
144
145function editTabs(ui, vm, act) {
146 const tabs = SOUND_TABS.filter((n) => vm.heard[n] || n === vm.editTab)
147 return spread(ui, [
148 ...tabs.map((n) => toggle(ui, 'tab-' + n, tabName(n), vm.editTab === n, () => act.editTab(n))),
149 toggle(ui, 'tab-add', '+ sound', vm.editTab === 'add', () => act.editTab('add')),
150 dim(ui, '│'),
151 toggle(ui, 'tab-song', 'song', vm.editTab === 'song', () => act.editTab('song')),
152 toggle(ui, 'tab-master', 'master', vm.editTab === 'master', () => act.editTab('master')),
153 ], [
154 vm.canUndo ? ui.Button({ key: 'undo', label: '↶ undo', onPress: () => act.undo(), hotkey: 'u' }) : null,
155 ui.Button({ key: 'save-mp3', label: '↓ mp3', onPress: () => act.share() }),
156 ui.Button({ key: 'edit-done', label: 'done', onPress: () => act.edit() }),
157 ], 'edit-tabs')
158}
159
160function soundCard(ui, vm, act) {
161 const x = vm.info, L = x.layer
162 const tweak = (e) => () => act.tweak({ layer: L, ...e })
163 const rows = []
164 rows.push(line(ui, 'ed-sound', 'sound', x.insts.length
165 ? x.insts.map((it) => toggle(ui, 'inst-' + L + '-' + it.name, it.name, it.name === x.inst, tweak({ kind: 'inst', value: it.value })))
166 : [dim(ui, L === 'rumble' ? 'the kick through a long dark room' : L === 'pad' ? 'a dark chord, one swell a loop' : 'one sound')]))
167 rows.push(line(ui, 'ed-pattern', 'pattern', x.presets.length
168 ? [dim(ui, 'try:'), ...x.presets.map((p) => toggle(ui, 'pat-' + L + '-' + p.name, p.name, p.on, tweak({ kind: 'pattern', name: p.name }))), x.edited ? link(ui, 'pat-reset-' + L, 'as written', tweak({ kind: 'reset-pattern' })) : null, dim(ui, '· or click the grid')]
169 : [dim(ui, L === 'rumble' ? 'follows the kick' : 'one long swell')]))
170 if (x.notes) {
171 rows.push(line(ui, 'ed-notes', 'notes', [
172 ...x.notes.map((n, i) => (n ? ui.Button({ key: 'note-' + L + '-' + i, label: n, onPress: tweak({ kind: 'note', i }) }) : dim(ui, '·', { key: 'rest-' + L + '-' + i }))),
173 ui.Button({ key: 'notes-suggest-' + L, label: '⚄ suggest', onPress: tweak({ kind: 'suggest-notes' }) }),
174 x.notesEdited ? link(ui, 'notes-reset-' + L, 'as written', tweak({ kind: 'reset-notes' })) : null,
175 ]))
176 rows.push(line(ui, 'ed-scale', '', [dim(ui, 'scale ' + x.scale.join(' ') + ' · tap a note to move it up the scale')]))
177 }
178 if (x.hasTone) rows.push(line(ui, 'ed-tone', 'tone', [
179 ...stepper(ui, 'cut-' + L, CUT_WORDS, x.tone[0] + 3, tweak({ kind: 'tone', cut: -1 }), tweak({ kind: 'tone', cut: 1 })),
180 dim(ui, '·'), dim(ui, 'grit'),
181 ...stepper(ui, 'grit-' + L, GRIT_WORDS, x.tone[1], tweak({ kind: 'tone', grit: -1 }), tweak({ kind: 'tone', grit: 1 })),
182 dim(ui, '·'),
183 ui.Button({ key: 'mute-' + L, label: vm.heard[L] ? 'mute' : 'play it', onPress: () => act.layer(L) }),
184 ]))
185 return rows
186}
187
188function masterCard(ui, vm, act) {
189 const m = vm.master
190 const tw = (e) => () => act.tweak({ kind: 'master', ...e })
191 return [
192 line(ui, 'ed-lp', 'filter', [...stepper(ui, 'lp', LP_WORDS, m[0] + 3, tw({ lp: -1 }), tw({ lp: 1 })), dim(ui, '· close it for a muffled, far away sound')]),
193 line(ui, 'ed-hp', 'low cut', [...stepper(ui, 'hp', HP_WORDS, m[1], tw({ hp: -1 }), tw({ hp: 1 })), dim(ui, '· take the low end out')]),
194 line(ui, 'ed-space', 'space', [...stepper(ui, 'space', SPACE_WORDS, m[2] + 2, tw({ space: -1 }), tw({ space: 1 })), dim(ui, '· the room around it')]),
195 line(ui, 'ed-tempo', 'tempo', [ui.Button({ key: 'slower', label: '−', onPress: () => act.bpm(-2) }), ui.Text({ bold: true, children: [vm.view.bpm + ' bpm'] }), ui.Button({ key: 'faster', label: '+', onPress: () => act.bpm(2) }), m[0] || m[1] || m[2] ? link(ui, 'master-reset', 'as written', tw({ reset: true })) : null]),
196 ]
197}
198
199function addCard(ui, vm, act) {
200 const absent = SOUND_TABS.filter((n) => !vm.heard[n])
201 return [line(ui, 'ed-add', 'add', absent.length ? absent.map((n) => ui.Button({ key: 'add-' + n, label: '+ ' + tabName(n), onPress: () => { act.layer(n); act.editTab(n) } })) : [dim(ui, 'every sound plays')])]
202}
203
204// The song: its parts in order (× takes one out; not the one that plays),
205// the parts to add (★ the recommended one), and a new rhythm.
206function songCard(ui, vm, act) {
207 const tw = (e) => () => act.tweak(e)
208 return [
209 line(ui, 'ed-parts', 'parts', vm.plan.flatMap((p) => [
210 p.now ? ui.Text({ key: 'part-' + p.k, color: 'claude', bold: true, children: [p.name] }) : ui.Text({ key: 'part-' + p.k, children: [p.name] }),
211 p.now || vm.plan.length <= 2 ? null : link(ui, 'part-x-' + p.k, '×', tw({ kind: 'plan-remove', k: p.k })),
212 ].filter(Boolean))),
213 line(ui, 'ed-add-part', 'add', [
214 ...vm.planAdds.map((a) => ui.Button({ key: 'part-add-' + a.id, label: '+ ' + a.label + (a.best ? ' ★' : ''), onPress: tw({ kind: 'plan-add', id: a.id }) })),
215 vm.planEdited ? link(ui, 'plan-reset', 'as written', tw({ kind: 'plan-reset' })) : null,
216 ]),
217 line(ui, 'ed-rhythm', 'rhythm', [ui.Button({ key: 'dice', label: '⚄ new rhythm', onPress: () => act.dice(), hotkey: 'd' }), dim(ui, '· new patterns from the same name')]),
218 ]
219}
220
221function editCard(ui, vm, act) {
222 const body = vm.editTab === 'master' ? masterCard(ui, vm, act) : vm.editTab === 'song' ? songCard(ui, vm, act) : vm.editTab === 'add' ? addCard(ui, vm, act) : vm.info ? soundCard(ui, vm, act) : []
223 return col(ui, [editTabs(ui, vm, act), ...body], { key: 'edit-card', borderStyle: 'single', borderDimColor: true, paddingX: 1 })
224}
225
226const talkLine = (ui, vm) => (vm.said || vm.you ? row(ui, [vm.you ? dim(ui, 'you: ' + vm.you, { wrap: 'truncate-end' }) : null, vm.said ? ui.Text({ wrap: 'truncate-end', children: ['› ' + vm.said] }) : null], { key: 'talk', columnGap: 2 }) : null)
227
228function deck(ui, vm, act) {
229 return [
230 topLine(ui, vm, act),
231 whereLine(ui, vm),
232 moodLine(ui, vm, act),
233 vm.gridEl,
234 bottomLine(ui, vm, act),
235 vm.editOpen ? editCard(ui, vm, act) : null,
236 talkLine(ui, vm),
237 ]
238}
239
240// The favorites, the tracks you heard (to find a lost one), and a way back.
241function trackRow(ui, vm, act, it, { isFav, prefix }) {
242 const here = vm.view?.id === it.code
243 return row(ui, [
244 toggle(ui, prefix + 'play-' + it.code, here ? '▶ ' + it.label : it.label, here, () => (isFav ? act.playFavorite(it.code) : act.pick(it.code))),
245 dim(ui, it.mood + ' · ' + it.bpm),
246 isFav ? link(ui, prefix + 'share-' + it.code, 'copy line', () => act.copy(it.code)) : null,
247 isFav ? link(ui, prefix + 'rm-' + it.code, 'remove', () => act.toggleFav(it.code)) : link(ui, prefix + 'fav-' + it.code, '♡ keep', () => act.toggleFav(it.code)),
248 ], { key: prefix + it.code, columnGap: 2 })
249}
250
251function favorites(ui, vm, act) {
252 const favCodes = new Set(vm.favorites.map((f) => f.code))
253 const recent = vm.recent.filter((r) => !favCodes.has(r.code))
254 return [
255 spread(ui, [ui.Text({ bold: true, children: ['♥ favorites'] }), dim(ui, vm.favorites.length + ' tracks')], [
256 vm.favorites.length ? toggle(ui, 'play-all', vm.playlist === 'favorites' ? '● playing them in turn' : '▶ play them in turn', vm.playlist === 'favorites', () => act.playFavorites()) : null,
257 link(ui, 'to-crate', 'new song', () => act.screen('crate')),
258 link(ui, 'back', '‹ back', () => act.screen('deck')),
259 ], 'fav-top'),
260 vm.favorites.length ? null : dim(ui, 'Press ♡ while a track plays to keep it here.'),
261 ...vm.favorites.map((it) => trackRow(ui, vm, act, it, { isFav: true, prefix: 'f-' })),
262 recent.length ? dim(ui, 'heard before', { key: 'heard' }) : null,
263 ...recent.map((it) => trackRow(ui, vm, act, it, { isFav: false, prefix: 'h-' })),
264 ]
265}
266
267// New song, option B (Ruslan, 2026-10-08: "B is the best but user can just
268// click on 1 2 3 etc or type themselves"): the name box first, with one
269// example of what a name gives; under it your recent chats and a few
270// classics, numbered. A click or the digit (in an empty chat box) plays one.
271const ago = (ms) => {
272 const m = Math.max(1, Math.round(ms / 60000))
273 if (m < 60) return m + ' min ago'
274 const h = Math.round(m / 60)
275 if (h < 24) return h + ' h ago'
276 const d = Math.round(h / 24)
277 return d === 1 ? 'yesterday' : d + ' d ago'
278}
279
280function numbered(ui, act, key, n, phrase, meta) {
281 return ui.Box({ key, flexDirection: 'row', columnGap: 2, children: [
282 ui.Box({ flexShrink: 1, children: [ui.Button({ key: key + '-play', label: phrase, plain: true, hotkey: String(n), onPress: () => act.pick(phrase) })] }),
283 meta ? dim(ui, meta, { key: key + '-meta', wrap: 'truncate-end' }) : null,
284 ].filter(Boolean) })
285}
286
287function crate(ui, vm, act) {
288 const { chats, classics, example, now } = vm.crate
289 let n = 0
290 const chatRows = chats.map((c) => numbered(ui, act, 'chat-' + c.phrase, ++n, c.phrase, [c.project, ago(now - c.at)].filter(Boolean).join(' · ')))
291 const classicRows = classics.map((p) => numbered(ui, act, 'classic-' + p, ++n, p, ''))
292 return [
293 spread(ui, [ui.Text({ bold: true, children: ['new song'] }), dim(ui, `· type a name, get a song: "${example.phrase}" plays ${example.mood}, ${example.bpm} bpm`, { wrap: 'truncate-end' })], [link(ui, 'back', '‹ back', () => act.screen('deck'))], 'crate-top'),
294 ui.Input({ key: 'phrase', label: 'name', placeholder: 'type any name…', value: '', submitLabel: 'play', onSubmit: (v) => act.phrase(v) }),
295 ui.Box({ key: 'lists', flexDirection: 'row', flexWrap: 'wrap', columnGap: 4, children: [
296 chats.length ? col(ui, [dim(ui, 'or press a number · your recent chats', { key: 'chats-head' }), ...chatRows], { key: 'chats', width: '58%', minWidth: 34, flexGrow: 1 }) : null,
297 col(ui, [
298 ui.Box({ key: 'classics-head', flexDirection: 'row', columnGap: 2, children: [dim(ui, chats.length ? 'classics' : 'or press a number · classics'), link(ui, 'classics-more', '⚄ others', () => act.moreClassics())] }),
299 ...classicRows,
300 ], { key: 'classics', minWidth: 26, flexGrow: 1 }),
301 ].filter(Boolean) }),
302 ]
303}
304
305function down(ui, vm, act) {
306 return [
307 spread(ui, [ui.Text({ bold: true, children: ['♪ techno'] }), dim(ui, vm.down || 'starting the player…')], [
308 ui.Button({ key: 'retry', label: 'try again', onPress: () => act.retry() }),
309 ui.Button({ key: 'close', label: 'hide', role: 'dismiss', plain: true, dimColor: true, onPress: () => act.close() }),
310 ], 'down'),
311 ]
312}
313
314// A new version on GitHub: one line with an update button (Ruslan,
315// 2026-10-08: "just give an update ready ... maybe we build a button").
316function updateLine(ui, vm, act) {
317 const u = vm.update
318 if (!u) return null
319 const say = {
320 ready: [ui.Text({ color: 'claude', bold: true, children: ['techno ' + u.latest + ' is ready'] }), ui.Button({ key: 'update', label: 'update', variant: 'primary', onPress: () => act.update() })],
321 working: [dim(ui, 'updating to ' + u.latest + '…')],
322 done: [ui.Text({ color: 'claude', bold: true, children: ['✓ updated to ' + u.latest] }), dim(ui, '· open a new chat to use it')],
323 failed: [ui.Text({ children: ['the update failed: ' + (u.why || 'unknown')] }), ui.Button({ key: 'update', label: 'try again', onPress: () => act.update() })],
324 }[u.state]
325 return row(ui, say, { key: 'update-line' })
326}
327
328export function appView(ui, vm, act) {
329 const body = !vm.view ? down(ui, vm, act) : vm.screen === 'favorites' ? favorites(ui, vm, act) : vm.screen === 'crate' ? crate(ui, vm, act) : deck(ui, vm, act)
330 return col(ui, [...body, updateLine(ui, vm, act), vm.status ? dim(ui, vm.status, { key: 'status', wrap: 'truncate-end' }) : null], { borderStyle: 'round', paddingX: 1 })
331}
332
333// The bar above the chat box while the app is hidden: the same transport as
334// the deck (no section word: "i don't want to see these intro drop 1 at all
335// here"), then auto, open and × on the right.
336export function miniView(ui, vm, act) {
337 if (!vm.view) return spread(ui, [ui.Text({ bold: true, children: ['♪'] }), dim(ui, vm.down || 'starting the player…')], [link(ui, 'mini-close', '×', () => act.closeBar())], 'mini')
338 const v = vm.view
339 return spread(ui, [
340 ui.Text({ bold: true, children: ['♪'] }),
341 ...transport(ui, vm, act, 'mini-'),
342 ], [
343 vm.update?.state === 'ready' ? ui.Button({ key: 'mini-update', label: '↑ update', variant: 'primary', onPress: () => act.update() }) : null,
344 vm.update?.state === 'done' ? dim(ui, '✓ updated · new chat') : null,
345 toggle(ui, 'mini-auto', vm.auto ? '● auto' : '○ auto', vm.auto, () => act.auto()),
346 link(ui, 'mini-open', 'open', () => act.open()),
347 link(ui, 'mini-close', '×', () => act.closeBar()),
348 ], 'mini')
349}
350hooks/words.js 51 lines1// Chat words: the two or three most unusual words of a prompt become a
2// phrase that auto can pick as a track (Ruslan, 2026-10-05: "use the data
3// from chats to randomize the track"). The phrase is the track's name and
4// sits in its share code, so only plain words pass: no paths, links, emails,
5// numbers, or anything that looks like a key. Pure, so the tests call it.
6
7const COMMON = new Set(`
8a about above after again against all also always am an and any are aren around as ask at back be because been before
9being below between both but by can cannot could did didn do does doesn doing don done down during each else even
10ever every few find first for from further get gets getting give go goes going gone good got had has hasn have having
11he her here hers herself him himself his how however i if in into is isn it its itself just keep know last let like
12look looks make makes making many may maybe me might more most much must my myself need needs never new next no nor
13not now of off ok okay on once one only or other our ours out over own please put quite rather really right said same
14say see seem seems shall she should show so some something still such sure take than thank thanks that the their
15theirs them themselves then there these they thing things think this those though through thus to too try trying
16twice under until up upon us use used using very want wants was wasn way we well were weren what when where whether
17which while who whom why will with without won would wouldn yes yet you your yours yourself
18again best less lets thats whats
19add added change changes changed check code create delete doesnt dont error file files fix fixed help hey hi im
20issue line lines move remove rename run running set start stop test tests update write wrong work works working
21claude
22techno track tracks music song songs beat beats bass kick hats clap drop drops acid swing tempo bpm mood sad dark
23mysterious louder quieter darker lighter faster slower play playing pause skip previous favorite favorites
24`.split(/\s+/).filter(Boolean))
25
26// A word that can carry a track name: letters only, 3 to 14 of them.
27const plain = (w) => /^[a-z]{3,14}$/.test(w) && !COMMON.has(w)
28
29// The phrase for a prompt, or null when it has fewer than two such words.
30export function keyWords(text) {
31 const clean = String(text ?? '')
32 .replace(/<[^>]*>/g, ' ') // tags
33 .replace(/\S*[/\\@:=_`$#{}[\]<>|~]\S*/g, ' ') // paths, links, emails, code
34 .replace(/\S*\d\S*/g, ' ') // anything with a digit
35 .replace(/\b[A-Z][A-Z0-9]{3,}\b/g, ' ') // SHOUTED names and keys
36 if (/^\s*\//.test(String(text ?? ''))) return null // a slash command
37 const words = clean.toLowerCase().split(/[^a-z']+/).map((w) => w.replace(/'.*$/, ''))
38 const seen = new Set()
39 const picked = words
40 .map((w, i) => ({ w, i }))
41 .filter(({ w }) => plain(w) && !seen.has(w) && seen.add(w))
42 if (picked.length < 2) return null
43 // the longest words are the rarest; keep them in the order they were said
44 return picked
45 .sort((a, b) => b.w.length - a.w.length || a.i - b.i)
46 .slice(0, 3)
47 .sort((a, b) => a.i - b.i)
48 .map(({ w }) => w)
49 .join(' ')
50}
51hooks/classics.js 22 lines1// The classics: names that are good songs on their own. The new song screen
2// shows a few of them, and auto plays one after every three of your chats
3// (Ruslan, 2026-10-08: "let's generate more classics as well! maybe we
4// sometimes wire them in"). Pure data, shared by the chat and the player.
5// The first eight are the old starters; keep them, old favorites point at them.
6export const CLASSICS = [
7 'late night deploy', 'coffee at 3am', 'merge conflict', 'friday deploy', 'null pointer', 'ship it', 'warehouse 4am', 'rooftop sunrise',
8 // the city at night, 5am
9 'empty tram at 5am', 'neon on wet asphalt', 'last train home', 'streetlights going out', 'still awake at 5am', 'sodium lights', 'underpass echo', 'the city exhales',
10 // space, 3am
11 'drifting past the moon', 'orbit at 3am', 'signal from deep space', 'falling through stars', 'cold sleep', 'launch window', 'chasing the comet', 'mission control at 3am',
12 // the errors you meet on a sunday
13 'segfault on sunday', 'connection refused', 'kernel panic', 'permission denied', 'race condition', 'deadlock', 'cache miss', 'timeout exceeded', 'out of memory', 'sunday hotfix', 'rollback at midnight', 'build failed again',
14]
15
16// A small stable hash, so a pick from the classics is the same in every call.
17export function pickIndex(text, n) {
18 let h = 2166136261
19 for (let i = 0; i < text.length; i++) h = Math.imul(h ^ text.charCodeAt(i), 16777619)
20 return (h >>> 0) % n
21}
22hooks/grid.client.js 113 lines1// The step sequencer, drawn on the surface's own frame clock so the playhead
2// moves without a redraw of the whole pane. Every step is a Box of the same
3// width, so the columns line up in any font.
4//
5// A click on a row name posts { toggle: layer } (mute). A click on a cell
6// posts { step: i, layer } (add or remove that hit in every bar).
7//
8// props: { rows: [[name, pattern, color, isOn, isMuted, isNext]], step, stepMs, playing, stamp }
9// stamp is when the current bar started; a new stamp resyncs the playhead.
10// A layer the build has not brought in (or took out) is dim; one you muted is struck through.
11// The next sound of the build is a dashed row: where it will play, not yet clickable.
12const LABEL = 8
13const TOP = 1 // the beat ruler (no "bar n/8" line: Ruslan, 2026-10-04)
14const cellX = (i) => LABEL + i * 3 + Math.floor(i / 4)
15
16// Theme keys, not raw colors, so the grid reads in the light and the dark
17// theme alike: an empty step is the theme's subtle grey, the playhead its text color.
18const REST = 'subtle'
19const REST_BEAT = 'subtle'
20const MUTED = 'inactive'
21const HEAD_REST = 'suggestion'
22const HEAD_HIT = 'text'
23const HOVER = 'inactive'
24
25function cellAt(x) {
26 for (let i = 0; i < 16; i++) if (x >= cellX(i) && x <= cellX(i) + 2) return i
27 return -1
28}
29
30export default function Grid(props, surface) {
31 const { Box, Text } = surface.elements
32 const prev = surface.state
33 let st = prev ?? { step: props.step, stamp: props.stamp, ref: { props }, stop: null, stepMs: 0, hover: null }
34 if (!prev) {
35 surface.onPointer((ev) => {
36 const cur = surface.state ?? st
37 const rows = cur.ref.props.rows
38 const r = ev.y - TOP
39 const row = ev.type === 'leave' ? null : rows[r]
40 const i = row ? (ev.x < LABEL ? -1 : cellAt(ev.x)) : null
41 if (ev.type === 'down' && row && !row[5]) {
42 if (i === -1) surface.post({ toggle: row[0] })
43 else if (i !== null && i >= 0) surface.post({ step: i, layer: row[0] })
44 }
45 const hover = row && i !== null ? { r, i } : null
46 const same = (hover && cur.hover && hover.r === cur.hover.r && hover.i === cur.hover.i) || (!hover && !cur.hover)
47 if (!same) surface.setState({ ...cur, hover })
48 })
49 }
50 st.ref.props = props
51
52 // resync to the pane's clock whenever the pane hands a new stamp
53 let changed = !prev
54 if (props.stamp !== st.stamp) {
55 st = { ...st, step: props.step, stamp: props.stamp }
56 changed = true
57 }
58 const wantMs = props.playing ? props.stepMs : 0
59 if (wantMs !== st.stepMs) {
60 if (st.stop) st.stop()
61 const tick = () => {
62 const s = surface.state
63 surface.setState({ ...s, step: (s.step + 1) % 128 })
64 }
65 st = { ...st, stepMs: wantMs, stop: wantMs ? surface.every(wantMs, tick) : null }
66 changed = true
67 }
68 if (changed) surface.setState(st)
69
70 const col = props.playing ? st.step % 16 : -1
71 const hover = st.hover
72 const gap = (i) => (i % 4 === 3 ? 2 : 1)
73
74 const ruler = Box({
75 key: 'gr',
76 flexDirection: 'row',
77 children: [
78 Box({ key: 'rl', width: LABEL, children: [Text({ children: [' '] })] }),
79 ...Array.from({ length: 16 }, (_, i) =>
80 Box({ key: 'r' + i, width: 2, marginRight: gap(i), children: [Text({ dimColor: i !== col, bold: i === col, children: [i % 4 === 0 ? String(i / 4 + 1) : '·'] })] }),
81 ),
82 ],
83 })
84 const rows = props.rows.map(([name, pattern, color, isOn, isMuted, isNext], r) => {
85 if (isNext) {
86 const cells = [...pattern].map((c, i) => Box({ key: name + i, width: 2, height: 1, marginRight: gap(i), children: [Text({ dimColor: true, children: [c !== '.' ? '▪▪' : '╌╌'] })] }))
87 return Box({ key: 'g-' + name, flexDirection: 'row', children: [Box({ key: 'l-' + name, width: LABEL, children: [Text({ dimColor: true, italic: true, children: [name] })] }), ...cells] })
88 }
89 const label = Text({
90 bold: isOn,
91 dimColor: !isOn,
92 strikethrough: !!isMuted,
93 underline: hover?.r === r && hover.i === -1,
94 children: [name],
95 })
96 const cells = [...pattern].map((c, i) => {
97 const hit = c !== '.'
98 let bg
99 if (i === col) bg = hit && isOn ? HEAD_HIT : HEAD_REST
100 else if (hover?.r === r && hover.i === i) bg = HOVER
101 else if (!hit) bg = i % 4 === 0 ? REST_BEAT : REST
102 else bg = isOn ? color[c === 'X' ? 0 : 1] : MUTED
103 return Box({ key: name + i, width: 2, height: 1, marginRight: gap(i), backgroundColor: bg, children: [Text({ children: [' '] })] })
104 })
105 return Box({
106 key: 'g-' + name,
107 flexDirection: 'row',
108 children: [Box({ key: 'l-' + name, width: LABEL, children: [label] }), ...cells],
109 })
110 })
111 return Box({ flexDirection: 'column', children: [ruler, ...rows] })
112}
113hooks/conductor.js 147 lines1// The conductor: what each loop of a long set plays, and the mixer that cuts
2// the rendered loops into bars for a gapless stream. Plain JavaScript like
3// the engine, so the background player (player/techno.mjs) and the scripts
4// share it.
5//
6// A set is the current track at a part of PLAN, plus how many loops it has
7// played there. With auto on, each part plays its `loops`, then the next one;
8// after the outro the next track comes in through a HANDOVERS shape and goes
9// on from its build. With auto, the transitions come from `transitionsOf`
10// (seeded by the track): a build-up on the last loop of a part with `rise`,
11// a fall on the last loop of a drop, a swell into a new sound, a build-up
12// before the low end swaps in the handover. The first loop of a drop (and of
13// the new track after a swap) gets a crash, with auto a boom too.
14import { PLAN, HANDOVERS, HANDOVER_LOOPS, atPart, onlyLayers, cleanTrack, encodeCode, transitionsOf, planOf } from './engine.js'
15
16// A fresh set on a track (from its first part unless it has one). `at` is
17// the position in the track's plan (a part can come twice in it).
18export function newSet(track) {
19 const t = cleanTrack(track)
20 const plan = planOf(t)
21 const k = t.part === null ? 0 : Math.max(0, plan.indexOf(t.part))
22 return { track: atPart(t, plan[k]), loop: 0, handover: null, at: k }
23}
24
25// The position in the plan, for a set saved before 0.9 (no `at`).
26export const posOf = (set) => set.at ?? Math.max(0, planOf(set.track).indexOf(set.track.part))
27
28// What the next loop plays: the arguments of renderLoop. Only auto adds the
29// transitions, since only auto knows what comes next.
30export function loopSpec(set, { auto }) {
31 const none = { rise: false, impact: false, build: null, fall: null, swell: false }
32 if (set.handover) {
33 const shape = HANDOVERS[set.handover.shape]
34 const h = shape.steps[set.handover.step]
35 const swap = auto && set.handover.step === shape.steps.length - 1 && set.handover.loop >= HANDOVER_LOOPS - 1
36 return { ...none, track: onlyLayers(set.handover.from, h.from), with: h.to.length ? onlyLayers(atPart(set.track, shape.to), h.to) : null, ...(swap ? { rise: true, build: transitionsOf(set.handover.from).swap } : {}) }
37 }
38 const k = posOf(set), p = PLAN[set.track.part]
39 const last = set.loop >= p.loops - 1
40 const fx = auto ? transitionsOf(set.track) : null
41 const rise = !!fx && last && fx.build[k] !== undefined
42 const drop = p.section === 'drop' && set.loop === 0
43 const landed = !!set.swapped && set.loop === 0
44 return {
45 track: set.track,
46 with: null,
47 rise,
48 impact: drop || landed,
49 build: rise ? fx.build[k] : fx && drop ? fx.build[k - 1] ?? null : fx && landed ? set.swapped : null,
50 fall: fx && last ? fx.fall[k] ?? null : null,
51 swell: !!(fx && last && fx.swell[k]),
52 }
53}
54
55// The key of a spec, so the player renders a loop once and reuses it.
56export const specKey = (spec) => [encodeCode(spec.track), spec.with ? encodeCode(spec.with) : '', spec.rise ? 'r' : '', spec.impact ? 'i' : '', spec.build ?? '', spec.fall ?? '', spec.swell ? 's' : ''].join('|')
57
58// Moves on by one part, or one handover step. `nextTrack()` gives the track
59// that comes in after the outro. Returns the set (mutated).
60export function stepOn(set, nextTrack) {
61 set.loop = 0
62 set.swapped = null
63 if (set.handover) {
64 set.handover.step++
65 set.handover.loop = 0
66 const shape = HANDOVERS[set.handover.shape]
67 if (set.handover.step >= shape.steps.length) {
68 // the new song lands with the crash and the boom of the swap build-up
69 set.swapped = transitionsOf(set.handover.from).swap
70 set.handover = null
71 const plan = planOf(set.track)
72 set.at = Math.max(0, plan.indexOf(shape.to))
73 set.track = atPart(set.track, plan[set.at])
74 }
75 return set
76 }
77 const plan = planOf(set.track), k = posOf(set)
78 if (k < plan.length - 1) {
79 set.at = k + 1
80 set.track = atPart(set.track, plan[k + 1])
81 return set
82 }
83 const next = cleanTrack(nextTrack())
84 const shape = transitionsOf(set.track).land
85 set.handover = { from: set.track, step: 0, loop: 0, shape }
86 set.track = { ...atPart(next, HANDOVERS[shape].to), bpm: set.track.bpm }
87 return set
88}
89
90// After a loop played to its end: with auto, count it and move on when the
91// part (or the handover step) has had its loops.
92export function afterLoop(set, { auto, nextTrack }) {
93 if (!auto) return set
94 if (set.handover) {
95 set.handover.loop++
96 if (set.handover.loop >= HANDOVER_LOOPS) stepOn(set, nextTrack)
97 return set
98 }
99 set.loop++
100 if (set.loop >= PLAN[set.track.part].loops) stepOn(set, nextTrack)
101 return set
102}
103
104// How many loops are left in this part (or handover step), this one included.
105export function loopsLeft(set) {
106 if (set.handover) return HANDOVER_LOOPS - set.handover.loop
107 return Math.max(1, PLAN[set.track.part].loops - set.loop)
108}
109
110// A soft knee over 0.9, so a tail added on a full bar never clips.
111const knee = (v) => (v > 0.9 ? 0.9 + 0.09 * Math.tanh((v - 0.9) / 0.09) : v < -0.9 ? -0.9 - 0.09 * Math.tanh((-v - 0.9) / 0.09) : v)
112
113// The mixer cuts a rendered loop into its 8 bars and adds the tail of the
114// loop before (reverb, delay) onto the start of the next one. A bar is one
115// interleaved stereo Float32Array.
116export function mixer() {
117 let carryL = new Float32Array(0), carryR = new Float32Array(0)
118 return {
119 // bar `k` (0..7) of the render `r`; the last bar hands the render's tail on
120 bar(r, k) {
121 const from = Math.round((k * r.loop) / 8), to = Math.round(((k + 1) * r.loop) / 8)
122 const n = to - from
123 const out = new Float32Array(n * 2)
124 for (let i = 0; i < n; i++) {
125 out[i * 2] = knee(r.left[from + i] + (i < carryL.length ? carryL[i] : 0))
126 out[i * 2 + 1] = knee(r.right[from + i] + (i < carryR.length ? carryR[i] : 0))
127 }
128 carryL = carryL.length > n ? carryL.subarray(n) : new Float32Array(0)
129 carryR = carryR.length > n ? carryR.subarray(n) : new Float32Array(0)
130 if (k === 7) {
131 const tl = r.left.subarray(r.loop), tr = r.right.subarray(r.loop)
132 const len = Math.max(tl.length, carryL.length)
133 const nl = new Float32Array(len), nr = new Float32Array(len)
134 nl.set(carryL); nr.set(carryR)
135 for (let i = 0; i < tl.length; i++) { nl[i] += tl[i]; nr[i] += tr[i] }
136 carryL = nl; carryR = nr
137 }
138 return out
139 },
140 }
141}
142
143// What the deck calls the part that plays: its section (intro, groove,
144// build, break, outro), a drop with its number, "mixing in" in a handover
145// (Ruslan, 2026-10-05, option A: "kick - it's not clear what it means").
146export const sectionLabel = (v) => (!v ? '' : v.section === 'handover' ? 'mixing in' : v.section === 'drop' ? v.name : v.section)
147