12 KiB
Audio Field Spine
Objective
Build the audio side around the real six-microphone rig: two Kiyo camera mics, two PS Eye camera mics, the local Focusrite ADC with a shielded cardioid near the host's face, and the neighbor Focusrite ADC with a shotgun mic pointed at the co-streamer.
The Focusrite channels are the dialogue anchors. The camera mics are spatial context, calibration evidence, and fallback texture. Treating all six as equal would be tidy on paper and stupid in the room, which is the classic way paper gets someone fired by physics.
Current Mechanism
The repo has a profile and tool:
config/audio-field.example.jsondeclares each mic source, machine, device query, clock domain, field channel, quality priority, placeholder geometry, two local speaker channels, calibration sweep settings, and FOA AmbiX bus format.scripts/audio_field.pyvalidates shared or distributed profiles, lists PortAudio devices, checks local distributed sources, generates calibration sweeps, summarizes the sync plan, preserves shared-input capture helpers, and encodes an already aligned six-channel WAV into first-order AmbiX.
The default profile is distributed-clocks. The coherent field is not captured directly. It is assembled after delay and sampling-rate-offset alignment.
Latency is allowed. The live goal is bounded buffered real-time: hold enough audio to estimate delay and slow clock drift, emit blocks behind the live edge, and converge instead of chasing instant output with bad math. The profile owns that latency policy.
Runtime sync is not optional for the real show. Static sweep calibration gives the first estimate; the live system must keep updating delay, sampling-rate offset, polarity, and phase confidence as devices drift. Known speaker output is telemetry: every calibration chirplet or intentionally embedded probe gives the estimator a fresh phase-field observation.
If passive telemetry is not enough, the system should emit extra chirplets automatically. That is an optimization loop: spend the smallest audible probe budget that raises sync confidence enough to protect the field.
Pipeline
flowchart TD
A["config/audio-field.json"] --> B["audio_field.py validate"]
C["local Focusrite shielded cardioid"] --> D["local reference timeline"]
E["neighbor Focusrite shotgun"] --> F["remote high-quality dialogue capture"]
G["Kiyo + PS Eye camera mics"] --> H["spatial/context captures"]
I["2 local speakers"] --> J["calibration sweep / pulse"]
J --> D
J --> F
J --> H
D --> K["delay + SRO alignment"]
F --> K
H --> K
K --> L["bounded field cache"]
P["known speaker chirplets"] --> Q["runtime delay/SRO/phase estimator"]
R["confidence probe optimizer"] --> P
D --> Q
F --> Q
H --> Q
Q --> K
L --> M["aligned six-channel blocks"]
M --> N["FOA encoder"]
N --> O["AmbiX ACN/SN3D output"]
Commands
Create a local profile first:
Copy-Item .\config\audio-field.example.json .\config\audio-field.json
List audio devices:
.\.venv\Scripts\python.exe .\scripts\audio_field.py devices
Probe whether a Focusrite path accepts high-rate capture:
.\.venv\Scripts\python.exe .\scripts\audio_field.py probe-rates --device-query Scarlett --hostapi WASAPI --direction input --channels 1 --rate 48000 --rate 96000
Probe the speaker output path separately:
.\.venv\Scripts\python.exe .\scripts\audio_field.py probe-rates --device-query Scarlett --hostapi WASAPI --direction output --channels 2 --rate 44100 --rate 48000 --rate 96000
Validate the profile and local device matches:
.\.venv\Scripts\python.exe .\scripts\audio_field.py validate --profile .\config\audio-field.json --check-devices
Summarize the clock domains and required alignment path:
.\.venv\Scripts\python.exe .\scripts\audio_field.py sync-plan --profile .\config\audio-field.json
Generate a calibration sweep without touching hardware:
.\.venv\Scripts\python.exe .\scripts\audio_field.py make-stimulus --profile .\config\audio-field.json
Create a distributed run folder that can accept one WAV per mic:
.\.venv\Scripts\python.exe .\scripts\audio_field.py init-run --profile .\config\audio-field.json
Record all locally visible distributed mics for a deadline calibration pass:
.\.venv\Scripts\python.exe .\scripts\audio_field.py record-local-calibration --profile .\config\audio-field.json --seconds 10
For passive runtime sync against ground-truth program output, capture the Scarlett loopback with the local mics:
.\.venv\Scripts\python.exe .\scripts\audio_field.py record-local-calibration --profile .\config\audio-field.json --seconds 20 --record-loopback --loopback-query Scarlett --loopback-rate 48000
.\.venv\Scripts\python.exe .\scripts\audio_field.py analyze-reference-sync --profile .\config\audio-field.json --run .\calibration\runs\<run-folder> --window-seconds 4 --hop-seconds 1 --method normalized --min-score 0.08
The loopback path is ground-truth content, but not automatically a perfect ground-truth clock. If WASAPI loopback reports discontinuities, use the stable windows and treat the rest as suspect.
Record local mics while playing one speaker sweep. Use the output rate that the device probe says is real:
.\.venv\Scripts\python.exe .\scripts\audio_field.py record-local-calibration --profile .\config\audio-field.json --seconds 10 --play-sweep --speaker-channel 0 --output-rate 44100
After dropping the neighbor Focusrite WAV into the run's sources/ folder, estimate sweep arrivals and assemble the aligned field:
.\.venv\Scripts\python.exe .\scripts\audio_field.py analyze-distributed --profile .\config\audio-field.json --run .\calibration\runs\<run-folder>
.\.venv\Scripts\python.exe .\scripts\audio_field.py assemble-aligned --profile .\config\audio-field.json --run .\calibration\runs\<run-folder>
Record the neighbor Focusrite shotgun directly into an existing distributed run over SSH/SFTP:
.\.venv\Scripts\python.exe .\scripts\audio_field.py record-remote-focusrite --profile .\config\audio-field.json --run .\calibration\runs\<run-folder> --seconds 10 --sample-rate 48000
This records Analogue 1 + 2 (Focusrite USB Audio) on 192.168.1.84 with remote FFmpeg, pulls it back as sources/mic_focusrite_neighbor.wav, and updates the run manifest. Use this for calibration imports; the existing OBS SRT source is still the live monitoring/streaming path.
analyze-distributed now does a deadline-grade chirplet refinement by default: it uses the coarse sweep matched-filter peak as the initial time-of-arrival, then searches a small fractional-delay and chirp-rate neighborhood with chirplet atoms. Tune the local search when needed:
.\.venv\Scripts\python.exe .\scripts\audio_field.py analyze-distributed --profile .\config\audio-field.json --run .\calibration\runs\<run-folder> --chirplet-refine --search-samples 3 --fractional-steps 8 --rate-ppm 150
This is not a full adaptive chirplet decomposition engine. It is the stream-day version: enough chirplet parameter search to get sub-sample timing refinement without turning calibration into a thesis defense in the middle of setup.
Encode an offline aligned six-channel WAV to FOA AmbiX:
.\.venv\Scripts\python.exe .\scripts\audio_field.py encode-foa --profile .\config\audio-field.json --input .\calibration\runs\<run-folder>\field-aligned.wav --output .\calibration\runs\<run-folder>\field-foa-ambix.wav
The play-record and record-field commands remain for a shared-input-device profile only. They deliberately refuse the distributed rig until an alignment stage emits one coherent six-channel WAV.
Invariants
- Each physical microphone declares its machine, device query, clock domain, field channel, geometry, gain, delay, polarity, directivity, and role.
- The local Focusrite shielded cardioid is the default reference mic.
- The neighbor Focusrite shotgun is the co-streamer dialogue anchor and should be captured losslessly for calibration.
- Focusrite captures should use the best available native/exclusive path and high-rate calibration capture when practical, then resample into the field timeline after alignment.
- Playback sample rate is its own device fact. Probe it separately; do not assume the Scarlett input and output paths accept the same rates through PortAudio/WASAPI.
- Distributed-clock microphones must be aligned and resampled into one reference timeline before FOA encoding.
- Output may run behind live input by the configured latency budget, but it must converge toward real-time instead of letting cache depth grow without bound.
- Sweep arrival estimates should use chirplet refinement when calibration signal quality allows it; plain matched-filter peaks are a fallback, not the precision path.
- Runtime sync updates delay/SRO/phase estimates from known speaker chirplets every block/frame, gated by confidence so a bad observation freezes rather than poisons the field.
- Active chirplet probes are scheduled by an optimization loop, not by panic. It should prefer masked windows, respect minimum spacing and level caps, and maximize expected confidence gain per audible intrusion.
- FOA output is AmbiX: ACN channel order, SN3D normalization, channels
W,Y,Z,X. - Speaker calibration is a measurement path, not a generic monitor output.
- Camera fusion may publish listener or source pose later, but it does not own audio channel timing.
Module Boundaries
The audio code is split so the hard parts can be tested without the room, drivers, or neighbor machine:
audio_field.bufferingowns source buffers and ordered field assembly.audio_field.latencyowns the bounded-delay real-time convergence policy.audio_field.portsdeclares injectable capture, alignment, encoder, and sink protocols.audio_field.pipelinewires those ports together without knowing whether the source is a real Focusrite, an SRT receiver, a WAV fixture, or a test double.- The next runtime estimator module should own chirplet observations, delay/SRO/phase state, confidence gates, and phase-field updates. It should feed the aligner; it should not be hidden inside the FOA encoder.
- The active probe optimizer owns whether to emit extra chirplets. It reads sync confidence and probe budget, then decides when and where an intentional chirplet is worth the cost.
scripts/audio_field.pystays as the operator CLI and hardware probe surface.
This is the portfolio-piece line: each module owns one invariant, and the tests use mocks/fakes instead of asking a driver stack to please be emotionally available.
First Cut Limits
The current encoder is a source-style FOA mix from calibrated microphone channels and declared orientations. It accepts an already aligned six-channel WAV. It does not capture or align independent USB/network clocks by itself, and it is not a full arbitrary-array sound-field reconstruction.
Next honest steps:
- Confirm local Kiyo, PS Eye, local Focusrite, and local speaker device matches.
- Confirm the neighbor Focusrite capture path, preferably native/exclusive or the best FFmpeg/driver route available on that machine.
- Probe the local and neighbor Focusrites for 48 kHz and 96 kHz capture support through the best available driver path.
- Capture the two Focusrite anchors losslessly during calibration; prefer 96 kHz when driver/device support is real, then resample after sync.
- Replace placeholder mic/speaker geometry with measured world coordinates.
- Build the delay/SRO alignment stage that feeds
audio_field.buffering.FieldAssemblyCache. - Emit aligned six-channel blocks under the configured latency policy.
- Encode the first FOA AmbiX test only after that aligned WAV/block stream exists.