@brett_lamy/docstream 1.10.0 → 1.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/README.md +25 -1
  2. package/dist/docs/DocsRenderer.d.ts.map +1 -1
  3. package/dist/docs/DocsRenderer.js +2 -1
  4. package/dist/docs/DocsRenderer.js.map +1 -1
  5. package/dist/index.d.ts +3 -2
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +2 -1
  8. package/dist/index.js.map +1 -1
  9. package/dist/strudel/StepGrid.d.ts +10 -0
  10. package/dist/strudel/StepGrid.d.ts.map +1 -0
  11. package/dist/strudel/StepGrid.js +78 -0
  12. package/dist/strudel/StepGrid.js.map +1 -0
  13. package/dist/strudel/StrudelPlayer.d.ts +7 -1
  14. package/dist/strudel/StrudelPlayer.d.ts.map +1 -1
  15. package/dist/strudel/StrudelPlayer.js +37 -8
  16. package/dist/strudel/StrudelPlayer.js.map +1 -1
  17. package/dist/strudel/StrudelRack.d.ts +24 -0
  18. package/dist/strudel/StrudelRack.d.ts.map +1 -0
  19. package/dist/strudel/StrudelRack.js +123 -0
  20. package/dist/strudel/StrudelRack.js.map +1 -0
  21. package/dist/strudel/ast/edit.d.ts +41 -0
  22. package/dist/strudel/ast/edit.d.ts.map +1 -0
  23. package/dist/strudel/ast/edit.js +78 -0
  24. package/dist/strudel/ast/edit.js.map +1 -0
  25. package/dist/strudel/ast/index.d.ts +7 -0
  26. package/dist/strudel/ast/index.d.ts.map +1 -0
  27. package/dist/strudel/ast/index.js +4 -0
  28. package/dist/strudel/ast/index.js.map +1 -0
  29. package/dist/strudel/ast/mini.d.ts +86 -0
  30. package/dist/strudel/ast/mini.d.ts.map +1 -0
  31. package/dist/strudel/ast/mini.js +269 -0
  32. package/dist/strudel/ast/mini.js.map +1 -0
  33. package/dist/strudel/ast/program.d.ts +93 -0
  34. package/dist/strudel/ast/program.d.ts.map +1 -0
  35. package/dist/strudel/ast/program.js +288 -0
  36. package/dist/strudel/ast/program.js.map +1 -0
  37. package/dist/strudel/controls.d.ts +21 -0
  38. package/dist/strudel/controls.d.ts.map +1 -0
  39. package/dist/strudel/controls.js +115 -0
  40. package/dist/strudel/controls.js.map +1 -0
  41. package/dist/strudel/engine.d.ts +5 -0
  42. package/dist/strudel/engine.d.ts.map +1 -1
  43. package/dist/strudel/engine.js +14 -0
  44. package/dist/strudel/engine.js.map +1 -1
  45. package/dist/strudel/index.d.ts +9 -1
  46. package/dist/strudel/index.d.ts.map +1 -1
  47. package/dist/strudel/index.js +5 -1
  48. package/dist/strudel/index.js.map +1 -1
  49. package/dist/styles.css +598 -0
  50. package/package.json +2 -1
  51. package/src/docs/DocsRenderer.tsx +2 -1
  52. package/src/index.ts +3 -2
  53. package/src/strudel/StepGrid.tsx +130 -0
  54. package/src/strudel/StrudelPlayer.tsx +79 -8
  55. package/src/strudel/StrudelRack.tsx +322 -0
  56. package/src/strudel/ast/edit.ts +95 -0
  57. package/src/strudel/ast/index.ts +19 -0
  58. package/src/strudel/ast/mini.ts +319 -0
  59. package/src/strudel/ast/program.ts +348 -0
  60. package/src/strudel/controls.tsx +182 -0
  61. package/src/strudel/engine.ts +14 -0
  62. package/src/strudel/index.ts +17 -1
  63. package/src/styles.css +598 -0
@@ -0,0 +1,130 @@
1
+ /* Mini-notation drawn as a step sequencer: sequences are rows whose steps share the width by weight, `[ ]`
2
+ nests a row inside a step, `< >` shows its one-per-cycle choices, `,` stacks rows. Click a step to toggle
3
+ it with a rest; double-click to type a new value. Operators show as badges (×2, (3,8), ?, !3). */
4
+ import { useState, type ReactNode } from "react"
5
+ import { miniAtoms, type MiniAtom, type MiniGroup, type MiniOp, type MiniSequence, type MiniStep } from "./ast/mini"
6
+
7
+ export interface StepGridProps {
8
+ mini: MiniGroup
9
+ /** Called with the atom and its new text. */
10
+ onAtomChange: (atom: MiniAtom, text: string) => void
11
+ /** Accessible name for the grid. */
12
+ label?: string
13
+ }
14
+
15
+ function stepWeight(step: MiniStep): number {
16
+ let w = 1
17
+ for (const op of step.ops) {
18
+ if (op.op === "weight") w += (op.amount ?? 2) - 1
19
+ if (op.op === "replicate") w += (op.count ?? 2) - 1
20
+ }
21
+ return w
22
+ }
23
+
24
+ const text = (s: MiniStep | null): string => (s && s.slice.type === "atom" ? s.slice.value : "…")
25
+
26
+ function badge(op: MiniOp): string | null {
27
+ switch (op.op) {
28
+ case "fast":
29
+ return `×${text(op.amount)}`
30
+ case "slow":
31
+ return `÷${text(op.amount)}`
32
+ case "replicate":
33
+ return `!${op.count ?? 2}`
34
+ case "degrade":
35
+ return op.amount === null ? "?" : `?${op.amount}`
36
+ case "euclid":
37
+ return `(${text(op.pulses)},${text(op.steps)}${op.rotation ? `,${text(op.rotation)}` : ""})`
38
+ case "tail":
39
+ return `:${text(op.value)}`
40
+ case "range":
41
+ return `..${text(op.to)}`
42
+ case "weight":
43
+ return null
44
+ }
45
+ }
46
+
47
+ function AtomCell({ atom, fallback, onAtomChange }: { atom: MiniAtom; fallback: string; onAtomChange: StepGridProps["onAtomChange"] }) {
48
+ const [editing, setEditing] = useState(false)
49
+ if (editing) {
50
+ return (
51
+ <input
52
+ className="strudel-step__input"
53
+ defaultValue={atom.value}
54
+ aria-label="Step value"
55
+ autoFocus
56
+ onBlur={(e) => {
57
+ setEditing(false)
58
+ const v = e.currentTarget.value.trim()
59
+ if (v && v !== atom.value && /^[\p{L}\p{Nl}0-9~\-#.^_]+$/u.test(v)) onAtomChange(atom, v)
60
+ }}
61
+ onKeyDown={(e) => {
62
+ if (e.key === "Enter") e.currentTarget.blur()
63
+ if (e.key === "Escape") setEditing(false)
64
+ }}
65
+ />
66
+ )
67
+ }
68
+ return (
69
+ <button
70
+ type="button"
71
+ className="strudel-step__cell"
72
+ data-rest={atom.rest || undefined}
73
+ aria-pressed={!atom.rest}
74
+ aria-label={atom.rest ? "Rest" : atom.value}
75
+ title={atom.rest ? "Rest — click to fill, double-click to type" : `${atom.value} — click to rest, double-click to type`}
76
+ onClick={() => onAtomChange(atom, atom.rest ? fallback : "~")}
77
+ onDoubleClick={() => setEditing(true)}
78
+ >
79
+ {atom.rest ? "" : atom.value}
80
+ </button>
81
+ )
82
+ }
83
+
84
+ export function StepGrid({ mini, onAtomChange, label }: StepGridProps) {
85
+ // A rest that's filled back in becomes the row's most common sound/value.
86
+ const counts = new Map<string, number>()
87
+ for (const a of miniAtoms(mini, { ops: false })) if (!a.rest) counts.set(a.value, (counts.get(a.value) ?? 0) + 1)
88
+ const fallback = [...counts.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] ?? "x"
89
+
90
+ const row = (seq: MiniSequence, key: number | string): ReactNode => (
91
+ <div key={key} className="strudel-step__row">
92
+ {seq.steps.map((s, i) => step(s, i))}
93
+ </div>
94
+ )
95
+
96
+ const group = (g: MiniGroup): ReactNode =>
97
+ g.type === "sequence" ? (
98
+ row(g, 0)
99
+ ) : (
100
+ <div className="strudel-step__layers" data-kind={g.type}>
101
+ {g.layers.map((l, i) => row(l, i))}
102
+ </div>
103
+ )
104
+
105
+ const step = (s: MiniStep, key: number): ReactNode => {
106
+ const badges = s.ops.map(badge).filter((b): b is string => !!b)
107
+ const slice = s.slice
108
+ let body: ReactNode
109
+ if (slice.type === "atom") body = <AtomCell atom={slice} fallback={fallback} onAtomChange={onAtomChange} />
110
+ else if (slice.type === "subcycle") body = <div className="strudel-step__nest">{group(slice.body)}</div>
111
+ else
112
+ body = (
113
+ <div className="strudel-step__nest" data-kind={slice.type} title={slice.type === "alternation" ? "One per cycle" : "Polymeter"}>
114
+ <div className="strudel-step__layers">{slice.layers.map((l, i) => row(l, i))}</div>
115
+ </div>
116
+ )
117
+ return (
118
+ <div key={key} className="strudel-step" style={{ flexGrow: stepWeight(s) }}>
119
+ {body}
120
+ {badges.length > 0 && <span className="strudel-step__badge">{badges.join(" ")}</span>}
121
+ </div>
122
+ )
123
+ }
124
+
125
+ return (
126
+ <div className="strudel-steps" role="group" aria-label={label ?? "Steps"}>
127
+ {group(mini)}
128
+ </div>
129
+ )
130
+ }
@@ -1,5 +1,5 @@
1
- import { useEffect, useId, useRef, useState, useSyncExternalStore } from "react"
2
- import { Code2, Loader2, Pause, Play } from "lucide-react"
1
+ import { lazy, Suspense, useEffect, useId, useRef, useState, useSyncExternalStore } from "react"
2
+ import { Code2, Loader2, Pause, Play, SlidersHorizontal, Undo2 } from "lucide-react"
3
3
  import { HighlightedCode } from "../docs/HighlightedCode"
4
4
  import {
5
5
  DEFAULT_SAMPLES,
@@ -8,10 +8,14 @@ import {
8
8
  stopStrudel,
9
9
  strudelNow,
10
10
  subscribeStrudel,
11
+ updateStrudel,
11
12
  type StrudelHap,
12
13
  type StrudelPattern,
13
14
  } from "./engine"
14
15
 
16
+ // The rack parses the code (acorn + the mini-notation parser); it loads the first time someone opens it.
17
+ const StrudelRack = lazy(() => import("./StrudelRack"))
18
+
15
19
  export interface StrudelPlayerProps {
16
20
  /** Strudel pattern code (what you would type at strudel.cc). */
17
21
  code?: string
@@ -23,6 +27,12 @@ export interface StrudelPlayerProps {
23
27
  showCode?: boolean
24
28
  /** Sample banks to load before the first play. Defaults to the Tidal dirt-samples. */
25
29
  samples?: readonly string[]
30
+ /** Offer the equipment rack (generated from the code) behind a toggle. Defaults to true. */
31
+ rack?: boolean
32
+ /** Open the rack from the start. */
33
+ showRack?: boolean
34
+ /** Called with the code after every rack edit. */
35
+ onCodeChange?: (code: string) => void
26
36
  }
27
37
 
28
38
  type Source = { kind: "loading" } | { kind: "error"; message: string } | { kind: "ready"; code: string }
@@ -106,10 +116,24 @@ function drawRoll(canvas: HTMLCanvasElement, pattern: StrudelPattern | null, now
106
116
  }
107
117
 
108
118
  /** A live-coded music player: Strudel (strudel.cc) runs the pattern in this page, no iframe. */
109
- export function StrudelPlayer({ code, src, title, className, showCode = false, samples = DEFAULT_SAMPLES }: StrudelPlayerProps) {
119
+ export function StrudelPlayer({
120
+ code,
121
+ src,
122
+ title,
123
+ className,
124
+ showCode = false,
125
+ samples = DEFAULT_SAMPLES,
126
+ rack = true,
127
+ showRack = false,
128
+ onCodeChange,
129
+ }: StrudelPlayerProps) {
110
130
  const id = useId()
111
131
  const [source, setSource] = useState<Source>(() => (code !== undefined ? { kind: "ready", code } : { kind: "loading" }))
112
132
  const [codeOpen, setCodeOpen] = useState(showCode)
133
+ const [rackOpen, setRackOpen] = useState(showRack)
134
+ // The rack edits a working copy; `playable` is that copy with the rack's mutes and solos applied.
135
+ const [edited, setEdited] = useState<string | null>(null)
136
+ const [playable, setPlayable] = useState<string | null>(null)
113
137
  const [error, setError] = useState<string | null>(null)
114
138
  const [cycle, setCycle] = useState(0)
115
139
  const pattern = useRef<StrudelPattern | null>(null)
@@ -142,7 +166,33 @@ export function StrudelPlayer({ code, src, title, className, showCode = false, s
142
166
 
143
167
  // Stop our pattern when the player unmounts or its code changes underneath it.
144
168
  const ready = source.kind === "ready" ? source.code : null
145
- useEffect(() => () => void stopStrudel(id), [id, ready])
169
+ useEffect(() => {
170
+ setEdited(null)
171
+ setPlayable(null)
172
+ return () => void stopStrudel(id)
173
+ }, [id, ready])
174
+ const current = edited ?? ready
175
+ const toPlay = playable ?? current
176
+
177
+ // Live edits: while playing, swap in the new pattern without stopping.
178
+ useEffect(() => {
179
+ if (status !== "playing" || !toPlay) return
180
+ const timer = setTimeout(() => {
181
+ updateStrudel(id, toPlay).then(
182
+ (p) => {
183
+ if (p) pattern.current = p
184
+ setError(null)
185
+ },
186
+ (err: unknown) => setError(err instanceof Error ? err.message : String(err)),
187
+ )
188
+ }, 120)
189
+ return () => clearTimeout(timer)
190
+ }, [toPlay])
191
+
192
+ const edit = (next: string) => {
193
+ setEdited(next)
194
+ onCodeChange?.(next)
195
+ }
146
196
 
147
197
  useEffect(() => {
148
198
  const canvas = canvasRef.current
@@ -167,14 +217,14 @@ export function StrudelPlayer({ code, src, title, className, showCode = false, s
167
217
  }, [status])
168
218
 
169
219
  const toggle = async () => {
170
- if (source.kind !== "ready") return
220
+ if (!toPlay) return
171
221
  if (status !== "idle") {
172
222
  await stopStrudel(id)
173
223
  return
174
224
  }
175
225
  setError(null)
176
226
  try {
177
- pattern.current = await playStrudel(id, source.code, samples)
227
+ pattern.current = await playStrudel(id, toPlay, samples)
178
228
  } catch (err) {
179
229
  if (err instanceof Error && err.message === "superseded") return
180
230
  setError(err instanceof Error ? err.message : String(err))
@@ -218,6 +268,17 @@ export function StrudelPlayer({ code, src, title, className, showCode = false, s
218
268
  </span>
219
269
  </div>
220
270
  <canvas ref={canvasRef} className="docstream-strudel__roll" aria-hidden="true" />
271
+ {source.kind === "ready" && rack && (
272
+ <button
273
+ type="button"
274
+ className="docstream-strudel__toggle"
275
+ aria-expanded={rackOpen}
276
+ aria-label={rackOpen ? "Hide equipment" : "Show equipment"}
277
+ onClick={() => setRackOpen((open) => !open)}
278
+ >
279
+ <SlidersHorizontal size={16} />
280
+ </button>
281
+ )}
221
282
  {source.kind === "ready" && (
222
283
  <button
223
284
  type="button"
@@ -236,9 +297,19 @@ export function StrudelPlayer({ code, src, title, className, showCode = false, s
236
297
  {error}
237
298
  </div>
238
299
  )}
239
- {source.kind === "ready" && codeOpen && (
300
+ {current !== null && rack && rackOpen && (
301
+ <Suspense fallback={<div className="docstream-strudel__sub">Loading equipment…</div>}>
302
+ {edited !== null && edited !== ready && (
303
+ <button type="button" className="docstream-strudel__revert" onClick={() => edit(ready!)}>
304
+ <Undo2 size={13} aria-hidden="true" /> Revert edits
305
+ </button>
306
+ )}
307
+ <StrudelRack code={current} onChange={edit} onPlayableChange={setPlayable} />
308
+ </Suspense>
309
+ )}
310
+ {current !== null && codeOpen && (
240
311
  <div id={codeId} className="docstream-strudel__code">
241
- <HighlightedCode code={source.code.trim()} language="javascript" />
312
+ <HighlightedCode code={current.trim()} language="javascript" />
242
313
  </div>
243
314
  )}
244
315
  </figure>
@@ -0,0 +1,322 @@
1
+ /* The rack: equipment generated from a Strudel song's syntax tree. Every track becomes a device — mute/solo,
2
+ a gain fader, its sound and scale, a step grid for its pattern, and a knob for each effect it uses (plus any
3
+ you add) — and the song gets a master strip with the tempo. Every control edits the code in place (edit.ts),
4
+ so the code stays the source of truth: what you turn is what you'd have typed. */
5
+ import { useEffect, useMemo, useState } from "react"
6
+ import { AudioWaveform, Drum, KeyboardMusic, Plus, X } from "lucide-react"
7
+ import { miniAtoms, type MiniAtom } from "./ast/mini"
8
+ import { findCall, parseStrudel, StrudelSyntaxError, type StrudelArg, type StrudelCall, type StrudelSong, type StrudelTrack } from "./ast/program"
9
+ import { formatNumber, isAudible, mixCode, removeMethod, setMethod, setMiniAtom, setMiniSource, setTempo, type StrudelMix } from "./ast/edit"
10
+ import { Fader, Knob, defaultFormat } from "./controls"
11
+ import { StepGrid } from "./StepGrid"
12
+
13
+ export interface StrudelRackProps {
14
+ code: string
15
+ onChange: (code: string) => void
16
+ /** The code to play for the current mute/solo mix (the written code with silenced tracks). */
17
+ onPlayableChange?: (code: string) => void
18
+ className?: string
19
+ }
20
+
21
+ interface Param {
22
+ label: string
23
+ min: number
24
+ max: number
25
+ default: number
26
+ log?: boolean
27
+ format?: (v: number) => string
28
+ }
29
+
30
+ const hz = (v: number) => (v >= 1000 ? `${+(v / 1000).toFixed(1)}k` : v.toFixed(0))
31
+ const pct = (v: number) => `${Math.round(v * 100)}`
32
+
33
+ /** Effects the rack knows how to draw, in display order. Others still parse; they just get no knob. */
34
+ export const STRUDEL_PARAMS: Record<string, Param> = {
35
+ lpf: { label: "Cutoff", min: 40, max: 16000, default: 1200, log: true, format: hz },
36
+ cutoff: { label: "Cutoff", min: 40, max: 16000, default: 1200, log: true, format: hz },
37
+ lpq: { label: "Reso", min: 0, max: 30, default: 5 },
38
+ hpf: { label: "Low cut", min: 20, max: 8000, default: 200, log: true, format: hz },
39
+ attack: { label: "Attack", min: 0, max: 2, default: 0.01 },
40
+ decay: { label: "Decay", min: 0, max: 2, default: 0.2 },
41
+ sustain: { label: "Sustain", min: 0, max: 1, default: 0.6, format: pct },
42
+ release: { label: "Release", min: 0, max: 4, default: 0.3 },
43
+ vib: { label: "Vibrato", min: 0, max: 16, default: 4 },
44
+ vibmod: { label: "Vib depth", min: 0, max: 2, default: 0.2 },
45
+ shape: { label: "Shape", min: 0, max: 1, default: 0.3, format: pct },
46
+ distort: { label: "Drive", min: 0, max: 5, default: 0.5 },
47
+ crush: { label: "Crush", min: 1, max: 16, default: 4 },
48
+ coarse: { label: "Coarse", min: 1, max: 32, default: 4 },
49
+ speed: { label: "Speed", min: -2, max: 4, default: 1 },
50
+ pan: { label: "Pan", min: 0, max: 1, default: 0.5, format: (v) => (Math.abs(v - 0.5) < 0.02 ? "C" : v < 0.5 ? `L${Math.round((0.5 - v) * 200)}` : `R${Math.round((v - 0.5) * 200)}`) },
51
+ room: { label: "Reverb", min: 0, max: 1, default: 0.4, format: pct },
52
+ size: { label: "Size", min: 0, max: 8, default: 2 },
53
+ delay: { label: "Delay", min: 0, max: 1, default: 0.3, format: pct },
54
+ delaytime: { label: "Time", min: 0.01, max: 1, default: 0.25, log: true },
55
+ delayfeedback: { label: "Feedback", min: 0, max: 0.95, default: 0.5, format: pct },
56
+ }
57
+
58
+ const SYNTHS = ["sine", "triangle", "square", "sawtooth", "supersaw", "pulse", "white", "pink", "brown"]
59
+ const NOTES = ["C", "C#", "Db", "D", "D#", "Eb", "E", "F", "F#", "Gb", "G", "G#", "Ab", "A", "A#", "Bb", "B"]
60
+ const MODES = ["major", "minor", "dorian", "phrygian", "lydian", "mixolydian", "aeolian", "locrian", "major pentatonic", "minor pentatonic", "chromatic", "whole tone", "harmonic minor", "melodic minor", "blues"]
61
+
62
+ type Mini = Extract<StrudelArg, { kind: "mini" }>
63
+
64
+ const firstNumber = (call: StrudelCall | null) => (call?.args[0]?.kind === "number" ? call.args[0].value : null)
65
+ const staticMini = (call: StrudelCall | null): Mini | null => {
66
+ const a = call?.args[0]
67
+ return a?.kind === "mini" && a.mini ? a : null
68
+ }
69
+
70
+ /** A name for a track: its label, the sounds of a drum row, or its role and synth. */
71
+ export function trackName(track: StrudelTrack): string {
72
+ if (track.label) return track.label
73
+ const head = "args" in track.chain.head ? track.chain.head : null
74
+ if (track.kind === "drums") {
75
+ const mini = staticMini(findCall(track.chain, "s") ?? findCall(track.chain, "sound"))
76
+ const names = mini ? [...new Set(miniAtoms(mini.mini!, { ops: false }).filter((a) => !a.rest).map((a) => a.value.split(":")[0]!))] : []
77
+ return names.length ? names.slice(0, 4).join(" · ") : "Drums"
78
+ }
79
+ const scale = staticMini(findCall(track.chain, "scale"))
80
+ const octave = Number(/(\d)/.exec(scale?.source ?? "")?.[1] ?? NaN)
81
+ const chords = staticMini(head)?.source.includes(",")
82
+ const role = chords ? "Chords" : octave <= 2 ? "Bass" : octave >= 4 ? "Lead" : head?.name === "note" || head?.name === "n" ? "Keys" : "Synth"
83
+ return track.sound ? `${role} · ${track.sound}` : role
84
+ }
85
+
86
+ function Device({
87
+ song,
88
+ track,
89
+ mix,
90
+ onMix,
91
+ onChange,
92
+ }: {
93
+ song: StrudelSong
94
+ track: StrudelTrack
95
+ mix: StrudelMix
96
+ onMix: (kind: "mute" | "solo") => void
97
+ onChange: (code: string) => void
98
+ }) {
99
+ const { code } = song
100
+ const chain = track.chain
101
+ const head = "args" in chain.head ? chain.head : null
102
+ const pattern = staticMini(head) ?? (chain.head.name === "" && "literal" in chain.head && chain.head.literal?.kind === "mini" ? chain.head.literal : null)
103
+ const soundCall = findCall(chain, "s") ?? findCall(chain, "sound")
104
+ const sound = staticMini(soundCall)
105
+ const soundAtoms = sound ? miniAtoms(sound.mini!, { ops: false }) : []
106
+ const synth = soundAtoms.length === 1 && SYNTHS.includes(soundAtoms[0]!.value) ? soundAtoms[0]! : null
107
+ const scale = staticMini(findCall(chain, "scale"))
108
+ const scaleMatch = scale ? /^([A-Ga-g][#b]?)(\d*):(.+)$/.exec(scale.source) : null
109
+ const gainCall = findCall(chain, "gain")
110
+ const gain = firstNumber(gainCall)
111
+ const present = Object.keys(STRUDEL_PARAMS).filter((p) => findCall(chain, p))
112
+ const missing = Object.keys(STRUDEL_PARAMS).filter((p) => !present.includes(p) && p !== "cutoff")
113
+ const audible = isAudible(track, mix)
114
+ const name = trackName(track)
115
+ const Icon = track.kind === "drums" ? Drum : track.kind === "melodic" ? KeyboardMusic : AudioWaveform
116
+
117
+ const set = (method: string, value: number) => onChange(setMethod(code, chain, method, formatNumber(value)))
118
+ const setAtom = (mini: Mini) => (atom: MiniAtom, text: string) => onChange(setMiniAtom(code, mini, atom, text))
119
+
120
+ return (
121
+ <section className="strudel-device" data-kind={track.kind} data-audible={audible} aria-label={name}>
122
+ <header className="strudel-device__head">
123
+ <span className="strudel-device__led" aria-hidden="true" />
124
+ <Icon size={14} aria-hidden="true" />
125
+ <span className="strudel-device__name" title={code.slice(track.start, track.end)}>
126
+ {name}
127
+ </span>
128
+ <button type="button" className="strudel-device__toggle" data-kind="mute" aria-pressed={mix.muted?.has(track.index) || track.muted} onClick={() => onMix("mute")} title="Mute">
129
+ M
130
+ </button>
131
+ <button type="button" className="strudel-device__toggle" data-kind="solo" aria-pressed={!!mix.solo?.has(track.index)} onClick={() => onMix("solo")} title="Solo">
132
+ S
133
+ </button>
134
+ </header>
135
+ <div className="strudel-device__body">
136
+ <Fader
137
+ label="Gain"
138
+ value={gain ?? 1}
139
+ min={0}
140
+ max={1.5}
141
+ defaultValue={1}
142
+ disabled={!!gainCall && gain === null}
143
+ {...(gainCall && gain === null ? { display: "∿", title: "Modulated by a pattern" } : {})}
144
+ format={pct}
145
+ onChange={(v) => set("gain", v)}
146
+ />
147
+ <div className="strudel-device__main">
148
+ {(synth || scaleMatch) && (
149
+ <div className="strudel-device__selects strudel-lcd">
150
+ {synth && (
151
+ <label>
152
+ <span>Sound</span>
153
+ <select value={synth.value} onChange={(e) => onChange(setMiniAtom(code, sound!, synth, e.target.value))}>
154
+ {SYNTHS.map((s) => (
155
+ <option key={s}>{s}</option>
156
+ ))}
157
+ </select>
158
+ </label>
159
+ )}
160
+ {scaleMatch && (
161
+ <label>
162
+ <span>Key</span>
163
+ <select
164
+ value={scaleMatch[1]!.toUpperCase().replace(/^([A-G])B$/, "$1b")}
165
+ onChange={(e) => onChange(setMiniSource(code, scale!, `${e.target.value}${scaleMatch[2]}:${scaleMatch[3]}`))}
166
+ >
167
+ {NOTES.map((n) => (
168
+ <option key={n}>{n}</option>
169
+ ))}
170
+ </select>
171
+ <select value={scaleMatch[3]} onChange={(e) => onChange(setMiniSource(code, scale!, `${scaleMatch[1]}${scaleMatch[2]}:${e.target.value}`))}>
172
+ {(MODES.includes(scaleMatch[3]!) ? MODES : [scaleMatch[3]!, ...MODES]).map((m) => (
173
+ <option key={m}>{m}</option>
174
+ ))}
175
+ </select>
176
+ </label>
177
+ )}
178
+ </div>
179
+ )}
180
+ {pattern?.mini && <StepGrid mini={pattern.mini} onAtomChange={setAtom(pattern)} label={`${name} steps`} />}
181
+ {!pattern && sound?.mini && !synth && <StepGrid mini={sound.mini} onAtomChange={setAtom(sound)} label={`${name} sounds`} />}
182
+ {pattern?.error && <p className="strudel-device__error">{pattern.error.message}</p>}
183
+ <div className="strudel-device__knobs">
184
+ {present.map((p) => {
185
+ const call = findCall(chain, p)!
186
+ const value = firstNumber(call)
187
+ const spec = STRUDEL_PARAMS[p]!
188
+ return (
189
+ <div key={p} className="strudel-device__knob">
190
+ <Knob
191
+ label={spec.label}
192
+ value={value ?? spec.default}
193
+ min={spec.min}
194
+ max={spec.max}
195
+ {...(spec.log ? { log: true } : {})}
196
+ defaultValue={spec.default}
197
+ format={spec.format ?? defaultFormat}
198
+ disabled={value === null}
199
+ {...(value === null ? { display: "∿", title: `${p} is modulated: ${code.slice(call.argsRange.start, call.argsRange.end)}` } : {})}
200
+ onChange={(v) => set(p, v)}
201
+ />
202
+ <button type="button" className="strudel-device__remove" aria-label={`Remove ${spec.label}`} onClick={() => onChange(removeMethod(code, chain, p))}>
203
+ <X size={10} />
204
+ </button>
205
+ </div>
206
+ )
207
+ })}
208
+ {missing.length > 0 && (
209
+ <label className="strudel-device__add" title="Add an effect">
210
+ <Plus size={14} aria-hidden="true" />
211
+ <select
212
+ aria-label={`Add an effect to ${name}`}
213
+ value=""
214
+ onChange={(e) => e.target.value && set(e.target.value, STRUDEL_PARAMS[e.target.value]!.default)}
215
+ >
216
+ <option value="">Add…</option>
217
+ {missing.map((p) => (
218
+ <option key={p} value={p}>
219
+ {STRUDEL_PARAMS[p]!.label} ({p})
220
+ </option>
221
+ ))}
222
+ </select>
223
+ </label>
224
+ )}
225
+ </div>
226
+ </div>
227
+ </div>
228
+ </section>
229
+ )
230
+ }
231
+
232
+ /** Equipment generated from Strudel code. Controlled: `code` in, edited code out through `onChange`. */
233
+ export function StrudelRack({ code, onChange, onPlayableChange, className }: StrudelRackProps) {
234
+ const parsed = useMemo((): { song: StrudelSong } | { error: string } => {
235
+ try {
236
+ return { song: parseStrudel(code) }
237
+ } catch (err) {
238
+ return { error: err instanceof StrudelSyntaxError ? `Syntax error: ${err.message}` : String(err) }
239
+ }
240
+ }, [code])
241
+ const [muted, setMuted] = useState<ReadonlySet<number>>(() => new Set())
242
+ const [solo, setSolo] = useState<ReadonlySet<number>>(() => new Set())
243
+ const song = "song" in parsed ? parsed.song : null
244
+ const mix = useMemo(() => ({ muted, solo }), [muted, solo])
245
+
246
+ useEffect(() => {
247
+ if (song) onPlayableChange?.(mixCode(song, mix))
248
+ }, [song, mix])
249
+
250
+ if (!song) {
251
+ return (
252
+ <div className={`strudel-rack ${className ?? ""}`.trim()}>
253
+ <p className="strudel-rack__error" role="alert">
254
+ {"error" in parsed ? parsed.error : ""}
255
+ </p>
256
+ </div>
257
+ )
258
+ }
259
+
260
+ const toggle = (set: ReadonlySet<number>, i: number) => {
261
+ const next = new Set(set)
262
+ if (!next.delete(i)) next.add(i)
263
+ return next
264
+ }
265
+ const cps = song.tempo?.cps
266
+ const master = song.masterChain
267
+ const postgain = master ? firstNumber(findCall(master, "postgain")) : null
268
+
269
+ return (
270
+ <div className={`strudel-rack ${className ?? ""}`.trim()}>
271
+ <div className="strudel-rack__master" role="group" aria-label="Master">
272
+ <span className="strudel-rack__title">
273
+ Master
274
+ <small>Strudel</small>
275
+ </span>
276
+ <output className="strudel-lcd strudel-lcd--bpm" aria-label="Tempo">
277
+ {Math.round((cps && Number.isFinite(cps) ? cps : 0.5) * 2400) / 10}
278
+ <small>BPM</small>
279
+ </output>
280
+ <Knob
281
+ size="lg"
282
+ label="Tempo"
283
+ value={cps && Number.isFinite(cps) ? cps : 0.5}
284
+ min={0.1}
285
+ max={2}
286
+ defaultValue={0.5}
287
+ format={(v) => `${Math.round(v * 240)} bpm`}
288
+ onChange={(v) => onChange(setTempo(song, v))}
289
+ />
290
+ {master && (
291
+ <Knob
292
+ size="lg"
293
+ label="Output"
294
+ value={postgain ?? 1}
295
+ min={0}
296
+ max={2}
297
+ defaultValue={1}
298
+ format={pct}
299
+ onChange={(v) => onChange(setMethod(code, master, "postgain", formatNumber(v)))}
300
+ />
301
+ )}
302
+ <span className="strudel-rack__summary">
303
+ {song.tracks.length} track{song.tracks.length === 1 ? "" : "s"}
304
+ {song.layout === "stack" ? " · stack" : song.layout === "labels" ? " · $:" : ""}
305
+ </span>
306
+ </div>
307
+ {song.tracks.length === 0 && <p className="strudel-rack__error">No pattern found.</p>}
308
+ {song.tracks.map((t) => (
309
+ <Device
310
+ key={t.index}
311
+ song={song}
312
+ track={t}
313
+ mix={mix}
314
+ onChange={onChange}
315
+ onMix={(kind) => (kind === "mute" ? setMuted(toggle(muted, t.index)) : setSolo(toggle(solo, t.index)))}
316
+ />
317
+ ))}
318
+ </div>
319
+ )
320
+ }
321
+
322
+ export default StrudelRack