@brett_lamy/docstream 1.9.1 → 1.11.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 (84) hide show
  1. package/README.md +54 -0
  2. package/dist/docs/DocsRenderer.d.ts.map +1 -1
  3. package/dist/docs/DocsRenderer.js +13 -1
  4. package/dist/docs/DocsRenderer.js.map +1 -1
  5. package/dist/index.d.ts +5 -0
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +3 -0
  8. package/dist/index.js.map +1 -1
  9. package/dist/stl/StlViewer.d.ts +13 -0
  10. package/dist/stl/StlViewer.d.ts.map +1 -0
  11. package/dist/stl/StlViewer.js +277 -0
  12. package/dist/stl/StlViewer.js.map +1 -0
  13. package/dist/stl/index.d.ts +5 -0
  14. package/dist/stl/index.d.ts.map +1 -0
  15. package/dist/stl/index.js +3 -0
  16. package/dist/stl/index.js.map +1 -0
  17. package/dist/stl/parse.d.ts +13 -0
  18. package/dist/stl/parse.d.ts.map +1 -0
  19. package/dist/stl/parse.js +69 -0
  20. package/dist/stl/parse.js.map +1 -0
  21. package/dist/strudel/StepGrid.d.ts +10 -0
  22. package/dist/strudel/StepGrid.d.ts.map +1 -0
  23. package/dist/strudel/StepGrid.js +78 -0
  24. package/dist/strudel/StepGrid.js.map +1 -0
  25. package/dist/strudel/StrudelPlayer.d.ts +21 -0
  26. package/dist/strudel/StrudelPlayer.d.ts.map +1 -0
  27. package/dist/strudel/StrudelPlayer.js +207 -0
  28. package/dist/strudel/StrudelPlayer.js.map +1 -0
  29. package/dist/strudel/StrudelRack.d.ts +24 -0
  30. package/dist/strudel/StrudelRack.d.ts.map +1 -0
  31. package/dist/strudel/StrudelRack.js +123 -0
  32. package/dist/strudel/StrudelRack.js.map +1 -0
  33. package/dist/strudel/ast/edit.d.ts +41 -0
  34. package/dist/strudel/ast/edit.d.ts.map +1 -0
  35. package/dist/strudel/ast/edit.js +78 -0
  36. package/dist/strudel/ast/edit.js.map +1 -0
  37. package/dist/strudel/ast/index.d.ts +7 -0
  38. package/dist/strudel/ast/index.d.ts.map +1 -0
  39. package/dist/strudel/ast/index.js +4 -0
  40. package/dist/strudel/ast/index.js.map +1 -0
  41. package/dist/strudel/ast/mini.d.ts +86 -0
  42. package/dist/strudel/ast/mini.d.ts.map +1 -0
  43. package/dist/strudel/ast/mini.js +269 -0
  44. package/dist/strudel/ast/mini.js.map +1 -0
  45. package/dist/strudel/ast/program.d.ts +93 -0
  46. package/dist/strudel/ast/program.d.ts.map +1 -0
  47. package/dist/strudel/ast/program.js +288 -0
  48. package/dist/strudel/ast/program.js.map +1 -0
  49. package/dist/strudel/controls.d.ts +19 -0
  50. package/dist/strudel/controls.d.ts.map +1 -0
  51. package/dist/strudel/controls.js +109 -0
  52. package/dist/strudel/controls.js.map +1 -0
  53. package/dist/strudel/engine.d.ts +43 -0
  54. package/dist/strudel/engine.d.ts.map +1 -0
  55. package/dist/strudel/engine.js +79 -0
  56. package/dist/strudel/engine.js.map +1 -0
  57. package/dist/strudel/index.d.ts +13 -0
  58. package/dist/strudel/index.d.ts.map +1 -0
  59. package/dist/strudel/index.js +8 -0
  60. package/dist/strudel/index.js.map +1 -0
  61. package/dist/strudel/url.d.ts +5 -0
  62. package/dist/strudel/url.d.ts.map +1 -0
  63. package/dist/strudel/url.js +9 -0
  64. package/dist/strudel/url.js.map +1 -0
  65. package/dist/styles.css +564 -0
  66. package/package.json +15 -1
  67. package/src/docs/DocsRenderer.tsx +13 -1
  68. package/src/env.d.ts +3 -0
  69. package/src/index.ts +5 -0
  70. package/src/stl/StlViewer.tsx +334 -0
  71. package/src/stl/index.ts +4 -0
  72. package/src/stl/parse.ts +72 -0
  73. package/src/strudel/StepGrid.tsx +130 -0
  74. package/src/strudel/StrudelPlayer.tsx +317 -0
  75. package/src/strudel/StrudelRack.tsx +312 -0
  76. package/src/strudel/ast/edit.ts +95 -0
  77. package/src/strudel/ast/index.ts +19 -0
  78. package/src/strudel/ast/mini.ts +319 -0
  79. package/src/strudel/ast/program.ts +348 -0
  80. package/src/strudel/controls.tsx +145 -0
  81. package/src/strudel/engine.ts +112 -0
  82. package/src/strudel/index.ts +20 -0
  83. package/src/strudel/url.ts +9 -0
  84. package/src/styles.css +564 -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,145 @@
1
+ /* The rack's hardware: a rotary knob and a fader. Both are ARIA sliders — drag (Shift for fine), arrow keys,
2
+ Page Up/Down, Home/End — and double-click resets to the default. Values map logarithmically when `log`. */
3
+ import { useRef, type KeyboardEvent, type PointerEvent } from "react"
4
+
5
+ export interface ControlProps {
6
+ label: string
7
+ value: number
8
+ min: number
9
+ max: number
10
+ /** Map the travel logarithmically (frequencies). */
11
+ log?: boolean
12
+ defaultValue?: number
13
+ format?: (value: number) => string
14
+ onChange: (value: number) => void
15
+ disabled?: boolean
16
+ /** Shown instead of the value, e.g. "∿" for a modulated parameter. */
17
+ display?: string
18
+ title?: string
19
+ }
20
+
21
+ const clamp = (v: number, min: number, max: number) => Math.min(max, Math.max(min, v))
22
+
23
+ function toUnit(v: number, { min, max, log }: Pick<ControlProps, "min" | "max" | "log">) {
24
+ if (log && min > 0) return Math.log(clamp(v, min, max) / min) / Math.log(max / min)
25
+ return (clamp(v, min, max) - min) / (max - min)
26
+ }
27
+
28
+ function fromUnit(u: number, { min, max, log }: Pick<ControlProps, "min" | "max" | "log">) {
29
+ const t = clamp(u, 0, 1)
30
+ return log && min > 0 ? min * (max / min) ** t : min + t * (max - min)
31
+ }
32
+
33
+ export function defaultFormat(v: number): string {
34
+ const a = Math.abs(v)
35
+ if (a >= 1000) return `${+(v / 1000).toFixed(1)}k`
36
+ if (a >= 100) return v.toFixed(0)
37
+ if (a >= 10) return v.toFixed(1)
38
+ return v.toFixed(2)
39
+ }
40
+
41
+ function useDrag(props: ControlProps, axis: "x" | "y", travel: number) {
42
+ const start = useRef<{ pos: number; unit: number } | null>(null)
43
+ const set = (unit: number) => props.onChange(fromUnit(unit, props))
44
+ const onPointerDown = (e: PointerEvent<HTMLElement>) => {
45
+ if (props.disabled) return
46
+ e.currentTarget.setPointerCapture(e.pointerId)
47
+ e.currentTarget.focus()
48
+ start.current = { pos: axis === "y" ? e.clientY : e.clientX, unit: toUnit(props.value, props) }
49
+ }
50
+ const onPointerMove = (e: PointerEvent<HTMLElement>) => {
51
+ const s = start.current
52
+ if (!s) return
53
+ const delta = axis === "y" ? s.pos - e.clientY : e.clientX - s.pos
54
+ set(s.unit + (delta / travel) * (e.shiftKey ? 0.2 : 1))
55
+ }
56
+ const onPointerUp = () => {
57
+ start.current = null
58
+ }
59
+ const onKeyDown = (e: KeyboardEvent<HTMLElement>) => {
60
+ if (props.disabled) return
61
+ const u = toUnit(props.value, props)
62
+ const step = e.shiftKey ? 0.01 : 0.05
63
+ const next: Record<string, number> = {
64
+ ArrowUp: u + step,
65
+ ArrowRight: u + step,
66
+ ArrowDown: u - step,
67
+ ArrowLeft: u - step,
68
+ PageUp: u + 0.2,
69
+ PageDown: u - 0.2,
70
+ Home: 0,
71
+ End: 1,
72
+ }
73
+ if (!(e.key in next)) return
74
+ e.preventDefault()
75
+ set(next[e.key]!)
76
+ }
77
+ const onDoubleClick = () => {
78
+ if (!props.disabled && props.defaultValue !== undefined) props.onChange(props.defaultValue)
79
+ }
80
+ return { onPointerDown, onPointerMove, onPointerUp, onPointerCancel: onPointerUp, onKeyDown, onDoubleClick }
81
+ }
82
+
83
+ function aria(props: ControlProps, format: (v: number) => string) {
84
+ return {
85
+ role: "slider",
86
+ tabIndex: props.disabled ? -1 : 0,
87
+ "aria-label": props.label,
88
+ "aria-valuemin": props.min,
89
+ "aria-valuemax": props.max,
90
+ "aria-valuenow": +props.value.toFixed(4),
91
+ "aria-valuetext": props.display ?? format(props.value),
92
+ "aria-disabled": props.disabled || undefined,
93
+ title: props.title,
94
+ } as const
95
+ }
96
+
97
+ const ARC = 270
98
+ const polar = (deg: number, r: number) => {
99
+ const a = ((deg - 90) * Math.PI) / 180
100
+ return [20 + r * Math.cos(a), 20 + r * Math.sin(a)] as const
101
+ }
102
+ const arc = (from: number, to: number, r: number) => {
103
+ const [x1, y1] = polar(from, r)
104
+ const [x2, y2] = polar(to, r)
105
+ return `M ${x1} ${y1} A ${r} ${r} 0 ${to - from > 180 ? 1 : 0} 1 ${x2} ${y2}`
106
+ }
107
+
108
+ export function Knob(props: ControlProps) {
109
+ const format = props.format ?? defaultFormat
110
+ const handlers = useDrag(props, "y", 160)
111
+ const u = toUnit(props.value, props)
112
+ const from = -ARC / 2
113
+ const to = from + ARC * u
114
+ const [px, py] = polar(to, 11)
115
+ return (
116
+ <div className="strudel-knob" data-disabled={props.disabled || undefined}>
117
+ <div className="strudel-knob__dial" {...aria(props, format)} {...handlers}>
118
+ <svg viewBox="0 0 40 40" aria-hidden="true">
119
+ <path d={arc(from, from + ARC, 16)} className="strudel-knob__track" />
120
+ {!props.disabled && u > 0.001 && <path d={arc(from, to, 16)} className="strudel-knob__fill" />}
121
+ <circle cx="20" cy="20" r="11.5" className="strudel-knob__cap" />
122
+ <line x1="20" y1="20" x2={px} y2={py} className="strudel-knob__pointer" />
123
+ </svg>
124
+ </div>
125
+ <span className="strudel-knob__value">{props.display ?? format(props.value)}</span>
126
+ <span className="strudel-knob__label">{props.label}</span>
127
+ </div>
128
+ )
129
+ }
130
+
131
+ export function Fader(props: ControlProps) {
132
+ const format = props.format ?? defaultFormat
133
+ const handlers = useDrag(props, "y", 90)
134
+ const u = toUnit(props.value, props)
135
+ return (
136
+ <div className="strudel-fader" data-disabled={props.disabled || undefined}>
137
+ <span className="strudel-fader__value">{props.display ?? format(props.value)}</span>
138
+ <div className="strudel-fader__track" {...aria(props, format)} {...handlers} aria-orientation="vertical">
139
+ <div className="strudel-fader__fill" style={{ height: `${u * 100}%` }} />
140
+ <div className="strudel-fader__thumb" style={{ bottom: `calc(${u * 100}% - 5px)` }} />
141
+ </div>
142
+ <span className="strudel-fader__label">{props.label}</span>
143
+ </div>
144
+ )
145
+ }
@@ -0,0 +1,112 @@
1
+ // One Strudel runtime per page, loaded on the first play (@strudel/web is ~850 KB and touches
2
+ // `window` at import). Players share it, so starting one stops whichever was playing.
3
+
4
+ /** A scheduled event, as `Pattern.queryArc` returns it. */
5
+ export interface StrudelHap {
6
+ whole?: { begin: number | { valueOf(): number }; end: number | { valueOf(): number } } | null
7
+ part: { begin: number | { valueOf(): number }; end: number | { valueOf(): number } }
8
+ value: unknown
9
+ }
10
+
11
+ export interface StrudelPattern {
12
+ queryArc(begin: number, end: number): StrudelHap[]
13
+ }
14
+
15
+ interface StrudelRepl {
16
+ evaluate(code: string, autostart?: boolean): Promise<StrudelPattern | undefined>
17
+ stop(): void
18
+ scheduler: { now(): number; started: boolean }
19
+ state: { evalError?: unknown }
20
+ }
21
+
22
+ interface StrudelWeb {
23
+ initStrudel(options?: { prebake?: () => unknown; onToggle?: (started: boolean) => void }): Promise<StrudelRepl>
24
+ samples(source: string): Promise<unknown>
25
+ }
26
+
27
+ /** Sample banks loaded before the first pattern, matching what strudel.cc code expects (`s("bd sd")`). */
28
+ export const DEFAULT_SAMPLES = ["github:tidalcycles/dirt-samples"]
29
+
30
+ export interface StrudelSnapshot {
31
+ /** The player id that owns the runtime right now, or null. */
32
+ owner: string | null
33
+ status: "idle" | "loading" | "playing"
34
+ }
35
+
36
+ let runtime: Promise<StrudelRepl> | null = null
37
+ let loaded: StrudelRepl | null = null
38
+ let snapshot: StrudelSnapshot = { owner: null, status: "idle" }
39
+ const listeners = new Set<() => void>()
40
+
41
+ function set(next: StrudelSnapshot) {
42
+ snapshot = next
43
+ listeners.forEach((fn) => fn())
44
+ }
45
+
46
+ export function subscribeStrudel(fn: () => void): () => void {
47
+ listeners.add(fn)
48
+ return () => listeners.delete(fn)
49
+ }
50
+
51
+ export function getStrudelSnapshot(): StrudelSnapshot {
52
+ return snapshot
53
+ }
54
+
55
+ function load(samples: readonly string[]): Promise<StrudelRepl> {
56
+ runtime ??= import("@strudel/web").then((mod: StrudelWeb) =>
57
+ mod.initStrudel({
58
+ prebake: () => Promise.all(samples.map((s) => mod.samples(s))),
59
+ onToggle: (started) => {
60
+ if (!started && snapshot.status === "playing") set({ owner: null, status: "idle" })
61
+ },
62
+ }),
63
+ ).then((repl) => (loaded = repl))
64
+ runtime.catch(() => {
65
+ runtime = null
66
+ })
67
+ return runtime
68
+ }
69
+
70
+ /** Evaluate `code` and start it, taking the runtime from any other player. Must run from a user gesture. */
71
+ export async function playStrudel(owner: string, code: string, samples: readonly string[] = DEFAULT_SAMPLES): Promise<StrudelPattern> {
72
+ set({ owner, status: "loading" })
73
+ try {
74
+ const repl = await load(samples)
75
+ if (snapshot.owner !== owner) throw new Error("superseded")
76
+ const pattern = await repl.evaluate(code, true)
77
+ if (!pattern || repl.state.evalError) {
78
+ const err = repl.state.evalError
79
+ throw err instanceof Error ? err : new Error(String(err ?? "Strudel could not evaluate this pattern"))
80
+ }
81
+ if (snapshot.owner === owner) set({ owner, status: "playing" })
82
+ return pattern
83
+ } catch (err) {
84
+ if (snapshot.owner === owner) set({ owner: null, status: "idle" })
85
+ throw err
86
+ }
87
+ }
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
+
103
+ export async function stopStrudel(owner: string): Promise<void> {
104
+ if (snapshot.owner !== owner) return
105
+ set({ owner: null, status: "idle" })
106
+ ;(await runtime)?.stop()
107
+ }
108
+
109
+ /** The scheduler's position in cycles, for animation frames; null while stopped or not loaded. */
110
+ export function strudelNow(): number | null {
111
+ return loaded?.scheduler.started ? loaded.scheduler.now() : null
112
+ }
@@ -0,0 +1,20 @@
1
+ export { StrudelPlayer } from "./StrudelPlayer"
2
+ export type { StrudelPlayerProps } from "./StrudelPlayer"
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"
19
+ export { isStrudelFile, isStrudelLanguage } from "./url"
20
+ export * from "./ast"
@@ -0,0 +1,9 @@
1
+ /** True for a URL whose path ends in `.strudel` (query string and hash ignored). */
2
+ export function isStrudelFile(url: string): boolean {
3
+ return /\.strudel(?:[?#]|$)/i.test(url.trim())
4
+ }
5
+
6
+ /** Fence languages that render as a Strudel player. */
7
+ export function isStrudelLanguage(language: string | null | undefined): boolean {
8
+ return !!language && /^strudel$/i.test(language)
9
+ }