feat(ios,p0): iOS P0 platform, audio fixes, channel UX

This commit is contained in:
Edison Jwa
2026-05-17 22:00:00 +09:00
parent a1fefc8ab6
commit 7a59f5b9a1
38 changed files with 1705 additions and 674 deletions
+58 -21
View File
@@ -85,8 +85,8 @@ use audiopus::{
};
use coreaudio::audio_unit::audio_format::LinearPcmFlags;
use coreaudio::audio_unit::render_callback::{self, data};
use coreaudio::audio_unit::{AudioUnit, Element, SampleFormat, Scope, StreamFormat};
use coreaudio::audio_unit::IOType;
use coreaudio::audio_unit::{AudioUnit, Element, SampleFormat, Scope, StreamFormat};
use tokio::sync::mpsc;
use tracing::{debug, error, info, warn};
use tsclientlib::audio::AudioHandler;
@@ -110,7 +110,6 @@ const FRAME_SAMPLES_MONO: usize = 960;
/// shared `crate::framing` module.
const MAX_OPUS_FRAME: usize = 1275;
/// Sample rate every layer above us assumes. Matches the Opus
/// encoder rate, the `tsclientlib::AudioHandler` mix rate, and the
/// sample rate we ask iOS to give us via VPIO's StreamFormat.
@@ -175,12 +174,9 @@ impl IosCaptureState {
frames_sent: Arc<AtomicU32>,
mic_gain: f32,
) -> Result<Self, AudioError> {
let mut encoder = OpusEncoder::new(
OpusSampleRate::Hz48000,
OpusChannels::Mono,
OpusApp::Voip,
)
.map_err(|e| AudioError::Opus(format!("encoder new (ios): {e}")))?;
let mut encoder =
OpusEncoder::new(OpusSampleRate::Hz48000, OpusChannels::Mono, OpusApp::Voip)
.map_err(|e| AudioError::Opus(format!("encoder new (ios): {e}")))?;
// VoIP-tuned settings — bitrate 32 kbps, complexity 10,
// inband FEC on, packet-loss-perc 5. Soft-fail each setter
@@ -438,12 +434,8 @@ impl IosVoiceUnit {
// scratch are owned by the closure — no Mutex needed
// because the input callback is the sole writer/reader on
// the audio thread.
let mut capture_state = IosCaptureState::new(
voice_out_tx,
transmit_active,
frames_sent,
mic_gain,
)?;
let mut capture_state =
IosCaptureState::new(voice_out_tx, transmit_active, frames_sent, mic_gain)?;
unit.set_input_callback(move |args: render_callback::Args<data::Interleaved<i16>>| {
// VPIO with our pinned stream format delivers
@@ -530,10 +522,21 @@ impl IosVoiceUnit {
// earlier callbacks (when scratch was bigger) would
// leak through otherwise.
scratch_stereo[..needed].fill(0.0);
// Lock + fill. Same pattern as Linux/SDL output.
{
let mut h = handler_for_render.lock().unwrap();
let _removed = h.fill_buffer(&mut scratch_stereo[..needed]);
// Non-blocking fill on the realtime callback thread.
// If the inbound forwarder currently owns this mutex,
// emit this period as silence instead of blocking and
// risking an AudioUnit underrun pop/click.
match handler_for_render.try_lock() {
Ok(mut h) => {
let _removed = h.fill_buffer(&mut scratch_stereo[..needed]);
}
Err(std::sync::TryLockError::WouldBlock) => {
// scratch_stereo is already zeroed above.
}
Err(std::sync::TryLockError::Poisoned(e)) => {
// Never panic on the realtime IO thread.
warn!(target: "chanora_audio", "AudioHandler mutex poisoned: {e}");
}
}
// Downmix stereo f32 -> mono i16 with master gain.
@@ -652,9 +655,43 @@ impl IosVoiceUnit {
),
}
Ok(Self {
unit,
})
Ok(Self { unit })
}
/// Restart the audio unit after route change handling.
///
/// Route rebinding on iOS is most reliable when we bounce the
/// VoiceProcessingIO unit through an uninitialize/reinitialize
/// cycle, then start again.
pub fn restart(&mut self) -> Result<(), AudioError> {
self.unit
.stop()
.map_err(|e| AudioError::Backend(format!("vpio restart stop: {e}")))?;
self.unit
.uninitialize()
.map_err(|e| AudioError::Backend(format!("vpio restart uninit: {e}")))?;
self.unit
.initialize()
.map_err(|e| AudioError::Backend(format!("vpio restart init: {e}")))?;
self.unit
.start()
.map_err(|e| AudioError::Backend(format!("vpio restart start: {e}")))?;
info!(target: "chanora_audio", "ios VPIO audio unit restarted");
Ok(())
}
/// Pause the audio unit during an interruption.
pub fn pause(&mut self) -> Result<(), AudioError> {
self.unit
.stop()
.map_err(|e| AudioError::Backend(format!("vpio pause stop: {e}")))
}
/// Resume the audio unit after an interruption.
pub fn resume(&mut self) -> Result<(), AudioError> {
self.unit
.start()
.map_err(|e| AudioError::Backend(format!("vpio resume start: {e}")))
}
}