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:
@@ -35,6 +35,22 @@ import AVFoundation
|
|||||||
NSLog("chanora_flutter: AVAudioSession setup failed: \(error)")
|
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)
|
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -72,6 +72,10 @@ class ChanoraApp extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return MaterialApp(
|
return MaterialApp(
|
||||||
onGenerateTitle: (ctx) => AppL10n.of(ctx).appTitle,
|
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(
|
theme: ThemeData(
|
||||||
useMaterial3: true,
|
useMaterial3: true,
|
||||||
colorSchemeSeed: const Color(0xFF3F51B5),
|
colorSchemeSeed: const Color(0xFF3F51B5),
|
||||||
@@ -833,11 +837,11 @@ class _BetaHomeState extends State<_BetaHome> {
|
|||||||
onPressed: () => _onShowDiagnostics(context),
|
onPressed: () => _onShowDiagnostics(context),
|
||||||
),
|
),
|
||||||
if (_phase == _Phase.connected) ...[
|
if (_phase == _Phase.connected) ...[
|
||||||
IconButton(
|
// The previous Refresh action (Icons.refresh + _onRefresh)
|
||||||
tooltip: l10n.refreshAction,
|
// was removed in v1.0.0-rc.8 — the snapshot stream the
|
||||||
icon: const Icon(Icons.refresh),
|
// bridge pushes via BridgeEvent::SnapshotChanged keeps the
|
||||||
onPressed: _onRefresh,
|
// tree current automatically, and a manual refresh was a
|
||||||
),
|
// no-op from the user's perspective.
|
||||||
IconButton(
|
IconButton(
|
||||||
tooltip: l10n.disconnectAction,
|
tooltip: l10n.disconnectAction,
|
||||||
icon: const Icon(Icons.logout),
|
icon: const Icon(Icons.logout),
|
||||||
@@ -1006,9 +1010,19 @@ class _BetaHomeState extends State<_BetaHome> {
|
|||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
voiceBar,
|
// In narrow / one-column layout the Voice
|
||||||
const SizedBox(height: 12),
|
// 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),
|
Expanded(child: snapshotView),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
voiceBar,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -229,32 +229,17 @@ class VoiceBar extends StatelessWidget {
|
|||||||
// Row 3: PTT-only secondary content.
|
// Row 3: PTT-only secondary content.
|
||||||
//
|
//
|
||||||
// On hardware-keyboard hosts (Windows / macOS / Linux /
|
// On hardware-keyboard hosts (Windows / macOS / Linux /
|
||||||
// Web) this is a one-line bound-key + release-tail
|
// Web) this is a one-line bound-key + release-tail hint
|
||||||
// hint.
|
// sitting right under the mode badge.
|
||||||
//
|
//
|
||||||
// On touch-only hosts (iOS / iPadOS / Android) there is
|
// On touch-only hosts (iOS / iPadOS / Android) the
|
||||||
// no hardware key to bind, so we replace the hint with
|
// on-screen Push to Talk button is rendered AT THE
|
||||||
// a touch-and-hold on-screen PTT button driven by
|
// BOTTOM of the Voice Bar (see below) so it sits
|
||||||
// `_PttHoldButton`. The release-tail still applies; the
|
// closest to the user's thumb when the Voice Bar is
|
||||||
// small print below the button shows it for parity
|
// pinned to the bottom of a narrow-layout screen. The
|
||||||
// with the desktop hint line.
|
// release-tail value is folded into the small print
|
||||||
if (isPtt && _isTouchOnlyPttHost) ...[
|
// under the button rather than shown here.
|
||||||
const SizedBox(height: 8),
|
if (isPtt && !_isTouchOnlyPttHost)
|
||||||
_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)
|
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(left: 22, top: 2),
|
padding: const EdgeInsets.only(left: 22, top: 2),
|
||||||
child: Text(
|
child: Text(
|
||||||
@@ -288,12 +273,41 @@ class VoiceBar extends StatelessWidget {
|
|||||||
// bind-key flow through the Voice Bar's settings gear
|
// bind-key flow through the Voice Bar's settings gear
|
||||||
// (single configuration entry point — see the comment
|
// (single configuration entry point — see the comment
|
||||||
// on `onConfigure`).
|
// 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(
|
PttCapabilityBadge(
|
||||||
level: pttLevel,
|
level: pttLevel,
|
||||||
backendId: pttBackendId,
|
backendId: pttBackendId,
|
||||||
boundInputClass: pttBoundInputClass,
|
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
|
// Leave-voice button intentionally absent: TeamSpeak's
|
||||||
// model is "user is always in some channel", not
|
// model is "user is always in some channel", not
|
||||||
// Discord's join/leave-voice. To stop being heard /
|
// Discord's join/leave-voice. To stop being heard /
|
||||||
|
|||||||
@@ -1,11 +1,22 @@
|
|||||||
// Voice settings dialog (SDD-097). Surfaces a TransmitMode radio
|
// Voice settings dialog (SDD-097). Surfaces a TransmitMode radio
|
||||||
// group, a bind-key button, and a release-tail slider.
|
// 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 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../l10n/generated/app_localizations.dart';
|
import '../l10n/generated/app_localizations.dart';
|
||||||
import '../src/rust/api.dart' as rust;
|
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
|
/// Result returned by [`VoiceSettingsDialog`]. `null` indicates a
|
||||||
/// cancelled dialog.
|
/// cancelled dialog.
|
||||||
class VoiceSettingsResult {
|
class VoiceSettingsResult {
|
||||||
@@ -107,7 +118,15 @@ class _VoiceSettingsDialogState extends State<VoiceSettingsDialog> {
|
|||||||
// them entirely when the user has switched to a
|
// them entirely when the user has switched to a
|
||||||
// non-PTT mode so the dialog stays focused on what's
|
// non-PTT mode so the dialog stays focused on what's
|
||||||
// actually configurable for that mode.
|
// 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) ...[
|
if (_mode == rust.BridgeTransmitMode.ptt) ...[
|
||||||
|
if (!_isTouchOnlyPttHost) ...[
|
||||||
OutlinedButton.icon(
|
OutlinedButton.icon(
|
||||||
icon: const Icon(Icons.keyboard),
|
icon: const Icon(Icons.keyboard),
|
||||||
label: Text(l10n.voiceBindKeyAction),
|
label: Text(l10n.voiceBindKeyAction),
|
||||||
@@ -122,6 +141,7 @@ class _VoiceSettingsDialogState extends State<VoiceSettingsDialog> {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
|
],
|
||||||
Text(
|
Text(
|
||||||
l10n.voiceReleaseTailLabel,
|
l10n.voiceReleaseTailLabel,
|
||||||
style: theme.textTheme.titleSmall,
|
style: theme.textTheme.titleSmall,
|
||||||
|
|||||||
@@ -1,8 +1,22 @@
|
|||||||
import Cocoa
|
import Cocoa
|
||||||
import FlutterMacOS
|
import FlutterMacOS
|
||||||
|
import AVFoundation
|
||||||
|
|
||||||
@main
|
@main
|
||||||
class AppDelegate: FlutterAppDelegate {
|
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 {
|
override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -528,9 +528,55 @@ impl ChanoraSession {
|
|||||||
let mut guard = self.inner.lock().await;
|
let mut guard = self.inner.lock().await;
|
||||||
let state = guard.as_mut().ok_or(CoreError::NotConnected)?;
|
let state = guard.as_mut().ok_or(CoreError::NotConnected)?;
|
||||||
|
|
||||||
// Tear down any prior engine + controller. The controller
|
// Build the new engine BEFORE tearing down the old one so a
|
||||||
// must be torn down before the engine because its forwarder
|
// construction failure (e.g. CoreAudio rejects the stream
|
||||||
// task references the gate that lives in the engine.
|
// 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() {
|
if let Some(prev) = state.ptt_controller.take() {
|
||||||
prev.stop().await;
|
prev.stop().await;
|
||||||
}
|
}
|
||||||
@@ -538,20 +584,12 @@ impl ChanoraSession {
|
|||||||
prev.stop();
|
prev.stop();
|
||||||
}
|
}
|
||||||
|
|
||||||
let voice_out = state.protocol.voice_out();
|
// Rewire the session's long-lived selector to the new gate
|
||||||
let voice_in = state
|
// (SAD-083). The selector retains cached mode / hard-mute /
|
||||||
.protocol
|
// ptt_held so settings set before audio-start take effect
|
||||||
.take_voice_in()
|
// immediately.
|
||||||
.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())?;
|
|
||||||
self.voice_selector.replace_gate(gate.clone());
|
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
|
// Wire the PTT controller (SDD-088). It owns the platform
|
||||||
// backend, the active binding, and the capability watch
|
// backend, the active binding, and the capability watch
|
||||||
|
|||||||
@@ -347,16 +347,34 @@ impl AudioEngine {
|
|||||||
let out_format = out_cfg.sample_format();
|
let out_format = out_cfg.sample_format();
|
||||||
let dev_sample_rate = out_cfg.sample_rate().0;
|
let dev_sample_rate = out_cfg.sample_rate().0;
|
||||||
let dev_channels = out_cfg.channels() as usize;
|
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 {
|
let out_stream_cfg = cpal::StreamConfig {
|
||||||
channels: out_cfg.channels(),
|
channels: out_cfg.channels(),
|
||||||
sample_rate: out_cfg.sample_rate(),
|
sample_rate: out_cfg.sample_rate(),
|
||||||
buffer_size: cpal::BufferSize::Fixed(2048),
|
buffer_size,
|
||||||
};
|
};
|
||||||
info!(
|
info!(
|
||||||
target: "chanora_audio",
|
target: "chanora_audio",
|
||||||
dev_sample_rate,
|
dev_sample_rate,
|
||||||
dev_channels,
|
dev_channels,
|
||||||
buffer_size_frames = 2048,
|
buffer_size = ?buffer_size,
|
||||||
"output stream using device native config (no 48k force)"
|
"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_sample_rate = in_cfg.sample_rate().0;
|
||||||
let in_channels = in_cfg.channels() as usize;
|
let in_channels = in_cfg.channels() as usize;
|
||||||
let in_format = in_cfg.sample_format();
|
let in_format = in_cfg.sample_format();
|
||||||
// Same buffer-size rationale as the output stream — request a
|
// Buffer-size rationale (same shape as the output path):
|
||||||
// ~46 ms period on the capture side to give the Opus encoder
|
// * Windows: pin to 2048 frames to avoid the small-period
|
||||||
// realistic time to run inside the cpal callback without
|
// jitter of WASAPI shared mode.
|
||||||
// overrunning. cpal carries over the device's negotiated rate /
|
// * macOS / iOS: CoreAudio picks a HAL-friendly default;
|
||||||
// channels / sample-format from `in_cfg` via the From impl, then
|
// iOS RemoteIO rejects arbitrary buffer-size requests.
|
||||||
// we override only the buffer size.
|
// * 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();
|
let mut in_stream_cfg: cpal::StreamConfig = in_cfg.into();
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
{
|
||||||
in_stream_cfg.buffer_size = cpal::BufferSize::Fixed(2048);
|
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(
|
let opus_enc = OpusEncoder::new(
|
||||||
OpusSampleRate::Hz48000,
|
OpusSampleRate::Hz48000,
|
||||||
|
|||||||
@@ -332,6 +332,25 @@ impl ProtocolClient {
|
|||||||
self.voice_in_rx.lock().ok().and_then(|mut g| g.take())
|
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
|
/// Take the loss-notifier. Returns `None` if it has already been
|
||||||
/// taken. The supervisor in `chanora_core` consumes this to
|
/// taken. The supervisor in `chanora_core` consumes this to
|
||||||
/// drive auto-reconnect; nothing else should call it.
|
/// drive auto-reconnect; nothing else should call it.
|
||||||
|
|||||||
Reference in New Issue
Block a user