@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,348 @@
1
+ /* A Strudel program as a song: the JavaScript is parsed with acorn (the parser Strudel's own transpiler uses) and
2
+ read the way the REPL runs it — `setcps`/`setcpm` set the tempo, `$:` labels or a top-level `stack(...)` hold
3
+ the tracks, and each track is a method chain (`n("0 2").scale("C:minor").s("sawtooth").lpf(800)`) whose string
4
+ arguments are parsed as mini-notation. Every node keeps its source range for exact edits (see edit.ts). */
5
+ import { parse as parseJs, type Node as AcornNode } from "acorn"
6
+ import { MiniSyntaxError, miniAtoms, parseMini, type MiniGroup, type Range } from "./mini"
7
+
8
+ export type { Range }
9
+
10
+ interface EsNode extends Range {
11
+ type: string
12
+ [key: string]: unknown
13
+ }
14
+
15
+ export type StrudelArg = Range &
16
+ (
17
+ | { kind: "number"; value: number }
18
+ /** A double-quoted or backtick string: mini-notation. `mini` is null when it doesn't parse (see `error`). */
19
+ | { kind: "mini"; source: string; contentStart: number; mini: MiniGroup | null; error: MiniSyntaxError | null }
20
+ /** A single-quoted string: plain JavaScript text, as in the Strudel REPL. */
21
+ | { kind: "string"; value: string }
22
+ /** A nested pattern or signal expression, e.g. `sine.range(200, 800).slow(4)`. */
23
+ | { kind: "pattern"; chain: StrudelChain }
24
+ | { kind: "function"; text: string }
25
+ | { kind: "other"; text: string }
26
+ )
27
+
28
+ export interface StrudelCall extends Range {
29
+ name: string
30
+ nameRange: Range
31
+ args: StrudelArg[]
32
+ /** Between the parentheses (empty for a property access such as `.p1`). */
33
+ argsRange: Range
34
+ /** `.name(args)` including the dot: delete it to remove the method from its chain. */
35
+ methodRange: Range
36
+ }
37
+
38
+ export interface StrudelChain extends Range {
39
+ /** The call or identifier the chain starts from (`s("bd")`, `sine`, `"c e"`). */
40
+ head: StrudelCall | (Range & { name: string; literal?: StrudelArg })
41
+ methods: StrudelCall[]
42
+ }
43
+
44
+ export type TrackKind = "drums" | "melodic" | "other"
45
+
46
+ export interface StrudelTrack extends Range {
47
+ index: number
48
+ /** The `$:`/`name:` label, if the track has one (`$` labels count as unnamed). */
49
+ label: string | null
50
+ /** A `_$:` label: Strudel's own mute. */
51
+ muted: boolean
52
+ chain: StrudelChain
53
+ kind: TrackKind
54
+ /** The first sound it plays (`s`/`sound`), e.g. "sawtooth" or "bd". */
55
+ sound: string | null
56
+ }
57
+
58
+ export interface StrudelTempo {
59
+ /** Cycles per second. */
60
+ cps: number
61
+ unit: "cps" | "cpm"
62
+ call: StrudelCall
63
+ }
64
+
65
+ export interface StrudelSong {
66
+ code: string
67
+ tempo: StrudelTempo | null
68
+ tracks: StrudelTrack[]
69
+ /** How the tracks are written: `$:` labels, args of one `stack(...)`, or one pattern. */
70
+ layout: "labels" | "stack" | "single" | "empty"
71
+ /** Methods applied to the whole `stack(...)`, e.g. `.postgain(.8)`. */
72
+ master: StrudelCall[]
73
+ masterChain: StrudelChain | null
74
+ /** Every mini-notation string in the program, in source order. */
75
+ minis: Extract<StrudelArg, { kind: "mini" }>[]
76
+ /** The ESTree program from acorn, for tools that need the raw JavaScript tree. */
77
+ program: AcornNode
78
+ }
79
+
80
+ export class StrudelSyntaxError extends Error {
81
+ constructor(message: string, readonly offset: number) {
82
+ super(message)
83
+ this.name = "StrudelSyntaxError"
84
+ }
85
+ }
86
+
87
+ const SYNTHS = new Set(["sine", "triangle", "square", "sawtooth", "saw", "tri", "sin", "sqr", "white", "pink", "brown", "supersaw", "pulse", "noise"])
88
+ const TEMPO = new Set(["setcps", "setCps", "setcpm", "setCpm"])
89
+ const SETUP = new Set(["samples", "soundfont", "loadSoundfont", "initHydra", "aliasBank", "registerSound"])
90
+ const MELODIC_HEADS = new Set(["note", "n", "freq", "chord", "voicing", "arp"])
91
+
92
+ /** Parse Strudel code. Throws `StrudelSyntaxError` when the JavaScript doesn't parse. */
93
+ export function parseStrudel(code: string): StrudelSong {
94
+ let program: EsNode
95
+ try {
96
+ program = parseJs(code, {
97
+ ecmaVersion: "latest",
98
+ sourceType: "module",
99
+ allowAwaitOutsideFunction: true,
100
+ allowReturnOutsideFunction: true,
101
+ }) as unknown as EsNode
102
+ } catch (err) {
103
+ const e = err as { message?: string; pos?: number }
104
+ throw new StrudelSyntaxError(e.message ?? String(err), e.pos ?? 0)
105
+ }
106
+
107
+ const text = (n: Range) => code.slice(n.start, n.end)
108
+ // Keyed by offset: stack arguments are read once as arguments and again as tracks.
109
+ const minis = new Map<number, Extract<StrudelArg, { kind: "mini" }>>()
110
+
111
+ // Constant arithmetic (`120/4`, `-.5`, `(1+2)*3`) counts as a number.
112
+ function constant(node: EsNode): number | null {
113
+ if (node.type === "Literal" && typeof node.value === "number") return node.value
114
+ if (node.type === "UnaryExpression" && (node.operator === "-" || node.operator === "+")) {
115
+ const v = constant(node.argument as EsNode)
116
+ return v === null ? null : node.operator === "-" ? -v : v
117
+ }
118
+ if (node.type === "BinaryExpression") {
119
+ const a = constant(node.left as EsNode)
120
+ const b = constant(node.right as EsNode)
121
+ if (a === null || b === null) return null
122
+ switch (node.operator) {
123
+ case "+": return a + b
124
+ case "-": return a - b
125
+ case "*": return a * b
126
+ case "/": return a / b
127
+ case "%": return a % b
128
+ case "**": return a ** b
129
+ }
130
+ }
131
+ return null
132
+ }
133
+
134
+ function arg(node: EsNode): StrudelArg {
135
+ const range = { start: node.start, end: node.end }
136
+ const value = constant(node)
137
+ if (value !== null) return { kind: "number", value, ...range }
138
+ const raw = node.type === "Literal" && typeof node.value === "string" ? String(node.raw) : null
139
+ const template = node.type === "TemplateLiteral" && !(node.expressions as EsNode[]).length
140
+ if ((raw && raw.startsWith('"')) || template) {
141
+ const source = raw ? (node.value as string) : String(((node.quasis as EsNode[])[0]!.value as { cooked: string }).cooked)
142
+ // Offsets are into the raw literal; escapes would shift them, so only plain strings map 1:1.
143
+ let mini: MiniGroup | null = null
144
+ let error: MiniSyntaxError | null = null
145
+ try {
146
+ mini = parseMini(source)
147
+ } catch (err) {
148
+ if (err instanceof MiniSyntaxError) error = err
149
+ else throw err
150
+ }
151
+ const out = { kind: "mini" as const, source, contentStart: node.start + 1, mini, error, ...range }
152
+ minis.set(node.start, out)
153
+ return out
154
+ }
155
+ if (raw) return { kind: "string", value: node.value as string, ...range }
156
+ if (node.type === "ArrowFunctionExpression" || node.type === "FunctionExpression") return { kind: "function", text: text(node), ...range }
157
+ const c = chain(node)
158
+ if (c) return { kind: "pattern", chain: c, ...range }
159
+ return { kind: "other", text: text(node), ...range }
160
+ }
161
+
162
+ function call(node: EsNode, name: string, nameNode: EsNode, receiverEnd: number): StrudelCall {
163
+ const args = (node.arguments as EsNode[]).map(arg)
164
+ // The text between the parentheses: from just after "(" to just before ")".
165
+ const open = code.indexOf("(", nameNode.end)
166
+ return {
167
+ name,
168
+ nameRange: { start: nameNode.start, end: nameNode.end },
169
+ args,
170
+ argsRange: { start: open + 1, end: node.end - 1 },
171
+ methodRange: { start: receiverEnd, end: node.end },
172
+ start: node.start,
173
+ end: node.end,
174
+ }
175
+ }
176
+
177
+ // Flatten `a(x).b(y).c` into a head and its methods.
178
+ function chain(node: EsNode): StrudelChain | null {
179
+ const methods: StrudelCall[] = []
180
+ let cur = node
181
+ for (;;) {
182
+ if (cur.type === "CallExpression") {
183
+ const callee = cur.callee as EsNode
184
+ if (callee.type === "MemberExpression" && !callee.computed && (callee.property as EsNode).type === "Identifier") {
185
+ const object = callee.object as EsNode
186
+ const property = callee.property as EsNode
187
+ methods.unshift(call(cur, property.name as string, property, object.end))
188
+ cur = object
189
+ continue
190
+ }
191
+ if (callee.type === "Identifier") {
192
+ return { head: call(cur, callee.name as string, callee, cur.start), methods, start: node.start, end: node.end }
193
+ }
194
+ return null
195
+ }
196
+ if (cur.type === "MemberExpression" && !cur.computed && (cur.property as EsNode).type === "Identifier") {
197
+ // A getter in the chain, e.g. `.p1` — a method with no arguments or parentheses.
198
+ const object = cur.object as EsNode
199
+ const property = cur.property as EsNode
200
+ methods.unshift({
201
+ name: property.name as string,
202
+ nameRange: { start: property.start, end: property.end },
203
+ args: [],
204
+ argsRange: { start: cur.end, end: cur.end },
205
+ methodRange: { start: object.end, end: cur.end },
206
+ start: cur.start,
207
+ end: cur.end,
208
+ })
209
+ cur = object
210
+ continue
211
+ }
212
+ if (cur.type === "Identifier") return { head: { name: cur.name as string, start: cur.start, end: cur.end }, methods, start: node.start, end: node.end }
213
+ if ((cur.type === "Literal" && typeof cur.value === "string") || cur.type === "TemplateLiteral") {
214
+ return { head: { name: "", literal: arg(cur), start: cur.start, end: cur.end }, methods, start: node.start, end: node.end }
215
+ }
216
+ return null
217
+ }
218
+ }
219
+
220
+ const calls = (c: StrudelChain): StrudelCall[] => ("args" in c.head ? [c.head, ...c.methods] : c.methods)
221
+
222
+ function soundOf(c: StrudelChain): string | null {
223
+ for (const k of calls(c)) {
224
+ if (k.name !== "s" && k.name !== "sound") continue
225
+ const first = k.args[0]
226
+ if (first?.kind === "mini" && first.mini) {
227
+ const atom = miniAtoms(first.mini, { ops: false }).find((a) => !a.rest)
228
+ if (atom) return atom.value.split(":")[0]!
229
+ }
230
+ if (first?.kind === "string") return first.value
231
+ }
232
+ return null
233
+ }
234
+
235
+ function kindOf(c: StrudelChain, sound: string | null): TrackKind {
236
+ const names = calls(c).map((k) => k.name)
237
+ if (names.some((n) => MELODIC_HEADS.has(n)) || names.includes("scale") || (sound && SYNTHS.has(sound))) return "melodic"
238
+ if (sound) return "drums"
239
+ return "other"
240
+ }
241
+
242
+ function track(node: EsNode, index: number, label: string | null): StrudelTrack | null {
243
+ const c = chain(node)
244
+ if (!c) return null
245
+ const sound = soundOf(c)
246
+ const muted = label !== null && label.startsWith("_")
247
+ const name = label === null ? null : label.replace(/^_/, "")
248
+ return {
249
+ index,
250
+ label: name === "$" || name === "" ? null : name,
251
+ muted,
252
+ chain: c,
253
+ kind: kindOf(c, sound),
254
+ sound,
255
+ start: node.start,
256
+ end: node.end,
257
+ }
258
+ }
259
+
260
+ let tempo: StrudelTempo | null = null
261
+ const labelled: StrudelTrack[] = []
262
+ let root: EsNode | null = null
263
+ const unwrap = (n: EsNode) => (n.type === "AwaitExpression" ? (n.argument as EsNode) : n)
264
+
265
+ for (const statement of program.body as EsNode[]) {
266
+ if (statement.type === "LabeledStatement") {
267
+ const body = statement.body as EsNode
268
+ if (body.type === "ExpressionStatement") {
269
+ const t = track(body.expression as EsNode, labelled.length, (statement.label as EsNode).name as string)
270
+ if (t) labelled.push(t)
271
+ }
272
+ continue
273
+ }
274
+ if (statement.type !== "ExpressionStatement") continue
275
+ const expr = unwrap(statement.expression as EsNode)
276
+ const callee = expr.type === "CallExpression" ? (expr.callee as EsNode) : null
277
+ const name = callee?.type === "Identifier" ? (callee.name as string) : null
278
+ if (name && TEMPO.has(name)) {
279
+ const k = call(expr, name, callee!, expr.start)
280
+ const value = k.args[0]?.kind === "number" ? k.args[0].value : NaN
281
+ const unit = name.toLowerCase() === "setcpm" ? "cpm" : "cps"
282
+ tempo = { cps: unit === "cpm" ? value / 60 : value, unit, call: k }
283
+ continue
284
+ }
285
+ if (name && SETUP.has(name)) continue
286
+ root = expr
287
+ }
288
+
289
+ let tracks: StrudelTrack[] = labelled
290
+ let layout: StrudelSong["layout"] = labelled.length ? "labels" : "empty"
291
+ let master: StrudelCall[] = []
292
+ let masterChain: StrudelChain | null = null
293
+ if (!labelled.length && root) {
294
+ const c = chain(root)
295
+ if (c && "args" in c.head && c.head.name === "stack") {
296
+ layout = "stack"
297
+ master = c.methods
298
+ masterChain = c
299
+ tracks = (c.head.args as StrudelArg[])
300
+ .map((a, i) => (a.kind === "pattern" || a.kind === "mini" ? track(nodeAt(program, a.start, a.end)!, i, null) : null))
301
+ .filter((t): t is StrudelTrack => !!t)
302
+ } else if (c) {
303
+ layout = "single"
304
+ tracks = [track(root, 0, null)!]
305
+ }
306
+ }
307
+
308
+ return {
309
+ code,
310
+ tempo,
311
+ tracks,
312
+ layout,
313
+ master,
314
+ masterChain,
315
+ minis: [...minis.values()].sort((a, b) => a.start - b.start),
316
+ program: program as unknown as AcornNode,
317
+ }
318
+ }
319
+
320
+ // The ESTree node spanning exactly [start, end) (to re-read a stack argument as a track).
321
+ function nodeAt(root: EsNode, start: number, end: number): EsNode | null {
322
+ let found: EsNode | null = null
323
+ const visit = (n: unknown) => {
324
+ if (found || !n || typeof n !== "object") return
325
+ if (Array.isArray(n)) return n.forEach(visit)
326
+ const node = n as EsNode
327
+ if (typeof node.type === "string" && node.start === start && node.end === end) {
328
+ found = node
329
+ return
330
+ }
331
+ if (typeof node.start === "number" && (node.start > start || node.end < end)) return
332
+ for (const key of Object.keys(node)) if (key !== "type") visit(node[key])
333
+ }
334
+ visit(root)
335
+ return found
336
+ }
337
+
338
+ /** All calls in a chain, head first. */
339
+ export function chainCalls(chain: StrudelChain): StrudelCall[] {
340
+ return "args" in chain.head ? [chain.head, ...chain.methods] : chain.methods
341
+ }
342
+
343
+ /** The last call named `name` in the chain (later calls win in Strudel). */
344
+ export function findCall(chain: StrudelChain, name: string): StrudelCall | null {
345
+ const all = chainCalls(chain)
346
+ for (let i = all.length - 1; i >= 0; i--) if (all[i]!.name === name) return all[i]!
347
+ return null
348
+ }
@@ -0,0 +1,182 @@
1
+ /* The rack's hardware: a rotary knob (rubber skirt, machined cap, LED ring) and a channel fader (slotted
2
+ track, tick scale, chunky cap). Both are ARIA sliders — drag (Shift for fine), arrow keys, Page Up/Down,
3
+ Home/End — and double-click resets to the default. Values map logarithmically when `log`. */
4
+ import { useId, useRef, type KeyboardEvent, type PointerEvent } from "react"
5
+
6
+ export interface ControlProps {
7
+ label: string
8
+ value: number
9
+ min: number
10
+ max: number
11
+ /** Map the travel logarithmically (frequencies). */
12
+ log?: boolean
13
+ defaultValue?: number
14
+ format?: (value: number) => string
15
+ onChange: (value: number) => void
16
+ disabled?: boolean
17
+ /** Shown instead of the value, e.g. "∿" for a modulated parameter. */
18
+ display?: string
19
+ title?: string
20
+ /** Knob only: "lg" for master controls. */
21
+ size?: "md" | "lg"
22
+ }
23
+
24
+ const clamp = (v: number, min: number, max: number) => Math.min(max, Math.max(min, v))
25
+
26
+ function toUnit(v: number, { min, max, log }: Pick<ControlProps, "min" | "max" | "log">) {
27
+ if (log && min > 0) return Math.log(clamp(v, min, max) / min) / Math.log(max / min)
28
+ return (clamp(v, min, max) - min) / (max - min)
29
+ }
30
+
31
+ function fromUnit(u: number, { min, max, log }: Pick<ControlProps, "min" | "max" | "log">) {
32
+ const t = clamp(u, 0, 1)
33
+ return log && min > 0 ? min * (max / min) ** t : min + t * (max - min)
34
+ }
35
+
36
+ export function defaultFormat(v: number): string {
37
+ const a = Math.abs(v)
38
+ if (a >= 1000) return `${+(v / 1000).toFixed(1)}k`
39
+ if (a >= 100) return v.toFixed(0)
40
+ if (a >= 10) return v.toFixed(1)
41
+ return v.toFixed(2)
42
+ }
43
+
44
+ function useDrag(props: ControlProps, travel: number) {
45
+ const start = useRef<{ pos: number; unit: number } | null>(null)
46
+ const set = (unit: number) => props.onChange(fromUnit(unit, props))
47
+ const onPointerDown = (e: PointerEvent<HTMLElement>) => {
48
+ if (props.disabled) return
49
+ e.currentTarget.setPointerCapture(e.pointerId)
50
+ e.currentTarget.focus()
51
+ start.current = { pos: e.clientY, unit: toUnit(props.value, props) }
52
+ }
53
+ const onPointerMove = (e: PointerEvent<HTMLElement>) => {
54
+ const s = start.current
55
+ if (!s) return
56
+ set(s.unit + ((s.pos - e.clientY) / travel) * (e.shiftKey ? 0.2 : 1))
57
+ }
58
+ const onPointerUp = () => {
59
+ start.current = null
60
+ }
61
+ const onKeyDown = (e: KeyboardEvent<HTMLElement>) => {
62
+ if (props.disabled) return
63
+ const u = toUnit(props.value, props)
64
+ const step = e.shiftKey ? 0.01 : 0.05
65
+ const next: Record<string, number> = {
66
+ ArrowUp: u + step,
67
+ ArrowRight: u + step,
68
+ ArrowDown: u - step,
69
+ ArrowLeft: u - step,
70
+ PageUp: u + 0.2,
71
+ PageDown: u - 0.2,
72
+ Home: 0,
73
+ End: 1,
74
+ }
75
+ if (!(e.key in next)) return
76
+ e.preventDefault()
77
+ set(next[e.key]!)
78
+ }
79
+ const onDoubleClick = () => {
80
+ if (!props.disabled && props.defaultValue !== undefined) props.onChange(props.defaultValue)
81
+ }
82
+ return { onPointerDown, onPointerMove, onPointerUp, onPointerCancel: onPointerUp, onKeyDown, onDoubleClick }
83
+ }
84
+
85
+ function aria(props: ControlProps, format: (v: number) => string) {
86
+ return {
87
+ role: "slider",
88
+ tabIndex: props.disabled ? -1 : 0,
89
+ "aria-label": props.label,
90
+ "aria-valuemin": props.min,
91
+ "aria-valuemax": props.max,
92
+ "aria-valuenow": +props.value.toFixed(4),
93
+ "aria-valuetext": props.display ?? format(props.value),
94
+ "aria-disabled": props.disabled || undefined,
95
+ title: props.title,
96
+ } as const
97
+ }
98
+
99
+ const ARC = 270
100
+ const LEDS = 15
101
+ const polar = (deg: number, r: number, c = 30) => {
102
+ const a = ((deg - 90) * Math.PI) / 180
103
+ return [c + r * Math.cos(a), c + r * Math.sin(a)] as const
104
+ }
105
+
106
+ export function Knob(props: ControlProps) {
107
+ const id = useId().replace(/:/g, "")
108
+ const format = props.format ?? defaultFormat
109
+ const handlers = useDrag(props, 160)
110
+ const u = toUnit(props.value, props)
111
+ const angle = -ARC / 2 + ARC * u
112
+ // A bipolar parameter (pan, speed) lights from the centre; the rest light from the left.
113
+ const bipolar = props.min < 0 || props.label === "Pan"
114
+ const zero = bipolar ? toUnit(props.min < 0 ? 0 : (props.min + props.max) / 2, props) : 0
115
+ const lit = (i: number) => {
116
+ const t = i / (LEDS - 1)
117
+ return !props.disabled && (bipolar ? (t >= Math.min(zero, u) - 0.001 && t <= Math.max(zero, u) + 0.001) : t <= u + 0.001)
118
+ }
119
+ return (
120
+ <div className="strudel-knob" data-size={props.size ?? "md"} data-disabled={props.disabled || undefined}>
121
+ <div className="strudel-knob__dial" {...aria(props, format)} {...handlers}>
122
+ <svg viewBox="0 0 60 60" aria-hidden="true">
123
+ <defs>
124
+ <radialGradient id={`${id}-skirt`} cx="50%" cy="35%" r="70%">
125
+ <stop offset="0" stopColor="#4a4d55" />
126
+ <stop offset=".6" stopColor="#1d1f24" />
127
+ <stop offset="1" stopColor="#0b0c0e" />
128
+ </radialGradient>
129
+ <linearGradient id={`${id}-cap`} x1="0" y1="0" x2="0" y2="1">
130
+ <stop offset="0" stopColor="#f2f3f5" />
131
+ <stop offset=".45" stopColor="#a9adb5" />
132
+ <stop offset=".55" stopColor="#8a8f98" />
133
+ <stop offset="1" stopColor="#d7dae0" />
134
+ </linearGradient>
135
+ </defs>
136
+ {Array.from({ length: LEDS }, (_, i) => {
137
+ const [x, y] = polar(-ARC / 2 + (ARC * i) / (LEDS - 1), 27)
138
+ return <circle key={i} cx={x} cy={y} r="1.7" className="strudel-knob__led" data-on={lit(i) || undefined} />
139
+ })}
140
+ <circle cx="30" cy="31.5" r="19.5" className="strudel-knob__shadow" />
141
+ <circle cx="30" cy="30" r="19" fill={`url(#${id}-skirt)`} className="strudel-knob__skirt" />
142
+ {/* Grip ridges on the skirt, then the machined cap and its indicator. */}
143
+ <g transform={`rotate(${angle} 30 30)`}>
144
+ {Array.from({ length: 24 }, (_, i) => (
145
+ <line key={i} x1="30" y1="11.6" x2="30" y2="14" transform={`rotate(${i * 15} 30 30)`} className="strudel-knob__ridge" />
146
+ ))}
147
+ <circle cx="30" cy="30" r="12.5" fill={`url(#${id}-cap)`} className="strudel-knob__cap" />
148
+ <circle cx="30" cy="30" r="12.5" className="strudel-knob__brush" />
149
+ <line x1="30" y1="19.5" x2="30" y2="26" className="strudel-knob__pointer" />
150
+ </g>
151
+ </svg>
152
+ </div>
153
+ <span className="strudel-knob__value">{props.display ?? format(props.value)}</span>
154
+ <span className="strudel-knob__label">{props.label}</span>
155
+ </div>
156
+ )
157
+ }
158
+
159
+ const TICKS = [10, 8, 6, 4, 2, 0]
160
+
161
+ export function Fader(props: ControlProps) {
162
+ const format = props.format ?? defaultFormat
163
+ const handlers = useDrag(props, 104)
164
+ const u = toUnit(props.value, props)
165
+ return (
166
+ <div className="strudel-fader" data-disabled={props.disabled || undefined}>
167
+ <span className="strudel-fader__value">{props.display ?? format(props.value)}</span>
168
+ <div className="strudel-fader__body">
169
+ <div className="strudel-fader__scale" aria-hidden="true">
170
+ {TICKS.map((t) => (
171
+ <span key={t}>{t}</span>
172
+ ))}
173
+ </div>
174
+ <div className="strudel-fader__track" {...aria(props, format)} {...handlers} aria-orientation="vertical">
175
+ <div className="strudel-fader__slot" />
176
+ <div className="strudel-fader__cap" style={{ bottom: `calc(${u} * (100% - 22px))` }} />
177
+ </div>
178
+ </div>
179
+ <span className="strudel-fader__label">{props.label}</span>
180
+ </div>
181
+ )
182
+ }
@@ -86,6 +86,20 @@ export async function playStrudel(owner: string, code: string, samples: readonly
86
86
  }
87
87
  }
88
88
 
89
+ /**
90
+ * Swap the pattern `owner` is playing for `code` without stopping (live edits). Resolves null when `owner`
91
+ * isn't playing; throws when the code doesn't evaluate (the old pattern keeps playing).
92
+ */
93
+ export async function updateStrudel(owner: string, code: string): Promise<StrudelPattern | null> {
94
+ if (snapshot.owner !== owner || snapshot.status !== "playing" || !loaded) return null
95
+ const pattern = await loaded.evaluate(code, true)
96
+ if (!pattern || loaded.state.evalError) {
97
+ const err = loaded.state.evalError
98
+ throw err instanceof Error ? err : new Error(String(err ?? "Strudel could not evaluate this pattern"))
99
+ }
100
+ return pattern
101
+ }
102
+
89
103
  export async function stopStrudel(owner: string): Promise<void> {
90
104
  if (snapshot.owner !== owner) return
91
105
  set({ owner: null, status: "idle" })
@@ -1,4 +1,20 @@
1
1
  export { StrudelPlayer } from "./StrudelPlayer"
2
2
  export type { StrudelPlayerProps } from "./StrudelPlayer"
3
- export { DEFAULT_SAMPLES, playStrudel, stopStrudel } from "./engine"
3
+ export { StrudelRack, STRUDEL_PARAMS, trackName } from "./StrudelRack"
4
+ export type { StrudelRackProps } from "./StrudelRack"
5
+ export { StepGrid } from "./StepGrid"
6
+ export type { StepGridProps } from "./StepGrid"
7
+ export { Fader, Knob } from "./controls"
8
+ export type { ControlProps } from "./controls"
9
+ export {
10
+ DEFAULT_SAMPLES,
11
+ getStrudelSnapshot,
12
+ playStrudel,
13
+ stopStrudel,
14
+ strudelNow,
15
+ subscribeStrudel,
16
+ updateStrudel,
17
+ } from "./engine"
18
+ export type { StrudelHap, StrudelPattern, StrudelSnapshot } from "./engine"
4
19
  export { isStrudelFile, isStrudelLanguage } from "./url"
20
+ export * from "./ast"