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
@@ -35,6 +35,22 @@ import AVFoundation
NSLog("chanora_flutter: AVAudioSession setup failed: \(error)")
}
// Request microphone access on first launch rather than waiting
// for the user's first voice-channel join. The latter is
// surprising: the user has only tapped "connect to server" and
// suddenly iOS pops the permission prompt because joining a
// text channel happens to trigger audio engine startup. Asking
// up-front matches user expectations for a voice-chat client.
//
// The request is asynchronous and non-blocking. If the user
// denies, voice_join will surface a clearer error later when
// the audio engine fails to open the input device. The
// permission state is cached by iOS so subsequent launches
// skip the prompt.
AVAudioSession.sharedInstance().requestRecordPermission { granted in
NSLog("chanora_flutter: microphone permission granted=\(granted)")
}
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
+21 -7
View File
@@ -72,6 +72,10 @@ class ChanoraApp extends StatelessWidget {
Widget build(BuildContext context) {
return MaterialApp(
onGenerateTitle: (ctx) => AppL10n.of(ctx).appTitle,
// No "DEBUG" banner in the top-right corner. This is purely
// cosmetic for the developer-build experience; release builds
// never render it regardless of this flag.
debugShowCheckedModeBanner: false,
theme: ThemeData(
useMaterial3: true,
colorSchemeSeed: const Color(0xFF3F51B5),
@@ -833,11 +837,11 @@ class _BetaHomeState extends State<_BetaHome> {
onPressed: () => _onShowDiagnostics(context),
),
if (_phase == _Phase.connected) ...[
IconButton(
tooltip: l10n.refreshAction,
icon: const Icon(Icons.refresh),
onPressed: _onRefresh,
),
// The previous Refresh action (Icons.refresh + _onRefresh)
// was removed in v1.0.0-rc.8 — the snapshot stream the
// bridge pushes via BridgeEvent::SnapshotChanged keeps the
// tree current automatically, and a manual refresh was a
// no-op from the user's perspective.
IconButton(
tooltip: l10n.disconnectAction,
icon: const Icon(Icons.logout),
@@ -1006,9 +1010,19 @@ class _BetaHomeState extends State<_BetaHome> {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
voiceBar,
const SizedBox(height: 12),
// In narrow / one-column layout the Voice
// Bar lives at the BOTTOM of the body so the
// PTT button (rendered as the Voice Bar's
// last element on touch-only mobile hosts)
// sits closest to the user's thumb. The
// channel tree fills the remaining space
// above. In wide mode (Row layout above)
// the Voice Bar is the left column with the
// channel tree on the right, so the
// ordering question doesn't apply.
Expanded(child: snapshotView),
const SizedBox(height: 12),
voiceBar,
],
);
},
+40 -26
View File
@@ -229,32 +229,17 @@ class VoiceBar extends StatelessWidget {
// Row 3: PTT-only secondary content.
//
// On hardware-keyboard hosts (Windows / macOS / Linux /
// Web) this is a one-line bound-key + release-tail
// hint.
// Web) this is a one-line bound-key + release-tail hint
// sitting right under the mode badge.
//
// On touch-only hosts (iOS / iPadOS / Android) there is
// no hardware key to bind, so we replace the hint with
// a touch-and-hold on-screen PTT button driven by
// `_PttHoldButton`. The release-tail still applies; the
// small print below the button shows it for parity
// with the desktop hint line.
if (isPtt && _isTouchOnlyPttHost) ...[
const SizedBox(height: 8),
_PttHoldButton(
active: levelActive,
onHeldChanged: onPttHeldChanged,
),
const SizedBox(height: 4),
Padding(
padding: const EdgeInsets.only(left: 4),
child: Text(
'${l10n.voiceReleaseTailLabel}: $releaseTailMs${l10n.voiceReleaseTailHint}',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
] else if (isPtt)
// On touch-only hosts (iOS / iPadOS / Android) the
// on-screen Push to Talk button is rendered AT THE
// BOTTOM of the Voice Bar (see below) so it sits
// closest to the user's thumb when the Voice Bar is
// pinned to the bottom of a narrow-layout screen. The
// release-tail value is folded into the small print
// under the button rather than shown here.
if (isPtt && !_isTouchOnlyPttHost)
Padding(
padding: const EdgeInsets.only(left: 22, top: 2),
child: Text(
@@ -288,12 +273,41 @@ class VoiceBar extends StatelessWidget {
// bind-key flow through the Voice Bar's settings gear
// (single configuration entry point — see the comment
// on `onConfigure`).
if (isPtt)
//
// Hidden on touch-only mobile hosts (iOS / iPadOS /
// Android) because the capability story there is always
// "L0Focused via on-screen button" and that's already
// visually obvious from the PTT button being on the
// bar. Showing a degraded-capability badge there would
// be redundant + confusing.
if (isPtt && !_isTouchOnlyPttHost)
PttCapabilityBadge(
level: pttLevel,
backendId: pttBackendId,
boundInputClass: pttBoundInputClass,
),
// On touch-only mobile hosts the Push to Talk button is
// the LAST element of the Voice Bar so it lands closest
// to the user's thumb when the Voice Bar is pinned to
// the bottom of a narrow-layout screen. The release-
// tail value sits above the button so the user sees
// how long their voice continues after they let go.
if (isPtt && _isTouchOnlyPttHost) ...[
const SizedBox(height: 4),
Center(
child: Text(
'${l10n.voiceReleaseTailLabel}: $releaseTailMs${l10n.voiceReleaseTailHint}',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
const SizedBox(height: 8),
_PttHoldButton(
active: levelActive,
onHeldChanged: onPttHeldChanged,
),
],
// Leave-voice button intentionally absent: TeamSpeak's
// model is "user is always in some channel", not
// Discord's join/leave-voice. To stop being heard /
@@ -1,11 +1,22 @@
// Voice settings dialog (SDD-097). Surfaces a TransmitMode radio
// group, a bind-key button, and a release-tail slider.
import 'dart:io' show Platform;
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/material.dart';
import '../l10n/generated/app_localizations.dart';
import '../src/rust/api.dart' as rust;
/// True when the host is a touch-only mobile platform without a
/// hardware keyboard the user would bind a PTT key on. Mirrors the
/// helper in `voice_bar.dart`.
bool get _isTouchOnlyPttHost {
if (kIsWeb) return false;
return Platform.isIOS || Platform.isAndroid;
}
/// Result returned by [`VoiceSettingsDialog`]. `null` indicates a
/// cancelled dialog.
class VoiceSettingsResult {
@@ -107,21 +118,30 @@ class _VoiceSettingsDialogState extends State<VoiceSettingsDialog> {
// them entirely when the user has switched to a
// non-PTT mode so the dialog stays focused on what's
// actually configurable for that mode.
//
// Additionally on touch-only mobile hosts (iOS / iPadOS
// / Android) there is no hardware keyboard to bind a
// key on — the VoiceBar renders an on-screen Push to
// Talk button instead. Hide the Bind Key affordance
// there but keep the release-tail slider since it
// still applies to the on-screen button's behaviour.
if (_mode == rust.BridgeTransmitMode.ptt) ...[
OutlinedButton.icon(
icon: const Icon(Icons.keyboard),
label: Text(l10n.voiceBindKeyAction),
onPressed: () {
Navigator.of(context).pop(
VoiceSettingsResult(
mode: _mode,
releaseTailMs: _releaseTail.round(),
bindKeyRequested: true,
),
);
},
),
const SizedBox(height: 8),
if (!_isTouchOnlyPttHost) ...[
OutlinedButton.icon(
icon: const Icon(Icons.keyboard),
label: Text(l10n.voiceBindKeyAction),
onPressed: () {
Navigator.of(context).pop(
VoiceSettingsResult(
mode: _mode,
releaseTailMs: _releaseTail.round(),
bindKeyRequested: true,
),
);
},
),
const SizedBox(height: 8),
],
Text(
l10n.voiceReleaseTailLabel,
style: theme.textTheme.titleSmall,
@@ -1,8 +1,22 @@
import Cocoa
import FlutterMacOS
import AVFoundation
@main
class AppDelegate: FlutterAppDelegate {
override func applicationDidFinishLaunching(_ notification: Notification) {
super.applicationDidFinishLaunching(notification)
// Ask for microphone access on launch rather than on first
// voice-channel join. Matches user expectations for a voice
// chat client and saves the user from a surprising prompt
// mid-flow. macOS caches the choice; subsequent launches skip
// the prompt.
AVCaptureDevice.requestAccess(for: .audio) { granted in
NSLog("chanora_flutter: microphone permission granted=\(granted)")
}
}
override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
return true
}
+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
+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.