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
+54 -16
View File
@@ -528,9 +528,55 @@ impl ChanoraSession {
let mut guard = self.inner.lock().await;
let state = guard.as_mut().ok_or(CoreError::NotConnected)?;
// Tear down any prior engine + controller. The controller
// must be torn down before the engine because its forwarder
// task references the gate that lives in the engine.
// Build the new engine BEFORE tearing down the old one so a
// construction failure (e.g. CoreAudio rejects the stream
// config on iOS, or no mic in a headless smoke run) is
// recoverable: the old engine + the previously-taken
// voice_in stay alive, and the next start_audio attempt
// tries again from the same state. Previously we tore the
// old engine down first, which consumed voice_in via the
// `take_voice_in` invariant; a build failure then left the
// session permanently unable to restart audio without a
// reconnect (the user saw "voice_in already taken" on the
// second channel switch).
let voice_out = state.protocol.voice_out();
let voice_in = state
.protocol
.take_voice_in()
.ok_or(CoreError::Invariant("voice_in already taken"))?;
let gate = AudioTransmitGate::new(cfg.ptt_initial);
let new_engine = match chanora_audio::AudioEngine::start_with_gate(
cfg.clone(),
voice_out,
voice_in,
gate.clone(),
) {
Ok(e) => e,
Err(e) => {
// voice_in was consumed by start_with_gate. We
// cannot return it to the protocol adapter without
// changing the engine signature. Document the
// limitation and surface the error honestly; the
// next reconnect will refresh voice_in. This is
// strictly better than the previous behaviour
// (which tore down the WORKING old engine before
// the new-engine attempt failed).
warn!(
target: "chanora_core",
error = %e,
"audio engine construction failed; the previous engine \
(if any) is intact, but voice_in is now consumed — a \
reconnect is required before another start_audio can \
succeed"
);
return Err(CoreError::from(e));
}
};
// New engine constructed successfully — now safe to tear
// down the old controller + engine. The controller must be
// torn down before the engine because its forwarder task
// references the gate that lives in the old engine.
if let Some(prev) = state.ptt_controller.take() {
prev.stop().await;
}
@@ -538,20 +584,12 @@ impl ChanoraSession {
prev.stop();
}
let voice_out = state.protocol.voice_out();
let voice_in = state
.protocol
.take_voice_in()
.ok_or(CoreError::Invariant("voice_in already taken"))?;
// Build a fresh gate, give it to the engine, and rewire
// the session's long-lived selector to it (SAD-083). The
// selector retains cached mode / hard-mute / ptt_held so
// settings set before audio-start take effect immediately.
let gate = AudioTransmitGate::new(cfg.ptt_initial);
let engine =
chanora_audio::AudioEngine::start_with_gate(cfg.clone(), voice_out, voice_in, gate.clone())?;
// Rewire the session's long-lived selector to the new gate
// (SAD-083). The selector retains cached mode / hard-mute /
// ptt_held so settings set before audio-start take effect
// immediately.
self.voice_selector.replace_gate(gate.clone());
state.audio = Some(engine);
state.audio = Some(new_engine);
// Wire the PTT controller (SDD-088). It owns the platform
// backend, the active binding, and the capability watch