spielwerk draft
This commit is contained in:
Generated
+6
-6
@@ -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.
|
||||
|
||||
---
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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}}
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user