Last Updated: May 11, 2026 Note: Newest entries at top, oldest at bottom.
Status: Complete. Small backend truthfulness fix only; no GUI redesign, no transport or DSP changes.
What changed:
source/spatial_engine/realtimeEngine/src/RealtimeBackend.hppRealtimeBackend::init()no longer callsdefaultBackendApiDisplayName()in the pre-stream-open path when the compiled backend family isRtAudio- provisional pre-open label is now
RtAudio API unknown - non-RtAudio backends still use
defaultBackendApiDisplayName()for the pre-open label - the existing post-
mAudioIO.init(...)refresh remains unchanged:mBackendFamilyLabel = mAudioIO.backendName()mBackendApiLabel = mAudioIO.backendApiDisplayName()
Why:
- On Linux, constructing a temporary
RtAudio()can choose the first compiled API with devices in RtAudio's internal priority order, which may prefer JACK before any stream is actually opened. - That made a pre-open static API probe too strong: it could present
RtAudio / JACKas if it were confirmed, when the active stream API was not yet known.
Behavioral impact:
- Pre-open backend labeling is now intentionally conservative for RtAudio.
- Init-time logging and any pre-open diagnostics now prefer
RtAudio API unknownover a speculative backend/API guess. - Post-open and post-start status remains unchanged and continues to use live backend values from the actual stream/backend object.
Validation:
cmake --build build --target spatialroot_gui --parallel 8passed after the fix.- Follow-up audit confirmed:
EngineSession::queryStatus()pre-backend RtAudio path is conservative- GUI
Sample Rate OKstill depends only oneffectiveStreamSampleRateKnown && effectiveStreamSampleRate == 48000 - startup still fails when the effective running stream rate is known and differs from
48000 Hz
Status: Complete. Focused backend/API reporting and sample-rate correctness pass; no new backend families, no sample-rate selection, no transport redesign.
What changed:
-
source/spatial_engine/realtimeEngine/src/EngineSession.hpp/EngineSession.cpp- extended
EngineStatuswith a minimal read-only audio/backend snapshot:audioBackendLabelrequestedSampleRateeffectiveStreamSampleRateeffectiveStreamSampleRateKnownoutputDeviceNameoutputDevicePreferredSampleRateoutputDevicePreferredSampleRateKnown
queryStatus()now surfaces backend/API and sample-rate truth to the GUI and any host embeddingEngineSessionCore- startup failure handling continues to use existing
getLastError()/getFailureDiagnostics()plumbing, but now prefers backend-provided error strings when available
- extended
-
source/spatial_engine/realtimeEngine/src/RealtimeBackend.hpp- added read-only accessors for backend family/API label, selected device name, selected device preferred/default sample rate, and effective running stream sample rate
- added contextual backend error text so init/start failures include backend label, selected device, requested rate, preferred/default rate, and effective rate when known
- added a post-open/post-start stream-rate verification step to close the AlloLib fallback hole
- if the effective running stream rate is known and not
48000, startup now fails immediately with:Sample rate mismatch: Spatial Root requires 48000 Hz, but the audio stream is running at <rate> Hz.
-
internal/cult-allolib/include/al/io/al_AudioIO.hpp/src/io/al_AudioIO.cpp- added narrow read-only accessors to expose compiled backend family, active API display name, and effective stream sample rate without redesigning the backend abstraction
- RtAudio-backed builds now report the best available API label, e.g.
RtAudio / CoreAudio,RtAudio / JACK,RtAudio / ALSA,RtAudio / PulseAudio,RtAudio / WASAPI, orRtAudio API unknown
-
source/gui/imgui/src/App.cpp- removed the old platform-guess backend label helper
- Audio Setup and Copy Diagnostics now distinguish:
- required sample rate
- requested engine sample rate
- selected device preferred/default sample rate
- actual stream sample rate
Sample Rate OKis now shown only when the effective running stream rate is known and equals48000- when the actual stream rate is unavailable, the GUI reports
48 kHz Not Confirmed/Actual stream sample rate: unknownrather than guessing - Audio Setup layout was tightened afterward so these fields fit in the existing card without scrolling;
FORMATwas renamed toBUFFER SIZE,Copy Diagnosticswas moved onto the same row, and the sample-rate status text was compacted and made more legible
Existing diagnostics reused:
EngineSession::getLastError()EngineSession::getFailureDiagnostics()- existing startup-stage capture in
EngineSession::start() EngineSession::queryStatus()/EngineStatusas the single GUI-facing status surface
Behavioral impact:
- Backend support policy did not change in this pass.
- The only functional backend behavior change is startup failure when the effective running stream rate is known to differ from
48000 Hz. - Selected-device preferred/default rate remains warning-only metadata; it is no longer treated as proof of the actual runtime stream rate.
Validation:
./build.sh --guipassed after the core backend/sample-rate implementation.- Final GUI-only follow-up tweaks after that build:
- sample-rate box body text changed to white in the OK state
- Audio Setup row/box layout compressed to avoid scroll
- these two tweaks were not rebuilt separately
Status: Complete. Small CULT backend fix plus prior GUI invocation hardening; no LUSID schema, ADM parsing-model, or engine changes.
Root cause:
- The remaining Sony 360RA package failure was no longer GUI-specific once the GUI subprocess path was fixed to launch a real argv vector on POSIX.
data/sourceData/360RA_test.wavis anRF64ADM WAV with ads64chunk.internal/cult_transcoder/src/packaging/packagingHelper.hppacceptedRF64/BW64containers, but the lightweight package splitter header reader still trusted the 32-bitdatachunk size and ignoredds64.- On this file, that produced a truncated
frameCount/dataSizeview forpackage-adm-wav, which then failed mid-split with:package-adm-wav: Short read while splitting source WAV
What changed:
source/gui/imgui/src/SubprocessRunner.cpp- POSIX subprocess launch now uses a real argv path via
posix_spawnp()instead of flattening args into one shell string forpopen() - this preserves spaces and shell metacharacters in GUI-supplied paths and keeps GUI invocation parity with direct CLI runs
- POSIX subprocess launch now uses a real argv path via
source/gui/imgui/src/App.cpp- GUI transcode logging now records working directory, executable path, and
argv[i]entries separately - failure summaries now include exit code, expected output paths, and recent subprocess output tail
- GUI transcode logging now records working directory, executable path, and
internal/cult_transcoder/src/packaging/packagingHelper.hpp- package input inspection now reads
ds64and uses the true 64-bitdatasize forRF64/BW64package splitting - odd-byte padding now follows the effective 64-bit chunk payload size on this path
- package input inspection now reads
internal/cult_transcoder/tests/test_packaging_helper.cpp- added an RF64 regression fixture proving
readWavSourceInfo()honorsds64for package input
- added an RF64 regression fixture proving
Validation:
- Direct scene-only CLI still passed on the Sony source:
cult-transcoder transcode --in data/sourceData/360RA_test.wav --in-format adm_wav ...
- Direct package CLI now passed on the Sony source:
cult-transcoder package-adm-wav --in data/sourceData/360RA_test.wav --out-package /private/tmp/sr_360ra_cli_pkg --report /private/tmp/sr_360ra_cli_pkg_report.json --stdout-report --lfe-mode hardcoded
- Output package contained:
scene.lusid.jsonscene_report.jsonchannel_order.txt- 13 mono float32 stems (
1.1.wav...13.1.wav, with4.1emitted asLFE.wav)
- GUI subprocess helper parity check also passed with the same
package-adm-wavcommand path and output shape cmake --build build --target cult-transcoder --parallel 8passedcmake --build build --target spatialroot_gui --parallel 8passed
Conclusion:
- There were two separate issues on this workflow:
- a GUI subprocess invocation bug on POSIX
- a CULT
RF64/ds64package-splitting bug for Sony 360RA source material
- With both fixes in place, the GUI package path now matches the successful direct CLI behavior for
360RA_test.wav.
Status: Complete. Small backend fix only; no GUI redesign, no EngineSession/CULT ownership changes.
Root cause:
- The failing
package-adm-wavruns were not caused by the recent GUI/path audits or by bad GUI arguments. - Reproduced failures on
data/sourceData/CANYON-ATMOS-LFE.wavanddata/sourceData/ASCENT-ATMOS-LFE.wavoccurred on a nearly full volume (/System/Volumes/Datahad ~148–170 MiB free). package-adm-wavneeds to write one mono float32 stem per output node, so large Atmos sources can require several GiB of temporary/package output.- Before this pass, the splitter only surfaced the generic close-time error
Failed while writing package stems, which obscured the actual cause.
What changed:
internal/cult_transcoder/src/packaging/adm_package.cpp- added a disk-space preflight before package temp-dir creation and stem writing
- estimates required bytes from
frameCount * stemCountplus a small metadata budget - resolves space against the nearest existing parent directory of the requested package output
- fails early with a specific error such as
Insufficient disk space for package stems at '...' (need about 4.4 GiB, have 148.1 MiB) - improved split/write error propagation so stream-write and close failures include the specific stem path and frame context instead of a generic package-level message
Validation:
./build.sh --guipassed after the backend fix.- Scene JSON regression check passed again on
data/sourceData/CANYON-ATMOS-LFE.wav. package-adm-wavnow fails early and clearly on large Atmos sources when disk space is insufficient, both with and without spaces in output paths.- A tiny authored ADM WAV roundtrip package test passed via the CLI, including an output package path with spaces.
Conclusion:
- This was not a recent Spatial Root regression in CLI argument wiring or GUI packaging/path handling.
package-adm-wavremains functionally working, but large-package generation is constrained by available disk space and should surface that requirement clearly in release-hardening docs/testing.
Status: Complete. GUI-only fixes; no EngineSession or CULT bridge changes.
What changed:
source/gui/imgui/src/App.cpp— Transcoder workflow selector and mode/output-type toggles now clear stale run status and stale log output, and the workflow selector is disabled while a transcode is running so users cannot mutate the active run mid-flight.source/gui/imgui/src/App.cpp— Transcoder run buttons are now gated on real prerequisites: existing input file/directory, valid output file/directory shape, and a resolvablecult-transcoderbinary. Missing or invalidSPATIALROOT_CULT_TRANSCODERnow surfaces inline before the user clicks Run.source/gui/imgui/src/App.cpp— Workflow 1 browse controls now use type-correct native pickers (scene.lusid.jsonfile, WAV directory, package directory) instead of the permissive file-or-directory picker.source/gui/imgui/src/App.cpp— command preview text is now built from the same argument vectors sent toSubprocessRunner, with shell-style quoting for spaces and explicit--reportpaths in all workflows.source/gui/imgui/src/App.cpp— manual transcoder completion now requires both a zero exit code and the expected output files to exist before the UI reports success. Missing output after exit code 0 is now treated as failure and logged clearly; missing report files are downgraded to warnings when primary outputs exist.source/gui/imgui/src/App.cpp— manual transcode log now resets per run and logs the full command line actually executed instead of a truncated placeholder.source/gui/imgui/src/App.cpp— Workflow 0 package mode now writes the GUI-requested report to a sibling*_report.jsonpath instead of inside the destination package directory, while still expectingscene.lusid.jsoninside the package itself.source/gui/imgui/src/App.hpp— added small GUI-only state fields for transcode status detail and expected-output verification.
Validation:
./build.sh --guipassed after the fix pass.- CLI smoke test passed:
cult-transcoder transcode --in data/sourceData/CANYON-ATMOS-LFE.wav ... --stdout-report --lfe-mode hardcoded
- CLI smoke test passed:
cult-transcoder adm-author --lusid-package data/sourceData/LUSID_package ... --stdout-report
- CLI smoke test failed on current CULT/backend behavior:
cult-transcoder package-adm-wav --in data/sourceData/CANYON-ATMOS-LFE.wav ...- failure reported:
package-adm-wav: Failed while writing package stems - treated as a remaining backend limitation, not fixed in this GUI-only pass
Manual test checklist for the GUI tab:
- Open TRANSCODE tab and switch between both workflows; confirm status/log reset and inputs remain.
- In Workflow 0, verify Scene JSON and Full LUSID package previews include
--reportand match the selected paths. - In Workflow 1, verify the Scene JSON picker only returns files and the WAV/package pickers only return directories.
- Run a known-good transcode and confirm the UI only reports success when the expected output file(s) exist.
Status: Complete. Minimal surgical changes only.
What changed:
source/gui/imgui/src/App.cpp—resolveProjectPath(): now checksSPATIALROOT_ASSET_ROOTenv var first. If set, all relative layout preset paths resolve from that root instead ofmProjectRoot. This is the packaged-build override for layout presets. Developer builds using--rootor run from repo root are unaffected.source/gui/imgui/src/App.cpp—findCultTranscoder(): now checksSPATIALROOT_CULT_TRANSCODERenv var first. If set and the path exists, it is used immediately (skipping build-tree search). If set but the binary is not found at that path, returns""and the caller surfaces an error. Developer build-tree lookup (build/internal/cult_transcoder/cult-transcoder) is unchanged as a fallback.source/gui/imgui/src/App.cpp—findSpatialRenderer(): now checksSPATIALROOT_SPATIAL_RENDERenv var first, consistent with cult-transcoder handling. Developer build-tree lookup is unchanged as fallback.source/gui/imgui/src/App.cpp— constructor: logsSPATIALROOT_ASSET_ROOTvalue at startup if set.source/gui/imgui/src/App.cpp— three call-site error messages forfindCultTranscoder()updated to mentionSPATIALROOT_CULT_TRANSCODERas a resolution path.source/gui/imgui/src/App.cpp— removedlayout.remapCsvPath = mRemapPath;(mRemapPath was always""; LayoutInput field defaults to"").source/gui/imgui/src/App.hpp— removedstd::string mRemapPath;member variable (was DEPRECATED; no GUI control ever set it; layout-routing validation uses JSON-derived routing exclusively).
Supported packaging env vars (new):
| Env var | Purpose |
|---|---|
SPATIALROOT_ASSET_ROOT |
Base path for layout preset resolution (overrides project root). Set to the directory containing source/speaker_layouts/ in a packaged build. |
SPATIALROOT_CULT_TRANSCODER |
Full path to the cult-transcoder binary. Set when bundling the binary outside the build tree. |
SPATIALROOT_SPATIAL_RENDER |
Full path to spatialroot_spatial_render. Set when bundling the offline renderer. |
Pre-existing packaging env vars (unchanged):
| Env var | Purpose |
|---|---|
SPATIALROOT_TEMP_ROOT |
Override for temp/cache session root |
SPATIALROOT_SETTINGS_ROOT |
Override for persistent app settings root |
Developer path behavior (unchanged):
- Run from repo root (or with
--root /path/to/repo): layouts resolve fromsource/speaker_layouts/, cult-transcoder frombuild/internal/cult_transcoder/cult-transcoder. - No env vars required for developer builds.
Deferred:
- No CMake install rules added. Full staging (copy layouts + helper binaries next to GUI binary) is deferred to the distribution pass.
- App-relative binary lookup (finding binaries relative to the GUI executable using platform APIs) is deferred. Env var overrides are sufficient for the current OS compatibility testing phase.
- Full macOS
.appbundle structure and Windows installer are deferred. LayoutInput::remapCsvPathfield and--remapCLI flag remain inEngineSession/spatialroot_realtimefor any future CSV-based layout routing use. Only the GUI's dead scaffolding variable was removed.
Status: Complete. Small surgical fixes only; no backend or architecture changes.
What changed:
source/gui/imgui/src/App.hpp: Removed deadtranscodeOutputPath()declaration (function was defined but never called; contained staledata/processedData/stageForRender/repo-root path assumption).source/gui/imgui/src/App.hpp: Removed stale "Workflow 2" comment leftover from earlier tab consolidation.source/gui/imgui/src/App.cpp: Removed deadtranscodeOutputPath()definition.source/gui/imgui/src/App.cpp: Workflow combo in Transcode tab now resetsmTcDone/mTcSuccesson switch, preventing stale status display ("Complete"/"Failed") when switching between ADM→LUSID and LUSID→ADM workflows.source/gui/imgui/src/App.cpp: FixeddoLaunchEngine()to passstatic_cast<ElevationMode>(mElevationMode)intoEngineOptionsrather than always hardcodingRescaleAtmosUp. The subsequentsetElevationMode()call is retained for live-update consistency.source/gui/imgui/src/App.cpp: CMD preview for Workflow 0 (Scene JSON only) now includes--stdout-reportto match actual args sent.source/gui/imgui/src/App.cpp: "Reset Parameters" tooltip now explicitly states elevation mode is not reset.
Deferred (packaging audit):
kLayoutPaths[]in App.hpp usessource/speaker_layouts/...paths resolved relative tomProjectRoot. These only work when the app is run from the project root or when--rootis supplied. Packaging must bundle layouts and redirect the root.findCultTranscoder()andfindSpatialRenderer()resolve binaries frombuild/...relative to project root. Packaged builds must bundle these binaries and redirect resolution.mRemapPathis passed toapplyLayout()but never set by any GUI control. It is dead GUI scaffolding; remove after layout-routing validation.
Status: Complete. Minor GUI text/log updates only.
What changed:
source/gui/imgui/src/App.cpp: Added explicit transcode completion/failure summary log entries after manual transcoder runs finish.source/gui/imgui/src/App.cpp: Renamed the Transcode tab toggle to "Keep temp sessions" and added a tooltip clarifying it only preserves GUI temp sessions (not output files).
Status: Complete. Documentation-only update.
What changed:
PUBLIC_DOCS/API.md: Updated to reflect the current dB-based runtime parameters, setter names (setMasterGainDbetc.), lifecycle notes (configureRuntime safe before/afterstart()), andgetRuntimeParams()/resetRuntimeParams().internalDocs/API.md: Aligned struct names (EngineOptions,SceneInput,LayoutInput,RuntimeParams) and diagnostic types (DiagnosticEvents).internalDocs/API/new_context.md: Removed machine-specific test paths and updated struct naming references.
Status: Complete. No backend changes.
Motivation: TRANSCODE tab had a nested horizontal sub-tab row ("ADM to LUSID Scene", "ADM WAV to LUSID Package", "LUSID to ADM Export") creating a visually cluttered stacked-tab appearance. Desired a cleaner Spatial Root-style workflow panel with fewer visual tiers.
What changed:
-
source/gui/imgui/src/App.hpp:- Reduced
kTcWorkflowNames[]from 3 to 2: "ADM/BW64 to LUSID" and "LUSID to ADM/BW64" - Added
kTcOutputTypeNames[]for output type selector: "Scene JSON only", "Full LUSID package" - Added
mTcOutputTypestate variable to track output type selection - Unified the two ADM-to-LUSID workflows (formerly separate tabs) into a single UI with output type dispatch
- Reduced
-
source/gui/imgui/src/App.cpp:- Refactored
renderTranscodeTab(): removed nestedImGui::BeginTabBar("##tc_workflow")sub-tab bar - Replaced with button-based workflow selector at the top:
[ADM/BW64 to LUSID]/[LUSID to ADM/BW64] - Added top-level description: "Convert ADM, BW64/WAV, and LUSID assets for Spatial Root workflows."
- Added output type dropdown inside "ADM/BW64 to LUSID" workflow (Scene JSON only vs Full LUSID package)
- Output type selection routes to appropriate backend command:
- Scene JSON only →
cult-transcoder transcode(preserved) - Full LUSID package →
cult-transcoder package-adm-wav(preserved)
- Scene JSON only →
- LUSID to ADM/BW64 workflow uses
cult-transcoder adm-author(preserved) - Updated all form labels to sentence-case: "Source", "Detected format", "Output type", "Output path", "Options", "Keep temp files for debugging"
- Button labels changed to "Convert" and "Export ADM/BW64" (not all-caps)
- Status display shows "Status: Idle/Running/Complete/Failed"
- Refactored
-
Backend preservation:
- All three cult-transcoder subcommands remain wired:
transcode,package-adm-wav,adm-author - All existing state fields retained (though workflow 1 fields now unused in UI but remain in class)
- File browse behavior, temp-file toggle, format detection, LFE mode all preserved
- All three cult-transcoder subcommands remain wired:
Status: Complete. Covers EngineSession API, CLI, GUI, and docs.
Motivation: Runtime controls (gain, focus, mix) were disabled in the GUI before playback started and reset to hardcoded defaults on every Start click. Default values were duplicated across RuntimeParams, CLI argument parsing, and resetRuntimeToDefaults() in App.cpp. configureRuntime() mixed parameter setup with output routing setup, making it unsafe to call before applyLayout().
What changed:
-
EngineSession.hpp/EngineSession.cpp:- Added
RuntimeParams::defaults()— single canonical source of default values. - Added file-local helpers:
clampDb,clampFocus,dbToLinear,linearToDb(defensive: zero/negative linear → -60 dB, not -inf). - Added private
sanitizeRuntimeParams(),applyRuntimeParamsToConfig(),configureOutputRouting(). - Added public
getRuntimeParams()— reads atomics, converts linear→dB, re-clamps; reflects setters, OSC, andconfigureRuntimechanges. - Added public
resetRuntimeParams()— equivalent toconfigureRuntime(RuntimeParams::defaults()). - Refactored
configureRuntime(): now only writes gain/focus/mix atomics + syncs OSC if running. No longer touchesmSpatializerorOutputRemap. - Moved output routing setup (remap CSV scaffolding + layout-derived routing) to
configureOutputRouting(), called at the end ofapplyLayout().configureRuntime()is now safe to call beforeapplyLayout(). - Updated
start(): initializes OSC param values fromgetRuntimeParams()instead of reading atomics directly. - Updated individual setters to use
clampDb/clampFocus/dbToLinearhelpers consistently.
- Added
-
main.cpp: CLI now initializesRuntimeParamsfromRuntimeParams::defaults()and overrides from flags, eliminating duplicated literal defaults. -
App.cpp:- Removed
BeginDisabledguard around runtime controls — sliders/inputs are always editable as staged values before Run. - Input callbacks always clamp; setters called only when running.
- Added "Reset Parameters" SmallButton inline with the RUNTIME CONTROLS header. Before Run: resets GUI staged values via
resetRuntimeToDefaults(). After Run: callsmSession->resetRuntimeParams()and syncs GUI frommSession->getRuntimeParams(). - Removed
resetRuntimeToDefaults()call fromonStart()— staged values are now preserved when playback starts. - Updated
resetRuntimeToDefaults()to useRuntimeParams::defaults()instead of hardcoded literals.
- Removed
-
internalDocs/API_internal.md,REALTIME_ENGINE.md,AGENTS.md: updated to reflect new API methods, routing refactor, defaults source, and OSC sync semantics.
OSC sync note: configureRuntime() and resetRuntimeParams() sync OSC-visible parameter values when the OSC server is running. Individual setters (setMasterGainDb etc.) do not sync OSC values — they write atomics only. getRuntimeParams() reflects OSC changes because both OSC callbacks and setters write the same atomics.
Status: Complete. --adm without --positions now mirrors the realtime ADM architecture end-to-end.
Motivation: The offline renderer accepted --adm but required a separately-provided --positions scene file. The realtime path invokes CULT Transcoder to generate scene.lusid.json from the ADM metadata automatically. The offline path should mirror this: CULT generates the scene, the original ADM WAV remains the multichannel audio payload, and source node IDs map to WAV channels using the established CULT convention.
What changed:
source/spatial_engine/spatialRender/main.cpp--admwithout--positions: invokescult-transcoder transcodeautomatically to generatescene.lusid.jsonin a unique temp directory, then passes the generated scene through the existing render path. The original ADM WAV is the audio payload throughout.--admwith--positions: uses the provided scene file directly. CULT is not invoked. This is the legacy/direct path for users who have already run CULT separately.- CULT invocation:
--in-format adm_wav --out-format lusid_json --lfe-mode hardcoded --report <temp>/reports/transcode_report.json - CULT binary resolution order:
--cult-transcoderCLI flag →CULT_TRANSCODERenv var →build/internal/cult_transcoder/(cwd-relative) →internal/cult_transcoder/build/(cwd-relative) → executable-relative equivalents. Failure prints all searched paths. - Temp directory created under
std::filesystem::temp_directory_path(); deleted on success unless--keep-temp-dir; always preserved on failure with path printed to stderr. - Post-load source validation: after
loadSourcesFromADM(), every source declared in the LUSID scene is verified against the loaded audio map. Missing sources produce a hard failure that names each unmappable source, the ADM file path, and the expected convention. - Error messages for all failure paths now include the relevant file path(s) and temp dir location.
- New flags:
--cult-transcoder PATH,--keep-temp-dir. - Updated
--helpto document both--admmodes, the CULT search order, temp-dir behavior, and the source mapping convention.
Offline ADM source mapping convention (applies to the offline renderer only; not a general ADM or LUSID rule):
"N.1"maps to 0-based WAV channelN-1(e.g."1.1"→ ch0,"2.1"→ ch1)"LFE"maps to WAV channel 3 when the file has ≥ 4 channels — matches--lfe-mode hardcodedpassed to CULT- Sources that cannot be mapped using this convention are a hard failure (not silently skipped)
Explicit non-change: No realtime engine files modified. WavUtils.hpp, WavUtils.cpp, SpatialRenderer, OfflineOutputRouteMap, and all Phase 2 device-indexed output behavior are unchanged.
Status: Complete. Offline-only routing helper added; no realtime or offline render behavior change.
Motivation: The repository already had an offline renderer, but its channel model diverged from the realtime engine's two-space routing semantics. Main speakers were still rendered to consecutive output indices even when the layout used sparse deviceChannel assignments.
What changed:
- Added
source/spatial_engine/spatialRender/OfflineOutputRouteMap.hpp - Added
source/spatial_engine/spatialRender/OfflineOutputRouteMap.cpp - Added an offline CLI diagnostic path in
source/spatial_engine/spatialRender/main.cpp:--print-output-route-map--validate-layout-only
Behavior: OfflineOutputRouteMap builds an offline-owned compact-internal-channel to sparse-device-output routing table from SpeakerLayoutData, includes subwoofers in the same map, derives outputChannelCount as max(deviceChannel) + 1, accepts non-contiguous device channels, reports silent gaps as warnings, and rejects negative or duplicate deviceChannel assignments.
Explicit non-change: SpatialRenderer still renders with its pre-existing consecutive main-speaker output behavior in this phase. The new route map is diagnostic/scaffolding for Phase 2 wiring only.
Status: Complete. Offline render output now uses the offline-owned route map for device-indexed WAV channel assignment.
Motivation: Phase 1 established the offline route-map helper, but the actual offline renderer still wrote main speakers to consecutive WAV channels while only partially honoring sparse subwoofer deviceChannel assignments. That diverged from the repo’s compact-internal-bus to sparse-output-bus model.
What changed:
source/spatial_engine/spatialRender/main.cppnow builds and validatesOfflineOutputRouteMapbefore offline audio rendering begins.source/spatial_engine/spatialRender/SpatialRenderer.hppsource/spatial_engine/spatialRender/SpatialRenderer.cpp- render to a compact internal bus sized to
numSpeakers + numSubwoofers - place LFE/subwoofer output on compact internal subwoofer channels instead of sparse final output indices
- scatter the compact internal bus to the final device-indexed WAV bus using
OfflineOutputRouteMap.routes
- render to a compact internal bus sized to
Behavior: Offline WAV output now preserves layout deviceChannel assignments for both main speakers and subwoofers. Final output width is max(deviceChannel) + 1, non-contiguous device channels are supported, and unmapped output channels are present and silent.
Explicit non-change: This phase does not alter realtime engine behavior, DBAP/LBAP math, LUSID parsing, direct-speaker semantics, GUI behavior, or the public engine API.
Status: Complete. No behavior change on success; richer log output on failure.
Motivation: When engine startup failed (bad scene file, missing audio, wrong device, invalid layout), the GUI engine log showed only a one-line high-level error (e.g. loadScene failed: No source files could be loaded). The detailed diagnostic output from Streaming, RealtimeBackend, JSONLoader, and LayoutLoader appeared only in the process terminal (stdout/stderr), making remote or GUI-only debugging much harder.
Approach: StageCapture — a RAII stream-tee that installs TeeStreamBuf on both std::cout and std::cerr for the duration of each startup stage. All existing terminal output is preserved unchanged. On failure, the captured text is formatted into a structured === Failure diagnostics === block and stored in EngineSession::mFailureDiagnostics. The GUI retrieves it via the new getFailureDiagnostics() API and appends it line-by-line to mEngineLog.
Thread safety: Capture is only active during single-threaded startup stages. In start(), capture is explicitly restored before mStreaming->startLoader() to avoid racing with the background loader thread. No capture occurs during audio callback execution.
Files changed:
source/spatial_engine/realtimeEngine/src/EngineSession.hpp— addedgetFailureDiagnostics()andstoreFailureDiagnostics()declarations; addedmFailureDiagnosticsmembersource/spatial_engine/realtimeEngine/src/EngineSession.cpp— addedTeeStreamBuf,StageCapture; wired intoloadScene,applyLayout,start; addedgetFailureDiagnostics()andstoreFailureDiagnostics()implementationssource/gui/imgui/src/App.hpp— addedappendFailureDiagnostics()declarationsource/gui/imgui/src/App.cpp— addedappendFailureDiagnostics()implementation; called at all five failure points indoLaunchEngine()
Failure log format:
=== Failure diagnostics ===
Stage: load scene (ADM streaming)
Scene: /path/to/scene.lusid.json
ADM: /path/to/source.wav
Error: No source channels could be loaded from ADM.
Terminal output:
[Streaming] FATAL: Failed to open ADM file.
...
=== End failure diagnostics ===
Acceptance: Successful runs produce the same engine log as before. Failed runs now include the same diagnostic detail visible in the terminal. Terminal output is unchanged.
Build validated: [100%] Built target spatialroot_gui.
Status: Complete. Replaces the single ADM→LUSID panel with a three-workflow tab bar exposing the full current CULT transcoder CLI surface.
Motivation: The previous Transcoder tab only exposed cult-transcoder transcode (ADM→LUSID Scene). CULT now supports two additional subcommands (package-adm-wav, adm-author) that were inaccessible from the GUI.
Files changed:
source/gui/imgui/src/App.hpp— added workflow state members and constants for all three workflowssource/gui/imgui/src/App.cpp—renderTranscodeTab()rebuilt withBeginTabBar("##tc_workflow")
Three sub-tabs now exposed:
- ADM to LUSID Scene (
cult-transcoder transcode) — converts ADM XML or ADM WAV/BWF metadata toscene.lusid.json - ADM WAV to LUSID Package (
cult-transcoder package-adm-wav) — extracts ADM, converts metadata, splits interleaved audio into a LUSID package directory - LUSID to ADM Export (
cult-transcoder adm-author) — authors LUSID package material into Logic-compatible ADM BWF/WAV + sidecar ADM XML
Each tab has: file/folder pickers with type-appropriate browse dialogs, inline validation warnings for missing required fields, run button disabled until required fields are filled, live command preview, run-status indicator. Experimental adm-author options (--dbmd-source, --metadata-post-data) gated behind a collapsible section with amber warning text. --stdout-report added to all workflows so report JSON appears in the shared log.
Architecture preserved: subprocess CLI calls via SubprocessRunner, shared mTcRunner/mTcLog/status. No new integration style introduced.
Build validated: [100%] Built target spatialroot_gui.
Status: Complete. Non-destructive source-layout separation only.
- Moved offline renderer implementation from
source/spatial_engine/src/intosource/spatial_engine/spatialRender/ - New offline-owned paths:
source/spatial_engine/spatialRender/main.cppsource/spatial_engine/spatialRender/SpatialRenderer.hppsource/spatial_engine/spatialRender/SpatialRenderer.cpp
- Shared infrastructure intentionally stayed in
source/spatial_engine/src/:JSONLoader.*LayoutLoader.*WavUtils.*
source/spatial_engine/realtimeEngine/was preserved as the active runtime engine with no intended behavior changessource/spatial_engine/spatialRender/CMakeLists.txtwas updated to build from the relocated offline sources while continuing to link shared implementations from../src/- Ambiguity intentionally preserved: some realtime logic was historically adapted from
SpatialRenderer, but the realtime engine remains self-contained and should not depend on offline internals
Status: Non-destructive path cleanup complete.
- Active application code moved under
source/:source/gui/,source/spatial_engine/, andsource/scripts/ - Bundled internal toolchain components moved under
internal/:internal/cult_transcoder/andinternal/LUSID/ - Root CMake, nested CMake files, launcher scripts, GUI path helpers, and onboarding/build docs were updated to resolve from the new layout
- Submodule paths were preserved via tracked moves, including root
.gitmodulesupdates forinternal/cult_transcoderandinternal/LUSID - No engine, GUI, transcoder, or schema logic was intentionally refactored during this pass; changes were limited to path resolution and repository organization
- One pre-existing untracked local artifact remains at
source/spatial_engine/realtimeEngine/build/; it moved with the directory rename and was left untouched to avoid destructive cleanup of local generated files
Status: Complete in the ImGui GUI layer.
- Added
SpatialRootPathsas the centralized cache/temp-session path utility using platform app-cache defaults andstd::filesystem - Generated GUI-owned ADM→LUSID sessions now live under
<cacheRoot>/temp-sessions/session_<timestamp>_<shortid>/ - Each owned temp session writes
.spatialroot_temp_sessionplusmanifest.json; generatedscene.lusid.jsonandreports/are added only when produced by the workflow - App shutdown cleanup deletes only owned temp sessions that are inside the temp-sessions root and carry the marker file
Save Generated Scene...andSave Diagnostic Files...copy temp-session contents to a user-selected durable location; the original temp session still remains eligible for deletion- Added
--keep-temp-sessionsand GUIKeep temporary generated files for debugging - Broad
processedDatacleanup was intentionally avoided. Deletion applies only to Spatial Root temp sessions the GUI created and can prove it owns
Status: Fixes 11.1–11.5 patched and confirmed. Residual intermittent soft clicks deferred.
Problem: Pausing produced an audible click. Stop was comparatively graceful (CoreAudio hardware buffer drain), but could also click at high amplitude. Rapid pause/resume toggling clicked. Subsequent pauses (after resume and re-pause) clicked even when the first pause was clean.
Root causes and fixes (RealtimeBackend.hpp):
-
11.1 — memset wipes fade: The late early-return after Step 4 ran
std::memset(outBuffer, 0, ...)on the fade-completing block, erasing the 8ms ramp that Step 4 had just computed. Hardware received silence for the entire block → click. Fix: removed the memset. Step 4's multiply-by-zero already zeroes the buffer correctly. -
11.2 — stop() no fade:
stop()calledmAudioIO.stop()directly with no prior fade. Fix: armmConfig.paused = truebefore the hard stop, sleep 50ms unconditionally, then callmAudioIO.stop(), then resetmConfig.paused = false. -
11.3 — resume mid-fade forced reset: The resume branch in Step C set
mPauseFade = 0.0funconditionally, causing a gain jump when a fade-out was mid-ramp. Fix: removed the forced reset; step computed as(1.0f - mPauseFade) / fadeFramesso the ramp continues from current value. -
11.4 — stop-after-recent-pause conditional sleep: The initial Bug 11.2 fix put the 50ms sleep inside
if (!paused), so it was skipped if a fade was already in progress. Fix: moved sleep outside the guard — unconditional. -
11.5 — Spatializer anchor caching: During steady pause the full render path still ran, updating
mPrevSafePos/mPrevGuardFired/mPrevWasFastMoveron every block with the frozencurrentFrameposition. After many paused blocks these anchors were over-fit to the paused position; resume → re-pause produced a transient. Fix: new fast-path early-return before Step 1 — if fully paused (fade complete), memset + return without calling the Spatializer.
Deferred: Some intermittent soft clicks persist, likely related to the 50ms exponential smoother having residual gain on the first steady-paused block, or sub-block timing of rapid toggle events. Not reliably reproducible. See REALTIME_ENGINE.md § Deferred for full notes.
Status: Fix confirmed working at translab. Bug 10.1 closed.
Problem: After the April 17 normalized DBAP upgrade, fast-moving sources produced audible pops or gain steps. Root cause: mPrevSafePos[si] was always written as the block-center guard-resolved position (safePos), even for fast-mover blocks whose last rendered audio corresponded to the last sub-step (near positionEnd). Under normalized DBAP, the dominant speaker gain can jump 4× (from 1/sqrt(N) ≈ 0.25 equidistant to ≈1.0 near-speaker) across the normalization basin boundary. When positionEnd and block-center straddle that boundary, the Bug 9.1 doBlend anchor on the following block was wrong — it injected a discontinuity rather than smoothing one.
Fix (Spatializer.hpp):
- Fast-mover loop now captures
lastSubSafePos = subSafePosatj == kNumSubSteps - 1 - Fast-mover branch writes its own state immediately after the loop:
mPrevSafePos[si] = lastSubSafePos,mPrevGuardFired[si] = 0,mPrevWasFastMover[si] = 1 - Normal-path state update guarded by
!isFastMover; clearsmPrevWasFastMover[si] = 0 - New
mPrevWasFastMoverper-source state vector added (same pattern asmPrevGuardFired)
Key insight: Normalized DBAP moved the dangerous gain discontinuity from the speaker surface (where the proximity guard operates) to the normalization basin boundary (where one speaker becomes clearly dominant). The guard still functions correctly but is no longer the primary discontinuity risk. Any cross-block anchor mismatch that was previously sub-threshold under the old smooth gain function can now be audible.
Full diagnosis + code locations: internalDocs/engine_testing/5-7-engine_fix.md
Bug audit entry: internalDocs/engine_testing/4_1_bug_audit.md — Bug 10 / Bug 10.1
Deferred follow-up: Bug 10 (per-sub-step guard variability) — endpoint pre-guarding. Implement if pops persist.
Status: Complete through normalization, fork integration, and auto-compensation removal. Runtime validation remains the next live phase.
thirdparty/allolibwas removed and replaced by the internal fork atinternal/cult-allolibal_Dbap.cppnow uses max-scaled L2 normalization in bothrenderSample()andrenderBuffer(), enforcingsum(v_k^2) = 1- DBAP focus now has a minimum supported value of
0.1, clamped across construction, runtime configuration, direct setters, and OSC callbacks - Focus auto-compensation was removed from engine, GUI, CLI, OSC, and current API-facing docs because normalized DBAP no longer needs a corrective gain layer
internalDocs/DBAP/dbapMath.mdwas established as the DBAP math source of truth; onboarding and plan docs were updated to match
Historical note: older entries below may mention autoCompensation, mPendingAutoComp, or thirdparty/allolib because they describe the pre-normalization engine state at the time those entries were written.
Historical sources were consolidated into this file; pre-consolidation subfolder files were removed.
Status: All three stages complete as of 2026-03-31. Python removed from primary workflow.
What was built:
init.sh/build.shreplace the Python build system entirelyspatialroot_realtimeC++ binary is the primary engine CLIEngineSessionCorestatic library exposes the full V1.1 API (runtime setters,oscPort=0guard, typedElevationMode)spatialroot_gui— Dear ImGui + GLFW desktop GUI — builds, launches, and linksEngineSessionCoredirectly (no subprocess, no OSC dependency for local control)
Deferred items:
- macOS Dock icon: in-app Dock tile icon via
[NSApp setApplicationIconImage:]works while running. The desktop/Finder file icon does not update — requires packaging as a.appbundle. Deferred to a future phase. SPATIALROOT_BUILD_GUI=ONflag: GUI build disabled in CI untilsource/gui/imgui/CMakeLists.txtintegration is verified.
Key entries from refactor_log.md:
Native file dialogs (NSOpenPanel) + Browse buttons + device dropdown (03-30):
- Replaced broken
osascript + popenfile dialog withNSOpenPanelObjective-C++ (FileDialog_macOS.mm) - Browse buttons on SOURCE, LAYOUT, REMAP CSV, TRANSCODE INPUT fields
- Inline "ADM"/"LUSID" green tag next to SOURCE label
- Device scan:
al::AudioDeviceenumeration via Scan button + combo osascriptblocked main thread and appeared behind GLFW window;NSOpenPanelruns its own Cocoa modal
ImGui + GLFW CMake target (03-30): Created source/gui/imgui/CMakeLists.txt. Uses imgui_impl_opengl3_loader.h — no GLAD or GLEW needed. Links EngineSessionCore + glfw + OpenGL::GL.
App class (App.hpp/App.cpp) (03-30): AppState enum, EngineSession mSession (unique_ptr for restart), UI state, runtime controls (gain/focus/spkMixDb/subMixDb/autoComp/elevMode), SubprocessRunner for cult-transcoder invocation, thread-safe log. Full tick() → tickEngine() + renderUI(). doLaunchEngine() implements 5-stage lifecycle. findCultTranscoder() checks build/internal/cult_transcoder/cult-transcoder then internal/cult_transcoder/build/cult-transcoder. Historical note: the autoComp control mentioned here was removed on 2026-04-17 after DBAP normalization landed.
Aesthetic overhaul (03-30): Menlo 13.5px font. StyleColorsLight() base + dark/cream palette. Workflow breadcrumb header. Four bordered card layout for engine tab: INPUT CONFIGURATION (186px), TRANSPORT (108px), RUNTIME CONTROLS (220px), ENGINE LOG.
Engine restart fix (03-31): mSession → std::unique_ptr<EngineSession>. doLaunchEngine() resets via std::make_unique<EngineSession>() before each launch.
Executable name fix (03-31): set_target_properties(spatialroot_gui PROPERTIES OUTPUT_NAME "Spatial Root") in CMakeLists. run.sh/run.ps1 updated to reference quoted binary name.
GUI framework decision: Dear ImGui + GLFW chosen over Qt. Qt cannot be used as a git submodule (multi-GB source, complex bootstrap). All dependencies must be open-source submodules. ImGui (MIT, ~5 MB) + GLFW (zlib, ~1 MB).
Task 2.1 — Runtime setters (03-30): Added six V1.1 setter methods to EngineSession.hpp/.cpp: setMasterGain, setDbapFocus, setSpeakerMixDb, setSubMixDb, setAutoCompensation, setElevationMode. All use std::memory_order_relaxed. setDbapFocus and setAutoCompensation(true) set mPendingAutoComp. Historical note: setAutoCompensation() and the pending-auto-comp path were removed on 2026-04-17 after normalized DBAP made them obsolete.
Task 2.3 — OSC port=0 guard (03-30): al::ParameterServer with port=0 binds to OS-assigned ephemeral port — does NOT disable OSC. Added if (mOscPort > 0) guard in start().
Task 2.4 — ElevationMode type fix (03-30): Changed EngineOptions::elevationMode from int to ElevationMode enum with default ElevationMode::RescaleAtmosUp (struct subsequently renamed to EngineConfig).
Embedding test (03-30): embedding_test.cpp — compile+link verification, no test data. Calls full V1.1 API surface, expects loadScene() to fail gracefully with non-existent path.
Root CMakeLists.txt (03-29): Option flags: SPATIALROOT_BUILD_ENGINE, SPATIALROOT_BUILD_OFFLINE, SPATIALROOT_BUILD_CULT, SPATIALROOT_BUILD_GUI. cmake_minimum_required raised to 3.20 (cult_transcoder requires it). AlloLib added once at root with if(NOT TARGET al) guard.
init.sh + build.sh (03-29): Pure bash. init.sh: check cmake/git, init allolib submodule, init cult_transcoder + nested libbw64, call build.sh. build.sh: cmake --build uniformly for generator independence. --engine-only, --offline-only, --cult-only flags. Old init.sh called Python configCPP_posix.py::setupCppTools() — fully replaced.
init.ps1 + build.ps1 (03-29): PowerShell equivalents. Windows multi-config generators place binaries under build/Release/.
README.md rewrite (03-29): spatialroot_realtime as primary CLI with actual flags. init.sh + build.sh documented. OSC port fixed (9009, not 12345 as README previously claimed). Two-step ADM workflow documented.
third_audit.md (supersedes secondary): Resolved the open GUI/runtime-control replacement question. C++ Qt GUI embedding EngineSessionCore directly was the original plan (since abandoned for ImGui). Direct in-process runtime setters required. OSC demoted to secondary. RealtimeConfig already uses std::atomic fields — only missing a thin public setter surface on EngineSession.
secondary_audit.md: Pre-refactor assessment. "The three surfaces (library API, CLI, GUI) are not cleanly separated at the process boundary." GUI launches Python which launches the binary. No top-level CMake. Build system lives in Python. OSC port discrepancy: README says 12345, code uses 9009 — code is authoritative.
initial_audit.md: Original baseline audit. Python stack assessment:
runRealtime.py— top-level launcher; callssetupCppTools()then invokes cult-transcoder +spatialroot_realtimeas subprocesses- Python GUI (
gui/realtimeGUI/) — PySide6, 3 process-hops from audio thread: GUI → Python QProcess → runRealtime.py → C++ binary - Python LUSID library (
internal/LUSID/src/) — parallel Python implementation of what JSONLoader.cpp does; maintenance liability - Python venv at repo root — 1.6 GB, only PySide6 and python-osc actually needed
EngineSessionalready implements exact lifecycle described in API.md; CMake buildsEngineSessionCorestatic library
Historical sources were consolidated into this file; pre-consolidation subfolder files were removed. Removed in Phase 6 (2026-03-31).
Status at removal: Python orchestration layer (runPipeline.py) was modernized to use cult-transcoder preprocessing and gained --adm flag for direct ADM input. Both ADM and LUSID package input modes were functional. Removed along with all Python tooling in Phase 6.
Old pipeline flow:
ADM WAV → extractMetaData() → XML → parse_adm_xml_to_lusid_scene() → LUSID object
↓
channelHasAudio() → containsAudio.json
↓
packageForRender() → scene.lusid.json + mono WAV stems
↓
runSpatialRender() → spatialroot_spatial_render --sources <folder>
Phase 3.9 modernized flow:
ADM WAV → cult-transcoder transcode → scene.lusid.json
↓
spatialroot_spatial_render --adm <file> --positions <json> --layout <json> --out <wav>
Key changes in v3.9: Removed containsAudio analysis (cult-transcoder assumes all channels active). packageForRender became optional utility. LUSID package input mode retained for backward compatibility.
Removed at Phase 6: runPipeline.py, src/analyzeADM/, src/packageADM/, src/analyzeRender.py, src/config/configCPP*.py.
- Purpose: Extracted ADM XML metadata from BW64/RF64/WAV files.
- Implementation: Built using EBU libraries (
libbw64andlibadm). - Output:
processedData/currentMetaData.xml - Limitations: Required a separate build step. Limited to ADM extraction without additional processing.
- Purpose: Handles ADM extraction and ADM→LUSID transcoding.
- Implementation: Integrated into the
cult-transcoderbinary. - Usage:
cult-transcoder transcode --in-format adm_wav
Historical sources were consolidated into this file; pre-consolidation subfolder files were removed. Removed in Phase 6 (2026-03-31), replaced by Dear ImGui + GLFW GUI.
- Framework: PySide6 (do NOT switch back)
- Location:
gui/realtimeGUI/(removed) - Launcher:
realtimeMain.pyat project root (removed) - Process launch:
runRealtime.pyviaQProcess - IPC: AlloLib
al::Parameter+al::ParameterServer(OSC port 9009)
Process model: GUI → Python QProcess → runRealtime.py subprocess → C++ binary. Three process-hops from audio thread.
OSC control surface: GUI sent OSC via python-osc UDP → al::ParameterServer. Engine printed "ParameterServer listening" sentinel to stdout to signal readiness. GUI monitored stdout, transitioned LAUNCHING → RUNNING on sentinel, then called flush_to_osc() to push all current slider values.
gui/realtimeGUI/
├── realtimeGUI.py — Main window
├── realtime_runner.py — QProcess wrapper + OSC sender
└── realtime_panels/
├── RealtimeInputPanel.py
├── RealtimeControlsPanel.py
├── RealtimeLogPanel.py
└── RealtimeTransportPanel.py
| Parameter | OSC Address | Range | Default |
|---|---|---|---|
| Master Gain | /realtime/gain |
0.0–1.0 (legacy PySide6 GUI range) | 0.5 |
| DBAP Focus | /realtime/focus |
0.1–5.0 | 1.5 |
| Speaker Mix dB | /realtime/speaker_mix_db |
-10–+10 | 0.0 |
| Sub Mix dB | /realtime/sub_mix_db |
-10–+10 | 0.0 |
| Auto-Comp | /realtime/auto_comp |
0/1 | 0 |
| Pause/Play | /realtime/paused |
0/1 | 0 |
Restart bug workaround: On restart, slider state must be reset to defaults BEFORE restarting the engine. Without this, flush_to_osc() on engine_ready pushes stale values from prior run (e.g. gain=1.5) into new engine. Fix: call reset_to_defaults() before restart().
Graceful stop: SIGTERM → wait 3000 ms → SIGKILL.
OSC debounce: Slider valueChanged debounced at 40ms. Checkbox/combobox changes sent immediately.
Exit code handling: 0, -2, 130 all treated as clean exits.
Source type detection (_detect_source):
- Not exists → error
- File ending
.wav→ ADM source - Directory containing
scene.lusid.json→ LUSID package - Otherwise → unrecognised
- Input Panel: Source (ADM WAV or LUSID package), Layout (JSON), Remap CSV, Buffer size, Scan audio toggle
- Transport Panel: Start, Stop, Kill, Restart, Pause, Play + state indicator
- Controls Panel: Sliders for gain/focus/speaker mix/sub mix, auto-comp checkbox, elevation mode
- Log Panel: Console output from engine subprocess
Historical sources were consolidated into this file; pre-consolidation subfolder files were removed. Superseded by
init.sh/build.shin Stage 1 of Phase 6.
Added cross-platform C++ tool building via Python OS-detection router.
src/config/configCPP.py — OS detection router:
import os
if os.name == "nt":
from .configCPP_windows import setupCppTools
else:
from .configCPP_posix import setupCppToolsBuild commands at the time:
| Platform | Tool | Build command |
|---|---|---|
| POSIX | cult-transcoder |
cmake --build |
| POSIX | spatialroot_spatial_render |
make -jN (generator-specific, replaced by cmake --build in Phase 6) |
| POSIX | spatialroot_realtime |
make -jN (same) |
| Windows | all | cmake --build --config Release |
Executable paths at the time:
| Tool | POSIX | Windows |
|---|---|---|
| ADM Extractor | src/adm_extract/build/spatialroot_adm_extract |
...spatialroot_adm_extract.exe |
| Spatial Renderer | source/spatial_engine/spatialRender/build/spatialroot_spatial_render |
...spatialroot_spatial_render.exe |
All of src/config/configCPP*.py removed in Phase 6.
From AGENTS.md §0. All items resolved.
| # | Status | Severity | Issue | Location |
|---|---|---|---|---|
| 1 | ✅ FIXED | Critical | WAV 4 GB header overflow — SF_FORMAT_WAV wraps 32-bit size field |
WavUtils.cpp |
| 2 | ✅ FIXED | High | Legacy script trusted corrupted WAV header without cross-check (script removed Phase 6) | (historical) |
| 3 | ✅ FIXED | Low | Stale DEBUG print statements left in renderer |
SpatialRenderer.cpp |
| 4 | ✅ FIXED | Medium | masterGain default mismatch — now consistently 0.5 |
SpatialRenderer.hpp, main.cpp, docs |
| 5 | ✅ FIXED | Medium | dbap_focus not forwarded for plain "dbap" mode (archived, pre-Phase 6) |
(historical) |
| 6 | ✅ FIXED | Medium | Legacy Python wrapper exposed master_gain (wrapper removed Phase 6) |
(historical) |
Future items (tracked separately):
- #9 (Info): Large interleaved buffer ~11.3 GB peak for 56ch × 566s. Mitigation: chunked streaming write.
- #10 (Info): Test files only exercise
audio_object+LFEpaths;direct_speakeruntested at render level.
Historical sources were consolidated into this file; pre-consolidation subfolder files were removed. Core fixes are now part of the renderer — see SPATIALIZATION.md for current docs.
Root cause: VBAP requires source direction to be within a valid speaker triplet. Outside triplets → zero output, source becomes inaudible.
Solution: Zero-block detection + nearest-speaker fallback:
- Input energy test → render to temp buffer → measure output energy → detect failure
- Retarget: direction 90% toward nearest speaker (90/10 blend)
- Threshold:
kPannerZeroThreshold = 1e-6
Root cause: Single direction per 64-sample block. Fast motion can cross triplet/gap boundaries within one block → audible dropout.
Solution: Fast-mover detection + sub-stepping:
- Sample directions at 25%/75% through block
- Angular threshold: ~14° (0.25 rad)
- Sub-step at 16-sample hops when threshold exceeded
Architecture decisions: Temp buffer approach (correctness first). SLERP verified. DBAP coordinate transform: AlloLib applies internal (x,y,z) → (x,-z,y) — compensated automatically.
Historical sources were consolidated into this file; pre-consolidation subfolder files were removed.
Inside Sample loop:
→ LUSID SOURCE LOOP
→ [LUSID Reader] && [mono wavs source folder or straight from multichannel file]
→ [source sample, position]
→ [Spatializer] -- fills mc buffer once per sample
→ mc player -- reads from mc buffer once per sample
Problem: Memory limitation for large multichannel files (2.5 GB+ for 56ch ADM), startup delays, audio dropouts.
Solution: Double buffering with background pre-loading (later implemented as Streaming.hpp):
- Two pre-allocated buffers alternate PLAYING/LOADING
- Background thread loads chunks asynchronously
animate()monitors 50% consumption, signals next chunk load- Audio thread reads from PLAYING buffer only
Performance targets at the time: Before: 2.5 GB memory, 10–30s startup. After: ~6 MB active working set (2 chunks), <1s startup.
Chunk size: ~2.88 MB (1 minute at 48kHz for 56ch). Block-based reads (512-frame blocks). Later evolved to 480k frames (10s buffers at 75% prefetch in Phase 11).
Locked v1 design decisions documented at the time:
- 48kHz sample rate, hard real-time constraints
- Target hardware: AlloSphere (54 speakers), TransLAB (various)
framesPerBufferTBD (later: 64 default, user-configurable)- Up to 128 sources simultaneously
- DBAP for v1, block-rate interpolation
- CPU safety: gain state machine (every 1/2/4 blocks, optional Top-K)
Historical sources were consolidated into this file; pre-consolidation subfolder files were removed. Historical record of the EngineSession API extraction.
Goal: Extract orchestration logic from main.cpp into EngineSession.hpp/.cpp.
Key changes:
- Created
EngineSession.hpp/.cppwith lifecycle API:configureEngine()→loadScene()→applyLayout()→configureRuntime()→start()→update()→shutdown() - Refactored
main.cpp— removed all heavy agent instantiation; reduced to arg parsing + polling loop - Resolved
RealtimeConfigcompilation issue:std::atomicfields delete implicit copy constructors — solved by makingEngineSessiondefault-constructible and populating config viasession.config()directly - Updated
CMakeLists.txtto includeEngineSession.cpp
Goal: Evolve past void arguments, resolve Mismatch 5 (Error Handlers).
Key changes:
- Defined typed structs:
EngineOptions,SceneInput,LayoutInput,RuntimeParams(These structs were renamed toEngineConfig,SceneConfig,LayoutConfig,RuntimeConfigbefore handoff — see "Completed milestones" below.) - Updated all lifecycle method signatures to use const-ref struct injection
- Added
std::string getLastError() constfor explicit error propagation main.cpp: removed allsession.config()direct mutations; documented struct grouping inline- Protected AlloLib parameters via Pimpl
struct OscParams;— explicit workaround for pointer stability with background OSC threads - Replaced generic pausing with strict
setPaused(bool)
Decisions:
- Created
api_internal_contract.mdto stop future agents hallucinating features based on aspirational Phase 1 texts - Formalized
setPaused(bool)as sole transport control — abandonedstop()andseek()due to state-corruption risks in mismatch ledger - Enforced
update()as required main-thread tick (compromise to getcomputeFocusCompensation()safely off audio thread without a complex worker pool)
Completed milestones (as of handoff):
EngineConfig,SceneConfig,LayoutConfigextracted- AlloLib parameter lifetimes isolated into OSC context
getLastError()implemented- Shutdown sequence ordered:
mParamServer→mBackend→mStreaming
Demoted/retired concepts:
- Dynamic scene reloading — unsafe with current buffer architecture
- Arbitrary playhead seeking — unsafe with ring buffers
- CLI-only debugging flags — left in
main.cpp, not ported toEngineSession
Historical sources were consolidated into this file; pre-consolidation subfolder files were removed. Notes from the very first ImGui GUI prototype before aesthetic iteration.
Engine issues found:
- Engine doesn't restart when stopped or source file changed — time continues from previous instance
- Engine needs to be fully reset between tracks — tracks don't play after the first track
- These led to the
unique_ptr<EngineSession>restart fix in Stage 3
v2 requirements:
- ADM/LUSID detected green text should be displayed next to "source"
- Device still needs dropdown menu and scanning logic
- Aesthetic: "could be sleeker"
Historical sources were consolidated into this file; pre-consolidation subfolder files were removed. Reference document capturing Python source details before removal. All described files removed in Phase 6.
| File | Role |
|---|---|
runRealtime.py |
Top-level launcher — called cult-transcoder then spatialroot_realtime |
realtimeMain.py |
Alternate CLI entry point |
runPipeline.py |
Offline pipeline orchestrator |
src/config/configCPP.py |
OS detection router |
src/config/configCPP_posix.py |
POSIX CMake/make orchestration |
src/config/configCPP_windows.py |
Windows CMake orchestration |
gui/realtimeGUI/ |
PySide6 desktop GUI |
gui/realtimeGUI/realtime_runner.py |
QProcess wrapper + OSC sender |
src/analyzeADM/checkAudioChannels.py |
Per-channel audio activity scan |
src/packageADM/splitStems.py |
Mono WAV stem splitter |
src/analyzeRender.py |
PDF render analysis |
internal/LUSID/src/ |
Python LUSID library (scene.py, xml_etree_parser.py, parser.py) |
internal/cult_transcoder/build/cult-transcoder transcode \
--in <adm_wav_path> \
--in-format adm_wav \
--out processedData/stageForRender/scene.lusid.json \
--out-format lusid_json \
[--report <report_json_path>] \
[--lfe-mode hardcoded|speaker-label]Binary path on Windows: check both build/Release/cult-transcoder.exe (VS) and build/cult-transcoder.exe (Ninja).
_ENGINE_READY_SENTINEL = "ParameterServer listening"Sequence: GUI → LAUNCHING → scan stdout for sentinel → RUNNING → flush_to_osc(). Launch timeout: 3000 ms for process start. See REALTIME_ENGINE.md § Threading and Safety for current implementation.
--scan_audio flag, exportAudioActivity(), containsAudio.json — removed 2026-03-04. Superseded by cult-transcoder (assumes all channels active). Algorithm: chunked per-channel RMS scan, 30 chunks per channel, chunk_size=48000, threshold -100 dBFS. Added ~14s startup time.
Mono WAV naming: {chanNumber}.1.wav (1-indexed), LFE → LFE.wav. LFE detection: hardcoded channel 4 (_DEV_LFE_HARDCODED = True). Output: processedData/stageForRender/. Prior existing WAVs deleted before splitting.
Directories created before any subprocess launch:
processedData/ — output root for all pipeline artifacts
processedData/stageForRender/ — cult-transcoder writes scene.lusid.json here
| Phase | Date | Description |
|---|---|---|
| Phase 1 | January 2026 | Initial ADM extraction pipeline using spatialroot_adm_extract |
| Phase 2 | February 2026 | Codebase audit; spatialroot_adm_extract deprecated |
| Phase 3 | March 4, 2026 | Transitioned to cult-transcoder; removed per-channel audio scan |
| Phase 4 | March 7, 2026 | cult-transcoder gains --lfe-mode flag; ADM profile detection (Atmos, Sony360RA) |
| Phase 5 | March 7, 2026 | TRANSCODE UI added to PySide6 GUI (superseded by ImGui GUI in Phase 6) |
| Phase 6 | March 31, 2026 | C++ refactor complete. Python GUI/entrypoints/build/venv removed. ImGui + GLFW GUI shipped. |
| Phase 7 | April 17, 2026 | Normalized DBAP (sum(v_k²)=1). thirdparty/allolib → internal/cult-allolib. Auto-compensation removed. |
| Bug 10.1 | May 7, 2026 | Fast-mover continuity anchor fix for normalized DBAP (mPrevSafePos written as last sub-step position). |
| Phase 8 | May 10, 2026 | Persistent default speaker layout + cross-platform app settings paths. DefaultLayoutManager added to GUI layer. Settings dir (~/Library/Application Support/Spatial Root/ etc.) is strictly separate from session temp cache. Atomic writes, non-fatal startup fallback, GUI controls: Set as Default / Clear Default / status display. |
| Phase 9 | May 10, 2026 | Offline Render tab added to Dear ImGui GUI. Wraps spatialroot_spatial_render via the existing SubprocessRunner. ADM WAV mode (experimental) and LUSID Package mode both supported. GUI does not invoke CULT directly; the offline renderer owns CULT invocation, temp dir lifecycle, and source mapping validation. Realtime engine behavior unchanged. |