Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions crates/daisy-spatializer/src/hrir_data.rs
Original file line number Diff line number Diff line change
Expand Up @@ -73,3 +73,61 @@ pub static HRIR_BEHIND_LEFT_R: [f32; 140] = [
-0.00744548114, -0.00840723328, -0.00822249521, -0.00738474075, -0.00659618992, -0.00573343132,
-0.0068345652, -0.00170050072,
];

/// behind and to the right (elev 0, az 135 deg, right hemisphere as measured): the right ear leads and is louder. (left ear)
#[rustfmt::skip]
pub static HRIR_BEHIND_RIGHT_L: [f32; 140] = [
0.000191251223, 1.47008977e-05, -8.16221745e-06, -4.01469915e-05, -7.45840734e-05, -6.25497923e-05,
-5.49596407e-05, -8.36005565e-05, -3.0166153e-05, -0.000124228827, -0.000153540852, 7.07533036e-05,
-6.73965842e-05, -8.44886381e-06, -0.000114029528, -0.000201031857, 0.000625737885, -0.000643042382,
0.000736362592, -0.0011314007, 0.00137496123, -0.000528445875, 0.00104942382, 0.00198160554,
0.0254640263, 0.143842712, 0.128949881, -0.0834767371, -0.0964842588, 0.0465437174,
0.0445832945, -0.031828884, 0.03582903, 0.266797066, 0.341709524, 0.149304375,
0.0384966135, 0.0792761147, 0.0366958529, -0.0421384461, -0.0671508089, -0.12543948,
-0.174161121, -0.128920302, -0.0802240595, -0.0809995607, -0.0745583922, -0.0286741853,
0.0268042088, 0.0328753218, 0.021194471, 0.0455865785, 0.0680219829, 0.0619345382,
0.0529427938, 0.0482897907, 0.0448700339, 0.0412457399, 0.0228385907, -0.00556660676,
-0.0235984232, -0.0409562998, -0.0705932304, -0.0970793068, -0.102680698, -0.0903467014,
-0.0677555948, -0.047247868, -0.0294785108, -0.00982518122, 0.00337028224, 0.0123548573,
0.0207716916, 0.0226972904, 0.0207512192, 0.0165960807, 0.0064397417, -0.0035831097,
-0.00973060448, -0.0164581668, -0.0225861929, -0.0263079721, -0.0277342834, -0.0266735349,
-0.0250890478, -0.0228840914, -0.0179628581, -0.0140219694, -0.0146106258, -0.0124013107,
-0.00882213283, -0.00704370905, -0.00407665921, 0.00141341705, 0.00775115518, 0.00910567399,
0.0049938811, 0.000593817269, -0.00315919379, -0.00746046007, -0.00952734705, -0.00777670974,
-0.00877514761, -0.0139615145, -0.0178805105, -0.0177255217, -0.0144472094, -0.0126594668,
-0.0127405366, -0.0107886773, -0.0088807987, -0.00953567121, -0.0106301336, -0.010956998,
-0.010054335, -0.00752476603, -0.00400163746, -0.00196078001, -0.00371892168, -0.00743872672,
-0.00969987828, -0.00908207893, -0.00746255834, -0.0063651111, -0.00432398356, -0.00296841608,
-0.00392086245, -0.00528306887, -0.00521943904, -0.00385475927, -0.00395180006, -0.00601512892,
-0.00744548114, -0.00840723328, -0.00822249521, -0.00738474075, -0.00659618992, -0.00573343132,
-0.0068345652, -0.00170050072,
];

/// behind and to the right (elev 0, az 135 deg, right hemisphere as measured): the right ear leads and is louder. (right ear)
#[rustfmt::skip]
pub static HRIR_BEHIND_RIGHT_R: [f32; 140] = [
0.00337877171, -0.00447274279, 0.00275876792, 0.00207341462, -0.0109690819, 0.0171860177,
0.00239873584, 0.506461322, 0.529778779, -0.633589327, -0.669333816, 0.466076881,
0.509902418, -0.244769707, 0.152371213, 0.99000001, 0.267850041, -0.317839026,
0.582891643, 0.888389647, -0.115155153, -0.669275999, -0.227772832, -0.126259521,
-0.466231793, -0.390703768, -0.251123369, -0.416943848, -0.394003719, -0.0656630248,
0.125797272, 0.0225323252, 0.0251900926, 0.243783712, 0.282317579, 0.127475008,
0.0731645897, 0.10052447, 0.00169459463, -0.126459002, -0.103546247, -0.0738668367,
-0.113875017, -0.148587629, -0.131102577, -0.0917927623, -0.0814751834, -0.0529217646,
-0.015240198, -0.00481301313, 0.000353860407, 0.0254197791, 0.0568460152, 0.0595769398,
0.0462431759, 0.0503280014, 0.0505702905, 0.0188427977, -0.0191724021, -0.022956118,
-0.0119788153, -0.033670228, -0.0625450462, -0.0583199598, -0.0528321788, -0.0556138493,
-0.0494542681, -0.0225108787, 0.000134588496, 0.00145884184, -0.000883619592, 0.0044011632,
0.0182036441, 0.0176838208, 0.00975852087, 0.0194898546, 0.0300157201, 0.0169468056,
-0.00374204968, -0.00302326865, -0.00602516485, -0.0276838094, -0.0330429375, -0.01862555,
-0.0195246972, -0.0379398763, -0.0360605344, -0.0164718926, -0.0084501598, -0.00755816,
0.00658946577, 0.0164657366, 0.00800296664, 0.00140121591, 0.00662036845, 0.011142578,
0.00825175457, 0.00770792877, 0.00718233641, -0.00315625756, -0.0132216346, -0.0146700563,
-0.0104484633, -0.0105020544, -0.0139129357, -0.0165294651, -0.0200253353, -0.0242106263,
-0.0253410432, -0.0180779994, -0.00704361452, -0.00136290793, -0.0028871058, -0.00425841846,
-0.00342559232, -0.0034398993, -0.00166663481, 0.00050952204, -0.00158727355, -0.00979536027,
-0.0190977305, -0.0222556833, -0.0195009336, -0.0140545405, -0.0101392316, -0.00714147417,
-0.0063768588, -0.00788627937, -0.00517182099, 0.00263214577, 0.00805410463, 0.00690532429,
0.00392907718, 0.00328908954, 0.00206950423, -0.00111155596, -0.000888807175, 0.00283338269,
0.00191294018, 5.59133223e-05,
];
184 changes: 113 additions & 71 deletions crates/daisy-spatializer/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,25 +2,25 @@
#![no_main]
#![allow(deprecated)] // cortex-m-rt 0.7 #[pre_init]; migrate later.

//! Binaural spatializer — **phase 2**: mono codec IN → HRIR *direction* + a
//! parametric *room* → stereo codec OUT, so a mono guitar patched to the left
//! input images *behind and to the left*, a few feet away, on headphones.
//! Binaural spatializer — **phase 3**: TWO mono codec inputs, each placed at its
//! own HRIR *direction*, summed through one shared *room* → stereo codec OUT. The
//! left input images *behind and to the left*, the right input *behind and to the
//! right*, both a few feet out in the same room, on headphones.
//!
//! Signal path (in the DMA1_STR1 audio callback, 48-frame stereo blocks):
//! ```text
//! ┌► [48→64 reblock] ─► StereoConvolver(HRIR_L/R) ─► [64→48] ─┐ direct
//! input L (mono) ──────┤ ├─► + ─► out L/R
//! ► air-abs LP ─┬► EarlyReflections ──────────────────────────┤
//! └► FdnReverb (late) ──────────────────────────┘ room
//! input L ─► Voice1 [48→64 ► HRIR behind-LEFT ► 64→48] ─┐ direct
//! input R ─► Voice2 [48→64 ► HRIR behind-RIGHT ► 64→48] ─┤─► + ─► out L/R
//! ½(L+R) ─► air-abs LP ─┬► EarlyReflections ─────────────┤
//! └► FdnReverb (late) ──────────────┘ shared room
//! ```
//! The **HRIR** ([`daisy_dsp::convolution::StereoConvolver`], fully wet) gives
//! *direction*: the measured left/right IRs carry the interaural time/level/pinna
//! cues that place the source behind-left — a pan/delay cannot. The **room**
//! gives *distance / externalization* (the biggest "out there, not in-head"
//! factor): early reflections ([`daisy_dsp::room::EarlyReflections`], the
//! strongest cue) + late reverb ([`daisy_dsp::reverb::FdnReverb`]) + an
//! air-absorption low-pass, mixed against the direct with fixed distance gains.
//! [`SampleFifo`] adapters bridge the codec's 48-frame callback to the FFT
//! Each **HRIR** ([`daisy_dsp::convolution::StereoConvolver`], fully wet) gives
//! *direction*: the measured IRs carry the interaural time/level/pinna cues a
//! pan/delay cannot. The **shared room** ([`daisy_dsp::room::EarlyReflections`] —
//! the strongest externalization cue — + [`daisy_dsp::reverb::FdnReverb`] + an
//! air-absorption low-pass) gives *distance / externalization*; one room for both
//! sources is both physically right and the economical choice (one reverb, not
//! two). [`SampleFifo`] adapters bridge the codec's 48-frame callback to the FFT
//! convolver's 64-sample block (`daisy_dsp::reblock`); the first early reflection
//! (8 ms) is set beyond the convolver's ~2 ms latency so it never precedes the
//! direct sound.
Expand All @@ -32,9 +32,9 @@
//! HARDWARE-ONLY (needs a real codec + the clock hand-off). The `renode_test`
//! feature skips the codec bring-up so the XIP boot can be smoke-tested in sim.
//!
//! Phases 1-2 of `docs/binaural-spatializer.md`; later phases add a second
//! source and live positioning from the Hothouse knobs. Room params are fixed
//! here (phase 4 wires them to the knobs).
//! Phases 1-3 of `docs/binaural-spatializer.md`. Source directions and room
//! params are fixed here; phase 4 wires them to the Hothouse knobs (live
//! positioning + HRIR interpolation).

mod hrir_data;

Expand All @@ -50,7 +50,7 @@ use daisy_dsp::filter::OnePole;
use daisy_dsp::reblock::SampleFifo;
use daisy_dsp::reverb::FdnReverb;
use daisy_dsp::room::{room_taps, EarlyReflections, Tap};
use hrir_data::{HRIR_BEHIND_LEFT_L, HRIR_BEHIND_LEFT_R};
use hrir_data::{HRIR_BEHIND_LEFT_L, HRIR_BEHIND_LEFT_R, HRIR_BEHIND_RIGHT_L, HRIR_BEHIND_RIGHT_R};

// --- audio geometry ----------------------------------------------------------
/// Codec sample rate (Hz).
Expand Down Expand Up @@ -100,7 +100,9 @@ const WET_EARLY: f32 = 0.5;
const WET_LATE: f32 = 0.35;

// --- DSP state: backing buffers + the objects that borrow them ---------------
static mut CONV_SCRATCH: [f32; SCRATCH] = [0.0; SCRATCH];
// One convolver scratch per source (two independent HRIR voices).
static mut CONV_SCRATCH1: [f32; SCRATCH] = [0.0; SCRATCH];
static mut CONV_SCRATCH2: [f32; SCRATCH] = [0.0; SCRATCH];
static mut REVERB_BUF: [f32; FdnReverb::REQUIRED_BUF] = [0.0; FdnReverb::REQUIRED_BUF];
static mut EARLY_BUF: [f32; EARLY_BUF_LEN] = [0.0; EARLY_BUF_LEN];
static mut ROOM_TAPS: [Tap; NTAPS] = [Tap {
Expand All @@ -109,21 +111,53 @@ static mut ROOM_TAPS: [Tap; NTAPS] = [Tap {
gain_r: 0.0,
}; NTAPS];

/// The whole spatializer voice: the HRIR direct path (with its 48↔64 reblock
/// FIFOs) plus the room (early reflections, late reverb, air-absorption). Built
/// once in `main` into the static buffers above, then driven by the callback.
// The renode_test boot smoke builds this (to exercise the real init + static
// allocation from XIP) but compiles out the callback that reads the fields.
/// One spatialized source: its HRIR convolver + the 48↔64 reblock FIFOs that
/// feed it. `process` takes a 48-frame mono block and yields the 48-frame
/// spatialized stereo (or "not primed yet" during the initial fill).
#[cfg_attr(not(feature = "codec"), allow(dead_code))]
struct Dsp {
struct Voice {
conv: StereoConvolver<'static>,
early: EarlyReflections<'static>,
reverb: FdnReverb<'static>,
air_lp: OnePole,
in_fifo: SampleFifo<128>, // covers the largest 48/64 carry (< 2 blocks)
out_l: SampleFifo<256>,
out_r: SampleFifo<256>,
}

#[cfg(feature = "codec")]
impl Voice {
/// Push one 48-frame mono block; fill `(dl, dr)` with the spatialized stereo.
/// Returns `false` (leaving `dl`/`dr` untouched) until the reblock+convolver
/// pipeline has primed.
fn process(&mut self, mono: &[f32], dl: &mut [f32], dr: &mut [f32]) -> bool {
self.in_fifo.extend(mono);
let mut blk = [0.0f32; B];
let (mut ol, mut or) = ([0.0f32; B], [0.0f32; B]);
while self.in_fifo.pop(&mut blk) {
self.conv.process_block(&blk, &mut ol, &mut or);
self.out_l.extend(&ol);
self.out_r.extend(&or);
}
if self.out_l.len() >= dl.len() {
self.out_l.pop(dl);
self.out_r.pop(dr);
true
} else {
false
}
}
}

/// The full render: two spatialized sources (behind-left, behind-right) summed,
/// plus one SHARED room (early reflections, late reverb, air-absorption) fed by
/// the source mix — sharing the room is both physically right (one room) and the
/// economical choice (one reverb, not two).
#[cfg_attr(not(feature = "codec"), allow(dead_code))]
struct Dsp {
voice1: Voice, // source 1 = left input, behind-left
voice2: Voice, // source 2 = right input, behind-right
early: EarlyReflections<'static>,
reverb: FdnReverb<'static>,
air_lp: OnePole,
}
static mut DSP: Option<Dsp> = None;

/// Frames per codec callback, and the interleaved stereo block length.
Expand All @@ -132,10 +166,10 @@ const FRAMES: usize = daisy_audio::BLOCK_SIZE;
#[cfg(feature = "codec")]
const STEREO: usize = 2 * FRAMES;

/// The audio callback: mono (left in) → [direct HRIR] + [room] → stereo out.
/// Runs in the DMA1_STR1 interrupt. The HRIR gives *direction* (behind-left); the
/// room (early reflections + late reverb + air-absorption) gives *distance /
/// externalization* — makes it sound "out there," not inside the head.
/// The audio callback: two mono inputs → two HRIR *directions* + a shared *room*
/// → stereo out. Runs in the DMA1_STR1 interrupt. Source 1 (left in) images
/// behind-left, source 2 (right in) behind-right; the shared room externalizes
/// both ("out there," not inside the head).
#[cfg(feature = "codec")]
fn spatialize(input: &[f32; STEREO], output: &mut [f32; STEREO]) {
// SAFETY: runs only in the audio ISR; `DSP` and its buffers are touched
Expand All @@ -150,59 +184,69 @@ fn spatialize(input: &[f32; STEREO], output: &mut [f32; STEREO]) {
}
};

// Mono source = LEFT input channel (patch the mono guitar to the left in).
let mut mono = [0.0f32; FRAMES];
for (f, m) in mono.iter_mut().enumerate() {
*m = input[2 * f];
// Two mono sources: left input → src1, right input → src2.
let (mut m1, mut m2) = ([0.0f32; FRAMES], [0.0f32; FRAMES]);
for f in 0..FRAMES {
m1[f] = input[2 * f];
m2[f] = input[2 * f + 1];
}

// Direct path: HRIR via 48→64 reblock → convolver → 64→48 reblock.
dsp.in_fifo.extend(&mono);
let mut blk = [0.0f32; B];
let (mut ol, mut or) = ([0.0f32; B], [0.0f32; B]);
while dsp.in_fifo.pop(&mut blk) {
dsp.conv.process_block(&blk, &mut ol, &mut or);
dsp.out_l.extend(&ol);
dsp.out_r.extend(&or);
}
let (mut dl, mut dr) = ([0.0f32; FRAMES], [0.0f32; FRAMES]);
let primed = dsp.out_l.len() >= FRAMES;
if primed {
dsp.out_l.pop(&mut dl);
dsp.out_r.pop(&mut dr);
}
// Direct HRIR paths (per source). The buffers start at silence and are
// left untouched until each voice's pipeline primes, so an unprimed voice
// simply contributes zero to the mix below.
let (mut d1l, mut d1r) = ([0.0f32; FRAMES], [0.0f32; FRAMES]);
let (mut d2l, mut d2r) = ([0.0f32; FRAMES], [0.0f32; FRAMES]);
dsp.voice1.process(&m1, &mut d1l, &mut d1r);
dsp.voice2.process(&m2, &mut d2l, &mut d2r);

// Room path: air-absorption LP send → early reflections + late reverb.
// Shared room, fed by the source mix → air-absorption LP → early + late.
let mut room_in = [0.0f32; FRAMES];
for f in 0..FRAMES {
room_in[f] = 0.5 * (m1[f] + m2[f]);
}
let mut send = [0.0f32; FRAMES];
dsp.air_lp.process(&mono, &mut send);
dsp.air_lp.process(&room_in, &mut send);
let (mut el, mut erf) = ([0.0f32; FRAMES], [0.0f32; FRAMES]);
dsp.early.process(&send, &mut el, &mut erf);
let (mut rl, mut rr) = ([0.0f32; FRAMES], [0.0f32; FRAMES]);
dsp.reverb.process(&send, &mut rl, &mut rr);

// Mix direct + early + late with the fixed distance gains.
// Mix: (direct src1 + direct src2) + shared early + late.
for f in 0..FRAMES {
let (d_l, d_r) = if primed { (dl[f], dr[f]) } else { (0.0, 0.0) };
output[2 * f] = DRY * d_l + WET_EARLY * el[f] + WET_LATE * rl[f];
output[2 * f + 1] = DRY * d_r + WET_EARLY * erf[f] + WET_LATE * rr[f];
let dir_l = d1l[f] + d2l[f];
let dir_r = d1r[f] + d2r[f];
output[2 * f] = DRY * dir_l + WET_EARLY * el[f] + WET_LATE * rl[f];
output[2 * f + 1] = DRY * dir_r + WET_EARLY * erf[f] + WET_LATE * rr[f];
}
}
}

/// Build the whole DSP voice into its static buffers. Call once, before starting
/// Build the whole DSP render into its static buffers. Call once, before starting
/// audio (the ISR is still masked).
fn init_dsp() {
// SAFETY: single-threaded init; each static buffer is borrowed exactly once
// (they then live for the program's life inside `DSP`).
unsafe {
let conv_scratch: &'static mut [f32] = &mut *core::ptr::addr_of_mut!(CONV_SCRATCH);
let conv = StereoConvolver::new(
&HRIR_BEHIND_LEFT_L,
&HRIR_BEHIND_LEFT_R,
B,
1.0,
conv_scratch,
);
let scratch1: &'static mut [f32] = &mut *core::ptr::addr_of_mut!(CONV_SCRATCH1);
let scratch2: &'static mut [f32] = &mut *core::ptr::addr_of_mut!(CONV_SCRATCH2);
let voice1 = Voice {
conv: StereoConvolver::new(&HRIR_BEHIND_LEFT_L, &HRIR_BEHIND_LEFT_R, B, 1.0, scratch1),
in_fifo: SampleFifo::new(),
out_l: SampleFifo::new(),
out_r: SampleFifo::new(),
};
let voice2 = Voice {
conv: StereoConvolver::new(
&HRIR_BEHIND_RIGHT_L,
&HRIR_BEHIND_RIGHT_R,
B,
1.0,
scratch2,
),
in_fifo: SampleFifo::new(),
out_l: SampleFifo::new(),
out_r: SampleFifo::new(),
};

room_taps(
&mut *core::ptr::addr_of_mut!(ROOM_TAPS),
Expand All @@ -224,13 +268,11 @@ fn init_dsp() {
let air_lp = OnePole::lowpass(SR, AIR_CUTOFF_HZ);

*core::ptr::addr_of_mut!(DSP) = Some(Dsp {
conv,
voice1,
voice2,
early,
reverb,
air_lp,
in_fifo: SampleFifo::new(),
out_l: SampleFifo::new(),
out_r: SampleFifo::new(),
});
}
}
Expand Down
5 changes: 4 additions & 1 deletion docs/binaural-spatializer.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,10 @@ directions, resample to 48 kHz, export a binary asset → loaded into **SDRAM**.
2. ✅ **Built** (adds `daisy_dsp::room::EarlyReflections` + the room mix). The
room = early reflections (strongest externalization cue) + `FdnReverb` (late) +
air-absorption LP + distance dry/wet, fixed params. HW listening pending.
3. Two sources + mix.
3. ✅ **Built** (two `Voice`s + a shared room). Left input → behind-left, right
input → behind-right, each its own HRIR convolver; the two directs are summed
and one shared room (fed by the source mix) externalizes both. Second HRIR
direction added to `tools/hrir-gen`. HW listening pending.
4. Live positioning from the knobs (HRIR interpolation) — also wires the phase-2
room params (distance/reverb) to the Hothouse knobs.
5. **Stretch:** BRIR path; **head-tracking** via a small IMU — dynamic cues resolve front/back and hugely improve externalization (the biggest quality upgrade).
Expand Down
8 changes: 8 additions & 0 deletions tools/hrir-gen/src/hrir_gen/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,14 @@ class Direction:
doc="behind and to the left (elev 0, az 135 deg mirrored to the left "
"hemisphere): the left ear leads and is louder.",
),
Direction(
name="BEHIND_RIGHT",
elev=0,
azim=135,
swap=False,
doc="behind and to the right (elev 0, az 135 deg, right hemisphere as "
"measured): the right ear leads and is louder.",
),
]


Expand Down
Loading