From dfbf598ffb577dbabdbbc71104ffd9a7fe3708d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Michelle?= Date: Mon, 9 Mar 2026 18:24:44 +0100 Subject: [PATCH] fixes mixdown --- .../audio-effects/WerkstattDeviceEditor.sass | 19 ++++++- .../audio-effects/WerkstattDeviceEditor.tsx | 12 ++++- .../audio-effects/werkstatt-default.txt | 5 +- .../studio/adapters/src/offline-renderer.ts | 1 + packages/studio/adapters/src/protocols.ts | 1 + .../core-processors/src/EngineProcessor.ts | 1 + .../audio-effects/WerkstattDeviceProcessor.ts | 53 +++++++++++++++---- .../core-workers/src/offline-engine-main.ts | 3 ++ packages/studio/core/src/Engine.ts | 3 +- packages/studio/core/src/EngineFacade.ts | 4 ++ packages/studio/core/src/EngineWorklet.ts | 13 +++++ .../studio/core/src/OfflineEngineRenderer.ts | 36 ++++++++++++- 12 files changed, 132 insertions(+), 19 deletions(-) diff --git a/packages/app/studio/src/ui/devices/audio-effects/WerkstattDeviceEditor.sass b/packages/app/studio/src/ui/devices/audio-effects/WerkstattDeviceEditor.sass index da3817e1..c3f3c6dc 100644 --- a/packages/app/studio/src/ui/devices/audio-effects/WerkstattDeviceEditor.sass +++ b/packages/app/studio/src/ui/devices/audio-effects/WerkstattDeviceEditor.sass @@ -2,7 +2,7 @@ component display: grid - grid-template-rows: 1fr auto + grid-template-rows: 1fr auto auto gap: 4px padding: 4px width: 320px @@ -16,7 +16,7 @@ component border: 1px solid rgba(255, 255, 255, 0.1) border-radius: 2px font-family: monospace - font-size: 10px + font-size: 9px line-height: 1.4 padding: 4px outline: none @@ -25,6 +25,21 @@ component &:focus border-color: rgba(255, 255, 255, 0.3) + > .error + display: none + font-family: monospace + font-size: 9px + line-height: 1.3 + color: #ff6060 + background: rgba(255, 0, 0, 0.1) + border: 1px solid rgba(255, 0, 0, 0.3) + border-radius: 2px + padding: 3px 4px + white-space: pre-wrap + word-break: break-word + &.visible + display: block + > button background: rgba(255, 255, 255, 0.1) color: #c0c0c0 diff --git a/packages/app/studio/src/ui/devices/audio-effects/WerkstattDeviceEditor.tsx b/packages/app/studio/src/ui/devices/audio-effects/WerkstattDeviceEditor.tsx index 24e54db4..ceb44806 100644 --- a/packages/app/studio/src/ui/devices/audio-effects/WerkstattDeviceEditor.tsx +++ b/packages/app/studio/src/ui/devices/audio-effects/WerkstattDeviceEditor.tsx @@ -1,7 +1,7 @@ import css from "./WerkstattDeviceEditor.sass?inline" import defaultCode from "./werkstatt-default.txt?raw" import {DeviceHost, WerkstattDeviceBoxAdapter} from "@opendaw/studio-adapters" -import {Lifecycle} from "@opendaw/lib-std" +import {Lifecycle, 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" @@ -26,9 +26,18 @@ export const WerkstattDeviceEditor = ({lifecycle, service, adapter, deviceHost}: const storedCode = box.code.getValue() const userCode = storedCode.length > 0 ? WerkstattCompiler.stripHeader(storedCode) : defaultCode const textarea = as HTMLTextAreaElement + const errorDisplay =
as HTMLDivElement const runButton = + lifecycle.ownAll( + service.engine.subscribeDeviceMessage(UUID.toString(adapter.uuid), message => { + errorDisplay.textContent = message + errorDisplay.classList.add("visible") + }) + ) return ( (
{textarea} + {errorDisplay} {runButton}
)} diff --git a/packages/app/studio/src/ui/devices/audio-effects/werkstatt-default.txt b/packages/app/studio/src/ui/devices/audio-effects/werkstatt-default.txt index 859213b4..4ce2c27e 100644 --- a/packages/app/studio/src/ui/devices/audio-effects/werkstatt-default.txt +++ b/packages/app/studio/src/ui/devices/audio-effects/werkstatt-default.txt @@ -2,11 +2,12 @@ class Processor { bufferL = new Float32Array(sampleRate * 2) bufferR = new Float32Array(sampleRate * 2) writeHead = 0 - delaySamples = Math.round(sampleRate * 60 / 120 / 4 * 3) + delaySamples = 0 feedback = 0.5 - process({src, out}, {s0, s1}) { + process({src, out}, {s0, s1, bpm}) { const [srcL, srcR] = src const [outL, outR] = out + this.delaySamples = Math.round(sampleRate * 60 / bpm / 4 * 3) for (let i = s0; i < s1; i++) { const readHead = (this.writeHead - this.delaySamples + this.bufferL.length) % this.bufferL.length const delayedL = this.bufferL[readHead] diff --git a/packages/studio/adapters/src/offline-renderer.ts b/packages/studio/adapters/src/offline-renderer.ts index e1899749..8e9267ad 100644 --- a/packages/studio/adapters/src/offline-renderer.ts +++ b/packages/studio/adapters/src/offline-renderer.ts @@ -18,6 +18,7 @@ export interface OfflineEngineRenderConfig { export interface OfflineEngineProtocol { initialize(enginePort: MessagePort, config: OfflineEngineInitializeConfig): Promise + addModule(code: string): Promise render(config: OfflineEngineRenderConfig): Promise step(samples: number): Promise stop(): void diff --git a/packages/studio/adapters/src/protocols.ts b/packages/studio/adapters/src/protocols.ts index 8315fd5b..6e999af7 100644 --- a/packages/studio/adapters/src/protocols.ts +++ b/packages/studio/adapters/src/protocols.ts @@ -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 fetchSoundfont(uuid: UUID.Bytes): Promise fetchNamWasm(): Promise diff --git a/packages/studio/core-processors/src/EngineProcessor.ts b/packages/studio/core-processors/src/EngineProcessor.ts index 6f33a331..6688f8f9 100644 --- a/packages/studio/core-processors/src/EngineProcessor.ts +++ b/packages/studio/core-processors/src/EngineProcessor.ts @@ -139,6 +139,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 { return dispatcher.dispatchAndReturn(this.fetchAudio, uuid) } diff --git a/packages/studio/core-processors/src/devices/audio-effects/WerkstattDeviceProcessor.ts b/packages/studio/core-processors/src/devices/audio-effects/WerkstattDeviceProcessor.ts index e183edd3..79619686 100644 --- a/packages/studio/core-processors/src/devices/audio-effects/WerkstattDeviceProcessor.ts +++ b/packages/studio/core-processors/src/devices/audio-effects/WerkstattDeviceProcessor.ts @@ -1,5 +1,5 @@ -import {int, isDefined, Option, Terminable, UUID} from "@opendaw/lib-std" -import {AudioEffectDeviceAdapter, WerkstattDeviceBoxAdapter} from "@opendaw/studio-adapters" +import {int, isDefined, Nullable, Option, Terminable, UUID} from "@opendaw/lib-std" +import {AudioEffectDeviceAdapter, EngineToClient, WerkstattDeviceBoxAdapter} from "@opendaw/studio-adapters" import {EngineContext} from "../../EngineContext" import {Block, Processor} from "../../processing" import {PeakBroadcaster} from "../../PeakBroadcaster" @@ -8,12 +8,27 @@ import {AudioBuffer} from "@opendaw/lib-dsp" import {AudioProcessor} from "../../AudioProcessor" 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, s0: int, s1: int): Nullable => { + 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 out: ReadonlyArray @@ -29,8 +44,10 @@ export class WerkstattDeviceProcessor extends AudioProcessor implements AudioEff readonly #id: int = WerkstattDeviceProcessor.ID++ readonly #adapter: WerkstattDeviceBoxAdapter + readonly #engineToClient: EngineToClient readonly #output: AudioBuffer readonly #peaks: PeakBroadcaster + readonly #uuid: string #source: Option = Option.None #userProcessor: Option = Option.None @@ -40,8 +57,10 @@ export class WerkstattDeviceProcessor extends AudioProcessor implements AudioEff 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.ownAll( adapter.box.code.catchupAndSubscribe(owner => { const newUpdate = parseUpdate(owner.getValue()) @@ -57,8 +76,7 @@ export class WerkstattDeviceProcessor extends AudioProcessor implements AudioEff } #tryLoad(update: int): void { - const uuid = UUID.toString(this.#adapter.uuid) - const registry = (globalThis as any).openDAW?.werkstattProcessors?.[uuid] + const registry = (globalThis as any).openDAW?.werkstattProcessors?.[this.#uuid] if (isDefined(registry) && registry.update === update) { this.#swapProcessor(registry.create, update) } @@ -70,11 +88,21 @@ export class WerkstattDeviceProcessor extends AudioProcessor implements AudioEff this.#currentUpdate = update this.#silenced = false } catch (error) { - console.error("Werkstatt: failed to instantiate Processor", error) + this.#reportError(`Failed to instantiate Processor: ${error}`) this.#silenced = true } } + #reportError(message: string): void { + this.#engineToClient.deviceMessage(this.#uuid, message) + } + + #silence(message: string): void { + this.#silenced = true + this.#output.clear() + this.#reportError(message) + } + get incoming(): Processor {return this} get outgoing(): Processor {return this} @@ -97,11 +125,9 @@ export class WerkstattDeviceProcessor extends AudioProcessor implements AudioEff processAudio(block: Block): void { if (this.#silenced) { - const uuid = UUID.toString(this.#adapter.uuid) - const registry = (globalThis as any).openDAW?.werkstattProcessors?.[uuid] const expectedUpdate = parseUpdate(this.#adapter.box.code.getValue()) - if (isDefined(registry) && registry.update === expectedUpdate) { - this.#swapProcessor(registry.create, expectedUpdate) + if (expectedUpdate > 0 && expectedUpdate !== this.#currentUpdate) { + this.#tryLoad(expectedUpdate) } if (this.#silenced) {return} } @@ -115,8 +141,13 @@ export class WerkstattDeviceProcessor extends AudioProcessor implements AudioEff try { proc.process(io, block) } catch (error) { - console.error("Werkstatt: runtime error in process()", error) - this.#silenced = true + 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) } diff --git a/packages/studio/core-workers/src/offline-engine-main.ts b/packages/studio/core-workers/src/offline-engine-main.ts index 86c4ff0d..e018ada2 100644 --- a/packages/studio/core-workers/src/offline-engine-main.ts +++ b/packages/studio/core-workers/src/offline-engine-main.ts @@ -39,6 +39,9 @@ Communicator.executor( running: false }) }, + async addModule(code: string): Promise { + new Function(code)() + }, async step(numSamples: int): Promise { const engine = state.unwrap() const result: Float32Array[] = Arrays.create(() => new Float32Array(numSamples), engine.numberOfChannels) diff --git a/packages/studio/core/src/Engine.ts b/packages/studio/core/src/Engine.ts index fe901557..33bd6b45 100644 --- a/packages/studio/core/src/Engine.ts +++ b/packages/studio/core/src/Engine.ts @@ -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): void scheduleClipStop(trackIds: ReadonlyArray): void subscribeClipNotification(observer: Observer): Subscription + subscribeDeviceMessage(uuid: string, listener: Procedure): Subscription registerMonitoringSource(uuid: UUID.Bytes, node: AudioNode, numChannels: 1 | 2): void unregisterMonitoringSource(uuid: UUID.Bytes): void diff --git a/packages/studio/core/src/EngineFacade.ts b/packages/studio/core/src/EngineFacade.ts index 31c296d9..8f038778 100644 --- a/packages/studio/core/src/EngineFacade.ts +++ b/packages/studio/core/src/EngineFacade.ts @@ -5,6 +5,7 @@ import { ObservableValue, Observer, Option, + Procedure, Subscription, Terminator, UUID @@ -131,6 +132,9 @@ export class EngineFacade implements Engine { scheduleClipStop(trackIds: ReadonlyArray): void { this.#worklet.unwrap("No worklet to scheduleClipStop").scheduleClipStop(trackIds) } + subscribeDeviceMessage(uuid: string, listener: Procedure): 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)) } diff --git a/packages/studio/core/src/EngineWorklet.ts b/packages/studio/core/src/EngineWorklet.ts index 98bb9d2f..bb655ebf 100644 --- a/packages/studio/core/src/EngineWorklet.ts +++ b/packages/studio/core/src/EngineWorklet.ts @@ -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 readonly #notifyNoteSignals: Notifier readonly #playingClips: Array + readonly #deviceMessageListeners: SetMultimap> = new SetMultimap() readonly #commands: EngineCommands readonly #isReady: Promise @@ -171,6 +174,11 @@ export class EngineWorklet extends AudioWorkletNode implements Engine { Communicator.executor(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 => { 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): 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() diff --git a/packages/studio/core/src/OfflineEngineRenderer.ts b/packages/studio/core/src/OfflineEngineRenderer.ts index 5d9655ed..6666d255 100644 --- a/packages/studio/core/src/OfflineEngineRenderer.ts +++ b/packages/studio/core/src/OfflineEngineRenderer.ts @@ -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 { return dispatcher.dispatchAndReturn(this.initialize, enginePort, config) } + addModule(code: string): Promise { + return dispatcher.dispatchAndReturn(this.addModule, code) + } render(config: OfflineEngineRenderConfig): Promise { 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( @@ -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()