Skip to content

Commit 7eae452

Browse files
authored
Merge pull request #152 from babblevoice/wideband-reader
Native 16k audio tap: wideband reader (captions) + native-rate recordings
2 parents bfc93ee + cb8a84d commit 7eae452

7 files changed

Lines changed: 230 additions & 26 deletions

File tree

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@babblevoice/projectrtp",
3-
"version": "3.1.0",
3+
"version": "3.2.0",
44
"description": "A scalable Node addon RTP server",
55
"main": "index.js",
66
"directories": {

rust/src/channel/actor.rs

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -628,6 +628,9 @@ async fn handle_command_local(
628628
}
629629

630630
Command::Record { cfg, ack } => {
631+
// Record at the channel's native rate (16k for G.722, else 8k).
632+
let mut cfg = cfg;
633+
cfg.sample_rate = state.codecx.native_samplerate();
631634
// A bare `record` at a different path leaves a pending recorder
632635
// dangling — clear it so the play-end activation doesn't later
633636
// open it alongside this fresh one.
@@ -773,9 +776,12 @@ async fn handle_command_local(
773776
// play-end (or barge-in). The pre-buffer accumulates inbound
774777
// samples while the player runs; on activation it is flushed
775778
// via `write_raw`. C++ `pendingrecorder` equivalent.
776-
let file_str = cfg.recorder.file.to_string_lossy().into_owned();
779+
// Record at the channel's native rate (16k for G.722, else 8k).
780+
let mut recorder_cfg = cfg.recorder;
781+
recorder_cfg.sample_rate = state.codecx.native_samplerate();
782+
let file_str = recorder_cfg.file.to_string_lossy().into_owned();
777783
subs.pending_recorder = Some(PendingRecorder {
778-
cfg: cfg.recorder,
784+
cfg: recorder_cfg,
779785
file_str,
780786
});
781787

rust/src/channel/audio_reader.rs

Lines changed: 79 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -119,9 +119,11 @@ impl AudioReader {
119119
pub fn is_closed(&self) -> bool { self.sender.is_closed() }
120120

121121
/// Feed one 20 ms frame. Called from the recorder's feed point in the
122-
/// tick (same cache state, same timing). Samples are already narrowband
123-
/// 8 kHz linear — wideband and wire-byte formats are pulled directly
124-
/// from `codecx`.
122+
/// tick (same cache state, same timing). Samples are narrowband 8 kHz
123+
/// linear; wideband (16 kHz) and wire-byte formats are pulled from
124+
/// `codecx` for the inbound side. The outbound side has no `codecx`, so a
125+
/// caller that holds wideband for the out side (the mixer, where out is
126+
/// the peer's inbound) supplies it via `feed_with`.
125127
///
126128
/// Never blocks. On full queue or closed consumer the frame is silently
127129
/// dropped (bumping `drops`). Returns `true` if the frame was queued.
@@ -131,7 +133,21 @@ impl AudioReader {
131133
in_samples_8k: Option<&[i16]>,
132134
out_samples_8k: Option<&[i16]>,
133135
) -> bool {
134-
let Some(bytes) = self.build_frame(codecx, in_samples_8k, out_samples_8k) else {
136+
self.feed_with(codecx, in_samples_8k, out_samples_8k, None)
137+
}
138+
139+
/// As [`feed`](Self::feed), but with the outbound side's wideband (16 kHz)
140+
/// samples supplied by the caller. Used by the mixer so an `out`/`both`
141+
/// reader at 16 kHz emits the peer's true wideband instead of silence.
142+
/// Ignored by 8 kHz readers and wire-byte formats.
143+
pub fn feed_with(
144+
&mut self,
145+
codecx: &mut CodecBundle,
146+
in_samples_8k: Option<&[i16]>,
147+
out_samples_8k: Option<&[i16]>,
148+
out_samples_16k: Option<&[i16]>,
149+
) -> bool {
150+
let Some(bytes) = self.build_frame(codecx, in_samples_8k, out_samples_8k, out_samples_16k) else {
135151
return false;
136152
};
137153
match self.sender.try_send(bytes) {
@@ -145,9 +161,10 @@ impl AudioReader {
145161
codecx: &mut CodecBundle,
146162
in_samples_8k: Option<&[i16]>,
147163
out_samples_8k: Option<&[i16]>,
164+
out_samples_16k: Option<&[i16]>,
148165
) -> Option<Vec<u8>> {
149166
match self.cfg.format {
150-
ReaderFormat::L16 => self.build_l16(codecx, in_samples_8k, out_samples_8k),
167+
ReaderFormat::L16 => self.build_l16(codecx, in_samples_8k, out_samples_8k, out_samples_16k),
151168
ReaderFormat::Pcma => codecx.require_wire_as(8).map(|b| b.to_vec()),
152169
ReaderFormat::Pcmu => codecx.require_wire_as(0).map(|b| b.to_vec()),
153170
ReaderFormat::G722 => codecx.require_wire_as(9).map(|b| b.to_vec()),
@@ -162,16 +179,20 @@ impl AudioReader {
162179
codecx: &mut CodecBundle,
163180
in_samples_8k: Option<&[i16]>,
164181
out_samples_8k: Option<&[i16]>,
182+
out_samples_16k: Option<&[i16]>,
165183
) -> Option<Vec<u8>> {
166184
// Resolve the two sides at the requested sample rate.
167185
let (in_samples, out_samples): (Option<Vec<i16>>, Option<Vec<i16>>) = match self.cfg.samplerate {
168186
16000 => {
169-
// Wideband is cached on codecx; upsamples from narrowband if the
170-
// wire is a narrowband codec. Only the "in" side lives on codecx
171-
// — the "out" side can't sensibly become wideband in v1 (no
172-
// upsample cache for player frames). Treat out=None at 16k.
173-
let wb = codecx.require_wideband_16k().map(|s| s.to_vec());
174-
(wb, None)
187+
// Inbound wideband is cached on codecx (decoded from G.722, or
188+
// upsampled from narrowband). The outbound side has no codecx
189+
// here, so its wideband must be supplied by the caller — the
190+
// mixer passes the peer's wideband for a bridged call. When it
191+
// isn't supplied (e.g. a non-bridged channel whose out is an
192+
// 8 kHz player) the out side is silent at 16k.
193+
let wb_in = codecx.require_wideband_16k().map(|s| s.to_vec());
194+
let wb_out = out_samples_16k.map(|s| s.to_vec());
195+
(wb_in, wb_out)
175196
}
176197
_ => (
177198
in_samples_8k.map(|s| s.to_vec()),
@@ -257,3 +278,50 @@ pub struct ForwarderHandle {
257278
pub id: u64,
258279
pub cancel: Arc<tokio::sync::Notify>,
259280
}
281+
282+
#[cfg(test)]
283+
mod tests {
284+
use super::*;
285+
use crate::codec::CodecBundle;
286+
287+
fn reader(direction: ReaderDirection, samplerate: u32) -> AudioReader {
288+
let (tx, _rx) = make_channel();
289+
let cfg = ReaderConfig { direction, samplerate, ..Default::default() };
290+
AudioReader::new(1, cfg, tx)
291+
}
292+
293+
// The fix: at 16 kHz an `out` reader emits the caller-supplied peer
294+
// wideband. Previously the out side was hard-coded to silence at 16 kHz.
295+
#[test]
296+
fn out_reader_16k_uses_supplied_wideband() {
297+
let r = reader(ReaderDirection::Out, 16000);
298+
let mut cx = CodecBundle::new();
299+
let out_wb: Vec<i16> = vec![ 1234; 320 ]; // 20 ms @ 16 kHz mono
300+
let in_8k = vec![ 0i16; 160 ];
301+
let out_8k = vec![ 0i16; 160 ];
302+
303+
let bytes = r
304+
.build_frame(&mut cx, Some(&in_8k), Some(&out_8k), Some(&out_wb))
305+
.expect("frame produced");
306+
307+
assert_eq!(bytes.len(), 320 * 2, "16k mono 20ms = 640 bytes");
308+
assert_eq!(i16::from_le_bytes([ bytes[0], bytes[1] ]), 1234);
309+
}
310+
311+
// The 8 kHz path is unchanged: the out side uses the 8 kHz samples directly
312+
// and ignores any supplied wideband.
313+
#[test]
314+
fn out_reader_8k_uses_narrowband() {
315+
let r = reader(ReaderDirection::Out, 8000);
316+
let mut cx = CodecBundle::new();
317+
let in_8k = vec![ 0i16; 160 ];
318+
let out_8k = vec![ 321i16; 160 ];
319+
320+
let bytes = r
321+
.build_frame(&mut cx, Some(&in_8k), Some(&out_8k), None)
322+
.expect("frame produced");
323+
324+
assert_eq!(bytes.len(), 160 * 2, "8k mono 20ms = 320 bytes");
325+
assert_eq!(i16::from_le_bytes([ bytes[0], bytes[1] ]), 321);
326+
}
327+
}

rust/src/channel/mixer.rs

Lines changed: 67 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -577,19 +577,36 @@ impl Member {
577577
/// R=far leg) — matches C++ mix recording semantics. When `None`
578578
/// (Local mode, N≥3, or peer had no inbound this tick) stereo
579579
/// falls back to duplicate-mono.
580-
async fn write_recordings(&mut self, peer_samples: Option<&[i16]>) {
580+
async fn write_recordings(&mut self, peer_samples: Option<&[i16]>, peer_wideband: Option<&[i16]>) {
581581
if !self.inbound_this_tick { return; }
582582
let Some(samples) = self.state.codecx.require_narrowband_8k().map(|s| s.to_vec()) else { return; };
583+
let self_wb = if self.state.codecx.is_wideband() {
584+
self.state.codecx.require_wideband_16k().map(|s| s.to_vec())
585+
} else {
586+
None
587+
};
583588
let ch_count = self.state.in_count.load(Ordering::Relaxed);
589+
590+
// Recorders capture at the channel's native rate: wideband self + peer
591+
// when the codec is wideband (recorder WAV rate is stamped to match),
592+
// else 8 kHz narrowband. Power runs on whichever self leg is used
593+
// (RMS is amplitude-normalised, so thresholds hold at either rate).
594+
let (rec_self, rec_peer): (&[i16], Option<&[i16]>) = match self_wb.as_deref() {
595+
Some(wb) => (wb, peer_wideband),
596+
None => (&samples, peer_samples),
597+
};
584598
feed_recorders(
585-
&mut self.subs.recorders, &samples, peer_samples, ch_count,
599+
&mut self.subs.recorders, rec_self, rec_peer, ch_count,
586600
&mut self.state.pending_events,
587601
).await;
588-
// AudioReaders share the recorder's L/R convention: L=self inbound,
589-
// R=peer inbound (in mix mode that's the "outbound" side — what
590-
// we'd send out). Same cache, same timing as the recorder.
602+
603+
// AudioReaders share the recorder's L/R convention (L=self inbound,
604+
// R=peer inbound — in mix mode that's the outbound side). Readers
605+
// manage their own rate: the in side is fed 8 kHz (16k readers pull
606+
// wideband from codecx), and a 16k reader's out side gets the peer's
607+
// wideband via feed_with.
591608
for reader in self.subs.readers.iter_mut() {
592-
reader.feed(&mut self.state.codecx, Some(&samples), peer_samples);
609+
reader.feed_with(&mut self.state.codecx, Some(&samples), peer_samples, peer_wideband);
593610
}
594611
self.subs.readers.retain(|r| !r.is_closed());
595612
}
@@ -720,6 +737,7 @@ async fn run_post_mix_phase(
720737
n_alive: usize,
721738
) {
722739
let peer_samples_by_id = compute_peer_samples_by_id(members, n_alive);
740+
let peer_wideband_by_id = compute_peer_wideband_by_id(members, n_alive);
723741

724742
// Clone the id list so we can look up each member's peer samples
725743
// from the sibling map while holding a `&mut` to the member.
@@ -735,8 +753,16 @@ async fn run_post_mix_phase(
735753
} else {
736754
None
737755
};
756+
// 16k wideband counterpart for the reader tap's out side (320 samples).
757+
let peer_wideband: Option<Vec<i16>> = if n_alive == 2 {
758+
peer_wideband_by_id.iter()
759+
.find_map(|(&pid, s)| if pid != id { Some(s.clone()) } else { None })
760+
.or_else(|| Some(vec![ 0i16; MIX_FRAME_SAMPLES * 2 ]))
761+
} else {
762+
None
763+
};
738764
let Some(m) = members.get_mut(&id) else { continue; };
739-
m.write_recordings(peer_samples.as_deref()).await;
765+
m.write_recordings(peer_samples.as_deref(), peer_wideband.as_deref()).await;
740766
m.send_dtmf_outbound().await;
741767
m.drain_pending_events();
742768
}
@@ -768,6 +794,32 @@ fn compute_peer_samples_by_id(
768794
.collect()
769795
}
770796

797+
/// Build the per-member "peer wideband" (16 kHz) map for the N=2 reader tap —
798+
/// the far party's true wideband (decoded from G.722, or upsampled from
799+
/// narrowband) so an `out`/`both` reader at 16 kHz isn't fed a downsampled
800+
/// copy. Mirrors `compute_peer_samples_by_id` but at 16 kHz (320 samples /
801+
/// 20 ms). Empty for N≠2; only the reader uses this (the recorder stays 8 kHz).
802+
fn compute_peer_wideband_by_id(
803+
members: &mut HashMap<ChannelId, Box<Member>>,
804+
n_alive: usize,
805+
) -> HashMap<ChannelId, Vec<i16>> {
806+
if n_alive != 2 {
807+
return HashMap::new();
808+
}
809+
members.iter_mut()
810+
.map(|(&id, m)| {
811+
let samples = if m.inbound_this_tick {
812+
m.state.codecx.require_wideband_16k()
813+
.map(|s| s.to_vec())
814+
.unwrap_or_else(|| vec![ 0i16; MIX_FRAME_SAMPLES * 2 ])
815+
} else {
816+
vec![ 0i16; MIX_FRAME_SAMPLES * 2 ]
817+
};
818+
(id, samples)
819+
})
820+
.collect()
821+
}
822+
771823
/// For N=2, tell each member's codec bundle the peer's iLBC wire PT
772824
/// (when dynamic, i.e. ≥96) so the bridge can decode/encode at the
773825
/// right PT on both sides of an asymmetric-dynamic pair.
@@ -1001,6 +1053,9 @@ async fn apply_forwarded(m: &mut Member, cmd: Command) {
10011053
let _ = ack.send(());
10021054
}
10031055
Command::Record { cfg, ack } => {
1056+
// Record at the channel's native rate (16k for G.722, else 8k).
1057+
let mut cfg = cfg;
1058+
cfg.sample_rate = m.state.codecx.native_samplerate();
10041059
m.subs.pending_recorder = None;
10051060
m.subs.prebuffer.clear();
10061061
let file_str = cfg.file.to_string_lossy().into_owned();
@@ -1131,9 +1186,12 @@ async fn apply_forwarded(m: &mut Member, cmd: Command) {
11311186
}
11321187
// Queue the recorder — opened on play-end or barge-in via
11331188
// `activate_pending_recorder`. Parity with local-mode handler.
1134-
let file_str = cfg.recorder.file.to_string_lossy().into_owned();
1189+
// Record at the channel's native rate (16k for G.722, else 8k).
1190+
let mut recorder_cfg = cfg.recorder;
1191+
recorder_cfg.sample_rate = m.state.codecx.native_samplerate();
1192+
let file_str = recorder_cfg.file.to_string_lossy().into_owned();
11351193
m.subs.pending_recorder = Some(super::actor::PendingRecorder {
1136-
cfg: cfg.recorder,
1194+
cfg: recorder_cfg,
11371195
file_str,
11381196
});
11391197
let _ = ack.send(());

rust/src/channel/tick.rs

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -306,11 +306,41 @@ async fn feed_recorders_and_readers(
306306
// inbound produces no samples, so it doesn't qualify.
307307
let has_recordable_audio = decoded.is_some() || player_frame.is_some();
308308
if has_recordable_audio {
309-
write_recorder_frames(state, subs, in_s, out_s).await;
309+
if state.codecx.is_wideband() {
310+
// Native-rate recording on a wideband channel: true wideband
311+
// inbound (G.722 decode) + the outbound (player/echo/silence)
312+
// upsampled to 16 kHz so the WAV stays coherent. The recorder's
313+
// WAV rate is stamped to 16k at open to match.
314+
let in_wb = state.codecx.require_wideband_16k()
315+
.map(|s| s.to_vec())
316+
.unwrap_or_else(|| upsample_2x(in_s));
317+
let out_wb = upsample_2x(out_s);
318+
write_recorder_frames(state, subs, &in_wb, &out_wb).await;
319+
} else {
320+
write_recorder_frames(state, subs, in_s, out_s).await;
321+
}
310322
}
323+
// Readers manage their own rate (a 16k reader pulls wideband from codecx);
324+
// always hand them the 8 kHz narrowband here.
311325
feed_readers(state, subs, in_s, out_s);
312326
}
313327

328+
/// Linear 2x upsample (8 kHz -> 16 kHz) used for the recorder's outbound leg
329+
/// on a wideband channel, where the player/echo source is narrowband. The
330+
/// inbound leg uses the codec's true wideband decode; this only covers the
331+
/// out side so a stereo native recording stays sample-aligned.
332+
fn upsample_2x(input: &[i16]) -> Vec<i16> {
333+
if input.is_empty() { return Vec::new(); }
334+
let mut out = Vec::with_capacity(input.len() * 2);
335+
for i in 0..input.len() {
336+
let cur = input[i] as i32;
337+
let next = if i + 1 < input.len() { input[i + 1] as i32 } else { cur };
338+
out.push(cur as i16);
339+
out.push(((cur + next) / 2) as i16);
340+
}
341+
out
342+
}
343+
314344
async fn write_recorder_frames(
315345
state: &mut ChannelState,
316346
subs: &mut Subsystems,
@@ -763,4 +793,18 @@ mod tests {
763793
"expected full IVR digit sequence; got {:?}", digits
764794
);
765795
}
796+
797+
#[test]
798+
fn upsample_2x_doubles_and_interpolates() {
799+
let input = [100i16, 200, 300, 400];
800+
let out = upsample_2x(&input);
801+
assert_eq!(out.len(), 8);
802+
assert_eq!(out[0], 100);
803+
assert_eq!(out[1], 150); // (100+200)/2
804+
assert_eq!(out[2], 200);
805+
assert_eq!(out[3], 250); // (200+300)/2
806+
// last sample duplicates (no next to interpolate toward)
807+
assert_eq!(out[7], 400);
808+
assert!(upsample_2x(&[]).is_empty());
809+
}
766810
}

0 commit comments

Comments
 (0)