spielwerk draft

This commit is contained in:
André Michelle
2026-03-18 10:43:59 +01:00
parent 8bb02b4306
commit 50eded0570
19 changed files with 1164 additions and 9 deletions
+6 -6
View File
@@ -5984,9 +5984,9 @@
}
},
"node_modules/flatted": {
"version": "3.4.1",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.1.tgz",
"integrity": "sha512-IxfVbRFVlV8V/yRaGzk0UVIcsKKHMSfYw66T/u4nTwlWteQePsxe//LjudR1AMX4tZW3WFCh3Zqa/sjlqpbURQ==",
"version": "3.4.2",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
"integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
"dev": true,
"license": "ISC"
},
@@ -8948,9 +8948,9 @@
"license": "ISC"
},
"node_modules/nan": {
"version": "2.25.0",
"resolved": "https://registry.npmjs.org/nan/-/nan-2.25.0.tgz",
"integrity": "sha512-0M90Ag7Xn5KMLLZ7zliPWP3rT90P6PN+IzVFS0VqmnPktBk3700xUVv8Ikm9EUaUE5SDWdp/BIxdENzVznpm1g==",
"version": "2.26.1",
"resolved": "https://registry.npmjs.org/nan/-/nan-2.26.1.tgz",
"integrity": "sha512-vodKprLlmaKmraa9E/TxHQwpH4eKYTJbLdeQE49pb9GOmrLs68zESjJu0LQOz1W6JwJmftOWD5Ls4dpd/elQtQ==",
"dev": true,
"license": "MIT",
"optional": true
@@ -0,0 +1,457 @@
# Spielwerk
A programmable MIDI effect that lets you write custom note transformations in JavaScript. Filter, reshape, generate, or delay notes — declare parameters with knobs and hot-reload changes in real time.
---
![screenshot](spielwerk.webp)
---
## 0. Overview
_Spielwerk_ is a scriptable MIDI effect device. You write a `Processor` class in JavaScript that receives incoming notes and yields transformed or new notes. Parameters declared in the code appear as automatable knobs on the device panel.
Example uses:
- Custom velocity curves and mapping
- Pitch transposition and micro-tuning
- Chord generators
- Arpeggiators and step sequencers
- Probability-based note filtering
- Note echo and delay
- Humanization and timing randomization
---
## 1. Editor
Click the **Editor** button on the device panel to open the full-screen code editor. The editor uses Monaco (the engine behind VS Code) with JavaScript syntax highlighting.
The status bar at the bottom shows the current state:
- **Idle** — No compilation attempted yet
- **Successfully compiled** — Code compiled and loaded into the engine
- **Error message** — Syntax error, runtime error, or validation failure
---
## 2. Parameters
Declare parameters using `// @param` comments at the top of your code:
```javascript
// @param amount 0.5
// @param chance 1.0
// @param offset
```
Each `@param` directive creates an automatable knob on the device panel. The syntax is:
```
// @param <label> [default]
```
- **label** — Parameter name (single word, no spaces). Appears on the knob.
- **default** — Optional default value between 0.0 and 1.0. Defaults to 0.0 if omitted.
Parameters are reconciled on each compile: new parameters are added, removed parameters are deleted, and existing parameters keep their current value.
---
## 3. Keyboard Shortcuts
| Shortcut | Action |
|---------------------|-------------------------------------|
| `Alt+Enter` | Compile and run |
| `Ctrl+S` / `Cmd+S` | Compile, run, and save to project |
---
## 4. Safety
The engine validates every note your code yields:
- **Pitch range** — Must be 0–127. Out-of-range values silence the processor.
- **Velocity range** — Must be 0.0–1.0. Out-of-range values silence the processor.
- **Duration** — Must be positive. Zero or negative durations silence the processor.
- **Position** — Must not be in the past (before block start). Past positions silence the processor.
- **NaN detection** — If any note property is NaN, the processor is silenced.
- **Note flood** — Maximum 128 notes per block. Exceeding this silences the processor.
- **Scheduler overflow** — Maximum 128 future-scheduled notes. Exceeding this silences the processor.
- **Runtime errors** — If `processNotes()` throws, the processor is silenced.
When silenced, all active notes are released and the device passes nothing until the next successful compile.
---
## 5. API Reference
Your code must define a `class Processor` with a generator method `processNotes`. Optionally implement `paramChanged` to receive parameter updates.
### Processor class
```javascript
class Processor {
* processNotes(block, notes) {
// block.from — start position in ppqn (inclusive)
// block.to — end position in ppqn (exclusive)
// block.bpm — current project tempo in BPM
// block.flags — bitmask:
// 1 (transporting) — transport is active
// 2 (discontinuous) — position jumped (seek, loop restart)
//
// notes — iterator of upstream note starts in [from, to)
// Each note: { position, duration, pitch, velocity, cent }
//
// Yield notes with: { position, duration, pitch, velocity, cent }
// - position in [from, to) → emitted immediately
// - position >= to → held in scheduler, emitted in a future block
// - position < from → ERROR (note in the past)
for (const note of notes) {
yield note // passthrough
}
}
paramChanged(label, value) {
// label — string matching the @param name
// value — number between 0.0 and 1.0
}
reset() {
// optional — called on transport jump (seek, loop restart)
// use to clear accumulated state like held note arrays
}
}
```
### Note properties
| Property | Type | Range | Description |
|------------|----------|-----------|---------------------------------------|
| `position` | `number` | ppqn | Start position (480 ppqn per quarter) |
| `duration` | `number` | ppqn | Note length |
| `pitch` | `number` | 0–127 | MIDI note number |
| `velocity` | `number` | 0.0–1.0 | Note velocity |
| `cent` | `number` | any | Fine pitch offset in cents |
### Future scheduling
Notes with `position >= block.to` are not emitted immediately. They are held in an internal scheduler and emitted automatically when the transport reaches their position in a later block. This enables effects like echo/delay and humanizers that shift notes across block boundaries.
### State across blocks
Your processor instance persists across blocks. Use class fields to track state:
```javascript
class Processor {
held = [] // survives between blocks
counter = 0 // survives between blocks
* processNotes(block, notes) { ... }
}
```
State is reset when the code is recompiled (a new instance is created).
---
## 6. Examples
### Passthrough
```javascript
class Processor {
* processNotes(block, notes) {
for (const note of notes) {
yield note
}
}
}
```
### Velocity Curve
```javascript
// @param target 0.5
// @param strength 0.0
// @param mix 1.0
class Processor {
target = 0.5
strength = 0
mix = 1
paramChanged(label, value) {
if (label === "target") this.target = value
if (label === "strength") this.strength = value
if (label === "mix") this.mix = value
}
* processNotes(block, notes) {
for (const note of notes) {
const magnet = note.velocity + (this.target - note.velocity) * this.strength
const velocity = note.velocity * (1 - this.mix) + Math.max(0, Math.min(1, magnet)) * this.mix
yield { ...note, velocity }
}
}
}
```
### Pitch Shift
```javascript
// @param semitones 0.5
class Processor {
semitones = 0
paramChanged(label, value) {
if (label === "semitones") this.semitones = Math.round(value * 24 - 12)
}
* processNotes(block, notes) {
for (const note of notes) {
yield { ...note, pitch: note.pitch + this.semitones }
}
}
}
```
### Chord Generator
```javascript
// @param mode 0.0
class Processor {
chords = [[0, 4, 7], [0, 3, 7], [0, 4, 7, 11], [0, 3, 7, 10]]
mode = 0
paramChanged(label, value) {
if (label === "mode") this.mode = Math.floor(value * this.chords.length * 0.999)
}
* processNotes(block, notes) {
for (const note of notes) {
for (const interval of this.chords[this.mode]) {
yield { ...note, pitch: note.pitch + interval }
}
}
}
}
```
### Probability Gate
```javascript
// @param chance 0.5
class Processor {
chance = 0.5
paramChanged(label, value) {
if (label === "chance") this.chance = value
}
* processNotes(block, notes) {
for (const note of notes) {
if (Math.random() < this.chance) {
yield note
}
}
}
}
```
### Echo / Note Delay
```javascript
// @param repeats 0.25
// @param delay 0.25
// @param decay 0.7
class Processor {
repeats = 3
delay = 120
decay = 0.7
paramChanged(label, value) {
if (label === "repeats") this.repeats = Math.round(value * 7) + 1
if (label === "delay") this.delay = Math.round(24 + value * 456)
if (label === "decay") this.decay = value
}
* processNotes(block, notes) {
for (const note of notes) {
for (let i = 0; i < this.repeats; i++) {
yield {
...note,
position: note.position + i * this.delay,
velocity: note.velocity * Math.pow(this.decay, i)
}
}
}
}
}
```
Future repeats are automatically scheduled and emitted in the correct block.
### Humanizer
```javascript
// @param timing 0.2
// @param velRange 0.3
class Processor {
timing = 10
velRange = 0.1
paramChanged(label, value) {
if (label === "timing") this.timing = value * 50
if (label === "velRange") this.velRange = value * 0.3
}
* processNotes(block, notes) {
for (const note of notes) {
yield {
...note,
position: note.position + Math.random() * this.timing,
velocity: Math.max(0, Math.min(1, note.velocity + (Math.random() - 0.5) * this.velRange))
}
}
}
}
```
### Arpeggiator
```javascript
// @param rate 0.25
// @param gate 0.8
class Processor {
rate = 120
gate = 0.8
held = []
paramChanged(label, value) {
if (label === "rate") this.rate = Math.round(24 + value * 456)
if (label === "gate") this.gate = 0.1 + value * 0.9
}
reset() {
this.held = []
}
activeAt(position) {
return this.held
.filter(note => note.position <= position && position < note.position + note.duration)
.sort((noteA, noteB) => noteA.pitch - noteB.pitch)
}
* processNotes(block, notes) {
for (const note of notes) {
this.held.push(note)
}
this.held = this.held.filter(note => note.position + note.duration > block.from)
const duration = Math.max(1, Math.floor(this.rate * this.gate))
let index = Math.ceil(block.from / this.rate)
let position = index * this.rate
while (position < block.to) {
const stack = this.activeAt(position)
if (stack.length > 0) {
const source = stack[index % stack.length]
yield { position, duration, pitch: source.pitch, velocity: source.velocity, cent: source.cent }
}
position = ++index * this.rate
}
}
}
```
---
## 7. AI Prompt
Copy the following prompt into an AI assistant to get help writing Spielwerk processors:
```
You are helping the user write a MIDI effect processor for the openDAW Spielwerk device.
The user writes plain JavaScript (no imports, no modules). The code runs inside an AudioWorklet.
The code MUST define a class called `Processor` with the following interface:
class Processor {
* processNotes(block, notes) { } // required, generator function
paramChanged(label, value) { } // optional
reset() { } // optional, called on transport jump
}
## processNotes(block, notes)
A generator function called on every audio block. Receives upstream notes and yields output notes.
block (timing and transport):
- block.from — start position in ppqn, inclusive (480 ppqn = 1 quarter note)
- block.to — end position in ppqn, exclusive
- block.bpm — current project tempo in beats per minute
- block.flags — bitmask of transport state:
1 = transporting (transport is active)
2 = discontinuous (position jumped, e.g. seek or loop restart)
notes (upstream note starts):
- An iterator of note objects in the range [block.from, block.to)
- Each note has: { position, duration, pitch, velocity, cent }
- position: number (ppqn) — when the note starts
- duration: number (ppqn) — how long the note lasts
- pitch: number (0–127) — MIDI note number
- velocity: number (0.0–1.0) — note velocity
- cent: number — fine pitch offset in cents
You MUST yield note objects with the same shape: { position, duration, pitch, velocity, cent }
Position rules:
- position >= block.from and < block.to → emitted immediately
- position >= block.to → held in an internal scheduler and emitted in the correct future block
- position < block.from → ERROR, the processor will be silenced
You can yield zero notes (filter), the same note modified (transform), or multiple notes
per input (generator). You can also yield notes not derived from any input.
## paramChanged(label, value)
Called when a parameter knob changes value.
- label — string, matches the name from the @param comment
- value — number between 0.0 and 1.0
## Declaring parameters
Parameters are declared as comments at the top of the file:
// @param <name> [default]
- name: single word, no spaces
- default: optional float between 0.0 and 1.0, defaults to 0.0
Each @param creates an automatable knob on the device UI.
Examples:
// @param amount 0.5
// @param chance 1.0
// @param rate
## reset()
Optional. Called when the transport jumps (seek, loop restart — discontinuous flag).
Use to clear accumulated state like held note arrays. If not implemented, the host
still clears its own internal state (retainer, scheduler), but YOUR state persists.
## State
The Processor instance persists across blocks. Use class fields to store state
(e.g., held notes for an arpeggiator, counters, buffers). State is reset on recompile.
Implement reset() to clear state on transport jumps.
## Constraints
- Notes are validated every block. Invalid pitch (outside 0-127), velocity (outside 0-1),
NaN values, negative duration, or position in the past will silence the processor.
- Maximum 128 notes per block. Exceeding this will silence the processor.
- Maximum 128 scheduled (future) notes. Exceeding this will silence the processor.
- Do not use import/export/require. No access to DOM or fetch.
- The code runs in an AudioWorklet thread. Only AudioWorklet-safe APIs are available
(Math, typed arrays, basic JS). No console, no setTimeout, no DOM.
- You can define and use helper classes alongside the Processor class.
## Template
// @param amount 0.5
class Processor {
amount = 0.5
paramChanged(label, value) {
if (label === "amount") this.amount = value
}
* processNotes(block, notes) {
for (const note of notes) {
yield { ...note, velocity: note.velocity * this.amount }
}
}
}
```
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

@@ -20,6 +20,7 @@ import {
RevampDeviceBox,
ReverbDeviceBox,
SoundfontDeviceBox,
SpielwerkDeviceBox,
StereoToolDeviceBox,
TapeDeviceBox,
TidalDeviceBox,
@@ -53,6 +54,7 @@ import {
RevampDeviceBoxAdapter,
ReverbDeviceBoxAdapter,
SoundfontDeviceBoxAdapter,
SpielwerkDeviceBoxAdapter,
StereoToolDeviceBoxAdapter,
TapeDeviceBoxAdapter,
TidalDeviceBoxAdapter,
@@ -93,6 +95,7 @@ import {TidalDeviceEditor} from "@/ui/devices/audio-effects/TidalDeviceEditor"
import {DattorroReverbDeviceEditor} from "@/ui/devices/audio-effects/DattorroReverbDeviceEditor"
import {NeuralAmpDeviceEditor} from "@/ui/devices/audio-effects/NeuralAmpDeviceEditor"
import {WaveshaperDeviceEditor} from "@/ui/devices/audio-effects/WaveshaperDeviceEditor"
import {SpielwerkDeviceEditor} from "@/ui/devices/midi-effects/SpielwerkDeviceEditor"
import {WerkstattDeviceEditor} from "@/ui/devices/audio-effects/WerkstattDeviceEditor"
export namespace DeviceEditorFactory {
@@ -128,6 +131,12 @@ export namespace DeviceEditorFactory {
service={service}
adapter={service.project.boxAdapters.adapterFor(box, ZeitgeistDeviceBoxAdapter)}
deviceHost={deviceHost}/>
),
visitSpielwerkDeviceBox: (box: SpielwerkDeviceBox) => (
<SpielwerkDeviceEditor lifecycle={lifecycle}
service={service}
adapter={service.project.boxAdapters.adapterFor(box, SpielwerkDeviceBoxAdapter)}
deviceHost={deviceHost}/>
)
}), `No MidiEffectDeviceEditor found for ${box}`)
@@ -12,6 +12,7 @@ component
> .knobs
display: grid
grid-template-rows: repeat(3, 3.5em)
grid-template-columns: repeat(1, 3.5em)
grid-auto-columns: 3.5em
grid-auto-flow: column
row-gap: 0.25em
@@ -0,0 +1,132 @@
import {asInstanceOf, Editing, isDefined, Nullable, UUID} from "@opendaw/lib-std"
import {SpielwerkDeviceBox, WerkstattParameterBox} from "@opendaw/studio-boxes"
const COMPILER_VERSION = 1
const HEADER_PATTERN = /^\/\/ @spielwerk (\w+) (\d+) (\d+)\n/
const PARAM_PATTERN = /^\/\/ @param (\w+)(?: ([.\d]+))?$/gm
interface ParamDeclaration {
label: string
defaultValue: number
}
const parseHeader = (source: string): { userCode: string, update: number } => {
const match = source.match(HEADER_PATTERN)
return match !== null ? {
userCode: source.slice(match[0].length),
update: parseInt(match[3])
} : {
userCode: source,
update: 0
}
}
const createHeader = (update: number): string => `// @spielwerk js ${COMPILER_VERSION} ${update}\n`
const parseParams = (code: string): ReadonlyArray<ParamDeclaration> => {
const params: Array<ParamDeclaration> = []
let match: Nullable<RegExpExecArray>
PARAM_PATTERN.lastIndex = 0
while ((match = PARAM_PATTERN.exec(code)) !== null) {
params.push({
label: match[1],
defaultValue: isDefined(match[2]) ? parseFloat(match[2]) : 0.0
})
}
return params
}
const reconcileParameters = (deviceBox: SpielwerkDeviceBox, declared: ReadonlyArray<ParamDeclaration>): void => {
const boxGraph = deviceBox.graph
const existingPointers = deviceBox.parameters.pointerHub.filter()
const existingByLabel = new Map<string, WerkstattParameterBox>()
for (const pointer of existingPointers) {
const paramBox = asInstanceOf(pointer.box, WerkstattParameterBox)
existingByLabel.set(paramBox.label.getValue(), paramBox)
}
const seen = new Set<string>()
for (const {label} of declared) {seen.add(label)}
for (const [label, paramBox] of existingByLabel) {
if (!seen.has(label)) {
paramBox.delete()
}
}
seen.clear()
for (let index = 0; index < declared.length; index++) {
const {label, defaultValue} = declared[index]
if (seen.has(label)) {continue}
seen.add(label)
const existing = existingByLabel.get(label)
if (isDefined(existing)) {
existing.index.setValue(index)
if (existing.defaultValue.getValue() !== defaultValue) {
existing.defaultValue.setValue(defaultValue)
existing.value.setValue(defaultValue)
}
} else {
WerkstattParameterBox.create(boxGraph, UUID.generate(), paramBox => {
paramBox.owner.refer(deviceBox.parameters)
paramBox.label.setValue(label)
paramBox.index.setValue(index)
paramBox.value.setValue(defaultValue)
paramBox.defaultValue.setValue(defaultValue)
})
}
}
}
const registerWorklet = async (audioContext: BaseAudioContext,
uuid: string,
update: number,
userCode: string): Promise<void> => {
const wrappedCode = `
if (typeof globalThis.openDAW === "undefined") { globalThis.openDAW = {} }
if (typeof globalThis.openDAW.spielwerkProcessors === "undefined") { globalThis.openDAW.spielwerkProcessors = {} }
globalThis.openDAW.spielwerkProcessors["${uuid}"] = {
update: ${update},
create: (function spielwerk() {
${userCode}
return Processor
})()
}
`
new Function(wrappedCode)
const blob = new Blob([wrappedCode], {type: "application/javascript"})
const blobUrl = URL.createObjectURL(blob)
try {
await audioContext.audioWorklet.addModule(blobUrl)
} finally {
URL.revokeObjectURL(blobUrl)
}
}
export namespace SpielwerkCompiler {
export const stripHeader = (source: string): string => parseHeader(source).userCode
export const load = async (audioContext: BaseAudioContext, deviceBox: SpielwerkDeviceBox): Promise<void> => {
const {userCode, update} = parseHeader(deviceBox.code.getValue())
if (update === 0) {return}
const uuid = UUID.toString(deviceBox.address.uuid)
return registerWorklet(audioContext, uuid, update, userCode)
}
export const compile = async (audioContext: BaseAudioContext,
editing: Editing,
deviceBox: SpielwerkDeviceBox,
source: string,
append: boolean = false): Promise<void> => {
const userCode = parseHeader(source).userCode
const currentUpdate = parseHeader(deviceBox.code.getValue()).update
const newUpdate = currentUpdate + 1
const uuid = UUID.toString(deviceBox.address.uuid)
const params = parseParams(userCode)
const modifier = () => {
deviceBox.code.setValue(createHeader(newUpdate) + userCode)
reconcileParameters(deviceBox, params)
}
if (append) {
editing.append(modifier)
} else {
editing.modify(modifier)
}
return registerWorklet(audioContext, uuid, newUpdate, userCode)
}
}
@@ -0,0 +1,18 @@
@use "@/mixins"
component
display: flex
flex-direction: row
align-items: stretch
align-content: start
column-gap: 1em
margin-right: 1em
@include mixins.Control
> .knobs
display: grid
grid-template-rows: repeat(3, 3.5em)
grid-template-columns: repeat(1, 3.5em)
grid-auto-columns: 3.5em
grid-auto-flow: column
row-gap: 0.25em
@@ -0,0 +1,120 @@
import css from "./SpielwerkDeviceEditor.sass?inline"
import defaultCode from "./spielwerk-default.txt?raw"
import {DeviceHost, SpielwerkDeviceBoxAdapter} from "@opendaw/studio-adapters"
import {asInstanceOf, EmptyExec, Lifecycle, Terminable, Terminator, UUID} from "@opendaw/lib-std"
import {createElement} from "@opendaw/lib-jsx"
import {DeviceEditor} from "@/ui/devices/DeviceEditor.tsx"
import {MenuItems} from "@/ui/devices/menu-items.ts"
import {Html} from "@opendaw/lib-dom"
import {StudioService} from "@/service/StudioService"
import {SpielwerkCompiler} from "./SpielwerkCompiler"
import {WerkstattParameterBox} from "@opendaw/studio-boxes"
import {ControlBuilder} from "@/ui/devices/ControlBuilder"
import {Button} from "@/ui/components/Button"
import {Icon} from "@/ui/components/Icon"
import {IconSymbol} from "@opendaw/studio-enums"
const className = Html.adoptStyleSheet(css, "SpielwerkDeviceEditor")
type Construct = {
lifecycle: Lifecycle
service: StudioService
adapter: SpielwerkDeviceBoxAdapter
deviceHost: DeviceHost
}
export const SpielwerkDeviceEditor = ({lifecycle, service, adapter, deviceHost}: Construct) => {
const {project} = service
const {editing, midiLearning} = project
const box = adapter.box
const storedCode = box.code.getValue()
const userCode = storedCode.length > 0 ? SpielwerkCompiler.stripHeader(storedCode) : defaultCode
const compile = async (code: string) => SpielwerkCompiler.compile(service.audioContext, editing, box, code)
if (storedCode.length > 0) {
SpielwerkCompiler.load(service.audioContext, box).finally(EmptyExec)
} else {
SpielwerkCompiler.compile(service.audioContext, editing, box, userCode, true).finally(EmptyExec)
}
const toggleEditor = () => {
const isActive = service.activeCodeEditor
.map(state => UUID.equals(state.handler.uuid, adapter.uuid)).unwrapOrElse(false)
if (isActive) {
service.closeCodeEditor()
} else {
service.openWerkstattEditor({
handler: {
uuid: adapter.uuid,
name: adapter.labelField,
compile,
subscribeErrors: observer =>
service.engine.subscribeDeviceMessage(UUID.toString(adapter.uuid), observer),
subscribeCode: observer =>
box.code.subscribe(owner => observer(SpielwerkCompiler.stripHeader(owner.getValue())))
},
initialCode: SpielwerkCompiler.stripHeader(box.code.getValue()) || defaultCode,
previousScreen: service.layout.screen.getValue()
})
}
}
const knobs: HTMLElement = (<div className="knobs"/>)
const toggleEditorButton: HTMLElement = (
<Button lifecycle={lifecycle}
onClick={toggleEditor}
appearance={{framed: true, tooltip: "Toggle Code Editor"}}
style={{
fontSize: "16px",
height: "min-content",
marginTop: "1em"
}}><Icon symbol={IconSymbol.Code}/></Button>
)
const set = UUID.newSet<{ uuid: UUID.Bytes, lifecycle: Terminable }>(({uuid}) => uuid)
lifecycle.ownAll(
{
terminate: () => {
const isActive = service.activeCodeEditor
.map(state => UUID.equals(state.handler.uuid, adapter.uuid)).unwrapOrElse(false)
if (isActive) {service.closeCodeEditor()}
}
},
service.activeCodeEditor.catchupAndSubscribe(option => {
const isActive = option.map(state => UUID.equals(state.handler.uuid, adapter.uuid)).unwrapOrElse(false)
toggleEditorButton.classList.toggle("active", isActive)
}),
box.parameters.pointerHub.catchupAndSubscribe({
onAdded: ({box: paramBox}) => {
const werkstattParam = asInstanceOf(paramBox, WerkstattParameterBox)
const parameter = adapter.parameters.parameterAt(werkstattParam.value.address)
const terminator = new Terminator()
const element: HTMLElement = ControlBuilder.createKnob({
lifecycle: terminator,
editing,
midiLearning,
adapter,
parameter
})
element.style.order = String(werkstattParam.index.getValue())
terminator.own(werkstattParam.index.catchupAndSubscribe(owner =>
element.style.order = String(owner.getValue())))
knobs.appendChild(element)
set.add({uuid: paramBox.address.uuid, lifecycle: terminator})
terminator.own({terminate: () => element.remove()})
},
onRemoved: ({box: {address: {uuid}}}) =>
set.removeByKey(uuid).lifecycle.terminate()
})
)
return (
<DeviceEditor lifecycle={lifecycle}
project={project}
adapter={adapter}
populateMenu={parent => MenuItems.forEffectDevice(parent, service, deviceHost, adapter)}
populateControls={() => (
<div className={className}>
{knobs}
{toggleEditorButton}
</div>
)}
populateMeter={() => null}
icon={IconSymbol.Code}/>
)
}
@@ -0,0 +1,7 @@
class Processor {
* processNotes(block, notes) {
for (const note of notes) {
yield note
}
}
}
@@ -58,6 +58,7 @@ import {
SignatureEventBox,
SoundfontDeviceBox,
SoundfontFileBox,
SpielwerkDeviceBox,
StereoToolDeviceBox,
TapeDeviceBox,
TidalDeviceBox,
@@ -111,6 +112,7 @@ import {TapeDeviceBoxAdapter} from "./devices/instruments/TapeDeviceBoxAdapter"
import {VaporisateurDeviceBoxAdapter} from "./devices/instruments/VaporisateurDeviceBoxAdapter"
import {ArpeggioDeviceBoxAdapter} from "./devices/midi-effects/ArpeggioDeviceBoxAdapter"
import {PitchDeviceBoxAdapter} from "./devices/midi-effects/PitchDeviceBoxAdapter"
import {SpielwerkDeviceBoxAdapter} from "./devices/midi-effects/SpielwerkDeviceBoxAdapter"
import {NanoDeviceBoxAdapter} from "./devices/instruments/NanoDeviceBoxAdapter"
import {PlayfieldDeviceBoxAdapter} from "./devices/instruments/PlayfieldDeviceBoxAdapter"
import {StereoToolDeviceBoxAdapter} from "./devices/audio-effects/StereoToolDeviceBoxAdapter"
@@ -242,6 +244,7 @@ export class BoxAdapters implements Terminable {
visitRootBox: (box: RootBox): BoxAdapter => new RootBoxAdapter(this.#context, box),
visitSoundfontDeviceBox: (box: SoundfontDeviceBox) => new SoundfontDeviceBoxAdapter(this.#context, box),
visitSoundfontFileBox: (box: SoundfontFileBox) => new SoundfontFileBoxAdapter(this.#context, box),
visitSpielwerkDeviceBox: (box: SpielwerkDeviceBox) => new SpielwerkDeviceBoxAdapter(this.#context, box),
visitStereoToolDeviceBox: (box: StereoToolDeviceBox) => new StereoToolDeviceBoxAdapter(this.#context, box),
visitTapeDeviceBox: (box: TapeDeviceBox) => new TapeDeviceBoxAdapter(this.#context, box),
visitTimelineBox: (box: TimelineBox) => new TimelineBoxAdapter(this.#context, box),
@@ -5,6 +5,7 @@ export namespace DeviceManualUrls {
// MIDI Effects
export const Arpeggio = "manuals/devices/midi/arpeggio"
export const Pitch = "manuals/devices/midi/pitch"
export const Spielwerk = "manuals/devices/midi/spielwerk"
export const Velocity = "manuals/devices/midi/velocity"
export const Zeitgeist = "manuals/devices/midi/zeitgeist"
@@ -0,0 +1,57 @@
import {asInstanceOf, StringMapping, Terminator, UUID, ValueMapping} from "@opendaw/lib-std"
import {Address, BooleanField, Int32Field, PointerField, StringField} from "@opendaw/lib-box"
import {SpielwerkDeviceBox, WerkstattParameterBox} from "@opendaw/studio-boxes"
import {Pointers} from "@opendaw/studio-enums"
import {DeviceHost, Devices, MidiEffectDeviceAdapter} from "../../DeviceAdapter"
import {BoxAdaptersContext} from "../../BoxAdaptersContext"
import {DeviceManualUrls} from "../../DeviceManualUrls"
import {AudioUnitBoxAdapter} from "../../audio-unit/AudioUnitBoxAdapter"
import {ParameterAdapterSet} from "../../ParameterAdapterSet"
export class SpielwerkDeviceBoxAdapter implements MidiEffectDeviceAdapter {
readonly #terminator = new Terminator()
readonly type = "midi-effect"
readonly accepts = "midi"
readonly manualUrl = DeviceManualUrls.Spielwerk
readonly #context: BoxAdaptersContext
readonly #box: SpielwerkDeviceBox
readonly #parametric: ParameterAdapterSet
constructor(context: BoxAdaptersContext, box: SpielwerkDeviceBox) {
this.#context = context
this.#box = box
this.#parametric = this.#terminator.own(new ParameterAdapterSet(this.#context))
this.#terminator.own(
box.parameters.pointerHub.catchupAndSubscribe({
onAdded: (({box}) => this.#parametric
.createParameter(
asInstanceOf(box, WerkstattParameterBox).value,
ValueMapping.unipolar(), StringMapping.percent({fractionDigits: 1}),
asInstanceOf(box, WerkstattParameterBox).label.getValue())),
onRemoved: (({box}) => this.#parametric
.removeParameter(asInstanceOf(box, WerkstattParameterBox).value.address))
})
)
}
get box(): SpielwerkDeviceBox {return this.#box}
get uuid(): UUID.Bytes {return this.#box.address.uuid}
get address(): Address {return this.#box.address}
get indexField(): Int32Field {return this.#box.index}
get labelField(): StringField {return this.#box.label}
get enabledField(): BooleanField {return this.#box.enabled}
get minimizedField(): BooleanField {return this.#box.minimized}
get host(): PointerField<Pointers.MIDIEffectHost> {return this.#box.host}
get parameters(): ParameterAdapterSet {return this.#parametric}
deviceHost(): DeviceHost {
return this.#context.boxAdapters
.adapterFor(this.#box.host.targetVertex.unwrap("no device-host").box, Devices.isHost)
}
audioUnitBoxAdapter(): AudioUnitBoxAdapter {return this.deviceHost().audioUnitBoxAdapter()}
terminate(): void {this.#terminator.terminate()}
}
@@ -18,6 +18,7 @@ import {
RevampDeviceBox,
ReverbDeviceBox,
SoundfontDeviceBox,
SpielwerkDeviceBox,
StereoToolDeviceBox,
TapeDeviceBox,
TidalDeviceBox,
@@ -49,6 +50,7 @@ import {
RevampDeviceBoxAdapter,
ReverbDeviceBoxAdapter,
SoundfontDeviceBoxAdapter,
SpielwerkDeviceBoxAdapter,
StereoToolDeviceBoxAdapter,
TapeDeviceBoxAdapter,
TidalDeviceBoxAdapter,
@@ -92,6 +94,7 @@ import {DattorroReverbDeviceProcessor} from "./devices/audio-effects/DattorroRev
import {NeuralAmpDeviceProcessor} from "./devices/audio-effects/NeuralAmpDeviceProcessor"
import {WaveshaperDeviceProcessor} from "./devices/audio-effects/WaveshaperDeviceProcessor"
import {WerkstattDeviceProcessor} from "./devices/audio-effects/WerkstattDeviceProcessor"
import {SpielwerkDeviceProcessor} from "./devices/midi-effects/SpielwerkDeviceProcessor"
export namespace InstrumentDeviceProcessorFactory {
export const create = (context: EngineContext,
@@ -127,7 +130,9 @@ export namespace MidiEffectDeviceProcessorFactory {
visitVelocityDeviceBox: (box: VelocityDeviceBox): MidiEffectProcessor =>
new VelocityDeviceProcessor(context, context.boxAdapters.adapterFor(box, VelocityDeviceBoxAdapter)),
visitZeitgeistDeviceBox: (box: ZeitgeistDeviceBox): MidiEffectProcessor =>
new ZeitgeistDeviceProcessor(context, context.boxAdapters.adapterFor(box, ZeitgeistDeviceBoxAdapter))
new ZeitgeistDeviceProcessor(context, context.boxAdapters.adapterFor(box, ZeitgeistDeviceBoxAdapter)),
visitSpielwerkDeviceBox: (box: SpielwerkDeviceBox): MidiEffectProcessor =>
new SpielwerkDeviceProcessor(context, context.boxAdapters.adapterFor(box, SpielwerkDeviceBoxAdapter))
}), `Could not create midi-effect for'${box.name}'`)
}
@@ -0,0 +1,310 @@
import {
Arrays,
asInstanceOf,
assert,
Bits,
byte,
float,
Id,
int,
isDefined,
Nullable,
Option,
Terminable,
UUID
} from "@opendaw/lib-std"
import {EngineToClient, SpielwerkDeviceBoxAdapter} from "@opendaw/studio-adapters"
import {WerkstattParameterBox} from "@opendaw/studio-boxes"
import {Event, EventSpanRetainer, NoteEvent, ppqn} from "@opendaw/lib-dsp"
import {EngineContext} from "../../EngineContext"
import {EventProcessor} from "../../EventProcessor"
import {Block, BlockFlag, Processor} from "../../processing"
import {AutomatableParameter} from "../../AutomatableParameter"
import {NoteEventSource, NoteEventTarget, NoteLifecycleEvent} from "../../NoteEventSource"
import {MidiEffectProcessor} from "../../MidiEffectProcessor"
const HEADER_PATTERN = /^\/\/ @spielwerk (\w+) (\d+) (\d+)\n/
const MAX_NOTES_PER_BLOCK: int = 128
const MAX_SCHEDULED_NOTES: int = 128
const parseUpdate = (code: string): int => {
const match = code.match(HEADER_PATTERN)
return match !== null ? parseInt(match[3]) : -1
}
interface ScheduledNote {
readonly position: ppqn
readonly duration: ppqn
readonly pitch: byte
readonly velocity: float
readonly cent: number
}
interface UserNote {
readonly position: ppqn
readonly duration: ppqn
readonly pitch: byte
readonly velocity: float
readonly cent: number
}
interface UserBlock {
readonly from: ppqn
readonly to: ppqn
readonly bpm: number
readonly s0: int
readonly s1: int
readonly flags: int
}
interface UserProcessor {
processNotes(block: UserBlock, notes: IterableIterator<UserNote>): IterableIterator<UserNote>
paramChanged?(label: string, value: number): void
reset?(): void
}
const validateNote = (note: any, from: ppqn): Nullable<string> => {
if (!isDefined(note)) {return "processNotes yielded undefined"}
if (typeof note.pitch !== "number" || note.pitch !== note.pitch) {return `Invalid pitch: ${note.pitch}`}
if (note.pitch < 0 || note.pitch > 127) {return `Pitch out of range: ${note.pitch} (must be 0–127)`}
if (typeof note.velocity !== "number" || note.velocity !== note.velocity) {return `Invalid velocity: ${note.velocity}`}
if (note.velocity < 0 || note.velocity > 1) {return `Velocity out of range: ${note.velocity} (must be 0–1)`}
if (typeof note.duration !== "number" || note.duration !== note.duration) {return `Invalid duration: ${note.duration}`}
if (note.duration <= 0) {return `Duration must be positive: ${note.duration}`}
if (typeof note.position !== "number" || note.position !== note.position) {return `Invalid position: ${note.position}`}
if (note.position < from) {return `Position ${note.position} is in the past (block starts at ${from})`}
return null
}
export class SpielwerkDeviceProcessor extends EventProcessor implements MidiEffectProcessor {
readonly #adapter: SpielwerkDeviceBoxAdapter
readonly #engineToClient: EngineToClient
readonly #retainer: EventSpanRetainer<Id<NoteEvent>>
readonly #scheduled: Array<ScheduledNote>
readonly #uuid: string
readonly #boundParameters: Array<AutomatableParameter<number>>
#source: Option<NoteEventSource> = Option.None
#userProcessor: Option<UserProcessor> = Option.None
#currentUpdate: int = -1
#silenced: boolean = false
#wasPlaying: boolean = false
constructor(context: EngineContext, adapter: SpielwerkDeviceBoxAdapter) {
super(context)
this.#adapter = adapter
this.#engineToClient = context.engineToClient
this.#retainer = new EventSpanRetainer<Id<NoteEvent>>()
this.#scheduled = []
this.#uuid = UUID.toString(adapter.uuid)
this.#boundParameters = []
const {parameters, box} = adapter
this.ownAll(
box.code.catchupAndSubscribe(owner => {
const newUpdate = parseUpdate(owner.getValue())
if (newUpdate > 0 && newUpdate !== this.#currentUpdate) {
this.#silenced = true
this.#userProcessor = Option.None
this.#tryLoad(newUpdate)
}
}),
box.parameters.pointerHub.catchupAndSubscribe({
onAdded: (({box}) => {
const paramBox = asInstanceOf(box, WerkstattParameterBox)
const bound = this.bindParameter(parameters.parameterAt(paramBox.value.address))
this.#boundParameters.push(bound)
this.parameterChanged(bound)
}),
onRemoved: (({box}) => {
const paramBox = asInstanceOf(box, WerkstattParameterBox)
Arrays.removeIf(this.#boundParameters, parameter =>
parameter.address === paramBox.value.address)
})
}),
context.registerProcessor(this)
)
this.readAllParameters()
}
get uuid(): UUID.Bytes {return this.#adapter.uuid}
get incoming(): Processor {return this}
get outgoing(): Processor {return this}
get noteEventTarget(): Option<NoteEventTarget> {return Option.wrap(this)}
setNoteEventSource(source: NoteEventSource): Terminable {
assert(this.#source.isEmpty(), "NoteEventSource already set")
this.#source = Option.wrap(source)
return Terminable.create(() => this.#source = Option.None)
}
* processNotes(from: ppqn, to: ppqn, flags: int): IterableIterator<NoteLifecycleEvent> {
const playing = Bits.every(flags, BlockFlag.transporting | BlockFlag.playing)
if (this.#retainer.nonEmpty()) {
if (Bits.every(flags, BlockFlag.discontinuous) || (this.#wasPlaying && !playing)) {
for (const event of this.#retainer.releaseAll()) {
yield NoteLifecycleEvent.stop(event, from)
}
Arrays.clear(this.#scheduled)
this.#userProcessor.ifSome(proc => {
if (isDefined(proc.reset)) {proc.reset()}
})
} else {
for (const event of this.#retainer.releaseLinearCompleted(to)) {
yield NoteLifecycleEvent.stop(event, event.position + event.duration)
}
}
}
this.#wasPlaying = playing
if (this.#silenced) {
const expectedUpdate = parseUpdate(this.#adapter.box.code.getValue())
if (expectedUpdate > 0 && expectedUpdate !== this.#currentUpdate) {
this.#tryLoad(expectedUpdate)
}
}
if (this.#source.nonEmpty() && this.#userProcessor.nonEmpty() && !this.#silenced) {
const source = this.#source.unwrap()
const proc = this.#userProcessor.unwrap()
const starts: Array<UserNote> = []
let hasStops = false
for (const event of source.processNotes(from, to, flags)) {
if (NoteLifecycleEvent.isStart(event)) {
starts.push({
position: event.position,
duration: event.duration,
pitch: event.pitch,
velocity: event.velocity,
cent: event.cent
})
} else {
hasStops = true
}
}
if (hasStops && !playing) {
for (const event of this.#retainer.releaseAll()) {
yield NoteLifecycleEvent.stop(event, from)
}
Arrays.clear(this.#scheduled)
proc.reset?.()
}
if (Bits.every(flags, BlockFlag.transporting)) {
for (let i = this.#scheduled.length - 1; i >= 0; i--) {
const note = this.#scheduled[i]
if (note.position >= from && note.position < to) {
this.#scheduled.splice(i, 1)
yield* this.#emitNote(note)
}
}
}
const userBlock: UserBlock = {from, to, bpm: 0, s0: 0, s1: 0, flags}
try {
let noteCount: int = 0
for (const yielded of proc.processNotes(userBlock, starts[Symbol.iterator]())) {
if (++noteCount > MAX_NOTES_PER_BLOCK) {
this.#silence(`Note flood: exceeded ${MAX_NOTES_PER_BLOCK} notes per block`)
return
}
const error = validateNote(yielded, from)
if (error !== null) {
this.#silence(error)
return
}
if (yielded.position >= to) {
if (this.#scheduled.length >= MAX_SCHEDULED_NOTES) {
this.#silence(`Scheduler full: exceeded ${MAX_SCHEDULED_NOTES} scheduled notes`)
return
}
this.#scheduled.push({
position: yielded.position,
duration: yielded.duration,
pitch: yielded.pitch,
velocity: yielded.velocity,
cent: yielded.cent ?? 0
})
} else {
yield* this.#emitNote(yielded)
}
}
} catch (err) {
this.#silence(`Runtime error: ${err}`)
return
}
for (const event of this.#retainer.releaseLinearCompleted(to)) {
yield NoteLifecycleEvent.stop(event, event.position + event.duration)
}
}
}
* iterateActiveNotesAt(position: ppqn, _onlyExternal: boolean): IterableIterator<NoteEvent> {
yield* this.#retainer.overlapping(position, NoteEvent.Comparator)
}
reset(): void {
this.#retainer.clear()
Arrays.clear(this.#scheduled)
this.eventInput.clear()
}
processEvents(_block: Block, _from: ppqn, _to: ppqn): void {}
parameterChanged(parameter: AutomatableParameter): void {
this.#userProcessor.ifSome(proc => {
if (isDefined(proc.paramChanged)) {
const paramBox = asInstanceOf(parameter.adapter.field.box, WerkstattParameterBox)
proc.paramChanged(paramBox.label.getValue(), parameter.getValue())
}
})
}
handleEvent(_block: Block, _event: Event): void {}
index(): number {return this.#adapter.indexField.getValue()}
adapter(): SpielwerkDeviceBoxAdapter {return this.#adapter}
#tryLoad(update: int): void {
const registry = (globalThis as any).openDAW?.spielwerkProcessors?.[this.#uuid]
if (isDefined(registry) && registry.update === update) {
this.#swapProcessor(registry.create, update)
}
}
#swapProcessor(ProcessorClass: any, update: int): void {
try {
this.#userProcessor = Option.wrap(new ProcessorClass() as UserProcessor)
this.#currentUpdate = update
this.#silenced = false
this.#pushAllParameters()
} catch (error) {
this.#silence(`Failed to instantiate Processor: ${error}`)
}
}
#pushAllParameters(): void {
this.#userProcessor.ifSome(proc => {
if (isDefined(proc.paramChanged)) {
for (const bound of this.#boundParameters) {
const paramBox = asInstanceOf(bound.adapter.field.box, WerkstattParameterBox)
proc.paramChanged(paramBox.label.getValue(), bound.getValue())
}
}
})
}
#reportError(message: string): void {
this.#engineToClient.deviceMessage(this.#uuid, message)
}
#silence(message: string): void {
this.#silenced = true
Arrays.clear(this.#scheduled)
this.#reportError(message)
}
* #emitNote(note: ScheduledNote): IterableIterator<NoteLifecycleEvent> {
const lifecycle = NoteLifecycleEvent.start(note.position, note.duration, note.pitch, note.velocity, note.cent)
this.#retainer.addAndRetain({...lifecycle})
yield lifecycle
}
}
+2
View File
@@ -18,12 +18,14 @@ import {
UnknownMidiEffectDeviceBox,
VelocityDeviceBox,
WaveshaperDeviceBox,
SpielwerkDeviceBox,
WerkstattDeviceBox,
ZeitgeistDeviceBox
} from "@opendaw/studio-boxes"
export type EffectBox =
| ArpeggioDeviceBox | PitchDeviceBox | VelocityDeviceBox | ZeitgeistDeviceBox | UnknownMidiEffectDeviceBox
| SpielwerkDeviceBox
| MaximizerDeviceBox | DelayDeviceBox | ReverbDeviceBox | RevampDeviceBox | StereoToolDeviceBox | TidalDeviceBox
| ModularDeviceBox | UnknownAudioEffectDeviceBox | CompressorDeviceBox | GateDeviceBox
| CrusherDeviceBox | FoldDeviceBox | DattorroReverbDeviceBox | NeuralAmpDeviceBox | WaveshaperDeviceBox
+19 -1
View File
@@ -20,6 +20,7 @@ import {
ReverbDeviceBox,
StereoToolDeviceBox,
TidalDeviceBox,
SpielwerkDeviceBox,
VelocityDeviceBox,
WaveshaperDeviceBox,
WerkstattDeviceBox,
@@ -112,6 +113,22 @@ export namespace EffectFactories {
}
}
export const Spielwerk: EffectFactory = {
defaultName: "Spielwerk",
defaultIcon: IconSymbol.Code,
description: "User-scripted MIDI effect processor",
manualPage: DeviceManualUrls.Spielwerk,
separatorBefore: false,
external: false,
type: "midi",
create: ({boxGraph}, hostField, index) =>
SpielwerkDeviceBox.create(boxGraph, UUID.generate(), (box) => {
box.label.setValue("Spielwerk")
box.index.setValue(index)
box.host.refer(hostField)
})
}
export const StereoTool: EffectFactory = {
defaultName: "Stereo Tool",
defaultIcon: IconSymbol.Stereo,
@@ -402,7 +419,8 @@ export namespace EffectFactories {
Arpeggio,
Pitch,
Velocity,
Zeitgeist
Zeitgeist,
Spielwerk
}
export const AudioNamed = {
@@ -28,6 +28,7 @@ import {NeuralAmpDeviceBox} from "./audio-effects/NeuralAmpDeviceBox"
import {WaveshaperDeviceBox} from "./audio-effects/WaveshaperDeviceBox"
import {WerkstattDeviceBox} from "./audio-effects/WerkstattDeviceBox"
import {WerkstattParameterBox} from "./audio-effects/WerkstattParameterBox"
import {SpielwerkDeviceBox} from "./midi-effects/SpielwerkDeviceBox"
export const DeviceDefinitions = [
UnknownAudioEffectDevice,
@@ -61,5 +62,6 @@ export const DeviceDefinitions = [
NeuralAmpDeviceBox,
WaveshaperDeviceBox,
WerkstattDeviceBox,
WerkstattParameterBox
WerkstattParameterBox,
SpielwerkDeviceBox
]
@@ -0,0 +1,8 @@
import {BoxSchema} from "@opendaw/lib-box-forge"
import {Pointers} from "@opendaw/studio-enums"
import {DeviceFactory} from "../../std/DeviceFactory"
export const SpielwerkDeviceBox: BoxSchema<Pointers> = DeviceFactory.createMidiEffect("SpielwerkDeviceBox", {
10: {type: "string", name: "code", value: ""},
11: {type: "field", name: "parameters", pointerRules: {accepts: [Pointers.Parameter], mandatory: false}}
})
+5
View File
@@ -27,6 +27,7 @@ This works universally — for transformers (1:1), generators (1:N), and filters
```typescript
class Processor {
paramChanged?(name: string, value: number): void
reset?(): void // called on transport jump (seek, loop restart)
// Called per block. Receives upstream note starts, yields output notes.
// Each yielded note must have: { position, duration, pitch, velocity, cent }
@@ -42,6 +43,7 @@ class Processor {
- `notes` — an iterator of upstream note starts in `[block.from, block.to)`. Each note is `{ position, duration, pitch, velocity, cent }`. Stop events are hidden from the user.
- The user yields zero or more notes per input note. Each must have `position`, `duration`, `pitch`, `velocity`, `cent`. Position must be `>= block.from`. Notes with position in `[from, to)` are emitted immediately. Notes with position `>= to` are held in an internal scheduler and emitted in the appropriate future block.
- `paramChanged` — optional, same as audio Werkstatt. Receives mapped parameter values from `// @param` declarations.
- `reset` — optional. Called when the transport jumps (discontinuous flag: seek, loop restart). Use to clear accumulated state like held note arrays.
---
@@ -375,6 +377,9 @@ class Processor {
if (name === "repeat") this.repeat = value
if (name === "octaves") this.octaves = value
}
reset() {
this.held = []
}
activeAt(position) {
return this.held
.filter(note => note.position <= position && position < note.position + note.duration)