diff --git a/apps/chanora_flutter/pubspec.yaml b/apps/chanora_flutter/pubspec.yaml index b5b7baa..d14ad2f 100644 --- a/apps/chanora_flutter/pubspec.yaml +++ b/apps/chanora_flutter/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 1.0.0-rc.8+63 +version: 1.0.0-rc.8+64 environment: sdk: ^3.11.5 diff --git a/crates/chanora_audio/src/ios_voice_unit.rs b/crates/chanora_audio/src/ios_voice_unit.rs index bb0fbe7..931bd58 100644 --- a/crates/chanora_audio/src/ios_voice_unit.rs +++ b/crates/chanora_audio/src/ios_voice_unit.rs @@ -320,10 +320,11 @@ impl IosVoiceUnit { /// and SDL backends accept so engine.rs can swap backends with /// a `cfg`. /// - /// * `_handler` — shared AudioHandler the inbound forwarder + /// * `handler` — shared AudioHandler the inbound forwarder /// feeds Opus packets into. The output render callback pulls - /// decoded f32 frames from it. - /// * `_output_gain` / `_output_muted` — same atomics the cpal + /// decoded f32 frames from it (48 kHz stereo) and downmixes + /// to the i16 mono buffer VPIO expects. + /// * `output_gain` / `output_muted` — same atomics the cpal /// and SDL output paths read on every callback so the master /// volume + local-mute UI works identically across backends. /// * `voice_out_tx` — channel the capture pipeline sends @@ -333,14 +334,13 @@ impl IosVoiceUnit { /// * `frames_sent` — counter the bridge stats surface reads. /// * `mic_gain` — pre-encode amplitude scale. /// - /// Capture wiring landed in commit 3; playback wiring lands - /// in commit 4 (the render callback still emits silence - /// until then). + /// Capture wiring landed in commit 3; playback wiring landed + /// in commit 4. Route-change observation is commit 5. #[allow(clippy::too_many_arguments)] pub fn start( - _handler: Arc>>, - _output_gain: Arc, - _output_muted: Arc, + handler: Arc>>, + output_gain: Arc, + output_muted: Arc, voice_out_tx: mpsc::Sender, transmit_active: Arc, frames_sent: Arc, @@ -445,14 +445,93 @@ impl IosVoiceUnit { }) .map_err(|e| AudioError::Backend(format!("vpio set input callback: {e}")))?; - // Install the render callback that emits playback samples - // to the hardware. The buffer iOS hands us is uninitialised - // — we MUST fill it (writing silence if we have nothing, - // never leaving stale frames). Commit 4 replaces the silence - // loop with an AudioHandler::fill_buffer + downmix path. - unit.set_render_callback(|args: render_callback::Args>| { - for s in args.data.buffer.iter_mut() { - *s = 0; + // Install the render callback that drives playback. The + // buffer iOS hands us is uninitialised — we MUST fill it + // (writing silence if we have nothing, never leaving stale + // frames). + // + // Pipeline per callback: + // 1. Lock the AudioHandler, ask it to fill a scratch + // f32 stereo buffer (length = 2 * num_frames). The + // handler runs Opus decode + per-client jitter + // buffer + mix. Same primitive cpal + SDL output + // paths use; this is the platform-neutral playback + // contract from `tsclientlib::audio::AudioHandler`. + // 2. Downmix to i16 mono with master gain. VPIO expects + // mono int16 (the stream format we pinned above); + // the handler produces stereo f32. We average L+R + // to a single mono channel rather than dropping R — + // the cpal-side mono-output path made the same + // mistake briefly (commit 6a4dbad / fix) and lost + // half the spatial mix. + // 3. Local-mute zeroes the output but STILL drains + // AudioHandler in step 1 so its jitter buffer + // doesn't grow unbounded while muted. This is the + // contract every other backend follows (matches + // SdlOutput::callback and the cpal output stream). + // + // The closure owns the f32 scratch Vec so we re-use the + // backing allocation across callbacks. The first few + // callbacks may grow it; after that the audio thread + // never touches the allocator. + let mut scratch_stereo: Vec = Vec::with_capacity(FRAME_SAMPLES_MONO * 2); + let handler_for_render = handler.clone(); + let output_gain_for_render = output_gain.clone(); + let output_muted_for_render = output_muted.clone(); + unit.set_render_callback(move |args: render_callback::Args>| { + let out: &mut [i16] = args.data.buffer; + // VPIO with our pinned stream format gives us a mono + // buffer; `num_frames` == `out.len()`. Stereo f32 + // scratch is twice that. + let num_frames = out.len(); + let needed = num_frames * 2; + if scratch_stereo.len() < needed { + scratch_stereo.resize(needed, 0.0); + } + // Zero only the live slice. AudioHandler::fill_buffer + // writes silence into untouched samples but residual + // values from earlier callbacks (when the buffer was + // bigger) would leak through otherwise. `fill` + // optimises to memset. + scratch_stereo[..needed].fill(0.0); + + { + let mut h = handler_for_render.lock().unwrap(); + let _removed_ids = h.fill_buffer(&mut scratch_stereo[..needed]); + // `_removed_ids` lists clients whose stream just + // finished draining; the bridge layer surfaces + // "stopped speaking" via BridgeEvent::VoiceState + // already, so we ignore it here (matches SDL + + // cpal output behaviour). + } + + if output_muted_for_render.load(Ordering::Relaxed) { + for s in out.iter_mut() { + *s = 0; + } + return Ok(()); + } + + let gain = f32::from_bits(output_gain_for_render.load(Ordering::Relaxed)); + // Downmix stereo -> mono with gain. The `(l + r) * 0.5` + // averaging preserves total signal energy with a 3 dB + // headroom (sum-of-correlated peaks at full scale + // would otherwise clip). Multiplying by gain after + // the downmix saves one multiplication per sample. + // + // Cast to i16 with saturate-on-overflow. Hard-clip is + // acceptable here because the upstream signal is + // already in [-1.0, 1.0] from the f32 stereo mix; + // gain values >1.0 are the only path to clipping and + // hard-clip at the engine boundary is what every other + // backend's i16 path does (see the cpal-side + // FromF32 for i16 impl in engine.rs). + for (i, dst) in out.iter_mut().enumerate() { + let l = scratch_stereo[i * 2]; + let r = scratch_stereo[i * 2 + 1]; + let mono_f32 = (l + r) * 0.5 * gain; + let clamped = mono_f32.clamp(-1.0, 1.0); + *dst = (clamped * i16::MAX as f32) as i16; } Ok(()) })