Merge branch 'werkstatt' into dev
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
Generated
+7
-6
@@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
+2
-1
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
+198
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user