Merge branch 'werkstatt' into dev

This commit is contained in:
André Michelle
2026-03-14 20:07:59 +01:00
49 changed files with 1548 additions and 205 deletions
+3 -3
View File
@@ -11,14 +11,14 @@ jobs:
steps:
- name: ⬇️ Checkout main repo
uses: actions/checkout@v4
uses: actions/checkout@v5
with:
fetch-depth: 0
- name: 🦄 Set up Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v5
with:
node-version: 20
node-version: 22
cache: npm
- name: 📦 Install dependencies
+3 -3
View File
@@ -9,9 +9,9 @@ jobs:
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
with:
node-version: 20
node-version: 22
- run: npm ci
- run: npx ts-node --transpile-only deploy/discord.ts
+3 -3
View File
@@ -17,12 +17,12 @@ jobs:
steps:
- name: ⬇️ Checkout repo
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: 🦄 Set up Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v5
with:
node-version: 20
node-version: 22
- name: 📦 Install SFTP client
run: npm install ssh2-sftp-client
+7 -6
View File
@@ -1012,6 +1012,7 @@
"version": "8.2.4",
"resolved": "https://registry.npmjs.org/@lerna/create/-/create-8.2.4.tgz",
"integrity": "sha512-A8AlzetnS2WIuhijdAzKUyFpR5YbLLfV3luQ4lzBgIBgRfuoBDZeF+RSZPhra+7A6/zTUlrbhKZIOi/MNhqgvQ==",
"deprecated": "This package is an implementation detail of Lerna and is no longer published separately.",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -6025,9 +6026,9 @@
}
},
"node_modules/force-graph": {
"version": "1.51.1",
"resolved": "https://registry.npmjs.org/force-graph/-/force-graph-1.51.1.tgz",
"integrity": "sha512-uEEX8iRzgq1IKRISOw6RrB2RLMhcI25xznQYrCTVvxZHZZ+A2jH6qIolYuwavVxAMi64pFp2yZm4KFVdD993cg==",
"version": "1.51.2",
"resolved": "https://registry.npmjs.org/force-graph/-/force-graph-1.51.2.tgz",
"integrity": "sha512-zZNdMqx8qIQGurgnbgYIUsdXxSfvhfRSIdncsKGv/twUOZpwCsk9hPHmdjdcme1+epATgb41G0rkIGHJ0Wydng==",
"license": "MIT",
"dependencies": {
"@tweenjs/tween.js": "18 - 25",
@@ -8329,9 +8330,9 @@
"license": "MIT"
},
"node_modules/mediabunny": {
"version": "1.39.1",
"resolved": "https://registry.npmjs.org/mediabunny/-/mediabunny-1.39.1.tgz",
"integrity": "sha512-gaQKDWtfLhgSN9tRj37lgznHf9MOvey7sbT6GdQA7mWl1kaE5R+P1lfl9CDsgtzongx12IG9X/y2vgZmTHzGDg==",
"version": "1.39.2",
"resolved": "https://registry.npmjs.org/mediabunny/-/mediabunny-1.39.2.tgz",
"integrity": "sha512-VcrisGRt+OI7tTPrziucJoCIPYIS/DEWY37TqzQVLWSUUHiyvsiRizEypQ3FOlhfIZ4ytAG/Mw4zxfetCTyKUg==",
"license": "MPL-2.0",
"workspaces": [
"packages/*"
@@ -0,0 +1,242 @@
# Werkstatt
A programmable audio effect that lets you write custom DSP code in JavaScript. Define your own signal processing, declare parameters with knobs, and hot-reload changes in real time.
---
## 0. Overview
_Werkstatt_ is a scriptable audio effect device. You write a `Processor` class in JavaScript that receives stereo audio buffers and outputs processed audio sample by sample. Parameters declared in the code appear as automatable knobs on the device panel.
Example uses:
- Custom distortion or waveshaping
- Experimental stereo effects
- Granular or glitch processing
- Ring modulation
- Prototyping new effect ideas
---
## 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 audio engine
- **Error message** — Syntax error or runtime error details
---
## 2. Parameters
Declare parameters using `// @param` comments at the top of your code:
```javascript
// @param gain 1.0
// @param mix 0.5
// @param drive 0.0
```
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 your output on every audio block:
- **NaN detection** — If any output sample is NaN, the processor is silenced and the error is reported.
- **Overflow protection** — If any sample exceeds ~60 dB (amplitude > 1000), the processor is silenced.
- **Runtime errors** — If `process()` throws an exception, the processor is silenced and the error is shown.
When silenced, the device outputs silence until the next successful compile.
---
## 5. API Reference
Your code must define a `class Processor` with a `process` method. Optionally implement `paramChanged` to receive parameter updates.
### Globals
| Variable | Type | Description |
|--------------|----------|------------------------------------------|
| `sampleRate` | `number` | Audio sample rate in Hz (e.g. 44100, 48000) |
### Processor class
```javascript
class Processor {
process(io, block) {
// io.src[0], io.src[1] — input Float32Arrays (left, right)
// io.out[0], io.out[1] — output Float32Arrays (left, right)
//
// block.s0 — first sample index to process (inclusive)
// block.s1 — last sample index to process (exclusive)
// block.index — block counter (increments each audio callback)
// block.bpm — current project tempo in beats per minute
// block.p0 — start position in ppqn (pulses per quarter note, 480 ppqn)
// block.p1 — end position in ppqn
// block.flags — bitmask:
// 1 (transporting) — transport is active
// 2 (discontinuous) — position jumped (seek, loop restart)
// 4 (playing) — playback is active
// 8 (bpmChanged) — tempo changed this block
}
paramChanged(label, value) {
// label — string matching the @param name
// value — number between 0.0 and 1.0
}
}
```
---
## 6. Example: Stereo Tremolo
```javascript
// @param speed 0.3
// @param depth 0.5
class Processor {
phase = 0
speed = 0.3
depth = 0.5
paramChanged(label, value) {
if (label === "speed") this.speed = value
if (label === "depth") this.depth = value
}
process({src, out}, {s0, s1}) {
const [srcL, srcR] = src
const [outL, outR] = out
const freq = 0.5 + this.speed * 19.5
const increment = freq / sampleRate
for (let i = s0; i < s1; i++) {
const mod = 1.0 - this.depth * (0.5 + 0.5 * Math.sin(this.phase * Math.PI * 2))
outL[i] = srcL[i] * mod
outR[i] = srcR[i] * mod
this.phase += increment
if (this.phase >= 1.0) this.phase -= 1.0
}
}
}
```
---
## 7. AI Prompt
Copy the following prompt into an AI assistant to get help writing Werkstatt processors:
```
You are helping the user write a DSP processor for the openDAW Werkstatt audio effect.
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 {
process(io, block) { } // required
paramChanged(label, value) { } // optional
}
## process(io, block)
Called on every audio block. Must fill the output buffers between s0 and s1.
io (audio buffers):
- io.src[0] — Float32Array, left input channel
- io.src[1] — Float32Array, right input channel
- io.out[0] — Float32Array, left output channel (write to this)
- io.out[1] — Float32Array, right output channel (write to this)
block (timing and transport):
- block.s0 — first sample index to process (inclusive)
- block.s1 — last sample index to process (exclusive)
- block.index — block counter (increments each audio callback)
- block.bpm — current project tempo in beats per minute
- block.p0 — start position in ppqn (pulses per quarter note, 480 ppqn)
- block.p1 — end position in ppqn
- block.flags — bitmask of transport state:
1 = transporting (transport is active)
2 = discontinuous (position jumped, e.g. seek or loop restart)
4 = playing (playback is active)
8 = bpmChanged (tempo changed this block)
You MUST only read/write indices from s0 to s1 (exclusive). Do NOT assume the arrays
start at index 0 or that the full length is available.
Use block.bpm and block.p0/p1 for tempo-synced effects. Use block.flags to detect
transport state changes (e.g. reset phase on discontinuous).
## 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, defaults to 0.0
Each @param creates an automatable knob on the device UI.
Examples:
// @param gain 1.0
// @param mix 0.5
// @param rate
## Globals
- sampleRate — number, the audio sample rate in Hz (e.g. 44100, 48000).
Always use this instead of hardcoding a sample rate.
## Constraints
- Output is validated every block. NaN or amplitudes > 1000 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.
- Performance matters: this runs at audio rate. Avoid allocations inside process().
Pre-allocate buffers in the constructor or as class fields.
## Template
// @param gain 1.0
class Processor {
gain = 1.0
paramChanged(label, value) {
if (label === "gain") this.gain = value
}
process({src, out}, {s0, s1}) {
const [srcL, srcR] = src
const [outL, outR] = out
for (let i = s0; i < s1; i++) {
outL[i] = srcL[i] * this.gain
outR[i] = srcR[i] * this.gain
}
}
}
```
@@ -6,6 +6,7 @@ import {
Func,
int,
isAbsent,
MutableObservableOption,
Notifier,
Nullable,
Observer,
@@ -69,6 +70,7 @@ import {Surface} from "@/ui/surface/Surface"
import {SoftwareMIDIPanel} from "@/ui/software-midi/SoftwareMIDIPanel"
import {Mixdowns} from "@/service/Mixdowns"
import {ShadertoyState} from "@/ui/shadertoy/ShadertoyState"
import {WerkstattEditorState} from "@/ui/werkstatt-editor/WerkstattEditorState"
/**
* I am just piling stuff after stuff in here to boot the environment.
@@ -114,6 +116,7 @@ export class StudioService implements ProjectEnv {
readonly #soundfontService: SoundfontService
#shadertoyState: Option<ShadertoyState> = Option.None
readonly #activeCodeEditor: MutableObservableOption<WerkstattEditorState> = new MutableObservableOption()
#factoryFooterLabel: Option<Provider<FooterLabel>> = Option.None
@@ -377,6 +380,25 @@ export class StudioService implements ProjectEnv {
factoryFooterLabel(): Option<Provider<FooterLabel>> {return this.#factoryFooterLabel}
get optShadertoyState(): Option<ShadertoyState> {return this.#shadertoyState}
get activeCodeEditor(): MutableObservableOption<WerkstattEditorState> {return this.#activeCodeEditor}
openWerkstattEditor(state: WerkstattEditorState): void {
const previousScreen = this.layout.screen.getValue() === "code"
? this.#activeCodeEditor.map(existing => existing.previousScreen).unwrapOrNull() ?? state.previousScreen
: state.previousScreen
this.layout.screen.setValue(null)
this.#activeCodeEditor.wrap({...state, previousScreen})
this.layout.screen.setValue("code")
RouteLocation.get().navigateTo("/")
}
closeCodeEditor(): void {
const previousScreen = this.#activeCodeEditor.map(state => state.previousScreen).unwrapOrNull()
this.#activeCodeEditor.clear()
if (this.layout.screen.getValue() === "code") {
queueMicrotask(() => this.switchScreen(previousScreen ?? "default"))
}
}
resetPeaks(): void {this.#signals.notify({type: "reset-peaks"})}
@@ -793,6 +793,10 @@ export const IconLibrary = () => (
d="M3 5v1H1v10h14V6h-2V5A5 5 0 0 0 3 5m7 1V5a2 2 0 1 0-4 0v1zm-.748 5a1.252 1.252 0 1 1-2.504 0a1.252 1.252 0 0 1 2.504 0"
clip-rule="evenodd"/>
</symbol>
<symbol id={IconSymbol.toName(IconSymbol.Code)} viewBox="0 0 24 24" fill="currentColor">
<path
d="M23 12L15.9289 19.0711L14.5147 17.6569L20.1716 12L14.5147 6.34317L15.9289 4.92896L23 12ZM3.82843 12L9.48528 17.6569L8.07107 19.0711L1 12L8.07107 4.92896L9.48528 6.34317L3.82843 12Z"/>
</symbol>
</defs>
</symbol>
)
@@ -28,6 +28,7 @@ import {
VaporisateurDeviceBox,
VelocityDeviceBox,
WaveshaperDeviceBox,
WerkstattDeviceBox,
ZeitgeistDeviceBox
} from "@opendaw/studio-boxes"
import {ArpeggioDeviceEditor} from "@/ui/devices/midi-effects/ArpeggioDeviceEditor.tsx"
@@ -60,6 +61,7 @@ import {
VaporisateurDeviceBoxAdapter,
VelocityDeviceBoxAdapter,
WaveshaperDeviceBoxAdapter,
WerkstattDeviceBoxAdapter,
ZeitgeistDeviceBoxAdapter
} from "@opendaw/studio-adapters"
import {DelayDeviceEditor} from "@/ui/devices/audio-effects/DelayDeviceEditor.tsx"
@@ -91,6 +93,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 {WerkstattDeviceEditor} from "@/ui/devices/audio-effects/WerkstattDeviceEditor"
export namespace DeviceEditorFactory {
export const toMidiEffectDeviceEditor = (service: StudioService, lifecycle: Lifecycle, box: Box, deviceHost: DeviceHost) =>
@@ -274,6 +277,12 @@ export namespace DeviceEditorFactory {
service={service}
adapter={service.project.boxAdapters.adapterFor(box, WaveshaperDeviceBoxAdapter)}
deviceHost={deviceHost}/>
),
visitWerkstattDeviceBox: (box: WerkstattDeviceBox) => (
<WerkstattDeviceEditor lifecycle={lifecycle}
service={service}
adapter={service.project.boxAdapters.adapterFor(box, WerkstattDeviceBoxAdapter)}
deviceHost={deviceHost}/>
)
}), `No AudioEffectDeviceEditor found for ${box}`)
}
@@ -0,0 +1,132 @@
import {asInstanceOf, Editing, isDefined, UUID} from "@opendaw/lib-std"
import {WerkstattDeviceBox, WerkstattParameterBox} from "@opendaw/studio-boxes"
const COMPILER_VERSION = 1
const HEADER_PATTERN = /^\/\/ @werkstatt (\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)
if (match !== null) {
return {userCode: source.slice(match[0].length), update: parseInt(match[3])}
}
return {userCode: source, update: 0}
}
const createHeader = (update: number): string =>
`// @werkstatt js ${COMPILER_VERSION} ${update}\n`
const parseParams = (code: string): ReadonlyArray<ParamDeclaration> => {
const params: Array<ParamDeclaration> = []
let match: RegExpExecArray | null
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: WerkstattDeviceBox, 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.werkstattProcessors === "undefined") { globalThis.openDAW.werkstattProcessors = {} }
globalThis.openDAW.werkstattProcessors["${uuid}"] = {
update: ${update},
create: (function werkstatt() {
${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 WerkstattCompiler {
export const stripHeader = (source: string): string => parseHeader(source).userCode
export const load = async (
audioContext: BaseAudioContext,
deviceBox: WerkstattDeviceBox
): 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: WerkstattDeviceBox,
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,17 @@
@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-auto-columns: 3.5em
grid-auto-flow: column
row-gap: 0.25em
@@ -0,0 +1,126 @@
import css from "./WerkstattDeviceEditor.sass?inline"
import defaultCode from "./werkstatt-default.txt?raw"
import {DeviceHost, WerkstattDeviceBoxAdapter} 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 {DevicePeakMeter} from "@/ui/devices/panel/DevicePeakMeter.tsx"
import {Html} from "@opendaw/lib-dom"
import {StudioService} from "@/service/StudioService"
import {EffectFactories} from "@opendaw/studio-core"
import {WerkstattCompiler} from "./WerkstattCompiler"
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, "WerkstattDeviceEditor")
type Construct = {
lifecycle: Lifecycle
service: StudioService
adapter: WerkstattDeviceBoxAdapter
deviceHost: DeviceHost
}
export const WerkstattDeviceEditor = ({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 ? WerkstattCompiler.stripHeader(storedCode) : defaultCode
const compile = async (code: string) => WerkstattCompiler.compile(service.audioContext, editing, box, code)
if (storedCode.length > 0) {
WerkstattCompiler.load(service.audioContext, box).finally(EmptyExec)
} else {
WerkstattCompiler.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(WerkstattCompiler.stripHeader(owner.getValue())))
},
initialCode: WerkstattCompiler.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={() => (
<DevicePeakMeter lifecycle={lifecycle}
receiver={project.liveStreamReceiver}
address={adapter.address}/>
)}
icon={EffectFactories.AudioNamed.Werkstatt.defaultIcon}/>
)
}
@@ -0,0 +1,16 @@
// @param gain 1.0
class Processor {
gain = 0
paramChanged(label, value) {
if (label === "gain") this.gain = value
}
process({src, out}, {s0, s1}) {
const [srcL, srcR] = src
const [outL, outR] = out
for (let i = s0; i < s1; i++) {
outL[i] = srcL[i] * this.gain
outR[i] = srcR[i] * this.gain
}
}
}
@@ -28,57 +28,60 @@ const visible = new DefaultObservableValue(true)
export const ShadertoyPreview = ({lifecycle, service}: Construct) => {
return (
<div className={className} onInit={element => {
replaceChildren(element, (
<Frag>
<ShadertoyLogo onInit={element => {
lifecycle.own(Events.subscribe(element, "click", () => visible.setValue(!visible.getValue())))
}}/>
<canvas onInit={canvas => {
const gl = canvas.getContext("webgl2")
if (isAbsent(gl)) {
element.setAttribute("data-status", "WebGL2 not supported")
return
}
const runner = new ShadertoyRunner(service.optShadertoyState.unwrap("no state"), gl)
const shaderLifecycle = lifecycle.own(new Terminator())
lifecycle.ownAll(
visible.catchupAndSubscribe(owner => canvas.classList.toggle("hidden", !owner.getValue())),
service.project.rootBox.shadertoy.catchupAndSubscribe(({targetVertex}) => {
shaderLifecycle.terminate()
targetVertex.match({
none: () => {
element.classList.add("hidden")
return Terminable.Empty
},
some: (box) => {
element.classList.remove("hidden")
const {shaderCode, highres} = asInstanceOf(box, ShadertoyBox)
return shaderCode.catchupAndSubscribe(code => {
const {status, error} = tryCatch(() => runner.compile(code.getValue()))
if (status === "failure") {
element.setAttribute("data-status", String(error))
return
}
element.removeAttribute("data-status")
shaderLifecycle.ownAll(setupShadertoyRunner(runner, canvas, highres))
})
}
const shaderLifecycle = lifecycle.own(new Terminator())
lifecycle.own(
service.project.rootBox.shadertoy.catchupAndSubscribe(({targetVertex}) => {
shaderLifecycle.terminate()
targetVertex.match({
none: () => {
element.classList.add("hidden")
return Terminable.Empty
},
some: (box) => {
element.classList.remove("hidden")
const canvas: HTMLCanvasElement = (<canvas/>)
replaceChildren(element, (
<Frag>
<ShadertoyLogo onInit={logo => {
shaderLifecycle.own(Events.subscribe(logo, "click",
() => visible.setValue(!visible.getValue())))
}}/>
{canvas}
</Frag>
))
const gl = canvas.getContext("webgl2")
if (isAbsent(gl)) {
element.setAttribute("data-status", "WebGL2 not supported")
return Terminable.Empty
}
const runner = new ShadertoyRunner(service.optShadertoyState.unwrap("no state"), gl)
shaderLifecycle.ownAll(
visible.catchupAndSubscribe(owner => canvas.classList.toggle("hidden", !owner.getValue())),
Events.subscribe(canvas, "click", async () => {
try {
if (document.fullscreenElement) {
await document.exitFullscreen()
} else {
await canvas.requestFullscreen()
}
} catch { /* ignore fullscreen errors (often caused by extensions) */ }
})
}),
Events.subscribe(canvas, "click", async () => {
try {
if (document.fullscreenElement) {
await document.exitFullscreen()
} else {
await canvas.requestFullscreen()
}
} catch { /* ignore fullscreen errors (often caused by extensions) */ }
)
const {shaderCode, highres} = asInstanceOf(box, ShadertoyBox)
return shaderCode.catchupAndSubscribe(code => {
const {status, error} = tryCatch(() => runner.compile(code.getValue()))
if (status === "failure") {
element.setAttribute("data-status", String(error))
return
}
element.removeAttribute("data-status")
shaderLifecycle.ownAll(setupShadertoyRunner(runner, canvas, highres))
})
)
}}/>
</Frag>
))
}
})
})
)
}}>
</div>
)
}
}
+2 -1
View File
@@ -37,7 +37,8 @@ const ScreenShortcutKeys: Record<Workspace.ScreenKeys, keyof typeof GlobalShortc
"piano": "workspace-screen-piano",
"project": "workspace-screen-project",
"shadertoy": "workspace-screen-shadertoy",
"meter": "workspace-screen-meter"
"meter": "workspace-screen-meter",
"code": "workspace-screen-default"
}
export const Header = ({lifecycle, service}: Construct) => {
@@ -142,6 +142,12 @@ export const Manuals: ReadonlyArray<Manual> = [
label: "Waveshaper",
path: "/manuals/devices/audio/waveshaper",
icon: EffectFactories.AudioNamed.Waveshaper.defaultIcon
},
{
type: "page",
label: "Werkstatt",
path: "/manuals/devices/audio/werkstatt",
icon: EffectFactories.AudioNamed.Werkstatt.defaultIcon
}
]
},
@@ -158,6 +158,7 @@ export class RegionLoopDurationModifier implements RegionModifier {
}
#dispatchChange(): void {
this.#adapters.forEach(adapter => adapter.trackBoxAdapter.unwrap().regions.dispatchChange())
this.#adapters.forEach(adapter => adapter.trackBoxAdapter
.ifSome(track => track.regions.dispatchChange()))
}
}
@@ -0,0 +1,10 @@
import {MutableObservableValue, Procedure, Subscription, UUID} from "@opendaw/lib-std"
export interface CodeEditorHandler {
readonly uuid: UUID.Bytes
readonly name: MutableObservableValue<string>
compile(code: string): Promise<void>
subscribeErrors(observer: Procedure<string>): Subscription
subscribeCode(observer: Procedure<string>): Subscription
}
@@ -0,0 +1,51 @@
@use "@/colors"
component
display: flex
flex-direction: column
flex: 1
position: relative
@include colors.panel-background
.content
display: flex
flex-direction: column
flex: 1
overflow: hidden
header
height: 33px
padding: 1em
display: flex
align-items: center
flex-direction: row
column-gap: 1em
font-size: 11px
border-bottom: 1px solid var(--color-background)
> *, span
padding: 0.125em 0.125em
width: min-content
white-space: nowrap
.monaco-editor
flex: 1
position: relative
overflow: hidden
.status
padding: 0.5em 1em
font-size: 11px
font-family: monospace
white-space: pre-wrap
max-height: 80px
overflow-y: auto
border-top: 1px solid rgba(white, 0.1)
&.idle
color: rgba(white, 0.4)
&.success
color: hsl(120, 60%, 60%)
&.error
background-color: hsl(0, 50%, 15%)
color: hsl(0, 80%, 70%)
border-top-color: hsl(0, 50%, 25%)
@@ -0,0 +1,136 @@
import css from "./WerkstattEditorPanel.sass?inline"
import defaultCode from "../devices/audio-effects/werkstatt-default.txt?raw"
import {isDefined, Lifecycle, Nullable} from "@opendaw/lib-std"
import {Await, createElement} from "@opendaw/lib-jsx"
import {Events, Html, Keyboard, Shortcut} from "@opendaw/lib-dom"
import {Promises} from "@opendaw/lib-runtime"
import {IconSymbol} from "@opendaw/studio-enums"
import {StudioService} from "@/service/StudioService"
import {ThreeDots} from "@/ui/spinner/ThreeDots"
import {Button} from "@/ui/components/Button"
import {Icon} from "@/ui/components/Icon"
import {CodeEditorHandler} from "./CodeEditorHandler"
const className = Html.adoptStyleSheet(css, "WerkstattEditorPanel")
type Construct = {
lifecycle: Lifecycle
service: StudioService
}
export const WerkstattEditorPanel = ({lifecycle, service}: Construct) => {
const statusLabel: HTMLDivElement = (<div className="status idle">Idle</div>)
const state = service.activeCodeEditor.unwrapOrNull()
const handler: Nullable<CodeEditorHandler> = isDefined(state) ? state.handler : null
const initialCode = isDefined(state) ? state.initialCode : defaultCode
const setStatus = (text: string, type: "idle" | "success" | "error") => {
statusLabel.textContent = text
statusLabel.className = `status ${type}`
}
const nameSpan: HTMLSpanElement = (<span className="name">Code Editor</span>)
if (isDefined(handler)) {
lifecycle.own(handler.name.catchupAndSubscribe(owner => nameSpan.textContent = owner.getValue()))
}
return (
<div className={className}>
<Await
factory={() => Promise.all([
Promises.guardedRetry(() => import("./monaco-setup"), (_error, count) => count < 10)
.then(({monaco}) => monaco)
])}
failure={({retry, reason}) => (<p onclick={retry}>{reason}</p>)}
loading={() => ThreeDots()}
success={([monaco]) => {
const container = (<div className="monaco-editor"/>)
const modelUri = monaco.Uri.parse("file:///werkstatt.js")
let model = monaco.editor.getModel(modelUri)
if (!model) {
model = monaco.editor.createModel(initialCode, "javascript", modelUri)
} else {
model.setValue(initialCode)
}
const editor = monaco.editor.create(container, {
language: "javascript",
quickSuggestions: {
other: true,
comments: false,
strings: false
},
occurrencesHighlight: "off",
suggestOnTriggerCharacters: true,
acceptSuggestionOnCommitCharacter: true,
acceptSuggestionOnEnter: "on",
wordBasedSuggestions: "off",
model: model,
theme: "vs-dark",
automaticLayout: true
})
const compileCode = async () => {
if (!isDefined(handler)) {
setStatus("No handler connected", "error")
return
}
try {
await handler.compile(editor.getValue())
setStatus("Successfully compiled", "success")
} catch (reason: unknown) {
setStatus(String(reason), "error")
}
}
if (isDefined(handler)) {
lifecycle.own(handler.subscribeErrors(message => setStatus(message, "error")))
lifecycle.own(handler.subscribeCode(code => {
if (editor.getValue() !== code) {editor.setValue(code)}
}))
}
const allowed = ["c", "v", "x", "a", "z", "y"]
editor.addCommand(monaco.KeyMod.Alt | monaco.KeyCode.Enter, () => compileCode().finally())
lifecycle.ownAll(
Events.subscribe(container, "keydown", event => {
if (Keyboard.isControlKey(event) && event.code === "KeyS") {
compileCode()
.then(() => service.projectProfileService.save().finally())
.finally()
event.preventDefault()
event.stopPropagation()
}
}, {capture: true}),
Events.subscribe(container, "keydown", event => {
if ((event.ctrlKey || event.metaKey) && allowed.includes(event.key.toLowerCase())) {
return
}
event.stopPropagation()
}),
Events.subscribe(container, "keyup", event => {
if ((event.ctrlKey || event.metaKey) && allowed.includes(event.key.toLowerCase())) {
return
}
event.stopPropagation()
}),
Events.subscribe(container, "keypress", event => event.stopPropagation())
)
requestAnimationFrame(() => editor.focus())
const close = () => service.closeCodeEditor()
return (
<div className="content">
<header>
{nameSpan}
<Button lifecycle={lifecycle}
onClick={compileCode}
appearance={{tooltip: `Compile (${Shortcut.of("Enter", {alt: true}).format()})`}}>
<span>Compile</span> <Icon symbol={IconSymbol.Play}/>
</Button>
<Button lifecycle={lifecycle}
onClick={close}
appearance={{tooltip: "Close editor"}}>
<span>Close Editor</span> <Icon symbol={IconSymbol.Exit}/>
</Button>
</header>
{container}
{statusLabel}
</div>
)
}}/>
</div>
)
}
@@ -0,0 +1,9 @@
import {Nullable} from "@opendaw/lib-std"
import {CodeEditorHandler} from "./CodeEditorHandler"
import {Workspace} from "@/ui/workspace/Workspace"
export type WerkstattEditorState = Readonly<{
handler: CodeEditorHandler
initialCode: string
previousScreen: Nullable<Workspace.ScreenKeys>
}>
@@ -0,0 +1,18 @@
import "@/monaco/imports"
import * as monaco from "monaco-editor"
import "monaco-editor/esm/vs/language/typescript/monaco.contribution"
import "monaco-editor/esm/vs/basic-languages/javascript/javascript.contribution"
import "monaco-editor/esm/vs/editor/contrib/clipboard/browser/clipboard"
monaco.languages.typescript.javascriptDefaults.setDiagnosticsOptions({
noSemanticValidation: true,
noSyntaxValidation: false
})
monaco.languages.typescript.javascriptDefaults.addExtraLib(`
/** Audio sample rate in Hz (e.g. 44100 or 48000) */
declare const sampleRate: number;
`, "ts:werkstatt-globals.d.ts")
export {monaco}
export type Monaco = typeof monaco
@@ -8,7 +8,7 @@ const BrowserPanel = PanelState.create({
name: "Browser",
icon: IconSymbol.Panel,
panelType: PanelType.BrowserPanel,
constrains: {type: "flex", minSize: 240, maxSize: 480, flex: 0.25}
constrains: {type: "flex", minSize: 256, maxSize: 480, flex: 0}
})
const DevicesPanel = PanelState.create({
type: "panel",
@@ -219,6 +219,35 @@ export const DefaultWorkspace = Object.freeze({
notMinimizable: true,
constrains: {type: "flex", minSize: 0, flex: 1}
})
},
"code": {
name: "Code",
icon: IconSymbol.Code,
hidden: true,
content: {
type: "layout",
orientation: "horizontal",
contents: [
BrowserPanel,
{
type: "layout",
orientation: "vertical",
contents: [
PanelState.create({
type: "panel",
name: "Code Editor",
icon: IconSymbol.Code,
panelType: PanelType.CodeEditor,
notMinimizable: true,
constrains: {type: "flex", minSize: 0, flex: 1}
}),
DevicesPanel
],
constrains: {type: "flex", minSize: 0, flex: 1}
}
],
constrains: {type: "flex", minSize: 20, flex: 1}
}
}
} satisfies Record<string, Workspace.Screen>)
@@ -17,6 +17,7 @@ import {VUMeterPanel} from "@/ui/meter/VUMeterPanel"
import {PianoModePanel} from "@/ui/piano-panel/PianoModePanel.tsx"
import {ShadertoyPreview} from "@/ui/shadertoy/ShadertoyPreview"
import {ShadertoyEditor} from "@/ui/shadertoy/ShadertoyEditor"
import {WerkstattEditorPanel} from "@/ui/werkstatt-editor/WerkstattEditorPanel"
export const createPanelFactory = (service: StudioService): PanelContentFactory => ({
create: (lifecycle: Lifecycle, type: PanelType): JsxValue => {
@@ -47,6 +48,8 @@ export const createPanelFactory = (service: StudioService): PanelContentFactory
return (<ShadertoyPreview lifecycle={lifecycle} service={service}/>)
case PanelType.ShadertoyEditor:
return (<ShadertoyEditor lifecycle={lifecycle} service={service}/>)
case PanelType.CodeEditor:
return (<WerkstattEditorPanel lifecycle={lifecycle} service={service}/>)
case PanelType.EmptyFlexSpace:
return (<FlexSpace/>)
default:
@@ -12,5 +12,6 @@ export enum PanelType {
ProjectInfo,
ShadertoyPreview,
ShadertoyEditor,
CodeEditor,
EmptyFlexSpace
}
+15
View File
@@ -143,6 +143,21 @@ describe("editing", () => {
expect(scene.graph.findBox(survivingBox!.address.uuid).nonEmpty()).true
})
it("should append changes to the last committed history step", (scene: TestScene) => {
const barBox = scene.editing.modify(() => BarBox.create(scene.graph, UUID.generate())).unwrap()
const barUuid = barBox.address.uuid
expect(scene.graph.findBox(barUuid).nonEmpty()).true
expect(barBox.bool.getValue()).false
scene.editing.append(() => barBox.bool.setValue(true))
expect(barBox.bool.getValue()).true
expect(scene.editing.canUndo()).true
scene.editing.undo()
expect(scene.graph.findBox(barUuid).nonEmpty()).false
scene.editing.redo()
expect(scene.graph.findBox(barUuid).nonEmpty()).true
expect((scene.graph.findBox(barUuid).unwrap().box as BarBox).bool.getValue()).true
})
it("should handle box with pointer created and deleted in same transaction", (scene: TestScene) => {
// Create a target box first (this one persists)
const targetBox = scene.editing.modify(() => BarBox.create(scene.graph, UUID.generate())).unwrap()
+46 -3
View File
@@ -142,9 +142,6 @@ export class BoxEditing implements Editing {
return this.#pending.length <= 0
}
/** @deprecated No longer needed — modify() now handles calls during undo/redo transparently. */
mustModify(): boolean {return !this.#graph.inTransaction()}
modify<R>(modifier: SyncProvider<Maybe<R>>, mark: boolean = true): Option<R> {
assert(!this.#inProcess, "Cannot call modify while a modification process is running")
if (this.#modifying || this.#graph.inTransaction()) {
@@ -173,6 +170,52 @@ export class BoxEditing implements Editing {
return Option.wrap(result)
}
append<R>(modifier: SyncProvider<Maybe<R>>): Option<R> {
assert(!this.#inProcess, "Cannot call append while a modification process is running")
if (this.#modifying || this.#graph.inTransaction()) {
this.#notifier.notify()
return Option.wrap(modifier())
}
if (this.#pending.length > 0) {
if (this.#historyIndex > 0) {
this.#marked[this.#historyIndex - 1] =
[...this.#marked[this.#historyIndex - 1], ...this.#pending.splice(0)]
} else {
this.mark()
}
}
this.#modifying = true
const updates: Array<Update> = []
const subscription = this.#graph.subscribeToAllUpdates({
onUpdate: (update: Update) => updates.push(update)
})
this.#graph.beginTransaction()
const result = modifier()
this.#graph.endTransaction()
subscription.terminate()
const optimized = optimizeUpdates(updates)
if (optimized.length > 0) {
const modification = new Modification(optimized)
if (this.#historyIndex > 0) {
if (this.#marked.length > this.#historyIndex) {
if (this.#savedHistoryIndex > this.#historyIndex) {
this.#savedHistoryIndex = -1
}
this.#marked.splice(this.#historyIndex)
}
this.#marked[this.#historyIndex - 1] =
[...this.#marked[this.#historyIndex - 1], modification]
} else {
this.#marked.push([modification])
this.#historyIndex = this.#marked.length
}
}
this.#modifying = false
this.#graph.edges().validateRequirements()
this.#notifier.notify()
return Option.wrap(result)
}
beginModification(): ModificationProcess {
assert(!this.#modifying && !this.#inProcess, "Cannot begin modification while another is in progress")
this.#modifying = true
+2
View File
@@ -12,6 +12,7 @@ export type SyncProvider<T> = true extends (T extends Promise<unknown> ? true :
export interface Editing {
modify<R>(modifier: SyncProvider<Maybe<R>>, mark?: boolean): Option<R>
append<R>(modifier: SyncProvider<Maybe<R>>): Option<R>
mark(): void
canUndo(): boolean
canRedo(): boolean
@@ -27,6 +28,7 @@ export interface Editing {
export namespace Editing {
export const Transient: Editing = Object.freeze({
modify: <R>(modifier: SyncProvider<Maybe<R>>, _mark?: boolean): Option<R> => Option.wrap(modifier()),
append: <R>(modifier: SyncProvider<Maybe<R>>): Option<R> => Option.wrap(modifier()),
mark: () => {},
canUndo: () => false,
canRedo: () => false,
@@ -74,6 +74,7 @@ import {
VelocityDeviceBox,
WarpMarkerBox,
WaveshaperDeviceBox,
WerkstattDeviceBox,
ZeitgeistDeviceBox
} from "@opendaw/studio-boxes"
import {AudioUnitBoxAdapter} from "./audio-unit/AudioUnitBoxAdapter"
@@ -133,6 +134,7 @@ import {TidalDeviceBoxAdapter} from "./devices/audio-effects/TidalDeviceBoxAdapt
import {DattorroReverbDeviceBoxAdapter} from "./devices/audio-effects/DattorroReverbDeviceBoxAdapter"
import {NeuralAmpDeviceBoxAdapter} from "./devices/audio-effects/NeuralAmpDeviceBoxAdapter"
import {WaveshaperDeviceBoxAdapter} from "./devices/audio-effects/WaveshaperDeviceBoxAdapter"
import {WerkstattDeviceBoxAdapter} from "./devices/audio-effects/WerkstattDeviceBoxAdapter"
import {NeuralAmpModelBoxAdapter} from "./nam/NeuralAmpModelBoxAdapter"
import {AudioPitchStretchBoxAdapter} from "./audio/AudioPitchStretchBoxAdapter"
import {TransientMarkerBoxAdapter} from "./audio/TransientMarkerBoxAdapter"
@@ -252,6 +254,7 @@ export class BoxAdapters implements Terminable {
visitValueRegionBox: (box: ValueRegionBox) => new ValueRegionBoxAdapter(this.#context, box),
visitVaporisateurDeviceBox: (box: VaporisateurDeviceBox) => new VaporisateurDeviceBoxAdapter(this.#context, box),
visitWaveshaperDeviceBox: (box: WaveshaperDeviceBox) => new WaveshaperDeviceBoxAdapter(this.#context, box),
visitWerkstattDeviceBox: (box: WerkstattDeviceBox) => new WerkstattDeviceBoxAdapter(this.#context, box),
visitVelocityDeviceBox: (box: VelocityDeviceBox) => new VelocityDeviceBoxAdapter(this.#context, box),
visitZeitgeistDeviceBox: (box: ZeitgeistDeviceBox) => new ZeitgeistDeviceBoxAdapter(this.#context, box)
}), `Could not find factory for ${unknownBox}`)
@@ -23,6 +23,7 @@ export namespace DeviceManualUrls {
export const Modular = "manuals/devices/audio/modular"
export const NeuralAmp = "manuals/devices/audio/neural-amp"
export const Waveshaper = "manuals/devices/audio/waveshaper"
export const Werkstatt = "manuals/devices/audio/werkstatt"
// Instruments
export const Tape = "manuals/devices/instruments/tape"
@@ -0,0 +1,62 @@
import {asInstanceOf, Option, StringMapping, Terminator, UUID, ValueMapping} from "@opendaw/lib-std"
import {Address, BooleanField, Int32Field, PointerField, StringField} from "@opendaw/lib-box"
import {WerkstattDeviceBox, WerkstattParameterBox} from "@opendaw/studio-boxes"
import {Pointers} from "@opendaw/studio-enums"
import {AudioEffectDeviceAdapter, DeviceHost, Devices} from "../../DeviceAdapter"
import {LabeledAudioOutput} from "../../LabeledAudioOutputsOwner"
import {BoxAdaptersContext} from "../../BoxAdaptersContext"
import {DeviceManualUrls} from "../../DeviceManualUrls"
import {AudioUnitBoxAdapter} from "../../audio-unit/AudioUnitBoxAdapter"
import {ParameterAdapterSet} from "../../ParameterAdapterSet"
export class WerkstattDeviceBoxAdapter implements AudioEffectDeviceAdapter {
readonly #terminator = new Terminator()
readonly type = "audio-effect"
readonly accepts = "audio"
readonly manualUrl = DeviceManualUrls.Werkstatt
readonly #context: BoxAdaptersContext
readonly #box: WerkstattDeviceBox
readonly #parametric: ParameterAdapterSet
constructor(context: BoxAdaptersContext, box: WerkstattDeviceBox) {
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(): WerkstattDeviceBox {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.AudioEffectHost> {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()}
*labeledAudioOutputs(): Iterable<LabeledAudioOutput> {
yield {address: this.address, label: this.labelField.getValue(), children: () => Option.None}
}
terminate(): void {this.#terminator.terminate()}
}
@@ -18,6 +18,7 @@ export interface OfflineEngineRenderConfig {
export interface OfflineEngineProtocol {
initialize(enginePort: MessagePort, config: OfflineEngineInitializeConfig): Promise<void>
addModule(code: string): Promise<void>
render(config: OfflineEngineRenderConfig): Promise<Float32Array[]>
step(samples: number): Promise<Float32Array[]>
stop(): void
@@ -33,6 +33,7 @@ export interface EngineCommands extends Terminable {
export interface EngineToClient {
log(message: string): void
error(reason: unknown): void
deviceMessage(uuid: string, message: string): void
fetchAudio(uuid: UUID.Bytes): Promise<AudioData>
fetchSoundfont(uuid: UUID.Bytes): Promise<SoundFont2>
fetchNamWasm(): Promise<ArrayBuffer>
@@ -12,7 +12,7 @@ import {
unitValue,
UUID
} from "@opendaw/lib-std"
import {Address, BooleanField, Int32Field, PointerField, StringField} from "@opendaw/lib-box"
import {Address, BooleanField, Box, Int32Field, PointerField, StringField} from "@opendaw/lib-box"
import {ppqn, UpdateClockRate} from "@opendaw/lib-dsp"
import {BoxAdapter} from "../BoxAdapter"
import {BoxAdaptersContext} from "../BoxAdaptersContext"
@@ -85,6 +85,12 @@ export class TrackBoxAdapter implements BoxAdapter {
adapter.labelField.setValue(value)
}
})
this.#resolveOwnerDeviceBox(box).flatMap(owner => this.#context.boxAdapters.optAdapter(owner))
.ifSome(adapter => {
if ("labelField" in adapter && adapter.labelField instanceof StringField) {
adapter.labelField.setValue(value)
}
})
})
}
@@ -102,6 +108,14 @@ export class TrackBoxAdapter implements BoxAdapter {
return Option.wrap(adapter.labelField.getValue())
}
}
const ownerAdapter = this.#resolveOwnerDeviceBox(box)
.flatMap(owner => this.#context.boxAdapters.optAdapter(owner))
if (ownerAdapter.nonEmpty()) {
const adapter = ownerAdapter.unwrap()
if ("labelField" in adapter && adapter.labelField instanceof StringField) {
return Option.wrap(adapter.labelField.getValue())
}
}
return Option.wrap(box.name)
})
}
@@ -121,6 +135,14 @@ export class TrackBoxAdapter implements BoxAdapter {
return adapter.labelField.catchupAndSubscribe(owner => observer(Option.wrap(owner.getValue())))
}
}
const ownerAdapter = this.#resolveOwnerDeviceBox(box)
.flatMap(owner => this.#context.boxAdapters.optAdapter(owner))
if (ownerAdapter.nonEmpty()) {
const adapter = ownerAdapter.unwrap()
if ("labelField" in adapter && adapter.labelField instanceof StringField) {
return adapter.labelField.catchupAndSubscribe(owner => observer(Option.wrap(owner.getValue())))
}
}
observer(Option.wrap(box.name))
return Terminable.Empty
}
@@ -128,6 +150,72 @@ export class TrackBoxAdapter implements BoxAdapter {
return Terminable.Empty
}
terminate() {this.#terminator.terminate()}
get audioUnit(): AudioUnitBox {return asInstanceOf(this.#box.tracks.targetVertex.unwrap().box, AudioUnitBox)}
get target(): PointerField<Pointers.Automation> {return this.#box.target}
get clips(): TrackClips {return this.#clips}
get regions(): TrackRegions {return this.#regions}
get enabled(): BooleanField {return this.#box.enabled}
get indexField(): Int32Field {return this.#box.index}
get type(): TrackType {return this.#box.type.getValue()}
get box(): TrackBox {return this.#box}
get uuid(): UUID.Bytes {return this.#box.address.uuid}
get address(): Address {return this.#box.address}
get listIndex(): int {return this.#listIndex.getValue()}
set listIndex(value: int) {this.#listIndex.setValue(value)}
accepts(subject: AnyClipBoxAdapter | AnyRegionBoxAdapter): boolean {
switch (subject.type) {
case "audio-clip":
return this.type === TrackType.Audio
case "note-clip":
return this.type === TrackType.Notes
case "value-clip":
return this.type === TrackType.Value
case "audio-region":
return this.type === TrackType.Audio
case "note-region":
return this.type === TrackType.Notes
case "value-region":
return this.type === TrackType.Value
}
}
valueAt(position: ppqn, fallback: unitValue): unitValue {
if (!this.enabled.getValue()) {return fallback}
let value = fallback
const intervals = this.#context.clipSequencing.iterate(this.uuid, position, position + UpdateClockRate)
for (const {optClip, sectionFrom} of intervals) {
value = optClip.match({
none: () => {
const region = this.regions.collection.lowerEqual(position, region => !region.mute)
if (region === null) {
const firstRegion = this.regions.collection.optAt(0)
return isInstanceOf(firstRegion, ValueRegionBoxAdapter) ? firstRegion.incomingValue(fallback) : fallback
} else if (isInstanceOf(region, ValueRegionBoxAdapter)) {
if (position < region.complete) {
return region.valueAt(position, fallback)
} else {
return region.outgoingValue(fallback)
}
}
return fallback
},
some: clip => {
if (sectionFrom === position) {
if (isInstanceOf(clip, ValueClipBoxAdapter)) {
return clip.valueAt(position, fallback)
}
}
return fallback
}
})
}
return value
}
#catchupAndSubscribeTargetControlName(observer: Observer<Option<string>>): Subscription {
const type = this.type
switch (type) {
@@ -159,69 +247,12 @@ export class TrackBoxAdapter implements BoxAdapter {
}
}
terminate() {this.#terminator.terminate()}
get audioUnit(): AudioUnitBox {return asInstanceOf(this.#box.tracks.targetVertex.unwrap().box, AudioUnitBox)}
get target(): PointerField<Pointers.Automation> {return this.#box.target}
get clips(): TrackClips {return this.#clips}
get regions(): TrackRegions {return this.#regions}
get enabled(): BooleanField {return this.#box.enabled}
get indexField(): Int32Field {return this.#box.index}
get type(): TrackType {return this.#box.type.getValue()}
get box(): TrackBox {return this.#box}
get uuid(): UUID.Bytes {return this.#box.address.uuid}
get address(): Address {return this.#box.address}
get listIndex(): int {return this.#listIndex.getValue()}
set listIndex(value: int) {this.#listIndex.setValue(value)}
accepts(subject: AnyClipBoxAdapter | AnyRegionBoxAdapter): boolean {
switch (subject.type) {
case "audio-clip":
return this.type === TrackType.Audio
case "note-clip":
return this.type === TrackType.Notes
case "value-clip":
return this.type === TrackType.Value
case "audio-region":
return this.type === TrackType.Audio
case "note-region":
return this.type === TrackType.Notes
case "value-region":
return this.type === TrackType.Value
#resolveOwnerDeviceBox(box: Box): Option<Box> {
for (const [pointer] of box.outgoingEdges()) {
if (pointer.pointerType === Pointers.Parameter) {
return pointer.targetVertex.map(vertex => vertex.box)
}
}
}
valueAt(position: ppqn, fallback: unitValue): unitValue {
if (!this.enabled.getValue()) {return fallback}
let value = fallback
const intervals = this.#context.clipSequencing.iterate(this.uuid, position, position + UpdateClockRate)
for (const {optClip, sectionFrom} of intervals) {
value = optClip.match({
none: () => {
const region = this.regions.collection.lowerEqual(position, region => !region.mute)
if (region === null) {
const firstRegion = this.regions.collection.optAt(0)
return isInstanceOf(firstRegion, ValueRegionBoxAdapter) ? firstRegion.incomingValue(fallback) : fallback
} else if (isInstanceOf(region, ValueRegionBoxAdapter)) {
if (position < region.complete) {
return region.valueAt(position, fallback)
} else {
return region.outgoingValue(fallback)
}
}
return fallback
},
some: clip => {
if (sectionFrom === position) {
if (isInstanceOf(clip, ValueClipBoxAdapter)) {
return clip.valueAt(position, fallback)
}
}
return fallback
}
})
}
return value
return Option.None
}
}
@@ -26,6 +26,7 @@ import {
VaporisateurDeviceBox,
VelocityDeviceBox,
WaveshaperDeviceBox,
WerkstattDeviceBox,
ZeitgeistDeviceBox
} from "@opendaw/studio-boxes"
import {DelayDeviceProcessor} from "./devices/audio-effects/DelayDeviceProcessor"
@@ -56,6 +57,7 @@ import {
VaporisateurDeviceBoxAdapter,
VelocityDeviceBoxAdapter,
WaveshaperDeviceBoxAdapter,
WerkstattDeviceBoxAdapter,
ZeitgeistDeviceBoxAdapter
} from "@opendaw/studio-adapters"
import {NopDeviceProcessor} from "./devices/audio-effects/NopDeviceProcessor"
@@ -89,6 +91,7 @@ import {TidalDeviceProcessor} from "./devices/audio-effects/TidalDeviceProcessor
import {DattorroReverbDeviceProcessor} from "./devices/audio-effects/DattorroReverbDeviceProcessor"
import {NeuralAmpDeviceProcessor} from "./devices/audio-effects/NeuralAmpDeviceProcessor"
import {WaveshaperDeviceProcessor} from "./devices/audio-effects/WaveshaperDeviceProcessor"
import {WerkstattDeviceProcessor} from "./devices/audio-effects/WerkstattDeviceProcessor"
export namespace InstrumentDeviceProcessorFactory {
export const create = (context: EngineContext,
@@ -161,6 +164,8 @@ export namespace AudioEffectDeviceProcessorFactory {
visitNeuralAmpDeviceBox: (box: NeuralAmpDeviceBox): AudioEffectDeviceProcessor =>
new NeuralAmpDeviceProcessor(context, context.boxAdapters.adapterFor(box, NeuralAmpDeviceBoxAdapter)),
visitWaveshaperDeviceBox: (box: WaveshaperDeviceBox): AudioEffectDeviceProcessor =>
new WaveshaperDeviceProcessor(context, context.boxAdapters.adapterFor(box, WaveshaperDeviceBoxAdapter))
new WaveshaperDeviceProcessor(context, context.boxAdapters.adapterFor(box, WaveshaperDeviceBoxAdapter)),
visitWerkstattDeviceBox: (box: WerkstattDeviceBox): AudioEffectDeviceProcessor =>
new WerkstattDeviceProcessor(context, context.boxAdapters.adapterFor(box, WerkstattDeviceBoxAdapter))
}), `Could not create audio-effect for'${box.name}'`)
}
@@ -146,6 +146,7 @@ export class EngineProcessor extends AudioWorkletProcessor implements EngineCont
dispatcher => new class implements EngineToClient {
log(message: string): void {dispatcher.dispatchAndForget(this.log, message)}
error(error: unknown): void {dispatcher.dispatchAndForget(this.error, error)}
deviceMessage(uuid: string, message: string): void {dispatcher.dispatchAndForget(this.deviceMessage, uuid, message)}
fetchAudio(uuid: UUID.Bytes): Promise<AudioData> {
return dispatcher.dispatchAndReturn(this.fetchAudio, uuid)
}
@@ -0,0 +1,198 @@
import {Arrays, asInstanceOf, int, isDefined, Nullable, Option, Terminable, UUID} from "@opendaw/lib-std"
import {AudioEffectDeviceAdapter, WerkstattDeviceBoxAdapter} from "@opendaw/studio-adapters"
import {WerkstattParameterBox} from "@opendaw/studio-boxes"
import {EngineContext} from "../../EngineContext"
import {Block, Processor} from "../../processing"
import {PeakBroadcaster} from "../../PeakBroadcaster"
import {AudioEffectDeviceProcessor} from "../../AudioEffectDeviceProcessor"
import {AudioBuffer} from "@opendaw/lib-dsp"
import {AudioProcessor} from "../../AudioProcessor"
import {AutomatableParameter} from "../../AutomatableParameter"
const HEADER_PATTERN = /^\/\/ @werkstatt (\w+) (\d+) (\d+)\n/
const MAX_AMPLITUDE = 1000.0 // ~60dB
const parseUpdate = (code: string): int => {
const match = code.match(HEADER_PATTERN)
return match !== null ? parseInt(match[3]) : -1
}
const validateOutput = (channels: ReadonlyArray<Float32Array>, s0: int, s1: int): Nullable<string> => {
for (let ch = 0; ch < channels.length; ch++) {
const channel = channels[ch]
for (let i = s0; i < s1; i++) {
const sample = channel[i]
if (sample !== sample) {return `NaN detected in output channel ${ch} at sample ${i}`}
if (sample > MAX_AMPLITUDE || sample < -MAX_AMPLITUDE) {
return `Signal overflow in channel ${ch} at sample ${i} (amplitude: ${sample.toFixed(1)})`
}
}
}
return null
}
interface UserIO {
src: ReadonlyArray<Float32Array>
out: ReadonlyArray<Float32Array>
}
interface UserProcessor {
process(io: UserIO, block: Block): void
paramChanged?(label: string, value: number): void
}
export class WerkstattDeviceProcessor extends AudioProcessor implements AudioEffectDeviceProcessor {
static ID: int = 0 | 0
readonly #id: int = WerkstattDeviceProcessor.ID++
readonly #adapter: WerkstattDeviceBoxAdapter
readonly #engineToClient: EngineContext["engineToClient"]
readonly #output: AudioBuffer
readonly #peaks: PeakBroadcaster
readonly #uuid: string
readonly #boundParameters: Array<AutomatableParameter<number>>
#source: Option<AudioBuffer> = Option.None
#userProcessor: Option<UserProcessor> = Option.None
#currentUpdate: int = -1
#silenced: boolean = false
constructor(context: EngineContext, adapter: WerkstattDeviceBoxAdapter) {
super(context)
this.#adapter = adapter
this.#engineToClient = context.engineToClient
this.#output = new AudioBuffer()
this.#peaks = this.own(new PeakBroadcaster(context.broadcaster, adapter.address))
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.#output.clear()
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),
context.audioOutputBufferRegistry.register(adapter.address, this.#output, this.outgoing)
)
this.readAllParameters()
}
#tryLoad(update: int): void {
const registry = (globalThis as any).openDAW?.werkstattProcessors?.[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.#reportError(`Failed to instantiate Processor: ${error}`)
this.#silenced = true
}
}
#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
this.#output.clear()
this.#reportError(message)
}
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())
}
})
}
get incoming(): Processor {return this}
get outgoing(): Processor {return this}
reset(): void {
this.#peaks.clear()
this.#output.clear()
this.eventInput.clear()
}
get uuid(): UUID.Bytes {return this.#adapter.uuid}
get audioOutput(): AudioBuffer {return this.#output}
setAudioSource(source: AudioBuffer): Terminable {
this.#source = Option.wrap(source)
return {terminate: () => this.#source = Option.None}
}
index(): int {return this.#adapter.indexField.getValue()}
adapter(): AudioEffectDeviceAdapter {return this.#adapter}
processAudio(block: Block): void {
if (this.#silenced) {
const expectedUpdate = parseUpdate(this.#adapter.box.code.getValue())
if (expectedUpdate > 0 && expectedUpdate !== this.#currentUpdate) {
this.#tryLoad(expectedUpdate)
}
if (this.#silenced) {return}
}
if (this.#source.isEmpty() || this.#userProcessor.isEmpty()) {return}
const source = this.#source.unwrap()
const proc = this.#userProcessor.unwrap()
const io: UserIO = {
src: [source.getChannel(0), source.getChannel(1)],
out: [this.#output.getChannel(0), this.#output.getChannel(1)]
}
try {
proc.process(io, block)
} catch (error) {
this.#silence(`Runtime error: ${error}`)
return
}
const validationError = validateOutput(io.out, block.s0, block.s1)
if (validationError !== null) {
this.#silence(validationError)
return
}
this.#peaks.process(io.out[0], io.out[1], block.s0, block.s1)
}
toString(): string {return `{${this.constructor.name} (${this.#id})`}
}
@@ -39,6 +39,9 @@ Communicator.executor<OfflineEngineProtocol>(
running: false
})
},
async addModule(code: string): Promise<void> {
new Function(code)()
},
async step(numSamples: int): Promise<Float32Array[]> {
const engine = state.unwrap()
const result: Float32Array[] = Arrays.create(() => new Float32Array(numSamples), engine.numberOfChannels)
+3 -1
View File
@@ -18,6 +18,7 @@ import {
UnknownMidiEffectDeviceBox,
VelocityDeviceBox,
WaveshaperDeviceBox,
WerkstattDeviceBox,
ZeitgeistDeviceBox
} from "@opendaw/studio-boxes"
@@ -25,4 +26,5 @@ export type EffectBox =
| ArpeggioDeviceBox | PitchDeviceBox | VelocityDeviceBox | ZeitgeistDeviceBox | UnknownMidiEffectDeviceBox
| MaximizerDeviceBox | DelayDeviceBox | ReverbDeviceBox | RevampDeviceBox | StereoToolDeviceBox | TidalDeviceBox
| ModularDeviceBox | UnknownAudioEffectDeviceBox | CompressorDeviceBox | GateDeviceBox
| CrusherDeviceBox | FoldDeviceBox | DattorroReverbDeviceBox | NeuralAmpDeviceBox | WaveshaperDeviceBox
| CrusherDeviceBox | FoldDeviceBox | DattorroReverbDeviceBox | NeuralAmpDeviceBox | WaveshaperDeviceBox
| WerkstattDeviceBox
+18 -1
View File
@@ -22,6 +22,7 @@ import {
TidalDeviceBox,
VelocityDeviceBox,
WaveshaperDeviceBox,
WerkstattDeviceBox,
ZeitgeistDeviceBox
} from "@opendaw/studio-boxes"
import {IconSymbol} from "@opendaw/studio-enums"
@@ -308,6 +309,21 @@ export namespace EffectFactories {
})
}
export const Werkstatt: EffectFactory = {
defaultName: "Werkstatt",
defaultIcon: IconSymbol.Code,
description: "User-scripted DSP processor",
manualPage: DeviceManualUrls.Werkstatt,
separatorBefore: false,
type: "audio",
create: ({boxGraph}, hostField, index): WerkstattDeviceBox =>
WerkstattDeviceBox.create(boxGraph, UUID.generate(), (box) => {
box.label.setValue("Werkstatt")
box.index.setValue(index)
box.host.refer(hostField)
})
}
export const Modular: EffectFactory = {
defaultName: "🔇 Create New Modular Audio Effect (inaudible yet)",
defaultIcon: IconSymbol.Box,
@@ -385,7 +401,8 @@ export namespace EffectFactories {
Waveshaper,
Tidal,
NeuralAmp,
Maximizer
Maximizer,
Werkstatt
}
if (!includeNeuralAmp) {
delete (AudioNamed as { NeuralAmp?: typeof NeuralAmp }).NeuralAmp
+2 -1
View File
@@ -1,4 +1,4 @@
import {int, Nullable, ObservableValue, Observer, Subscription, Terminable, UUID} from "@opendaw/lib-std"
import {int, Nullable, ObservableValue, Observer, Procedure, Subscription, Terminable, UUID} from "@opendaw/lib-std"
import {AudioData, bpm, ppqn} from "@opendaw/lib-dsp"
import {ClipNotification, EnginePreferences, NoteSignal} from "@opendaw/studio-adapters"
import {Project} from "./project"
@@ -25,6 +25,7 @@ export interface Engine extends Terminable {
scheduleClipPlay(clipIds: ReadonlyArray<UUID.Bytes>): void
scheduleClipStop(trackIds: ReadonlyArray<UUID.Bytes>): void
subscribeClipNotification(observer: Observer<ClipNotification>): Subscription
subscribeDeviceMessage(uuid: string, listener: Procedure<string>): Subscription
registerMonitoringSource(uuid: UUID.Bytes, node: AudioNode, numChannels: 1 | 2): void
unregisterMonitoringSource(uuid: UUID.Bytes): void
+4
View File
@@ -5,6 +5,7 @@ import {
ObservableValue,
Observer,
Option,
Procedure,
Subscription,
Terminator,
UUID
@@ -131,6 +132,9 @@ export class EngineFacade implements Engine {
scheduleClipStop(trackIds: ReadonlyArray<UUID.Bytes>): void {
this.#worklet.unwrap("No worklet to scheduleClipStop").scheduleClipStop(trackIds)
}
subscribeDeviceMessage(uuid: string, listener: Procedure<string>): Subscription {
return this.#worklet.unwrap("No worklet to subscribeDeviceMessage").subscribeDeviceMessage(uuid, listener)
}
registerMonitoringSource(uuid: UUID.Bytes, node: AudioNode, numChannels: 1 | 2): void {
this.#worklet.ifSome(worklet => worklet.registerMonitoringSource(uuid, node, numChannels))
}
+13
View File
@@ -9,6 +9,8 @@ import {
ObservableValue,
Observer,
Option,
Procedure,
SetMultimap,
Subscription,
SyncStream,
Terminator,
@@ -65,6 +67,7 @@ export class EngineWorklet extends AudioWorkletNode implements Engine {
readonly #notifyClipNotification: Notifier<ClipNotification>
readonly #notifyNoteSignals: Notifier<NoteSignal>
readonly #playingClips: Array<UUID.Bytes>
readonly #deviceMessageListeners: SetMultimap<string, Procedure<string>> = new SetMultimap()
readonly #commands: EngineCommands
readonly #isReady: Promise<void>
@@ -171,6 +174,11 @@ export class EngineWorklet extends AudioWorkletNode implements Engine {
Communicator.executor<EngineToClient>(messenger.channel("engine-to-client"), {
log: (message: string): void => console.log("WORKLET", message),
error: (reason: unknown) => this.dispatchEvent(new ErrorEvent("error", {error: reason})),
deviceMessage: (uuid: string, message: string): void => {
for (const listener of this.#deviceMessageListeners.get(uuid)) {
listener(message)
}
},
ready: (): void => resolve(),
fetchAudio: (uuid: UUID.Bytes): Promise<AudioData> => {
return new Promise((resolve, reject) => {
@@ -284,6 +292,11 @@ export class EngineWorklet extends AudioWorkletNode implements Engine {
return this.#notifyClipNotification.subscribe(observer)
}
subscribeDeviceMessage(uuid: string, listener: Procedure<string>): Subscription {
this.#deviceMessageListeners.add(uuid, listener)
return {terminate: () => this.#deviceMessageListeners.remove(uuid, listener)}
}
registerMonitoringSource(uuid: UUID.Bytes, node: AudioNode, numChannels: 1 | 2): void {
this.#monitoringSources.set(UUID.toString(uuid), {node, numChannels})
this.#rebuildMonitoringMerger()
@@ -1,5 +1,6 @@
import {DefaultObservableValue, Errors, int, isDefined, Nullable, Option, panic, SyncStream, Terminable, Terminator, TimeSpan, UUID} from "@opendaw/lib-std"
import {AudioData, ppqn} from "@opendaw/lib-dsp"
import {WerkstattDeviceBox} from "@opendaw/studio-boxes"
import {Communicator, Messenger, Wait} from "@opendaw/lib-runtime"
import {AnimationFrame} from "@opendaw/lib-dom"
import {
@@ -46,6 +47,9 @@ export class OfflineEngineRenderer {
initialize(enginePort: MessagePort, config: OfflineEngineInitializeConfig): Promise<void> {
return dispatcher.dispatchAndReturn(this.initialize, enginePort, config)
}
addModule(code: string): Promise<void> {
return dispatcher.dispatchAndReturn(this.addModule, code)
}
render(config: OfflineEngineRenderConfig): Promise<Float32Array[]> {
return dispatcher.dispatchAndReturn(this.render, config)
}
@@ -95,7 +99,10 @@ export class OfflineEngineRenderer {
return response.arrayBuffer()
},
notifyClipSequenceChanges: (): void => {},
switchMarkerState: (): void => {}
switchMarkerState: (): void => {},
deviceMessage: (uuid: string, message: string): void => {
console.warn(`OFFLINE-ENGINE device(${uuid}): ${message}`)
}
})
const engineCommands = Communicator.sender<EngineCommands>(
@@ -137,6 +144,30 @@ export class OfflineEngineRenderer {
exportConfiguration: optExportConfiguration.unwrapOrUndefined()
})
const HEADER_PATTERN = /^\/\/ @werkstatt (\w+) (\d+) (\d+)\n/
for (const box of source.boxGraph.boxes()) {
if (box instanceof WerkstattDeviceBox) {
const code = box.code.getValue()
const match = code.match(HEADER_PATTERN)
if (match !== null) {
const userCode = code.slice(match[0].length)
const update = parseInt(match[3])
const uuid = UUID.toString(box.address.uuid)
await protocol.addModule(`
if (typeof globalThis.openDAW === "undefined") { globalThis.openDAW = {} }
if (typeof globalThis.openDAW.werkstattProcessors === "undefined") { globalThis.openDAW.werkstattProcessors = {} }
globalThis.openDAW.werkstattProcessors["${uuid}"] = {
update: ${update},
create: (function werkstatt() {
${userCode}
return Processor
})()
}
`)
}
}
}
engineCommands.setupMIDI(port, sab)
return new OfflineEngineRenderer(
@@ -163,8 +194,9 @@ export class OfflineEngineRenderer {
enabled.setValue(false)
boxGraph.endTransaction()
const endPosition = source.lastRegionAction()
const maxDurationSeconds = source.tempoMap.ppqnToSeconds(endPosition) + 30
const renderer = await this.create(source, optExportConfiguration, sampleRate)
const result = await renderer.render({}, endPosition, progress, abortSignal)
const result = await renderer.render({maxDurationSeconds}, endPosition, progress, abortSignal)
boxGraph.beginTransaction()
enabled.setValue(wasEnabled)
boxGraph.endTransaction()
@@ -106,8 +106,8 @@ export namespace AudioUnitsClipboard {
if (isOutputPaste) {
// Split into two transactions to ensure deletion notifications fire
// before new boxes are created (avoids "already has input" conflict)
editing.modify(() => clearOutputContent(rootBoxAdapter), false)
editing.modify(() => pasteOutputContent(entry.data, boxGraph, rootBoxAdapter))
editing.modify(() => clearOutputContent(rootBoxAdapter))
editing.append(() => pasteOutputContent(entry.data, boxGraph, rootBoxAdapter))
} else {
editing.modify(() => {
const pastedBox = pasteNewAudioUnit(entry.data, boxGraph, rootBoxAdapter, getEditedAudioUnit())
+1
View File
@@ -18,6 +18,7 @@ export enum IconSymbol {
Clips,
Close,
CloudFolder,
Code,
CollapseDown,
Compressor,
Connected,
@@ -0,0 +1,8 @@
import {BoxSchema} from "@opendaw/lib-box-forge"
import {Pointers} from "@opendaw/studio-enums"
import {DeviceFactory} from "../../std/DeviceFactory"
export const WerkstattDeviceBox: BoxSchema<Pointers> = DeviceFactory.createAudioEffect("WerkstattDeviceBox", {
10: {type: "string", name: "code", value: ""},
11: {type: "field", name: "parameters", pointerRules: {accepts: [Pointers.Parameter], mandatory: false}}
})
@@ -0,0 +1,17 @@
import {BoxSchema} from "@opendaw/lib-box-forge"
import {Pointers} from "@opendaw/studio-enums"
import {ParameterPointerRules} from "../../std/Defaults"
export const WerkstattParameterBox: BoxSchema<Pointers> = {
type: "box",
class: {
name: "WerkstattParameterBox",
fields: {
1: {type: "pointer", name: "owner", pointerType: Pointers.Parameter, mandatory: true},
2: {type: "string", name: "label", value: ""},
3: {type: "int32", name: "index", constraints: "index", unit: ""},
4: {type: "float32", name: "value", constraints: "unipolar", unit: "%", pointerRules: ParameterPointerRules},
5: {type: "float32", name: "defaultValue", constraints: "unipolar", unit: "%"}
}
}
}
@@ -26,6 +26,8 @@ import {DattorroReverbDeviceBox} from "./audio-effects/DattorroReverbDeviceBox"
import {GateDeviceBox} from "./audio-effects/GateDeviceBox"
import {NeuralAmpDeviceBox} from "./audio-effects/NeuralAmpDeviceBox"
import {WaveshaperDeviceBox} from "./audio-effects/WaveshaperDeviceBox"
import {WerkstattDeviceBox} from "./audio-effects/WerkstattDeviceBox"
import {WerkstattParameterBox} from "./audio-effects/WerkstattParameterBox"
export const DeviceDefinitions = [
UnknownAudioEffectDevice,
@@ -57,5 +59,7 @@ export const DeviceDefinitions = [
PitchDeviceBox,
ZeitgeistDeviceBox,
NeuralAmpDeviceBox,
WaveshaperDeviceBox
WaveshaperDeviceBox,
WerkstattDeviceBox,
WerkstattParameterBox
]
+103 -62
View File
@@ -24,23 +24,26 @@ Prove the full pipeline end-to-end: forge schema → generated Box → adapter
class Processor {
process(inputL: Float32Array, inputR: Float32Array,
outputL: Float32Array, outputR: Float32Array,
fromIndex: number, toIndex: number): void {
block: {index, p0, p1, s0, s1, bpm, flags}): void {
// user DSP code
}
}
```
- `constructor()` — allocate memory, initialise state. `sampleRate` is available on `globalThis`.
- `process()` — block-based stereo processing. The host passes `block.s0`/`block.s1` as `fromIndex`/`toIndex`.
- `process()` — block-based stereo processing. The `block` object provides:
- `s0`/`s1` — sample range (loop indices into the Float32Arrays)
- `p0`/`p1` — position in ppqn (pulse per quarter note)
- `bpm` — current tempo
- `flags` — bitmask: `1` = transporting, `2` = discontinuous, `4` = playing, `8` = bpmChanged
- `index` — block index
### Example: Hard Clipper
```typescript
class Processor {
process(inputL: Float32Array, inputR: Float32Array,
outputL: Float32Array, outputR: Float32Array,
fromIndex: number, toIndex: number): void {
for (let i = fromIndex; i < toIndex; i++) {
process(inputL, inputR, outputL, outputR, block) {
for (let i = block.s0; i < block.s1; i++) {
outputL[i] = Math.max(-0.5, Math.min(0.5, inputL[i]))
outputR[i] = Math.max(-0.5, Math.min(0.5, inputR[i]))
}
@@ -53,11 +56,9 @@ class Processor {
```typescript
class Processor {
phase = 0
process(inputL: Float32Array, inputR: Float32Array,
outputL: Float32Array, outputR: Float32Array,
fromIndex: number, toIndex: number): void {
process(inputL, inputR, outputL, outputR, block) {
const inc = 440 / sampleRate
for (let i = fromIndex; i < toIndex; i++) {
for (let i = block.s0; i < block.s1; i++) {
const mod = Math.sin(this.phase * Math.PI * 2)
this.phase += inc
if (this.phase >= 1) this.phase -= 1
@@ -315,9 +316,71 @@ export const WerkstattEditor = ({lifecycle, service, adapter, deviceHost}: Const
---
## Iteration 2 — User-Managed Parameters
## Iteration 2 — Code-Declared Parameters
Adds automatable parameters via +/- buttons in the editor. The code references them by name via `paramChanged(name, value)`.
Parameters are declared as `// @param` comments at the top of the user code. The compiler parses them on the main thread before `addModule()`, then reconciles the box graph — creating, updating, or removing `WerkstattParameterBox` instances to match. No UI buttons for adding/removing parameters; the code is the single source of truth.
This makes the device fully vibe-codable: an AI can generate the complete effect (params + DSP) in one text block.
### Parameter Declaration Format
```
// @param <name> <min> <max> <default> <mapping>
```
- `name` — identifier, used in `paramChanged(name, value)`
- `min`, `max`, `default` — numeric literals (no expressions, no `sampleRate`, no computation)
- `mapping` — one of: `linear`, `exp`, `log`, `int`
Parameters are pure static data. No reading from the environment, no computed values. The host owns the parameter values; the code just receives them via `paramChanged()`.
### Supported Mappings
- `linear` — uniform distribution. Use for mix, gain, pan, etc.
- `exp` — exponential distribution. Use for frequency, time constants, etc.
- `log` — logarithmic distribution. Use for dB values.
- `int` — linear but snapped to integers. Use for semitones, choices, etc.
The mapping defines how the knob's 0→1 range maps to min→max. Parameter values passed to `paramChanged()` are always the mapped value (not the raw 0→1).
### Compiler: Parsing and Reconciliation
The compiler extracts parameter declarations before sending code to the worklet:
```typescript
const PARAM_PATTERN = /^\/\/ @param (\w+) ([.\d-]+) ([.\d-]+) ([.\d-]+) (\w+)$/gm
interface ParamDeclaration {
name: string
min: number
max: number
defaultValue: number
mapping: string
}
const parseParams = (code: string): ParamDeclaration[] => {
const params: ParamDeclaration[] = []
let match: RegExpExecArray | null
while ((match = PARAM_PATTERN.exec(code)) !== null) {
params.push({
name: match[1],
min: parseFloat(match[2]),
max: parseFloat(match[3]),
defaultValue: parseFloat(match[4]),
mapping: match[5]
})
}
return params
}
```
On compile, the compiler:
1. Parses `// @param` comments from the code text
2. Compares declared params against existing `WerkstattParameterBox` instances in the box graph
3. Creates new param boxes for newly declared params
4. Removes param boxes for params no longer declared
5. Updates min/max/default/mapping on existing params whose declarations changed
6. Sends the code (without param comments) to the worklet via `addModule()`
### What Changes
@@ -369,14 +432,6 @@ this.#terminator.own(
)
```
Supported mappings (selectable per parameter in the UI):
- `linear` — uniform distribution. Use for mix, gain, pan, etc.
- `exp` — exponential distribution. Use for frequency, time constants, etc.
- `log` — logarithmic distribution. Use for dB values.
- `int` — linear but snapped to integers. Use for semitones, choices, etc.
The mapping defines how the knob's 0→1 range maps to min→max. Parameter values passed to `paramChanged()` are always the mapped value (not the raw 0→1).
**Processor**: Add dynamic parameter binding and `paramChanged` forwarding.
```typescript
@@ -408,56 +463,35 @@ parameterChanged(parameter: AutomatableParameter): void {
}
```
**Editor**: Add +/- buttons and parameter list.
```typescript
const addParameter = () => {
editing.modify(() => {
WerkstattParameterBox.create(boxGraph, UUID.generate(), paramBox => {
paramBox.owner.refer(adapter.box.parameters)
paramBox.name.setValue("param")
paramBox.min.setValue(0)
paramBox.max.setValue(1)
paramBox.defaultValue.setValue(0)
paramBox.mapping.setValue("linear")
})
})
}
const removeParameter = (paramBox: WerkstattParameterBox) => {
editing.modify(() => paramBox.delete())
}
```
**Editor**: Parameter knobs auto-generate from the box graph. No +/- buttons needed — the compiler reconciles on each Run.
### Class Contract (Extended)
```typescript
class Processor {
paramChanged?(name: string, value: number): void
process(inputL: Float32Array, inputR: Float32Array,
outputL: Float32Array, outputR: Float32Array,
fromIndex: number, toIndex: number): void
process(inputL, inputR, outputL, outputR, block): void
}
```
### Example: Simple Delay
Parameters configured in the UI: `time` (0.001–2.0, exp), `feedback` (0–0.95, linear)
```typescript
// @param time 0.001 2.0 0.5 exp
// @param feedback 0 0.95 0.5 linear
class Processor {
readonly bufferL = new Float32Array(sampleRate * 2)
readonly bufferR = new Float32Array(sampleRate * 2)
bufferL = new Float32Array(sampleRate * 2)
bufferR = new Float32Array(sampleRate * 2)
writeHead = 0
delaySamples = sampleRate * 0.5
feedback = 0.5
paramChanged(name: string, value: number) {
paramChanged(name, value) {
if (name === "time") this.delaySamples = value * sampleRate
if (name === "feedback") this.feedback = value
}
process(inputL: Float32Array, inputR: Float32Array,
outputL: Float32Array, outputR: Float32Array,
fromIndex: number, toIndex: number): void {
for (let i = fromIndex; i < toIndex; i++) {
process(inputL, inputR, outputL, outputR, block) {
for (let i = block.s0; i < block.s1; i++) {
const readHead = (this.writeHead - this.delaySamples + this.bufferL.length) % this.bufferL.length
const delayedL = this.bufferL[readHead]
const delayedR = this.bufferR[readHead]
@@ -473,20 +507,21 @@ class Processor {
### Example: Biquad Lowpass with Automatable Cutoff
Parameters configured in the UI: `cutoff` (20–20000, exp), `resonance` (0.1–10, linear)
```typescript
// @param cutoff 20 20000 1000 exp
// @param resonance 0.1 10 0.707 linear
class Processor {
x1L = 0; x2L = 0; y1L = 0; y2L = 0
x1R = 0; x2R = 0; y1R = 0; y2R = 0
b0 = 0; b1 = 0; b2 = 0; a1 = 0; a2 = 0
cutoff = 1000; resonance = 0.707
paramChanged(name: string, value: number) {
paramChanged(name, value) {
if (name === "cutoff") this.cutoff = value
if (name === "resonance") this.resonance = value
this.recalcCoefficients(this.cutoff, this.resonance)
}
recalcCoefficients(cutoff: number, resonance: number) {
recalcCoefficients(cutoff, resonance) {
const w0 = 2 * Math.PI * cutoff / sampleRate
const alpha = Math.sin(w0) / (2 * resonance)
const cosw0 = Math.cos(w0)
@@ -497,10 +532,8 @@ class Processor {
this.a1 = (-2 * cosw0) / a0
this.a2 = (1 - alpha) / a0
}
process(inputL: Float32Array, inputR: Float32Array,
outputL: Float32Array, outputR: Float32Array,
fromIndex: number, toIndex: number): void {
for (let i = fromIndex; i < toIndex; i++) {
process(inputL, inputR, outputL, outputR, block) {
for (let i = block.s0; i < block.s1; i++) {
const outL = this.b0 * inputL[i] + this.b1 * this.x1L + this.b2 * this.x2L
- this.a1 * this.y1L - this.a2 * this.y2L
this.x2L = this.x1L; this.x1L = inputL[i]
@@ -559,17 +592,25 @@ The processor must be **stateful** — many DSP algorithms need persistent memor
Memory is just typed arrays — `Float32Array` for audio buffers, `Float64Array` for state requiring precision. `sampleRate` is available on `globalThis` in the worklet.
## Testing
### Offline Rendering
Werkstatt must work with offline rendering (`OfflineEngineRenderer`). The user code injection via `addModule()` must also happen on the offline `AudioContext`. Verify:
- Werkstatt processes audio correctly during `OfflineEngineRenderer.step()`
- Version gating resolves before first audio block
- Error recovery (silencing) works in offline context
## Unsolved Issues
### 1. Cleanup of addModule Code
Each `addModule()` call adds code that persists in the worklet scope forever — there's no way to unload a module. The registry entry per device UUID is overwritten on each recompile, but the IIFE closures from previous compiles remain in memory. Acceptable for a scripting device.
### 2. Error Reporting
Runtime errors caught in `processAudio()` need to reach the editor UI. Options: `broadcastFloats` with a special address, or a dedicated `MessagePort` channel.
### 2. Error Reporting (Solved)
Runtime errors are reported via `EngineToClient.deviceMessage(uuid, message)`. The `EngineWorklet` dispatches to per-device listeners via `SetMultimap`. The editor subscribes via `engine.subscribeDeviceMessage(uuid, listener)` and displays errors inline.
## Design Decisions
- **Parameters managed via UI, not code**: +/- buttons in the editor create/remove `WerkstattParameterBox` instances. The code references them by name via `paramChanged()`. This eliminates host-side reflection, main-thread eval, and parameter reconciliation entirely.
- **Parameters declared in code as comments**: `// @param name min max default mapping` at the top of the user code. The compiler parses them on the main thread (pure regex, no eval), then reconciles the box graph. This makes the device fully vibe-codable — an AI can generate the complete effect in one text block. Parameters are pure static data: no expressions, no environment reads, no computation.
- **Block-based stereo processing**: `process(inputL, inputR, outputL, outputR, fromIndex, toIndex)` — the host extracts `s0`/`s1` from the per-chunk `Block`. No per-sample function call overhead, full stereo/cross-channel capability.
- **Error recovery is host-injected**: broken code silences the processor until the next successful recompile.
- **Peak metering is host-injected**: `PeakBroadcaster` runs on the output after the user's `process()` call.