diff --git a/CLAUDE.md b/CLAUDE.md index a3633df..5d9367b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,10 +18,13 @@ 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 **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 + + 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 here — changes go to the spatcore repo and arrive via a pin bump. @@ -44,6 +47,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/CMakeLists.txt b/CMakeLists.txt index b35e522..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 @@ -79,7 +78,11 @@ 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, 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 @@ -106,6 +109,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-AUDIO-DEVICE-AND-PATCH-HANDOFF.md b/Documentation/XOA-AUDIO-DEVICE-AND-PATCH-HANDOFF.md new file mode 100644 index 0000000..7d50276 --- /dev/null +++ b/Documentation/XOA-AUDIO-DEVICE-AND-PATCH-HANDOFF.md @@ -0,0 +1,580 @@ +# 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 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 +`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` | **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 `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: + +- **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: + +``` +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 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. + +> 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. + +### 7.2 What XOA builds: the shell + +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. + +**A. Two patch tabs** (WFS-DIY reference: `Source/gui/AudioPatchTab.{h,cpp}`) + +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()` + `Manager` listener | same names | +| Strings | `LOC()` macro | same | +| Status line | `StatusBar` | same | +| Accessibility | `TTSManager` singleton | same | +| Long-press button | `LongPressButton` | same | +| Stream Deck+ pages | `PatchWindowPages` | **absent** — XOA links `spatcore-controllers` but never includes it | + +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.4 Sequencing + +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. + +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. + +--- + +## 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, 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`. + +--- + +## 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 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` | diff --git a/Documentation/XOA-DEVPLAN.md b/Documentation/XOA-DEVPLAN.md index 97e7e86..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)** | @@ -195,6 +195,100 @@ 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. + +**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. + +**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. + --- ## 5. Work packages @@ -426,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 @@ -451,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 @@ -831,6 +929,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 @@ -1073,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-PLAN.md b/Documentation/XOA-PLAN.md index bfbddfd..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 @bf96b3c 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/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 a8370e4..342a48b 100644 --- a/Resources/lang/en.json +++ b/Resources/lang/en.json @@ -52,15 +52,14 @@ "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", "loadProject": "Load project…", "saveProject": "Save project…", @@ -165,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", @@ -179,6 +174,7 @@ "inputName": "Name", "inputGain": "Gain", "inputMute": "Mute", + "inputFormat": "Format", "inputPositionX": "X", "inputPositionY": "Y", "inputPositionZ": "Z", @@ -208,11 +204,36 @@ "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" }, + "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" }, "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/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 220b99b..3ef0e6e 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 @@ -24,6 +28,7 @@ AudioEngine::AudioEngine (XoaValueTreeState& s) publishRotation(); publishBusParams(); publishSpeakerComp(); + publishPatchRouting(); rebuildDecoderNow(); updateReferenceRadius(); // seed the encoder r_ref from the speaker layout } @@ -47,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 @@ -72,6 +75,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; } @@ -84,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); @@ -93,6 +100,7 @@ void AudioEngine::unregisterListeners() speakersSection.removeListener (this); decoderSection.removeListener (this); + audioPatchSection.removeListener (this); listenersRegistered = false; } @@ -104,13 +112,23 @@ 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; + // 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(); @@ -122,7 +140,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; @@ -136,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 } //============================================================================== @@ -167,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)); } } @@ -187,6 +194,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 @@ -208,6 +234,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) @@ -334,6 +372,18 @@ 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; + + // 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()); @@ -343,11 +393,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,87 +412,123 @@ void AudioEngine::audioDeviceAboutToStart (juce::AudioIODevice* device) measuredLatencyMs.store (sr > 0.0 ? (double) (outLatency + block) / sr * 1000.0 : 0.0, std::memory_order_relaxed); + // 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()); + + 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 (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); - filePlayer.prepareToPlay (sr, block); + 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); - const int numOut = device->getActiveOutputChannels().countNumberOfSetBits(); - 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); } -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); + numSpeakersPrepared.store (0, std::memory_order_relaxed); + for (auto& p : hwInputPeak) + p.store (0.0f, 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); + + 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 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(); - - juce::AudioSourceChannelInfo info (&outBuf, 0, n); - - // 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. + // 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()); + + // 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 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 || numInputChannels > 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 < numInputChannels && inputChannelData[i] != nullptr) - juce::FloatVectorOperations::copy (d, inputChannelData[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); } @@ -444,10 +536,29 @@ void AudioEngine::audioDeviceIOCallbackWithContext (const float* const* inputCha 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); + { + 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) + spkBuf.clear(); // silence the whole block rather than emit a partial tail + + juce::AudioSourceChannelInfo outInfo (&spkBuf, 0, n); if (inputSource.load (std::memory_order_relaxed) == InputSource::testScene) { @@ -456,30 +567,58 @@ 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, numSpk, stemsPtr, numStems); } else { - filePlayer.renderNextBlock (inputScratch, n); - algorithm.processBlock (info, inputScratch, - fileNumChannels.load (std::memory_order_relaxed), numOutputChannels, + // 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); } // Per-speaker compensation (delay/EQ/gain) on the decoded output, in place. - speakerComp.processBlock (outBuf, numOutputChannels, 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 < numOutputChannels && 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) - 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 000b3b1..ea6e52a 100644 --- a/Source/Audio/AudioEngine.h +++ b/Source/Audio/AudioEngine.h @@ -5,12 +5,15 @@ #include #include #include +#include +#include "spatcore/io/DeviceHost.h" +#include "spatcore/io/DeviceIoCallback.h" #include "spatcore/rt/RtSnapshot.h" #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" @@ -22,12 +25,17 @@ #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 -> +// BusParamsPublisher master gain + input-source (none / test scene) -> // RtSnapshot // DecoderRebuildControl Speakers/Decoder subtree changes -> a 150 ms // debounce -> DecoderMatrixBuilder rebuild + publish @@ -42,14 +50,19 @@ namespace xoa { -class AudioEngine : private juce::AudioIODeviceCallback, +class AudioEngine : private juce::AudioSource, private juce::ChangeListener, private juce::ValueTree::Listener, private juce::Timer, 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 @@ -66,12 +79,12 @@ class AudioEngine : private juce::AudioIODeviceCallback, void closeAudioDevice(); // stop the callback, persist state juce::AudioDeviceManager& getDeviceManager() noexcept { return deviceManager; } - FilePlayer& getFilePlayer() noexcept { return filePlayer; } + spatcore::io::DeviceHost& getDeviceHost() noexcept { return deviceHost; } 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); } @@ -84,10 +97,6 @@ class AudioEngine : private juce::AudioIODeviceCallback, 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(); @@ -120,19 +129,38 @@ class AudioEngine : private juce::AudioIODeviceCallback, 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); } 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; @@ -142,6 +170,11 @@ class AudioEngine : private juce::AudioIODeviceCallback, 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). */ @@ -149,13 +182,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; @@ -197,9 +230,18 @@ class AudioEngine : private juce::AudioIODeviceCallback, // these members keep the registration alive for the engine's lifetime. juce::ValueTree speakersSection; juce::ValueTree decoderSection; + juce::ValueTree audioPatchSection; juce::AudioDeviceManager deviceManager; - FilePlayer filePlayer; + + // 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 }; + DecoderMatrixBuilder decoderBuilder; AmbiCalculationEngine calcEngine; // control-side encoder (owns the live matrices) @@ -214,6 +256,7 @@ class AudioEngine : private juce::AudioIODeviceCallback, spatcore::rt::RtSnapshot rotationSnapshot; spatcore::rt::RtSnapshot busParamsSnapshot; spatcore::rt::RtSnapshot speakerCompSnapshot; + spatcore::rt::RtSnapshot patchSnapshot; AmbiBusAlgorithm algorithm; SpeakerCompProcessor speakerComp; TestSignalGenerator testSignal; @@ -225,23 +268,41 @@ class AudioEngine : private juce::AudioIODeviceCallback, // Per-input stem meters (WP10 C9), updated per block on the audio thread. std::array, xoa::kMaxInputs> inputPeak {}; - juce::AudioBuffer inputScratch; // [kMaxFileChannels x block] - juce::AudioBuffer stemScratch; // [kMaxInputs x block] mono-encoder stems + // Per-HARDWARE-input meters (patch matrix tinting), pre-gather. + std::array, xoa::kMaxHardwareChannels> hwInputPeak {}; + + 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; juce::uint32 busEpoch = 0; juce::uint32 speakerCompEpoch = 0; + juce::uint32 patchEpoch = 0; std::atomic measuredLatencyMs { 0.0 }; 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 }; + + // 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 bool listenersRegistered = false; bool callbackRegistered = false; 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/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/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/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..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 @@ -102,16 +105,51 @@ 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, "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). //============================================================================== @@ -133,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 11974e8..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). @@ -85,6 +81,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..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, {} }, @@ -92,6 +86,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/Patch/AudioInterfaceWindow.cpp b/Source/GUI/Patch/AudioInterfaceWindow.cpp new file mode 100644 index 0000000..d2d85a1 --- /dev/null +++ b/Source/GUI/Patch/AudioInterfaceWindow.cpp @@ -0,0 +1,507 @@ +#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); +} + +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); +} + +//============================================================================== +// 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..e04cb74 --- /dev/null +++ b/Source/GUI/Patch/AudioInterfaceWindow.h @@ -0,0 +1,198 @@ +/* + ============================================================================== + + 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. 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. + + ============================================================================== +*/ + +#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. */ +class AudioInterfaceContent : public juce::Component +{ +public: + explicit AudioInterfaceContent (AppContext& ctx); + ~AudioInterfaceContent() override = default; + + 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 NotifyingTabbedComponent : juce::TabbedComponent + { + NotifyingTabbedComponent() : juce::TabbedComponent (juce::TabbedButtonBar::TabsAtTop) {} + std::function onTabChanged; + void currentTabChanged (int newIndex, const juce::String&) override + { + if (onTabChanged) + onTabChanged (newIndex); + } + }; + + AppContext& context; + + DeviceInfoBar infoBar; + NotifyingTabbedComponent tabs; + + // Owned by the TabbedComponent. + DeviceSettingsPanel* devicePanel = nullptr; + XoaPatchTab* inputTab = nullptr; + XoaPatchTab* outputTab = nullptr; + + 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/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 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/GUI/Tabs/SystemConfigTab.cpp b/Source/GUI/Tabs/SystemConfigTab.cpp index 700f6ab..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,24 +48,20 @@ 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 (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 +109,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 +193,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); @@ -199,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 8249e10..f0af9e0 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,25 +32,31 @@ 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; + 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; - 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) 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 663f2fb..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 } }, @@ -68,6 +65,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..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, @@ -81,6 +72,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..4396447 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" }; @@ -71,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 @@ -97,6 +98,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 +140,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..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); @@ -91,6 +87,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 +107,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 +242,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 +319,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 +420,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 9b41a25..8ffdaa3 100644 --- a/Source/XoaConstants.h +++ b/Source/XoaConstants.h @@ -31,14 +31,29 @@ 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; + +/** 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 /** 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/spatcore b/spatcore index 8296f28..7d293e4 160000 --- a/spatcore +++ b/spatcore @@ -1 +1 @@ -Subproject commit 8296f28ace30ed4bdd16fb1f7f407947a4f179d0 +Subproject commit 7d293e4e3105fa847c62e2c2bddf51fac0d6cd9d diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index e8879d1..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 @@ -27,9 +27,9 @@ 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 ../Source/Audio/AudioEngine.cpp ../Source/DSP/AmbiCalculationEngine.cpp ../Source/DSP/ConvexHull.cpp @@ -43,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/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/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/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/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); diff --git a/tests/XoaTests.cpp b/tests/XoaTests.cpp index 58f1bf0..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(); @@ -62,6 +62,7 @@ void runXoaLocalizationTests(); void runXoaUiDescriptorTests(); void runXoaLayoutGeneratorTests(); void runXoaRvReViewTests(); +void runXoaPatchTests(); //============================================================================== static void testXoaConstants() @@ -118,7 +119,7 @@ int main() runXoaShTests(); runXoaRotationTests(); runXoaDecoderTests(); - runXoaFilePlayerTests(); + runXoaFileFormatTests(); runXoaBusTests(); runXoaSceneTests(); runXoaEngineTests(); @@ -134,6 +135,7 @@ int main() runXoaUiDescriptorTests(); runXoaLayoutGeneratorTests(); runXoaRvReViewTests(); + runXoaPatchTests(); } catch (const std::exception& e) { 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" };