diff --git a/apps/chanora_flutter/ios/Runner/AppDelegate.swift b/apps/chanora_flutter/ios/Runner/AppDelegate.swift index 08764f5..40c5d93 100644 --- a/apps/chanora_flutter/ios/Runner/AppDelegate.swift +++ b/apps/chanora_flutter/ios/Runner/AppDelegate.swift @@ -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) } diff --git a/apps/chanora_flutter/lib/main.dart b/apps/chanora_flutter/lib/main.dart index 89a382f..773d15f 100644 --- a/apps/chanora_flutter/lib/main.dart +++ b/apps/chanora_flutter/lib/main.dart @@ -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, ], ); }, diff --git a/apps/chanora_flutter/lib/widgets/voice_bar.dart b/apps/chanora_flutter/lib/widgets/voice_bar.dart index ed5c366..73adabb 100644 --- a/apps/chanora_flutter/lib/widgets/voice_bar.dart +++ b/apps/chanora_flutter/lib/widgets/voice_bar.dart @@ -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 / diff --git a/apps/chanora_flutter/lib/widgets/voice_settings.dart b/apps/chanora_flutter/lib/widgets/voice_settings.dart index daf2134..9cb7c2a 100644 --- a/apps/chanora_flutter/lib/widgets/voice_settings.dart +++ b/apps/chanora_flutter/lib/widgets/voice_settings.dart @@ -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 { // 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, diff --git a/apps/chanora_flutter/macos/Runner/AppDelegate.swift b/apps/chanora_flutter/macos/Runner/AppDelegate.swift index b3c1761..a9b7950 100644 --- a/apps/chanora_flutter/macos/Runner/AppDelegate.swift +++ b/apps/chanora_flutter/macos/Runner/AppDelegate.swift @@ -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 } diff --git a/core/chanora_core/src/lib.rs b/core/chanora_core/src/lib.rs index 0fc5d57..f5d7858 100644 --- a/core/chanora_core/src/lib.rs +++ b/core/chanora_core/src/lib.rs @@ -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 diff --git a/crates/chanora_audio/src/engine.rs b/crates/chanora_audio/src/engine.rs index de046b9..0957333 100644 --- a/crates/chanora_audio/src/engine.rs +++ b/crates/chanora_audio/src/engine.rs @@ -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, diff --git a/crates/chanora_protocol/src/adapter.rs b/crates/chanora_protocol/src/adapter.rs index 5512d26..b6861a2 100644 --- a/crates/chanora_protocol/src/adapter.rs +++ b/crates/chanora_protocol/src/adapter.rs @@ -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) { + 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.