fixes mixdown

This commit is contained in:
André Michelle
2026-03-09 18:24:44 +01:00
parent 627ac31a5b
commit dfbf598ffb
12 changed files with 132 additions and 19 deletions
@@ -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
@@ -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 = <textarea spellcheck={false}>{userCode}</textarea> as HTMLTextAreaElement
const errorDisplay = <div className="error"/> as HTMLDivElement
const runButton = <button onclick={async () => {
errorDisplay.textContent = ""
errorDisplay.classList.remove("visible")
await WerkstattCompiler.compile(service.audioContext, box, textarea.value)
}}>Run</button>
lifecycle.ownAll(
service.engine.subscribeDeviceMessage(UUID.toString(adapter.uuid), message => {
errorDisplay.textContent = message
errorDisplay.classList.add("visible")
})
)
return (
<DeviceEditor lifecycle={lifecycle}
project={project}
@@ -37,6 +46,7 @@ export const WerkstattDeviceEditor = ({lifecycle, service, adapter, deviceHost}:
populateControls={() => (
<div className={className}>
{textarea}
{errorDisplay}
{runButton}
</div>
)}
@@ -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]
@@ -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>
@@ -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<AudioData> {
return dispatcher.dispatchAndReturn(this.fetchAudio, uuid)
}
@@ -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<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>
@@ -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<AudioBuffer> = Option.None
#userProcessor: Option<UserProcessor> = 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)
}
@@ -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)
+2 -1
View File
@@ -1,4 +1,4 @@
import {int, Nullable, ObservableValue, Observer, Subscription, Terminable, UUID} from "@opendaw/lib-std"
import {int, Nullable, ObservableValue, Observer, Procedure, Subscription, Terminable, UUID} from "@opendaw/lib-std"
import {AudioData, bpm, ppqn} from "@opendaw/lib-dsp"
import {ClipNotification, EnginePreferences, NoteSignal} from "@opendaw/studio-adapters"
import {Project} from "./project"
@@ -25,6 +25,7 @@ export interface Engine extends Terminable {
scheduleClipPlay(clipIds: ReadonlyArray<UUID.Bytes>): void
scheduleClipStop(trackIds: ReadonlyArray<UUID.Bytes>): void
subscribeClipNotification(observer: Observer<ClipNotification>): Subscription
subscribeDeviceMessage(uuid: string, listener: Procedure<string>): Subscription
registerMonitoringSource(uuid: UUID.Bytes, node: AudioNode, numChannels: 1 | 2): void
unregisterMonitoringSource(uuid: UUID.Bytes): void
+4
View File
@@ -5,6 +5,7 @@ import {
ObservableValue,
Observer,
Option,
Procedure,
Subscription,
Terminator,
UUID
@@ -131,6 +132,9 @@ export class EngineFacade implements Engine {
scheduleClipStop(trackIds: ReadonlyArray<UUID.Bytes>): void {
this.#worklet.unwrap("No worklet to scheduleClipStop").scheduleClipStop(trackIds)
}
subscribeDeviceMessage(uuid: string, listener: Procedure<string>): Subscription {
return this.#worklet.unwrap("No worklet to subscribeDeviceMessage").subscribeDeviceMessage(uuid, listener)
}
registerMonitoringSource(uuid: UUID.Bytes, node: AudioNode, numChannels: 1 | 2): void {
this.#worklet.ifSome(worklet => worklet.registerMonitoringSource(uuid, node, numChannels))
}
+13
View File
@@ -9,6 +9,8 @@ import {
ObservableValue,
Observer,
Option,
Procedure,
SetMultimap,
Subscription,
SyncStream,
Terminator,
@@ -65,6 +67,7 @@ export class EngineWorklet extends AudioWorkletNode implements Engine {
readonly #notifyClipNotification: Notifier<ClipNotification>
readonly #notifyNoteSignals: Notifier<NoteSignal>
readonly #playingClips: Array<UUID.Bytes>
readonly #deviceMessageListeners: SetMultimap<string, Procedure<string>> = new SetMultimap()
readonly #commands: EngineCommands
readonly #isReady: Promise<void>
@@ -171,6 +174,11 @@ export class EngineWorklet extends AudioWorkletNode implements Engine {
Communicator.executor<EngineToClient>(messenger.channel("engine-to-client"), {
log: (message: string): void => console.log("WORKLET", message),
error: (reason: unknown) => this.dispatchEvent(new ErrorEvent("error", {error: reason})),
deviceMessage: (uuid: string, message: string): void => {
for (const listener of this.#deviceMessageListeners.get(uuid)) {
listener(message)
}
},
ready: (): void => resolve(),
fetchAudio: (uuid: UUID.Bytes): Promise<AudioData> => {
return new Promise((resolve, reject) => {
@@ -284,6 +292,11 @@ export class EngineWorklet extends AudioWorkletNode implements Engine {
return this.#notifyClipNotification.subscribe(observer)
}
subscribeDeviceMessage(uuid: string, listener: Procedure<string>): Subscription {
this.#deviceMessageListeners.add(uuid, listener)
return {terminate: () => this.#deviceMessageListeners.remove(uuid, listener)}
}
registerMonitoringSource(uuid: UUID.Bytes, node: AudioNode, numChannels: 1 | 2): void {
this.#monitoringSources.set(UUID.toString(uuid), {node, numChannels})
this.#rebuildMonitoringMerger()
@@ -1,5 +1,6 @@
import {DefaultObservableValue, Errors, int, isDefined, Nullable, Option, panic, SyncStream, Terminable, Terminator, TimeSpan, UUID} from "@opendaw/lib-std"
import {AudioData, ppqn} from "@opendaw/lib-dsp"
import {WerkstattDeviceBox} from "@opendaw/studio-boxes"
import {Communicator, Messenger, Wait} from "@opendaw/lib-runtime"
import {AnimationFrame} from "@opendaw/lib-dom"
import {
@@ -46,6 +47,9 @@ export class OfflineEngineRenderer {
initialize(enginePort: MessagePort, config: OfflineEngineInitializeConfig): Promise<void> {
return dispatcher.dispatchAndReturn(this.initialize, enginePort, config)
}
addModule(code: string): Promise<void> {
return dispatcher.dispatchAndReturn(this.addModule, code)
}
render(config: OfflineEngineRenderConfig): Promise<Float32Array[]> {
return dispatcher.dispatchAndReturn(this.render, config)
}
@@ -95,7 +99,10 @@ export class OfflineEngineRenderer {
return response.arrayBuffer()
},
notifyClipSequenceChanges: (): void => {},
switchMarkerState: (): void => {}
switchMarkerState: (): void => {},
deviceMessage: (uuid: string, message: string): void => {
console.warn(`OFFLINE-ENGINE device(${uuid}): ${message}`)
}
})
const engineCommands = Communicator.sender<EngineCommands>(
@@ -137,6 +144,30 @@ export class OfflineEngineRenderer {
exportConfiguration: optExportConfiguration.unwrapOrUndefined()
})
const HEADER_PATTERN = /^\/\/ @werkstatt (\w+) (\d+) (\d+)\n/
for (const box of source.boxGraph.boxes()) {
if (box instanceof WerkstattDeviceBox) {
const code = box.code.getValue()
const match = code.match(HEADER_PATTERN)
if (match !== null) {
const userCode = code.slice(match[0].length)
const update = parseInt(match[3])
const uuid = UUID.toString(box.address.uuid)
await protocol.addModule(`
if (typeof globalThis.openDAW === "undefined") { globalThis.openDAW = {} }
if (typeof globalThis.openDAW.werkstattProcessors === "undefined") { globalThis.openDAW.werkstattProcessors = {} }
globalThis.openDAW.werkstattProcessors["${uuid}"] = {
update: ${update},
create: (function werkstatt() {
${userCode}
return Processor
})()
}
`)
}
}
}
engineCommands.setupMIDI(port, sab)
return new OfflineEngineRenderer(
@@ -163,8 +194,9 @@ export class OfflineEngineRenderer {
enabled.setValue(false)
boxGraph.endTransaction()
const endPosition = source.lastRegionAction()
const maxDurationSeconds = source.tempoMap.ppqnToSeconds(endPosition) + 30
const renderer = await this.create(source, optExportConfiguration, sampleRate)
const result = await renderer.render({}, endPosition, progress, abortSignal)
const result = await renderer.render({maxDurationSeconds}, endPosition, progress, abortSignal)
boxGraph.beginTransaction()
enabled.setValue(wasEnabled)
boxGraph.endTransaction()