From bfad0c3c2b0ad56dd080dad75577a38edab53171 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Boulant Date: Sun, 2 Aug 2026 16:35:44 +0200 Subject: [PATCH 01/12] docs: handoff for adopting spatcore::io and the WFS-DIY patch window The shared audio device layer landed on spatcore main (8e0d7e6): DeviceHost, DeviceIoCallback, HardwareIndexMap and a converged TestSignalGenerator. XOA can adopt it now; the Audio Interface window and patch matrix it belongs under are still app-side in WFS-DIY and arrive later. This documents both stages, and in particular the things that would silently corrupt audio if a migration session did not know them: - Under DeviceIoCallback a single buffer row is BOTH hardware input h and hardware output h. XOA's stem gather happens to run before the decoder writes, so it is correct today by accident of ordering; the ordering becomes load-bearing. - The buffer width is max(highest active input, highest active output)+1 clamped to the ceiling, so it is not the speaker count. Taking the speaker count from the buffer would run the decoder over input-only rows. - countNumberOfSetBits() is a count, not an index bound, and XOA indexes its meters and prepare() by that ordinal. It also records what XOA is and is not exposed to: it is NOT affected by the 128-channel AudioSourcePlayer cap (it was never an AudioAppComponent), but it does have the channel-mask defect, since it never clears useDefaultInput/OutputChannels and so can neither honour nor persist a mask. CLAUDE.md registers the document and corrects two claims: the pinned spatcore commit does not contain the io layer, and the DEVPLAN's decision records run past D31. Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 22 +- .../XOA-AUDIO-DEVICE-AND-PATCH-HANDOFF.md | 457 ++++++++++++++++++ 2 files changed, 475 insertions(+), 4 deletions(-) create mode 100644 Documentation/XOA-AUDIO-DEVICE-AND-PATCH-HANDOFF.md diff --git a/CLAUDE.md b/CLAUDE.md index a3633df..6687611 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,10 +18,12 @@ Binary: `build/XOA_artefacts//XOA.exe`. After a fresh clone run ## Repo shape - `Source/` — the XOA application (JUCE gui app). App layer only. -- `spatcore/` — **submodule**, pinned to **bf96b3c** (post-v0.1.1: GPU - node-parallel SDN, Max-port FR diffusion, MCP protocol negotiation). - rt/dsp/wfs/reverb/gpu + - control (osc/state/mcp) + controllers. The CMake wiring comes from +- `spatcore/` — **submodule**, pinned to **8296f28** (post-v0.1.1: GPU + node-parallel SDN, Max-port FR diffusion, MCP protocol negotiation, the + shared EQ). rt/dsp/wfs/reverb/gpu + + control (osc/state/mcp) + controllers + ui. The audio device layer (`io/`, + target `spatcore-io`) landed later, at `8e0d7e6`, and is **not** in this pin — + see `Documentation/XOA-AUDIO-DEVICE-AND-PATCH-HANDOFF.md`. The CMake wiring comes from spatcore's `cmake/SpatcoreConsumer.cmake` helper (see CMakeLists.txt). Dependency direction is strictly app → spatcore; never modify spatcore from here — changes go to the spatcore repo and arrive via a pin bump. @@ -44,6 +46,18 @@ Binary: `build/XOA_artefacts//XOA.exe`. After a fresh clone run ## Where things are decided - Roadmap and architecture decisions: `Documentation/XOA-PLAN.md`. +- Execution order and decision records (numeric D1-D34, plus the named WP8 ones + such as `D-NFCstage` / `D-stems`; D35 is the next free number): + `Documentation/XOA-DEVPLAN.md` (it wins over the PRD where they conflict). + Requirements: `Documentation/XOA_PRD.md`. + Frozen OSC contract: `Documentation/XOA-OSC-MAP.md`. +- Migrating the audio device layer onto `spatcore::io`, and later adopting the + Audio Interface window and patch matrix lifted from WFS-DIY: + `Documentation/XOA-AUDIO-DEVICE-AND-PATCH-HANDOFF.md`. Read it before + touching `Source/Audio/AudioEngine.{h,cpp}` — the device layer is available + on spatcore main now, and the migration has invariants (buffer rows that are + simultaneously an input and an output; buffer width is not the speaker count) + that silently corrupt audio if missed. - The renderer/engine seams XOA plugs into (algorithm method contract, `RtSnapshot`, raw-pointer matrix hand-off): spatcore docs (`spatcore/docs/*.md`) + `Documentation/XOA-PLAN.md` §2. diff --git a/Documentation/XOA-AUDIO-DEVICE-AND-PATCH-HANDOFF.md b/Documentation/XOA-AUDIO-DEVICE-AND-PATCH-HANDOFF.md new file mode 100644 index 0000000..77fa7b0 --- /dev/null +++ b/Documentation/XOA-AUDIO-DEVICE-AND-PATCH-HANDOFF.md @@ -0,0 +1,457 @@ +# XOA — Audio Device Layer & Patch Window Handoff + +Version 0.1 — August 2026 — GPL-3.0 + +Handoff for the session that migrates XOA off its hand-rolled device handling and onto +`spatcore::io`, and later onto the Audio Interface window and patch matrix lifted from WFS-DIY. +Written 2026-08-02, immediately after the device layer landed on spatcore `main`. + +Authority: this document owns the **how** of the migration. Where it conflicts with +`XOA-DEVPLAN.md` on ordering, the DEVPLAN wins — nothing here is on the WP critical path, and the +device layer is additive. + +--- + +## 1. Status and what is available today + +| Piece | Where | State | +|---|---|---| +| `spatcore::io` device layer | `spatcore/io/` on spatcore `main` | **AVAILABLE** — merged as `8e0d7e6` (PR #5), CI green on Windows/macOS/Linux | +| WFS-DIY consuming it | `feat/patch-diagnostics-512ch` in `d:/dev/WFS_DIY_v1` | **DONE, not yet merged** — the reference wiring to copy | +| Audio Interface window + patch matrix in `spatcore/ui` | — | **NOT STARTED** (Stage 2). Still app-side in WFS-DIY | + +So this is a **two-stage** migration, and stage 1 does not depend on stage 2: + +- **Stage 1 — adopt the device layer.** Available now. Replaces `AudioEngine`'s device open/close + and its raw `AudioIODeviceCallback` with `spatcore::io::DeviceHost` + `DeviceIoCallback`, and + converges the test-signal generator. Sections 3–6. +- **Stage 2 — adopt the window.** Blocked on the lift. XOA gains the full Audio Interface window + and patch matrix in place of its `juce::AudioDeviceSelectorComponent`. Sections 7–8. + +Pin target for stage 1: + +``` +git -C spatcore fetch origin +git -C spatcore checkout 8e0d7e6 # or origin/main if later work has landed +git add spatcore +``` + +XOA has no `bump-spatcore` script (WFS-DIY's `tools/bump-spatcore.ps1` is WFS-specific — its gate +list names kernel hashes, GPU plugin rebuilds and the prebuilt `wfs_hip.dll`, none of which XOA +has). The literal commands above are the whole ritual. + +Two places record the pin in prose and must move with it: `CLAUDE.md` (currently `8296f28`, +correct for the submodule as it stands) and `Documentation/XOA-PLAN.md` §4, which still says +`spatcore @bf96b3c` and is two bumps stale. Update both to `8e0d7e6` in the bump commit. + +--- + +## 2. What the device layer is, and why XOA needs it + +It was written to fix two defects found in WFS-DIY. **XOA has one and a half of them.** + +### 2.1 The 128-channel cap — XOA is NOT affected + +`juce::AudioSourcePlayer` — the callback behind `juce::AudioAppComponent` — holds +`float* channels[128]` and stops compacting once those arrays are full, so a hardware channel at +index 128 or above simply has no slot in the buffer. WFS-DIY hit this because it is an +`AudioAppComponent`. **XOA is a raw `juce::AudioIODeviceCallback`** (`Source/Audio/AudioEngine.h:45`) +and never had the cap. Nothing to fix here; it is listed so nobody re-derives the fear. + +### 2.2 Channel masks that do not stick — XOA IS affected + +`juce::AudioDeviceManager` keeps `useDefaultInputChannels` / `useDefaultOutputChannels`, both +defaulting to **true**. While either is set, `setAudioDeviceSetup()` **discards the caller's +`BigInteger` mask** and substitutes `range(0, numChansNeeded)` — a count frozen at whatever the +last `initialise()` asked for. Two consequences for XOA: + +- XOA opens with `deviceManager.initialise (xoa::kMaxInputs /*64*/, xoa::kMaxSpeakers /*256*/, + savedXml.get(), true)` (`Source/Audio/AudioEngine.cpp:109`) and never calls + `setAudioDeviceSetup` anywhere — `grep -rn "setAudioDeviceSetup|useDefaultInput" Source/` returns + nothing. So the mask is always `range(0,64)` / `range(0,256)`, and a **>256-output rig is capped + at 256** with no diagnostic. +- Because the flags stay true, `createStateXml()` **never writes `audioDeviceInChans` / + `audioDeviceOutChans`**. XOA persists `ids::audioDeviceState` on every device change + (`AudioEngine.cpp:337`) but that state cannot carry a channel selection, so it cannot round-trip + one. Today that is invisible because XOA offers no channel selection; the moment the patch window + arrives in stage 2 it becomes a data-loss bug. + +`spatcore::io::DeviceHost` exists to enforce exactly this: **every** mutation writes an explicit +mask with both flags cleared. + +### 2.3 Compacted indices versus hardware channel numbers — XOA IS affected, latently + +This is the half. The device callback arrays contain only the **enabled** channels, packed with no +holes: entry *k* is the *k*-th set bit of the mask, not hardware channel *k*. XOA identity-maps +throughout — stem *i* ← `inputChannelData[i]` (`AudioEngine.cpp:435-442`), speaker *s* → +`outputChannelData[s]` via the `outBuf` wrapper (`:397`) — and has **no patch or routing concept at +all**. That identity is correct only while the mask is contiguous from bit 0. + +It is contiguous today, because XOA only ever asks for `range(0, N)`. It stops being contiguous the +moment anything can deselect a channel — which is precisely what stage 2 adds. `HardwareIndexMap` +is the translation that makes hardware numbering true by construction; `DeviceIoCallback` applies it +so the buffer row index **is** the hardware channel number. + +Related: `AudioEngine.cpp:363` derives the speaker count as +`device->getActiveOutputChannels().countNumberOfSetBits()` — a **count**, which is passed to +`algorithm.prepare` and `speakerComp.prepare`. Count and highest-index agree only for a contiguous +mask. See §5.3. + +--- + +## 3. Stage 1 — the API to adopt + +Four header-only files, namespace `spatcore::io`, target `spatcore-io` (option `SPATCORE_IO`, +default ON). Read the headers; they carry the rationale inline. + +| Header | Purpose | +|---|---| +| `spatcore/io/HardwareIndexMap.h` | `fromMasks(activeIn, activeOut, maxChannels)` → `numChannels`, `inputIndexForHw[]`, `outputIndexForHw[]` (`-1` = that direction is disabled on that hardware channel), `isIdentityMapping()`. `juce_core` only, fully unit-tested. | +| `spatcore/io/DeviceIoCallback.h` | `juce::AudioIODeviceCallback` driving a `juce::AudioSource`, **no channel cap**, buffer indexed by hardware channel. Sizes everything in `audioDeviceAboutToStart`; the callback allocates nothing and takes no lock. | +| `spatcore/io/DeviceHost.h` | Open/restore policy over a **non-owned** `juce::AudioDeviceManager&`. `restoreFromXml`, `openNamedDevice`, `setDeviceAllChannels`, `enableAllChannels`, plus the truthful `getNumActiveInputs/Outputs` and `getActiveInput/OutputMask`. | +| `spatcore/io/TestSignalGenerator.h` | Off / PinkNoise / Tone / Sweep / DiracPulse, replace-semantics, **500 ms protective ramp**. | + +Build change — one line added to the existing `target_link_libraries` call at +`CMakeLists.txt:104-113`. The whole call, so nothing gets dropped by a paste: + +```cmake +target_link_libraries(XOA PRIVATE + spatcore-audio + spatcore-control + spatcore-controllers + spatcore-ui + spatcore-io # <- add + hidapi::hidapi + juce::juce_audio_utils + juce::juce_gui_extra + juce::juce_opengl + juce::juce_osc) +``` + +`spatcore-io` links `juce_audio_devices`, which pulls in the platform driver backends +(ASIO/WASAPI, CoreAudio, ALSA/JACK). That is why it is a separate target from `spatcore-audio` — +the audio layer must stay linkable where the host owns the device. XOA opens its own device, so it +wants it. + +### 3.1 The reference wiring + +WFS-DIY's is the wiring to copy. **Paths in this table are in the WFS-DIY checkout** +(`d:/dev/WFS_DIY_v1`, branch `feat/patch-diagnostics-512ch`) — everywhere else in this document, a +bare `Source/...` path means XOA: + +| What | Where | +|---|---| +| Member declarations | `d:/dev/WFS_DIY_v1/Source/MainComponent.h`, next to `audioCallbacksAttached` | +| Startup restore, saved state | `Source/MainComponent.cpp` — `deviceHost.restoreFromXml(savedStateXml.get(), false)` | +| Startup restore, by name | `deviceHost.openNamedDevice(savedDeviceType, savedDeviceName)` | +| Callback registration | `attachAudioCallbacksIfNeeded()` (`:2434`) — `deviceManager.addAudioCallback(&ioCallback)` (`:2451`), once | +| Teardown | destructor: `removeAudioCallback` **before** closing the device | +| Truthful channel counts to the UI | `changeListenerCallback` and `loadAudioPatches` | +| Device picker | `d:/dev/WFS_DIY_v1/Source/gui/AudioInterfaceWindow.cpp` — `DeviceSettingsPanel::deviceChanged` / `enableAllChannels` | + +Two details that are easy to get wrong: + +- **Register the callback once.** `AudioDeviceManager` re-arms every registered callback across + device changes, so there is nothing to re-attach later. XOA already does this + (`AudioEngine.cpp:111`); keep it. +- **`removeAudioCallback` blocks until the audio thread has left the callback.** It must run before + anything the callback touches is destroyed. XOA's `closeAudioDevice()` already has the right + order (`AudioEngine.cpp:121-133`). + +--- + +## 4. Stage 1 — migration steps + +1. **Bump the pin and link the target** (§1, §3). Build. Nothing else changes yet; this commit is + green on its own. +2. **Replace the test-signal generator.** See §5.4 — this is the one step with a real behavioural + decision in it, and it is independent of the rest, so it can land first or last. +3. **Wrap the device manager in `DeviceHost`.** `AudioEngine` owns `juce::AudioDeviceManager` by + value (`AudioEngine.h:201`) and `DeviceHost` takes a reference, so this is additive — no + ownership moves: + + ```cpp + spatcore::io::DeviceHost deviceHost { deviceManager, 512 }; + ``` + + Then `openAudioDevice()` becomes `deviceHost.restoreFromXml (savedXml.get(), true)` in place of + the bare `deviceManager.initialise(...)`. That single substitution buys the explicit-mask policy, + the enable-all-channels behaviour and the >256-channel ceiling. + + Pick XOA's own name for the `512`. WFS-DIY passes + `WFSValueTreeState::maxHardwarePatchChannels`; XOA should add an equivalent to + `Source/XoaConstants.h` rather than relying on the default, so the policy number has one home. + It is a **ceiling, not an allocation** — `applyEnableAllPolicy` builds each mask from the + device's real channel counts and only clamps to it, so passing 512 for a rig with 8 outputs + costs nothing. + +4. **Convert `AudioEngine` to a `juce::AudioSource` and let `DeviceIoCallback` drive it.** Less work + than it sounds: the callback body is already `getNextAudioBlock`-shaped — it wraps the outputs in + a `juce::AudioBuffer` (`AudioEngine.cpp:397`) and builds a + `juce::AudioSourceChannelInfo` (`:409`). The three overrides map one-for-one: + + | Today | Becomes | + |---|---| + | `audioDeviceIOCallbackWithContext(...)` | `getNextAudioBlock (const juce::AudioSourceChannelInfo&)` | + | `audioDeviceAboutToStart(device)` (`:346`) | `prepareToPlay (samplesPerBlockExpected, sampleRate)` | + | `audioDeviceStopped()` (`:378`) | `releaseResources()` | + + `prepareToPlay` receives only block size and sample rate, so anything `audioDeviceAboutToStart` + currently reads off the device must come from elsewhere — `getOutputLatencyInSamples()` for + `measuredLatencyMs` (`:350` and the `.store` at `:356`) and `getActiveOutputChannels()` for + `numOut` (`:363`). Both + are available through `DeviceHost` (`getNumActiveOutputs()`) or by keeping the + `ChangeListener` XOA already has on the device manager. **Do not** re-derive them from the + buffer width — see §5.1. + + Then register `DeviceIoCallback` instead of `this`: + + ```cpp + spatcore::io::DeviceIoCallback ioCallback { *this, 512 }; + ... + deviceManager.addAudioCallback (&ioCallback); + ``` + +5. **Re-point the stem gather and the speaker write at the hardware-indexed buffer** — §5.1 and + §5.2. This is the step that can silently corrupt audio; read those two sections before writing + any of it. +6. **Run the acceptance pass** (§6). CI cannot cover any of this. + +--- + +## 5. Hazards — read before writing code + +### 5.1 One buffer row is BOTH hardware input *h* and hardware output *h* + +`DeviceIoCallback` gives the source a single buffer where row *h* is hardware channel *h*. For a +channel that is an active output, that row **aliases the device's own output storage**, and the +matching input is copied **into that same row** before the source runs +(`spatcore/io/DeviceIoCallback.h`). This is not new — `AudioSourcePlayer` has always done it — but +XOA has never seen it, because today its inputs and outputs live in two separate arrays. + +The invariant this creates: + +> **Every input must be read before anything writes an output.** + +XOA satisfies this today by accident of ordering: stems are gathered into `stemScratch` +(`AudioEngine.cpp:435-442`) before `algorithm.processBlock` writes (`:459`/`:465`). After the +migration that ordering becomes **load-bearing** — reordering the gather below the decode would +silently feed the encoder its own decoder output on every channel where a speaker and a microphone +share a hardware index. State it as a comment at the gather site. + +### 5.2 Buffer width is NOT the speaker count + +The buffer spans `min(maxChannels, max(highest active input, highest active output) + 1)` hardware +channels — and 0 when both masks are empty. On a rig +with 64 inputs and 6 outputs the buffer is **64** channels wide. XOA currently derives everything +from `numOutputChannels` as handed to the callback; after migration, taking the speaker count from +`info.buffer->getNumChannels()` would run the decoder for 64 speakers and write rows 6–63, which are +input-only rows whose writes are discarded — wasted work, wrong meters, no error. + +Take the speaker count from `DeviceHost::getNumActiveOutputs()` (or the map), never from the buffer. + +### 5.3 `countNumberOfSetBits()` is a count, not an index bound + +`AudioEngine.cpp:363` passes `getActiveOutputChannels().countNumberOfSetBits()` to +`algorithm.prepare` and `speakerComp.prepare`, and the meter arrays are `std::array<..., +kMaxSpeakers>` / `kMaxInputs` indexed by the same ordinal (`AudioEngine.h:223,226`, bounds-checked at +`:117-130`). Under hardware indexing, count and index diverge as soon as a mask has a hole. + +Decide explicitly, and write the decision down as a `D` record: + +- **Option A — require contiguity.** `DeviceHost`'s enable-all policy always produces + `range(0, N)`, so as long as nothing else writes a mask, count == index bound. Cheapest; breaks + the day stage 2 lets an operator deselect a channel. +- **Option B — index by hardware channel.** Raise `kMaxSpeakers` to the addressing ceiling and index + the meters by hardware channel. Honest, and what the patch window will want. +- **Option C — keep a compaction stage.** A speaker→hardware map of XOA's own, i.e. reinventing the + patch. Only sensible if stage 2 is abandoned. + +Recommendation: **A now, B when stage 2 lands**, with the assumption asserted +(`jassert (map.isIdentityMapping())`) so option A fails loudly instead of misrouting. + +### 5.4 The shared `TestSignalGenerator` has no SpeakerId mode + +`spatcore::io::TestSignalGenerator` is WFS-DIY's, with one fix: `prepare()` now recomputes the +tone's phase increment, which used to default to zero and be written only by `setFrequency()` — a +Tone selected on any path that skipped it was **pure silence**, and it came back at the wrong pitch +after a sample-rate change. It also gained `setDeterministicSeed()`. + +Two XOA behaviours are **not** in it: + +| XOA today | Shared version | +|---|---| +| `kSeed = 0x0A0A` always-on fixed seed (`Source/Audio/TestSignalGenerator.h:54`) | opt-in `setDeterministicSeed(42)`; default seeds from the wall clock | +| `SpeakerId` mode — declicked pink burst stepping across every output, `getCurrentSpeakerIndex()` (`:126-128`, `:165-204`) | absent | + +The seed is a one-line call at `prepare()`. `SpeakerId` is a real feature and was deliberately left +out of the shared class because adding an enum member makes WFS-DIY's `switch` statements +non-exhaustive for a mode nothing there uses. Consumers that would break: +`Source/GUI/Tabs/SpeakersDecoderTab.cpp:169,312-317` and two checks in +`tests/XoaTestSignalTests.cpp`. + +**Do this as a spatcore PR, not an XOA workaround.** Add `SignalType::SpeakerId` plus +`getCurrentSpeakerIndex()` to `spatcore/io/TestSignalGenerator.h`, port XOA's `renderSpeakerId` +verbatim, and add a test alongside the existing generator tests in +`spatcore/tests/SpatcoreTests.cpp`. Keeping a forked generator in XOA re-creates exactly the +duplication the shared layer exists to remove. + +Adding an enum member makes WFS-DIY's `switch` statements over `SignalType` non-exhaustive, which +is why it was left out in the first place. Four sites in `d:/dev/WFS_DIY_v1` need the new case: +`Source/gui/AudioPatchTab.cpp:179`, `:473` and `:818` — all three have no `default:`, so they warn +(`-Wswitch` / MSVC C4062) — and `Source/gui/PatchMatrixComponent.cpp:1138` (also WFS-DIY), which does have a +`default:` and so compiles silently but would render the new type with the fallback colour. The +`switch` statements at `AudioPatchTab.cpp:151` and `:596` are over combo-box ids, not the enum, and +need nothing. + +### 5.5 `DeviceHost` needs a scanned device manager + +`AudioDeviceManager` only builds its device-type list inside `initialise()` and the getters that +call `scanDevicesIfNeeded()`. On an un-initialised manager, `setAudioDeviceSetup()` takes a silent +early exit that opens nothing and returns an **empty error string**. `DeviceHost` guards against +this internally (`ensureDeviceTypesScanned`, commit `1f746f6`) — it was found only by pointing the +real WFS-DIY code at a Dante Virtual Soundcard whose service was down. Do not re-introduce the +pattern by calling `setAudioDeviceSetup` directly. + +--- + +## 6. Stage 1 acceptance — CI cannot do this on hardware + +No CI anywhere exercises a real multi-channel interface. Do **not** read that as "CI never touches +the device layer", though — two of XOA's lanes do, and they are a useful free gate: + +- XOA's `.github/workflows/ci.yml` runs three ctest lanes, which open no device, plus two Linux + lanes that launch the real binary under `xvfb`: the OSC control-replay (`osc_replay.py` spawns + `XOA --osc`) and `xvfb-run -a "$XOA_BIN" --gui-smoke`. `AppShell`'s constructor calls + `engine.openAudioDevice()` **unconditionally** (`Source/App/AppShell.cpp:88`) *before* + `applyStartupCommandLine` has even seen `--osc` / `--gui-smoke` (`:89`), and that reaches + `deviceManager.initialise (kMaxInputs, kMaxSpeakers, savedXml.get(), true)` with + `selectDefaultDeviceOnFailure = true`. There is no opt-out flag. So an assert, crash or hang + introduced in `DeviceHost` or `DeviceIoCallback` at startup **will** fail both of those lanes — + worth knowing when steps 3 and 4 of §4 land, and worth not mistaking for an unrelated CI flake. +- spatcore's CI builds `examples/minimal-app` on three OSes; it does not run `spatcore-tests`. +- `spatcore-tests` covers `HardwareIndexMap` (contiguous / sparse / empty / clamped), + `DeviceHost::applyEnableAllPolicy`, and the generator's pitch, ramp and seeded reproducibility — + but **never drives `DeviceIoCallback`**, because that needs a live `juce::AudioIODevice` + (`spatcore/tests/SpatcoreTests.cpp`, see the comment above the io tests). + +So the migration is proven by hand, on hardware: + +1. **Every channel opens.** With the largest interface available, `getNumActiveOutputs()` equals the + device's output count, and the saved `audioDeviceState` XML now contains + `audioDeviceInChans` / `audioDeviceOutChans` attributes. Their presence is the proof that the + `useDefault*Channels` fix took. +2. **The masks survive a restart.** Quit, relaunch, re-check the active counts. +3. **A high channel is reachable.** Send the test signal to the highest output and confirm it + sounds. On a >128-channel rig this is the case that was structurally impossible before. +4. **Inputs still reach the encoder.** Feed a signal to hardware input 0 while a speaker is mapped + to hardware output 0 and confirm the stem is clean — the §5.1 aliasing case. +5. **A failing device reports why.** Point it at a driver whose service is stopped; the error string + must be non-empty (§5.5). +6. **Latency and meters unchanged.** `measuredLatencyMs`, `inputPeak`, `outputPeak` read as before. + +A throwaway console harness is the fastest way to do 1, 3 and 5 without the GUI: a +`juce_add_console_app` that `add_subdirectory`s JUCE, includes the four `spatcore/io` headers, opens +a device through `DeviceHost` and prints listed-vs-active counts, `useDefault*` flags, the map width +and whether a tone rendered. That is how the `ensureDeviceTypesScanned` bug was caught. JUCE 9 +bundles the ASIO SDK (`modules/juce_audio_devices/native/asio/iasiodrv.h`), so `JUCE_ASIO=1` needs +no external SDK and the harness can enumerate real ASIO drivers. + +--- + +## 7. Stage 2 — the window XOA inherits + +WFS-DIY's Audio Interface window replaces `juce::AudioDeviceSelectorComponent` +(`Source/GUI/Tabs/SystemConfigTab.cpp:66-70`, constructed `(mgr, 0, 64, 0, 256, false, false, false, +false)`). What arrives: + +- **Device settings** — device type, device, sample rate, buffer size, with all channels enabled + automatically on selection. +- **An input patch matrix and an output patch matrix** — a scrollable grid of app channels × + hardware channels, with Scrolling / Patching / Testing modes, drag-diagonal 1:1 patching, and + per-hardware-input **signal-presence tinting** so an operator can see which physical input is + live. +- **Test tones per hardware channel**, click or keyboard, with the 500 ms protective ramp. +- Keyboard navigation and TTS announcements throughout. + +For XOA this is a capability gain, not a refactor: XOA has **no patch concept at all** today, so +adopting the window means adopting routing it never had. Speaker *s* stops being hardware output *s* +by assumption and becomes hardware output *patch(s)* by data. Budget for that — it touches +`algorithm.prepare`'s channel count, the meter indexing (§5.3) and anything that assumes speaker +ordinal equals device ordinal. + +### 7.1 What XOA already has for the provider seams + +The lifted window will be app-agnostic in the shared-EQ style: `std::function` providers read at use +time, raw `juce::ValueTree` + `juce::Identifier` for state, and the consuming app keeping a thin +shim header of `using` aliases plus a config factory. XOA has a near-identical counterpart for +almost every seam, which is why this lift is worth doing: + +| Seam | WFS-DIY | XOA | +|---|---|---| +| Parameter store | `WFSValueTreeState` | `XoaValueTreeState` — both derive `spatcore::control::state::TreeParameterStore` | +| Colours | `ColorScheme::get()` + `ColorScheme::Manager` listener | same names | +| Strings | `LOC()` macro | same | +| Status line | `StatusBar` | same | +| Accessibility | `TTSManager` singleton | same | +| Long-press button | `LongPressButton` | same | +| Window helpers | `WindowUtils`, `HelpCardSVG` | `WindowUtils`, `HelpCard` | +| Stream Deck+ pages | `PatchWindowPages` wired in `MainComponent` | **absent** — XOA links `spatcore-controllers` but never includes it | + +Two consequences worth stating up front: + +- XOA inherits the window's Stream Deck accessor surface as **unused public API**. That is why the + lift cannot trim it; do not treat those accessors as dead code. +- Localisation cost on the XOA side is small: the window uses ~32 distinct `audioPatch.*` keys, and + XOA ships **two** locales (`Resources/lang/en.json`, `fr.json`, single tier) against WFS-DIY's + nine across two tiers. ~64 string entries total. + +### 7.2 Sequencing + +1. WFS-DIY decouples the three GUI files and moves them to `spatcore/ui/patch/` — spatcore PR. +2. WFS-DIY re-pins and reduces its own files to shims — app PR. Behaviour parity is verified by hand + there, not by XOA. +3. XOA adopts: re-pin, link, replace the device selector, add the patch ValueTree section to its + schema, and re-point the decoder at patched hardware channels. + +Do **not** start step 3 before step 2 has been through a real show or a full manual pass in WFS-DIY. +The GUI has no bit-exactness gate, so WFS-DIY's own use is the only regression net the lift gets. + +--- + +## 8. Open decisions + +Record each as a `D` in `XOA-DEVPLAN.md` when taken. That file reaches **D34**, so **D35 is the +next free number** — reserve D35–D40 for the six below rather than minting numbers ad hoc, as D18 +does. + +1. **Does `AudioEngine` become a `juce::AudioSource`, or gain a thin adapter?** WFS-DIY kept + `juce::AudioAppComponent` purely to inherit `deviceManager` and to be the `AudioSource` passed as + `*this`, never calling `setAudioChannels()` so the base player stays inert. XOA has no such + constraint and can implement `juce::AudioSource` cleanly — recommended. +2. **Meter and prepare indexing: option A, B or C of §5.3**, and when B takes over. +3. **XOA's addressing ceiling constant** — the name and home for the `512` (§4 step 3). +4. **Who owns `SpeakerId`** — the recommendation in §5.4 is spatcore, in a small PR of its own. +5. **Should `DeviceHost` grow `setSampleRate` / `setBufferSize` / `resetToSavedSetup`?** WFS-DIY's + device panel still calls `setAudioDeviceSetup` directly at three sites in + `d:/dev/WFS_DIY_v1/Source/gui/AudioInterfaceWindow.cpp` — `:147` (Reset Device, a full re-open + from a saved setup, and so the one most exposed to the mask policy), `:516` (sample rate) and + `:546` (buffer size). All three are mask-safe only because a prior `DeviceHost` call already + cleared the `useDefault*` flags in the stored setup. Making that an enforced invariant rather + than an assumed one is a small spatcore change, best made **before** the window is lifted. +6. **Does the io layer ship as a tag?** spatcore is tagged only `v0.1.0` / `v0.1.1` while its + `CMakeLists.txt` declares `VERSION 0.2.0`. Pin a bare SHA (`8e0d7e6`) or tag `v0.2.0` first. + +--- + +## 9. Reference + +| Thing | Path | +|---|---| +| Device layer | `spatcore/io/{HardwareIndexMap,DeviceIoCallback,DeviceHost,TestSignalGenerator}.h` | +| Its tests | `spatcore/tests/SpatcoreTests.cpp` (io section, at the end) | +| CMake target | `spatcore/CMakeLists.txt`, the `SPATCORE_IO` block | +| Reference consumer | `d:/dev/WFS_DIY_v1`, branch `feat/patch-diagnostics-512ch` | +| The window to be lifted | `d:/dev/WFS_DIY_v1/Source/gui/{AudioInterfaceWindow,AudioPatchTab,PatchMatrixComponent}.{h,cpp}` | +| Shared-component precedent | spatcore `d7967f7` + WFS-DIY `e7ad1c7` (the EQ) | +| XOA's current device code | `Source/Audio/AudioEngine.{h,cpp}`, `Source/Audio/TestSignalGenerator.h`, `Source/GUI/Tabs/SystemConfigTab.cpp:66-70` | From 73428410b13c382e292f975d147b01abb327afd9 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Boulant Date: Sun, 2 Aug 2026 17:50:05 +0200 Subject: [PATCH 02/12] docs: the patch matrix is shared; XOA builds the shell MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Updates the handoff now that spatcore/ui/patch/PatchMatrixComponent exists and the boundary has moved. Section 7 was "the window XOA inherits"; it is now a build specification, because the tabs and window are staying in WFS-DIY. What changed and why: the matrix is 2,179 lines of scroll, hit-testing, drag-patching, 1:1 constraint enforcement, keyboard navigation and signal-presence tinting, none of it app-specific once the seams are cut — worth sharing. The shell around it is ~700 lines of layout and widget glue, and sharing it would have pulled the app's long-press button, its slider base (carrying TTSManager and OSC origin-tagging) and 3,199 generated lines of signal-flow artwork into spatcore. So section 7 now carries what a session here actually needs: how to construct the matrix and what every provider is for, the AudioPatch ValueTree section XOA must add, and a behaviour spec for the shell — including the three non-obvious bits (stop the tone on every exit path, the non-linear level and frequency mappings, the 1-based combo vs 0-based enum), a warning not to port WFS-DIY's dead TestSignalControlPanel, and the instruction to make the window stopped-only because patching a live rig destroys speakers. The load-bearing detail is unchanged from stage 1 and now has a second home: feed activeHardwareChannels from DeviceHost::getNumActiveOutputs(), not from the device's channel-name count. Co-Authored-By: Claude Fable 5 --- .../XOA-AUDIO-DEVICE-AND-PATCH-HANDOFF.md | 219 ++++++++++++++---- 1 file changed, 168 insertions(+), 51 deletions(-) diff --git a/Documentation/XOA-AUDIO-DEVICE-AND-PATCH-HANDOFF.md b/Documentation/XOA-AUDIO-DEVICE-AND-PATCH-HANDOFF.md index 77fa7b0..7293f4a 100644 --- a/Documentation/XOA-AUDIO-DEVICE-AND-PATCH-HANDOFF.md +++ b/Documentation/XOA-AUDIO-DEVICE-AND-PATCH-HANDOFF.md @@ -3,7 +3,7 @@ Version 0.1 — August 2026 — GPL-3.0 Handoff for the session that migrates XOA off its hand-rolled device handling and onto -`spatcore::io`, and later onto the Audio Interface window and patch matrix lifted from WFS-DIY. +`spatcore::io`, and then onto the shared patch matrix with a shell of XOA's own. Written 2026-08-02, immediately after the device layer landed on spatcore `main`. Authority: this document owns the **how** of the migration. Where it conflicts with @@ -18,15 +18,28 @@ device layer is additive. |---|---|---| | `spatcore::io` device layer | `spatcore/io/` on spatcore `main` | **AVAILABLE** — merged as `8e0d7e6` (PR #5), CI green on Windows/macOS/Linux | | WFS-DIY consuming it | `feat/patch-diagnostics-512ch` in `d:/dev/WFS_DIY_v1` | **DONE, not yet merged** — the reference wiring to copy | -| Audio Interface window + patch matrix in `spatcore/ui` | — | **NOT STARTED** (Stage 2). Still app-side in WFS-DIY | +| **Patch matrix** in `spatcore/ui/patch/` | `spatcore` branch `feat/shared-patch-matrix` | **AVAILABLE** — the scrollable grid, fully app-agnostic | +| Patch tabs + Audio Interface window | app-side in WFS-DIY only | **DELIBERATELY NOT SHARED** — XOA builds its own shell. See §7 | So this is a **two-stage** migration, and stage 1 does not depend on stage 2: -- **Stage 1 — adopt the device layer.** Available now. Replaces `AudioEngine`'s device open/close - and its raw `AudioIODeviceCallback` with `spatcore::io::DeviceHost` + `DeviceIoCallback`, and - converges the test-signal generator. Sections 3–6. -- **Stage 2 — adopt the window.** Blocked on the lift. XOA gains the full Audio Interface window - and patch matrix in place of its `juce::AudioDeviceSelectorComponent`. Sections 7–8. +- **Stage 1 — adopt the device layer.** Replaces `AudioEngine`'s device open/close and its raw + `AudioIODeviceCallback` with `spatcore::io::DeviceHost` + `DeviceIoCallback`, and converges the + test-signal generator. Sections 3–6. +- **Stage 2 — adopt the patch matrix and build a shell around it.** The matrix is shared; the two + patch tabs and the window that hosts them are **not**, and are not going to be. XOA writes those + itself against the shared matrix. Sections 7–8 specify exactly what to build and what behaviour + it must have. + +**Why the line is drawn there.** The matrix is the part worth sharing: 2,179 lines of scroll, +hit-testing, drag-patching, 1:1 constraint enforcement, keyboard navigation, accessibility +announcements and signal-presence tinting, none of which is app-specific once the seams are cut. The +tabs and the window around it are mostly layout and widget glue — perhaps 700 lines of real logic — +and sharing them would have dragged three things into spatcore that do not belong there: the app's +long-press button and slider base (the latter carrying `TTSManager` *and* OSC origin-tagging from +`Network/`), and `HelpCardSVG`, 3,199 generated lines of one app's signal-flow artwork with +`ColorScheme` and `LocalizationManager` baked inside. Rebuilding a shell in XOA is cheaper than +that, and leaves both apps free to lay their own window out. Pin target for stage 1: @@ -359,63 +372,161 @@ no external SDK and the harness can enumerate real ASIO drivers. --- -## 7. Stage 2 — the window XOA inherits +## 7. Stage 2 — the shared matrix, and the shell XOA builds + +XOA today configures its device with a stock `juce::AudioDeviceSelectorComponent` embedded in tab 0 +(`Source/GUI/Tabs/SystemConfigTab.cpp:66-70`, constructed +`(mgr, 0, 64, 0, 256, false, false, false, false)`) and has **no patch concept at all**: stem *i* is +device input *i* and speaker *s* is device output *s*, by assumption. + +Stage 2 replaces that with the shared matrix plus a shell XOA owns. Budget for the conceptual change, +not just the code: speaker *s* stops being hardware output *s* and becomes hardware output +*patch(s)*. That touches `algorithm.prepare`'s channel count, the meter indexing of §5.3, and +anything else that assumes speaker ordinal equals device ordinal. + +### 7.1 What is shared: `spatcore::ui::patch::PatchMatrixComponent` + +`spatcore/ui/patch/` — the scrollable application-channels × hardware-channels grid. It brings, for +free: + +- **Three modes** — Scrolling (drag pans), Patching (click to patch, drag diagonally for sequential + 1:1 runs), Testing (click a cell, row or column header to send the test tone to that hardware + channel). Testing is output-only by convention; the component does not enforce it. +- **1:1 constraint enforcement** — each application channel maps to at most one hardware channel and + vice versa, including during a drag preview. +- **Overflow gating** — columns at or above the patch tree's `activeHardwareChannels` are drawn + dimmed and refuse patching and testing, because the device never opened them. +- **Signal-presence tinting** — per-hardware-input peak level tints the column header green, so an + operator can see which physical input is live. Fed by `setHardwareInputPeakProvider`. +- **Keyboard navigation and accessibility** — arrows move the selection, space activates, + space-hold runs a test tone while held; every move announces what it landed on. +- **Auto-patch heuristics** — when a channel is added, it detects the existing hardware offset and + continues the pattern rather than defaulting to the diagonal. + +Construction: + +```cpp +spatcore::ui::patch::PatchMatrixConfig config; +config.patchTree = /* your InputPatch or OutputPatch tree */; +config.channelsTree = /* your channel list; the matrix only LISTENS to it */; +config.ids.patchData = ...; // five property names — see PatchMatrixConfig.h +config.numChannelsProvider = [this] { return numSpeakers(); }; +config.channelNameProvider = [this] (int ch) { return speakerName (ch); }; +config.rowColourProvider = [this] (int ch) { return speakerColour (ch); }; +config.paletteProvider = [] { return PatchMatrixPalette { ColorScheme::get().background, ... }; }; +config.translate = [] (const char* k) { return LOC (k); }; +config.uiScaleProvider = [] { return XoaLookAndFeel::uiScale; }; +config.announce = [] (const juce::String& t) { TTSManager::getInstance().announceImmediate (t, ...); }; + +auto matrix = std::make_unique ( + std::move (config), /*isInputPatch*/ false, &testSignal); +``` + +Every provider is null-checked with a sane fallback, so a config carrying only the trees still +yields a working matrix — fill them in as you go. None is snapshotted: they are invoked at paint and +layout time, so theme, language and UI-scale changes land on the next repaint. + +**The ValueTree schema you must provide.** The matrix owns `patchData` and reads four other +properties. XOA has no patch section today, so add one: + +``` +AudioPatch +└── OutputPatch (and InputPatch if you patch stems too) + ├── rows int — application channel count + ├── cols int — hardware columns to display + ├── activeHardwareChannels int — channels the device OPENED (see below) + └── patchData str — "1,0,0;0,1,0;..." rows by ';', cols by ',' +``` + +`activeHardwareChannels` is the load-bearing one and it is where §5.2 and §2.3 come back: feed it +from `DeviceHost::getNumActiveOutputs()`, **not** from `getOutputChannelNames().size()`. A channel +the device lists but never opened has no slot in the callback buffer, so metering or testing it is +silently impossible; that exact confusion is what the WFS-DIY work fixed. `cols` is yours to +police — WFS-DIY clamps it to `max(64, deviceChannels, highestPatched + 1)` bounded by the +addressing ceiling, so the matrix widens to the interface and never hides an existing patch. + +Reference shim: `d:/dev/WFS_DIY_v1/Source/gui/PatchMatrixComponent.h` and `PatchMatrixShim.cpp` — +a derived class plus a config factory, ~180 lines total. Copy its shape. -WFS-DIY's Audio Interface window replaces `juce::AudioDeviceSelectorComponent` -(`Source/GUI/Tabs/SystemConfigTab.cpp:66-70`, constructed `(mgr, 0, 64, 0, 256, false, false, false, -false)`). What arrives: +> Note the object-file trap it documents: MSBuild writes every object into one directory, so an +> app-side `PatchMatrixComponent.cpp` alongside the spatcore one silently overwrites its `.obj` and +> the link fails with unresolved externals. XOA is CMake-native and less exposed, but give the shim +> a distinct basename anyway. -- **Device settings** — device type, device, sample rate, buffer size, with all channels enabled - automatically on selection. -- **An input patch matrix and an output patch matrix** — a scrollable grid of app channels × - hardware channels, with Scrolling / Patching / Testing modes, drag-diagonal 1:1 patching, and - per-hardware-input **signal-presence tinting** so an operator can see which physical input is - live. -- **Test tones per hardware channel**, click or keyboard, with the 500 ms protective ramp. -- Keyboard navigation and TTS announcements throughout. +### 7.2 What XOA builds: the shell -For XOA this is a capability gain, not a refactor: XOA has **no patch concept at all** today, so -adopting the window means adopting routing it never had. Speaker *s* stops being hardware output *s* -by assumption and becomes hardware output *patch(s)* by data. Budget for that — it touches -`algorithm.prepare`'s channel count, the meter indexing (§5.3) and anything that assumes speaker -ordinal equals device ordinal. +Not shared, and not planned to be. Roughly 700 lines of real logic. Build it to XOA's own taste — +this is a behaviour specification, not a design to copy line for line. -### 7.1 What XOA already has for the provider seams +**A. Two patch tabs** (WFS-DIY reference: `Source/gui/AudioPatchTab.{h,cpp}`) -The lifted window will be app-agnostic in the shared-EQ style: `std::function` providers read at use -time, raw `juce::ValueTree` + `juce::Identifier` for state, and the consuming app keeping a thin -shim header of `using` aliases plus a config factory. XOA has a near-identical counterpart for -almost every seam, which is why this lift is worth doing: +Each hosts one matrix plus a button bar: + +| Element | Behaviour | +|---|---| +| Mode buttons | Scrolling / Patching, plus Testing on the output tab. Setting a mode calls `matrix->setMode()`; the matrix does the rest | +| Unpatch All | **Long-press guarded** — it is destructive. WFS-DIY uses an 800 ms hold | +| Header repaint timer | ~20 Hz on the input tab only, calling `matrix->repaintHeaderBand()`. That is what animates the signal-presence tint; without it the tint updates only on other repaints | +| Test controls (output tab) | signal-type combo, level slider, frequency slider (Tone only), Hold toggle | + +**B. Test-signal controls.** The generator is `spatcore::io::TestSignalGenerator` (§5.4). Three +behaviours are not obvious and matter: + +- **Stop the tone on every exit path** — leaving the tab, closing the window, changing mode, and + starting processing. WFS-DIY calls `setOutputChannel(-1)` at each. A test tone left running + because a window closed is exactly the kind of thing that damages a rig. +- **The slider mappings are non-linear**, so a linear slider feels wrong. Level: + `dB = 20·log10(10^(-92/20) + (1 − 10^(-92/20))·v²)` clamped to [−92, 0]. Frequency: + `f = 20·10^(3x)`, i.e. log across 20 Hz–20 kHz. +- **Combo ids are 1-based, the enum is 0-based.** WFS-DIY maps combo 1→`Off`, 2→`PinkNoise`, + 3→`Tone`, 4→`Sweep`, 5→`DiracPulse`, while its Stream Deck path casts the raw ordinal. Pick one + convention and hold it. + +Do **not** port `TestSignalControlPanel` from WFS-DIY: it is ~110 lines of dead code, never +instantiated anywhere in that repo, superseded by controls inlined into the output tab. + +**C. A device settings panel** (reference: `AudioInterfaceWindow.cpp`, `DeviceSettingsPanel`) + +Device type, device, sample rate and buffer size combos, plus Control Panel and Reset Device +buttons. **Route every mutation through `DeviceHost`** — that is the whole point of §2.2. WFS-DIY +still has three sites that bypass it (`AudioInterfaceWindow.cpp:147`, `:516`, `:546`), which is +mask-safe only because a prior `DeviceHost` call already cleared the `useDefault*` flags. Do not +reproduce that; see open decision 5 in §8. + +**D. A window to host it.** WFS-DIY uses a `juce::DocumentWindow` with a device info bar on top and +a tab bar below (Device Settings / Input Patch / Output Patch). XOA may prefer a tab inside the main +window — nothing in the matrix cares. + +**One behaviour to carry over regardless of layout: make it stopped-only.** Patching a live rig is +how speakers get destroyed. WFS-DIY closes the window when processing starts, blocks reopening while +it runs with a status message, and applies pending patch edits when processing starts. + +### 7.3 What XOA already has for the seams | Seam | WFS-DIY | XOA | |---|---|---| | Parameter store | `WFSValueTreeState` | `XoaValueTreeState` — both derive `spatcore::control::state::TreeParameterStore` | -| Colours | `ColorScheme::get()` + `ColorScheme::Manager` listener | same names | +| Colours | `ColorScheme::get()` + `Manager` listener | same names | | Strings | `LOC()` macro | same | | Status line | `StatusBar` | same | | Accessibility | `TTSManager` singleton | same | | Long-press button | `LongPressButton` | same | -| Window helpers | `WindowUtils`, `HelpCardSVG` | `WindowUtils`, `HelpCard` | -| Stream Deck+ pages | `PatchWindowPages` wired in `MainComponent` | **absent** — XOA links `spatcore-controllers` but never includes it | - -Two consequences worth stating up front: +| Stream Deck+ pages | `PatchWindowPages` | **absent** — XOA links `spatcore-controllers` but never includes it | -- XOA inherits the window's Stream Deck accessor surface as **unused public API**. That is why the - lift cannot trim it; do not treat those accessors as dead code. -- Localisation cost on the XOA side is small: the window uses ~32 distinct `audioPatch.*` keys, and - XOA ships **two** locales (`Resources/lang/en.json`, `fr.json`, single tier) against WFS-DIY's - nine across two tiers. ~64 string entries total. +Localisation is small: the matrix needs 6 keys and a shell adds roughly 25 more, against XOA's two +locales (`Resources/lang/{en,fr}.json`, single tier) — about 60 string entries, not the 17-file +burden WFS-DIY carries across two tiers. -### 7.2 Sequencing +### 7.4 Sequencing -1. WFS-DIY decouples the three GUI files and moves them to `spatcore/ui/patch/` — spatcore PR. -2. WFS-DIY re-pins and reduces its own files to shims — app PR. Behaviour parity is verified by hand - there, not by XOA. -3. XOA adopts: re-pin, link, replace the device selector, add the patch ValueTree section to its - schema, and re-point the decoder at patched hardware channels. +1. Merge the spatcore `feat/shared-patch-matrix` branch and re-pin. +2. XOA: add the `AudioPatch` ValueTree section and feed `activeHardwareChannels` from `DeviceHost`. +3. XOA: instantiate the matrix behind a config, in a throwaway window, and confirm it patches. +4. XOA: build the shell of §7.2 around it. +5. XOA: re-point the decoder at patched hardware channels — the real work, per §7 preamble. -Do **not** start step 3 before step 2 has been through a real show or a full manual pass in WFS-DIY. -The GUI has no bit-exactness gate, so WFS-DIY's own use is the only regression net the lift gets. +Steps 2–3 are worth doing on their own before any shell exists; a matrix in a bare window is enough +to prove the config and the schema. --- @@ -438,9 +549,13 @@ does. from a saved setup, and so the one most exposed to the mask policy), `:516` (sample rate) and `:546` (buffer size). All three are mask-safe only because a prior `DeviceHost` call already cleared the `useDefault*` flags in the stored setup. Making that an enforced invariant rather - than an assumed one is a small spatcore change, best made **before** the window is lifted. + than an assumed one is a small spatcore change, and XOA's device panel (§7.2 C) is the second + consumer that would otherwise have to remember the rule. 6. **Does the io layer ship as a tag?** spatcore is tagged only `v0.1.0` / `v0.1.1` while its `CMakeLists.txt` declares `VERSION 0.2.0`. Pin a bare SHA (`8e0d7e6`) or tag `v0.2.0` first. +7. **Does XOA patch its inputs too, or only its outputs?** WFS-DIY has both matrices. XOA's stems + are identity-mapped from device inputs today; an input patch is optional and can come later — + the matrix is the same component either way, constructed with `isInputPatch = true`. --- @@ -450,8 +565,10 @@ does. |---|---| | Device layer | `spatcore/io/{HardwareIndexMap,DeviceIoCallback,DeviceHost,TestSignalGenerator}.h` | | Its tests | `spatcore/tests/SpatcoreTests.cpp` (io section, at the end) | -| CMake target | `spatcore/CMakeLists.txt`, the `SPATCORE_IO` block | -| Reference consumer | `d:/dev/WFS_DIY_v1`, branch `feat/patch-diagnostics-512ch` | -| The window to be lifted | `d:/dev/WFS_DIY_v1/Source/gui/{AudioInterfaceWindow,AudioPatchTab,PatchMatrixComponent}.{h,cpp}` | +| CMake targets | `spatcore/CMakeLists.txt`, the `SPATCORE_IO` and `SPATCORE_UI` blocks | +| **Shared patch matrix** | `spatcore/ui/patch/{PatchMatrixConfig,PatchMatrixComponent}.h` — read the config header first, it carries the rationale for every seam | +| Reference consumer | `d:/dev/WFS_DIY_v1`, branch `feat/shared-patch-matrix` (which contains `feat/patch-diagnostics-512ch`) | +| Matrix shim to copy | `d:/dev/WFS_DIY_v1/Source/gui/PatchMatrixComponent.h` + `PatchMatrixShim.cpp` | +| Shell to spec against (NOT shared) | `d:/dev/WFS_DIY_v1/Source/gui/{AudioInterfaceWindow,AudioPatchTab}.{h,cpp}` | | Shared-component precedent | spatcore `d7967f7` + WFS-DIY `e7ad1c7` (the EQ) | | XOA's current device code | `Source/Audio/AudioEngine.{h,cpp}`, `Source/Audio/TestSignalGenerator.h`, `Source/GUI/Tabs/SystemConfigTab.cpp:66-70` | From b4b6c46a8ef7927f45f9c530e713d58ed0f90e28 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Boulant Date: Sun, 2 Aug 2026 18:14:06 +0200 Subject: [PATCH 03/12] spatcore pin bump: 8296f28 -> 00ea5d8 (io device layer + shared patch matrix) Link spatcore-io into the app and move the pin prose in CLAUDE.md and XOA-PLAN.md $4. Build-only change: nothing consumes the io layer yet (that lands with the AudioEngine migration). Release build and both ctest lanes verified green on this commit alone. Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 9 +++++---- CMakeLists.txt | 1 + Documentation/XOA-PLAN.md | 2 +- spatcore | 2 +- 4 files changed, 8 insertions(+), 6 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 6687611..e636486 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,11 +18,12 @@ Binary: `build/XOA_artefacts//XOA.exe`. After a fresh clone run ## Repo shape - `Source/` — the XOA application (JUCE gui app). App layer only. -- `spatcore/` — **submodule**, pinned to **8296f28** (post-v0.1.1: GPU +- `spatcore/` — **submodule**, pinned to **00ea5d8** (post-v0.1.1: GPU node-parallel SDN, Max-port FR diffusion, MCP protocol negotiation, the - shared EQ). rt/dsp/wfs/reverb/gpu + - control (osc/state/mcp) + controllers + ui. The audio device layer (`io/`, - target `spatcore-io`) landed later, at `8e0d7e6`, and is **not** in this pin — + shared EQ, the `spatcore::io` device layer (`io/`, target `spatcore-io`), + and the shared patch matrix (`ui/patch/`)). rt/dsp/wfs/reverb/gpu + + control (osc/state/mcp) + controllers + ui + io. Adopting the io layer and + patch window in XOA: see `Documentation/XOA-AUDIO-DEVICE-AND-PATCH-HANDOFF.md`. The CMake wiring comes from spatcore's `cmake/SpatcoreConsumer.cmake` helper (see CMakeLists.txt). Dependency direction is strictly app → spatcore; never modify spatcore from diff --git a/CMakeLists.txt b/CMakeLists.txt index b35e522..f8cc1e6 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -106,6 +106,7 @@ target_link_libraries(XOA PRIVATE spatcore-control spatcore-controllers spatcore-ui + spatcore-io hidapi::hidapi juce::juce_audio_utils juce::juce_gui_extra diff --git a/Documentation/XOA-PLAN.md b/Documentation/XOA-PLAN.md index bfbddfd..6a60bb8 100644 --- a/Documentation/XOA-PLAN.md +++ b/Documentation/XOA-PLAN.md @@ -78,7 +78,7 @@ every experiment. ## 4. Roadmap - **P0 — Bootstrap (this repo state).** Submodules (JUCE 9.0.0 @f8f88641, - spatcore @bf96b3c post-v0.1.1, hidapi @0.15.0), vendored juce_simpleweb + + spatcore @00ea5d8 post-v0.1.1, hidapi @0.15.0), vendored juce_simpleweb + roli_blocks_basics, CMake build (via spatcore's SpatcoreConsumer.cmake helper) of a minimal JUCE app linking spatcore-audio/-control/-controllers, GPLv3, CI build sanity green on the three OSes. diff --git a/spatcore b/spatcore index 8296f28..00ea5d8 160000 --- a/spatcore +++ b/spatcore @@ -1 +1 @@ -Subproject commit 8296f28ace30ed4bdd16fb1f7f407947a4f179d0 +Subproject commit 00ea5d87f761ca636e22f9cc1e8b4bf7fbcc97c5 From af3f9fc429faf3dc8839cceddefbb78ce5948d0e Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Boulant Date: Sun, 2 Aug 2026 18:28:01 +0200 Subject: [PATCH 04/12] refactor(audio): adopt the spatcore::io device layer (D35-D37, D40) AudioEngine now opens/restores its device through spatcore::io::DeviceHost and is driven as a juce::AudioSource by spatcore::io::DeviceIoCallback, replacing the bare AudioDeviceManager::initialise and the raw AudioIODeviceCallback: - DeviceHost writes explicit channel masks with JUCE's useDefault*Channels flags cleared, so the masks the app asks for are the masks that open, the saved device state can round-trip a channel selection, and rigs are no longer silently capped at 256 outputs (handoff $2.2). The addressing ceiling is the new xoa::kMaxHardwareChannels = 512 (D37). - DeviceIoCallback hands getNextAudioBlock a hardware-indexed buffer. The stem gather now runs off that buffer BEFORE anything writes an output -- a row that is an active output aliases the device's own storage with the matching input pre-copied in, so this ordering is load-bearing ($5.1). - Speaker/stem counts come from the device masks in prepareToPlay, never from the buffer width ($5.2), and stay ordinal-indexed under an asserted identity mapping (D36 option A; option B when the stage-2 patch window lands). Downstream (decode, comp, test signal, meters) sees a speaker-width view, unchanged in shape from before. - A device change made through the stock selector is re-asserted to the enable-all policy from the change listener (no-op when already right), and open/policy errors are captured in getLastDeviceError() -- a failing driver now reports a non-empty reason ($5.5). Verified: Release + Debug builds, both ctest lanes, and --gui-smoke in both configs against real hardware (Debug = asserts active). The $6 multi-channel hardware acceptance pass remains a manual step. Co-Authored-By: Claude Fable 5 --- Documentation/XOA-DEVPLAN.md | 30 ++++++++ Source/Audio/AudioEngine.cpp | 128 ++++++++++++++++++++++++----------- Source/Audio/AudioEngine.h | 53 ++++++++++++--- Source/XoaConstants.h | 7 ++ 4 files changed, 168 insertions(+), 50 deletions(-) diff --git a/Documentation/XOA-DEVPLAN.md b/Documentation/XOA-DEVPLAN.md index 97e7e86..3f3de07 100644 --- a/Documentation/XOA-DEVPLAN.md +++ b/Documentation/XOA-DEVPLAN.md @@ -195,6 +195,36 @@ Recorded here so no work package has to re-litigate them. listener position (the directional correction) and listener position via PSN/RTTrP tracker profiles (D3). +**Device-layer adoption (D35–D40)** — stage 1 of +`XOA-AUDIO-DEVICE-AND-PATCH-HANDOFF.md` (spatcore::io), post-WP, off the v1 +critical path. + +- **D35 — `AudioEngine` implements `juce::AudioSource` directly** (no adapter + object). WFS-DIY kept `AudioAppComponent` only to inherit its + `deviceManager`; XOA has no such constraint, and the old callback body was + already `getNextAudioBlock`-shaped. +- **D36 — Indexing: handoff §5.3 option A now, option B when stage 2 lands.** + Meters and `prepare` counts stay speaker-ordinal-indexed, valid only while + the channel masks are contiguous from bit 0 — which `DeviceHost`'s + enable-all policy guarantees. Asserted + (`jassert (map.isIdentityMapping())` in `prepareToPlay`) so a + non-contiguous mask fails loudly instead of misrouting. The stage-2 patch + window re-indexes meters by hardware channel (option B) when it arrives. +- **D37 — The addressing ceiling is `xoa::kMaxHardwareChannels = 512` in + `XoaConstants.h`** — the one home for the number handed to `DeviceHost` and + `DeviceIoCallback`. A ceiling, not an allocation; distinct from + `kMaxSpeakers` (decoder clamp) and `kMaxInputs` (stem count), which keep + their meanings. +- **D38 — `SpeakerId` is owned by spatcore** (spatcore PR #8): ported verbatim + into `spatcore::io::TestSignalGenerator`, enum member appended last so + consumer ordinals hold. XOA's forked generator becomes a `using` alias once + the PR merges and the pin moves past it. +- **D39 (open) — `DeviceHost` sample-rate/buffer-size/reset setters**: wanted + before XOA's stage-2 device panel exists so mask-safety is enforced rather + than assumed (WFS-DIY still has three direct `setAudioDeviceSetup` sites). + Small spatcore change; take it with stage 2. +- **D40 — spatcore pins stay bare SHAs**; no v0.2.0 tag gate for the io layer. + --- ## 5. Work packages diff --git a/Source/Audio/AudioEngine.cpp b/Source/Audio/AudioEngine.cpp index 220b99b..88f4560 100644 --- a/Source/Audio/AudioEngine.cpp +++ b/Source/Audio/AudioEngine.cpp @@ -104,11 +104,14 @@ void AudioEngine::openAudioDevice() std::unique_ptr savedXml = saved.isNotEmpty() ? juce::parseXML (saved) : nullptr; - // WP8: open device INPUTS too (identity-mapped to encoder stems). Hardware - // with fewer inputs opens what it has; numInputChannels reflects the actual. - deviceManager.initialise (xoa::kMaxInputs, xoa::kMaxSpeakers, savedXml.get(), true); + // Restore through DeviceHost so every mutation writes explicit channel + // masks with JUCE's useDefault*Channels flags cleared — without that, + // setAudioDeviceSetup silently substitutes range(0, count) and the state + // XML can never round-trip a channel selection (handoff §2.2). The policy + // opens EVERY channel the device has, up to kMaxHardwareChannels. + lastDeviceError = deviceHost.restoreFromXml (savedXml.get(), true); deviceManager.addChangeListener (this); - deviceManager.addAudioCallback (this); + deviceManager.addAudioCallback (&ioCallback); // once — the manager re-arms it across device changes callbackRegistered = true; // Bring the encoder engine to the device's sample rate / rig radius and keep @@ -122,7 +125,9 @@ void AudioEngine::closeAudioDevice() if (callbackRegistered) { calcEngine.stopTicking(); - deviceManager.removeAudioCallback (this); + // Blocks until the audio thread has left the callback — must stay + // ahead of destroying anything getNextAudioBlock touches. + deviceManager.removeAudioCallback (&ioCallback); deviceManager.removeChangeListener (this); callbackRegistered = false; @@ -334,6 +339,13 @@ void AudioEngine::syncCalcEngineToDevice() //============================================================================== void AudioEngine::changeListenerCallback (juce::ChangeBroadcaster*) { + // Re-assert the enable-all mask policy after any device change — e.g. one + // made through the stock selector in SystemConfigTab, which knows nothing + // of DeviceHost. No-ops when the setup already matches, so the change + // broadcast this can itself trigger terminates immediately. + if (auto err = deviceHost.enableAllChannels(); err.isNotEmpty()) + lastDeviceError = err; + if (auto xml = deviceManager.createStateXml()) store.setParameterWithoutUndo (ids::audioDeviceState, xml->toString()); @@ -343,11 +355,17 @@ void AudioEngine::changeListenerCallback (juce::ChangeBroadcaster*) } //============================================================================== -void AudioEngine::audioDeviceAboutToStart (juce::AudioIODevice* device) +void AudioEngine::prepareToPlay (int samplesPerBlockExpected, double sampleRate) { - const double sr = device->getCurrentSampleRate(); - const int block = device->getCurrentBufferSizeSamples(); - const int outLatency = device->getOutputLatencyInSamples(); + const double sr = sampleRate; + const int block = samplesPerBlockExpected; + + // The AudioSource signature carries only rate and block size; everything + // else comes off the device / DeviceHost — and NEVER off the io buffer, + // whose width is the hardware span, not the speaker count (§5.2). + int outLatency = 0; + if (auto* device = deviceManager.getCurrentAudioDevice()) + outLatency = device->getOutputLatencyInSamples(); deviceSampleRate.store (sr, std::memory_order_relaxed); deviceBlockSize.store (block, std::memory_order_relaxed); @@ -356,11 +374,25 @@ void AudioEngine::audioDeviceAboutToStart (juce::AudioIODevice* device) measuredLatencyMs.store (sr > 0.0 ? (double) (outLatency + block) / sr * 1000.0 : 0.0, std::memory_order_relaxed); + // Stage-1 indexing contract (D36, option A): the masks are contiguous from + // bit 0 — DeviceHost's enable-all policy writes range(0, N) — so buffer + // row == stem index == speaker ordinal throughout. Assert it so a + // non-contiguous mask (possible once the stage-2 patch window can deselect + // channels) fails loudly here instead of silently misrouting audio. + jassert (ioCallback.getChannelMap().isIdentityMapping()); + + // Counts are counts, not index bounds — the two agree only under the + // contiguity asserted above (§5.3). Outputs clamp to the decoder ceiling; + // hardware channels past it stay open but are fed silence. + const int numIn = juce::jmin (deviceHost.getNumActiveInputs(), xoa::kMaxInputs); + const int numOut = juce::jmin (deviceHost.getNumActiveOutputs(), xoa::kMaxSpeakers); + numActiveInputs.store (numIn, std::memory_order_relaxed); + numActiveOutputs.store (numOut, std::memory_order_relaxed); + inputScratch.setSize (xoa::kMaxFileChannels, block, false, false, true); stemScratch.setSize (xoa::kMaxInputs, block, false, false, true); filePlayer.prepareToPlay (sr, block); - const int numOut = device->getActiveOutputChannels().countNumberOfSetBits(); algorithm.prepare (xoa::kNumSHChannels, numOut, sr, block, &decoderBuilder, &rotationSnapshot, &busParamsSnapshot, true, calcEngine.encodeMatrix(), calcEngine.nfcCoeffs(), &calcEngine.encoderSource()); @@ -374,48 +406,56 @@ void AudioEngine::audioDeviceAboutToStart (juce::AudioIODevice* device) testSignal.prepare (sr, block); } -void AudioEngine::audioDeviceStopped() +void AudioEngine::releaseResources() { algorithm.releaseResources(); speakerComp.releaseResources(); filePlayer.releaseResources(); deviceSampleRate.store (0.0, std::memory_order_relaxed); deviceBlockSize.store (0, std::memory_order_relaxed); + numActiveInputs.store (0, std::memory_order_relaxed); + numActiveOutputs.store (0, std::memory_order_relaxed); } -void AudioEngine::audioDeviceIOCallbackWithContext (const float* const* inputChannelData, - int numInputChannels, - float* const* outputChannelData, - int numOutputChannels, - int numSamples, - const juce::AudioIODeviceCallbackContext& context) +void AudioEngine::getNextAudioBlock (const juce::AudioSourceChannelInfo& info) { - juce::ignoreUnused (context); - juce::ScopedNoDenormals noDenormals; - juce::AudioBuffer outBuf (outputChannelData, numOutputChannels, numSamples); + // The io buffer is HARDWARE-indexed: row h is hardware channel h, spanning + // max(highest in, highest out)+1 channels — NOT the speaker count (§5.2). + // DeviceIoCallback always fills from sample 0. + juce::AudioBuffer& ioBuf = *info.buffer; + jassert (info.startSample == 0); - // The input source writes into inputScratch, allocated to the block size - // reported at audioDeviceAboutToStart. JUCE's contract only varies the - // callback block downward (or restarts the device, which re-sizes the - // scratch), but AmbiBusAlgorithm defends against an over-size block, so the - // upstream scratch write must too: clamp the render to the scratch length - // and silence any tail we could not fill (defense-in-depth, no allocation). - const int n = juce::jmin (numSamples, inputScratch.getNumSamples()); - if (n < numSamples) - outBuf.clear(); + const int numIn = numActiveInputs.load (std::memory_order_relaxed); + // Clamp to the buffer width so a non-identity mapping (asserted against in + // prepareToPlay) degrades to short output rather than reading rows that do + // not exist. + const int numOut = juce::jmin (numActiveOutputs.load (std::memory_order_relaxed), + ioBuf.getNumChannels()); - juce::AudioSourceChannelInfo info (&outBuf, 0, n); + // The input source writes into inputScratch, allocated to the block size + // reported at prepareToPlay. DeviceIoCallback already clamps an over-size + // device block, but AmbiBusAlgorithm defends against one anyway, so the + // upstream scratch write keeps the same defense: clamp the render to the + // scratch length (defense-in-depth, no allocation). + const int n = juce::jmin (info.numSamples, inputScratch.getNumSamples()); // Mono-encoder stems (WP8): device inputs (identity-mapped) or the internal // test feed. Filled whenever a source could exist, so the encoder's one-block // ramp-out on deactivation still has audio to fade. The RT stage gates on the // published numSources, so filling here when disabled is harmless. + // + // ORDER IS LOAD-BEARING (§5.1): a buffer row that is an active output + // aliases the device's own output storage, with the matching input copied + // into that same row before this call. Every input must therefore be read + // BEFORE anything writes an output — moving this gather below the decode + // would feed the encoder its own decoder output on every channel where a + // speaker and a microphone share a hardware index. const bool testStems = stemFeed.load (std::memory_order_relaxed) == StemFeed::test; const juce::AudioBuffer* stemsPtr = nullptr; int numStems = 0; - if (testStems || numInputChannels > 0) + if (testStems || numIn > 0) { numStems = juce::jmin (xoa::kMaxInputs, stemScratch.getNumChannels()); if (testStems) @@ -435,8 +475,8 @@ void AudioEngine::audioDeviceIOCallbackWithContext (const float* const* inputCha for (int i = 0; i < numStems; ++i) { float* d = stemScratch.getWritePointer (i); - if (i < numInputChannels && inputChannelData[i] != nullptr) - juce::FloatVectorOperations::copy (d, inputChannelData[i], n); + if (i < numIn && i < ioBuf.getNumChannels()) + juce::FloatVectorOperations::copy (d, ioBuf.getReadPointer (i), n); else juce::FloatVectorOperations::clear (d, n); } @@ -449,6 +489,16 @@ void AudioEngine::audioDeviceIOCallbackWithContext (const float* const* inputCha inputPeak[(size_t) i].store (i < numStems ? stemScratch.getMagnitude (i, 0, n) : 0.0f, std::memory_order_relaxed); + // Everything downstream sees a speaker-width view of the io buffer (rows + // 0..numOut-1, valid under the identity mapping asserted in prepareToPlay) + // so the decode, comp, test signal and meters address speakers exactly as + // they always have — input-only rows above numOut are never touched. + juce::AudioBuffer outBuf (ioBuf.getArrayOfWritePointers(), numOut, info.numSamples); + if (n < info.numSamples) + outBuf.clear(); // silence the whole block rather than emit a partial tail + + juce::AudioSourceChannelInfo outInfo (&outBuf, 0, n); + if (inputSource.load (std::memory_order_relaxed) == InputSource::testScene) { float* ptrs[xoa::kNumSHChannels]; @@ -456,19 +506,19 @@ void AudioEngine::audioDeviceIOCallbackWithContext (const float* const* inputCha ptrs[c] = inputScratch.getWritePointer (c); scene::renderScene (xoa::kAmbisonicOrder, sceneCounter, n, deviceSampleRate.load (std::memory_order_relaxed), ptrs); - algorithm.processBlock (info, inputScratch, xoa::kNumSHChannels, numOutputChannels, + algorithm.processBlock (outInfo, inputScratch, xoa::kNumSHChannels, numOut, stemsPtr, numStems); } else { filePlayer.renderNextBlock (inputScratch, n); - algorithm.processBlock (info, inputScratch, - fileNumChannels.load (std::memory_order_relaxed), numOutputChannels, + algorithm.processBlock (outInfo, inputScratch, + fileNumChannels.load (std::memory_order_relaxed), numOut, stemsPtr, numStems); } // Per-speaker compensation (delay/EQ/gain) on the decoded output, in place. - speakerComp.processBlock (outBuf, numOutputChannels, n); + speakerComp.processBlock (outBuf, numOut, n); // Output test signal (FR-21), injected post-comp with replace-semantics on // its target channel(s) so it lands exactly where the meters read it. @@ -476,9 +526,9 @@ void AudioEngine::audioDeviceIOCallbackWithContext (const float* const* inputCha testSignal.renderNextBlock (outBuf, 0, n); // Post-comp / post-test-signal block-peak meters (what leaves the device). - for (int s = 0; s < numOutputChannels && s < xoa::kMaxSpeakers; ++s) + for (int s = 0; s < numOut && s < xoa::kMaxSpeakers; ++s) outputPeak[(size_t) s].store (outBuf.getMagnitude (s, 0, n), std::memory_order_relaxed); - for (int s = juce::jmax (0, numOutputChannels); s < xoa::kMaxSpeakers; ++s) + for (int s = juce::jmax (0, numOut); s < xoa::kMaxSpeakers; ++s) outputPeak[(size_t) s].store (0.0f, std::memory_order_relaxed); sceneCounter += n; diff --git a/Source/Audio/AudioEngine.h b/Source/Audio/AudioEngine.h index 000b3b1..2e21d24 100644 --- a/Source/Audio/AudioEngine.h +++ b/Source/Audio/AudioEngine.h @@ -6,6 +6,8 @@ #include #include +#include "spatcore/io/DeviceHost.h" +#include "spatcore/io/DeviceIoCallback.h" #include "spatcore/rt/RtSnapshot.h" #include "XoaConstants.h" @@ -22,9 +24,14 @@ #include "Parameters/XoaValueTreeState.h" //============================================================================== -// XOA - the audio engine (WP6). Owns the device layer (spatcore deliberately -// does not) and hosts the RT bus chain, wiring the store to it through three -// message-thread controllers: +// XOA - the audio engine (WP6). Owns the juce::AudioDeviceManager and hosts +// the RT bus chain. Device open/restore policy and the device callback come +// from spatcore::io: DeviceHost enforces explicit channel masks (JUCE's +// useDefault*Channels flags would otherwise silently discard them and cap the +// rig - handoff doc §2.2), and DeviceIoCallback drives this class as a +// juce::AudioSource through a HARDWARE-INDEXED buffer (row h == hardware +// channel h, inputs and outputs alike). The store is wired to the chain +// through three message-thread controllers: // // RotationPublisher rotation params -> RtSnapshot // BusParamsPublisher master gain / playback params + input-source -> @@ -42,7 +49,7 @@ namespace xoa { -class AudioEngine : private juce::AudioIODeviceCallback, +class AudioEngine : private juce::AudioSource, private juce::ChangeListener, private juce::ValueTree::Listener, private juce::Timer, @@ -66,6 +73,7 @@ class AudioEngine : private juce::AudioIODeviceCallback, void closeAudioDevice(); // stop the callback, persist state juce::AudioDeviceManager& getDeviceManager() noexcept { return deviceManager; } + spatcore::io::DeviceHost& getDeviceHost() noexcept { return deviceHost; } FilePlayer& getFilePlayer() noexcept { return filePlayer; } DecoderMatrixBuilder& getDecoderBuilder() noexcept { return decoderBuilder; } TestSignalGenerator& getTestSignalGenerator() noexcept { return testSignal; } @@ -133,6 +141,11 @@ class AudioEngine : private juce::AudioIODeviceCallback, double getSampleRate() const noexcept { return deviceSampleRate.load (std::memory_order_relaxed); } int getBlockSize() const noexcept { return deviceBlockSize.load (std::memory_order_relaxed); } + /** Error string from the last device open / policy application (message + thread). Empty means the last operation succeeded — DeviceHost + guarantees a real failure reports a non-empty reason (handoff §5.5). */ + const juce::String& getLastDeviceError() const noexcept { return lastDeviceError; } + /** Invoked (message thread) after each decoder rebuild, for UI status. */ std::function onDecoderRebuilt; @@ -149,13 +162,13 @@ class AudioEngine : private juce::AudioIODeviceCallback, private: //========================================================================== - // juce::AudioIODeviceCallback - void audioDeviceIOCallbackWithContext (const float* const* inputChannelData, int numInputChannels, - float* const* outputChannelData, int numOutputChannels, - int numSamples, - const juce::AudioIODeviceCallbackContext& context) override; - void audioDeviceAboutToStart (juce::AudioIODevice* device) override; - void audioDeviceStopped() override; + // juce::AudioSource, driven by spatcore::io::DeviceIoCallback. The buffer + // handed to getNextAudioBlock is HARDWARE-indexed: row h is hardware + // channel h, and a row that is an active output aliases the device's own + // output storage with the matching input pre-copied into it (§5.1). + void getNextAudioBlock (const juce::AudioSourceChannelInfo& info) override; + void prepareToPlay (int samplesPerBlockExpected, double sampleRate) override; + void releaseResources() override; // juce::ChangeListener (device state persistence) void changeListenerCallback (juce::ChangeBroadcaster*) override; @@ -199,6 +212,15 @@ class AudioEngine : private juce::AudioIODeviceCallback, juce::ValueTree decoderSection; juce::AudioDeviceManager deviceManager; + + // Open/restore policy and the device callback (spatcore::io). deviceHost + // holds only a reference to deviceManager; ioCallback drives this engine + // as the AudioSource. Both are bounded by the addressing ceiling, not the + // decoder clamp (kMaxSpeakers) — the ceiling is what the hardware masks + // may span, the clamp is how many speakers the decoder will feed. + spatcore::io::DeviceHost deviceHost { deviceManager, xoa::kMaxHardwareChannels }; + spatcore::io::DeviceIoCallback ioCallback { *this, xoa::kMaxHardwareChannels }; + FilePlayer filePlayer; DecoderMatrixBuilder decoderBuilder; AmbiCalculationEngine calcEngine; // control-side encoder (owns the live matrices) @@ -242,6 +264,15 @@ class AudioEngine : private juce::AudioIODeviceCallback, std::atomic deviceSampleRate { 0.0 }; std::atomic deviceBlockSize { 0 }; + // Active channel counts, written in prepareToPlay from the device masks + // and read in getNextAudioBlock. NEVER derived from the buffer width: the + // io buffer spans max(highest in, highest out)+1 hardware channels, so on + // a 64-in/6-out rig it is 64 wide while the speaker count is 6 (§5.2). + std::atomic numActiveInputs { 0 }; + std::atomic numActiveOutputs { 0 }; + + juce::String lastDeviceError; // message thread only + juce::int64 sceneCounter = 0; // audio-thread-owned scene sample position bool listenersRegistered = false; bool callbackRegistered = false; diff --git a/Source/XoaConstants.h b/Source/XoaConstants.h index 9b41a25..db4be87 100644 --- a/Source/XoaConstants.h +++ b/Source/XoaConstants.h @@ -31,6 +31,13 @@ static_assert (kNumSHChannels == 121, "10th-order Ambisonics carries 121 SH chan constexpr int kMaxInputs = 64; constexpr int kMaxSpeakers = 256; + +/** Hardware-channel addressing ceiling handed to spatcore::io (DeviceHost's + mask policy and DeviceIoCallback's buffer span). A ceiling, not an + allocation — masks are built from the device's real channel counts and only + clamped to it (D37). Distinct from kMaxSpeakers: the ceiling bounds what + the device may open, the clamp bounds how many speakers the decoder feeds. */ +constexpr int kMaxHardwareChannels = 512; constexpr int kDefaultInputs = 8; constexpr int kDefaultSpeakers = 24; // the M1 24-ring validation fixture From 448e22f89fadefed0ecb29dd31fa61609d3fa03b Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Boulant Date: Sun, 2 Aug 2026 18:36:06 +0200 Subject: [PATCH 05/12] refactor(audio): consume the shared spatcore test-signal generator (D38) spatcore pin bump 00ea5d8 -> ac45f0f (PR #8: SpeakerId in the shared generator). XOA's forked TestSignalGenerator becomes a using-alias shim in the shared-EQ style; SpeakerId and getCurrentSpeakerIndex() now come from spatcore with the ordinals unchanged, so the SpeakersDecoderTab combo mapping and persisted values hold. The deterministic seed is opt-in upstream (the shared prepare() seeds from the wall clock by default), so AudioEngine pins kTestSignalSeed at construction and the determinism test seeds explicitly. The shared prepare() also fixes the silent-Tone defect the fork never had (phase increment recomputed on sample-rate change). Verified: Release build, both ctest lanes, --gui-smoke. Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 2 +- Documentation/XOA-PLAN.md | 2 +- Source/Audio/AudioEngine.cpp | 4 + Source/Audio/TestSignalGenerator.h | 305 ++--------------------------- spatcore | 2 +- tests/XoaTestSignalTests.cpp | 5 +- 6 files changed, 29 insertions(+), 291 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index e636486..bccc7d8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,7 +18,7 @@ Binary: `build/XOA_artefacts//XOA.exe`. After a fresh clone run ## Repo shape - `Source/` — the XOA application (JUCE gui app). App layer only. -- `spatcore/` — **submodule**, pinned to **00ea5d8** (post-v0.1.1: GPU +- `spatcore/` — **submodule**, pinned to **ac45f0f** (post-v0.1.1: GPU node-parallel SDN, Max-port FR diffusion, MCP protocol negotiation, the shared EQ, the `spatcore::io` device layer (`io/`, target `spatcore-io`), and the shared patch matrix (`ui/patch/`)). rt/dsp/wfs/reverb/gpu + diff --git a/Documentation/XOA-PLAN.md b/Documentation/XOA-PLAN.md index 6a60bb8..89d0e15 100644 --- a/Documentation/XOA-PLAN.md +++ b/Documentation/XOA-PLAN.md @@ -78,7 +78,7 @@ every experiment. ## 4. Roadmap - **P0 — Bootstrap (this repo state).** Submodules (JUCE 9.0.0 @f8f88641, - spatcore @00ea5d8 post-v0.1.1, hidapi @0.15.0), vendored juce_simpleweb + + spatcore @ac45f0f post-v0.1.1, hidapi @0.15.0), vendored juce_simpleweb + roli_blocks_basics, CMake build (via spatcore's SpatcoreConsumer.cmake helper) of a minimal JUCE app linking spatcore-audio/-control/-controllers, GPLv3, CI build sanity green on the three OSes. diff --git a/Source/Audio/AudioEngine.cpp b/Source/Audio/AudioEngine.cpp index 88f4560..d75c52c 100644 --- a/Source/Audio/AudioEngine.cpp +++ b/Source/Audio/AudioEngine.cpp @@ -17,6 +17,10 @@ static constexpr int kDecoderDebounceMs = 150; AudioEngine::AudioEngine (XoaValueTreeState& s) : store (s), calcEngine (s) { + // The shared generator seeds from the wall clock by default; pin XOA's + // fixed seed so renders stay reproducible (applies at each prepare()). + testSignal.setDeterministicSeed (kTestSignalSeed); + registerListeners(); // Publish-before-enable: a valid snapshot on every seam before the device diff --git a/Source/Audio/TestSignalGenerator.h b/Source/Audio/TestSignalGenerator.h index 8a8727b..8d9d3a1 100644 --- a/Source/Audio/TestSignalGenerator.h +++ b/Source/Audio/TestSignalGenerator.h @@ -1,301 +1,32 @@ #pragma once -#include - -#include -#include +#include "spatcore/io/TestSignalGenerator.h" //============================================================================== -// XOA - output test-signal generator (WP7, FR-21). Ported header-only from -// WFS-DIY's TestSignalGenerator with two XOA changes: +// XOA - output test-signal generator (WP7, FR-21): the shared spatcore +// implementation, aliased into namespace xoa in the shared-EQ shim style. // -// 1. DETERMINISTIC seed. prepare() seeds the pink-noise RNG from a fixed -// constant (WFS-DIY seeded from the wall clock), so a render is fully -// reproducible - the offline tests can assert bit-equality across two -// fresh generators. -// 2. A SpeakerId mode. It steps a pink-noise burst across every output in -// turn (0.75 s on / 0.25 s gap), declicked at the burst edges, and exposes -// getCurrentSpeakerIndex() so the UI can name the speaker under test. The -// other modes keep WFS-DIY's single-target-channel behaviour. +// The two XOA-isms the local fork used to carry both moved upstream or became +// opt-in (D38, spatcore PR #8): // -// Injected by AudioEngine AFTER the decode + compensation, with REPLACE -// semantics on the target channel (it overwrites, so the operator hears only -// the signal). Thread model: control setters are message-thread + atomic; -// renderNextBlock() and prepare() own the generator state (prepare only runs -// with the device stopped). +// 1. SpeakerId mode (declicked pink burst stepping across every output, +// getCurrentSpeakerIndex() for the UI) is now a shared SignalType, +// appended last so persisted / combo-mapped ordinals held. +// 2. The DETERMINISTIC seed is opt-in upstream: the shared prepare() seeds +// from the wall clock unless setDeterministicSeed() was called. +// AudioEngine seeds kTestSignalSeed at construction so a render stays +// fully reproducible - the offline tests assert bit-equality across two +// fresh generators. //============================================================================== namespace xoa { -class TestSignalGenerator -{ -public: - enum class SignalType - { - Off, - PinkNoise, - Tone, - Sweep, - DiracPulse, - SpeakerId - }; - - TestSignalGenerator() = default; - - //========================================================================== - // Setup (message thread / device start). - //========================================================================== - void prepare (double newSampleRate, int /*maxBlockSize*/) - { - sampleRate = newSampleRate > 0.0 ? newSampleRate : 48000.0; - - for (auto& s : pinkNoiseState) s = 0.0f; - random.setSeed (kSeed); // deterministic (WFS-DIY used the clock) - - phase = 0.0f; - phaseIncrement = frequency / (float) sampleRate; - sweepPosition = 0.0f; - pulsePosition = 0.0f; - speakerIdPosition = 0.0f; - fadePosition.store (0.0f); - currentSpeakerIndex.store (-1); - } - - //========================================================================== - // Control (message thread; atomics). - //========================================================================== - void setSignalType (SignalType type) - { - if (currentType.exchange (type) != type) - { - phase = 0.0f; - sweepPosition = 0.0f; - pulsePosition = 0.0f; - speakerIdPosition = 0.0f; - // 500 ms fade-in for the continuous tones; none for the transient / - // stepping modes (they carry their own envelopes). - fadePosition.store ((type == SignalType::PinkNoise || type == SignalType::Tone) ? 0.0f : 1.0f); - } - } - - void setFrequency (float hz) - { - frequency = juce::jlimit (20.0f, 20000.0f, hz); - phaseIncrement = frequency / (float) sampleRate; - } - - void setLevel (float dB) { levelLinear.store (juce::Decibels::decibelsToGain (dB)); } - void setOutputChannel (int ch) { targetChannel.store (ch); } - - SignalType getSignalType() const noexcept { return currentType.load(); } - float getLevelDb() const { return juce::Decibels::gainToDecibels (levelLinear.load()); } - float getFrequency() const noexcept { return frequency; } - int getOutputChannel() const noexcept { return targetChannel.load(); } - - /** SpeakerId mode: the output currently under test, or -1 in a gap/inactive. */ - int getCurrentSpeakerIndex() const noexcept { return currentSpeakerIndex.load(); } - - bool isActive() const noexcept - { - const auto t = currentType.load(); - if (t == SignalType::Off) return false; - if (t == SignalType::SpeakerId) return true; // steps every output itself - return targetChannel.load() >= 0; - } - - void reset() - { - targetChannel.store (-1); - currentType.store (SignalType::Off); - fadePosition.store (0.0f); - currentSpeakerIndex.store (-1); - } - - //========================================================================== - // Audio thread. REPLACES the target channel(s) with the generated signal. - //========================================================================== - void renderNextBlock (juce::AudioBuffer& outputBuffer, int startSample, int numSamples) - { - const SignalType type = currentType.load(); - const float level = levelLinear.load(); - - if (type == SignalType::Off || numSamples <= 0) - return; - - if (type == SignalType::SpeakerId) - { - renderSpeakerId (outputBuffer, startSample, numSamples, level); - return; - } - - const int channel = targetChannel.load(); - if (channel < 0 || channel >= outputBuffer.getNumChannels()) - return; - - float* data = outputBuffer.getWritePointer (channel, startSample); - float fade = fadePosition.load(); - const float fadeStep = 1.0f / (kFadeDuration * (float) sampleRate); - - for (int i = 0; i < numSamples; ++i) - { - float sample = 0.0f; - switch (type) - { - case SignalType::PinkNoise: sample = generatePinkNoise(); break; - case SignalType::Tone: - sample = std::sin (phase * juce::MathConstants::twoPi); - phase += phaseIncrement; - if (phase >= 1.0f) phase -= 1.0f; - break; - case SignalType::Sweep: sample = generateSweep(); break; - case SignalType::DiracPulse: sample = generateDirac(); break; - default: sample = 0.0f; break; - } - - data[i] = sample * level * fade; - if (fade < 1.0f) fade = juce::jmin (1.0f, fade + fadeStep); - } - - fadePosition.store (fade); - } - -private: - //========================================================================== - void renderSpeakerId (juce::AudioBuffer& outputBuffer, int startSample, - int numSamples, float level) - { - const int numOut = outputBuffer.getNumChannels(); - if (numOut <= 0) - { - currentSpeakerIndex.store (-1); - return; - } - - const double dt = 1.0 / sampleRate; - const double cycle = (double) numOut * kSpeakerIdSlot; // one full sweep of the rig - int lastSpeaker = -1; - - for (int i = 0; i < numSamples; ++i) - { - const int speaker = (int) (speakerIdPosition / kSpeakerIdSlot); - const double withinSlot = speakerIdPosition - (double) speaker * kSpeakerIdSlot; - - if (withinSlot < kSpeakerIdBurst && speaker >= 0 && speaker < numOut) - { - // Raised-edge envelope over the burst (declick both ends). - float env = 1.0f; - if (withinSlot < kDeclick) - env = (float) (withinSlot / kDeclick); - else if (withinSlot > kSpeakerIdBurst - kDeclick) - env = (float) ((kSpeakerIdBurst - withinSlot) / kDeclick); - - outputBuffer.getWritePointer (speaker, startSample)[i] = generatePinkNoise() * level * env; - lastSpeaker = speaker; - } - // else: gap - leave the (decoded) output untouched on every channel. - - speakerIdPosition += dt; - if (speakerIdPosition >= cycle) - speakerIdPosition -= cycle; - } - - currentSpeakerIndex.store (lastSpeaker); - } - - float generatePinkNoise() - { - // Paul Kellett's refined method (7-pole, ~1/f from 20 Hz to Nyquist). - const float white = random.nextFloat() * 2.0f - 1.0f; - - pinkNoiseState[0] = 0.99886f * pinkNoiseState[0] + white * 0.0555179f; - pinkNoiseState[1] = 0.99332f * pinkNoiseState[1] + white * 0.0750759f; - pinkNoiseState[2] = 0.96900f * pinkNoiseState[2] + white * 0.1538520f; - pinkNoiseState[3] = 0.86650f * pinkNoiseState[3] + white * 0.3104856f; - pinkNoiseState[4] = 0.55000f * pinkNoiseState[4] + white * 0.5329522f; - pinkNoiseState[5] = -0.7616f * pinkNoiseState[5] - white * 0.0168980f; - - const float pink = pinkNoiseState[0] + pinkNoiseState[1] + pinkNoiseState[2] - + pinkNoiseState[3] + pinkNoiseState[4] + pinkNoiseState[5] - + pinkNoiseState[6] + white * 0.5362f; - - pinkNoiseState[6] = white * 0.115926f; - return pink * 0.11f; // approximate normalisation - } - - float generateSweep() - { - // Log sweep 20 Hz -> 20 kHz over 1 s, then a 3 s gap. - if (sweepPosition < kSweepDuration) - { - const float t = sweepPosition / kSweepDuration; - const float logStart = std::log (kSweepStartHz); - const float logEnd = std::log (kSweepEndHz); - const float freq = std::exp (logStart + t * (logEnd - logStart)); - - const float sample = std::sin (phase * juce::MathConstants::twoPi); - phase += freq / (float) sampleRate; - if (phase >= 1.0f) phase -= 1.0f; - - sweepPosition += 1.0f / (float) sampleRate; - return sample; - } - - sweepPosition += 1.0f / (float) sampleRate; - if (sweepPosition >= kSweepDuration + kSweepGap) - { - sweepPosition = 0.0f; - phase = 0.0f; - } - return 0.0f; - } - - float generateDirac() - { - const float sample = (pulsePosition < kPulseDuration) ? kPulseAmplitude : 0.0f; - pulsePosition += 1.0f / (float) sampleRate; - if (pulsePosition >= kPulseDuration + kPulseGap) - pulsePosition = 0.0f; - return sample; - } - - //========================================================================== - static constexpr int kSeed = 0x0A0Au; - - static constexpr float kFadeDuration = 0.5f; // 500 ms fade-in (tones) - - static constexpr float kSweepDuration = 1.0f; - static constexpr float kSweepGap = 3.0f; - static constexpr float kSweepStartHz = 20.0f; - static constexpr float kSweepEndHz = 20000.0f; - - static constexpr float kPulseDuration = 0.005f; // 5 ms burst - static constexpr float kPulseGap = 1.0f; // 1 s between pulses - static constexpr float kPulseAmplitude = 2.0f; - - static constexpr double kSpeakerIdBurst = 0.75; // seconds on - static constexpr double kSpeakerIdSlot = 1.0; // burst + 0.25 s gap - static constexpr double kDeclick = 0.010; // 10 ms edge fade - - // Control (atomic, message thread <-> audio thread). - std::atomic currentType { SignalType::Off }; - std::atomic targetChannel { -1 }; - std::atomic levelLinear { 0.01f }; // -40 dB default - std::atomic fadePosition { 0.0f }; - std::atomic currentSpeakerIndex { -1 }; - - // Generator state (audio thread + prepare). - double sampleRate = 48000.0; - float frequency = 1000.0f; - float phase = 0.0f; - float phaseIncrement = 1000.0f / 48000.0f; - float sweepPosition = 0.0f; - float pulsePosition = 0.0f; - double speakerIdPosition = 0.0; - float pinkNoiseState[7] = {}; - juce::Random random; +using TestSignalGenerator = spatcore::io::TestSignalGenerator; - JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (TestSignalGenerator) -}; +/** The fixed pink-noise RNG seed (WFS-DIY seeded from the clock; XOA pins it + for reproducibility). Applied via setDeterministicSeed() at engine + construction and in the offline tests. */ +inline constexpr juce::int64 kTestSignalSeed = 0x0A0A; } // namespace xoa diff --git a/spatcore b/spatcore index 00ea5d8..ac45f0f 160000 --- a/spatcore +++ b/spatcore @@ -1 +1 @@ -Subproject commit 00ea5d87f761ca636e22f9cc1e8b4bf7fbcc97c5 +Subproject commit ac45f0fbbbf6810d95112f083b018b0e8406db75 diff --git a/tests/XoaTestSignalTests.cpp b/tests/XoaTestSignalTests.cpp index bc897a3..1c1afc7 100644 --- a/tests/XoaTestSignalTests.cpp +++ b/tests/XoaTestSignalTests.cpp @@ -18,7 +18,9 @@ using SignalType = xoa::TestSignalGenerator::SignalType; //============================================================================== // A fixed seed makes pink noise reproducible: two freshly-prepared generators -// driven identically must produce bit-identical output. +// driven identically must produce bit-identical output. The shared generator +// seeds from the wall clock unless told otherwise, so the seed is explicit +// here — exactly as AudioEngine pins it at construction. void testTestSignalDeterminism() { constexpr int n = 512; @@ -26,6 +28,7 @@ void testTestSignalDeterminism() xoa::TestSignalGenerator a, b; for (auto* g : { &a, &b }) { + g->setDeterministicSeed (xoa::kTestSignalSeed); g->prepare (48000.0, n); g->setSignalType (SignalType::PinkNoise); g->setLevel (0.0f); From 179656f38283b5cb32a87777c1143f33ff42b657 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Boulant Date: Sun, 2 Aug 2026 18:57:33 +0200 Subject: [PATCH 06/12] spatcore pin bump: ac45f0f -> 7d293e4 (protective ramp on every test-signal type) The 500 ms hearing-protection ramp now applies to Sweep, DiracPulse and SpeakerId as well as the continuous types (spatcore PR #9) - no signal type may bypass it, so the operator always gets time to stop a too-loud test before it hurts. No XOA code changes: none of the test-signal tests assumed an unramped transient start. Release build, both ctest lanes and --gui-smoke green on this pin. Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 2 +- Documentation/XOA-PLAN.md | 2 +- spatcore | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index bccc7d8..5d9367b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,7 +18,7 @@ Binary: `build/XOA_artefacts//XOA.exe`. After a fresh clone run ## Repo shape - `Source/` — the XOA application (JUCE gui app). App layer only. -- `spatcore/` — **submodule**, pinned to **ac45f0f** (post-v0.1.1: GPU +- `spatcore/` — **submodule**, pinned to **7d293e4** (post-v0.1.1: GPU node-parallel SDN, Max-port FR diffusion, MCP protocol negotiation, the shared EQ, the `spatcore::io` device layer (`io/`, target `spatcore-io`), and the shared patch matrix (`ui/patch/`)). rt/dsp/wfs/reverb/gpu + diff --git a/Documentation/XOA-PLAN.md b/Documentation/XOA-PLAN.md index 89d0e15..dec8baa 100644 --- a/Documentation/XOA-PLAN.md +++ b/Documentation/XOA-PLAN.md @@ -78,7 +78,7 @@ every experiment. ## 4. Roadmap - **P0 — Bootstrap (this repo state).** Submodules (JUCE 9.0.0 @f8f88641, - spatcore @ac45f0f post-v0.1.1, hidapi @0.15.0), vendored juce_simpleweb + + spatcore @7d293e4 post-v0.1.1, hidapi @0.15.0), vendored juce_simpleweb + roli_blocks_basics, CMake build (via spatcore's SpatcoreConsumer.cmake helper) of a minimal JUCE app linking spatcore-audio/-control/-controllers, GPLv3, CI build sanity green on the three OSes. diff --git a/spatcore b/spatcore index ac45f0f..7d293e4 160000 --- a/spatcore +++ b/spatcore @@ -1 +1 @@ -Subproject commit ac45f0fbbbf6810d95112f083b018b0e8406db75 +Subproject commit 7d293e4e3105fa847c62e2c2bddf51fac0d6cd9d From ad243b96eb068c71ee5cc7e4c818bd8f60bc7407 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Boulant Date: Sun, 2 Aug 2026 20:10:12 +0200 Subject: [PATCH 07/12] feat(schema): per-input stem formats and the AudioPatch section (D43-D45) Stage-2 groundwork: an input stem is no longer implicitly mono. - inputFormat (0 = mono point source, 1..10 = AmbiX group of that order, (N+1)^2 stem channels) on each input's Channel node, with span helpers (getInputChannelCount / getStemChannelOffset / getTotalStemChannels) and a kMaxStemChannels = 128 total ceiling enforced by stepping formats down, last HOA input first (D45). - AudioPatch section: InputPatch (one flattened row per stem CHANNEL, D43) and OutputPatch (one row per speaker), property names shared with the spatcore patch matrix (patchData/rows/cols/activeHardware*). Identity-diagonal defaults; reconcileAudioPatch() remaps input rows by per-input block across format/count changes and pads new rows with the identity continuation, so a project that never opens the patch window keeps behaving exactly identity-mapped. recomputePatchCols applies the WFS-DIY column policy; updateHardwareChannelCount feeds the truthful DeviceHost counts. Patch edits are non-undoable, as in WFS-DIY. - Persistence: audiopatch.xml joins the section files (save/load/backup/ merge), with a post-load reconcile. - InputsTab: Format combo; position/conditioning/spread/NFC grey out for HOA groups (inert there, D44). Descriptor + registry entries keep the XoaUiDescriptorTests coverage gate green. Engine consumption of formats and patches lands in the next commit. Co-Authored-By: Claude Fable 5 --- Resources/lang/en.json | 7 + Source/GUI/Binding/TabParameterRegistry.h | 1 + Source/GUI/Binding/UiParameterDescriptors.h | 5 + Source/GUI/Tabs/InputsTab.cpp | 25 ++ Source/GUI/Tabs/InputsTab.h | 1 + Source/Parameters/XoaConstraints.h | 1 + Source/Parameters/XoaFileManager.cpp | 58 ++-- Source/Parameters/XoaFileManager.h | 8 +- Source/Parameters/XoaParameterDefaults.h | 2 + Source/Parameters/XoaParameterIDs.h | 34 ++- Source/Parameters/XoaValueTreeState.cpp | 316 +++++++++++++++++++- Source/Parameters/XoaValueTreeState.h | 57 ++++ Source/XoaConstants.h | 6 + 13 files changed, 489 insertions(+), 32 deletions(-) diff --git a/Resources/lang/en.json b/Resources/lang/en.json index a8370e4..6e0c3a4 100644 --- a/Resources/lang/en.json +++ b/Resources/lang/en.json @@ -179,6 +179,7 @@ "inputName": "Name", "inputGain": "Gain", "inputMute": "Mute", + "inputFormat": "Format", "inputPositionX": "X", "inputPositionY": "Y", "inputPositionZ": "Z", @@ -213,6 +214,12 @@ "convention": { "sn3d": "SN3D", "n3d": "N3D", "fuma": "FuMa" }, "distanceComp": { "off": "Off", "delay": "Delay", "delayGain": "Delay + Gain" }, "coordMode": { "cartesian": "Cartesian", "cylindrical": "Cylindrical", "spherical": "Spherical" }, + "inputFormat": { + "mono": "Mono", "hoa1": "HOA 1 (4 ch)", "hoa2": "HOA 2 (9 ch)", "hoa3": "HOA 3 (16 ch)", + "hoa4": "HOA 4 (25 ch)", "hoa5": "HOA 5 (36 ch)", "hoa6": "HOA 6 (49 ch)", + "hoa7": "HOA 7 (64 ch)", "hoa8": "HOA 8 (81 ch)", "hoa9": "HOA 9 (100 ch)", + "hoa10": "HOA 10 (121 ch)" + }, "eqShape": { "off": "Off", "lowCut": "Low cut", "lowShelf": "Low shelf", "peak": "Peak", "bandPass": "Band pass", "highShelf": "High shelf", "highCut": "High cut", "allPass": "All pass" diff --git a/Source/GUI/Binding/TabParameterRegistry.h b/Source/GUI/Binding/TabParameterRegistry.h index 11974e8..09ba585 100644 --- a/Source/GUI/Binding/TabParameterRegistry.h +++ b/Source/GUI/Binding/TabParameterRegistry.h @@ -85,6 +85,7 @@ inline const std::vector& registryRows() { Surface::inputs, i::inputPositionX }, { Surface::inputs, i::inputPositionY }, { Surface::inputs, i::inputPositionZ }, + { Surface::inputs, i::inputFormat }, { Surface::inputs, i::inputCoordinateMode }, { Surface::inputs, i::inputMaxSpeed }, { Surface::inputs, i::inputTrackingSmooth }, diff --git a/Source/GUI/Binding/UiParameterDescriptors.h b/Source/GUI/Binding/UiParameterDescriptors.h index 1f7019c..bef003f 100644 --- a/Source/GUI/Binding/UiParameterDescriptors.h +++ b/Source/GUI/Binding/UiParameterDescriptors.h @@ -92,6 +92,11 @@ inline const std::vector& allDescriptors() { i::inputName, "param.inputName", "", 0.0, Kind::text, false, true, Domain::inputs, {} }, { i::inputGain, "param.inputGain", "units.db", 0.1, Kind::slider, false, true, Domain::inputs, {} }, { i::inputMute, "param.inputMute", "", 0.0, Kind::toggle, false, true, Domain::inputs, {} }, + { i::inputFormat, "param.inputFormat", "", 1.0, Kind::combo, false, true, Domain::inputs, + { "enum.inputFormat.mono", "enum.inputFormat.hoa1", "enum.inputFormat.hoa2", + "enum.inputFormat.hoa3", "enum.inputFormat.hoa4", "enum.inputFormat.hoa5", + "enum.inputFormat.hoa6", "enum.inputFormat.hoa7", "enum.inputFormat.hoa8", + "enum.inputFormat.hoa9", "enum.inputFormat.hoa10" } }, { i::inputPositionX, "param.inputPositionX", "units.meters", 0.01, Kind::slider, false, true, Domain::inputs, {} }, { i::inputPositionY, "param.inputPositionY", "units.meters", 0.01, Kind::slider, false, true, Domain::inputs, {} }, { i::inputPositionZ, "param.inputPositionZ", "units.meters", 0.01, Kind::slider, false, true, Domain::inputs, {} }, diff --git a/Source/GUI/Tabs/InputsTab.cpp b/Source/GUI/Tabs/InputsTab.cpp index 8e08d60..5535ee9 100644 --- a/Source/GUI/Tabs/InputsTab.cpp +++ b/Source/GUI/Tabs/InputsTab.cpp @@ -83,6 +83,12 @@ InputsTab::InputsTab (AppContext& ctx) : TabPage (ctx, Surface::inputs) addRow (muteButton, "param.inputMute"); bindings.bindToggle (muteButton, ids::inputMute, BindingSet::kCurrentChannel); + // Stem format (D44). Position/spread/NFC do not apply to HOA groups — + // selectInput() greys them accordingly. + addAndMakeVisible (formatCombo); + addRow (formatCombo, "param.inputFormat"); + bindings.bindCombo (formatCombo, ids::inputFormat, BindingSet::kCurrentChannel); + for (auto* s : { &posXSlider, &posYSlider, &posZSlider }) { s->setTrackColours (ColorScheme::get().sliderTrackBg, ColorScheme::accents::spatial); @@ -191,6 +197,24 @@ void InputsTab::refresh() context.store.getFloatParameter (ids::inputPositionZ, currentInput) }; const auto mode = (xoa::coords::Mode) context.store.getIntParameter (ids::inputCoordinateMode, currentInput); posReadout.setText (xoa::coords::formatForDisplay (c, mode), juce::dontSendNotification); + + // Point-source controls are inert for HOA group stems (D44): the group is + // merged into the bus as-is, so position/conditioning/spread/NFC have no + // effect on it. Greyed rather than hidden so the layout holds. + const bool isPointSource = context.store.getInputFormat (currentInput) == 0; + for (juce::Component* comp : { static_cast (&posXSlider), + static_cast (&posYSlider), + static_cast (&posZSlider), + static_cast (&posXEditor), + static_cast (&posYEditor), + static_cast (&posZEditor), + static_cast (&coordModeCombo), + static_cast (&posReadout), + static_cast (&maxSpeedDial), + static_cast (&trackingSmoothDial), + static_cast (&spreadDial), + static_cast (&nfcButton) }) + comp->setEnabled (isPointSource); } void InputsTab::resized() @@ -252,6 +276,7 @@ void InputsTab::resized() row (nameEditor); sliderRow (gainSlider, gainEditor); buttonRow (muteButton); + row (formatCombo); sliderRow (posXSlider, posXEditor); sliderRow (posYSlider, posYEditor); sliderRow (posZSlider, posZEditor); diff --git a/Source/GUI/Tabs/InputsTab.h b/Source/GUI/Tabs/InputsTab.h index dc1ae8d..4a29ac3 100644 --- a/Source/GUI/Tabs/InputsTab.h +++ b/Source/GUI/Tabs/InputsTab.h @@ -57,6 +57,7 @@ class InputsTab : public TabPage, XoaStandardSlider gainSlider; juce::TextEditor gainEditor; juce::TextButton muteButton { "Mute" }; + juce::ComboBox formatCombo; // Mono / HOA order 1-10 (D44) XoaBidirectionalSlider posXSlider, posYSlider, posZSlider; juce::TextEditor posXEditor, posYEditor, posZEditor; juce::ComboBox coordModeCombo; diff --git a/Source/Parameters/XoaConstraints.h b/Source/Parameters/XoaConstraints.h index 663f2fb..3f6a651 100644 --- a/Source/Parameters/XoaConstraints.h +++ b/Source/Parameters/XoaConstraints.h @@ -68,6 +68,7 @@ inline const std::vector>& allBounds() { ids::inputPositionZ, { d::positionMin, d::positionMax, d::inputPositionZDefault, false } }, { ids::inputCoordinateMode, { d::coordinateModeMin, d::coordinateModeMax, d::coordinateModeDefault, true } }, { ids::inputSpread, { d::inputSpreadMin, d::inputSpreadMax, d::inputSpreadDefault, false } }, + { ids::inputFormat, { d::inputFormatMin, d::inputFormatMax, d::inputFormatDefault, true } }, { ids::inputMaxSpeed, { d::inputMaxSpeedMin, d::inputMaxSpeedMax, d::inputMaxSpeedDefault, false } }, { ids::inputTrackingSmooth, { d::inputTrackingSmoothMin, d::inputTrackingSmoothMax, d::inputTrackingSmoothDefault, false } }, diff --git a/Source/Parameters/XoaFileManager.cpp b/Source/Parameters/XoaFileManager.cpp index 8e7ec98..5196e88 100644 --- a/Source/Parameters/XoaFileManager.cpp +++ b/Source/Parameters/XoaFileManager.cpp @@ -47,10 +47,11 @@ const char* XoaFileManager::sectionFileStem (Section s) { switch (s) { - case Section::config: return "config"; - case Section::inputs: return "inputs"; - case Section::speakers: return "speakers"; - case Section::decoder: return "decoder"; + case Section::config: return "config"; + case Section::inputs: return "inputs"; + case Section::speakers: return "speakers"; + case Section::decoder: return "decoder"; + case Section::audioPatch: return "audiopatch"; } return ""; } @@ -59,10 +60,11 @@ juce::Identifier XoaFileManager::fileRootType (Section s) { switch (s) { - case Section::config: return ids::configFileRoot; - case Section::inputs: return ids::inputsFileRoot; - case Section::speakers: return ids::speakersFileRoot; - case Section::decoder: return ids::decoderFileRoot; + case Section::config: return ids::configFileRoot; + case Section::inputs: return ids::inputsFileRoot; + case Section::speakers: return ids::speakersFileRoot; + case Section::decoder: return ids::decoderFileRoot; + case Section::audioPatch: return ids::audioPatchFileRoot; } return {}; } @@ -71,10 +73,11 @@ juce::Identifier XoaFileManager::sectionNodeType (Section s) { switch (s) { - case Section::config: return ids::config; - case Section::inputs: return ids::inputs; - case Section::speakers: return ids::speakers; - case Section::decoder: return ids::decoder; + case Section::config: return ids::config; + case Section::inputs: return ids::inputs; + case Section::speakers: return ids::speakers; + case Section::decoder: return ids::decoder; + case Section::audioPatch: return ids::audioPatch; } return {}; } @@ -83,10 +86,11 @@ juce::ValueTree XoaFileManager::liveSection (Section s) const { switch (s) { - case Section::config: return state.getConfigSection(); - case Section::inputs: return state.getInputsSection(); - case Section::speakers: return state.getSpeakersSection(); - case Section::decoder: return state.getDecoderSection(); + case Section::config: return state.getConfigSection(); + case Section::inputs: return state.getInputsSection(); + case Section::speakers: return state.getSpeakersSection(); + case Section::decoder: return state.getDecoderSection(); + case Section::audioPatch: return state.getAudioPatchSection(); } return {}; } @@ -95,10 +99,11 @@ int XoaFileManager::undoDomainFor (Section s) const { switch (s) { - case Section::config: return XoaValueTreeState::configDomain; - case Section::inputs: return XoaValueTreeState::inputsDomain; - case Section::speakers: return XoaValueTreeState::speakersDomain; - case Section::decoder: return XoaValueTreeState::decoderDomain; + case Section::config: return XoaValueTreeState::configDomain; + case Section::inputs: return XoaValueTreeState::inputsDomain; + case Section::speakers: return XoaValueTreeState::speakersDomain; + case Section::decoder: return XoaValueTreeState::decoderDomain; + case Section::audioPatch: return XoaValueTreeState::configDomain; // patch edits are non-undoable; merges ride the config domain } return XoaValueTreeState::configDomain; } @@ -163,7 +168,8 @@ bool XoaFileManager::loadProject (const juce::File& manifestOrFolder) setProjectFolder (folder); bool allOk = true; - for (auto s : { Section::config, Section::inputs, Section::speakers, Section::decoder }) + for (auto s : { Section::config, Section::inputs, Section::speakers, + Section::decoder, Section::audioPatch }) { const auto file = fileForSection (s); if (file.existsAsFile()) @@ -181,7 +187,8 @@ bool XoaFileManager::saveProject() return false; bool allOk = true; - for (auto s : { Section::config, Section::inputs, Section::speakers, Section::decoder }) + for (auto s : { Section::config, Section::inputs, Section::speakers, + Section::decoder, Section::audioPatch }) allOk = saveSection (s) && allOk; return allOk; } @@ -227,7 +234,7 @@ bool XoaFileManager::saveSectionTo (Section s, const juce::File& file, bool with if (withBackup) XmlPersistence::cleanupBackups (backupFolder(), - { "config", "inputs", "speakers", "decoder" }, + { "config", "inputs", "speakers", "decoder", "audiopatch" }, kBackupKeepCount); return true; } @@ -307,6 +314,11 @@ bool XoaFileManager::loadSectionFrom (Section s, const juce::File& file) else if (s == Section::speakers) state.reconcileChannelSection (false, state.getUndoManagerForDomain (domain)); + // Any load can invalidate the patch row/span agreement: an inputs file + // changes formats/counts, an audiopatch file arrives with foreign rows. + if (s == Section::inputs || s == Section::speakers || s == Section::audioPatch) + state.reconcileAudioPatch(); + return true; } diff --git a/Source/Parameters/XoaFileManager.h b/Source/Parameters/XoaFileManager.h index 6b49201..e0d820d 100644 --- a/Source/Parameters/XoaFileManager.h +++ b/Source/Parameters/XoaFileManager.h @@ -30,7 +30,7 @@ class XoaFileManager public: explicit XoaFileManager (XoaValueTreeState& stateToManage); - enum class Section { config, inputs, speakers, decoder }; + enum class Section { config, inputs, speakers, decoder, audioPatch }; static constexpr int kBackupKeepCount = 10; static constexpr const char* kManifestExtension = ".xoa"; @@ -82,6 +82,12 @@ class XoaFileManager bool exportDecoder (const juce::File& f) { return saveSectionTo (Section::decoder, f, false); } bool importDecoder (const juce::File& f) { return loadSectionFrom (Section::decoder, f); } + bool saveAudioPatch() { return saveSection (Section::audioPatch); } + bool loadAudioPatch() { return loadSection (Section::audioPatch); } + bool loadAudioPatchBackup (int index) { return loadSectionBackup (Section::audioPatch, index); } + bool exportAudioPatch (const juce::File& f) { return saveSectionTo (Section::audioPatch, f, false); } + bool importAudioPatch (const juce::File& f) { return loadSectionFrom (Section::audioPatch, f); } + //========================================================================== // WFS-DIY speaker-layout import (FR-16) //========================================================================== diff --git a/Source/Parameters/XoaParameterDefaults.h b/Source/Parameters/XoaParameterDefaults.h index 1b5478c..ef13bcc 100644 --- a/Source/Parameters/XoaParameterDefaults.h +++ b/Source/Parameters/XoaParameterDefaults.h @@ -81,6 +81,8 @@ constexpr double inputPositionXDefault = 1.0, inputPositionYDefault = 0.0, constexpr double positionMin = -100.0, positionMax = 100.0; // meters constexpr double coordinateModeDefault = 0.0, coordinateModeMin = 0.0, coordinateModeMax = 2.0; constexpr double inputSpreadDefault = 0.0, inputSpreadMin = 0.0, inputSpreadMax = 180.0; // degrees +// Stem format: 0 = mono, 1..10 = AmbiX group of that order ((N+1)^2 channels). +constexpr double inputFormatDefault = 0.0, inputFormatMin = 0.0, inputFormatMax = 10.0; constexpr bool inputNfcEnabledDefault = false; // Position conditioning (WP8). maxSpeed 0 = off; the limiter's own clamp is // [0.01, 20] m/s. trackingSmooth is the 1-Euro percentage (0 = raw, 100 = max). diff --git a/Source/Parameters/XoaParameterIDs.h b/Source/Parameters/XoaParameterIDs.h index 9c8eec4..842f20f 100644 --- a/Source/Parameters/XoaParameterIDs.h +++ b/Source/Parameters/XoaParameterIDs.h @@ -34,12 +34,20 @@ inline const juce::Identifier encoder { "Encoder" }; inline const juce::Identifier eq { "EQ" }; inline const juce::Identifier band { "Band" }; +// Audio patch section (stage 2 of the io/patch handoff). Property names match +// WFS-DIY / spatcore::ui::patch::PatchMatrixIds verbatim so the shared matrix +// binds without id overrides and a WFS-DIY patch file reads familiarly. +inline const juce::Identifier audioPatch { "AudioPatch" }; +inline const juce::Identifier inputPatch { "InputPatch" }; +inline const juce::Identifier outputPatch { "OutputPatch" }; + // Section-file / manifest root node types -inline const juce::Identifier configFileRoot { "XOAConfig" }; -inline const juce::Identifier inputsFileRoot { "XOAInputs" }; -inline const juce::Identifier speakersFileRoot { "XOASpeakers" }; -inline const juce::Identifier decoderFileRoot { "XOADecoder" }; -inline const juce::Identifier projectManifest { "XOAProject" }; +inline const juce::Identifier configFileRoot { "XOAConfig" }; +inline const juce::Identifier inputsFileRoot { "XOAInputs" }; +inline const juce::Identifier speakersFileRoot { "XOASpeakers" }; +inline const juce::Identifier decoderFileRoot { "XOADecoder" }; +inline const juce::Identifier audioPatchFileRoot { "XOAAudioPatch" }; +inline const juce::Identifier projectManifest { "XOAProject" }; // Bookkeeping properties inline const juce::Identifier idProp { "id" }; @@ -97,6 +105,12 @@ inline const juce::Identifier monoInputsEnabled { "monoInputsEnabled" }; inline const juce::Identifier inputName { "inputName" }; inline const juce::Identifier inputGain { "inputGain" }; inline const juce::Identifier inputMute { "inputMute" }; +// Stem format: 0 = mono point source (the encoder path), 1..10 = an AmbiX +// (ACN/SN3D) Ambisonics group of order N spanning (N+1)^2 stem channels, +// merged into the bus with order-adapt gains. Position/spread/NFC are inert +// for group formats. Clusters (linking stems) are a later feature; nothing +// may assume a group's hardware channels are contiguous. +inline const juce::Identifier inputFormat { "inputFormat" }; // Input / Position (canonical cartesian meters; mode is display-only) inline const juce::Identifier inputPositionX { "inputPositionX" }; @@ -133,6 +147,16 @@ inline const juce::Identifier eqGain { "eqGain" }; inline const juce::Identifier eqQ { "eqQ" }; inline const juce::Identifier eqSlope { "eqSlope" }; +// AudioPatch / {Input,Output}Patch node properties. These are DIRECT tree +// properties (the shared matrix and the store write them with explicit +// ValueTree access), not (id, channelIndex)-addressable parameters — keep +// them out of the OSC/descriptor surfaces. +inline const juce::Identifier patchData { "patchData" }; +inline const juce::Identifier rows { "rows" }; +inline const juce::Identifier cols { "cols" }; +inline const juce::Identifier activeHardwareInputs { "activeHardwareInputs" }; +inline const juce::Identifier activeHardwareOutputs { "activeHardwareOutputs" }; + // Decoder inline const juce::Identifier decoderType { "decoderType" }; inline const juce::Identifier decoderWeighting { "decoderWeighting" }; diff --git a/Source/Parameters/XoaValueTreeState.cpp b/Source/Parameters/XoaValueTreeState.cpp index 6404c48..00277e0 100644 --- a/Source/Parameters/XoaValueTreeState.cpp +++ b/Source/Parameters/XoaValueTreeState.cpp @@ -91,6 +91,15 @@ void XoaValueTreeState::initializeDefaultState() state.appendChild (decoder, nullptr); state.appendChild (juce::ValueTree (ids::monitoring), nullptr); + + // Audio patch (stage 2): identity-diagonal defaults so a fresh project + // behaves exactly like the pre-patch identity mapping. + juce::ValueTree audioPatch (ids::audioPatch); + audioPatch.appendChild (createDefaultPatchTree (true, kDefaultInputs), nullptr); + audioPatch.appendChild (createDefaultPatchTree (false, kDefaultSpeakers), nullptr); + state.appendChild (audioPatch, nullptr); + + lastReconciledSpans.assign ((size_t) kDefaultInputs, 1); } juce::ValueTree XoaValueTreeState::createDefaultInput (int index) const @@ -102,6 +111,7 @@ juce::ValueTree XoaValueTreeState::createDefaultInput (int index) const channel.setProperty (ids::inputName, d::getDefaultInputName (index), nullptr); channel.setProperty (ids::inputGain, d::inputGainDefault, nullptr); channel.setProperty (ids::inputMute, d::inputMuteDefault, nullptr); + channel.setProperty (ids::inputFormat, static_cast (d::inputFormatDefault), nullptr); input.appendChild (channel, nullptr); juce::ValueTree position (ids::position); @@ -236,9 +246,12 @@ int XoaValueTreeState::resolveChannelIndex (const juce::ValueTree& changedNode) void XoaValueTreeState::handlePostWrite (juce::ValueTree& node, const juce::Identifier& property, const juce::var& value, int channelIndex) { - // No cross-parameter invariants in v1; the hook exists for the WP9 C5 - // feedback observer, which reads the current OriginTag to decide whether - // to echo the change out over OSC. + // Stage-2 invariant: a format change re-spans the stem channels, so the + // total ceiling must be re-enforced and the input patch rows remapped. + // reconcileAudioPatch self-guards against its own writes re-entering. + if (property == ids::inputFormat) + reconcileAudioPatch(); + if (postWriteObserver) postWriteObserver (node, property, value, channelIndex); } @@ -310,6 +323,12 @@ void XoaValueTreeState::applyChannelCount (juce::ValueTree section, const juce:: undoManager); writeProperty (section, countId, targetCount, undoManager); + + // A count change re-spans the patch rows (and, for inputs, may push the + // stem-channel total over its ceiling). Patch maintenance is deliberately + // outside undo, so an undo of the count change relies on the next + // reconcile rather than replay. + reconcileAudioPatch(); } int XoaValueTreeState::getNumInputs() const @@ -405,4 +424,295 @@ juce::ValueTree XoaValueTreeState::getSpeakerTree (int channelIndex) const : juce::ValueTree(); } +//============================================================================== +// Stem formats and spans (D44/D45) +//============================================================================== + +int XoaValueTreeState::channelCountForFormat (int format) noexcept +{ + const int order = juce::jlimit (0, kAmbisonicOrder, format); + return order <= 0 ? 1 : (order + 1) * (order + 1); +} + +int XoaValueTreeState::getInputFormat (int inputIndex) const +{ + return juce::jlimit (0, kAmbisonicOrder, getIntParameter (ids::inputFormat, inputIndex)); +} + +int XoaValueTreeState::getInputChannelCount (int inputIndex) const +{ + return channelCountForFormat (getInputFormat (inputIndex)); +} + +int XoaValueTreeState::getStemChannelOffset (int inputIndex) const +{ + int offset = 0; + const int n = juce::jmin (inputIndex, getNumInputs()); + for (int i = 0; i < n; ++i) + offset += getInputChannelCount (i); + return offset; +} + +int XoaValueTreeState::getTotalStemChannels() const +{ + return getStemChannelOffset (getNumInputs()); +} + +std::vector XoaValueTreeState::currentInputSpans() const +{ + std::vector spans ((size_t) getNumInputs()); + for (size_t i = 0; i < spans.size(); ++i) + spans[i] = getInputChannelCount ((int) i); + return spans; +} + +void XoaValueTreeState::clampStemSpans() +{ + auto spans = currentInputSpans(); + int total = 0; + for (const int s : spans) + total += s; + if (total <= kMaxStemChannels) + return; + + // Over the ceiling: step formats down, last HOA input first, one order at + // a time, until the sum fits. Mono-only always fits (kMaxInputs mono + // channels < kMaxStemChannels), so this terminates. + for (int i = (int) spans.size() - 1; i >= 0 && total > kMaxStemChannels; --i) + { + int format = getInputFormat (i); + while (format > 0 && total > kMaxStemChannels) + { + --format; + const int newSpan = channelCountForFormat (format); + total -= spans[(size_t) i] - newSpan; + spans[(size_t) i] = newSpan; + } + + if (format != getInputFormat (i)) + if (auto tree = getTreeForParameter (ids::inputFormat, i); tree.isValid()) + writeProperty (tree, ids::inputFormat, format, nullptr); + } +} + +//============================================================================== +// Audio patch (D41-D43) +//============================================================================== + +juce::ValueTree XoaValueTreeState::getAudioPatchSection() const { return state.getChildWithName (ids::audioPatch); } +juce::ValueTree XoaValueTreeState::getInputPatchTree() const { return getAudioPatchSection().getChildWithName (ids::inputPatch); } +juce::ValueTree XoaValueTreeState::getOutputPatchTree() const { return getAudioPatchSection().getChildWithName (ids::outputPatch); } + +namespace +{ + // A patchData row of `cols` zeros ("0,0,...") — the matrix's own row + // format; empty-string rows are avoided so token alignment can never + // depend on how a tokenizer treats empties. + juce::String zeroPatchRow (int cols) + { + juce::StringArray cells; + cells.ensureStorageAllocated (cols); + for (int c = 0; c < cols; ++c) + cells.add ("0"); + return cells.joinIntoString (","); + } + + juce::String patchRowWithBit (int cols, int bit) + { + juce::StringArray cells; + cells.ensureStorageAllocated (cols); + for (int c = 0; c < cols; ++c) + cells.add (c == bit ? "1" : "0"); + return cells.joinIntoString (","); + } + + /** Hardware column of the row's '1' (1:1 patches), or -1 when unpatched. */ + int patchedColumnOfRow (const juce::String& row) + { + const auto cells = juce::StringArray::fromTokens (row, ",", ""); + for (int c = 0; c < cells.size(); ++c) + if (cells[c].getIntValue() == 1) + return c; + return -1; + } + + /** Highest hardware column carrying a '1' anywhere in patchData, or -1. */ + int highestPatchedColumn (const juce::String& patchData) + { + int highest = -1; + const auto rows = juce::StringArray::fromTokens (patchData, ";", ""); + for (const auto& row : rows) + { + const auto cells = juce::StringArray::fromTokens (row, ",", ""); + for (int c = cells.size(); --c > highest;) + if (cells[c].getIntValue() == 1) + { highest = c; break; } + } + return highest; + } + + /** Fill placeholder rows with the identity-diagonal continuation: a new + row at flattened index k gets hardware column k when free — so a + project that never opens the patch window keeps behaving identity- + mapped as channels grow. Occupied or out-of-range diagonals stay + unpatched. `rowsArr` placeholders are empty strings on entry. */ + void fillNewRowsWithIdentity (juce::StringArray& rowsArr, int cols) + { + std::vector used ((size_t) xoa::kMaxHardwareChannels, false); + for (const auto& row : rowsArr) + if (const int c = patchedColumnOfRow (row); c >= 0 && c < xoa::kMaxHardwareChannels) + used[(size_t) c] = true; + + for (int r = 0; r < rowsArr.size(); ++r) + { + if (rowsArr[r].isNotEmpty()) + continue; + if (r < xoa::kMaxHardwareChannels && ! used[(size_t) r]) + { + used[(size_t) r] = true; + rowsArr.set (r, patchRowWithBit (juce::jmax (cols, r + 1), r)); + } + else + { + rowsArr.set (r, zeroPatchRow (cols)); + } + } + } +} // namespace + +juce::String XoaValueTreeState::buildIdentityPatchData (int numRows) +{ + const int cols = juce::jlimit (1, kMaxHardwareChannels, juce::jmax (64, numRows)); + juce::StringArray rowStrings; + for (int r = 0; r < numRows; ++r) + rowStrings.add (patchRowWithBit (cols, r)); + return rowStrings.joinIntoString (";"); +} + +juce::ValueTree XoaValueTreeState::createDefaultPatchTree (bool isInput, int numRows) const +{ + juce::ValueTree tree (isInput ? ids::inputPatch : ids::outputPatch); + tree.setProperty (ids::rows, numRows, nullptr); + tree.setProperty (ids::cols, juce::jlimit (1, kMaxHardwareChannels, juce::jmax (64, numRows)), nullptr); + tree.setProperty (isInput ? ids::activeHardwareInputs : ids::activeHardwareOutputs, 0, nullptr); + tree.setProperty (ids::patchData, buildIdentityPatchData (numRows), nullptr); + return tree; +} + +void XoaValueTreeState::recomputePatchCols() +{ + auto applyPolicy = [] (juce::ValueTree tree, const juce::Identifier& activeId) + { + if (! tree.isValid()) + return; + const int active = (int) tree.getProperty (activeId, 0); + const int highest = highestPatchedColumn (tree.getProperty (ids::patchData).toString()); + const int cols = juce::jlimit (1, kMaxHardwareChannels, + juce::jmax (64, juce::jmax (active, highest + 1))); + if ((int) tree.getProperty (ids::cols, 0) != cols) + tree.setProperty (ids::cols, cols, nullptr); + }; + + applyPolicy (getInputPatchTree(), ids::activeHardwareInputs); + applyPolicy (getOutputPatchTree(), ids::activeHardwareOutputs); +} + +void XoaValueTreeState::updateHardwareChannelCount (int activeInputs, int activeOutputs) +{ + if (auto tree = getInputPatchTree(); tree.isValid()) + if ((int) tree.getProperty (ids::activeHardwareInputs, -1) != activeInputs) + tree.setProperty (ids::activeHardwareInputs, activeInputs, nullptr); + + if (auto tree = getOutputPatchTree(); tree.isValid()) + if ((int) tree.getProperty (ids::activeHardwareOutputs, -1) != activeOutputs) + tree.setProperty (ids::activeHardwareOutputs, activeOutputs, nullptr); + + recomputePatchCols(); +} + +void XoaValueTreeState::reconcileAudioPatch() +{ + if (reconcilingPatch) + return; + const juce::ScopedValueSetter guard (reconcilingPatch, true); + + // Formats first: the row remap below must see post-clamp spans. + clampStemSpans(); + + auto section = getAudioPatchSection(); + const auto spans = currentInputSpans(); + + if (! section.isValid()) + { + lastReconciledSpans = spans; + return; + } + + // INPUT side: remap the flattened rows by per-input block so a format + // change keeps every other input's patches, and the changed input keeps + // the rows the old and new spans share. + if (auto tree = getInputPatchTree(); tree.isValid()) + { + const int cols = juce::jmax (1, (int) tree.getProperty (ids::cols, 64)); + const auto oldRows = juce::StringArray::fromTokens (tree.getProperty (ids::patchData).toString(), ";", ""); + + // The span cache says which old rows belonged to which input. It is + // trustworthy only when it accounts for exactly the rows present; + // otherwise (first run, foreign file) fall back to one-row-per-input + // prefix preservation. + auto oldSpans = lastReconciledSpans; + int cachedTotal = 0; + for (const int s : oldSpans) + cachedTotal += s; + if (cachedTotal != oldRows.size()) + oldSpans.assign ((size_t) oldRows.size(), 1); + + // Kept rows are copied per input block; rows an input gained are left + // as empty placeholders for the identity fill below. + juce::StringArray newRows; + int oldStart = 0; + for (size_t i = 0; i < spans.size(); ++i) + { + const int oldSpan = i < oldSpans.size() ? oldSpans[i] : 0; + const int keep = juce::jmin (oldSpan, spans[i]); + for (int r = 0; r < spans[i]; ++r) + newRows.add (r < keep && oldStart + r < oldRows.size() ? oldRows[oldStart + r] + : juce::String()); + oldStart += oldSpan; + } + fillNewRowsWithIdentity (newRows, cols); + + const juce::String newData = newRows.joinIntoString (";"); + if ((int) tree.getProperty (ids::rows, -1) != newRows.size()) + tree.setProperty (ids::rows, newRows.size(), nullptr); + if (tree.getProperty (ids::patchData).toString() != newData) + tree.setProperty (ids::patchData, newData, nullptr); + } + + // OUTPUT side: one row per speaker — truncate, pad with the identity + // continuation (a grown speaker keeps sounding on its ordinal output + // when that column is free, matching pre-patch behaviour). + if (auto tree = getOutputPatchTree(); tree.isValid()) + { + const int numSpeakers = getNumSpeakers(); + const int cols = juce::jmax (1, (int) tree.getProperty (ids::cols, 64)); + auto rowsArr = juce::StringArray::fromTokens (tree.getProperty (ids::patchData).toString(), ";", ""); + + while (rowsArr.size() > numSpeakers) + rowsArr.remove (rowsArr.size() - 1); + while (rowsArr.size() < numSpeakers) + rowsArr.add (juce::String()); + fillNewRowsWithIdentity (rowsArr, cols); + + const juce::String newData = rowsArr.joinIntoString (";"); + if ((int) tree.getProperty (ids::rows, -1) != numSpeakers) + tree.setProperty (ids::rows, numSpeakers, nullptr); + if (tree.getProperty (ids::patchData).toString() != newData) + tree.setProperty (ids::patchData, newData, nullptr); + } + + recomputePatchCols(); + lastReconciledSpans = spans; +} + } // namespace xoa diff --git a/Source/Parameters/XoaValueTreeState.h b/Source/Parameters/XoaValueTreeState.h index d0dbf76..3e63495 100644 --- a/Source/Parameters/XoaValueTreeState.h +++ b/Source/Parameters/XoaValueTreeState.h @@ -5,6 +5,7 @@ #include "XoaParameterIDs.h" #include +#include //============================================================================== // XOA — the parameter store: schema subclass of spatcore's TreeParameterStore. @@ -95,6 +96,52 @@ class XoaValueTreeState : public spatcore::control::state::TreeParameterStore juce::ValueTree getInputTree (int channelIndex) const; juce::ValueTree getSpeakerTree (int channelIndex) const; + //========================================================================== + // Stem formats and channel spans (stage 2, D44/D45). An input's stem is + // either mono (1 channel) or an AmbiX group of order N ((N+1)^2 channels); + // the flattened span [offset, offset + count) indexes both the input + // patch-matrix rows and the engine's stem scratch rows. The store enforces + // sum(spans) <= kMaxStemChannels by stepping formats down (last HOA input + // first) whenever a format or count write would exceed it. + //========================================================================== + + /** 1 for mono (format 0), (format+1)^2 for an HOA group. */ + static int channelCountForFormat (int format) noexcept; + + int getInputFormat (int inputIndex) const; // 0 = mono, 1..10 = HOA order + int getInputChannelCount (int inputIndex) const; // span of that input + int getStemChannelOffset (int inputIndex) const; // sum of spans before it + int getTotalStemChannels() const; // sum over all inputs + + //========================================================================== + // Audio patch (stage 2, D41-D43). Two direct-access trees under + // AudioPatch, property names shared with the spatcore patch matrix + // (patchData/rows/cols/activeHardware*). Patch edits are NOT undoable — + // the matrix writes with a null undo manager, matching WFS-DIY. + //========================================================================== + + juce::ValueTree getAudioPatchSection() const; + juce::ValueTree getInputPatchTree() const; + juce::ValueTree getOutputPatchTree() const; + + /** Re-run the column-count policy on both patch trees: + cols = clamp(max(64, activeHardware*, highestPatched+1), kMaxHardwareChannels). + Called by the matrix after every patch write (via its recomputeColumns + provider) and after the active-channel counts change. */ + void recomputePatchCols(); + + /** Truthful active-channel counts from the device layer (DeviceHost) — + never from the device's channel-name lists. Feeds the matrices' + overflow gating; also re-runs the column policy. */ + void updateHardwareChannelCount (int activeInputs, int activeOutputs); + + /** Restore patch-tree invariants after anything that changes row + identity: input format/count changes remap the input patch rows by + per-input block (preserving patches through a format change where the + spans overlap), speaker count changes truncate/extend the output patch. + Safe to call at any time; no-op when everything already agrees. */ + void reconcileAudioPatch(); + /** Typed shadow of the base RAII domain switch. */ struct ScopedDomain : ScopedUndoDomain { @@ -134,6 +181,16 @@ class XoaValueTreeState : public spatcore::control::state::TreeParameterStore void applyChannelCount (juce::ValueTree section, const juce::Identifier& countId, int targetCount, juce::UndoManager* undoManager, bool isInputs); + juce::ValueTree createDefaultPatchTree (bool isInput, int numRows) const; + static juce::String buildIdentityPatchData (int numRows); + void clampStemSpans(); + std::vector currentInputSpans() const; + + // Spans as of the last reconcile — what lets a format change remap the + // input-patch rows by per-input block instead of guessing. + std::vector lastReconciledSpans; + bool reconcilingPatch = false; + PostWriteObserver postWriteObserver; JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (XoaValueTreeState) diff --git a/Source/XoaConstants.h b/Source/XoaConstants.h index db4be87..5a0c93d 100644 --- a/Source/XoaConstants.h +++ b/Source/XoaConstants.h @@ -38,6 +38,12 @@ constexpr int kMaxSpeakers = 256; clamped to it (D37). Distinct from kMaxSpeakers: the ceiling bounds what the device may open, the clamp bounds how many speakers the decoder feeds. */ constexpr int kMaxHardwareChannels = 512; + +/** Total stem-CHANNEL ceiling across all inputs (D45). An input of format + Mono occupies 1 stem channel; an HOA-order-N input occupies (N+1)^2, so a + single order-10 group takes 121. The store enforces the sum; sized like + kMaxFileChannels (one full-order group plus headroom). */ +constexpr int kMaxStemChannels = 128; constexpr int kDefaultInputs = 8; constexpr int kDefaultSpeakers = 24; // the M1 24-ring validation fixture From 9a392754f444bcdea6ec95a1207a56a13d26f6b6 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Boulant Date: Sun, 2 Aug 2026 20:20:54 +0200 Subject: [PATCH 08/12] feat(engine): patched routing + multi-format stem merge (D44, D46) The audio path now runs in three explicit domains joined by the patch maps (RtSnapshot'd PatchRtState, composed from the AudioPatch trees on the message thread, identity by default): - GATHER: flattened stem channel k reads hardware input hwForStemChannel[k]; per-input meters take the max across the input's span; new per-hardware-input peaks feed the patch matrix tinting. - BUS: applyEncoder handles both formats per input. Mono keeps the point-source path bit-exactly (SH row broadcast + NFC lanes). An HOA group contributes its own channel c to bus channel c at its liveMatrix factor - now order-adapt x gain composed by AmbiCalculationEngine (zero-padded upmix via weights::orderAdaptGains), with NFC forced off and the same per-coefficient one-block ramps. EncoderRtParams carries the per-input stem offsets/orders. - SCATTER: decode+comp run on a speaker-domain scratch sized by the STORE's speaker count (no longer the device outs), then speaker s is copied to hardware row hwForSpeaker[s]; unfed active outputs are cleared. The test signal is injected post-scatter in the hardware domain (D46) so testing-mode reaches every open output. Per-speaker meters read the speaker's patched hardware row - what actually leaves the device. The $5.1 gather-before-write invariant is now structural (decode never targets io rows), and the identity jassert stays: it guards the DeviceHost contiguous-mask guarantee the hardware indexing rests on. Verified: Release + Debug, ctest (incl. offline-render bit-exactness smoke), --gui-smoke both configs. Co-Authored-By: Claude Fable 5 --- Source/Audio/AudioEngine.cpp | 219 +++++++++++++++++++-------- Source/Audio/AudioEngine.h | 40 ++++- Source/Audio/PatchRouting.h | 99 ++++++++++++ Source/DSP/AmbiBusAlgorithm.h | 62 ++++++-- Source/DSP/AmbiCalculationEngine.cpp | 54 ++++++- Source/DSP/AmbiCalculationEngine.h | 3 + Source/DSP/AmbiRtTypes.h | 16 ++ 7 files changed, 411 insertions(+), 82 deletions(-) create mode 100644 Source/Audio/PatchRouting.h diff --git a/Source/Audio/AudioEngine.cpp b/Source/Audio/AudioEngine.cpp index d75c52c..006e360 100644 --- a/Source/Audio/AudioEngine.cpp +++ b/Source/Audio/AudioEngine.cpp @@ -28,6 +28,7 @@ AudioEngine::AudioEngine (XoaValueTreeState& s) publishRotation(); publishBusParams(); publishSpeakerComp(); + publishPatchRouting(); rebuildDecoderNow(); updateReferenceRadius(); // seed the encoder r_ref from the speaker layout } @@ -76,6 +77,12 @@ void AudioEngine::registerListeners() speakersSection.addListener (this); decoderSection.addListener (this); + // Patch routing follows the AudioPatch trees. Format/count changes reach + // this listener too: the store's reconcile rewrites patchData whenever the + // spans move, so one listener covers every route. + audioPatchSection = store.getAudioPatchSection(); + audioPatchSection.addListener (this); + listenersRegistered = true; } @@ -97,6 +104,7 @@ void AudioEngine::unregisterListeners() speakersSection.removeListener (this); decoderSection.removeListener (this); + audioPatchSection.removeListener (this); listenersRegistered = false; } @@ -196,6 +204,25 @@ void AudioEngine::publishSpeakerComp() speakerCompSnapshot.publish (composeSpeakerCompParams (store, ++speakerCompEpoch)); } +void AudioEngine::publishPatchRouting() +{ + patchSnapshot.publish (rt::composePatchRouting (store, ++patchEpoch)); +} + +int AudioEngine::getHardwareOutputForSpeaker (int speakerIndex) const +{ + if (speakerIndex < 0 || speakerIndex >= store.getNumSpeakers()) + return -1; + + const auto tree = store.getOutputPatchTree(); + if (! tree.isValid()) + return speakerIndex; // no patch section: identity + + const auto rows = juce::StringArray::fromTokens ( + tree.getProperty (ids::patchData).toString(), ";", ""); + return speakerIndex < rows.size() ? rt::patchRowColumn (rows[speakerIndex]) : -1; +} + void AudioEngine::updateSpeakerEq() { // Benign-staleness (D3): push coefficients onto the RT-owned biquads from @@ -217,6 +244,18 @@ void AudioEngine::onSpeakerStructureChanged() // work it actually requires. void AudioEngine::valueTreePropertyChanged (juce::ValueTree&, const juce::Identifier& property) { + // Patch-tree properties (this listener also covers audioPatchSection). + // Routing follows patchData/rows; cols and activeHardware* are + // display-side only. None of them touch the decoder. + if (property == ids::patchData || property == ids::rows) + { + publishPatchRouting(); + return; + } + if (property == ids::cols || property == ids::activeHardwareInputs + || property == ids::activeHardwareOutputs) + return; + // Trim / mute / solo: comp gain only (no decoder rebuild). if (property == ids::speakerGain || property == ids::speakerDelay || property == ids::speakerMute || property == ids::speakerSolo) @@ -350,6 +389,11 @@ void AudioEngine::changeListenerCallback (juce::ChangeBroadcaster*) if (auto err = deviceHost.enableAllChannels(); err.isNotEmpty()) lastDeviceError = err; + // Truthful active counts to the patch trees (overflow gating, §7.1) — + // from DeviceHost, never the device's channel-name lists. + store.updateHardwareChannelCount (deviceHost.getNumActiveInputs(), + deviceHost.getNumActiveOutputs()); + if (auto xml = deviceManager.createStateXml()) store.setParameterWithoutUndo (ids::audioDeviceState, xml->toString()); @@ -378,33 +422,38 @@ void AudioEngine::prepareToPlay (int samplesPerBlockExpected, double sampleRate) measuredLatencyMs.store (sr > 0.0 ? (double) (outLatency + block) / sr * 1000.0 : 0.0, std::memory_order_relaxed); - // Stage-1 indexing contract (D36, option A): the masks are contiguous from - // bit 0 — DeviceHost's enable-all policy writes range(0, N) — so buffer - // row == stem index == speaker ordinal throughout. Assert it so a - // non-contiguous mask (possible once the stage-2 patch window can deselect - // channels) fails loudly here instead of silently misrouting audio. + // The device masks stay contiguous from bit 0 (DeviceHost's enable-all + // policy writes range(0, N) — the patch layer routes INTERNALLY and never + // deselects device channels), so io-buffer row h == hardware channel h. + // Assert the guarantee this whole stage rests on. jassert (ioCallback.getChannelMap().isIdentityMapping()); - // Counts are counts, not index bounds — the two agree only under the - // contiguity asserted above (§5.3). Outputs clamp to the decoder ceiling; - // hardware channels past it stay open but are fed silence. - const int numIn = juce::jmin (deviceHost.getNumActiveInputs(), xoa::kMaxInputs); - const int numOut = juce::jmin (deviceHost.getNumActiveOutputs(), xoa::kMaxSpeakers); + const int numIn = juce::jmin (deviceHost.getNumActiveInputs(), xoa::kMaxHardwareChannels); + const int numOutHw = juce::jmin (deviceHost.getNumActiveOutputs(), xoa::kMaxHardwareChannels); numActiveInputs.store (numIn, std::memory_order_relaxed); - numActiveOutputs.store (numOut, std::memory_order_relaxed); + numActiveOutputs.store (numOutHw, std::memory_order_relaxed); + + // The decode/comp stage runs in the SPEAKER domain and is scattered to + // hardware outputs by the output patch — its channel count is the store's + // speaker count, not the device's output count (D42/D46). + const int numSpk = juce::jmin (store.getNumSpeakers(), xoa::kMaxSpeakers); + numSpeakersPrepared.store (numSpk, std::memory_order_relaxed); inputScratch.setSize (xoa::kMaxFileChannels, block, false, false, true); - stemScratch.setSize (xoa::kMaxInputs, block, false, false, true); + stemScratch.setSize (xoa::kMaxStemChannels, block, false, false, true); + speakerScratch.setSize (xoa::kMaxSpeakers, block, false, false, true); + speakerScratch.clear(); + hwWritten.assign ((size_t) xoa::kMaxHardwareChannels, 0); filePlayer.prepareToPlay (sr, block); - algorithm.prepare (xoa::kNumSHChannels, numOut, sr, block, + algorithm.prepare (xoa::kNumSHChannels, numSpk, sr, block, &decoderBuilder, &rotationSnapshot, &busParamsSnapshot, true, calcEngine.encodeMatrix(), calcEngine.nfcCoeffs(), &calcEngine.encoderSource()); - // Per-speaker comp runs after the decode. Allocate its per-output state for - // the actual device outs, then seed the RT biquads from the current EQ (the - // ms-based comp POD is already published and needs no rebuild for the SR). - speakerComp.prepare (sr, block, numOut, &speakerCompSnapshot); + // Per-speaker comp runs after the decode, still per speaker. Seed the RT + // biquads from the current EQ (the ms-based comp POD is already published + // and needs no rebuild for the SR). + speakerComp.prepare (sr, block, numSpk, &speakerCompSnapshot); updateSpeakerEq(); testSignal.prepare (sr, block); @@ -419,6 +468,9 @@ void AudioEngine::releaseResources() deviceBlockSize.store (0, std::memory_order_relaxed); numActiveInputs.store (0, std::memory_order_relaxed); numActiveOutputs.store (0, std::memory_order_relaxed); + numSpeakersPrepared.store (0, std::memory_order_relaxed); + for (auto& p : hwInputPeak) + p.store (0.0f, std::memory_order_relaxed); } void AudioEngine::getNextAudioBlock (const juce::AudioSourceChannelInfo& info) @@ -431,12 +483,14 @@ void AudioEngine::getNextAudioBlock (const juce::AudioSourceChannelInfo& info) juce::AudioBuffer& ioBuf = *info.buffer; jassert (info.startSample == 0); - const int numIn = numActiveInputs.load (std::memory_order_relaxed); - // Clamp to the buffer width so a non-identity mapping (asserted against in - // prepareToPlay) degrades to short output rather than reading rows that do - // not exist. - const int numOut = juce::jmin (numActiveOutputs.load (std::memory_order_relaxed), - ioBuf.getNumChannels()); + const auto patch = patchSnapshot.acquire(); + + const int numIn = juce::jmin (numActiveInputs.load (std::memory_order_relaxed), + ioBuf.getNumChannels()); + const int numOutHw = juce::jmin (numActiveOutputs.load (std::memory_order_relaxed), + ioBuf.getNumChannels()); + const int numSpk = juce::jmin (numSpeakersPrepared.load (std::memory_order_relaxed), + xoa::kMaxSpeakers); // The input source writes into inputScratch, allocated to the block size // reported at prepareToPlay. DeviceIoCallback already clamps an over-size @@ -445,42 +499,48 @@ void AudioEngine::getNextAudioBlock (const juce::AudioSourceChannelInfo& info) // scratch length (defense-in-depth, no allocation). const int n = juce::jmin (info.numSamples, inputScratch.getNumSamples()); - // Mono-encoder stems (WP8): device inputs (identity-mapped) or the internal - // test feed. Filled whenever a source could exist, so the encoder's one-block - // ramp-out on deactivation still has audio to fade. The RT stage gates on the - // published numSources, so filling here when disabled is harmless. + // Hardware-input meters for the patch matrix tinting — read straight off + // the device rows, BEFORE anything writes. + for (int h = 0; h < xoa::kMaxHardwareChannels; ++h) + hwInputPeak[(size_t) h].store (h < numIn ? ioBuf.getMagnitude (h, 0, n) : 0.0f, + std::memory_order_relaxed); + + // Stem gather (WP8 + D43): flattened stem channel k reads the hardware + // input the input patch routes it from (identity when unpatched at + // startup), or the internal test feed. Filled whenever a source could + // exist, so the encoder's one-block ramp-out on deactivation still has + // audio to fade. The RT stage gates on the published numSources. // // ORDER IS LOAD-BEARING (§5.1): a buffer row that is an active output // aliases the device's own output storage, with the matching input copied - // into that same row before this call. Every input must therefore be read - // BEFORE anything writes an output — moving this gather below the decode - // would feed the encoder its own decoder output on every channel where a - // speaker and a microphone share a hardware index. + // into that same row before this call. Every input must be read BEFORE + // the output scatter below writes any hardware row. const bool testStems = stemFeed.load (std::memory_order_relaxed) == StemFeed::test; const juce::AudioBuffer* stemsPtr = nullptr; int numStems = 0; if (testStems || numIn > 0) { - numStems = juce::jmin (xoa::kMaxInputs, stemScratch.getNumChannels()); + numStems = juce::jmin (patch.numStemChannels, stemScratch.getNumChannels()); if (testStems) { const double sr = deviceSampleRate.load (std::memory_order_relaxed); const double f0 = 2.0 * juce::MathConstants::pi / (sr > 0.0 ? sr : 48000.0); - for (int i = 0; i < numStems; ++i) + for (int k = 0; k < numStems; ++k) { - float* d = stemScratch.getWritePointer (i); - const double w = f0 * (220.0 + 40.0 * i); // distinct tone per input + float* d = stemScratch.getWritePointer (k); + const double w = f0 * (220.0 + 40.0 * k); // distinct tone per stem channel for (int j = 0; j < n; ++j) d[j] = 0.2f * (float) std::sin (w * (double) (sceneCounter + j)); } } else { - for (int i = 0; i < numStems; ++i) + for (int k = 0; k < numStems; ++k) { - float* d = stemScratch.getWritePointer (i); - if (i < numIn && i < ioBuf.getNumChannels()) - juce::FloatVectorOperations::copy (d, ioBuf.getReadPointer (i), n); + float* d = stemScratch.getWritePointer (k); + const int hw = patch.hwForStemChannel[k]; + if (hw >= 0 && hw < numIn) + juce::FloatVectorOperations::copy (d, ioBuf.getReadPointer (hw), n); else juce::FloatVectorOperations::clear (d, n); } @@ -488,20 +548,29 @@ void AudioEngine::getNextAudioBlock (const juce::AudioSourceChannelInfo& info) stemsPtr = &stemScratch; } - // Per-input stem meters (observation-only): the gathered stem magnitudes. + // Per-input stem meters (observation-only): max across the input's span. for (int i = 0; i < xoa::kMaxInputs; ++i) - inputPeak[(size_t) i].store (i < numStems ? stemScratch.getMagnitude (i, 0, n) : 0.0f, - std::memory_order_relaxed); - - // Everything downstream sees a speaker-width view of the io buffer (rows - // 0..numOut-1, valid under the identity mapping asserted in prepareToPlay) - // so the decode, comp, test signal and meters address speakers exactly as - // they always have — input-only rows above numOut are never touched. - juce::AudioBuffer outBuf (ioBuf.getArrayOfWritePointers(), numOut, info.numSamples); + { + float peak = 0.0f; + if (i < patch.numInputs && stemsPtr != nullptr) + for (int r = 0; r < patch.stemSpan[i]; ++r) + { + const int k = patch.stemOffset[i] + r; + if (k < numStems) + peak = juce::jmax (peak, stemScratch.getMagnitude (k, 0, n)); + } + inputPeak[(size_t) i].store (peak, std::memory_order_relaxed); + } + + // Decode + comp run in the SPEAKER domain on speakerScratch; the output + // patch scatters the result onto hardware rows afterwards (D46). The io + // buffer's input rows are therefore never decode targets, which is what + // makes the §5.1 aliasing invariant structural rather than accidental. + juce::AudioBuffer spkBuf (speakerScratch.getArrayOfWritePointers(), numSpk, info.numSamples); if (n < info.numSamples) - outBuf.clear(); // silence the whole block rather than emit a partial tail + spkBuf.clear(); // silence the whole block rather than emit a partial tail - juce::AudioSourceChannelInfo outInfo (&outBuf, 0, n); + juce::AudioSourceChannelInfo outInfo (&spkBuf, 0, n); if (inputSource.load (std::memory_order_relaxed) == InputSource::testScene) { @@ -510,30 +579,58 @@ void AudioEngine::getNextAudioBlock (const juce::AudioSourceChannelInfo& info) ptrs[c] = inputScratch.getWritePointer (c); scene::renderScene (xoa::kAmbisonicOrder, sceneCounter, n, deviceSampleRate.load (std::memory_order_relaxed), ptrs); - algorithm.processBlock (outInfo, inputScratch, xoa::kNumSHChannels, numOut, + algorithm.processBlock (outInfo, inputScratch, xoa::kNumSHChannels, numSpk, stemsPtr, numStems); } else { filePlayer.renderNextBlock (inputScratch, n); algorithm.processBlock (outInfo, inputScratch, - fileNumChannels.load (std::memory_order_relaxed), numOut, + fileNumChannels.load (std::memory_order_relaxed), numSpk, stemsPtr, numStems); } // Per-speaker compensation (delay/EQ/gain) on the decoded output, in place. - speakerComp.processBlock (outBuf, numOut, n); + speakerComp.processBlock (spkBuf, numSpk, n); - // Output test signal (FR-21), injected post-comp with replace-semantics on - // its target channel(s) so it lands exactly where the meters read it. + // Output scatter: speaker s -> hardware row hwForSpeaker[s]; active + // hardware outputs no speaker feeds are cleared (they would otherwise + // replay the input that was pre-copied into their row). + std::fill (hwWritten.begin(), hwWritten.end(), (char) 0); + for (int s = 0; s < numSpk; ++s) + { + const int hw = patch.hwForSpeaker[s]; + if (hw >= 0 && hw < numOutHw) + { + juce::FloatVectorOperations::copy (ioBuf.getWritePointer (hw), + spkBuf.getReadPointer (s), n); + hwWritten[(size_t) hw] = 1; + } + } + for (int h = 0; h < numOutHw; ++h) + if (! hwWritten[(size_t) h]) + juce::FloatVectorOperations::clear (ioBuf.getWritePointer (h), n); + + // Output test signal (FR-21), injected post-scatter in the HARDWARE + // domain (D46) with replace-semantics, so the patch matrix's testing mode + // reaches every open device output — patched or not. if (testSignal.isActive()) - testSignal.renderNextBlock (outBuf, 0, n); + { + juce::AudioBuffer hwOut (ioBuf.getArrayOfWritePointers(), numOutHw, n); + testSignal.renderNextBlock (hwOut, 0, n); + } - // Post-comp / post-test-signal block-peak meters (what leaves the device). - for (int s = 0; s < numOut && s < xoa::kMaxSpeakers; ++s) - outputPeak[(size_t) s].store (outBuf.getMagnitude (s, 0, n), std::memory_order_relaxed); - for (int s = juce::jmax (0, numOut); s < xoa::kMaxSpeakers; ++s) - outputPeak[(size_t) s].store (0.0f, std::memory_order_relaxed); + // Per-speaker block-peak meters: what actually leaves the device on the + // speaker's patched output (post-scatter, post-test-signal); 0 when the + // speaker is unpatched. + for (int s = 0; s < xoa::kMaxSpeakers; ++s) + { + float peak = 0.0f; + if (s < numSpk) + if (const int hw = patch.hwForSpeaker[s]; hw >= 0 && hw < numOutHw) + peak = ioBuf.getMagnitude (hw, 0, n); + outputPeak[(size_t) s].store (peak, std::memory_order_relaxed); + } sceneCounter += n; } diff --git a/Source/Audio/AudioEngine.h b/Source/Audio/AudioEngine.h index 2e21d24..c48d0f0 100644 --- a/Source/Audio/AudioEngine.h +++ b/Source/Audio/AudioEngine.h @@ -5,6 +5,7 @@ #include #include #include +#include #include "spatcore/io/DeviceHost.h" #include "spatcore/io/DeviceIoCallback.h" @@ -13,6 +14,7 @@ #include "XoaConstants.h" #include "Audio/DecoderRebuildWorker.h" #include "Audio/FilePlayer.h" +#include "Audio/PatchRouting.h" #include "Audio/SpeakerCompParams.h" #include "Audio/SpeakerCompProcessor.h" #include "Audio/TestSignalGenerator.h" @@ -128,14 +130,28 @@ class AudioEngine : private juce::AudioSource, return 0.0f; return outputPeak[(size_t) channel].load (std::memory_order_relaxed); } - /** Block-peak of a mono-encoder input stem (0 when the encoder is fed no - stems). Observation-only; measured at the stem gather. */ + /** Block-peak of an input stem (0 when the encoder is fed no stems) - + the max across the input's whole span for an HOA group. Observation- + only; measured at the stem gather. */ float getInputPeakLevel (int channel) const noexcept { if (channel < 0 || channel >= xoa::kMaxInputs) return 0.0f; return inputPeak[(size_t) channel].load (std::memory_order_relaxed); } + + /** Block-peak of a HARDWARE input channel (pre-gather, straight off the + device) - feeds the patch matrix's signal-presence tinting. */ + float getHwInputPeakLevel (int hardwareChannel) const noexcept + { + if (hardwareChannel < 0 || hardwareChannel >= xoa::kMaxHardwareChannels) + return 0.0f; + return hwInputPeak[(size_t) hardwareChannel].load (std::memory_order_relaxed); + } + + /** The hardware output speaker `s` is patched to, or -1 (message thread; + reads the store). Identity when the patch section is absent. */ + int getHardwareOutputForSpeaker (int speakerIndex) const; double getMeasuredLatencyMs() const noexcept { return measuredLatencyMs.load (std::memory_order_relaxed); } double getCpuLoad() const { return deviceManager.getCpuUsage(); } double getSampleRate() const noexcept { return deviceSampleRate.load (std::memory_order_relaxed); } @@ -155,6 +171,11 @@ class AudioEngine : private juce::AudioSource, const spatcore::rt::RtSnapshot& rotationSource() const noexcept { return rotationSnapshot; } const spatcore::rt::RtSnapshot& busParamsSource() const noexcept { return busParamsSnapshot; } const spatcore::rt::RtSnapshot& speakerCompSource() const noexcept { return speakerCompSnapshot; } + const spatcore::rt::RtSnapshot& patchSource() const noexcept { return patchSnapshot; } + + /** Recompose + publish the patch routing POD now (test seam; also the + listener path for patchData/rows edits). */ + void publishPatchRouting(); /** Recompose + publish the per-speaker comp POD now (test seam; also the listener path for gain/delay/mute/solo/distance-mode edits). */ @@ -210,6 +231,7 @@ class AudioEngine : private juce::AudioSource, // these members keep the registration alive for the engine's lifetime. juce::ValueTree speakersSection; juce::ValueTree decoderSection; + juce::ValueTree audioPatchSection; juce::AudioDeviceManager deviceManager; @@ -236,6 +258,7 @@ class AudioEngine : private juce::AudioSource, spatcore::rt::RtSnapshot rotationSnapshot; spatcore::rt::RtSnapshot busParamsSnapshot; spatcore::rt::RtSnapshot speakerCompSnapshot; + spatcore::rt::RtSnapshot patchSnapshot; AmbiBusAlgorithm algorithm; SpeakerCompProcessor speakerComp; TestSignalGenerator testSignal; @@ -247,8 +270,13 @@ class AudioEngine : private juce::AudioSource, // Per-input stem meters (WP10 C9), updated per block on the audio thread. std::array, xoa::kMaxInputs> inputPeak {}; + // Per-HARDWARE-input meters (patch matrix tinting), pre-gather. + std::array, xoa::kMaxHardwareChannels> hwInputPeak {}; + juce::AudioBuffer inputScratch; // [kMaxFileChannels x block] - juce::AudioBuffer stemScratch; // [kMaxInputs x block] mono-encoder stems + juce::AudioBuffer stemScratch; // [kMaxStemChannels x block] flattened stem channels (D43) + juce::AudioBuffer speakerScratch; // [kMaxSpeakers x block] decode+comp domain, scattered to hardware + std::vector hwWritten; // per-block scatter bookkeeping (sized at prepare) std::atomic inputSource { InputSource::file }; std::atomic stemFeed { StemFeed::device }; @@ -259,6 +287,7 @@ class AudioEngine : private juce::AudioSource, juce::uint32 rotationEpoch = 0; juce::uint32 busEpoch = 0; juce::uint32 speakerCompEpoch = 0; + juce::uint32 patchEpoch = 0; std::atomic measuredLatencyMs { 0.0 }; std::atomic deviceSampleRate { 0.0 }; @@ -271,6 +300,11 @@ class AudioEngine : private juce::AudioSource, std::atomic numActiveInputs { 0 }; std::atomic numActiveOutputs { 0 }; + // Speakers the decode/comp stage was prepared for (store count clamped to + // kMaxSpeakers) — the speaker domain is decoupled from the device outputs + // by the output patch. + std::atomic numSpeakersPrepared { 0 }; + juce::String lastDeviceError; // message thread only juce::int64 sceneCounter = 0; // audio-thread-owned scene sample position diff --git a/Source/Audio/PatchRouting.h b/Source/Audio/PatchRouting.h new file mode 100644 index 0000000..c09be80 --- /dev/null +++ b/Source/Audio/PatchRouting.h @@ -0,0 +1,99 @@ +#pragma once + +#include + +#include "XoaConstants.h" +#include "Parameters/XoaValueTreeState.h" + +//============================================================================== +// XOA - patch routing (stage 2, D41-D43). The message-thread composer that +// turns the AudioPatch trees into the POD the audio thread routes by: +// +// stem-scratch row k <- hardware input hwForStemChannel[k] (-1 = silent) +// speaker row s -> hardware output hwForSpeaker[s] (-1 = discard) +// +// Rows are FLATTENED stem channels (one per channel of every input's span, +// D43), so an HOA group may sit on arbitrary, non-contiguous hardware +// channels. When a patch tree is absent the composer falls back to the +// identity mapping, which reproduces pre-patch behaviour exactly. +// +// The POD also carries the per-input spans so the audio thread can meter an +// input across its whole group without a second snapshot. +//============================================================================== + +namespace xoa::rt +{ + +struct PatchRtState +{ + int numStemChannels = 0; // flattened stem rows in use + int numInputs = 0; + int hwForStemChannel[xoa::kMaxStemChannels] = {}; + int hwForSpeaker[xoa::kMaxSpeakers] = {}; + int stemOffset[xoa::kMaxInputs] = {}; // per input: first flattened row + int stemSpan[xoa::kMaxInputs] = {}; // per input: row count + juce::uint32 epoch = 0; // 0 = never published +}; + +static_assert (std::is_trivially_copyable_v, + "PatchRtState must be a POD for RtSnapshot"); + +/** Hardware column of a 1:1 patch row ("0,0,1,0..."), or -1 when unpatched. */ +inline int patchRowColumn (const juce::String& row) noexcept +{ + const auto cells = juce::StringArray::fromTokens (row, ",", ""); + for (int c = 0; c < cells.size(); ++c) + if (cells[c].getIntValue() == 1) + return c; + return -1; +} + +/** Compose the routing POD from the store's AudioPatch trees (message + thread; also driven directly by the offline tests). */ +inline PatchRtState composePatchRouting (const XoaValueTreeState& store, juce::uint32 epoch) +{ + PatchRtState p; + p.epoch = epoch; + + // Spans first: they define how many flattened rows exist. + p.numInputs = juce::jmin (store.getNumInputs(), xoa::kMaxInputs); + int offset = 0; + for (int i = 0; i < p.numInputs; ++i) + { + p.stemOffset[i] = offset; + p.stemSpan[i] = store.getInputChannelCount (i); + offset += p.stemSpan[i]; + } + p.numStemChannels = juce::jmin (offset, xoa::kMaxStemChannels); + + // Input side. Identity when the tree is absent; rows the tree does not + // cover stay identity as well (the store's reconcile keeps them in step, + // so this is a startup/foreign-file fallback, not a policy). + for (int k = 0; k < xoa::kMaxStemChannels; ++k) + p.hwForStemChannel[k] = k < xoa::kMaxHardwareChannels ? k : -1; + + if (auto tree = store.getInputPatchTree(); tree.isValid()) + { + const auto rows = juce::StringArray::fromTokens ( + tree.getProperty (ids::patchData).toString(), ";", ""); + for (int k = 0; k < p.numStemChannels && k < rows.size(); ++k) + p.hwForStemChannel[k] = patchRowColumn (rows[k]); + } + + // Output side, same shape: one row per speaker. + for (int s = 0; s < xoa::kMaxSpeakers; ++s) + p.hwForSpeaker[s] = s < xoa::kMaxHardwareChannels ? s : -1; + + if (auto tree = store.getOutputPatchTree(); tree.isValid()) + { + const auto rows = juce::StringArray::fromTokens ( + tree.getProperty (ids::patchData).toString(), ";", ""); + const int numSpeakers = juce::jmin (store.getNumSpeakers(), xoa::kMaxSpeakers); + for (int s = 0; s < numSpeakers && s < rows.size(); ++s) + p.hwForSpeaker[s] = patchRowColumn (rows[s]); + } + + return p; +} + +} // namespace xoa::rt diff --git a/Source/DSP/AmbiBusAlgorithm.h b/Source/DSP/AmbiBusAlgorithm.h index fa76123..51fe2f6 100644 --- a/Source/DSP/AmbiBusAlgorithm.h +++ b/Source/DSP/AmbiBusAlgorithm.h @@ -368,22 +368,35 @@ class AmbiBusAlgorithm private: //========================================================================== - // WP8 encoder stage: sum the mono stems into busA. Each active stem is - // filtered into its 11 order-lanes by its NFC bank (or aliased to dry when - // NFC is off), then accumulated per bus channel at its live encode - // coefficient with a one-block per-coefficient linear ramp from the last - // applied value (click-free, FR-5). Sources going inactive ramp their - // contribution out over one block using their still-present audio. The whole - // stage is skipped (busA untouched -> bit-identical) when the seams are null. - void applyEncoder (int n, const juce::AudioBuffer* stems, int numStems) noexcept + // WP8 encoder stage: sum the stems into busA. The stem buffer is FLATTENED + // (D44): input i's audio occupies rows [stemOffset[i], +stemSpan(i)). + // + // - A MONO input (stemOrder 0) is the original point-source path: its one + // dry row is filtered into 11 order-lanes by its NFC bank (or aliased + // to dry when NFC is off) and broadcast into all 121 bus channels at + // its live SH encode coefficients. + // - An HOA-group input (stemOrder 1..10) contributes its OWN channel c to + // bus channel c, scaled by its liveMatrix row (order-adapt x gain, zero + // above the group's order). No NFC, no lanes. + // + // Every applied coefficient moves with a one-block linear ramp from its + // last applied value (click-free, FR-5); sources going inactive ramp out + // over one block using their still-present audio. A format change may + // leave a previously-applied coefficient with no source lane under the new + // span (e.g. mono -> FOA drops lanes above channel 3); those are dropped + // hard - format switching is configuration, not a performance move. The + // whole stage is skipped (busA untouched -> bit-identical) when the seams + // are null. + void applyEncoder (int n, const juce::AudioBuffer* stems, int numStemChannels) noexcept { if (encoderSource == nullptr || encodeMatrixPtr == nullptr) return; const auto enc = encoderSource->acquire(); - const int stemCh = stems != nullptr ? stems->getNumChannels() : 0; - const int S = juce::jmin (juce::jmin (enc.numSources, numStems), - juce::jmin (stemCh, xoa::kMaxInputs)); + const int stemCh = stems != nullptr + ? juce::jmin (numStemChannels, stems->getNumChannels()) + : 0; + const int S = juce::jmin (enc.numSources, xoa::kMaxInputs); const int loopN = juce::jmax (S, lastActiveSources); if (loopN <= 0) { @@ -398,8 +411,10 @@ class AmbiBusAlgorithm for (int i = 0; i < loopN; ++i) { + const int offset = enc.stemOffset[i]; // loopN <= kMaxInputs by construction + const int span = enc.stemSpan (i); const bool active = i < S; - const bool hasAudio = stems != nullptr && i < stemCh; + const bool hasAudio = stems != nullptr && offset >= 0 && offset + span <= stemCh; float* appliedRow = appliedCoeff.data() + (size_t) i * xoa::kNumSHChannels; if (! hasAudio) @@ -409,7 +424,27 @@ class AmbiBusAlgorithm continue; } - const float* dry = stems->getReadPointer (i, 0); + const float* srcCoeff = encodeMatrixPtr + (size_t) i * xoa::kNumSHChannels; + + if (enc.stemOrder[i] > 0) + { + // HOA group: bus channel c accumulates the group's channel c. + for (int c = 0; c < xoa::kNumSHChannels; ++c) + { + const bool haveLane = c < span; + const float target = active && haveLane ? srcCoeff[c] : 0.0f; + const float from = appliedRow[c]; + if (from == 0.0f && target == 0.0f) + continue; + if (haveLane) + busA.addFromWithRamp (c, 0, stems->getReadPointer (offset + c), n, from, target); + // else: no lane under the new span - dropped hard (see above). + appliedRow[c] = target; + } + continue; + } + + const float* dry = stems->getReadPointer (offset, 0); const bool nfcOn = active && enc.nfcEnabled (i) && nfcCoeffsPtr != nullptr; if (nfcOn) @@ -421,7 +456,6 @@ class AmbiBusAlgorithm nfcCoeffsPtr + (size_t) i * nfc::kCoeffsPerSource); } - const float* srcCoeff = encodeMatrixPtr + (size_t) i * xoa::kNumSHChannels; for (int c = 0; c < xoa::kNumSHChannels; ++c) { const float target = active ? srcCoeff[c] : 0.0f; diff --git a/Source/DSP/AmbiCalculationEngine.cpp b/Source/DSP/AmbiCalculationEngine.cpp index 978a7dd..a555219 100644 --- a/Source/DSP/AmbiCalculationEngine.cpp +++ b/Source/DSP/AmbiCalculationEngine.cpp @@ -3,11 +3,30 @@ #include #include "DSP/AmbiNFCFilter.h" +#include "DSP/AmbiOrderWeights.h" #include "Parameters/XoaParameterIDs.h" namespace xoa { +namespace +{ + // The liveMatrix row of an AmbiX group input (D44): not SH coefficients + // but the per-bus-channel factor applied to the group's own channel c — + // order-adapt gain (zero-pad above the group's order on upmix) x the + // input's linear gain, all-zero when muted. Position/spread/NFC do not + // apply to groups. + void composeHoaRow (int order, float gainDb, bool mute, float* row121) + { + double adapt[xoa::kNumSHChannels]; + weights::orderAdaptGains (order, xoa::kAmbisonicOrder, adapt); + + const float linear = mute ? 0.0f : juce::Decibels::decibelsToGain (gainDb); + for (int c = 0; c < xoa::kNumSHChannels; ++c) + row121[c] = (float) adapt[c] * linear; + } +} // namespace + AmbiCalculationEngine::AmbiCalculationEngine (XoaValueTreeState& s) : store (s) { @@ -165,8 +184,15 @@ void AmbiCalculationEngine::tick() { if (rowDirty[(size_t) i]) { - enc::composeRow (readSource (i), referenceRadius, - liveMatrix.data() + (size_t) i * xoa::kNumSHChannels); + float* row = liveMatrix.data() + (size_t) i * xoa::kNumSHChannels; + const int format = store.getInputFormat (i); + if (format <= 0) + enc::composeRow (readSource (i), referenceRadius, row); + else + composeHoaRow (format, + (float) store.getFloatParameter (ids::inputGain, i), + static_cast (store.getParameter (ids::inputMute, i)), + row); rowDirty[(size_t) i] = 0; } if (nfcDirty[(size_t) i]) @@ -191,25 +217,45 @@ void AmbiCalculationEngine::publishParams() const bool enabled = static_cast (store.getParameter (ids::monoInputsEnabled)); const int numSources = enabled ? numInputs : 0; + // Formats and the flattened spans they imply (D44). NFC never applies to + // an AmbiX group, so its mask bit is forced off for those. + std::array orders {}; + std::array offsets {}; juce::uint64 mask = 0; + int offset = 0; for (int i = 0; i < numInputs; ++i) - if (static_cast (store.getParameter (ids::inputNfcEnabled, i))) + { + const int format = store.getInputFormat (i); + orders[(size_t) i] = (juce::uint8) format; + offsets[(size_t) i] = offset; + offset += XoaValueTreeState::channelCountForFormat (format); + + if (format <= 0 && static_cast (store.getParameter (ids::inputNfcEnabled, i))) mask |= (juce::uint64) 1 << i; + } const float rRef = (float) referenceRadius; - if (numSources == lastNumSources && mask == lastNfcMask && rRef == lastReferenceRadius) + if (numSources == lastNumSources && mask == lastNfcMask && rRef == lastReferenceRadius + && orders == lastStemOrders && offsets == lastStemOffsets) return; rt::EncoderRtParams p; p.numSources = numSources; p.nfcMask = mask; p.referenceRadius = rRef; + for (int i = 0; i < xoa::kMaxInputs; ++i) + { + p.stemOffset[i] = offsets[(size_t) i]; + p.stemOrder[i] = orders[(size_t) i]; + } p.epoch = ++epoch; snapshot.publish (p); lastNumSources = numSources; lastNfcMask = mask; lastReferenceRadius = rRef; + lastStemOrders = orders; + lastStemOffsets = offsets; } //============================================================================== diff --git a/Source/DSP/AmbiCalculationEngine.h b/Source/DSP/AmbiCalculationEngine.h index 4f1249a..fd9aebc 100644 --- a/Source/DSP/AmbiCalculationEngine.h +++ b/Source/DSP/AmbiCalculationEngine.h @@ -2,6 +2,7 @@ #include +#include #include #include "spatcore/dsp/InputSpeedLimiter.h" @@ -129,6 +130,8 @@ class AmbiCalculationEngine : private juce::ValueTree::Listener, int lastNumSources = -1; juce::uint64 lastNfcMask = 0; float lastReferenceRadius = -1.0f; + std::array lastStemOrders {}; + std::array lastStemOffsets {}; JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (AmbiCalculationEngine) }; diff --git a/Source/DSP/AmbiRtTypes.h b/Source/DSP/AmbiRtTypes.h index 2d8b8af..c5ed687 100644 --- a/Source/DSP/AmbiRtTypes.h +++ b/Source/DSP/AmbiRtTypes.h @@ -102,10 +102,26 @@ struct EncoderRtParams float referenceRadius = 2.0f; // rig mean radius (m); design is control-side juce::uint32 epoch = 0; // 0 = never published + // Stage 2 (D44): per-input stem spans in the FLATTENED stem buffer. Input + // i's audio occupies stem rows [stemOffset[i], stemOffset[i] + span) where + // span = 1 for a mono point source (stemOrder 0) or (o+1)^2 for an AmbiX + // group of order o. A mono input's liveMatrix row carries SH encode + // coefficients (broadcast one dry signal into 121 channels); an HOA + // input's row carries per-bus-channel order-adapt x gain factors (bus + // channel c reads stem row stemOffset + c). NFC never applies to groups. + int stemOffset[xoa::kMaxInputs] = {}; + juce::uint8 stemOrder[xoa::kMaxInputs] = {}; + bool nfcEnabled (int i) const noexcept { return i >= 0 && i < 64 && (nfcMask & (juce::uint64) 1 << i) != 0; } + + int stemSpan (int i) const noexcept + { + const int o = i >= 0 && i < xoa::kMaxInputs ? (int) stemOrder[i] : 0; + return o <= 0 ? 1 : (o + 1) * (o + 1); + } }; static_assert (std::is_trivially_copyable_v, From a27139bae8187a95ffec842853af680f0135c2f4 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Boulant Date: Sun, 2 Aug 2026 20:23:54 +0200 Subject: [PATCH 09/12] feat(gui): XOA config shim over the shared spatcore patch matrix XoaPatchMatrix derives spatcore::ui::patch::PatchMatrixComponent and binds the app seams: AudioPatch trees + shared property names, flattened stem-channel rows labelled/coloured by their owning input (D43), palette from ColorScheme, LOC strings, XoaLookAndFeel::uiScale, TTSManager announcements. Distinct basename per the handoff's object-file trap note. The Audio Interface window that hosts it lands next. Co-Authored-By: Claude Fable 5 --- CMakeLists.txt | 4 +- Source/GUI/Patch/XoaPatchMatrixShim.cpp | 141 ++++++++++++++++++++++++ Source/GUI/Patch/XoaPatchMatrixShim.h | 56 ++++++++++ 3 files changed, 200 insertions(+), 1 deletion(-) create mode 100644 Source/GUI/Patch/XoaPatchMatrixShim.cpp create mode 100644 Source/GUI/Patch/XoaPatchMatrixShim.h diff --git a/CMakeLists.txt b/CMakeLists.txt index f8cc1e6..b0ce920 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -79,7 +79,9 @@ target_sources(XOA PRIVATE Source/GUI/Tabs/SpeakersDecoderTab.cpp Source/GUI/Tabs/EqTab.cpp Source/GUI/Layout/SpeakerLayoutPanel.cpp - Source/GUI/Map3D/Map3DView.cpp) + Source/GUI/Map3D/Map3DView.cpp + # Stage 2: the shared patch matrix behind XOA's config shim. + Source/GUI/Patch/XoaPatchMatrixShim.cpp) # GUI kit (WP10). Headers are picked up via the Source include dir; only # translation units need listing here. The macOS dark-title-bar helper is diff --git a/Source/GUI/Patch/XoaPatchMatrixShim.cpp b/Source/GUI/Patch/XoaPatchMatrixShim.cpp new file mode 100644 index 0000000..766104e --- /dev/null +++ b/Source/GUI/Patch/XoaPatchMatrixShim.cpp @@ -0,0 +1,141 @@ +#include "XoaPatchMatrixShim.h" + +#include "../ColorScheme.h" +#include "../ColorUtilities.h" +#include "../XoaLookAndFeel.h" +#include "Accessibility/TTSManager.h" +#include "Localization/LocalizationManager.h" +#include "XoaConstants.h" + +namespace xoa::ui +{ + +namespace +{ + /** The input owning a flattened stem-channel row, and the row's offset + within that input's span. Returns {-1, 0} past the last input. */ + std::pair inputForStemRow (const XoaValueTreeState& store, int row) + { + int offset = 0; + const int numInputs = store.getNumInputs(); + for (int i = 0; i < numInputs; ++i) + { + const int span = store.getInputChannelCount (i); + if (row < offset + span) + return { i, row - offset }; + offset += span; + } + return { -1, 0 }; + } +} // namespace + +spatcore::ui::patch::PatchMatrixConfig +XoaPatchMatrix::makeConfig (XoaValueTreeState& store, bool isInputPatch) +{ + using namespace spatcore::ui::patch; + + PatchMatrixConfig config; + + //-------------------------------------------------------------------------- + // Trees + schema. Property names are shared verbatim with the component's + // defaults except the per-direction active-channel ids. + config.patchTree = isInputPatch ? store.getInputPatchTree() : store.getOutputPatchTree(); + config.channelsTree = isInputPatch ? store.getInputsSection() : store.getSpeakersSection(); + + config.ids.patchData = ids::patchData; + config.ids.rows = ids::rows; + config.ids.cols = ids::cols; + config.ids.activeHardwareChannels = isInputPatch ? ids::activeHardwareInputs + : ids::activeHardwareOutputs; + // No binaural tree: XOA's binaural monitoring is post-v1 (D2). + + config.maxHardwareChannels = xoa::kMaxHardwareChannels; + + //-------------------------------------------------------------------------- + // Host queries. Input rows are FLATTENED stem channels (D43): a group + // input owns several consecutive rows, labelled and coloured by the + // owning input so it reads as one block in the matrix. + config.numChannelsProvider = [&store, isInputPatch] + { + return isInputPatch ? store.getTotalStemChannels() : store.getNumSpeakers(); + }; + + config.recomputeColumns = [&store] { store.recomputePatchCols(); }; + + config.channelNameProvider = [&store, isInputPatch] (int row) -> juce::String + { + if (! isInputPatch) + { + return row >= 0 && row < store.getNumSpeakers() + ? store.getStringParameter (ids::speakerName, row) + : juce::String(); + } + + const auto [input, channelInGroup] = inputForStemRow (store, row); + if (input < 0) + return {}; + + const juce::String name = store.getStringParameter (ids::inputName, input); + if (store.getInputChannelCount (input) <= 1) + return name; + return name + " · ACN " + juce::String (channelInGroup); + }; + + config.rowColourProvider = [&store, isInputPatch] (int row) -> juce::Colour + { + if (isInputPatch) + { + const int input = inputForStemRow (store, row).first; + return input >= 0 ? XoaColorUtilities::getInputColor (input + 1) + : juce::Colours::grey; + } + // Speakers carry no per-channel colour in XOA's schema (unlike + // WFS-DIY's array colours); one accent reads calmer than 256 hues. + return ColorScheme::accents::spatial; + }; + + config.contrastingTextProvider = [] (juce::Colour background) + { + return XoaColorUtilities::getContrastingTextColor (background); + }; + + //-------------------------------------------------------------------------- + // Presentation — read at paint/layout time so live theme, language and + // scale changes show up without rebuilding the matrix. + config.paletteProvider = [] + { + const auto& scheme = ColorScheme::get(); + return PatchMatrixPalette { scheme.background, + scheme.backgroundAlt, + scheme.surfaceCard, + scheme.chromeDivider, + scheme.textPrimary, + scheme.textSecondary, + scheme.textDisabled }; + }; + + config.translate = [] (const char* key) { return LOC (key); }; + config.uiScaleProvider = [] { return XoaLookAndFeel::uiScale; }; + + //-------------------------------------------------------------------------- + // Accessibility. + config.announce = [] (const juce::String& text) + { + TTSManager::getInstance().announceImmediate ( + text, juce::AccessibilityHandler::AnnouncementPriority::medium); + }; + + config.announceDebounced = [] (const juce::String& text) + { + TTSManager::getInstance().announceDebounced (text); + }; + + config.cancelDebouncedAnnouncement = [] + { + TTSManager::getInstance().cancelDebouncedAnnouncement(); + }; + + return config; +} + +} // namespace xoa::ui diff --git a/Source/GUI/Patch/XoaPatchMatrixShim.h b/Source/GUI/Patch/XoaPatchMatrixShim.h new file mode 100644 index 0000000..eaa4763 --- /dev/null +++ b/Source/GUI/Patch/XoaPatchMatrixShim.h @@ -0,0 +1,56 @@ +/* + ============================================================================== + + XOA — tenth-order Ambisonics spatial audio processor. + XoaPatchMatrixShim — XOA's face of the shared spatcore patch matrix + (stage 2 of the audio-device handoff): a derived class plus the config + factory that binds the app's ValueTree schema, colours, localisation and + accessibility singletons into spatcore::ui::patch::PatchMatrixConfig. + + The basename is deliberately NOT PatchMatrixComponent: spatcore compiles + its own PatchMatrixComponent.cpp, and identical object basenames in one + build have bitten before (see the handoff §7.1 object-file trap note). + + This file is part of XOA, released under the GNU General Public License + v3.0. See LICENSE for details. + + ============================================================================== +*/ + +#pragma once + +#include "spatcore/ui/patch/PatchMatrixComponent.h" + +#include "Audio/TestSignalGenerator.h" +#include "Parameters/XoaValueTreeState.h" + +namespace xoa::ui +{ + +class XoaPatchMatrix : public spatcore::ui::patch::PatchMatrixComponent +{ +public: + /** isInputPatch selects the InputPatch tree (rows = FLATTENED stem + channels, one per channel of every input's span — D43) or the + OutputPatch tree (rows = speakers). The generator is only used by the + output matrix's Testing mode. */ + XoaPatchMatrix (XoaValueTreeState& store, + bool isInputPatch, + xoa::TestSignalGenerator* testSignalGen = nullptr) + : spatcore::ui::patch::PatchMatrixComponent (makeConfig (store, isInputPatch), + isInputPatch, + testSignalGen) + { + } + + /** The app half of the shared component: everything spatcore deliberately + does not know. Providers are invoked at paint/layout time, never + snapshotted, so theme/language/scale changes land on the next repaint. */ + static spatcore::ui::patch::PatchMatrixConfig makeConfig (XoaValueTreeState& store, + bool isInputPatch); + +private: + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (XoaPatchMatrix) +}; + +} // namespace xoa::ui From 71c06d0e1d856a9c9a845c23f1bf520f073d228f Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Boulant Date: Sun, 2 Aug 2026 20:45:35 +0200 Subject: [PATCH 10/12] feat(gui): the Audio Interface window (D41, D42, D46) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit XOA's own shell around the shared matrix (the handoff draws the sharing line here deliberately): a device info bar, and Device Settings / Input Patch / Output Patch tabs. - DeviceSettingsPanel routes every mutation through DeviceHost (openNamedDevice / setDeviceAllChannels / enableAllChannels); the sample-rate and buffer-size path re-asserts the mask policy after setAudioDeviceSetup, so the D39 spatcore setters stay optional. - One XoaPatchTab serves both directions: Scroll/Patch modes, Test on the output tab with the non-linear level and log-frequency mappings plus Hold, long-press Unpatch All, and the 20 Hz header repaint that animates the input signal-presence tint. - Transport gate (D41): while the file transport plays, tones stop, modes drop to Scrolling and the tabs are disabled behind a banner. A tone is also stopped on tab change and window close. - SystemConfigTab drops juce::AudioDeviceSelectorComponent for an "Audio Interface…" button plus a live device summary and the last DeviceHost error. - openAudioDevice now seeds the patch trees' active-channel counts. They were only written by the device-change listener, which does not fire at startup, so the matrices' overflow gating was disabled and channels the device never opened looked patchable. Found by opening the window: outputs 7-24 were drawn live on a 6-output device. GUI-verified on Windows: all three tabs render, the matrices show the identity default, gating dims the unopened columns, and switching an input to HOA 1 expands it to four labelled ACN rows in the input matrix, greying its point-source controls. Co-Authored-By: Claude Fable 5 --- CMakeLists.txt | 6 +- Resources/lang/en.json | 23 + Source/Audio/AudioEngine.cpp | 7 + Source/GUI/Patch/AudioInterfaceWindow.cpp | 546 ++++++++++++++++++++++ Source/GUI/Patch/AudioInterfaceWindow.h | 205 ++++++++ Source/GUI/Tabs/SystemConfigTab.cpp | 58 ++- Source/GUI/Tabs/SystemConfigTab.h | 13 +- 7 files changed, 846 insertions(+), 12 deletions(-) create mode 100644 Source/GUI/Patch/AudioInterfaceWindow.cpp create mode 100644 Source/GUI/Patch/AudioInterfaceWindow.h diff --git a/CMakeLists.txt b/CMakeLists.txt index b0ce920..2fefa11 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -80,8 +80,10 @@ target_sources(XOA PRIVATE Source/GUI/Tabs/EqTab.cpp Source/GUI/Layout/SpeakerLayoutPanel.cpp Source/GUI/Map3D/Map3DView.cpp - # Stage 2: the shared patch matrix behind XOA's config shim. - Source/GUI/Patch/XoaPatchMatrixShim.cpp) + # Stage 2: the shared patch matrix behind XOA's config shim, and the + # XOA-owned Audio Interface window around it. + Source/GUI/Patch/XoaPatchMatrixShim.cpp + Source/GUI/Patch/AudioInterfaceWindow.cpp) # GUI kit (WP10). Headers are picked up via the Source include dir; only # translation units need listing here. The macOS dark-title-bar helper is diff --git a/Resources/lang/en.json b/Resources/lang/en.json index 6e0c3a4..c6c5f4e 100644 --- a/Resources/lang/en.json +++ b/Resources/lang/en.json @@ -61,6 +61,7 @@ "show": "Show", "playback": "Playback", "device": "Audio device", + "audioInterface": "Audio Interface…", "appearance": "Appearance", "loadProject": "Load project…", "saveProject": "Save project…", @@ -209,6 +210,28 @@ "decoderCrossoverFrequency": "Crossover", "decoderNormalization": "Normalization" }, + "audioPatch": { + "window": { "title": "Audio Interface" }, + "openButton": "Audio Interface…", + "tabs": { "device": "Device Settings", "inputPatch": "Input Patch", "outputPatch": "Output Patch" }, + "device": { + "type": "Driver type", "device": "Device", "sampleRate": "Sample rate", + "bufferSize": "Buffer size", "controlPanel": "Control Panel…", "reset": "Reset Device" + }, + "info": { "channels": "Active in/out:", "noDevice": "No audio device open" }, + "mode": { "scrolling": "Scroll", "patching": "Patch", "testing": "Test" }, + "unpatchAll": "Unpatch All (hold)", + "test": { "hold": "Hold" }, + "transportLocked": "Patching is disabled while the transport is playing — stop playback to edit.", + "labels": { + "interfaceInput": "Interface input", "interfaceOutput": "Interface output", + "processorInputs": "Stem channels", "processorOutputs": "Speakers" + }, + "messages": { + "channelNotAvailable": "Channel not available — the device did not open it", + "chooseTestSignal": "Choose a test signal first" + } + }, "enum": { "contentOrder": { "auto": "Auto" }, "convention": { "sn3d": "SN3D", "n3d": "N3D", "fuma": "FuMa" }, diff --git a/Source/Audio/AudioEngine.cpp b/Source/Audio/AudioEngine.cpp index 006e360..2fd1965 100644 --- a/Source/Audio/AudioEngine.cpp +++ b/Source/Audio/AudioEngine.cpp @@ -126,6 +126,13 @@ void AudioEngine::openAudioDevice() deviceManager.addAudioCallback (&ioCallback); // once — the manager re-arms it across device changes callbackRegistered = true; + // Seed the patch trees' active-channel counts from the device we just + // opened. The ChangeListener only fires on LATER changes, and a count of + // 0 disables the matrices' overflow gating entirely — so without this the + // patch window would offer channels the device never opened. + store.updateHardwareChannelCount (deviceHost.getNumActiveInputs(), + deviceHost.getNumActiveOutputs()); + // Bring the encoder engine to the device's sample rate / rig radius and keep // its live matrices fresh at 50 Hz while the device is open. syncCalcEngineToDevice(); diff --git a/Source/GUI/Patch/AudioInterfaceWindow.cpp b/Source/GUI/Patch/AudioInterfaceWindow.cpp new file mode 100644 index 0000000..1949418 --- /dev/null +++ b/Source/GUI/Patch/AudioInterfaceWindow.cpp @@ -0,0 +1,546 @@ +#include "AudioInterfaceWindow.h" + +#include + +#include "../ColorScheme.h" +#include "../XoaLookAndFeel.h" +#include "Localization/LocalizationManager.h" + +namespace xoa::ui +{ + +namespace +{ + int px (int v) { return juce::roundToInt ((float) v * XoaLookAndFeel::uiScale); } +} + +//============================================================================== +// DeviceInfoBar +//============================================================================== + +DeviceInfoBar::DeviceInfoBar (xoa::AudioEngine& engineToUse) + : engine (engineToUse) +{ + startTimer (500); +} + +DeviceInfoBar::~DeviceInfoBar() +{ + stopTimer(); +} + +void DeviceInfoBar::paint (juce::Graphics& g) +{ + const auto& scheme = ColorScheme::get(); + g.fillAll (scheme.backgroundAlt); + + juce::String text; + auto& manager = engine.getDeviceManager(); + if (auto* device = manager.getCurrentAudioDevice()) + { + auto& host = engine.getDeviceHost(); + text << manager.getCurrentAudioDeviceType() << " · " << device->getName() + << " · " << juce::String (device->getCurrentSampleRate() / 1000.0, 1) << " kHz" + << " · " << device->getCurrentBufferSizeSamples() << " smp" + << " · " << LOC ("audioPatch.info.channels") + << " " << host.getNumActiveInputs() << " / " << host.getNumActiveOutputs(); + } + else + { + text = LOC ("audioPatch.info.noDevice"); + if (engine.getLastDeviceError().isNotEmpty()) + text << " — " << engine.getLastDeviceError(); + } + + g.setColour (scheme.textSecondary); + g.setFont (juce::Font (juce::FontOptions (juce::jmax (11.0f, 13.0f * XoaLookAndFeel::uiScale)))); + g.drawText (text, getLocalBounds().reduced (px (10), 0), juce::Justification::centredLeft); +} + +//============================================================================== +// DeviceSettingsPanel +//============================================================================== + +DeviceSettingsPanel::DeviceSettingsPanel (xoa::AudioEngine& engineToUse) + : engine (engineToUse), deviceManager (engineToUse.getDeviceManager()) +{ + auto addLabelled = [this] (juce::Label& label, const char* key, juce::ComboBox& combo) + { + label.setText (LOC (key), juce::dontSendNotification); + label.setJustificationType (juce::Justification::centredRight); + addAndMakeVisible (label); + addAndMakeVisible (combo); + }; + addLabelled (deviceTypeLabel, "audioPatch.device.type", deviceTypeCombo); + addLabelled (deviceLabel, "audioPatch.device.device", deviceCombo); + addLabelled (sampleRateLabel, "audioPatch.device.sampleRate", sampleRateCombo); + addLabelled (bufferSizeLabel, "audioPatch.device.bufferSize", bufferSizeCombo); + + controlPanelButton.setButtonText (LOC ("audioPatch.device.controlPanel")); + resetDeviceButton.setButtonText (LOC ("audioPatch.device.reset")); + addAndMakeVisible (controlPanelButton); + addAndMakeVisible (resetDeviceButton); + + errorLabel.setJustificationType (juce::Justification::centredLeft); + errorLabel.setColour (juce::Label::textColourId, ColorScheme::accents::mute); + addAndMakeVisible (errorLabel); + + // Every mutation routes through DeviceHost (§2.2): explicit masks, all + // channels, useDefault* flags cleared. + deviceTypeCombo.onChange = [this] + { + if (isUpdating) return; + const auto typeName = deviceTypeCombo.getText(); + juce::String deviceName; + for (auto* type : deviceManager.getAvailableDeviceTypes()) + if (type->getTypeName() == typeName) + { + type->scanForDevices(); + const auto names = type->getDeviceNames(); + const int def = juce::jlimit (0, juce::jmax (0, names.size() - 1), + type->getDefaultDeviceIndex (false)); + if (! names.isEmpty()) + deviceName = names[def]; + } + errorLabel.setText (engine.getDeviceHost().openNamedDevice (typeName, deviceName), + juce::dontSendNotification); + }; + + deviceCombo.onChange = [this] + { + if (isUpdating) return; + errorLabel.setText (engine.getDeviceHost().setDeviceAllChannels (deviceCombo.getText()), + juce::dontSendNotification); + }; + + sampleRateCombo.onChange = [this] { if (! isUpdating) applySampleRateOrBuffer(); }; + bufferSizeCombo.onChange = [this] { if (! isUpdating) applySampleRateOrBuffer(); }; + + controlPanelButton.onClick = [this] + { + if (auto* device = deviceManager.getCurrentAudioDevice()) + if (device->hasControlPanel()) + device->showControlPanel(); + }; + + resetDeviceButton.onClick = [this] + { + // Full reopen at the device's own defaults, masks re-asserted. + if (auto* device = deviceManager.getCurrentAudioDevice()) + errorLabel.setText (engine.getDeviceHost().setDeviceAllChannels (device->getName()), + juce::dontSendNotification); + }; + + deviceManager.addChangeListener (this); + updateAllControls(); +} + +DeviceSettingsPanel::~DeviceSettingsPanel() +{ + deviceManager.removeChangeListener (this); +} + +void DeviceSettingsPanel::applySampleRateOrBuffer() +{ + // Mask-safe only because DeviceHost cleared the useDefault* flags in the + // stored setup; re-assert the enable-all policy afterwards regardless + // (D39: a spatcore setter enforcing this stays open). + auto setup = deviceManager.getAudioDeviceSetup(); + setup.sampleRate = sampleRateCombo.getText().getDoubleValue(); + setup.bufferSize = bufferSizeCombo.getText().getIntValue(); + + juce::String error = deviceManager.setAudioDeviceSetup (setup, true); + if (error.isEmpty()) + error = engine.getDeviceHost().enableAllChannels(); + errorLabel.setText (error, juce::dontSendNotification); +} + +void DeviceSettingsPanel::updateAllControls() +{ + const juce::ScopedValueSetter guard (isUpdating, true); + + deviceTypeCombo.clear (juce::dontSendNotification); + int id = 1; + for (auto* type : deviceManager.getAvailableDeviceTypes()) + deviceTypeCombo.addItem (type->getTypeName(), id++); + deviceTypeCombo.setText (deviceManager.getCurrentAudioDeviceType(), juce::dontSendNotification); + + deviceCombo.clear (juce::dontSendNotification); + for (auto* type : deviceManager.getAvailableDeviceTypes()) + if (type->getTypeName() == deviceManager.getCurrentAudioDeviceType()) + { + int deviceId = 1; + for (const auto& name : type->getDeviceNames()) + deviceCombo.addItem (name, deviceId++); + } + + sampleRateCombo.clear (juce::dontSendNotification); + bufferSizeCombo.clear (juce::dontSendNotification); + + if (auto* device = deviceManager.getCurrentAudioDevice()) + { + deviceCombo.setText (device->getName(), juce::dontSendNotification); + + int srId = 1; + for (const double sr : device->getAvailableSampleRates()) + sampleRateCombo.addItem (juce::String (sr, 0), srId++); + sampleRateCombo.setText (juce::String (device->getCurrentSampleRate(), 0), + juce::dontSendNotification); + + int bufId = 1; + for (const int size : device->getAvailableBufferSizes()) + bufferSizeCombo.addItem (juce::String (size), bufId++); + bufferSizeCombo.setText (juce::String (device->getCurrentBufferSizeSamples()), + juce::dontSendNotification); + } +} + +void DeviceSettingsPanel::resized() +{ + auto area = getLocalBounds().reduced (px (20)); + const int rowH = px (32); + const int labelW = px (120); + const int comboW = px (280); + + auto row = [&] (juce::Label& label, juce::ComboBox& combo) + { + auto r = area.removeFromTop (rowH); + label.setBounds (r.removeFromLeft (labelW)); + r.removeFromLeft (px (6)); + combo.setBounds (r.removeFromLeft (comboW).reduced (0, px (3))); + area.removeFromTop (px (6)); + }; + row (deviceTypeLabel, deviceTypeCombo); + row (deviceLabel, deviceCombo); + row (sampleRateLabel, sampleRateCombo); + row (bufferSizeLabel, bufferSizeCombo); + + area.removeFromTop (px (8)); + auto buttons = area.removeFromTop (rowH); + buttons.removeFromLeft (labelW + px (6)); + controlPanelButton.setBounds (buttons.removeFromLeft (px (140)).reduced (0, px (2))); + buttons.removeFromLeft (px (8)); + resetDeviceButton.setBounds (buttons.removeFromLeft (px (140)).reduced (0, px (2))); + + area.removeFromTop (px (10)); + errorLabel.setBounds (area.removeFromTop (rowH)); +} + +//============================================================================== +// XoaPatchTab +//============================================================================== + +XoaPatchTab::XoaPatchTab (AppContext& ctx, bool isInputTab) + : context (ctx), + isInput (isInputTab), + unpatchAllButton (800), + matrix (ctx.store, isInputTab, + isInputTab ? nullptr : &ctx.engine.getTestSignalGenerator()) +{ + scrollingButton.setButtonText (LOC ("audioPatch.mode.scrolling")); + patchingButton.setButtonText (LOC ("audioPatch.mode.patching")); + testingButton.setButtonText (LOC ("audioPatch.mode.testing")); + unpatchAllButton.setButtonText (LOC ("audioPatch.unpatchAll")); + + scrollingButton.onClick = [this] { setMode (XoaPatchMatrix::Mode::Scrolling); }; + patchingButton.onClick = [this] { setMode (XoaPatchMatrix::Mode::Patching); }; + testingButton.onClick = [this] { setMode (XoaPatchMatrix::Mode::Testing); }; + unpatchAllButton.onLongPress = [this] { matrix.clearAllPatches(); }; + + addAndMakeVisible (scrollingButton); + addAndMakeVisible (patchingButton); + addAndMakeVisible (unpatchAllButton); + addAndMakeVisible (matrix); + + if (isInput) + { + // Signal-presence tint: per-hardware-input peaks off the engine, + // animated by the 20 Hz header repaint. + matrix.setHardwareInputPeakProvider ( + [&engine = context.engine] (int hw) { return engine.getHwInputPeakLevel (hw); }); + startTimerHz (20); + } + else + { + addAndMakeVisible (testingButton); + + // Inline test controls (Testing mode only). Combo ids are 1-based on + // the enum ordinals (1 = Off .. 5 = DiracPulse); SpeakerId stays on + // the SpeakersDecoderTab, where the announced index means a speaker. + signalTypeCombo.addItem (LOC ("enum.testSignal.off"), 1); + signalTypeCombo.addItem (LOC ("enum.testSignal.pink"), 2); + signalTypeCombo.addItem (LOC ("enum.testSignal.tone"), 3); + signalTypeCombo.addItem (LOC ("enum.testSignal.sweep"), 4); + signalTypeCombo.addItem (LOC ("enum.testSignal.dirac"), 5); + signalTypeCombo.setSelectedId (2, juce::dontSendNotification); + signalTypeCombo.onChange = [this] { applyTestSettings(); }; + addChildComponent (signalTypeCombo); + + holdButton.setButtonText (LOC ("audioPatch.test.hold")); + holdButton.setClickingTogglesState (true); + holdButton.onClick = [this] + { + auto& gen = context.engine.getTestSignalGenerator(); + gen.setHoldEnabled (holdButton.getToggleState()); + if (! holdButton.getToggleState()) + stopTestAudio(); + }; + addChildComponent (holdButton); + + // Non-linear mappings (§7.2 B): perceptual level curve to a -92 dB + // floor, log frequency across 20 Hz - 20 kHz. + auto& gen = context.engine.getTestSignalGenerator(); + levelSlider.setValue (dbToSliderValue (gen.getLevelDb())); + levelSlider.onValueChanged = [this] (float v) + { + const float dB = sliderValueToDb (v); + context.engine.getTestSignalGenerator().setLevel (dB); + levelValueLabel.setText (juce::String (dB, 1) + " " + LOC ("units.db"), + juce::dontSendNotification); + }; + addChildComponent (levelSlider); + addChildComponent (levelValueLabel); + + frequencySlider.setValue (frequencyToSliderValue (gen.getFrequency())); + frequencySlider.onValueChanged = [this] (float v) + { + const float hz = sliderValueToFrequency (v); + context.engine.getTestSignalGenerator().setFrequency (hz); + frequencyValueLabel.setText (juce::String (juce::roundToInt (hz)) + " " + LOC ("units.hz"), + juce::dontSendNotification); + }; + addChildComponent (frequencySlider); + addChildComponent (frequencyValueLabel); + } + + setMode (XoaPatchMatrix::Mode::Scrolling); +} + +XoaPatchTab::~XoaPatchTab() +{ + stopTimer(); + stopTestAudio(); +} + +void XoaPatchTab::timerCallback() +{ + matrix.repaintHeaderBand(); +} + +void XoaPatchTab::setMode (XoaPatchMatrix::Mode mode) +{ + if (mode == XoaPatchMatrix::Mode::Testing && isInput) + return; // testing is output-only by convention + + matrix.setMode (mode); // the matrix stops its own test tone when leaving Testing + + scrollingButton.setToggleState (mode == XoaPatchMatrix::Mode::Scrolling, juce::dontSendNotification); + patchingButton.setToggleState (mode == XoaPatchMatrix::Mode::Patching, juce::dontSendNotification); + testingButton.setToggleState (mode == XoaPatchMatrix::Mode::Testing, juce::dontSendNotification); + updateTestControlsVisibility(); + + if (mode != XoaPatchMatrix::Mode::Scrolling) + matrix.grabKeyboardFocus(); +} + +void XoaPatchTab::updateTestControlsVisibility() +{ + const bool testing = ! isInput && matrix.getMode() == XoaPatchMatrix::Mode::Testing; + signalTypeCombo.setVisible (testing); + holdButton.setVisible (testing); + levelSlider.setVisible (testing); + levelValueLabel.setVisible (testing); + + const bool tone = testing + && signalTypeCombo.getSelectedId() == 3; // Tone + frequencySlider.setVisible (tone); + frequencyValueLabel.setVisible (tone); +} + +void XoaPatchTab::applyTestSettings() +{ + auto& gen = context.engine.getTestSignalGenerator(); + const int selected = signalTypeCombo.getSelectedId(); + gen.setSignalType ((xoa::TestSignalGenerator::SignalType) juce::jmax (0, selected - 1)); + if (selected <= 1) + stopTestAudio(); + updateTestControlsVisibility(); +} + +void XoaPatchTab::stopTestAudio() +{ + context.engine.getTestSignalGenerator().setOutputChannel (-1); +} + +void XoaPatchTab::resetMode() +{ + stopTestAudio(); + setMode (XoaPatchMatrix::Mode::Scrolling); +} + +float XoaPatchTab::sliderValueToDb (float v) +{ + // dB = 20·log10(floor + (1 − floor)·v²), floor = 10^(-92/20) — perceptual + // taper that still reaches a true floor (§7.2 B). + const float floorLin = std::pow (10.0f, -92.0f / 20.0f); + const float lin = floorLin + (1.0f - floorLin) * v * v; + return juce::jlimit (-92.0f, 0.0f, 20.0f * std::log10 (lin)); +} + +float XoaPatchTab::dbToSliderValue (float dB) +{ + const float floorLin = std::pow (10.0f, -92.0f / 20.0f); + const float lin = std::pow (10.0f, juce::jlimit (-92.0f, 0.0f, dB) / 20.0f); + return std::sqrt (juce::jmax (0.0f, (lin - floorLin) / (1.0f - floorLin))); +} + +float XoaPatchTab::sliderValueToFrequency (float v) +{ + return 20.0f * std::pow (10.0f, 3.0f * juce::jlimit (0.0f, 1.0f, v)); // 20 Hz - 20 kHz log +} + +float XoaPatchTab::frequencyToSliderValue (float hz) +{ + return std::log10 (juce::jlimit (20.0f, 20000.0f, hz) / 20.0f) / 3.0f; +} + +void XoaPatchTab::paint (juce::Graphics& g) +{ + g.fillAll (ColorScheme::get().background); +} + +void XoaPatchTab::resized() +{ + auto area = getLocalBounds().reduced (px (8)); + + auto bar = area.removeFromTop (px (30)); + const int buttonW = px (100); + scrollingButton.setBounds (bar.removeFromLeft (buttonW).reduced (px (2), 0)); + patchingButton.setBounds (bar.removeFromLeft (buttonW).reduced (px (2), 0)); + if (! isInput) + testingButton.setBounds (bar.removeFromLeft (buttonW).reduced (px (2), 0)); + unpatchAllButton.setBounds (bar.removeFromRight (px (120))); + + if (! isInput) + { + auto testBar = area.removeFromTop (px (30)); + signalTypeCombo.setBounds (testBar.removeFromLeft (px (130)).reduced (px (2), px (3))); + holdButton.setBounds (testBar.removeFromLeft (px (70)).reduced (px (2), px (3))); + levelSlider.setBounds (testBar.removeFromLeft (px (160)).reduced (px (2), px (6))); + levelValueLabel.setBounds (testBar.removeFromLeft (px (70))); + frequencySlider.setBounds (testBar.removeFromLeft (px (160)).reduced (px (2), px (6))); + frequencyValueLabel.setBounds (testBar.removeFromLeft (px (70))); + } + + area.removeFromTop (px (4)); + matrix.setBounds (area); +} + +//============================================================================== +// AudioInterfaceContent +//============================================================================== + +AudioInterfaceContent::AudioInterfaceContent (AppContext& ctx) + : context (ctx), infoBar (ctx.engine) +{ + addAndMakeVisible (infoBar); + + const auto tabBg = ColorScheme::get().backgroundAlt; + devicePanel = new DeviceSettingsPanel (ctx.engine); + inputTab = new XoaPatchTab (ctx, true); + outputTab = new XoaPatchTab (ctx, false); + tabs.addTab (LOC ("audioPatch.tabs.device"), tabBg, devicePanel, true); + tabs.addTab (LOC ("audioPatch.tabs.inputPatch"), tabBg, inputTab, true); + tabs.addTab (LOC ("audioPatch.tabs.outputPatch"), tabBg, outputTab, true); + tabs.onTabChanged = [this] (int) + { + // Leaving a tab stops its tone and drops back to Scrolling. + if (outputTab != nullptr) outputTab->resetMode(); + if (inputTab != nullptr) inputTab->resetMode(); + }; + addAndMakeVisible (tabs); + + transportBanner.setText (LOC ("audioPatch.transportLocked"), juce::dontSendNotification); + transportBanner.setJustificationType (juce::Justification::centred); + transportBanner.setColour (juce::Label::backgroundColourId, + ColorScheme::accents::mute.withAlpha (0.85f)); + transportBanner.setColour (juce::Label::textColourId, juce::Colours::white); + addChildComponent (transportBanner); + + startTimerHz (8); // the transport gate poll (D41) +} + +AudioInterfaceContent::~AudioInterfaceContent() +{ + stopTimer(); +} + +void AudioInterfaceContent::timerCallback() +{ + const bool playing = context.engine.getFilePlayer().isPlaying(); + if (playing == gateActive) + return; + + gateActive = playing; + if (playing) + { + // D41: no patch edits on a running transport. Stop tones, drop to + // Scrolling, disable everything behind the banner. + if (outputTab != nullptr) outputTab->resetMode(); + if (inputTab != nullptr) inputTab->resetMode(); + tabs.setEnabled (false); + transportBanner.setVisible (true); + transportBanner.toFront (false); + } + else + { + tabs.setEnabled (true); + transportBanner.setVisible (false); + } +} + +void AudioInterfaceContent::windowClosing() +{ + if (outputTab != nullptr) outputTab->resetMode(); + if (inputTab != nullptr) inputTab->resetMode(); +} + +void AudioInterfaceContent::paint (juce::Graphics& g) +{ + g.fillAll (ColorScheme::get().background); +} + +void AudioInterfaceContent::resized() +{ + auto area = getLocalBounds(); + infoBar.setBounds (area.removeFromTop (px (28))); + tabs.setBounds (area); + transportBanner.setBounds (getLocalBounds().withTrimmedTop (px (28)).removeFromTop (px (40))); +} + +//============================================================================== +// AudioInterfaceWindow +//============================================================================== + +AudioInterfaceWindow::AudioInterfaceWindow (AppContext& ctx) + : juce::DocumentWindow (LOC ("audioPatch.window.title"), + ColorScheme::get().background, + juce::DocumentWindow::allButtons) +{ + setUsingNativeTitleBar (true); + content = new AudioInterfaceContent (ctx); + setContentOwned (content, false); + setResizable (true, true); + setResizeLimits (720, 480, 4096, 4096); + centreWithSize (px (1100), px (720)); +} + +void AudioInterfaceWindow::closeButtonPressed() +{ + // A test tone must never outlive the window that started it (§7.2 B). + if (content != nullptr) + content->windowClosing(); + setVisible (false); +} + +} // namespace xoa::ui diff --git a/Source/GUI/Patch/AudioInterfaceWindow.h b/Source/GUI/Patch/AudioInterfaceWindow.h new file mode 100644 index 0000000..bbd259b --- /dev/null +++ b/Source/GUI/Patch/AudioInterfaceWindow.h @@ -0,0 +1,205 @@ +/* + ============================================================================== + + XOA — tenth-order Ambisonics spatial audio processor. + AudioInterfaceWindow — the stage-2 Audio Interface window: device info bar, + Device Settings / Input Patch / Output Patch tabs around the shared + spatcore patch matrix. XOA-owned shell (deliberately not shared — handoff + §7), copied in shape from WFS-DIY's AudioInterfaceWindow/AudioPatchTab and + reduced: one XoaPatchTab class serves both directions, and the transport + gate (D41) replaces WFS-DIY's processing gate — patch edits only while the + file transport is stopped; while it runs the content is disabled behind a + banner and test tones are stopped. + + This file is part of XOA, released under the GNU General Public License + v3.0. See LICENSE for details. + + ============================================================================== +*/ + +#pragma once + +#include +#include + +#include + +#include "Audio/AudioEngine.h" +#include "Parameters/XoaValueTreeState.h" +#include "XoaPatchMatrixShim.h" +#include "../Tabs/TabPage.h" // AppContext +#include "../Widgets/LongPressButton.h" +#include "../Widgets/XoaStandardSlider.h" + +namespace xoa::ui +{ + +//============================================================================== +/** Read-only device facts across the window top: type, device, sample rate, + buffer size and the truthful active channel counts (DeviceHost). */ +class DeviceInfoBar : public juce::Component, + private juce::Timer +{ +public: + explicit DeviceInfoBar (xoa::AudioEngine& engineToUse); + ~DeviceInfoBar() override; + + void paint (juce::Graphics& g) override; + +private: + void timerCallback() override { repaint(); } + + xoa::AudioEngine& engine; + + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (DeviceInfoBar) +}; + +//============================================================================== +/** Device type / device / sample rate / buffer size, plus Control Panel and + Reset Device. No channel selection on purpose: every mutation routes + through the engine's DeviceHost, whose policy opens ALL channels with + explicit masks (§2.2). SR/buffer changes go through one local helper that + re-applies the mask policy afterwards (D39 stays open in spatcore). */ +class DeviceSettingsPanel : public juce::Component, + private juce::ChangeListener +{ +public: + explicit DeviceSettingsPanel (xoa::AudioEngine& engineToUse); + ~DeviceSettingsPanel() override; + + void resized() override; + +private: + void changeListenerCallback (juce::ChangeBroadcaster*) override { updateAllControls(); } + + void updateAllControls(); // rebuild every combo from the manager + void applySampleRateOrBuffer(); // setAudioDeviceSetup + re-assert masks + + xoa::AudioEngine& engine; + juce::AudioDeviceManager& deviceManager; + + juce::Label deviceTypeLabel, deviceLabel, sampleRateLabel, bufferSizeLabel, errorLabel; + juce::ComboBox deviceTypeCombo, deviceCombo, sampleRateCombo, bufferSizeCombo; + juce::TextButton controlPanelButton, resetDeviceButton; + + bool isUpdating = false; + + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (DeviceSettingsPanel) +}; + +//============================================================================== +/** One patch tab: mode buttons + long-press Unpatch All + the shared matrix. + The output tab adds Testing mode with inline test-signal controls (type / + level / frequency with the WFS-DIY non-linear mappings / Hold); the input + tab runs the 20 Hz header-repaint timer that animates the signal-presence + tint. */ +class XoaPatchTab : public juce::Component, + private juce::Timer +{ +public: + XoaPatchTab (AppContext& ctx, bool isInputTab); + ~XoaPatchTab() override; + + void resized() override; + void paint (juce::Graphics& g) override; + + /** Back to Scrolling; stops any test tone (tab switch / window close). */ + void resetMode(); + + /** Stop the test tone without touching the settings. */ + void stopTestAudio(); + + XoaPatchMatrix& getMatrix() { return matrix; } + +private: + void timerCallback() override; + void setMode (XoaPatchMatrix::Mode mode); + void applyTestSettings(); + void updateTestControlsVisibility(); + + static float sliderValueToDb (float v); + static float dbToSliderValue (float dB); + static float sliderValueToFrequency (float v); + static float frequencyToSliderValue (float hz); + + AppContext& context; + const bool isInput; + + juce::TextButton scrollingButton, patchingButton, testingButton; + LongPressButton unpatchAllButton; + + XoaPatchMatrix matrix; + + // Testing controls (output tab only; visible in Testing mode). + juce::ComboBox signalTypeCombo; + juce::TextButton holdButton; + XoaStandardSlider levelSlider, frequencySlider; + juce::Label levelValueLabel, frequencyValueLabel; + + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (XoaPatchTab) +}; + +//============================================================================== +/** Info bar + Device Settings / Input Patch / Output Patch tabs, plus the + transport gate (D41): while the file transport plays, the content is + disabled behind a banner and test tones stop. */ +class AudioInterfaceContent : public juce::Component, + private juce::Timer +{ +public: + explicit AudioInterfaceContent (AppContext& ctx); + ~AudioInterfaceContent() override; + + void resized() override; + void paint (juce::Graphics& g) override; + + /** Stop tones + reset modes (window closing). */ + void windowClosing(); + +private: + /** Tab switches must stop a running test tone (§7.2 B exit paths). */ + struct GateTabbedComponent : juce::TabbedComponent + { + GateTabbedComponent() : juce::TabbedComponent (juce::TabbedButtonBar::TabsAtTop) {} + std::function onTabChanged; + void currentTabChanged (int newIndex, const juce::String&) override + { + if (onTabChanged) + onTabChanged (newIndex); + } + }; + + void timerCallback() override; // the transport gate poll + + AppContext& context; + + DeviceInfoBar infoBar; + GateTabbedComponent tabs; + juce::Label transportBanner; + + // Owned by the TabbedComponent. + DeviceSettingsPanel* devicePanel = nullptr; + XoaPatchTab* inputTab = nullptr; + XoaPatchTab* outputTab = nullptr; + + bool gateActive = false; + + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (AudioInterfaceContent) +}; + +//============================================================================== +class AudioInterfaceWindow : public juce::DocumentWindow +{ +public: + explicit AudioInterfaceWindow (AppContext& ctx); + ~AudioInterfaceWindow() override = default; + + void closeButtonPressed() override; + +private: + AudioInterfaceContent* content = nullptr; // owned by the DocumentWindow + + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (AudioInterfaceWindow) +}; + +} // namespace xoa::ui diff --git a/Source/GUI/Tabs/SystemConfigTab.cpp b/Source/GUI/Tabs/SystemConfigTab.cpp index 700f6ab..8092f6c 100644 --- a/Source/GUI/Tabs/SystemConfigTab.cpp +++ b/Source/GUI/Tabs/SystemConfigTab.cpp @@ -62,12 +62,20 @@ SystemConfigTab::SystemConfigTab (AppContext& ctx) : TabPage (ctx, Surface::syst bindings.bindCombo (contentOrderCombo, ids::playbackContentOrder); bindings.bindCombo (conventionCombo, ids::playbackConvention); - // Audio device (audioDeviceState is persisted by the engine's device manager) - deviceSelector = std::make_unique ( - context.engine.getDeviceManager(), - 0, xoa::kMaxInputs, 0, xoa::kMaxSpeakers, - false, false, false, false); - addAndMakeVisible (*deviceSelector); + // Audio device + patching live in the Audio Interface window (stage 2): + // every device mutation there routes through DeviceHost, which the stock + // selector could not do. audioDeviceState is still persisted by the + // engine's device manager. + audioInterfaceButton.setButtonText (LOC ("systemConfig.audioInterface")); + audioInterfaceButton.onClick = [this] { openAudioInterfaceWindow(); }; + addAndMakeVisible (audioInterfaceButton); + + deviceSummaryLabel.setJustificationType (juce::Justification::centredLeft); + addAndMakeVisible (deviceSummaryLabel); + + deviceErrorLabel.setJustificationType (juce::Justification::centredLeft); + deviceErrorLabel.setColour (juce::Label::textColourId, ColorScheme::accents::mute); + addAndMakeVisible (deviceErrorLabel); // Appearance: theme + language themeLabel.setText (LOC ("systemConfig.theme"), juce::dontSendNotification); @@ -115,6 +123,34 @@ void SystemConfigTab::colorSchemeChanged() repaint(); } +void SystemConfigTab::openAudioInterfaceWindow() +{ + if (audioInterfaceWindow == nullptr) + audioInterfaceWindow = std::make_unique (context); + + audioInterfaceWindow->setVisible (true); + audioInterfaceWindow->toFront (true); +} + +void SystemConfigTab::refresh() +{ + TabPage::refresh(); + + auto& engine = context.engine; + juce::String summary; + if (auto* device = engine.getDeviceManager().getCurrentAudioDevice()) + summary << device->getName() + << " · " << juce::String (device->getCurrentSampleRate() / 1000.0, 1) << " kHz" + << " · " << device->getCurrentBufferSizeSamples() << " smp" + << " · " << engine.getDeviceHost().getNumActiveInputs() << " in / " + << engine.getDeviceHost().getNumActiveOutputs() << " out"; + else + summary = LOC ("audioPatch.info.noDevice"); + + deviceSummaryLabel.setText (summary, juce::dontSendNotification); + deviceErrorLabel.setText (engine.getLastDeviceError(), juce::dontSendNotification); +} + void SystemConfigTab::loadProjectDialog() { fileChooser = std::make_unique (LOC ("systemConfig.loadProject"), @@ -171,8 +207,14 @@ void SystemConfigTab::resized() auto right = area.removeFromRight (juce::jmax (px (280), area.getWidth() / 2)); right.removeFromLeft (px (8)); deviceGroup.setBounds (right); - if (deviceSelector != nullptr) - deviceSelector->setBounds (right.reduced (px (10), px (22))); + { + auto deviceArea = right.reduced (px (10), px (22)); + audioInterfaceButton.setBounds (deviceArea.removeFromTop (px (34)) + .removeFromLeft (px (220))); + deviceArea.removeFromTop (px (10)); + deviceSummaryLabel.setBounds (deviceArea.removeFromTop (px (26))); + deviceErrorLabel.setBounds (deviceArea.removeFromTop (px (26))); + } auto& col = area; const int rowH = px (32); diff --git a/Source/GUI/Tabs/SystemConfigTab.h b/Source/GUI/Tabs/SystemConfigTab.h index 8249e10..dbd22fb 100644 --- a/Source/GUI/Tabs/SystemConfigTab.h +++ b/Source/GUI/Tabs/SystemConfigTab.h @@ -14,11 +14,11 @@ #pragma once #include -#include // AudioDeviceSelectorComponent #include #include "TabPage.h" +#include "../Patch/AudioInterfaceWindow.h" namespace xoa::ui { @@ -32,11 +32,14 @@ class SystemConfigTab : public TabPage, void resized() override; + void refresh() override; + private: void colorSchemeChanged() override; void loadProjectDialog(); void saveProjectDialog(); void importWfsDialog(); + void openAudioInterfaceWindow(); juce::GroupComponent showGroup, playbackGroup, deviceGroup, appearanceGroup; @@ -50,7 +53,13 @@ class SystemConfigTab : public TabPage, juce::Label themeLabel, languageLabel; juce::ComboBox themeCombo, languageCombo; - std::unique_ptr deviceSelector; + // The stock AudioDeviceSelectorComponent is gone (stage 2): device + // settings and patching live in the Audio Interface window, which owns + // the DeviceHost-routed mutations. + juce::TextButton audioInterfaceButton; + juce::Label deviceSummaryLabel, deviceErrorLabel; + std::unique_ptr audioInterfaceWindow; + std::unique_ptr fileChooser; JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (SystemConfigTab) From 847e9155110bf742d665bdca97c5ba9a43560f61 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Boulant Date: Sun, 2 Aug 2026 20:53:30 +0200 Subject: [PATCH 11/12] test+docs: patch routing, HOA merge, and the D41-D47 records Tests (tests/XoaPatchTests.cpp): stem spans and the kMaxStemChannels ceiling stepping formats down; the identity default patch; a patchData round-trip through the routing POD including a NON-CONTIGUOUS speaker assignment and an unpatched row; a format change preserving the other inputs' hardware channels while re-spanning the rows; speaker growth keeping the identity continuation; and the HOA group merge verified against a double reference decode (component c -> bus channel c at the order-adapt gain, silent above the group's order). Also adds rt::makeMonoEncoderParams: EncoderRtParams gained per-input stem offsets, and brace-initialising it left them all zero, which points every input at stem row 0. The factory states the all-mono contract explicitly; the existing encoder tests now use it. Docs: D41-D47 in XOA-DEVPLAN.md (transport gate, both matrices, flattened rows, inputFormat, the stem ceiling, the hardware-domain test signal, clusters deferred), D24 marked superseded, and the handoff's status table updated to ADOPTED. Co-Authored-By: Claude Fable 5 --- .../XOA-AUDIO-DEVICE-AND-PATCH-HANDOFF.md | 12 +- Documentation/XOA-DEVPLAN.md | 42 +++ Source/DSP/AmbiRtTypes.h | 19 ++ tests/CMakeLists.txt | 1 + tests/XoaEncoderTests.cpp | 12 +- tests/XoaPatchTests.cpp | 286 ++++++++++++++++++ tests/XoaTests.cpp | 2 + 7 files changed, 365 insertions(+), 9 deletions(-) create mode 100644 tests/XoaPatchTests.cpp diff --git a/Documentation/XOA-AUDIO-DEVICE-AND-PATCH-HANDOFF.md b/Documentation/XOA-AUDIO-DEVICE-AND-PATCH-HANDOFF.md index 7293f4a..7d50276 100644 --- a/Documentation/XOA-AUDIO-DEVICE-AND-PATCH-HANDOFF.md +++ b/Documentation/XOA-AUDIO-DEVICE-AND-PATCH-HANDOFF.md @@ -16,10 +16,16 @@ device layer is additive. | Piece | Where | State | |---|---|---| -| `spatcore::io` device layer | `spatcore/io/` on spatcore `main` | **AVAILABLE** — merged as `8e0d7e6` (PR #5), CI green on Windows/macOS/Linux | +| `spatcore::io` device layer | `spatcore/io/` on spatcore `main` | **ADOPTED** by XOA (stage 1 complete, hardware acceptance pending) | | WFS-DIY consuming it | `feat/patch-diagnostics-512ch` in `d:/dev/WFS_DIY_v1` | **DONE, not yet merged** — the reference wiring to copy | -| **Patch matrix** in `spatcore/ui/patch/` | `spatcore` branch `feat/shared-patch-matrix` | **AVAILABLE** — the scrollable grid, fully app-agnostic | -| Patch tabs + Audio Interface window | app-side in WFS-DIY only | **DELIBERATELY NOT SHARED** — XOA builds its own shell. See §7 | +| **Patch matrix** in `spatcore/ui/patch/` | spatcore `main` (pin `7d293e4`) | **ADOPTED** by XOA via `Source/GUI/Patch/XoaPatchMatrixShim.{h,cpp}` | +| Patch tabs + Audio Interface window | app-side in WFS-DIY only | **NOT SHARED** — XOA built its own: `Source/GUI/Patch/AudioInterfaceWindow.{h,cpp}` | + +> **Status (2026-08-02).** Both stages are implemented on branch +> `feat/spatcore-io-stage1`; decisions are recorded as D35–D47 in +> `XOA-DEVPLAN.md`. Stage 2 additionally introduced per-input stem formats +> (mono or an AmbiX group, D44) — not in the original scope below. What +> remains is the §6 hardware acceptance pass. So this is a **two-stage** migration, and stage 1 does not depend on stage 2: diff --git a/Documentation/XOA-DEVPLAN.md b/Documentation/XOA-DEVPLAN.md index 3f3de07..1335799 100644 --- a/Documentation/XOA-DEVPLAN.md +++ b/Documentation/XOA-DEVPLAN.md @@ -225,6 +225,46 @@ critical path. Small spatcore change; take it with stage 2. - **D40 — spatcore pins stay bare SHAs**; no v0.2.0 tag gate for the io layer. +**Patch window and multi-format stems (D41–D47)** — stage 2 of the same +handoff: XOA adopts the shared `spatcore::ui::patch` matrix and builds its own +shell, and an input stem stops being implicitly mono. + +- **D41 — Patching is transport-gated.** XOA has no processing on/off state + (the engine decodes whenever the device is open), so the file transport is + the gate: while it plays, the Audio Interface window stops its tones, drops + to Scrolling and disables its tabs behind a banner. Patching a live rig is + how speakers get destroyed; this is the closest equivalent to WFS-DIY's + processing gate that XOA's architecture affords. +- **D42 — Both matrices ship.** Device Settings / Input Patch / Output Patch. + Stems stop being identity-mapped from device inputs, which is what makes an + interface whose mic lines do not start at channel 1 usable. +- **D43 — The input matrix rows are FLATTENED stem channels**, one per channel + of every input's span, not one per input. The shared matrix is then used + untouched (its 1:1 constraint still means one hardware channel per row), and + an Ambisonic group may legitimately sit on non-contiguous hardware channels. + Rows are labelled `" · ACN "` and share their input's colour, so a + group reads as one block. +- **D44 — `inputFormat` is Mono | HOA order 1–10**, AmbiX (ACN/SN3D) assumed — + no per-input convention in v1, matching the project-wide convention. A group + merges into the order-10 bus through `weights::orderAdaptGains` (zero-padded + upmix) times the input gain, composed into the SAME `liveMatrix` row the mono + path uses, so the RT contract (one 121-float row per input) is unchanged. + Position, spread, NFC and the conditioning dials are inert for groups and are + greyed in the Inputs tab. +- **D45 — `kMaxStemChannels = 128`** bounds the sum of all input spans (one + order-10 group is 121). Over the ceiling, formats step down last-HOA-first. +- **D46 — The test signal moved to the HARDWARE domain**, injected after the + output scatter, so the matrix's Testing mode can reach any open device output + including ones no speaker is patched to. Decode and per-speaker comp now run + on a speaker-domain scratch sized by the STORE's speaker count, decoupled + from the device output count. +- **D47 — Clusters (linking several stems) are deferred.** Format is per-input + and nothing keys on a group's hardware channels being contiguous, so clusters + can layer on top without reopening D43/D44. + +D24 ("PatchMatrixComponent not ported; v1 keeps identity channel mapping") is +superseded by D41–D46; the identity mapping survives only as the default patch. + --- ## 5. Work packages @@ -861,6 +901,8 @@ xvfb GUI-smoke gate green; all M1/M2/M3 offline baselines unchanged): **Decisions (D24–D34).** - **D24** — PatchMatrixComponent not ported; v1 keeps identity channel mapping. + **Superseded by D41–D46** (stage 2 adopts the shared matrix; identity + survives as the default patch). - **D25** — Network tab exposes the single OSC send-target only; multi-target → WP12. - **D26** — Localization ships EN + FR (minimal tier), proving the overlay scaffold. - **D27** — A persistent HeaderBar hosts transport/rotation/master/status; StatusBar diff --git a/Source/DSP/AmbiRtTypes.h b/Source/DSP/AmbiRtTypes.h index c5ed687..76ec4d7 100644 --- a/Source/DSP/AmbiRtTypes.h +++ b/Source/DSP/AmbiRtTypes.h @@ -128,6 +128,25 @@ static_assert (std::is_trivially_copyable_v, "EncoderRtParams must be a POD for RtSnapshot"); static_assert (xoa::kMaxInputs <= 64, "EncoderRtParams::nfcMask is a 64-bit mask"); +/** All-mono encoder params: input i occupies stem row i, the pre-stage-2 + contract. Use this rather than aggregate initialisation — brace-init + leaves stemOffset all-zero, which points every input at stem row 0. */ +inline EncoderRtParams makeMonoEncoderParams (int numSources, juce::uint64 nfcMask, + float referenceRadius, juce::uint32 epoch) noexcept +{ + EncoderRtParams p; + p.numSources = numSources; + p.nfcMask = nfcMask; + p.referenceRadius = referenceRadius; + p.epoch = epoch; + for (int i = 0; i < xoa::kMaxInputs; ++i) + { + p.stemOffset[i] = i; + p.stemOrder[i] = 0; + } + return p; +} + //============================================================================== // Composers (message thread; also compiled by the harness and tests). //============================================================================== diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index e8879d1..8ff7ddc 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -27,6 +27,7 @@ target_sources(xoa-tests PRIVATE XoaUiDescriptorTests.cpp XoaLayoutGeneratorTests.cpp XoaRvReViewTests.cpp + XoaPatchTests.cpp ../Source/Parameters/XoaValueTreeState.cpp ../Source/Parameters/XoaFileManager.cpp ../Source/Audio/FilePlayer.cpp diff --git a/tests/XoaEncoderTests.cpp b/tests/XoaEncoderTests.cpp index cf1ef5e..7038c90 100644 --- a/tests/XoaEncoderTests.cpp +++ b/tests/XoaEncoderTests.cpp @@ -87,7 +87,7 @@ void testEncoderNullDecode() // one source on the ring radius (distance gain 1), front, NFC off. xoa::enc::SourceParams sp; sp.x = 2.0; xoa::enc::composeRow (sp, 2.0, h.encMatrix.data()); // row for source 0 - h.encSnap.publish ({ 1, 0, 2.0f, 1u }); + h.encSnap.publish (xoa::rt::makeMonoEncoderParams (1, 0, 2.0f, 1u)); xoa::AmbiBusAlgorithm algo; algo.prepare (xoa::kMaxInputs, Harness::numOut, 48000.0, n, &h.builder, &h.rotSnap, &h.busSnap, @@ -153,7 +153,7 @@ void testEncoderNeutrality() // Test: encoder seams present but numSources == 0, with a NONZERO stems buffer. std::vector encMatrix ((size_t) xoa::kMaxInputs * xoa::kNumSHChannels, 0.5f); std::vector nfcPages ((size_t) xoa::kMaxInputs * xoa::nfc::kCoeffsPerSource, 0.0); - spatcore::rt::RtSnapshot encSnap; encSnap.publish ({ 0, 0, 2.0f, 1u }); + spatcore::rt::RtSnapshot encSnap; encSnap.publish (xoa::rt::makeMonoEncoderParams (0, 0, 2.0f, 1u)); juce::AudioBuffer testOut (numOut, n); { @@ -180,7 +180,7 @@ void testEncoderRampInOut() const int n = 256; xoa::enc::SourceParams sp; sp.x = 2.0; xoa::enc::composeRow (sp, 2.0, h.encMatrix.data()); - h.encSnap.publish ({ 1, 0, 2.0f, 1u }); + h.encSnap.publish (xoa::rt::makeMonoEncoderParams (1, 0, 2.0f, 1u)); xoa::AmbiBusAlgorithm algo; algo.prepare (xoa::kMaxInputs, Harness::numOut, 48000.0, n, &h.builder, &h.rotSnap, &h.busSnap, @@ -217,7 +217,7 @@ void testEncoderRampInOut() // Deactivate: numSources 0, keep the stem. The block ramps the contribution // OUT using the still-present audio (starts ~steady, ends near 0 - JUCE's // linear ramp reaches ~steady/n at the last sample, not exactly 0). - h.encSnap.publish ({ 0, 0, 2.0f, 2u }); + h.encSnap.publish (xoa::rt::makeMonoEncoderParams (0, 0, 2.0f, 2u)); runBlock(); double outStart = 0.0, outEnd = 0.0; for (int s = 0; s < Harness::numOut; ++s) @@ -248,7 +248,7 @@ void testEncoderNfcDcGain() xoa::enc::SourceParams sp; sp.x = 1.0; xoa::enc::composeRow (sp, 2.0, h.encMatrix.data()); xoa::nfc::designSourceSections (1.0, 2.0, 48000.0, h.nfcPages.data()); - h.encSnap.publish ({ 1, (juce::uint64) 1, 2.0f, 1u }); // nfcMask bit 0 set + h.encSnap.publish (xoa::rt::makeMonoEncoderParams (1, (juce::uint64) 1, 2.0f, 1u)); // nfcMask bit 0 set xoa::AmbiBusAlgorithm algo; algo.prepare (xoa::kMaxInputs, Harness::numOut, 48000.0, n, &h.builder, &h.rotSnap, &h.busSnap, @@ -287,7 +287,7 @@ void testStemsFewerThanSources() xoa::enc::SourceParams sp; sp.x = 2.0; sp.y = 0.3 * i; xoa::enc::composeRow (sp, 2.0, h.encMatrix.data() + (size_t) i * xoa::kNumSHChannels); } - h.encSnap.publish ({ 4, 0, 2.0f, 1u }); // 4 sources requested + h.encSnap.publish (xoa::rt::makeMonoEncoderParams (4, 0, 2.0f, 1u)); // 4 sources requested xoa::AmbiBusAlgorithm algo; algo.prepare (xoa::kMaxInputs, Harness::numOut, 48000.0, n, &h.builder, &h.rotSnap, &h.busSnap, diff --git a/tests/XoaPatchTests.cpp b/tests/XoaPatchTests.cpp new file mode 100644 index 0000000..e01b27b --- /dev/null +++ b/tests/XoaPatchTests.cpp @@ -0,0 +1,286 @@ +/* + XoaPatchTests.cpp - stage 2: per-input stem formats (D44/D45), the + AudioPatch schema and its reconcile (D43), the routing POD the audio + thread uses, and the HOA group merge in the bus algorithm. +*/ + +#include "XoaTestFramework.h" + +#include "XoaConstants.h" +#include "Audio/PatchRouting.h" +#include "DSP/AmbiBusAlgorithm.h" +#include "DSP/AmbiNFCFilter.h" +#include "DSP/AmbiOrderWeights.h" +#include "DSP/AmbiSphericalHarmonics.h" +#include "DSP/DecoderMatrixBuilder.h" +#include "Helpers/XoaCoordinates.h" +#include "Parameters/XoaParameterIDs.h" +#include "Parameters/XoaValueTreeState.h" + +#include "spatcore/rt/RtSnapshot.h" + +#include +#include + +namespace +{ + +using xoa::XoaValueTreeState; +namespace ids = xoa::ids; + +// double reference: output[s] = sum_{c < K} D.at(s,c) * busVec[c]. +double decodeRef (const xoa::decoder::DecoderMatrix& D, const double* busVec, int s) +{ + const int K = xoa::sh::numChannels (D.order); + double acc = 0.0; + for (int c = 0; c < K; ++c) + acc += D.at (s, c) * busVec[c]; + return acc; +} + +//============================================================================== +// Spans: mono is one channel, an HOA group is (order+1)^2, and the offsets are +// the running sum — the flattened addressing every other stage indexes by. +void testStemSpans() +{ + CHECK (XoaValueTreeState::channelCountForFormat (0) == 1); + CHECK (XoaValueTreeState::channelCountForFormat (1) == 4); + CHECK (XoaValueTreeState::channelCountForFormat (2) == 9); + CHECK (XoaValueTreeState::channelCountForFormat (10) == 121); + + XoaValueTreeState store; + store.setNumInputs (4); + CHECK (store.getTotalStemChannels() == 4); // all mono + CHECK (store.getStemChannelOffset (2) == 2); + + store.setParameter (ids::inputFormat, 1, 0); // input 0 -> FOA + CHECK (store.getInputChannelCount (0) == 4); + CHECK (store.getStemChannelOffset (0) == 0); + CHECK (store.getStemChannelOffset (1) == 4); // input 1 starts after the group + CHECK (store.getTotalStemChannels() == 7); // 4 + 1 + 1 + 1 + + store.setParameter (ids::inputFormat, 0, 0); // back to mono + CHECK (store.getTotalStemChannels() == 4); +} + +//============================================================================== +// The kMaxStemChannels ceiling holds by stepping formats down, last HOA input +// first. Two order-10 groups (121 each) cannot both fit in 128. +void testStemSpanCeiling() +{ + XoaValueTreeState store; + store.setNumInputs (2); + + store.setParameter (ids::inputFormat, 10, 0); + CHECK (store.getTotalStemChannels() <= xoa::kMaxStemChannels); + CHECK (store.getInputFormat (0) == 10); // 121 + 1 fits + + store.setParameter (ids::inputFormat, 10, 1); // 121 + 121 does not + CHECK (store.getTotalStemChannels() <= xoa::kMaxStemChannels); + CHECK (store.getInputFormat (0) == 10); // the earlier input keeps its order + CHECK (store.getInputFormat (1) < 10); // the later one was stepped down +} + +//============================================================================== +// A fresh project patches identity, and the routing POD reproduces it. +void testDefaultPatchIsIdentity() +{ + XoaValueTreeState store; + store.setNumInputs (4); + store.setNumSpeakers (6); + + const auto patch = xoa::rt::composePatchRouting (store, 1); + + CHECK (patch.numInputs == 4); + CHECK (patch.numStemChannels == 4); + for (int k = 0; k < 4; ++k) + { + CHECK (patch.hwForStemChannel[k] == k); + CHECK (patch.stemOffset[k] == k); + CHECK (patch.stemSpan[k] == 1); + } + for (int s = 0; s < 6; ++s) + CHECK (patch.hwForSpeaker[s] == s); +} + +//============================================================================== +// An explicit patchData string round-trips into the routing maps, including a +// NON-CONTIGUOUS assignment (the case the whole hardware-indexing design +// exists for) and an unpatched row. +void testPatchDataRoundTrip() +{ + XoaValueTreeState store; + store.setNumSpeakers (3); + + auto tree = store.getOutputPatchTree(); + CHECK (tree.isValid()); + + // speaker 0 -> hw 5, speaker 1 -> hw 2, speaker 2 -> unpatched + juce::StringArray rows; + rows.add ("0,0,0,0,0,1"); + rows.add ("0,0,1,0,0,0"); + rows.add ("0,0,0,0,0,0"); + tree.setProperty (ids::patchData, rows.joinIntoString (";"), nullptr); + + const auto patch = xoa::rt::composePatchRouting (store, 2); + CHECK (patch.hwForSpeaker[0] == 5); + CHECK (patch.hwForSpeaker[1] == 2); + CHECK (patch.hwForSpeaker[2] == -1); + CHECK (patch.epoch == 2); +} + +//============================================================================== +// A format change re-spans the input patch rows: every other input keeps its +// hardware channel, and the row count follows the new total. +void testReconcilePreservesOtherInputs() +{ + XoaValueTreeState store; + store.setNumInputs (4); + + auto tree = store.getInputPatchTree(); + CHECK (tree.isValid()); + CHECK ((int) tree.getProperty (ids::rows) == 4); + + // Park inputs 1..3 on distinctive hardware channels, input 0 on hw 0. + juce::StringArray rows; + rows.add ("1,0,0,0,0,0,0,0,0,0"); // input 0 -> hw 0 + rows.add ("0,0,0,0,0,0,0,1,0,0"); // input 1 -> hw 7 + rows.add ("0,0,0,0,0,0,0,0,1,0"); // input 2 -> hw 8 + rows.add ("0,0,0,0,0,0,0,0,0,1"); // input 3 -> hw 9 + tree.setProperty (ids::patchData, rows.joinIntoString (";"), nullptr); + store.reconcileAudioPatch(); // sync the span cache to this layout + + store.setParameter (ids::inputFormat, 1, 0); // input 0 -> FOA (4 rows) + + CHECK (store.getTotalStemChannels() == 7); + CHECK ((int) store.getInputPatchTree().getProperty (ids::rows) == 7); + + const auto patch = xoa::rt::composePatchRouting (store, 3); + CHECK (patch.numStemChannels == 7); + CHECK (patch.stemSpan[0] == 4); + CHECK (patch.stemOffset[1] == 4); + + // Input 0's first component keeps its channel; the inputs after it keep + // theirs, now addressed through their shifted flattened rows. + CHECK (patch.hwForStemChannel[0] == 0); + CHECK (patch.hwForStemChannel[4] == 7); + CHECK (patch.hwForStemChannel[5] == 8); + CHECK (patch.hwForStemChannel[6] == 9); +} + +//============================================================================== +// Growing the speaker count extends the output patch with the identity +// continuation, so a project that never opens the patch window keeps behaving +// exactly as it did before patching existed. +void testSpeakerGrowthKeepsIdentity() +{ + XoaValueTreeState store; + store.setNumSpeakers (4); + store.setNumSpeakers (8); + + const auto patch = xoa::rt::composePatchRouting (store, 4); + for (int s = 0; s < 8; ++s) + CHECK (patch.hwForSpeaker[s] == s); +} + +//============================================================================== +// The HOA merge, against a double reference decode (the XoaEncoderTests +// idiom): an order-1 group's channel c lands on BUS channel c at its +// order-adapt x gain factor, and contributes nothing above its own order. +void testHoaGroupMergeIntoBus() +{ + constexpr int numOut = 24; + constexpr int n = 64; + + xoa::decoder::SpeakerLayout layout; + layout.count = numOut; + for (int s = 0; s < numOut; ++s) + layout.positions[s] = xoa::coords::sphericalToCartesian ( + { 2.0, xoa::coords::normalizeAzimuthDegrees (360.0 * s / numOut), 0.0 }); + + xoa::DecoderMatrixBuilder builder; + builder.rebuild (layout, xoa::decoder::DesignOptions {}); + builder.publish(); + + spatcore::rt::RtSnapshot rotSnap; // unpublished -> unrotated + spatcore::rt::RtSnapshot busSnap; + busSnap.publish (xoa::rt::makeBusParams (0, 3, 0, 0, 0.0, 1u)); // silent HOA gather + + // Input 0 is an order-1 group. Its liveMatrix row carries the order-adapt + // gains times a 0.5 linear input gain — exactly what composeHoaRow builds. + std::vector encMatrix ((size_t) xoa::kMaxInputs * xoa::kNumSHChannels, 0.0f); + std::vector nfcPages ((size_t) xoa::kMaxInputs * xoa::nfc::kCoeffsPerSource, 0.0); + double adapt[xoa::kNumSHChannels]; + xoa::weights::orderAdaptGains (1, xoa::kAmbisonicOrder, adapt); + constexpr float groupGain = 0.5f; + for (int c = 0; c < xoa::kNumSHChannels; ++c) + encMatrix[(size_t) c] = (float) adapt[c] * groupGain; + + spatcore::rt::RtSnapshot encSnap; + xoa::rt::EncoderRtParams enc = xoa::rt::makeMonoEncoderParams (1, 0, 2.0f, 1u); + enc.stemOrder[0] = 1; // FOA: stem rows 0..3 + enc.stemOffset[0] = 0; + encSnap.publish (enc); + + xoa::AmbiBusAlgorithm algo; + algo.prepare (xoa::kMaxInputs, numOut, 48000.0, n, &builder, &rotSnap, &busSnap, + true, encMatrix.data(), nfcPages.data(), &encSnap); + + // Four distinct component signals so a mis-mapped channel is visible. + juce::AudioBuffer stems (4, n); + for (int c = 0; c < 4; ++c) + { + float* d = stems.getWritePointer (c); + for (int i = 0; i < n; ++i) + d[i] = 0.2f * std::sin (0.03f * (float) (i + 1) * (float) (c + 1)); + } + + juce::AudioBuffer hoa (1, n); hoa.clear(); + juce::AudioBuffer out (numOut, n); + + auto runBlock = [&] + { + out.clear(); + juce::AudioSourceChannelInfo info (&out, 0, n); + algo.processBlock (info, hoa, 0, numOut, &stems, 4); + }; + runBlock(); // block 1: coefficients ramp in + runBlock(); // block 2: steady + + // Reference: bus channel c = stem row c * adapt[c] * gain for c < 4, and + // zero above the group's order. Then decode through the same matrix. + const auto& D = builder.masterMatrix(); + double worst = 0.0; + for (int i = 0; i < n; ++i) + { + double busVec[xoa::kNumSHChannels] = {}; + for (int c = 0; c < 4; ++c) + busVec[c] = (double) stems.getReadPointer (c)[i] * adapt[c] * (double) groupGain; + + for (int s = 0; s < numOut; ++s) + worst = juce::jmax (worst, std::abs (decodeRef (D, busVec, s) + - (double) out.getSample (s, i))); + } + CHECK (worst < 1.0e-5); + + // And the group really is silent above its own order: zeroing the four + // component rows must silence the output entirely. + stems.clear(); + runBlock(); + for (int s = 0; s < numOut; ++s) + CHECK (out.getMagnitude (s, 0, n) < 1.0e-6f); +} + +} // namespace + +//============================================================================== +void runXoaPatchTests() +{ + testStemSpans(); + testStemSpanCeiling(); + testDefaultPatchIsIdentity(); + testPatchDataRoundTrip(); + testReconcilePreservesOtherInputs(); + testSpeakerGrowthKeepsIdentity(); + testHoaGroupMergeIntoBus(); +} diff --git a/tests/XoaTests.cpp b/tests/XoaTests.cpp index 58f1bf0..76b42ed 100644 --- a/tests/XoaTests.cpp +++ b/tests/XoaTests.cpp @@ -62,6 +62,7 @@ void runXoaLocalizationTests(); void runXoaUiDescriptorTests(); void runXoaLayoutGeneratorTests(); void runXoaRvReViewTests(); +void runXoaPatchTests(); //============================================================================== static void testXoaConstants() @@ -134,6 +135,7 @@ int main() runXoaUiDescriptorTests(); runXoaLayoutGeneratorTests(); runXoaRvReViewTests(); + runXoaPatchTests(); } catch (const std::exception& e) { From a5e106255bd5991664440212a9b6de596526ee58 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Boulant Date: Sun, 2 Aug 2026 22:04:18 +0200 Subject: [PATCH 12/12] refactor!: XOA is a processor - remove the file player (D48-D50) Architectural correction: program material is played by external apps (Reaper, QLab, ...) and arrives through the device inputs as stems and AmbiX HOA groups; there is no cueing mechanism in this app. A WFS-DIY style interactive sampler may come later - a plain file player will not. - FilePlayer and the header transport (Browse/Play/Stop/Loop, position bar, file label) are gone; the header keeps a Test-scene latch, the rotation dials, master gain and the status line. InputSource is now { none, testScene } with none the default: the no-source publish is a zero-channel BusRtParams whose gather clears busA every block while the encoder stage accumulates the device stems on top. makeBusParams, BusRtParams and the gather loop are byte-identical - the offline bit-exactness smoke passes unchanged. - The patch window's transport gate is dropped (D49, supersedes D41's mechanism): an external transport cannot be sensed, so a local gate would be theatre. Tones still stop on every exit path; the universal 500 ms ramp remains the safety property. - playbackFilePath/Loop/ContentOrder/Convention leave the schema, descriptors, registry, constraints, OSC bindings and locale in one commit (the descriptor-parity gate enforces the lockstep). OSC map bumped to v1.1 with a documented removal of the three /xoa/config/playback* leaves (D50); live HOA groups stay AmbiX-only, per-input convention/order override deferred to the clusters era. - Tests: XoaFilePlayerTests becomes XoaFileFormatTests, keeping the WP6 C1 large-channel WAV/FLAC spike (any future file import builds on it) and dropping the player half. The persistence test now proves a pre-D48 project file carrying the removed ids loads harmlessly. - Docs: PRD FR-8 rewritten (external program input), WP6 records annotated as historical, D48-D50 recorded, kMaxFileChannels re-documented (value baked into offline baselines, name kept). Verified: Release + Debug builds, ctest green (offline-render smoke UNCHANGED), --gui-smoke both configs, app launches and responds. Co-Authored-By: Claude Fable 5 --- CMakeLists.txt | 1 - Documentation/XOA-DEVPLAN.md | 36 ++++- Documentation/XOA-OSC-MAP.md | 21 ++- Documentation/XOA_PRD.md | 2 +- Resources/lang/en.json | 11 +- Source/App/AppShell.cpp | 3 +- Source/App/HeaderBar.cpp | 112 +++------------ Source/App/HeaderBar.h | 27 ++-- Source/Audio/AudioEngine.cpp | 43 ++---- Source/Audio/AudioEngine.h | 24 ++-- Source/Audio/FilePlayer.cpp | 133 ------------------ Source/Audio/FilePlayer.h | 91 ------------ Source/DSP/AmbiRtTypes.h | 18 ++- Source/GUI/Binding/TabParameterRegistry.h | 6 +- Source/GUI/Binding/UiParameterDescriptors.h | 6 - Source/GUI/Patch/AudioInterfaceWindow.cpp | 39 ----- Source/GUI/Patch/AudioInterfaceWindow.h | 29 ++-- Source/GUI/Tabs/SystemConfigTab.cpp | 23 --- Source/GUI/Tabs/SystemConfigTab.h | 5 +- Source/Network/OSCMessageRouter.h | 4 +- Source/Parameters/XoaConstraints.h | 5 +- Source/Parameters/XoaParameterDefaults.h | 9 -- Source/Parameters/XoaParameterIDs.h | 7 - Source/Parameters/XoaValueTreeState.cpp | 4 - Source/XoaConstants.h | 6 +- tests/CMakeLists.txt | 5 +- ...PlayerTests.cpp => XoaFileFormatTests.cpp} | 122 +--------------- tests/XoaParameterTests.cpp | 27 +--- tests/XoaTests.cpp | 6 +- tests/XoaUiDescriptorTests.cpp | 2 +- 30 files changed, 147 insertions(+), 680 deletions(-) delete mode 100644 Source/Audio/FilePlayer.cpp delete mode 100644 Source/Audio/FilePlayer.h rename tests/{XoaFilePlayerTests.cpp => XoaFileFormatTests.cpp} (56%) diff --git a/CMakeLists.txt b/CMakeLists.txt index 2fefa11..5e7fe25 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -64,7 +64,6 @@ target_sources(XOA PRIVATE Source/Main.cpp Source/Parameters/XoaValueTreeState.cpp Source/Parameters/XoaFileManager.cpp - Source/Audio/FilePlayer.cpp Source/Audio/AudioEngine.cpp Source/DSP/AmbiCalculationEngine.cpp Source/DSP/ConvexHull.cpp diff --git a/Documentation/XOA-DEVPLAN.md b/Documentation/XOA-DEVPLAN.md index 1335799..e71664f 100644 --- a/Documentation/XOA-DEVPLAN.md +++ b/Documentation/XOA-DEVPLAN.md @@ -42,7 +42,7 @@ parameter store + XML project I/O (WP2), the SH math core (WP3), SO(3) rotation + mirror (WP4), the SAD/mode-matching decoder designer + rV/rE (WP5), and the RT bus engine — gather (convention + FR-7 order adaptation) → click-free SO(3) rotation → -decode GEMM → master gain → device outs — with multichannel file playback, a +decode GEMM → master gain → device outs — with multichannel file playback (removed later by D48), a synthetic order-10 test-scene generator, an offline-render bit-exact harness, and a throwaway shell UI (WP6). The `xoa-tests` suite and the `xoa-offline-render-smoke` run on all three CI OSes. @@ -63,7 +63,7 @@ hardware in CI). | WP3 | SH math core: evaluation, conventions, order weights | M1 part | P2 | WP1 | M | **DONE** | | WP4 | Rotation & mirror | M1 part | P2 | WP3 | M | **DONE** | | WP5 | Speaker layout & decoder designer v1 (SAD + mode-matching, rV/rE) | M1 part | P2 | WP2, WP3 | L | **DONE** | -| WP6 | RT bus engine, file playback, minimal shell — **first audible** | **M1 exit** | P2 + P5 sliver | WP2, WP4, WP5 | XL | **DONE (M1)** | +| WP6 | RT bus engine, file playback (since removed, D48), minimal shell — **first audible** | **M1 exit** | P2 + P5 sliver | WP2, WP4, WP5 | XL | **DONE (M1)** | | WP7 | AllRAD, dual-band, per-speaker comp, test signals | **M2 exit** | P2 tail | WP6 | XL | **DONE (M2)** | | WP8 | Mono encoders, NFC, spread | M3 part | P2 tail | WP6 | L | **DONE (M3a)** | | WP9 | OSC & head-tracking (generic quaternion), listener position (D18) | **M3 exit** | P3 (scoped) | WP2, WP4, WP8 | M | **DONE (M3)** | @@ -262,6 +262,30 @@ shell, and an input stem stops being implicitly mono. and nothing keys on a group's hardware channels being contiguous, so clusters can layer on top without reopening D43/D44. +**XOA is a processor (D48–D50)** — architectural correction: program material +is played by external apps (Reaper, QLab, …) and arrives through the device +inputs; there is no cueing mechanism in this app. + +- **D48 — The file player is removed** (with its transport UI and + `playbackFilePath`/`playbackLoop`). External players feed device inputs; + stems and HOA groups (D44) carry the program into the bus. An interactive + sampler player (WFS-DIY style) is a possible future feature — a plain file + player is not. The bus gather stage, `rt::makeBusParams` and the offline + bit-exactness harness are untouched (the no-source shape publishes a + zero-channel gather that clears busA); the synthetic test scene stays as + the audible-without-hardware fallback, latched from the header. +- **D49 — The patch-window transport gate is dropped** (supersedes D41's + mechanism; the policy died with the transport). An external player's + transport cannot be sensed, so a local gate would be theatre. The safety + net is the explicit matrix interaction and the generator's universal 500 ms + protective ramp; test tones still stop on every exit path. +- **D50 — `playbackContentOrder` / `playbackConvention` deleted.** Live HOA + groups stay AmbiX-only (D44); a per-input convention/order override is + deferred to the clusters era (FuMa needs channel reordering inside the + group merge). OSC map bumped to v1.1 with a documented removal of the three + `/xoa/config/playback*` leaves. The conversion math in `makeBusParams` + survives for the test scene / harness and any future per-input override. + D24 ("PatchMatrixComponent not ported; v1 keeps identity channel mapping") is superseded by D41–D46; the identity mapping survives only as the default patch. @@ -496,6 +520,10 @@ ill-conditioned layouts (that's what κ reporting is for). ### WP6 — RT bus engine, file playback, minimal shell — **M1, first audible** (XL) +> **D48 note (2026-08):** the file player shipped here and was later removed — +> XOA is a processor; program arrives via device inputs. The WP6 text below is +> kept as the historical record of what M1 built. + **Goal.** The PRD's first audible milestone: an AmbiX file of any order 1–10 plays through order adaptation → rotation → SAD decode to a real rig, CPU-only, click-free — plus the offline-render harness that gates everything @@ -521,7 +549,7 @@ content is rare" mitigation (test-scene generator) lands here. - `Source/DSP/AmbiRtTypes.h` — trivially-copyable POD snapshots (rotation state, decoder swap handle), modeled on WFS-DIY `Source/DSP/BinauralCalculationEngine.h::RtParams`. -- `Source/Audio/FilePlayer.{h,cpp}` — multichannel WAV/CAF/FLAC up to +- `Source/Audio/FilePlayer.{h,cpp}` *(removed by D48)* — multichannel WAV/CAF/FLAC up to 128 ch, AmbiX metadata detection where present, manual order/convention override always available (FR-8). - **Spike (early, time-boxed):** verify JUCE `WavAudioFormat`/CAF actually @@ -1145,7 +1173,7 @@ spatcore and with the repo's no-new-dependencies posture. | FR-5 mono encoding + spread, click-free | WP8 | | FR-6 distance / NFC | WP8 | | FR-7 HOA stream up/downmix | WP3 (weights) + WP6 (chain) | -| FR-8 file playback ≤ 128 ch, AmbiX metadata | WP6 | +| FR-8 external program input (was: file playback, removed by D48) | WP6 (historical) + stage 2 | | FR-9 SO(3) rotation (Ivanic–Ruedenberg) | WP4 (math) + WP6 (RT apply) | | FR-10 rotation sources: dials / OSC / tracker | WP6 (dials), WP9 (OSC + quaternion), WP10 (full UI) | | FR-11 mirror; zoom v1.1 | WP4 (mirror); zoom **parked** §8 | diff --git a/Documentation/XOA-OSC-MAP.md b/Documentation/XOA-OSC-MAP.md index 657dc0f..953790a 100644 --- a/Documentation/XOA-OSC-MAP.md +++ b/Documentation/XOA-OSC-MAP.md @@ -1,6 +1,16 @@ # XOA — OSC Address Map -Version 0.1 — July 2026 — GPL-3.0 +Version 1.1 — August 2026 — GPL-3.0 + +> **Changelog** +> - **1.1 (2026-08)** — REMOVED: `/xoa/config/playbackLoop`, +> `/xoa/config/playbackContentOrder`, `/xoa/config/playbackConvention`. +> The built-in file player was removed (D48/D50): XOA is a processor; +> program material is played by external apps and arrives via the device +> inputs. A peer sending these addresses now gets the standard +> unknown-address ignore. Breaking change, made deliberately against the +> frozen-contract rule with a version bump. +> - **0.1 (2026-07)** — initial frozen map. This document is the **frozen contract** for XOA's OSC control surface (WP9, FR-22 / FR-10 / D18-FR-25). It is written before the network code so the @@ -162,18 +172,17 @@ smoothed tracker stream uses `/xoa/tracking/position` (§8) instead. | `masterGain` | f | dB, [-60, 12] | `masterGain` | | `distanceCompMode` | i | 0 off / 1 delay / 2 delay+gain | `distanceCompMode` | | `monoInputsEnabled` | i | 0/1 | `monoInputsEnabled` | -| `playbackLoop` | i | 0/1 | `playbackLoop` | -| `playbackContentOrder` | i | 0 auto … 10 | `playbackContentOrder` | -| `playbackConvention` | i | 0 SN3D / 1 N3D / 2 FuMa | `playbackConvention` | | `inputCount` | i | [1, max] — structural resize | `inputCount` | | `speakerCount` | i | [1, max] — structural resize | `speakerCount` | +*(`playbackLoop`, `playbackContentOrder`, `playbackConvention` were removed in +map v1.1 — see the changelog; D48/D50.)* + **Transport parameters are read-only over OSC** (`oscEnabled`, `oscReceivePort`, `oscSendPort`, `oscSendAddress`, `oscTcpEnabled`, `oscTcpPort`, `oscAcceptAnyHost`, `oscFeedbackEnabled`, `oscMeterEnabled`): they are reportable via `/xoa/get` but a remote peer may not reconfigure the -transport out from under itself. `playbackFilePath` and transport play-state -are likewise not OSC-writable in v1. +transport out from under itself. ### `/xoa/rotation/` `(f)deg` — scene orientation (FR-9) diff --git a/Documentation/XOA_PRD.md b/Documentation/XOA_PRD.md index 353f6de..80e6eb9 100644 --- a/Documentation/XOA_PRD.md +++ b/Documentation/XOA_PRD.md @@ -64,7 +64,7 @@ One HOA bus per project. All inputs are encoded to / adapted to the bus, transfo - **FR-7 HOA stream input.** Accept an AmbiX stream (file or network audio) of order M against the fixed order-10 bus: - **Upmix (M < 10):** zero-pad higher-order coefficients (standard practice; creates no false spatial detail, preserves compatibility). Optional gentle order-weighting shelf to avoid brightness mismatch. - **Downmix (M > 10):** truncate to order 10 with max-rE re-weighting to minimize truncation artifacts. -- **FR-8 File playback.** Multichannel WAV/CAF/FLAC up to 128 channels, with AmbiX metadata detection where present; manual order/convention override always available. +- **FR-8 External program input** *(rewritten by D48 — XOA is a processor, not a player)*. Program material is played by external applications (DAW, QLab, …) and arrives through the audio device inputs: mono stems and AmbiX HOA groups (per-input `inputFormat`, D44), routed by the input patch. There is no built-in file player and no cueing mechanism; an interactive sampler player (WFS-DIY style) is a post-v1 candidate. *(Historical v0 scope: multichannel WAV/CAF/FLAC playback up to 128 channels — shipped in M1, removed by D48.)* ### 4.3 Scene transforms (on the HOA bus) diff --git a/Resources/lang/en.json b/Resources/lang/en.json index c6c5f4e..342a48b 100644 --- a/Resources/lang/en.json +++ b/Resources/lang/en.json @@ -52,14 +52,12 @@ "ready": "Ready" }, "header": { - "noFile": "No file loaded — use the test scene or open an AmbiX file.", - "openTitle": "Open an Ambisonics file", + "testScene": "Test scene", "deviceStopped": "audio device stopped", "rebuilding": "rebuilding…" }, "systemConfig": { "show": "Show", - "playback": "Playback", "device": "Audio device", "audioInterface": "Audio Interface…", "appearance": "Appearance", @@ -166,10 +164,6 @@ "rotationYaw": "Yaw", "rotationPitch": "Pitch", "rotationRoll": "Roll", - "playbackFilePath": "File", - "playbackLoop": "Loop", - "playbackContentOrder": "Content order", - "playbackConvention": "Convention", "distanceCompMode": "Distance comp", "listenerX": "Listener X", "listenerY": "Listener Y", @@ -222,7 +216,6 @@ "mode": { "scrolling": "Scroll", "patching": "Patch", "testing": "Test" }, "unpatchAll": "Unpatch All (hold)", "test": { "hold": "Hold" }, - "transportLocked": "Patching is disabled while the transport is playing — stop playback to edit.", "labels": { "interfaceInput": "Interface input", "interfaceOutput": "Interface output", "processorInputs": "Stem channels", "processorOutputs": "Speakers" @@ -233,8 +226,6 @@ } }, "enum": { - "contentOrder": { "auto": "Auto" }, - "convention": { "sn3d": "SN3D", "n3d": "N3D", "fuma": "FuMa" }, "distanceComp": { "off": "Off", "delay": "Delay", "delayGain": "Delay + Gain" }, "coordMode": { "cartesian": "Cartesian", "cylindrical": "Cylindrical", "spherical": "Spherical" }, "inputFormat": { diff --git a/Source/App/AppShell.cpp b/Source/App/AppShell.cpp index 2abcbe2..170bc84 100644 --- a/Source/App/AppShell.cpp +++ b/Source/App/AppShell.cpp @@ -264,7 +264,8 @@ void AppShell::resized() auto area = getLocalBounds(); const float sc = XoaLookAndFeel::uiScale; - header.setBounds (area.removeFromTop (juce::roundToInt (120.0f * sc))); + // 90px: the header lost its transport row when the file player left (D48). + header.setBounds (area.removeFromTop (juce::roundToInt (90.0f * sc))); statusBar.setBounds (area.removeFromBottom (juce::roundToInt (30.0f * sc))); tabs.setBounds (area); } diff --git a/Source/App/HeaderBar.cpp b/Source/App/HeaderBar.cpp index d7400a7..9d73040 100644 --- a/Source/App/HeaderBar.cpp +++ b/Source/App/HeaderBar.cpp @@ -25,48 +25,19 @@ namespace xoa::ui HeaderBar::HeaderBar (AppContext& ctx) : context (ctx), bindings (ctx.store) { - // --- Transport -------------------------------------------------------- - addAndMakeVisible (openButton); - addAndMakeVisible (playButton); - addAndMakeVisible (stopButton); - addAndMakeVisible (loopButton); - addAndMakeVisible (sourceCombo); - addAndMakeVisible (fileLabel); - addAndMakeVisible (positionSlider); - - openButton.setButtonText (LOC ("common.browse")); - openButton.onClick = [this] { openFileDialog(); }; - playButton.onClick = [this] + // --- HOA source (D48) ------------------------------------------------- + // XOA is a processor: program material arrives from external players via + // the device inputs. The only in-app source is the synthetic test scene, + // the audible-without-hardware fallback. + testSceneButton.setButtonText (LOC ("header.testScene")); + testSceneButton.setClickingTogglesState (true); + testSceneButton.onClick = [this] { - sourceCombo.setSelectedId (1, juce::dontSendNotification); - context.engine.setInputSource (xoa::AudioEngine::InputSource::file); - context.engine.getFilePlayer().play(); - }; - stopButton.onClick = [this] { context.engine.getFilePlayer().stop(); }; - - // Loop is a store parameter (OSC-writable); refresh() pushes it to the player. - bindings.bindToggle (loopButton, ids::playbackLoop); - - sourceCombo.addItem ("File", 1); - sourceCombo.addItem ("Test scene", 2); - sourceCombo.setSelectedId (1, juce::dontSendNotification); - sourceCombo.onChange = [this] - { - context.engine.setInputSource (sourceCombo.getSelectedId() == 2 + context.engine.setInputSource (testSceneButton.getToggleState() ? xoa::AudioEngine::InputSource::testScene - : xoa::AudioEngine::InputSource::file); - }; - - fileLabel.setText (LOC ("header.noFile"), juce::dontSendNotification); - positionSlider.setTrackColours (ColorScheme::get().sliderTrackBg, ColorScheme::accents::time); - positionSlider.onGestureStart = [this] { positionDragging = true; }; - positionSlider.onGestureEnd = [this] { positionDragging = false; }; - positionSlider.onValueChanged = [this] (float v) - { - // Only user gestures seek; refresh() drives the thumb the rest of the time. - if (positionDragging) - context.engine.getFilePlayer().seekSeconds ((double) v); + : xoa::AudioEngine::InputSource::none); }; + addAndMakeVisible (testSceneButton); // --- Rotation dials (FR-10) ------------------------------------------ auto setupDial = [this] (XoaBasicDial& dial, juce::Label& label, @@ -100,52 +71,8 @@ HeaderBar::HeaderBar (AppContext& ctx) HeaderBar::~HeaderBar() = default; -void HeaderBar::openFileDialog() -{ - juce::String patterns = "*.wav;*.flac"; - #if JUCE_MAC - patterns += ";*.caf"; - #endif - - fileChooser = std::make_unique (LOC ("header.openTitle"), - juce::File(), patterns); - fileChooser->launchAsync (juce::FileBrowserComponent::openMode - | juce::FileBrowserComponent::canSelectFiles, - [this] (const juce::FileChooser& fc) - { - const auto file = fc.getResult(); - if (file == juce::File()) - return; - - const auto r = context.engine.openFile (file); - if (r.ok) - { - juce::String text; - text << file.getFileName() << " (" << r.numChannels << " ch, " - << juce::String (r.fileSampleRate / 1000.0, 1) << " kHz, order " - << r.detectedOrder << ")"; - if (! r.warnings.isEmpty()) - text << " · " << r.warnings.joinIntoString ("; "); - fileLabel.setText (text, juce::dontSendNotification); - - positionSlider.setRange (0.0f, (float) juce::jmax (0.001, context.engine.getFilePlayer().getLengthSeconds())); - sourceCombo.setSelectedId (1, juce::dontSendNotification); - } - else - { - fileLabel.setText ("Error: " + r.error, juce::dontSendNotification); - } - }); -} - void HeaderBar::refresh() { - // Push the loop parameter (UI- or OSC-driven) to the player. - context.engine.getFilePlayer().setLooping ((bool) context.store.getParameter (ids::playbackLoop)); - - if (! positionDragging) - positionSlider.setValue ((float) context.engine.getFilePlayer().getPositionSeconds()); - const double sr = context.engine.getSampleRate(); const int block = context.engine.getBlockSize(); @@ -173,23 +100,16 @@ void HeaderBar::resized() auto area = getLocalBounds().reduced (px (8), px (4)); - // Row 1: transport - auto r1 = area.removeFromTop (px (28)); - openButton .setBounds (r1.removeFromLeft (px (80))); r1.removeFromLeft (px (4)); - playButton .setBounds (r1.removeFromLeft (px (60))); r1.removeFromLeft (px (4)); - stopButton .setBounds (r1.removeFromLeft (px (60))); r1.removeFromLeft (px (8)); - loopButton .setBounds (r1.removeFromLeft (px (64))); r1.removeFromLeft (px (8)); - sourceCombo .setBounds (r1.removeFromLeft (px (120))); r1.removeFromLeft (px (8)); - fileLabel .setBounds (r1); - area.removeFromTop (px (4)); - positionSlider.setBounds (area.removeFromTop (px (18))); - area.removeFromTop (px (6)); - - // Row 2: rotation dials (left) | master (right) | status (bottom) + // Rotation dials (centre) | test-scene latch (left) | master (right) | + // status (bottom). No transport row (D48). auto statusRow = area.removeFromBottom (px (18)); statusLabel.setBounds (statusRow); area.removeFromBottom (px (4)); + testSceneButton.setBounds (area.removeFromLeft (px (110)) + .withSizeKeepingCentre (px (110), px (28))); + area.removeFromLeft (px (8)); + auto masterArea = area.removeFromRight (px (300)); masterLabel.setBounds (masterArea.removeFromLeft (px (72))); masterArea.removeFromLeft (px (4)); diff --git a/Source/App/HeaderBar.h b/Source/App/HeaderBar.h index fa6f7df..b34e1f4 100644 --- a/Source/App/HeaderBar.h +++ b/Source/App/HeaderBar.h @@ -2,10 +2,12 @@ ============================================================================== XOA — tenth-order Ambisonics spatial audio processor. - HeaderBar — the persistent top strip (WP10 C5, decision D27): file transport, - the three rotation dials (FR-10), master gain, and a live status readout - (sample rate / latency / CPU / decoder-rebuild / OSC). These are performance - controls that must never sit behind a tab switch. + HeaderBar — the persistent top strip (WP10 C5, decision D27): the HOA + source latch (none / test scene — program material arrives from external + players via the device inputs, D48), the three rotation dials (FR-10), + master gain, and a live status readout (sample rate / latency / CPU / + decoder-rebuild / OSC). These are performance controls that must never + sit behind a tab switch. This file is part of XOA, released under the GNU General Public License v3.0. See LICENSE for details. @@ -34,24 +36,15 @@ class HeaderBar : public juce::Component void resized() override; - /** App timer tick: transport position, status line, loop -> FilePlayer. */ + /** App timer tick: the live status line. */ void refresh(); private: - void openFileDialog(); - AppContext& context; BindingSet bindings; - // Transport - juce::TextButton openButton { "Open…" }; - juce::TextButton playButton { "Play" }; - juce::TextButton stopButton { "Stop" }; - juce::TextButton loopButton { "Loop" }; // latching (WFS toggle style) - juce::ComboBox sourceCombo; - juce::Label fileLabel; - XoaStandardSlider positionSlider; - bool positionDragging = false; + // HOA source (D48): a test-scene latch; no transport, no file. + juce::TextButton testSceneButton; // Rotation (FR-10) XoaBasicDial yawDial, pitchDial, rollDial; @@ -65,8 +58,6 @@ class HeaderBar : public juce::Component // Live status juce::Label statusLabel; - std::unique_ptr fileChooser; - JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (HeaderBar) }; diff --git a/Source/Audio/AudioEngine.cpp b/Source/Audio/AudioEngine.cpp index 2fd1965..3ef0e6e 100644 --- a/Source/Audio/AudioEngine.cpp +++ b/Source/Audio/AudioEngine.cpp @@ -52,9 +52,7 @@ void AudioEngine::registerListeners() store.addParameterListener (ids::rotationPitch, [this] (const juce::var&) { publishRotation(); }); store.addParameterListener (ids::rotationRoll, [this] (const juce::var&) { publishRotation(); }); - store.addParameterListener (ids::masterGain, [this] (const juce::var&) { publishBusParams(); }); - store.addParameterListener (ids::playbackContentOrder, [this] (const juce::var&) { publishBusParams(); }); - store.addParameterListener (ids::playbackConvention, [this] (const juce::var&) { publishBusParams(); }); + store.addParameterListener (ids::masterGain, [this] (const juce::var&) { publishBusParams(); }); // Distance-comp mode lives in Config, so it needs its own listener (the // Speakers subtree listener below only sees per-speaker edits). It changes @@ -95,8 +93,6 @@ void AudioEngine::unregisterListeners() store.removeParameterListeners (ids::rotationPitch); store.removeParameterListeners (ids::rotationRoll); store.removeParameterListeners (ids::masterGain); - store.removeParameterListeners (ids::playbackContentOrder); - store.removeParameterListeners (ids::playbackConvention); store.removeParameterListeners (ids::distanceCompMode); store.removeParameterListeners (ids::listenerX); store.removeParameterListeners (ids::listenerY); @@ -160,20 +156,7 @@ void AudioEngine::closeAudioDevice() void AudioEngine::setInputSource (InputSource source) { inputSource.store (source, std::memory_order_relaxed); - publishBusParams(); // the gather differs between file and scene -} - -FilePlayer::OpenResult AudioEngine::openFile (const juce::File& file) -{ - auto r = filePlayer.open (file); - if (r.ok) - { - fileNumChannels.store (r.numChannels, std::memory_order_relaxed); - fileDetectedOrder.store (r.detectedOrder, std::memory_order_relaxed); - store.setParameter (ids::playbackFilePath, file.getFullPathName()); - setInputSource (InputSource::file); // recomposes + publishes the gather - } - return r; + publishBusParams(); // the gather differs between none and scene } //============================================================================== @@ -191,18 +174,18 @@ void AudioEngine::publishBusParams() if (inputSource.load (std::memory_order_relaxed) == InputSource::testScene) { - // The synthetic scene is order-10 SN3D; the file content-order/convention - // overrides do not apply to it. + // The synthetic scene is order-10 SN3D. busParamsSnapshot.publish (rt::makeBusParams (0, xoa::kAmbisonicOrder, 0, xoa::kNumSHChannels, masterDb, ++busEpoch)); } else { - const int overrideOrder = store.getIntParameter (ids::playbackContentOrder); - const int convention = store.getIntParameter (ids::playbackConvention); - busParamsSnapshot.publish (rt::makeBusParams ( - overrideOrder, fileDetectedOrder.load (std::memory_order_relaxed), convention, - fileNumChannels.load (std::memory_order_relaxed), masterDb, ++busEpoch)); + // No HOA source (D48): zero input channels makes every gather slot + // srcChannel -1, so the gather stage clears busA each block and the + // encoder stage (stems / HOA groups from device inputs) accumulates + // on top. Master gain still rides this snapshot, so this publisher + // must keep running even with no source. + busParamsSnapshot.publish (rt::makeBusParams (0, 0, 0, 0, masterDb, ++busEpoch)); } } @@ -451,7 +434,6 @@ void AudioEngine::prepareToPlay (int samplesPerBlockExpected, double sampleRate) speakerScratch.setSize (xoa::kMaxSpeakers, block, false, false, true); speakerScratch.clear(); hwWritten.assign ((size_t) xoa::kMaxHardwareChannels, 0); - filePlayer.prepareToPlay (sr, block); algorithm.prepare (xoa::kNumSHChannels, numSpk, sr, block, &decoderBuilder, &rotationSnapshot, &busParamsSnapshot, true, @@ -470,7 +452,6 @@ void AudioEngine::releaseResources() { algorithm.releaseResources(); speakerComp.releaseResources(); - filePlayer.releaseResources(); deviceSampleRate.store (0.0, std::memory_order_relaxed); deviceBlockSize.store (0, std::memory_order_relaxed); numActiveInputs.store (0, std::memory_order_relaxed); @@ -591,9 +572,9 @@ void AudioEngine::getNextAudioBlock (const juce::AudioSourceChannelInfo& info) } else { - filePlayer.renderNextBlock (inputScratch, n); - algorithm.processBlock (outInfo, inputScratch, - fileNumChannels.load (std::memory_order_relaxed), numSpk, + // No HOA source (D48): the published zero-channel BusRtParams makes + // the gather clear busA; the encoder stage supplies all content. + algorithm.processBlock (outInfo, inputScratch, 0, numSpk, stemsPtr, numStems); } diff --git a/Source/Audio/AudioEngine.h b/Source/Audio/AudioEngine.h index c48d0f0..ea6e52a 100644 --- a/Source/Audio/AudioEngine.h +++ b/Source/Audio/AudioEngine.h @@ -13,7 +13,6 @@ #include "XoaConstants.h" #include "Audio/DecoderRebuildWorker.h" -#include "Audio/FilePlayer.h" #include "Audio/PatchRouting.h" #include "Audio/SpeakerCompParams.h" #include "Audio/SpeakerCompProcessor.h" @@ -36,7 +35,7 @@ // through three message-thread controllers: // // RotationPublisher rotation params -> RtSnapshot -// BusParamsPublisher master gain / playback params + input-source -> +// BusParamsPublisher master gain + input-source (none / test scene) -> // RtSnapshot // DecoderRebuildControl Speakers/Decoder subtree changes -> a 150 ms // debounce -> DecoderMatrixBuilder rebuild + publish @@ -58,7 +57,12 @@ class AudioEngine : private juce::AudioSource, private juce::AsyncUpdater { public: - enum class InputSource { file, testScene }; + /** The HOA bus source. XOA is a PROCESSOR (D48): program material is + played by external apps and arrives through the device inputs as + stems/HOA groups — `none` means the bus carries only what the encoder + stage accumulates. `testScene` is the synthetic order-10 scene, the + audible-without-hardware fallback. */ + enum class InputSource { none, testScene }; /** Where the mono-encoder stems come from: device input channels (identity-mapped hw ch i -> input i) or a deterministic internal test @@ -76,12 +80,11 @@ class AudioEngine : private juce::AudioSource, juce::AudioDeviceManager& getDeviceManager() noexcept { return deviceManager; } spatcore::io::DeviceHost& getDeviceHost() noexcept { return deviceHost; } - FilePlayer& getFilePlayer() noexcept { return filePlayer; } DecoderMatrixBuilder& getDecoderBuilder() noexcept { return decoderBuilder; } TestSignalGenerator& getTestSignalGenerator() noexcept { return testSignal; } //========================================================================== - // Input source + file (message thread). + // Input source (message thread). //========================================================================== void setInputSource (InputSource source); InputSource getInputSource() const noexcept { return inputSource.load (std::memory_order_relaxed); } @@ -94,10 +97,6 @@ class AudioEngine : private juce::AudioSource, through the store; this exposes it for the offline harness and tests). */ AmbiCalculationEngine& getCalculationEngine() noexcept { return calcEngine; } - /** Open a file, point the input at it, and persist the path. On success - the bus gather is recomposed for the file's channel count/order. */ - FilePlayer::OpenResult openFile (const juce::File& file); - /** Force the pending decoder rebuild now, synchronously (startup + explicit UI + tests). Invalidates any in-flight async rebuild. */ void flushDecoderRebuild(); @@ -243,7 +242,6 @@ class AudioEngine : private juce::AudioSource, spatcore::io::DeviceHost deviceHost { deviceManager, xoa::kMaxHardwareChannels }; spatcore::io::DeviceIoCallback ioCallback { *this, xoa::kMaxHardwareChannels }; - FilePlayer filePlayer; DecoderMatrixBuilder decoderBuilder; AmbiCalculationEngine calcEngine; // control-side encoder (owns the live matrices) @@ -273,15 +271,13 @@ class AudioEngine : private juce::AudioSource, // Per-HARDWARE-input meters (patch matrix tinting), pre-gather. std::array, xoa::kMaxHardwareChannels> hwInputPeak {}; - juce::AudioBuffer inputScratch; // [kMaxFileChannels x block] + juce::AudioBuffer inputScratch; // [kMaxFileChannels x block] test-scene HOA render juce::AudioBuffer stemScratch; // [kMaxStemChannels x block] flattened stem channels (D43) juce::AudioBuffer speakerScratch; // [kMaxSpeakers x block] decode+comp domain, scattered to hardware std::vector hwWritten; // per-block scatter bookkeeping (sized at prepare) - std::atomic inputSource { InputSource::file }; + std::atomic inputSource { InputSource::none }; std::atomic stemFeed { StemFeed::device }; - std::atomic fileNumChannels { 0 }; - std::atomic fileDetectedOrder { 0 }; // Epochs advance on the message thread only (one writer per snapshot). juce::uint32 rotationEpoch = 0; diff --git a/Source/Audio/FilePlayer.cpp b/Source/Audio/FilePlayer.cpp deleted file mode 100644 index 1d4cbd1..0000000 --- a/Source/Audio/FilePlayer.cpp +++ /dev/null @@ -1,133 +0,0 @@ -#include "Audio/FilePlayer.h" - -namespace xoa -{ - -// Read-ahead buffer (samples per channel). ~32k covers well over half a second -// at any supported rate - a large cushion against disk latency. -static constexpr int kReadAheadSamples = 32768; - -FilePlayer::FilePlayer() -{ - formatManager.registerBasicFormats(); // WAV + AIFF + FLAC + Ogg (+ CAF/MP3 on Apple) - readAheadThread.startThread(); -} - -FilePlayer::~FilePlayer() -{ - transport.setSource (nullptr); - readAheadThread.stopThread (2000); -} - -int FilePlayer::detectAmbiOrder (int channelCount) noexcept -{ - for (int n = xoa::kAmbisonicOrder; n >= 0; --n) - if ((n + 1) * (n + 1) == channelCount) - return n; - return 0; -} - -//============================================================================== -FilePlayer::OpenResult FilePlayer::open (const juce::File& file) -{ - OpenResult r; - - if (! file.existsAsFile()) - { - r.error = "File does not exist: " + file.getFullPathName(); - return r; - } - - // CAF is only readable where CoreAudioFormat is available (macOS). - std::unique_ptr reader (formatManager.createReaderFor (file)); - if (reader == nullptr) - { - if (file.hasFileExtension ("caf")) - r.error = "CAF is only supported on macOS in v1; please convert to WAV."; - else - r.error = "Unsupported or unreadable audio file: " + file.getFileName(); - return r; - } - - r.numChannels = (int) reader->numChannels; - r.fileSampleRate = reader->sampleRate; - r.lengthSamples = reader->lengthInSamples; - r.detectedOrder = detectAmbiOrder (r.numChannels); - - if (r.numChannels > xoa::kMaxFileChannels) - r.warnings.add ("File has " + juce::String (r.numChannels) + " channels; only the first " - + juce::String (xoa::kMaxFileChannels) + " will be played."); - if (r.detectedOrder == 0) - r.warnings.add ("Channel count " + juce::String (r.numChannels) - + " is not a perfect square; set the content order manually."); - - const int playChannels = juce::jmin (r.numChannels, xoa::kMaxFileChannels); - - // AudioFormatReaderSource takes ownership of the reader. - auto newSource = std::make_unique (reader.release(), true); - newSource->setLooping (looping); - - transport.setSource (newSource.get(), kReadAheadSamples, &readAheadThread, - r.fileSampleRate, playChannels); - readerSource = std::move (newSource); - numChannels = playChannels; - - if (deviceSampleRate > 0.0 && blockSize > 0) - transport.prepareToPlay (blockSize, deviceSampleRate); - - r.ok = true; - return r; -} - -void FilePlayer::close() -{ - transport.stop(); - transport.setSource (nullptr); - readerSource.reset(); - numChannels = 0; -} - -//============================================================================== -void FilePlayer::play() { if (readerSource != nullptr) transport.start(); } -void FilePlayer::stop() { transport.stop(); } -bool FilePlayer::isPlaying() const { return transport.isPlaying(); } - -void FilePlayer::setLooping (bool shouldLoop) -{ - looping = shouldLoop; - if (readerSource != nullptr) - readerSource->setLooping (shouldLoop); -} - -void FilePlayer::seekSeconds (double seconds) { transport.setPosition (seconds); } -double FilePlayer::getPositionSeconds() const { return transport.getCurrentPosition(); } -double FilePlayer::getLengthSeconds() const { return transport.getLengthInSeconds(); } - -//============================================================================== -void FilePlayer::prepareToPlay (double sr, int block) -{ - deviceSampleRate = sr; - blockSize = block; - transport.prepareToPlay (block, sr); -} - -void FilePlayer::renderNextBlock (juce::AudioBuffer& dest, int numSamples) noexcept -{ - // Channels the file does not provide stay silent. - dest.clear(); - - if (readerSource == nullptr || ! transport.isPlaying()) - return; - - juce::AudioSourceChannelInfo info (&dest, 0, numSamples); - transport.getNextAudioBlock (info); -} - -void FilePlayer::releaseResources() -{ - transport.releaseResources(); - deviceSampleRate = 0.0; - blockSize = 0; -} - -} // namespace xoa diff --git a/Source/Audio/FilePlayer.h b/Source/Audio/FilePlayer.h deleted file mode 100644 index 37636ae..0000000 --- a/Source/Audio/FilePlayer.h +++ /dev/null @@ -1,91 +0,0 @@ -#pragma once - -#include -#include - -#include - -#include "XoaConstants.h" - -//============================================================================== -// XOA - multichannel Ambisonics file playback (FR-8). -// -// Streams a WAV/FLAC (and CAF on macOS) of up to kMaxFileChannels through a -// juce::AudioTransportSource fed by a background read-ahead thread: a -// 121-channel order-10 file is far too large to preload, and the transport -// gives seek/loop and sample-rate correction (any file rate -> the device -// rate, FR-4) for free. -// -// AmbiX metadata detection is a channel-count heuristic (JUCE parses no HOA -// chunks): the largest order whose (N+1)^2 fits the channel count. The manual -// order/convention override in the parameter store always wins (FR-8). -// -// Threading: open/close/transport control are message-thread; renderNextBlock -// is the audio thread. AudioTransportSource::getNextAudioBlock takes a short -// CriticalSection (contended only during a message-thread setSource/stop) - the -// documented M1 tradeoff; a lock-free SPSC ring is the post-M1 option. -//============================================================================== - -namespace xoa -{ - -class FilePlayer -{ -public: - struct OpenResult - { - bool ok = false; - juce::String error; - int numChannels = 0; - double fileSampleRate = 0.0; - juce::int64 lengthSamples = 0; - int detectedOrder = 0; // AmbiX heuristic; 0 if not a perfect square fit - juce::StringArray warnings; - }; - - FilePlayer(); - ~FilePlayer(); - - /** Detect the largest Ambisonic order whose (N+1)^2 <= channelCount, - capped at the bus order. 0 when channelCount isn't a perfect square. */ - static int detectAmbiOrder (int channelCount) noexcept; - - //========================================================================== - // Message thread. - //========================================================================== - OpenResult open (const juce::File& file); - void close(); - - void play(); - void stop(); - bool isPlaying() const; - void setLooping (bool shouldLoop); - - void seekSeconds (double seconds); - double getPositionSeconds() const; - double getLengthSeconds() const; - int getNumChannels() const noexcept { return numChannels; } - - //========================================================================== - // Audio thread. - //========================================================================== - void prepareToPlay (double deviceSampleRate, int blockSize); - /** Fill dest with the next numSamples (cleared when stopped or empty). */ - void renderNextBlock (juce::AudioBuffer& dest, int numSamples) noexcept; - void releaseResources(); - -private: - juce::AudioFormatManager formatManager; - juce::TimeSliceThread readAheadThread { "xoa file read-ahead" }; - juce::AudioTransportSource transport; - std::unique_ptr readerSource; - - int numChannels = 0; - double deviceSampleRate = 0.0; - int blockSize = 0; - bool looping = false; - - JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (FilePlayer) -}; - -} // namespace xoa diff --git a/Source/DSP/AmbiRtTypes.h b/Source/DSP/AmbiRtTypes.h index 76ec4d7..b6028ab 100644 --- a/Source/DSP/AmbiRtTypes.h +++ b/Source/DSP/AmbiRtTypes.h @@ -54,7 +54,10 @@ static_assert (std::is_trivially_copyable_v, "RotationRtState must be a POD for RtSnapshot"); //============================================================================== -/** Values match ids::playbackConvention. */ +/** HOA channel-normalization/ordering conventions the gather can interpret. + (Historically driven by ids::playbackConvention; since D48/D50 only the + synthetic test scene and the offline harness feed the gather, both SN3D, + but the conversion math stays — a future per-input override reuses it.) */ enum class ContentConvention { sn3d = 0, n3d = 1, fuma = 2 }; struct BusRtParams @@ -168,12 +171,13 @@ inline RotationRtState makeRotationState (double yawDeg, double pitchDeg, double /** Build the gather table for content of the given order and convention. - @param overrideOrder ids::playbackContentOrder; <= 0 means "auto" -> - use detectedOrder. - @param detectedOrder FilePlayer's channel-count heuristic (or the true - order for synthetic sources). - @param convention ids::playbackConvention (ContentConvention). - @param numFileChannels channels the input source actually delivers. + @param overrideOrder <= 0 means "auto" -> use detectedOrder. + @param detectedOrder the content's true order (kAmbisonicOrder for the + synthetic test scene). + @param convention ContentConvention ordinal. + @param numFileChannels channels the input source actually delivers; + 0 -> every slot -1, the gather clears busA (the + no-source shape, D48). @param masterGainDb ids::masterGain. @param warning optional out: FuMa>3 fallback (PRD sec.9 rejection rule) and missing-channel notes land here. diff --git a/Source/GUI/Binding/TabParameterRegistry.h b/Source/GUI/Binding/TabParameterRegistry.h index 09ba585..a612d6b 100644 --- a/Source/GUI/Binding/TabParameterRegistry.h +++ b/Source/GUI/Binding/TabParameterRegistry.h @@ -51,18 +51,14 @@ inline const std::vector& registryRows() { namespace i = xoa::ids; static const std::vector rows = { - // Persistent header strip (C5): transport + rotation + master. + // Persistent header strip (C5): rotation + master (no transport, D48). { Surface::header, i::rotationYaw }, { Surface::header, i::rotationPitch }, { Surface::header, i::rotationRoll }, { Surface::header, i::masterGain }, - { Surface::header, i::playbackLoop }, - { Surface::header, i::playbackFilePath }, // System Config tab (C5). { Surface::systemConfig, i::showName }, - { Surface::systemConfig, i::playbackContentOrder }, - { Surface::systemConfig, i::playbackConvention }, { Surface::systemConfig, i::audioDeviceState }, // Network tab (C5): the WP9 OSC transport schema (single send target). diff --git a/Source/GUI/Binding/UiParameterDescriptors.h b/Source/GUI/Binding/UiParameterDescriptors.h index bef003f..9b23496 100644 --- a/Source/GUI/Binding/UiParameterDescriptors.h +++ b/Source/GUI/Binding/UiParameterDescriptors.h @@ -73,12 +73,6 @@ inline const std::vector& allDescriptors() { i::rotationYaw, "param.rotationYaw", "units.degrees",1.0, Kind::dial, false, false, Domain::config, {} }, { i::rotationPitch, "param.rotationPitch", "units.degrees",1.0, Kind::dial, false, false, Domain::config, {} }, { i::rotationRoll, "param.rotationRoll", "units.degrees",1.0, Kind::dial, false, false, Domain::config, {} }, - { i::playbackFilePath, "param.playbackFilePath", "", 0.0, Kind::system, false, false, Domain::config, {} }, - { i::playbackLoop, "param.playbackLoop", "", 0.0, Kind::toggle, false, false, Domain::config, {} }, - { i::playbackContentOrder, "param.playbackContentOrder", "", 1.0, Kind::combo, false, false, Domain::config, - { "enum.contentOrder.auto", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10" } }, - { i::playbackConvention, "param.playbackConvention", "", 1.0, Kind::combo, false, false, Domain::config, - { "enum.convention.sn3d", "enum.convention.n3d", "enum.convention.fuma" } }, { i::distanceCompMode, "param.distanceCompMode", "", 1.0, Kind::combo, false, false, Domain::config, { "enum.distanceComp.off", "enum.distanceComp.delay", "enum.distanceComp.delayGain" } }, { i::listenerX, "param.listenerX", "units.meters", 0.01, Kind::slider, false, false, Domain::config, {} }, diff --git a/Source/GUI/Patch/AudioInterfaceWindow.cpp b/Source/GUI/Patch/AudioInterfaceWindow.cpp index 1949418..d2d85a1 100644 --- a/Source/GUI/Patch/AudioInterfaceWindow.cpp +++ b/Source/GUI/Patch/AudioInterfaceWindow.cpp @@ -459,44 +459,6 @@ AudioInterfaceContent::AudioInterfaceContent (AppContext& ctx) if (inputTab != nullptr) inputTab->resetMode(); }; addAndMakeVisible (tabs); - - transportBanner.setText (LOC ("audioPatch.transportLocked"), juce::dontSendNotification); - transportBanner.setJustificationType (juce::Justification::centred); - transportBanner.setColour (juce::Label::backgroundColourId, - ColorScheme::accents::mute.withAlpha (0.85f)); - transportBanner.setColour (juce::Label::textColourId, juce::Colours::white); - addChildComponent (transportBanner); - - startTimerHz (8); // the transport gate poll (D41) -} - -AudioInterfaceContent::~AudioInterfaceContent() -{ - stopTimer(); -} - -void AudioInterfaceContent::timerCallback() -{ - const bool playing = context.engine.getFilePlayer().isPlaying(); - if (playing == gateActive) - return; - - gateActive = playing; - if (playing) - { - // D41: no patch edits on a running transport. Stop tones, drop to - // Scrolling, disable everything behind the banner. - if (outputTab != nullptr) outputTab->resetMode(); - if (inputTab != nullptr) inputTab->resetMode(); - tabs.setEnabled (false); - transportBanner.setVisible (true); - transportBanner.toFront (false); - } - else - { - tabs.setEnabled (true); - transportBanner.setVisible (false); - } } void AudioInterfaceContent::windowClosing() @@ -515,7 +477,6 @@ void AudioInterfaceContent::resized() auto area = getLocalBounds(); infoBar.setBounds (area.removeFromTop (px (28))); tabs.setBounds (area); - transportBanner.setBounds (getLocalBounds().withTrimmedTop (px (28)).removeFromTop (px (40))); } //============================================================================== diff --git a/Source/GUI/Patch/AudioInterfaceWindow.h b/Source/GUI/Patch/AudioInterfaceWindow.h index bbd259b..e04cb74 100644 --- a/Source/GUI/Patch/AudioInterfaceWindow.h +++ b/Source/GUI/Patch/AudioInterfaceWindow.h @@ -6,10 +6,11 @@ Device Settings / Input Patch / Output Patch tabs around the shared spatcore patch matrix. XOA-owned shell (deliberately not shared — handoff §7), copied in shape from WFS-DIY's AudioInterfaceWindow/AudioPatchTab and - reduced: one XoaPatchTab class serves both directions, and the transport - gate (D41) replaces WFS-DIY's processing gate — patch edits only while the - file transport is stopped; while it runs the content is disabled behind a - banner and test tones are stopped. + reduced: one XoaPatchTab class serves both directions. There is no + processing/transport gate (D49): XOA is a processor whose program comes + from external players it cannot sense, so the safety net is the explicit + matrix interaction and the generator's 500 ms protective ramp; test tones + still stop on every exit path (tab change, mode change, window close). This file is part of XOA, released under the GNU General Public License v3.0. See LICENSE for details. @@ -140,15 +141,12 @@ class XoaPatchTab : public juce::Component, }; //============================================================================== -/** Info bar + Device Settings / Input Patch / Output Patch tabs, plus the - transport gate (D41): while the file transport plays, the content is - disabled behind a banner and test tones stop. */ -class AudioInterfaceContent : public juce::Component, - private juce::Timer +/** Info bar + Device Settings / Input Patch / Output Patch tabs. */ +class AudioInterfaceContent : public juce::Component { public: explicit AudioInterfaceContent (AppContext& ctx); - ~AudioInterfaceContent() override; + ~AudioInterfaceContent() override = default; void resized() override; void paint (juce::Graphics& g) override; @@ -158,9 +156,9 @@ class AudioInterfaceContent : public juce::Component, private: /** Tab switches must stop a running test tone (§7.2 B exit paths). */ - struct GateTabbedComponent : juce::TabbedComponent + struct NotifyingTabbedComponent : juce::TabbedComponent { - GateTabbedComponent() : juce::TabbedComponent (juce::TabbedButtonBar::TabsAtTop) {} + NotifyingTabbedComponent() : juce::TabbedComponent (juce::TabbedButtonBar::TabsAtTop) {} std::function onTabChanged; void currentTabChanged (int newIndex, const juce::String&) override { @@ -169,21 +167,16 @@ class AudioInterfaceContent : public juce::Component, } }; - void timerCallback() override; // the transport gate poll - AppContext& context; DeviceInfoBar infoBar; - GateTabbedComponent tabs; - juce::Label transportBanner; + NotifyingTabbedComponent tabs; // Owned by the TabbedComponent. DeviceSettingsPanel* devicePanel = nullptr; XoaPatchTab* inputTab = nullptr; XoaPatchTab* outputTab = nullptr; - bool gateActive = false; - JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (AudioInterfaceContent) }; diff --git a/Source/GUI/Tabs/SystemConfigTab.cpp b/Source/GUI/Tabs/SystemConfigTab.cpp index 8092f6c..4ec79f6 100644 --- a/Source/GUI/Tabs/SystemConfigTab.cpp +++ b/Source/GUI/Tabs/SystemConfigTab.cpp @@ -25,11 +25,9 @@ namespace xoa::ui SystemConfigTab::SystemConfigTab (AppContext& ctx) : TabPage (ctx, Surface::systemConfig) { addAndMakeVisible (showGroup); - addAndMakeVisible (playbackGroup); addAndMakeVisible (deviceGroup); addAndMakeVisible (appearanceGroup); showGroup.setText (LOC ("systemConfig.show")); - playbackGroup.setText (LOC ("systemConfig.playback")); deviceGroup.setText (LOC ("systemConfig.device")); appearanceGroup.setText (LOC ("systemConfig.appearance")); @@ -50,18 +48,6 @@ SystemConfigTab::SystemConfigTab (AppContext& ctx) : TabPage (ctx, Surface::syst saveButton.onClick = [this] { saveProjectDialog(); }; importButton.onClick = [this] { importWfsDialog(); }; - // Playback interpretation - contentOrderLabel.setText (LOC ("param.playbackContentOrder"), juce::dontSendNotification); - contentOrderLabel.setJustificationType (juce::Justification::centredRight); - conventionLabel.setText (LOC ("param.playbackConvention"), juce::dontSendNotification); - conventionLabel.setJustificationType (juce::Justification::centredRight); - addAndMakeVisible (contentOrderLabel); - addAndMakeVisible (conventionLabel); - addAndMakeVisible (contentOrderCombo); - addAndMakeVisible (conventionCombo); - bindings.bindCombo (contentOrderCombo, ids::playbackContentOrder); - bindings.bindCombo (conventionCombo, ids::playbackConvention); - // Audio device + patching live in the Audio Interface window (stage 2): // every device mutation there routes through DeviceHost, which the stock // selector could not do. audioDeviceState is still persisted by the @@ -241,15 +227,6 @@ void SystemConfigTab::resized() } col.removeFromTop (px (8)); - { - auto g = col.removeFromTop (px (120)); - playbackGroup.setBounds (g); - auto inner = g.reduced (px (12), px (22)); - labelled (inner, contentOrderLabel, contentOrderCombo, px (160)); - labelled (inner, conventionLabel, conventionCombo, px (160)); - } - col.removeFromTop (px (8)); - { auto g = col.removeFromTop (px (120)); appearanceGroup.setBounds (g); diff --git a/Source/GUI/Tabs/SystemConfigTab.h b/Source/GUI/Tabs/SystemConfigTab.h index dbd22fb..f0af9e0 100644 --- a/Source/GUI/Tabs/SystemConfigTab.h +++ b/Source/GUI/Tabs/SystemConfigTab.h @@ -41,15 +41,12 @@ class SystemConfigTab : public TabPage, void importWfsDialog(); void openAudioInterfaceWindow(); - juce::GroupComponent showGroup, playbackGroup, deviceGroup, appearanceGroup; + juce::GroupComponent showGroup, deviceGroup, appearanceGroup; juce::Label showNameLabel; juce::TextEditor showNameEditor; juce::TextButton loadButton, saveButton, importButton; - juce::Label contentOrderLabel, conventionLabel; - juce::ComboBox contentOrderCombo, conventionCombo; - juce::Label themeLabel, languageLabel; juce::ComboBox themeCombo, languageCombo; diff --git a/Source/Network/OSCMessageRouter.h b/Source/Network/OSCMessageRouter.h index 38b3588..81339a6 100644 --- a/Source/Network/OSCMessageRouter.h +++ b/Source/Network/OSCMessageRouter.h @@ -89,9 +89,7 @@ inline const std::vector& bindings() { Family::config, "masterGain", i::masterGain, WireType::f32, false }, { Family::config, "distanceCompMode", i::distanceCompMode, WireType::i32, false }, { Family::config, "monoInputsEnabled", i::monoInputsEnabled, WireType::boolean, false }, - { Family::config, "playbackLoop", i::playbackLoop, WireType::boolean, false }, - { Family::config, "playbackContentOrder", i::playbackContentOrder, WireType::i32, false }, - { Family::config, "playbackConvention", i::playbackConvention, WireType::i32, false }, + // playbackLoop/ContentOrder/Convention removed in map v1.1 (D48/D50). { Family::config, "inputCount", i::inputCount, WireType::i32, false }, { Family::config, "speakerCount", i::speakerCount, WireType::i32, false }, // rotation diff --git a/Source/Parameters/XoaConstraints.h b/Source/Parameters/XoaConstraints.h index 3f6a651..6681703 100644 --- a/Source/Parameters/XoaConstraints.h +++ b/Source/Parameters/XoaConstraints.h @@ -45,13 +45,10 @@ inline const std::vector>& allBounds() { ids::oscSendPort, { d::oscPortMin, d::oscPortMax, d::oscSendPortDefault, true } }, { ids::oscTcpPort, { d::oscPortMin, d::oscPortMax, d::oscTcpPortDefault, true } }, - // Config / scene rotation + playback (playbackLoop is bool and - // playbackFilePath is string -> unbounded by convention) + // Config / scene rotation { ids::rotationYaw, { d::rotationYawMin, d::rotationYawMax, d::rotationYawDefault, false } }, { ids::rotationPitch, { d::rotationPitchMin, d::rotationPitchMax, d::rotationPitchDefault, false } }, { ids::rotationRoll, { d::rotationRollMin, d::rotationRollMax, d::rotationRollDefault, false } }, - { ids::playbackContentOrder, { d::playbackContentOrderMin, d::playbackContentOrderMax, d::playbackContentOrderDefault, true } }, - { ids::playbackConvention, { d::playbackConventionMin, d::playbackConventionMax, d::playbackConventionDefault, true } }, { ids::distanceCompMode, { d::distanceCompModeMin, d::distanceCompModeMax, d::distanceCompModeDefault, true } }, { ids::listenerX, { d::positionMin, d::positionMax, d::listenerXDefault, false } }, { ids::listenerY, { d::positionMin, d::positionMax, d::listenerYDefault, false } }, diff --git a/Source/Parameters/XoaParameterDefaults.h b/Source/Parameters/XoaParameterDefaults.h index ef13bcc..66f8d1d 100644 --- a/Source/Parameters/XoaParameterDefaults.h +++ b/Source/Parameters/XoaParameterDefaults.h @@ -49,15 +49,6 @@ constexpr double rotationYawDefault = 0.0, rotationYawMin = -180.0, rotation constexpr double rotationPitchDefault = 0.0, rotationPitchMin = -90.0, rotationPitchMax = 90.0; constexpr double rotationRollDefault = 0.0, rotationRollMin = -180.0, rotationRollMax = 180.0; -// Config / Playback. Content order 0 means "auto-detect from channel count"; -// the max is the bus order (order-generic rule FR-3). -inline const juce::String playbackFilePathDefault {}; -constexpr bool playbackLoopDefault = false; -constexpr double playbackContentOrderDefault = 0.0, playbackContentOrderMin = 0.0, - playbackContentOrderMax = (double) xoa::kAmbisonicOrder; -constexpr double playbackConventionDefault = 0.0, - playbackConventionMin = 0.0, playbackConventionMax = 2.0; // 0 SN3D, 1 N3D, 2 FuMa - // Config / distance compensation (FR-15). Default off preserves M1 behaviour // and every existing baseline. constexpr double distanceCompModeDefault = 0.0, diff --git a/Source/Parameters/XoaParameterIDs.h b/Source/Parameters/XoaParameterIDs.h index 842f20f..4396447 100644 --- a/Source/Parameters/XoaParameterIDs.h +++ b/Source/Parameters/XoaParameterIDs.h @@ -79,13 +79,6 @@ inline const juce::Identifier rotationYaw { "rotationYaw" }; inline const juce::Identifier rotationPitch { "rotationPitch" }; inline const juce::Identifier rotationRoll { "rotationRoll" }; -// Config / Playback (FR-8). Play state and transport position are -// deliberately runtime-only: persisting them would pollute undo/dirty. -inline const juce::Identifier playbackFilePath { "playbackFilePath" }; -inline const juce::Identifier playbackLoop { "playbackLoop" }; -inline const juce::Identifier playbackContentOrder { "playbackContentOrder" }; // 0 = auto-detect -inline const juce::Identifier playbackConvention { "playbackConvention" }; // 0 SN3D, 1 N3D, 2 FuMa - // Config / per-speaker distance compensation (FR-15). "distance*" routes to // Config; the per-speaker delay/gain come from speaker positions, not schema. inline const juce::Identifier distanceCompMode { "distanceCompMode" }; // 0 off, 1 delay, 2 delay+gain diff --git a/Source/Parameters/XoaValueTreeState.cpp b/Source/Parameters/XoaValueTreeState.cpp index 00277e0..6890682 100644 --- a/Source/Parameters/XoaValueTreeState.cpp +++ b/Source/Parameters/XoaValueTreeState.cpp @@ -59,10 +59,6 @@ void XoaValueTreeState::initializeDefaultState() config.setProperty (ids::rotationYaw, d::rotationYawDefault, nullptr); config.setProperty (ids::rotationPitch, d::rotationPitchDefault, nullptr); config.setProperty (ids::rotationRoll, d::rotationRollDefault, nullptr); - config.setProperty (ids::playbackFilePath, d::playbackFilePathDefault, nullptr); - config.setProperty (ids::playbackLoop, d::playbackLoopDefault, nullptr); - config.setProperty (ids::playbackContentOrder, static_cast (d::playbackContentOrderDefault), nullptr); - config.setProperty (ids::playbackConvention, static_cast (d::playbackConventionDefault), nullptr); config.setProperty (ids::distanceCompMode, static_cast (d::distanceCompModeDefault), nullptr); config.setProperty (ids::listenerX, d::listenerXDefault, nullptr); config.setProperty (ids::listenerY, d::listenerYDefault, nullptr); diff --git a/Source/XoaConstants.h b/Source/XoaConstants.h index 5a0c93d..8ffdaa3 100644 --- a/Source/XoaConstants.h +++ b/Source/XoaConstants.h @@ -50,8 +50,10 @@ constexpr int kDefaultSpeakers = 24; // the M1 24-ring validation fixture /** Per-speaker EQ band count (matches spatcore's per-output EQ chain). */ constexpr int kNumEqBands = 6; -/** FR-8 file-playback ceiling: multichannel WAV/CAF/FLAC up to 128 channels - (order 10 needs 121; the headroom matches the PRD's stated cap). */ +/** HOA bus-source channel ceiling (order 10 needs 121, plus headroom). Sizes + the test-scene render scratch and clamps rt::makeBusParams. Historically + the FR-8 file-playback cap; the file player is gone (D48) but the value is + baked into the offline-render baselines, so it keeps its name and value. */ constexpr int kMaxFileChannels = 128; /** Speed of sound (m/s), the single project-wide value. Used by the WP7 diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 8ff7ddc..51144a8 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -11,7 +11,7 @@ target_sources(xoa-tests PRIVATE XoaShTests.cpp XoaRotationTests.cpp XoaDecoderTests.cpp - XoaFilePlayerTests.cpp + XoaFileFormatTests.cpp XoaBusTests.cpp XoaSceneTests.cpp XoaEngineTests.cpp @@ -30,7 +30,6 @@ target_sources(xoa-tests PRIVATE XoaPatchTests.cpp ../Source/Parameters/XoaValueTreeState.cpp ../Source/Parameters/XoaFileManager.cpp - ../Source/Audio/FilePlayer.cpp ../Source/Audio/AudioEngine.cpp ../Source/DSP/AmbiCalculationEngine.cpp ../Source/DSP/ConvexHull.cpp @@ -44,7 +43,7 @@ target_include_directories(xoa-tests PRIVATE # The JUCE modules arrive transitively through the libs' PUBLIC link # interface (their sources compile into this target, JUCE-module style). # juce_audio_utils brings juce_audio_devices (AudioTransportSource) for -# FilePlayer; harmless headless (no device is opened by the tests). +# the engine sources; harmless headless (no device is opened by the tests). target_link_libraries(xoa-tests PRIVATE spatcore-audio spatcore-control diff --git a/tests/XoaFilePlayerTests.cpp b/tests/XoaFileFormatTests.cpp similarity index 56% rename from tests/XoaFilePlayerTests.cpp rename to tests/XoaFileFormatTests.cpp index 095cc81..1cd71e7 100644 --- a/tests/XoaFilePlayerTests.cpp +++ b/tests/XoaFileFormatTests.cpp @@ -1,10 +1,10 @@ /* - XoaFilePlayerTests.cpp - WP6 file-I/O layer tests. + XoaFileFormatTests.cpp - WP6 C1 large-channel file-format spike. - C1 spike (DEVPLAN WP6: "verify JUCE WavAudioFormat/CAF actually reads - 121-128-channel files on all three OSes before building on it"): this - suite IS the spike - green CI on the three OSes is the verdict that - FR-8 file playback can be built on stock juce_audio_formats. + Green CI on the three OSes is the verdict that stock juce_audio_formats + reads/writes 121-128-channel files correctly. Kept after the file player + was removed (D48): any future file import/export (offline-render --wav, + the possible sampler player) builds on this proof. Format scope (per plan): WAV (float32, any channel count up to kMaxFileChannels) and FLAC (<= 8 channels - a FLAC format-spec cap, not @@ -22,8 +22,6 @@ #include "XoaConstants.h" #include "XoaTestFramework.h" -#include "Audio/FilePlayer.h" - #include namespace @@ -154,122 +152,14 @@ void testGarbageFileRejected (const juce::File& dir) CHECK (flacReader == nullptr); } -//============================================================================== -// F5 - the FilePlayer streaming path. A constant per-channel signal is -// resampling-invariant, so a rendered block must equal (c+1)*0.1 on channel c -// regardless of read-ahead warm-up or rate correction. -//============================================================================== - -// Write a numChannels x numSamples WAV whose channel c is the constant (c+1)*0.1. -juce::File writeConstantWav (const juce::File& dir, int numChannels, int numSamples, double sr) -{ - juce::AudioBuffer buffer (numChannels, numSamples); - for (int c = 0; c < numChannels; ++c) - juce::FloatVectorOperations::fill (buffer.getWritePointer (c), - 0.1f * (float) (c + 1), numSamples); - - const auto file = dir.getChildFile ("player-" + juce::String (numChannels) + "ch.wav"); - file.deleteFile(); - - auto fileStream = std::make_unique (file); - std::unique_ptr os (std::move (fileStream)); - juce::WavAudioFormat wav; - const auto options = juce::AudioFormatWriterOptions {} - .withSampleRate (sr) - .withNumChannels (numChannels) - .withBitsPerSample (32) - .withSampleFormat (juce::AudioFormatWriterOptions::SampleFormat::floatingPoint); - auto writer = wav.createWriterFor (os, options); - CHECK (writer != nullptr); - if (writer != nullptr) - writer->writeFromAudioSampleBuffer (buffer, 0, numSamples); - return file; -} - -// Render blocks until non-silent (read-ahead warm-up) or a bounded number of -// attempts elapse. Returns true if audio arrived. -bool renderUntilAudio (xoa::FilePlayer& player, juce::AudioBuffer& block, int numSamples) -{ - for (int attempt = 0; attempt < 400; ++attempt) - { - player.renderNextBlock (block, numSamples); - if (block.getMagnitude (0, 0, numSamples) > 1.0e-6f) - return true; - juce::Thread::sleep (2); - } - return false; -} - -void testFilePlayerOrderDetection() -{ - CHECK (xoa::FilePlayer::detectAmbiOrder (4) == 1); - CHECK (xoa::FilePlayer::detectAmbiOrder (16) == 3); - CHECK (xoa::FilePlayer::detectAmbiOrder (121) == 10); - CHECK (xoa::FilePlayer::detectAmbiOrder (7) == 0); // not a perfect square - CHECK (xoa::FilePlayer::detectAmbiOrder (144) == 0); // order 11 > bus order -> no fit -} - -void testFilePlayerStreaming (const juce::File& dir) -{ - const int numCh = 4, block = 512; - const double sr = 48000.0; - const int lengthSamples = 48000; // 1 second - - const auto file = writeConstantWav (dir, numCh, lengthSamples, sr); - - xoa::FilePlayer player; - const auto r = player.open (file); - CHECK (r.ok); - CHECK (r.numChannels == numCh); - CHECK (r.fileSampleRate == sr); - CHECK (r.lengthSamples == lengthSamples); - CHECK (r.detectedOrder == 1); // (1+1)^2 = 4 - CHECK (player.getNumChannels() == numCh); - - player.prepareToPlay (sr, block); - - // Stopped -> silence. - juce::AudioBuffer buf (numCh, block); - buf.clear(); - player.renderNextBlock (buf, block); - CHECK (buf.getMagnitude (0, 0, block) == 0.0f); - - // Playing -> the constants (after read-ahead warm-up). - player.play(); - CHECK (player.isPlaying()); - CHECK (renderUntilAudio (player, buf, block)); - for (int c = 0; c < numCh; ++c) - { - const float expected = 0.1f * (float) (c + 1); - CHECK (std::abs (buf.getSample (c, 0) - expected) < 1.0e-3f); - CHECK (std::abs (buf.getSample (c, block - 1) - expected) < 1.0e-3f); - } - - // Seek + length report. - CHECK (std::abs (player.getLengthSeconds() - 1.0) < 1.0e-3); - player.seekSeconds (0.5); - CHECK (std::abs (player.getPositionSeconds() - 0.5) < 0.05); - - // Loop smoke: seek near the end, keep rendering past it, still get audio. - player.setLooping (true); - player.seekSeconds (0.98); - CHECK (renderUntilAudio (player, buf, block)); - - player.stop(); - player.close(); - CHECK (player.getNumChannels() == 0); -} - } // namespace //============================================================================== -void runXoaFilePlayerTests() +void runXoaFileFormatTests() { ScopedTempDir tmp; testWav121Channels (tmp.dir); testWav128Channels (tmp.dir); testFlac8Channels (tmp.dir); testGarbageFileRejected (tmp.dir); - testFilePlayerOrderDetection(); - testFilePlayerStreaming (tmp.dir); } diff --git a/tests/XoaParameterTests.cpp b/tests/XoaParameterTests.cpp index dbcbc46..20585e6 100644 --- a/tests/XoaParameterTests.cpp +++ b/tests/XoaParameterTests.cpp @@ -227,7 +227,8 @@ static void testDefaultSchema() } //============================================================================== -// T4b — WP6 Config additions: scene rotation + playback parameters +// T4b — WP6 Config additions: scene rotation parameters +// (playback ids were removed with the file player, D48/D50) //============================================================================== static void testWp6ConfigParameters() { @@ -237,26 +238,16 @@ static void testWp6ConfigParameters() CHECK (s.getFloatParameter (ids::rotationYaw) == 0.0f); CHECK (s.getFloatParameter (ids::rotationPitch) == 0.0f); CHECK (s.getFloatParameter (ids::rotationRoll) == 0.0f); - CHECK (s.getStringParameter (ids::playbackFilePath).isEmpty()); - CHECK (! static_cast (s.getParameter (ids::playbackLoop))); - CHECK (s.getIntParameter (ids::playbackContentOrder) == 0); // auto - CHECK (s.getIntParameter (ids::playbackConvention) == 0); // SN3D // Gate-1 live clamps from the bounds table s.setParameter (ids::rotationYaw, 500.0); CHECK (s.getFloatParameter (ids::rotationYaw) == 180.0f); s.setParameter (ids::rotationPitch, -123.0); CHECK (s.getFloatParameter (ids::rotationPitch) == -90.0f); - s.setParameter (ids::playbackContentOrder, 99.0); - CHECK (s.getIntParameter (ids::playbackContentOrder) == xoa::kAmbisonicOrder); - // In-range writes land verbatim; strings/bools stay unbounded + // In-range writes land verbatim s.setParameter (ids::rotationRoll, -45.0); CHECK (s.getFloatParameter (ids::rotationRoll) == -45.0f); - s.setParameter (ids::playbackFilePath, "d:/scenes/dome.wav"); - CHECK (s.getStringParameter (ids::playbackFilePath) == "d:/scenes/dome.wav"); - s.setParameter (ids::playbackLoop, true); - CHECK (static_cast (s.getParameter (ids::playbackLoop))); } //============================================================================== @@ -269,6 +260,9 @@ static void testWp6ConfigPersistence() // Non-default, in-range values load verbatim; an out-of-range angle is // clamped by Gate-2 (validateLoadedProperty), not rejected to default. + // The file also carries the REMOVED playback ids (a project saved by a + // pre-D48 build) — they must merge harmlessly: load succeeds, nothing + // reads them, and no live parameter is disturbed. const auto cfgFile = tmp.dir.getChildFile ("wp6_config.xml"); cfgFile.replaceWithText ( "\n" @@ -285,12 +279,8 @@ static void testWp6ConfigPersistence() CHECK (s.getFloatParameter (ids::rotationYaw) == 45.0f); // in-range verbatim CHECK (s.getFloatParameter (ids::rotationPitch) == 90.0f); // 9999 -> clamped to +90 CHECK (s.getFloatParameter (ids::rotationRoll) == -30.0f); - CHECK (s.getStringParameter (ids::playbackFilePath) == "d:/scenes/dome.wav"); - CHECK (static_cast (s.getParameter (ids::playbackLoop))); - CHECK (s.getIntParameter (ids::playbackContentOrder) == 3); - CHECK (s.getIntParameter (ids::playbackConvention) == 2); - // A legacy config that predates these params must not lose them: the merge + // A legacy config that predates newer params must not lose them: the merge // leaves each at its default rather than dropping it from the tree. const auto legacyFile = tmp.dir.getChildFile ("legacy_config.xml"); legacyFile.replaceWithText ( @@ -306,9 +296,6 @@ static void testWp6ConfigPersistence() CHECK (s2.getStringParameter (ids::showName) == "Legacy"); // file value applied CHECK (s2.getConfigSection().hasProperty (ids::rotationYaw)); // param survived the merge CHECK (s2.getFloatParameter (ids::rotationYaw) == 0.0f); // at its default - CHECK (s2.getStringParameter (ids::playbackFilePath).isEmpty()); - CHECK (s2.getIntParameter (ids::playbackContentOrder) == 0); // auto - CHECK (s2.getIntParameter (ids::playbackConvention) == 0); // SN3D } //============================================================================== diff --git a/tests/XoaTests.cpp b/tests/XoaTests.cpp index 76b42ed..2cf4a38 100644 --- a/tests/XoaTests.cpp +++ b/tests/XoaTests.cpp @@ -24,7 +24,7 @@ WFS import completion (XoaDecoderTests.cpp) 7. WP6 suite file-I/O spike: 121/128-ch WAV + 8-ch FLAC round-trips, garbage rejection - (XoaFilePlayerTests.cpp); RT snapshot composers + (XoaFileFormatTests.cpp); RT snapshot composers (rotation state, gather table) (XoaBusTests.cpp); synthetic test-scene generator: determinism, block-partition independence, encoding @@ -46,7 +46,7 @@ void runXoaParameterTests(); void runXoaShTests(); void runXoaRotationTests(); void runXoaDecoderTests(); -void runXoaFilePlayerTests(); +void runXoaFileFormatTests(); void runXoaBusTests(); void runXoaSceneTests(); void runXoaEngineTests(); @@ -119,7 +119,7 @@ int main() runXoaShTests(); runXoaRotationTests(); runXoaDecoderTests(); - runXoaFilePlayerTests(); + runXoaFileFormatTests(); runXoaBusTests(); runXoaSceneTests(); runXoaEngineTests(); diff --git a/tests/XoaUiDescriptorTests.cpp b/tests/XoaUiDescriptorTests.cpp index 9042ab8..5356138 100644 --- a/tests/XoaUiDescriptorTests.cpp +++ b/tests/XoaUiDescriptorTests.cpp @@ -38,7 +38,7 @@ using StrSet = std::set; // bool/string parameters that carry no numeric bounds and no OSC binding, yet are // real v1 parameters a surface must expose (or reach via a system surface). const char* const kExtras[] = { - "showName", "playbackFilePath", + "showName", "oscEnabled", "oscTcpEnabled", "oscAcceptAnyHost", "oscFeedbackEnabled", "oscMeterEnabled", "oscSendAddress", "audioDeviceState" };