@brett_lamy/docstream 1.10.0 → 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 +25 -1
- package/dist/docs/DocsRenderer.d.ts.map +1 -1
- package/dist/docs/DocsRenderer.js +2 -1
- package/dist/docs/DocsRenderer.js.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- 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 +7 -1
- package/dist/strudel/StrudelPlayer.d.ts.map +1 -1
- package/dist/strudel/StrudelPlayer.js +37 -8
- package/dist/strudel/StrudelPlayer.js.map +1 -1
- 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 +5 -0
- package/dist/strudel/engine.d.ts.map +1 -1
- package/dist/strudel/engine.js +14 -0
- package/dist/strudel/engine.js.map +1 -1
- package/dist/strudel/index.d.ts +9 -1
- package/dist/strudel/index.d.ts.map +1 -1
- package/dist/strudel/index.js +5 -1
- package/dist/strudel/index.js.map +1 -1
- package/dist/styles.css +370 -0
- package/package.json +2 -1
- package/src/docs/DocsRenderer.tsx +2 -1
- package/src/index.ts +3 -2
- package/src/strudel/StepGrid.tsx +130 -0
- package/src/strudel/StrudelPlayer.tsx +79 -8
- 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 +14 -0
- package/src/strudel/index.ts +17 -1
- package/src/styles.css +370 -0
|
@@ -28,7 +28,8 @@ import { ReplayPreview } from "../replay/ReplayPreview"
|
|
|
28
28
|
import { isReplayQaUrl } from "../replay/url"
|
|
29
29
|
import { VideoEmbed } from "../video"
|
|
30
30
|
import { StlViewer, isStlUrl } from "../stl"
|
|
31
|
-
import { StrudelPlayer
|
|
31
|
+
import { StrudelPlayer } from "../strudel/StrudelPlayer"
|
|
32
|
+
import { isStrudelFile, isStrudelLanguage } from "../strudel/url"
|
|
32
33
|
import { OpenApiOperation } from "../openapi/OpenApiOperation"
|
|
33
34
|
import { Mermaid } from "./Mermaid"
|
|
34
35
|
import { HighlightedCode } from "./HighlightedCode"
|
package/src/index.ts
CHANGED
|
@@ -90,8 +90,9 @@ export { VideoEmbed } from "./video"
|
|
|
90
90
|
export type { VideoEmbedProps } from "./video"
|
|
91
91
|
export { StlViewer, isStlUrl, parseStl } from "./stl"
|
|
92
92
|
export type { StlMesh, StlViewerProps } from "./stl"
|
|
93
|
-
export { StrudelPlayer
|
|
94
|
-
export
|
|
93
|
+
export { StrudelPlayer } from "./strudel/StrudelPlayer"
|
|
94
|
+
export { isStrudelFile, isStrudelLanguage } from "./strudel/url"
|
|
95
|
+
export type { StrudelPlayerProps } from "./strudel/StrudelPlayer"
|
|
95
96
|
export { OpenApiOperation } from "./openapi/OpenApiOperation"
|
|
96
97
|
export { createInlineDemoProject, createReactDemoFiles } from "./playground/project"
|
|
97
98
|
export type {
|
|
@@ -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({
|
|
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(() =>
|
|
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 (
|
|
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,
|
|
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
|
-
{
|
|
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={
|
|
312
|
+
<HighlightedCode code={current.trim()} language="javascript" />
|
|
242
313
|
</div>
|
|
243
314
|
)}
|
|
244
315
|
</figure>
|
|
@@ -0,0 +1,312 @@
|
|
|
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
|
+
<Icon size={15} aria-hidden="true" />
|
|
124
|
+
<span className="strudel-device__name" title={code.slice(track.start, track.end)}>
|
|
125
|
+
{name}
|
|
126
|
+
</span>
|
|
127
|
+
<button type="button" className="strudel-device__toggle" data-kind="mute" aria-pressed={mix.muted?.has(track.index) || track.muted} onClick={() => onMix("mute")} title="Mute">
|
|
128
|
+
M
|
|
129
|
+
</button>
|
|
130
|
+
<button type="button" className="strudel-device__toggle" data-kind="solo" aria-pressed={!!mix.solo?.has(track.index)} onClick={() => onMix("solo")} title="Solo">
|
|
131
|
+
S
|
|
132
|
+
</button>
|
|
133
|
+
</header>
|
|
134
|
+
<div className="strudel-device__body">
|
|
135
|
+
<Fader
|
|
136
|
+
label="Gain"
|
|
137
|
+
value={gain ?? 1}
|
|
138
|
+
min={0}
|
|
139
|
+
max={1.5}
|
|
140
|
+
defaultValue={1}
|
|
141
|
+
disabled={!!gainCall && gain === null}
|
|
142
|
+
{...(gainCall && gain === null ? { display: "∿", title: "Modulated by a pattern" } : {})}
|
|
143
|
+
format={pct}
|
|
144
|
+
onChange={(v) => set("gain", v)}
|
|
145
|
+
/>
|
|
146
|
+
<div className="strudel-device__main">
|
|
147
|
+
{(synth || scaleMatch) && (
|
|
148
|
+
<div className="strudel-device__selects">
|
|
149
|
+
{synth && (
|
|
150
|
+
<label>
|
|
151
|
+
<span>Sound</span>
|
|
152
|
+
<select value={synth.value} onChange={(e) => onChange(setMiniAtom(code, sound!, synth, e.target.value))}>
|
|
153
|
+
{SYNTHS.map((s) => (
|
|
154
|
+
<option key={s}>{s}</option>
|
|
155
|
+
))}
|
|
156
|
+
</select>
|
|
157
|
+
</label>
|
|
158
|
+
)}
|
|
159
|
+
{scaleMatch && (
|
|
160
|
+
<label>
|
|
161
|
+
<span>Key</span>
|
|
162
|
+
<select
|
|
163
|
+
value={scaleMatch[1]!.toUpperCase().replace(/^([A-G])B$/, "$1b")}
|
|
164
|
+
onChange={(e) => onChange(setMiniSource(code, scale!, `${e.target.value}${scaleMatch[2]}:${scaleMatch[3]}`))}
|
|
165
|
+
>
|
|
166
|
+
{NOTES.map((n) => (
|
|
167
|
+
<option key={n}>{n}</option>
|
|
168
|
+
))}
|
|
169
|
+
</select>
|
|
170
|
+
<select value={scaleMatch[3]} onChange={(e) => onChange(setMiniSource(code, scale!, `${scaleMatch[1]}${scaleMatch[2]}:${e.target.value}`))}>
|
|
171
|
+
{(MODES.includes(scaleMatch[3]!) ? MODES : [scaleMatch[3]!, ...MODES]).map((m) => (
|
|
172
|
+
<option key={m}>{m}</option>
|
|
173
|
+
))}
|
|
174
|
+
</select>
|
|
175
|
+
</label>
|
|
176
|
+
)}
|
|
177
|
+
</div>
|
|
178
|
+
)}
|
|
179
|
+
{pattern?.mini && <StepGrid mini={pattern.mini} onAtomChange={setAtom(pattern)} label={`${name} steps`} />}
|
|
180
|
+
{!pattern && sound?.mini && !synth && <StepGrid mini={sound.mini} onAtomChange={setAtom(sound)} label={`${name} sounds`} />}
|
|
181
|
+
{pattern?.error && <p className="strudel-device__error">{pattern.error.message}</p>}
|
|
182
|
+
<div className="strudel-device__knobs">
|
|
183
|
+
{present.map((p) => {
|
|
184
|
+
const call = findCall(chain, p)!
|
|
185
|
+
const value = firstNumber(call)
|
|
186
|
+
const spec = STRUDEL_PARAMS[p]!
|
|
187
|
+
return (
|
|
188
|
+
<div key={p} className="strudel-device__knob">
|
|
189
|
+
<Knob
|
|
190
|
+
label={spec.label}
|
|
191
|
+
value={value ?? spec.default}
|
|
192
|
+
min={spec.min}
|
|
193
|
+
max={spec.max}
|
|
194
|
+
{...(spec.log ? { log: true } : {})}
|
|
195
|
+
defaultValue={spec.default}
|
|
196
|
+
format={spec.format ?? defaultFormat}
|
|
197
|
+
disabled={value === null}
|
|
198
|
+
{...(value === null ? { display: "∿", title: `${p} is modulated: ${code.slice(call.argsRange.start, call.argsRange.end)}` } : {})}
|
|
199
|
+
onChange={(v) => set(p, v)}
|
|
200
|
+
/>
|
|
201
|
+
<button type="button" className="strudel-device__remove" aria-label={`Remove ${spec.label}`} onClick={() => onChange(removeMethod(code, chain, p))}>
|
|
202
|
+
<X size={10} />
|
|
203
|
+
</button>
|
|
204
|
+
</div>
|
|
205
|
+
)
|
|
206
|
+
})}
|
|
207
|
+
{missing.length > 0 && (
|
|
208
|
+
<label className="strudel-device__add" title="Add an effect">
|
|
209
|
+
<Plus size={14} aria-hidden="true" />
|
|
210
|
+
<select
|
|
211
|
+
aria-label={`Add an effect to ${name}`}
|
|
212
|
+
value=""
|
|
213
|
+
onChange={(e) => e.target.value && set(e.target.value, STRUDEL_PARAMS[e.target.value]!.default)}
|
|
214
|
+
>
|
|
215
|
+
<option value="">Add…</option>
|
|
216
|
+
{missing.map((p) => (
|
|
217
|
+
<option key={p} value={p}>
|
|
218
|
+
{STRUDEL_PARAMS[p]!.label} ({p})
|
|
219
|
+
</option>
|
|
220
|
+
))}
|
|
221
|
+
</select>
|
|
222
|
+
</label>
|
|
223
|
+
)}
|
|
224
|
+
</div>
|
|
225
|
+
</div>
|
|
226
|
+
</div>
|
|
227
|
+
</section>
|
|
228
|
+
)
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Equipment generated from Strudel code. Controlled: `code` in, edited code out through `onChange`. */
|
|
232
|
+
export function StrudelRack({ code, onChange, onPlayableChange, className }: StrudelRackProps) {
|
|
233
|
+
const parsed = useMemo((): { song: StrudelSong } | { error: string } => {
|
|
234
|
+
try {
|
|
235
|
+
return { song: parseStrudel(code) }
|
|
236
|
+
} catch (err) {
|
|
237
|
+
return { error: err instanceof StrudelSyntaxError ? `Syntax error: ${err.message}` : String(err) }
|
|
238
|
+
}
|
|
239
|
+
}, [code])
|
|
240
|
+
const [muted, setMuted] = useState<ReadonlySet<number>>(() => new Set())
|
|
241
|
+
const [solo, setSolo] = useState<ReadonlySet<number>>(() => new Set())
|
|
242
|
+
const song = "song" in parsed ? parsed.song : null
|
|
243
|
+
const mix = useMemo(() => ({ muted, solo }), [muted, solo])
|
|
244
|
+
|
|
245
|
+
useEffect(() => {
|
|
246
|
+
if (song) onPlayableChange?.(mixCode(song, mix))
|
|
247
|
+
}, [song, mix])
|
|
248
|
+
|
|
249
|
+
if (!song) {
|
|
250
|
+
return (
|
|
251
|
+
<div className={`strudel-rack ${className ?? ""}`.trim()}>
|
|
252
|
+
<p className="strudel-rack__error" role="alert">
|
|
253
|
+
{"error" in parsed ? parsed.error : ""}
|
|
254
|
+
</p>
|
|
255
|
+
</div>
|
|
256
|
+
)
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const toggle = (set: ReadonlySet<number>, i: number) => {
|
|
260
|
+
const next = new Set(set)
|
|
261
|
+
if (!next.delete(i)) next.add(i)
|
|
262
|
+
return next
|
|
263
|
+
}
|
|
264
|
+
const cps = song.tempo?.cps
|
|
265
|
+
const master = song.masterChain
|
|
266
|
+
const postgain = master ? firstNumber(findCall(master, "postgain")) : null
|
|
267
|
+
|
|
268
|
+
return (
|
|
269
|
+
<div className={`strudel-rack ${className ?? ""}`.trim()}>
|
|
270
|
+
<div className="strudel-rack__master" role="group" aria-label="Master">
|
|
271
|
+
<span className="strudel-rack__title">Master</span>
|
|
272
|
+
<Knob
|
|
273
|
+
label="Tempo"
|
|
274
|
+
value={cps && Number.isFinite(cps) ? cps : 0.5}
|
|
275
|
+
min={0.1}
|
|
276
|
+
max={2}
|
|
277
|
+
defaultValue={0.5}
|
|
278
|
+
format={(v) => `${Math.round(v * 240)} bpm`}
|
|
279
|
+
onChange={(v) => onChange(setTempo(song, v))}
|
|
280
|
+
/>
|
|
281
|
+
{master && (
|
|
282
|
+
<Knob
|
|
283
|
+
label="Output"
|
|
284
|
+
value={postgain ?? 1}
|
|
285
|
+
min={0}
|
|
286
|
+
max={2}
|
|
287
|
+
defaultValue={1}
|
|
288
|
+
format={pct}
|
|
289
|
+
onChange={(v) => onChange(setMethod(code, master, "postgain", formatNumber(v)))}
|
|
290
|
+
/>
|
|
291
|
+
)}
|
|
292
|
+
<span className="strudel-rack__summary">
|
|
293
|
+
{song.tracks.length} track{song.tracks.length === 1 ? "" : "s"}
|
|
294
|
+
{song.layout === "stack" ? " · stack" : song.layout === "labels" ? " · $:" : ""}
|
|
295
|
+
</span>
|
|
296
|
+
</div>
|
|
297
|
+
{song.tracks.length === 0 && <p className="strudel-rack__error">No pattern found.</p>}
|
|
298
|
+
{song.tracks.map((t) => (
|
|
299
|
+
<Device
|
|
300
|
+
key={t.index}
|
|
301
|
+
song={song}
|
|
302
|
+
track={t}
|
|
303
|
+
mix={mix}
|
|
304
|
+
onChange={onChange}
|
|
305
|
+
onMix={(kind) => (kind === "mute" ? setMuted(toggle(muted, t.index)) : setSolo(toggle(solo, t.index)))}
|
|
306
|
+
/>
|
|
307
|
+
))}
|
|
308
|
+
</div>
|
|
309
|
+
)
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
export default StrudelRack
|