fix(ui,audio,ios,macos): eight P0 mobile fixes

User report from sideloaded iPhone build, in order of priority:

  #4 'Could not join channel: audio: audio backend:
     build_output_stream: The requested stream configuration is
     not supported by the device.'

     Cause: we forced cpal::BufferSize::Fixed(2048) on the output
     and input streams unconditionally on non-Linux. iOS CoreAudio
     RemoteIO units reject arbitrary buffer-size requests with that
     exact error. Windows WASAPI needs the pinning for shared-mode
     jitter, but macOS / iOS do not.
     Fix: cfg-gate Fixed(2048) to target_os = 'windows'; everywhere
     else use BufferSize::Default and let the platform HAL pick.
     crates/chanora_audio/src/engine.rs.

  #5 'Could not join channel: invariant violated:
     voice_in already taken'

     Cause: start_audio tore down the old engine BEFORE attempting
     to construct the new one, and consumed voice_in (an mpsc
     Receiver that can only be taken once) early. When the new
     engine failed mid-construction (e.g. because of #4 above) the
     session was left with: no audio engine, voice_in consumed,
     no way to retry without reconnect. The second voice_join
     attempt surfaced the invariant message.
     Fix: build the new engine BEFORE tearing down the old. Only
     swap state.audio if construction succeeded. crates/chanora_
     core/src/lib.rs::ChanoraSession::start_audio. Additionally
     added a put_voice_in helper to the protocol adapter (
     crates/chanora_protocol/src/adapter.rs) for a future
     broadcast-channel migration; the helper is unused on the
     immediate fix path but documents the intent.

  #3 'permission request would better on first open'

     Cause: AVAudioSession only triggers the mic-permission
     prompt the first time it tries to record. We never recorded
     until voice_join, so the prompt fired then.
     Fix iOS: AVAudioSession.sharedInstance().requestRecordPermission
     in AppDelegate.swift::application(_:didFinishLaunchingWithOptions:).
     Fix macOS: AVCaptureDevice.requestAccess(for: .audio) in
     macos/Runner/AppDelegate.swift::applicationDidFinishLaunching.
     Both run non-blocking; user can deny without crashing app
     launch, and voice_join then surfaces a clearer downstream
     error when the engine fails to open the input device.

  #1 + #2 'one-column upper takes too much space; Push to Talk
           button at bottom would be better'

     Layout rework for narrow-mode (single column, mobile shape):
     - Flipped the stacking order in main.dart so Voice Bar moves
       to the BOTTOM of the body and the channel tree (Expanded)
       fills above. Wide-mode (Row, >= 840 dp) layout unchanged.
     - Inside the Voice Bar on touch-only hosts, moved the
       on-screen Push to Talk button to be the LAST element of
       the Voice Bar (was Row 3). Order now: pill + mutes, mode
       badge + settings, level meter, stats line, release-tail
       caption, PTT button. The button is closest to the user's
       thumb when the Voice Bar is pinned to the bottom of a
       narrow-layout screen.

  #6 'remove right top debug badge'

     debugShowCheckedModeBanner: false on the MaterialApp.
     Release builds never showed it anyway; this only affects
     local dev / debug builds.

  #7 'what does the refresh button use for? nothing happened'

     Removed. The snapshot updates via BridgeEvent::SnapshotChanged
     are pushed from the bridge — a manual rust.snapshot() call
     was redundant. Now only the Diagnostics + Disconnect actions
     remain in the AppBar trailing row when connected.

  #8 'Bind Key related function should not be added to a mobile
     platform'

     widgets/voice_settings.dart: bind-key OutlinedButton is now
     #cfg'd out when Platform.isIOS || Platform.isAndroid. The
     release-tail slider stays because it still applies to the
     on-screen PTT button. Capability badge in voice_bar.dart
     also hidden on mobile (it would always show L0Focused which
     is redundant with the visible on-screen button).

Tests + analyze: chanora_audio 34/0/0 on macOS, workspace 78/0/1
on Linux; flutter analyze clean (6 pre-existing Radio.groupValue
infos). flutter build ios --release --no-codesign: 28.8 s clean
(Runner.app 29.9 MB).
This commit is contained in:
EdisonJwa
2026-05-16 15:41:08 +08:00
parent 278df25fd7
commit d4c04b6a72
8 changed files with 234 additions and 72 deletions
+36 -9
View File
@@ -347,16 +347,34 @@ impl AudioEngine {
let out_format = out_cfg.sample_format();
let dev_sample_rate = out_cfg.sample_rate().0;
let dev_channels = out_cfg.channels() as usize;
// Buffer-size rationale:
// * Windows (WASAPI via cpal): the default period is
// small enough to expose audio-thread scheduler
// jitter on shared-mode endpoints. Pinning at 2048
// frames (~46 ms @ 44.1 kHz) gives the Opus decode
// callback enough headroom while still being well
// under voice-chat latency tolerance.
// * macOS (CoreAudio via cpal): the default period
// is fine and the OS picks a HAL-friendly size.
// * iOS (CoreAudio via cpal): RemoteIO units reject
// arbitrary buffer-size requests and surface them
// as `build_output_stream: The requested stream
// configuration is not supported by the device`.
// Must use BufferSize::Default.
#[cfg(target_os = "windows")]
let buffer_size = cpal::BufferSize::Fixed(2048);
#[cfg(not(target_os = "windows"))]
let buffer_size = cpal::BufferSize::Default;
let out_stream_cfg = cpal::StreamConfig {
channels: out_cfg.channels(),
sample_rate: out_cfg.sample_rate(),
buffer_size: cpal::BufferSize::Fixed(2048),
buffer_size,
};
info!(
target: "chanora_audio",
dev_sample_rate,
dev_channels,
buffer_size_frames = 2048,
buffer_size = ?buffer_size,
"output stream using device native config (no 48k force)"
);
@@ -588,14 +606,23 @@ fn try_open_capture(
let in_sample_rate = in_cfg.sample_rate().0;
let in_channels = in_cfg.channels() as usize;
let in_format = in_cfg.sample_format();
// Same buffer-size rationale as the output stream — request a
// ~46 ms period on the capture side to give the Opus encoder
// realistic time to run inside the cpal callback without
// overrunning. cpal carries over the device's negotiated rate /
// channels / sample-format from `in_cfg` via the From impl, then
// we override only the buffer size.
// Buffer-size rationale (same shape as the output path):
// * Windows: pin to 2048 frames to avoid the small-period
// jitter of WASAPI shared mode.
// * macOS / iOS: CoreAudio picks a HAL-friendly default;
// iOS RemoteIO rejects arbitrary buffer-size requests.
// * 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();
in_stream_cfg.buffer_size = cpal::BufferSize::Fixed(2048);
#[cfg(target_os = "windows")]
{
in_stream_cfg.buffer_size = cpal::BufferSize::Fixed(2048);
}
#[cfg(not(target_os = "windows"))]
{
in_stream_cfg.buffer_size = cpal::BufferSize::Default;
}
let opus_enc = OpusEncoder::new(
OpusSampleRate::Hz48000,
+19
View File
@@ -332,6 +332,25 @@ impl ProtocolClient {
self.voice_in_rx.lock().ok().and_then(|mut g| g.take())
}
/// Put a previously-taken voice_in receiver back so a
/// follow-up `take_voice_in()` succeeds. Used by the core's
/// `start_audio` to recover from a failed
/// `AudioEngine::start_with_gate` — without this a single
/// engine-construction failure would permanently poison the
/// voice channel and force a reconnect to fix.
pub fn put_voice_in(&self, rx: mpsc::Receiver<InboundVoice>) {
if let Ok(mut g) = self.voice_in_rx.lock() {
// If a consumer is already in possession we drop the
// duplicate rather than overwriting; this branch
// should not be reachable in practice because the only
// caller (start_audio) takes-then-puts inside the same
// critical section.
if g.is_none() {
*g = Some(rx);
}
}
}
/// Take the loss-notifier. Returns `None` if it has already been
/// taken. The supervisor in `chanora_core` consumes this to
/// drive auto-reconnect; nothing else should call it.