@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.
- package/README.md +54 -0
- package/dist/docs/DocsRenderer.d.ts.map +1 -1
- package/dist/docs/DocsRenderer.js +13 -1
- package/dist/docs/DocsRenderer.js.map +1 -1
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/stl/StlViewer.d.ts +13 -0
- package/dist/stl/StlViewer.d.ts.map +1 -0
- package/dist/stl/StlViewer.js +277 -0
- package/dist/stl/StlViewer.js.map +1 -0
- package/dist/stl/index.d.ts +5 -0
- package/dist/stl/index.d.ts.map +1 -0
- package/dist/stl/index.js +3 -0
- package/dist/stl/index.js.map +1 -0
- package/dist/stl/parse.d.ts +13 -0
- package/dist/stl/parse.d.ts.map +1 -0
- package/dist/stl/parse.js +69 -0
- package/dist/stl/parse.js.map +1 -0
- package/dist/strudel/StepGrid.d.ts +10 -0
- package/dist/strudel/StepGrid.d.ts.map +1 -0
- package/dist/strudel/StepGrid.js +78 -0
- package/dist/strudel/StepGrid.js.map +1 -0
- package/dist/strudel/StrudelPlayer.d.ts +21 -0
- package/dist/strudel/StrudelPlayer.d.ts.map +1 -0
- package/dist/strudel/StrudelPlayer.js +207 -0
- package/dist/strudel/StrudelPlayer.js.map +1 -0
- package/dist/strudel/StrudelRack.d.ts +24 -0
- package/dist/strudel/StrudelRack.d.ts.map +1 -0
- package/dist/strudel/StrudelRack.js +123 -0
- package/dist/strudel/StrudelRack.js.map +1 -0
- package/dist/strudel/ast/edit.d.ts +41 -0
- package/dist/strudel/ast/edit.d.ts.map +1 -0
- package/dist/strudel/ast/edit.js +78 -0
- package/dist/strudel/ast/edit.js.map +1 -0
- package/dist/strudel/ast/index.d.ts +7 -0
- package/dist/strudel/ast/index.d.ts.map +1 -0
- package/dist/strudel/ast/index.js +4 -0
- package/dist/strudel/ast/index.js.map +1 -0
- package/dist/strudel/ast/mini.d.ts +86 -0
- package/dist/strudel/ast/mini.d.ts.map +1 -0
- package/dist/strudel/ast/mini.js +269 -0
- package/dist/strudel/ast/mini.js.map +1 -0
- package/dist/strudel/ast/program.d.ts +93 -0
- package/dist/strudel/ast/program.d.ts.map +1 -0
- package/dist/strudel/ast/program.js +288 -0
- package/dist/strudel/ast/program.js.map +1 -0
- package/dist/strudel/controls.d.ts +19 -0
- package/dist/strudel/controls.d.ts.map +1 -0
- package/dist/strudel/controls.js +109 -0
- package/dist/strudel/controls.js.map +1 -0
- package/dist/strudel/engine.d.ts +43 -0
- package/dist/strudel/engine.d.ts.map +1 -0
- package/dist/strudel/engine.js +79 -0
- package/dist/strudel/engine.js.map +1 -0
- package/dist/strudel/index.d.ts +13 -0
- package/dist/strudel/index.d.ts.map +1 -0
- package/dist/strudel/index.js +8 -0
- package/dist/strudel/index.js.map +1 -0
- package/dist/strudel/url.d.ts +5 -0
- package/dist/strudel/url.d.ts.map +1 -0
- package/dist/strudel/url.js +9 -0
- package/dist/strudel/url.js.map +1 -0
- package/dist/styles.css +564 -0
- package/package.json +15 -1
- package/src/docs/DocsRenderer.tsx +13 -1
- package/src/env.d.ts +3 -0
- package/src/index.ts +5 -0
- package/src/stl/StlViewer.tsx +334 -0
- package/src/stl/index.ts +4 -0
- package/src/stl/parse.ts +72 -0
- package/src/strudel/StepGrid.tsx +130 -0
- package/src/strudel/StrudelPlayer.tsx +317 -0
- package/src/strudel/StrudelRack.tsx +312 -0
- package/src/strudel/ast/edit.ts +95 -0
- package/src/strudel/ast/index.ts +19 -0
- package/src/strudel/ast/mini.ts +319 -0
- package/src/strudel/ast/program.ts +348 -0
- package/src/strudel/controls.tsx +145 -0
- package/src/strudel/engine.ts +112 -0
- package/src/strudel/index.ts +20 -0
- package/src/strudel/url.ts +9 -0
- 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
|
+
}
|