new plan for monitoring with fx

This commit is contained in:
André Michelle
2026-02-05 16:54:15 +01:00
parent 5577766a60
commit 00221acbb4
2 changed files with 274 additions and 21 deletions
+21 -21
View File
@@ -3097,9 +3097,9 @@
"license": "MIT"
},
"node_modules/@types/node": {
"version": "25.2.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.2.0.tgz",
"integrity": "sha512-DZ8VwRFUNzuqJ5khrvwMXHmvPe+zGayJhr2CDNiKB1WBE1ST8Djl00D0IC4vvNmHMdj6DlbYRIaFE7WHjlDl5w==",
"version": "25.2.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.2.1.tgz",
"integrity": "sha512-CPrnr8voK8vC6eEtyRzvMpgp3VyVRhgclonE7qYi6P9sXwYb59ucfrnmFBTaP0yUi8Gk4yZg/LlTJULGxvTNsg==",
"license": "MIT",
"peer": true,
"dependencies": {
@@ -6025,9 +6025,9 @@
}
},
"node_modules/force-graph": {
"version": "1.51.0",
"resolved": "https://registry.npmjs.org/force-graph/-/force-graph-1.51.0.tgz",
"integrity": "sha512-aTnihCmiMA0ItLJLCbrQYS9mzriopW24goFPgUnKAAmAlPogTSmFWqoBPMXzIfPb7bs04Hur5zEI4WYgLW3Sig==",
"version": "1.51.1",
"resolved": "https://registry.npmjs.org/force-graph/-/force-graph-1.51.1.tgz",
"integrity": "sha512-uEEX8iRzgq1IKRISOw6RrB2RLMhcI25xznQYrCTVvxZHZZ+A2jH6qIolYuwavVxAMi64pFp2yZm4KFVdD993cg==",
"license": "MIT",
"dependencies": {
"@tweenjs/tween.js": "18 - 25",
@@ -6383,9 +6383,9 @@
}
},
"node_modules/get-tsconfig": {
"version": "4.13.1",
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.1.tgz",
"integrity": "sha512-EoY1N2xCn44xU6750Sx7OjOIT59FkmstNc3X6y5xpz7D5cBtZRe/3pSlTkDJgqsOk3WwZPkWfonhhUJfttQo3w==",
"version": "4.13.3",
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.3.tgz",
"integrity": "sha512-vp8Cj/+9Q/ibZUrq1rhy8mCTQpCk31A3uu9wc1C50yAb3x2pFHOsGdAZQ7jD86ARayyxZUViYeIztW+GE8dcrg==",
"license": "MIT",
"dependencies": {
"resolve-pkg-maps": "^1.0.0"
@@ -8327,9 +8327,9 @@
"license": "MIT"
},
"node_modules/mediabunny": {
"version": "1.31.1",
"resolved": "https://registry.npmjs.org/mediabunny/-/mediabunny-1.31.1.tgz",
"integrity": "sha512-7ijrySW675UTxXoAji8Xj/H8H1uyZhPDARlNO1FF1zGnzJV3snayU32c1bpumslx5vbUa/M7fm6kcG/3y7UxHQ==",
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/mediabunny/-/mediabunny-1.32.0.tgz",
"integrity": "sha512-wMWynAQqZkmsLm+d2+fovcBal3zBQu/bot3u+yV4bDTfNxPeEDHv1wjiz6yjyKcXBuxfaQK96gImqLcKLfJG7w==",
"license": "MPL-2.0",
"workspaces": [
"packages/*"
@@ -12979,9 +12979,9 @@
}
},
"packages/app/lab/node_modules/@types/node": {
"version": "22.19.8",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.8.tgz",
"integrity": "sha512-ebO/Yl+EAvVe8DnMfi+iaAyIqYdK0q/q0y0rw82INWEKJOBe6b/P3YWE8NW7oOlF/nXFNrHwhARrN/hdgDkraA==",
"version": "22.19.9",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.9.tgz",
"integrity": "sha512-PD03/U8g1F9T9MI+1OBisaIARhSzeidsUjQaf51fOxrfjeiKN9bLVO06lHuHYjxdnqLWJijJHfqXPSJri2EM2A==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -13075,9 +13075,9 @@
"license": "LGPL-3.0-or-later"
},
"packages/app/nam-test/node_modules/@types/node": {
"version": "22.19.8",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.8.tgz",
"integrity": "sha512-ebO/Yl+EAvVe8DnMfi+iaAyIqYdK0q/q0y0rw82INWEKJOBe6b/P3YWE8NW7oOlF/nXFNrHwhARrN/hdgDkraA==",
"version": "22.19.9",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.9.tgz",
"integrity": "sha512-PD03/U8g1F9T9MI+1OBisaIARhSzeidsUjQaf51fOxrfjeiKN9bLVO06lHuHYjxdnqLWJijJHfqXPSJri2EM2A==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -13131,9 +13131,9 @@
}
},
"packages/app/studio/node_modules/@types/node": {
"version": "22.19.8",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.8.tgz",
"integrity": "sha512-ebO/Yl+EAvVe8DnMfi+iaAyIqYdK0q/q0y0rw82INWEKJOBe6b/P3YWE8NW7oOlF/nXFNrHwhARrN/hdgDkraA==",
"version": "22.19.9",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.9.tgz",
"integrity": "sha512-PD03/U8g1F9T9MI+1OBisaIARhSzeidsUjQaf51fOxrfjeiKN9bLVO06lHuHYjxdnqLWJijJHfqXPSJri2EM2A==",
"dev": true,
"license": "MIT",
"dependencies": {
+253
View File
@@ -0,0 +1,253 @@
# Monitoring with FX
## Goal
Route monitoring audio through the AudioUnit's effect chain instead of directly to speakers.
## Approach
1. Recreate `ChannelMergerNode` on every monitoring state change
2. Map: `Array<{ uuid: UUID.Bytes, channels: Array<int> }>`
3. `MonitoringMixProcessor` reads assigned channels, mixes into instrument buffer
4. Map sent to worklet on every rebuild
## Architecture
### Main Thread (EngineWorklet.ts)
```typescript
#channelMerger: Nullable<ChannelMergerNode> = null
#monitoringSources: Map<UUID.Bytes, { node: AudioNode, numChannels: 1 | 2 }> = UUID.newMap()
registerMonitoringSource(uuid: UUID.Bytes, node: AudioNode, numChannels: 1 | 2): void {
this.#monitoringSources.set(uuid, { node, numChannels })
this.#rebuildMonitoringMerger()
}
unregisterMonitoringSource(uuid: UUID.Bytes): void {
this.#monitoringSources.delete(uuid)
this.#rebuildMonitoringMerger()
}
#rebuildMonitoringMerger(): void {
// Disconnect old merger
if (isDefined(this.#channelMerger)) {
this.#channelMerger.disconnect()
this.#channelMerger = null
}
if (this.#monitoringSources.size === 0) {
this.#commands.updateMonitoringMap([])
return
}
// Calculate total channels needed
let totalChannels = 0
for (const { numChannels } of this.#monitoringSources.values()) {
totalChannels += numChannels
}
// Create new merger
this.#channelMerger = this.context.createChannelMerger(totalChannels)
this.#channelMerger.connect(this)
// Connect sources and build map
const map: Array<{ uuid: UUID.Bytes, channels: Array<int> }> = []
let channel = 0
for (const [uuid, { node, numChannels }] of this.#monitoringSources) {
if (numChannels === 2) {
const splitter = this.context.createChannelSplitter(2)
node.connect(splitter)
splitter.connect(this.#channelMerger, 0, channel)
splitter.connect(this.#channelMerger, 1, channel + 1)
map.push({ uuid, channels: [channel, channel + 1] })
channel += 2
} else {
node.connect(this.#channelMerger, 0, channel)
map.push({ uuid, channels: [channel] })
channel += 1
}
}
this.#commands.updateMonitoringMap(map)
}
```
### Worklet Thread (EngineProcessor.ts)
```typescript
#currentInput: ReadonlyArray<Float32Array> = []
// In render()
render(inputs: Float32Array[][], ...): boolean {
this.#currentInput = inputs[0] ?? []
// ...
}
// Command handler
updateMonitoringMap: (map: Array<{ uuid: UUID.Bytes, channels: Array<int> }>) => {
this.#audioUnits.forEach(unit => unit.clearMonitoringChannels())
for (const { uuid, channels } of map) {
this.optAudioUnit(uuid).ifSome(unit => unit.setMonitoringChannels(channels))
}
}
getMonitoringChannel(channelIndex: int): Option<Float32Array> {
if (channelIndex >= this.#currentInput.length) return Option.None
return Option.wrap(this.#currentInput[channelIndex])
}
```
### MonitoringMixProcessor.ts
```typescript
export class MonitoringMixProcessor extends AbstractProcessor implements Processor, AudioInput {
#source: Option<AudioBuffer> = Option.None
#channels: Option<Array<int>> = Option.None
setAudioSource(source: AudioBuffer): Terminable {
this.#source = Option.wrap(source)
return { terminate: () => this.#source = Option.None }
}
setChannels(channels: Array<int>): void {
this.#channels = Option.wrap(channels)
}
clearChannels(): void {
this.#channels = Option.None
}
get isActive(): boolean {
return this.#channels.nonEmpty()
}
process(_processInfo: ProcessInfo): void {
if (this.#source.isEmpty() || this.#channels.isEmpty()) return
const [targetL, targetR] = this.#source.unwrap().channels()
const channels = this.#channels.unwrap()
const optL = this.context.getMonitoringChannel(channels[0])
if (optL.isEmpty()) return
const inputL = optL.unwrap()
const inputR = channels.length === 2
? this.context.getMonitoringChannel(channels[1]).unwrapOrElse(inputL)
: inputL
for (let i = 0; i < RenderQuantum; i++) {
targetL[i] += inputL[i]
targetR[i] += inputR[i]
}
}
}
```
### AudioDeviceChain.ts (`#wire`)
```typescript
#monitoringMixer: Option<MonitoringMixProcessor> = Option.None
#wire(): void {
let source: AudioDeviceProcessor = optInput.unwrap()
let edgeSource: Processor = source.outgoing
if (this.#monitoringMixer.nonEmpty() && this.#monitoringMixer.unwrap().isActive) {
const mixer = this.#monitoringMixer.unwrap()
this.#disconnector.own(mixer.setAudioSource(source.audioOutput))
this.#disconnector.own(context.registerEdge(source.outgoing, mixer))
edgeSource = mixer
}
for (const target of this.#orderedEffects) {
if (target.adapter().enabledField.getValue()) {
this.#disconnector.own(target.setAudioSource(source.audioOutput))
this.#disconnector.own(context.registerEdge(edgeSource, target.incoming))
source = target
edgeSource = target.outgoing
}
}
// ... channelStrip, output
}
```
### CaptureAudio.ts
```typescript
type MonitoringMode = "off" | "direct" | "effects"
#monitoringMode: MonitoringMode = "off"
get monitoringMode(): MonitoringMode { return this.#monitoringMode }
set monitoringMode(value: MonitoringMode) {
if (this.#monitoringMode === value) return
this.#disconnectMonitoring()
this.#monitoringMode = value
this.#connectMonitoring()
}
// In constructor, subscribe to requestChannels changes:
captureAudioBox.requestChannels.catchupAndSubscribe(owner => {
// ... existing channel handling ...
// Re-register if monitoring with effects (channel count may have changed)
if (this.#monitoringMode === "effects" && isDefined(this.#audioChain)) {
const engine = this.manager.project.engine
engine.unregisterMonitoringSource(this.audioUnitBox.address.uuid)
engine.registerMonitoringSource(this.audioUnitBox.address.uuid, this.#audioChain.gainNode, this.#audioChain.channelCount)
}
})
#connectMonitoring(): void {
if (!isDefined(this.#audioChain)) return
switch (this.#monitoringMode) {
case "off":
break
case "direct":
this.#audioChain.gainNode.connect(this.manager.project.env.audioContext.destination)
break
case "effects":
const engine = this.manager.project.engine
engine.registerMonitoringSource(this.audioUnitBox.address.uuid, this.#audioChain.gainNode, this.#audioChain.channelCount)
break
}
}
#disconnectMonitoring(): void {
if (!isDefined(this.#audioChain)) return
switch (this.#monitoringMode) {
case "off":
break
case "direct":
this.#audioChain.gainNode.disconnect(this.manager.project.env.audioContext.destination)
break
case "effects":
this.#audioChain.gainNode.disconnect()
this.manager.project.engine.unregisterMonitoringSource(this.audioUnitBox.address.uuid)
break
}
}
```
## Flow
```
CaptureAudio.monitorWithEffects = true
engine.registerMonitoringSource(uuid, gainNode, numChannels)
#rebuildMonitoringMerger()
- disconnect old merger
- create new ChannelMergerNode(totalChannels)
- connect all sources
- build map with channel assignments
- commands.updateMonitoringMap(map)
EngineProcessor: clear all → set channels for units in map
AudioUnit.setMonitoringChannels() → invalidateWiring()
#wire() includes mixer in edge chain
```
## Processing Order
```
instrument.process() → mixer.process() → effect.process()
↓ ↓
writes to buffer reads from input[0][channels],
mixes into source buffer
```