chore: restore product scaffold to rollback baseline

This commit is contained in:
Edison Jwa
2026-05-29 14:02:04 +09:00
parent 2896f14ec9
commit fe6e07353e
434 changed files with 27278 additions and 63230 deletions
+82 -261
View File
@@ -5,35 +5,28 @@
//! and stopped before disconnect. It does not retry on device
//! change.
use std::collections::HashMap;
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use std::sync::{Arc, Mutex};
#[cfg(target_os = "linux")]
use cpal::traits::{DeviceTrait, HostTrait};
#[cfg(all(
not(target_os = "linux"),
not(target_os = "ios"),
not(target_os = "macos"),
not(target_os = "android")
))]
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
#[cfg(all(
not(target_os = "linux"),
not(target_os = "ios"),
not(target_os = "macos"),
not(target_os = "android")
))]
use cpal::{SampleFormat, SizedSample};
#[cfg(all(
not(target_os = "linux"),
not(target_os = "ios"),
not(target_os = "macos"),
not(target_os = "android")
))]
use std::collections::hash_map::DefaultHasher;
#[cfg(all(
not(target_os = "linux"),
not(target_os = "ios"),
not(target_os = "macos"),
not(target_os = "android")
@@ -110,7 +103,6 @@ pub struct AudioDeviceInfo {
}
#[cfg(all(
not(target_os = "linux"),
not(target_os = "ios"),
not(target_os = "macos"),
not(target_os = "android")
@@ -120,7 +112,6 @@ fn desktop_device_id(device: &cpal::Device) -> Option<String> {
}
#[cfg(all(
not(target_os = "linux"),
not(target_os = "ios"),
not(target_os = "macos"),
not(target_os = "android")
@@ -132,7 +123,6 @@ fn short_device_id(id: &str) -> String {
}
#[cfg(all(
not(target_os = "linux"),
not(target_os = "ios"),
not(target_os = "macos"),
not(target_os = "android")
@@ -188,53 +178,29 @@ pub fn list_audio_devices() -> AudioDeviceList {
output_devices: Vec::new(),
};
let host = cpal::default_host();
#[cfg(target_os = "linux")]
{
let default_input_name = host.default_input_device().and_then(|device| {
device
.description()
.ok()
.map(|description| description.name().trim().to_owned())
.filter(|name| !name.is_empty())
});
let default_output_name = host.default_output_device().and_then(|device| {
device
.description()
.ok()
.map(|description| description.name().trim().to_owned())
.filter(|name| !name.is_empty())
});
list.input_devices =
crate::linux_pipewire_input::list_input_devices(default_input_name.as_deref());
list.output_devices =
crate::linux_pipewire_input::list_output_devices(default_output_name.as_deref());
}
#[cfg(not(target_os = "linux"))]
{
let default_in = host
.default_input_device()
.and_then(|device| desktop_device_id(&device));
if let Ok(devices) = host.input_devices() {
for d in devices {
if let Some(mut device) = describe_device(&d) {
device.is_default = default_in
.as_ref()
.is_some_and(|default_id| default_id == &device.id);
list.input_devices.push(device);
}
let default_in = host
.default_input_device()
.and_then(|device| desktop_device_id(&device));
let default_out = host
.default_output_device()
.and_then(|device| desktop_device_id(&device));
if let Ok(devices) = host.input_devices() {
for d in devices {
if let Some(mut device) = describe_device(&d) {
device.is_default = default_in
.as_ref()
.is_some_and(|default_id| default_id == &device.id);
list.input_devices.push(device);
}
}
let default_out = host
.default_output_device()
.and_then(|device| desktop_device_id(&device));
if let Ok(devices) = host.output_devices() {
for d in devices {
if let Some(mut device) = describe_device(&d) {
device.is_default = default_out
.as_ref()
.is_some_and(|default_id| default_id == &device.id);
list.output_devices.push(device);
}
}
if let Ok(devices) = host.output_devices() {
for d in devices {
if let Some(mut device) = describe_device(&d) {
device.is_default = default_out
.as_ref()
.is_some_and(|default_id| default_id == &device.id);
list.output_devices.push(device);
}
}
}
@@ -340,23 +306,31 @@ pub struct AudioEngine {
audio_processing_config: Arc<Mutex<crate::AudioProcessingConfig>>,
audio_processing_stats: Arc<crate::SharedAudioProcessingStats>,
audio_handler: Arc<Mutex<AudioHandler<SessionAudioId>>>,
client_volume_overrides: Arc<Mutex<HashMap<SessionAudioId, f32>>>,
#[cfg(any(target_os = "ios", target_os = "macos", target_os = "android"))]
voice_out_tx: mpsc::Sender<OutPacket>,
#[cfg(any(target_os = "ios", target_os = "macos", target_os = "android"))]
voice_activity_selector: Option<Arc<crate::TransmitModeSelector>>,
#[cfg(any(target_os = "ios", target_os = "macos", target_os = "android"))]
mic_gain: f32,
// Streams must be dropped to stop audio. Linux owns native
// PipeWire/PulseAudio threads; Windows owns cpal streams. On iOS both
// sides collapse into a single `IosVoiceUnit` (one VoiceProcessingIO
// AudioUnit hosts mic + speaker).
#[cfg(target_os = "linux")]
_input_stream: Mutex<Option<crate::linux_pipewire_input::LinuxInput>>,
#[cfg(target_os = "windows")]
#[cfg(any(target_os = "ios", target_os = "macos"))]
// Streams must be dropped to stop audio. Both are `!Send` because
// cpal's Stream isn't Send on some backends; we keep them in an
// Option wrapped by Mutex so stop() can move them out. On Linux
// the output side is `crate::sdl_output::SdlOutput` instead of a
// cpal Stream (see the SDD note inside `sdl_output.rs`); the
// same unsafe Send/Sync impl below covers both. On iOS both
// sides collapse into a single `IosVoiceUnit` (one
// VoiceProcessingIO AudioUnit hosts mic + speaker) — cpal is
// unused on iOS for the reasons documented in
// `ios_voice_unit.rs`.
#[cfg(all(
not(target_os = "ios"),
not(target_os = "macos"),
not(target_os = "android")
))]
_input_stream: Mutex<Option<cpal::Stream>>,
#[cfg(target_os = "linux")]
_output_stream: Mutex<Option<crate::linux_pipewire_input::LinuxOutput>>,
_output_stream: Mutex<Option<crate::sdl_output::SdlOutput>>,
#[cfg(all(
not(target_os = "linux"),
not(target_os = "ios"),
@@ -483,31 +457,6 @@ fn open_ios_voice_backend(
Ok(IosVoiceBackend::Vpio(unit))
}
fn apply_saved_client_volume(
audio_handler: &mut AudioHandler<SessionAudioId>,
client_volume_overrides: &Mutex<HashMap<SessionAudioId, f32>>,
client_id: SessionAudioId,
) {
let override_volume = match client_volume_overrides.lock() {
Ok(overrides) => overrides.get(&client_id).copied(),
Err(error) => {
tracing::warn!(
target: "chanora_audio",
client_id = client_id.0,
error = %error,
"client volume overrides lock poisoned — saved volume not applied"
);
None
}
};
if let Some(volume) = override_volume {
if let Some(queue) = audio_handler.get_mut_queues().get_mut(&client_id) {
queue.volume = volume;
}
}
}
impl AudioEngine {
#[cfg(target_os = "android")]
fn spawn_android_backend_event_task(
@@ -743,7 +692,7 @@ impl AudioEngine {
Self::start_with_gate_cpal(cfg, voice_out_tx, voice_in_rx, transmit_gate)
}
/// Non-Apple/non-Android implementation: native Linux voice I/O or cpal on Windows.
/// Non-Apple/non-Android implementation: cpal capture + (cpal | SDL2) output.
/// Kept as a separate function so the Apple and Android paths can
/// short-circuit at the top of `start_with_gate` without dragging a 200-line
/// cfg-gated block. Body is the pre-iOS-port code, unchanged
@@ -762,16 +711,7 @@ impl AudioEngine {
"starting audio engine: cpal host selected"
);
fn device_name(device: &cpal::Device) -> Option<String> {
device
.description()
.ok()
.map(|description| description.name().trim().to_owned())
.filter(|name| !name.is_empty())
}
/// Helper: find a device by stable id, falling back to default.
#[cfg(not(target_os = "linux"))]
fn find_device<DefaultFn, AllFn, Devices>(
host: &cpal::Host,
default_fn: DefaultFn,
@@ -795,30 +735,28 @@ impl AudioEngine {
default_fn(host)
}
#[cfg(target_os = "linux")]
let in_dev = host.default_input_device();
#[cfg(not(target_os = "linux"))]
let in_dev = find_device(
&host,
cpal::Host::default_input_device,
cpal::Host::input_devices,
cfg.input_device_id.as_deref(),
);
)
.ok_or(AudioError::NoInputDevice)?;
#[cfg(target_os = "linux")]
let out_dev = host.default_output_device();
#[cfg(not(target_os = "linux"))]
let out_dev = find_device(
&host,
cpal::Host::default_output_device,
cpal::Host::output_devices,
cfg.output_device_id.as_deref(),
);
)
.ok_or(AudioError::NoOutputDevice)?;
#[cfg(not(target_os = "linux"))]
let in_dev = in_dev.ok_or(AudioError::NoInputDevice)?;
#[cfg(not(target_os = "linux"))]
let out_dev = out_dev.ok_or(AudioError::NoOutputDevice)?;
info!(
target: "chanora_audio",
in_device = %in_dev.description().map(|d| d.name().to_owned()).unwrap_or_default(),
out_device = %out_dev.description().map(|d| d.name().to_owned()).unwrap_or_default(),
"starting audio engine"
);
// Log the cpal-reported default configs *before* trying to
// open streams, so a downstream stream-build failure can
@@ -830,29 +768,6 @@ impl AudioEngine {
// "Start audio button not work"). Promote what would
// otherwise be silent or laconic errors into structured
// log records the user can paste back.
#[cfg(target_os = "linux")]
if let Some(in_dev) = in_dev.as_ref() {
match in_dev.default_input_config() {
Ok(c) => info!(
target: "chanora_audio",
channels = c.channels(),
sample_rate = c.sample_rate(),
sample_format = ?c.sample_format(),
"default_input_config reported"
),
Err(e) => warn!(
target: "chanora_audio",
error = %e,
"default_input_config FAILED — capture will be disabled"
),
}
} else {
warn!(
target: "chanora_audio",
"cpal default_input_device unavailable; Linux PipeWire capture will be tried directly"
);
}
#[cfg(not(target_os = "linux"))]
match in_dev.default_input_config() {
Ok(c) => info!(
target: "chanora_audio",
@@ -867,24 +782,6 @@ impl AudioEngine {
"default_input_config FAILED — capture will be disabled"
),
}
#[cfg(target_os = "linux")]
if let Some(out_dev) = out_dev.as_ref() {
match out_dev.default_output_config() {
Ok(c) => info!(
target: "chanora_audio",
channels = c.channels(),
sample_rate = c.sample_rate(),
sample_format = ?c.sample_format(),
"default_output_config reported"
),
Err(e) => warn!(
target: "chanora_audio",
error = %e,
"default_output_config FAILED — output stream will fail to build"
),
}
}
#[cfg(not(target_os = "linux"))]
match out_dev.default_output_config() {
Ok(c) => info!(
target: "chanora_audio",
@@ -900,7 +797,6 @@ impl AudioEngine {
),
}
#[cfg(target_os = "windows")]
let transmit_flag_for_capture = transmit_gate.flag_arc();
let frames_sent = Arc::new(AtomicU32::new(0));
let frames_received = Arc::new(AtomicU32::new(0));
@@ -908,7 +804,6 @@ impl AudioEngine {
let output_muted = Arc::new(AtomicBool::new(false));
let audio_processing_config = Arc::new(Mutex::new(crate::AudioProcessingConfig::default()));
let audio_processing_stats = Arc::new(crate::SharedAudioProcessingStats::default());
let client_volume_overrides = Arc::new(Mutex::new(HashMap::new()));
// ---------- Capture ----------
// Capture is best-effort. If the platform default input
@@ -916,52 +811,24 @@ impl AudioEngine {
// headless null sources or for users who deny the mic
// permission) we log and continue — playback alone is
// still useful. PTT becomes a no-op in that case.
#[cfg(target_os = "linux")]
let capture_result = match crate::linux_pipewire_input::start_capture(
cfg.input_device_id.as_deref(),
in_dev.as_ref().and_then(device_name).as_deref(),
voice_out_tx.clone(),
transmit_gate.clone(),
frames_sent.clone(),
cfg.mic_gain,
) {
Ok((stream, device_info)) => Ok((Some(stream), true, device_info.name)),
Err(linux_error) => {
warn!(
target: "chanora_audio",
error = %linux_error,
"Linux native capture unavailable"
);
Err(linux_error)
}
};
#[cfg(target_os = "windows")]
let capture_result = try_open_capture(
&in_dev,
voice_out_tx,
transmit_flag_for_capture,
frames_sent.clone(),
cfg.mic_gain,
)
.map(|stream| {
(
Some(stream),
true,
device_name(&in_dev).unwrap_or_else(|| "cpal capture".to_string()),
)
});
let (input_stream, capture_active, capture_device_name) = match capture_result {
Ok((stream, active, device_name)) => (stream, active, device_name),
);
let (input_stream, capture_active) = match capture_result {
Ok(s) => (Some(s), true),
Err(e) => {
warn!(
target: "chanora_audio",
error = %e,
"capture stream unavailable; continuing with playback only"
);
(None, false, "<capture unavailable>".to_string())
(None, false)
}
};
#[cfg(target_os = "windows")]
if let Some(s) = &input_stream {
s.play()
.map_err(|e| AudioError::Backend(format!("input play: {e}")))?;
@@ -971,18 +838,21 @@ impl AudioEngine {
let audio_handler: Arc<Mutex<AudioHandler<SessionAudioId>>> =
Arc::new(Mutex::new(AudioHandler::new()));
// Linux uses native PipeWire/PulseAudio playback. PipeWire is primary;
// PulseAudio is fallback and can also be selected explicitly by id.
// Linux uses SDL2 for output (Qint / upstream tsclientlib
// pattern). cpal's Linux backend opens raw ALSA which routes
// through `dmix`+`plug` and produces audible crackling /
// popping on the 48 kHz → device-rate step. SDL2 on the same
// box routes through PipeWire's PA bridge (or PulseAudio)
// whose resampler is high-quality. We keep cpal on Windows
// and macOS — both have native backends (WASAPI / CoreAudio)
// without this problem. See `crates/chanora_audio/src/sdl_output.rs`
// for the full rationale.
#[cfg(target_os = "linux")]
let (output_stream, output_device_info) = crate::linux_pipewire_input::start_output(
cfg.output_device_id.as_deref(),
out_dev.as_ref().and_then(device_name).as_deref(),
let output_stream = crate::sdl_output::SdlOutput::start(
audio_handler.clone(),
output_gain.clone(),
output_muted.clone(),
)?;
#[cfg(target_os = "linux")]
let output_device_name = output_device_info.name;
#[cfg(not(target_os = "linux"))]
let output_stream = {
@@ -1057,20 +927,10 @@ impl AudioEngine {
.map_err(|e| AudioError::Backend(format!("output play: {e}")))?;
stream
};
#[cfg(not(target_os = "linux"))]
let output_device_name = device_name(&out_dev).unwrap_or_default();
info!(
target: "chanora_audio",
in_device = %capture_device_name,
out_device = %output_device_name,
"starting audio engine"
);
// ---------- Inbound forwarder ----------
let (shutdown_tx, mut shutdown_rx) = tokio::sync::oneshot::channel();
let handler_for_task = audio_handler.clone();
let client_volume_overrides_for_task = client_volume_overrides.clone();
let frames_received_for_task = frames_received.clone();
tokio::spawn(async move {
loop {
@@ -1087,11 +947,6 @@ impl AudioEngine {
if let Err(e) = h.handle_packet(id, v.packet) {
debug!(target: "chanora_audio", error = %e, "decode failed");
} else {
apply_saved_client_volume(
&mut h,
&client_volume_overrides_for_task,
id,
);
frames_received_for_task.fetch_add(1, Ordering::Relaxed);
}
}
@@ -1111,10 +966,6 @@ impl AudioEngine {
audio_processing_config,
audio_processing_stats,
audio_handler,
client_volume_overrides,
#[cfg(target_os = "linux")]
_input_stream: Mutex::new(input_stream),
#[cfg(target_os = "windows")]
_input_stream: Mutex::new(input_stream),
_output_stream: Mutex::new(Some(output_stream)),
shutdown_tx: Some(shutdown_tx),
@@ -1140,7 +991,6 @@ impl AudioEngine {
let output_muted = Arc::new(AtomicBool::new(false));
let audio_processing_config = Arc::new(Mutex::new(crate::AudioProcessingConfig::default()));
let audio_processing_stats = Arc::new(crate::SharedAudioProcessingStats::default());
let client_volume_overrides = Arc::new(Mutex::new(HashMap::new()));
let audio_handler: Arc<Mutex<AudioHandler<SessionAudioId>>> =
Arc::new(Mutex::new(AudioHandler::new()));
@@ -1287,7 +1137,6 @@ impl AudioEngine {
let (shutdown_tx, mut shutdown_rx) = tokio::sync::oneshot::channel();
let handler_for_task = audio_handler.clone();
let client_volume_overrides_for_task = client_volume_overrides.clone();
let frames_received_for_task = frames_received.clone();
tokio::spawn(async move {
loop {
@@ -1304,11 +1153,6 @@ impl AudioEngine {
if let Err(e) = h.handle_packet(id, v.packet) {
debug!(target: "chanora_audio", error = %e, "decode failed");
} else {
apply_saved_client_volume(
&mut h,
&client_volume_overrides_for_task,
id,
);
frames_received_for_task.fetch_add(1, Ordering::Relaxed);
}
}
@@ -1328,7 +1172,6 @@ impl AudioEngine {
audio_processing_config,
audio_processing_stats,
audio_handler,
client_volume_overrides,
voice_out_tx,
voice_activity_selector: cfg.voice_activity_selector.clone(),
mic_gain: cfg.mic_gain,
@@ -1381,7 +1224,6 @@ impl AudioEngine {
let output_muted = Arc::new(AtomicBool::new(false));
let audio_processing_config = Arc::new(Mutex::new(crate::AudioProcessingConfig::default()));
let audio_processing_stats = Arc::new(crate::SharedAudioProcessingStats::default());
let client_volume_overrides = Arc::new(Mutex::new(HashMap::new()));
let audio_handler: Arc<Mutex<AudioHandler<SessionAudioId>>> =
Arc::new(Mutex::new(AudioHandler::new()));
@@ -1416,7 +1258,6 @@ impl AudioEngine {
// waiting.
let (shutdown_tx, mut shutdown_rx) = tokio::sync::oneshot::channel();
let handler_for_task = audio_handler.clone();
let client_volume_overrides_for_task = client_volume_overrides.clone();
let frames_received_for_task = frames_received.clone();
tokio::spawn(async move {
loop {
@@ -1433,11 +1274,6 @@ impl AudioEngine {
if let Err(e) = h.handle_packet(id, v.packet) {
debug!(target: "chanora_audio", error = %e, "decode failed");
} else {
apply_saved_client_volume(
&mut h,
&client_volume_overrides_for_task,
id,
);
frames_received_for_task.fetch_add(1, Ordering::Relaxed);
}
}
@@ -1457,7 +1293,6 @@ impl AudioEngine {
audio_processing_config,
audio_processing_stats,
audio_handler,
client_volume_overrides,
voice_out_tx,
voice_activity_selector: cfg.voice_activity_selector.clone(),
mic_gain: cfg.mic_gain,
@@ -1477,8 +1312,12 @@ impl AudioEngine {
// common contract is that dropping the wrapper stops
// audio. iOS collapses input + output into one
// `IosVoiceUnit` (see `ios_voice_unit.rs`); every other
// platform has separate input + output stream owners.
#[cfg(any(target_os = "linux", target_os = "windows"))]
// platform has separate cpal input + cpal/SDL output.
#[cfg(all(
not(target_os = "ios"),
not(target_os = "macos"),
not(target_os = "android")
))]
{
let _ = self._input_stream.lock().unwrap().take();
let _ = self._output_stream.lock().unwrap().take();
@@ -1774,11 +1613,11 @@ impl AudioEngine {
}
/// Latest Android voice-audio diagnostics snapshot (SDD-112 item
/// 10 / SDD-113 item 7 / SDD-116 item 3). Returns `Some(...)`
/// 10 / SDD-113 item 7 / SDD-116 item 3). On non-Android targets
/// this always returns `None`. On Android it returns `Some(...)`
/// once `AndroidVoiceUnit::open()` has published a snapshot; the
/// slot is cleared on `close()` / `Drop`. Per SDD-090 the snapshot
/// contains only device-side technical scalars — no PII.
#[cfg(target_os = "android")]
/// slot is cleared on `close()` / `Drop`. Per SDD-090 the
/// snapshot contains only device-side technical scalars — no PII.
pub fn android_diagnostics(
&self,
) -> Option<crate::mobile_voice_backend::AndroidAudioDiagnostics> {
@@ -1830,25 +1669,6 @@ impl AudioEngine {
);
}
}
match self.client_volume_overrides.lock() {
Ok(mut overrides) => {
if (clamped - 1.0).abs() <= f32::EPSILON {
overrides.remove(&SessionAudioId(client_id));
} else {
overrides.insert(SessionAudioId(client_id), clamped);
}
}
Err(e) => {
tracing::warn!(
target: "chanora_audio",
client_id,
volume = clamped,
error = %e,
"set_client_volume: client volume overrides lock poisoned"
);
}
}
}
}
@@ -1860,7 +1680,7 @@ impl Drop for AudioEngine {
// ---------- Capture pipeline ----------
#[cfg(target_os = "windows")]
#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
fn try_open_capture(
in_dev: &cpal::Device,
voice_out_tx: mpsc::Sender<OutPacket>,
@@ -1879,8 +1699,9 @@ fn try_open_capture(
// jitter of WASAPI shared mode.
// * macOS / iOS: CoreAudio picks a HAL-friendly default;
// iOS RemoteIO rejects arbitrary buffer-size requests.
// * Linux does not reach this cpal capture path in production;
// it uses native PipeWire/PulseAudio capture instead.
// * Linux: same SDL2-vs-cpal split as the output path; we
// still use cpal for capture but leave Default since
// PipeWire's ALSA shim works well there.
let mut in_stream_cfg: cpal::StreamConfig = in_cfg.into();
#[cfg(target_os = "windows")]
{
@@ -1917,7 +1738,7 @@ fn try_open_capture(
}
#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
pub(crate) struct CaptureState {
struct CaptureState {
encoder: OpusEncoder,
in_sample_rate: u32,
in_channels: usize,
@@ -1956,7 +1777,7 @@ pub(crate) struct CaptureState {
#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
impl CaptureState {
pub(crate) fn new(
fn new(
encoder: OpusEncoder,
in_sample_rate: u32,
in_channels: usize,
@@ -1989,7 +1810,7 @@ impl CaptureState {
/// Consume an arbitrary-rate, multichannel cpal buffer; produce
/// 48 kHz mono frames; encode and send when `transmit_active`
/// is true (PTT engaged).
pub(crate) fn ingest<T: ToF32 + Copy>(&mut self, buf: &[T]) {
fn ingest<T: ToF32 + Copy>(&mut self, buf: &[T]) {
if !self.transmit_active.load(Ordering::Relaxed) {
// Drain accumulator while muted so we don't pop on PTT release.
self.pcm_accum.clear();
@@ -2138,7 +1959,7 @@ impl CaptureState {
/// Per-sample format conversion to f32 in the range [-1.0, 1.0].
#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
pub(crate) trait ToF32 {
trait ToF32 {
fn to_f32_sample(self) -> f32;
}
#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
@@ -2160,7 +1981,7 @@ impl ToF32 for u16 {
}
}
#[cfg(target_os = "windows")]
#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
fn build_input_stream<T>(
device: &cpal::Device,
config: &cpal::StreamConfig,