SLOPSHOPPER

techno

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.

newbandguardcommandtoastprompt
★ 2v0.9.11MITupdated 2026-10-08ruslanshchuchkin/claude-techno-mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · techno
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /techno ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ ♪ techno techno needs node (brew install node) [ try again ] hide │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ ♪ techno techno needs node (brew install node) [ try again ] hide │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ ⟨Claude Code's own drawing⟩
README

😈 claude-techno-mod

Type a phrase. Get a seven-minute techno set. Keep coding.

GitHub stars macOS Claude Code License: MIT

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.

  • 🎛️ One phrase, a whole set. Kick, then bass, then hats. Three drops, breakdowns between them, about seven minutes.
  • 🤖 Claude is the DJ. Say "darker", "drop the acid", "next track" and it turns the knobs.
  • 🌀 It never stops. When a track ends, it mixes into the next one, layer by layer, at the same tempo.
  • 🖤 No samples. No AI audio. No account. Every sound is synthesized on your Mac.

⚡ Install

[!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 claude session? Type /plugin marketplace add ruslanshchuchkin/claude-techno-mod, then /plugin install techno@techno-mod.

🎚️ The deck

The band in the video above, part by part:

On the deckWhat it does
♡ / shareKeep the track in favorites / copy its share line
vol − 80% +The volume, in steps of 20%. vol mutes. Or ask Claude: "quieter"
▰▰▱▱ drop 1The whole song, one block per 15 seconds, and where you are: intro, groove, build, drop, break, outro
the gridThe loop that plays now. Click a cell to add a hit, click a name to mute it
autoOn: it plays the whole set and moves on by itself. Off: it stays on one part
moodsad, mysterious or dark: the scale, the tempo and the energy in one click. The bpm sits beside it
+ new songType any name, or press a number: one of your recent chats or a classic
editThe 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

🗣️ It talks

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.

🧬 Your work is the playlist

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.)

🔗 Share a track

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.

🔊 How the sound is made

Every sound is math in plain JavaScript (hooks/engine.js). Nothing is sampled or downloaded.

SoundHow it's made
kickA sine wave that falls in pitch, with a click on top
bassA sine sub under two detuned saws, through a filter
hatsFiltered noise
padsDark 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 seeDo this
The band says the player did not startRun node -v. If it's missing or older than 20, install Node and press play again
/techno does not existOpen 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 SpotifymacOS gives them to the app that played last. Press play in the deck once
Still stuckOpen 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
FileWhat it is
hooks/engine.jsThe synth, the set plan, transitions, share codes
hooks/conductor.jsWhat each loop plays, and how auto moves on
hooks/register.jsThe chat side: /techno, the jam tool, the remote control
hooks/views.jsThe deck, the edit card, favorites
hooks/words.jsWhich words of a prompt become a track
player/techno.mjsThe background player
player/TechnoPlayer.swiftThe 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.

Source 7 files
hooks/register.js 483 lines
1// 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}
483
hooks/engine.js 1458 lines
1// 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 * 120
hooks/views.js 350 lines
1// 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}
350
hooks/words.js 51 lines
1// 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}
51
hooks/classics.js 22 lines
1// 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}
22
hooks/grid.client.js 113 lines
1// 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}
113
hooks/conductor.js 147 lines
1// 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