* feat(audio): add Silero ONNX VAD with WebRTC fallback Introduce SileroOnnxVad and SileroOnnxVadWorker for desktop targets. The worker runs Silero v6 ONNX inference on a dedicated thread, accumulating 10 ms frames into the 512-sample 16 kHz input the model expects. Add VadOutput, VoiceActivityDetector trait, and WebRtcFallbackVad to provide a uniform VAD interface with graceful fallback when the ONNX model is unavailable. Wire the new VadBackend variants through AudioProcessingConfig and the snapshot stats so the bridge can report which detector is active. * feat(audio): integrate desktop VAD worker into capture engine Wire SileroOnnxVadWorker into the desktop capture path so voice activity can open the transmit gate before encoding. The capture callback now processes all audio through resample, downmix, and VAD unconditionally; transmit_active still gates Opus encoding. Add new_desktop_audio_processing_state() to construct the config/stats/worker triple, and apply_desktop_vad_backend() to synchronously load or clear the worker on config changes. Override processing_backend to Noop for desktop so bridge diagnostics report the correct backend rather than the iOS-oriented PlatformVoiceProcessing default. Includes review-driven cleanups: StreamConfig clone to deref per clippy, and a comment explaining why two try_lock calls on silero_vad_worker are structurally necessary (borrow checker requires the policy probe and the fallback path to not share a lock guard because mark_vad_fallback_active takes &mut self). * fix(audio): modernize Windows PTT to current windows-rs API Port the Raw Input plus low-level keyboard hook PTT backend to the newer windows-rs patterns: OptionalHandle, Result-returning CreateWindowExW, and None for CallNextHookEx. Replaces the old HHOOK(0) pointer casts. Add deterministic tests for mouse button 4 and 5 press and release driving the gate. * build(windows): force MSVC release CRT for audiopus cmake builds audiopus_sys calls cmake::build(opus_path), so downstream Cargo env cannot use cmake-rs Config::define() to override CMake's MSVC Debug CRT defaults. Point cmake-rs at a small wrapper that injects the policy and cache variables during configure while passing cmake --build, --version, and -E through unchanged. Keeps Opus Debug builds on Rust's release dynamic CRT (/MD) instead of CMake's default debug CRT (/MDd), which otherwise pulls in unresolved __imp__CrtDbgReportW symbols at test link. Document that the iOS deployment target is intentionally absent from this file. It is enforced by tools/build-ios.sh and the Xcode project; setting it globally here would make native macOS cargo check runs try to link iPhone objects against the macOS SDK. * build(flutter): update pubspec.lock after plugin additions Regenerated lockfile reflecting the local_notifications and connectivity_plus plugin additions from the poke-notifications feature. * fix(audio): address PR #37 review findings Six fixes from independent PR review: 1. BLOCKER: Replace Windows-only cmake .cmd wrapper with cross-platform CMake env vars. Setting CMAKE=tools/cmake-msvc-release-crt.cmd globally broke non-Windows hosts because cmake-rs would try to execute a .cmd file on macOS/Linux. Instead, set CMAKE_POLICY_DEFAULT_CMP0091=NEW and CMAKE_MSVC_RUNTIME_LIBRARY= MultiThreadedDLL as env vars that CMake reads natively. MSVC- specific vars are safely ignored by GCC/Clang toolchains. Delete the now-unnecessary wrapper script. 2. IMPORTANT: Join the Silero worker thread in Drop instead of detaching it. The old code dropped the JoinHandle which detaches the thread; the new code calls handle.join() after closing the channel, ensuring the ONNX session is cleaned up before the worker is replaced during config changes. 3. IMPORTANT: Single-try_lock refactor of the capture VAD callback. The double try_lock (policy probe + send) is replaced by a single scoped try_lock that both probes availability and sends the frame. The guard is dropped before the fallback path, which needs &mut self for mark_vad_fallback_active. This also eliminates the VadWorkerPolicy enum and callback_vad_worker_policy function, whose behavior is now inlined into the callback. 4. IMPORTANT: Remove tracing from the realtime capture callback. mark_vad_fallback_active and sync_vad_backend emitted info!/warn! from the audio thread. Replace with silent atomic state publishing via SharedAudioProcessingStats; the bridge stats stream already exposes vad_fallback_active for diagnostics. 5. IMPORTANT: Defer ONNX model load outside the worker mutex. apply_desktop_vad_backend_to_worker now constructs the new worker before taking the lock, then swaps it in under a short hold. This prevents the realtime callback from being blocked during model I/O + thread spawn. 6. MINOR: Remove unused VadBackend import from vad/mod.rs after deleting the policy code. * fix(audio): address PR #37 second-pass review findings 5-agent review found 5 blocking issues. All addressed: 1. BLOCKER: CMake env vars don't reach CMake cache. Restored .cmd wrapper but scoped to Windows MSVC targets only via [target.x86_64-pc-windows-msvc] and [target.aarch64-pc-windows-msvc] in .cargo/config.toml. Non-Windows hosts are unaffected. 2. BLOCKER: processing_backend normalized in set_audio_processing_config on desktop (cfg-gated override to Noop), mirroring startup default. 3. BLOCKER: Model-path reload was already wired via reload_audio_processing_config. Fixed misleading doc comment in core/lib.rs. 4. BLOCKER: DEC-030 updated to reflect desktop VoiceActivity enablement. Traceability docs (SRS, SysDes, SAD, SDD, implementation-status) updated. 5. Silero ONNX cfg narrowed to desktop-only (excludes macOS/Android). Cargo.toml ort dependency target cfg narrowed similarly. 6. Realtime callback debt documented as TODO at CaptureState::ingest. * fix(audio): exclude ort dep on Android target ort does not provide first-class Android prebuilts in our pin, mirror the iOS/macOS exclusion so cargo metadata succeeds for android targets. * test(audio): fix stale select_ptt_backend import in ptt_privacy The helper moved out of the ptt_backends submodule onto the crate root; update the integration test imports so the test compiles again. * build(windows): scope MSVC release CRT cmake wrapper via Cargo [env] Cargo's [target.<triple>] table only forwards a fixed allowlist (linker, runner, rustflags, rustdocflags, ar), so setting CMAKE there was silently dropped and audiopus_sys kept linking the debug CRT, producing LNK4098 'MSVCRTD conflicts' and __imp__CrtDbgReportW errors on x86_64-pc-windows-msvc test builds. Move the override to Cargo's [env] table using cc/cmake-rs's target-suffixed CMAKE_<triple> lookup (force=true, relative=true) so it applies to MSVC targets only and not to host tooling. Add stdout markers to the wrapper so its invocation is provable in cargo -vv logs. Verified: cargo test -p chanora_audio --target x86_64-pc-windows-msvc --lib --no-run now links cleanly; CMakeCache.txt records CMAKE_MSVC_RUNTIME_LIBRARY=MultiThreadedDLL and CMP0091=NEW. * fix(flutter): gate VoiceActivity transmit mode by platform support VoiceActivity relies on the native VAD worker, which is only wired up on Windows, Linux, and Android. Showing the option on iOS, macOS, or web let users select a mode that silently never transmitted. Add voiceActivityTransmitAvailable + transmitModeSegmentsFor() helpers in voice_settings_controls.dart, hide the VAD row in voice_compact.dart and drop the VAD segment from the settings dialog when unsupported. Keep the legacy const transmitModeSegments for the existing widget test and add two new tests covering the gated helper.
1029 lines
34 KiB
Dart
1029 lines
34 KiB
Dart
// Compact voice UI for narrow / mobile layouts (Plan E hybrid:
|
|
// AppBar mutes + status chip with 2-line live readout + wide bottom-
|
|
// anchored PTT button + modal sheet for non-essential controls).
|
|
//
|
|
// rc.8 follow-up: the AppBar gear icon was removed; the modal sheet
|
|
// is now the **single** voice-controls surface on mobile. Mode +
|
|
// release-tail are surfaced inline (radio buttons + slider) inside
|
|
// the modal.
|
|
|
|
import 'dart:async' show StreamSubscription, Timer, unawaited;
|
|
import 'dart:io' show Platform;
|
|
|
|
import 'package:flutter/foundation.dart' show kIsWeb;
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:haptic_kit/haptic_kit.dart' show Haptics;
|
|
|
|
import '../l10n/generated/app_localizations.dart';
|
|
import 'audio_output_tile.dart';
|
|
import 'audio_processing_config_state.dart';
|
|
import 'ptt_capability_badge.dart';
|
|
import 'talk_power_warning.dart';
|
|
import 'voice_haptics.dart';
|
|
import 'voice_level_meter.dart';
|
|
import 'voice_settings_controls.dart';
|
|
import 'voice_status_summary.dart';
|
|
import '../src/rust/api.dart' as rust;
|
|
|
|
bool get _isIos {
|
|
if (kIsWeb) return false;
|
|
return Platform.isIOS;
|
|
}
|
|
|
|
bool get _isMacOS {
|
|
if (kIsWeb) return false;
|
|
return Platform.isMacOS;
|
|
}
|
|
|
|
bool get _isDesktopSileroVadHost {
|
|
if (kIsWeb) return false;
|
|
return Platform.isWindows || Platform.isLinux;
|
|
}
|
|
|
|
/// Two-line status chip that summarises the current voice state.
|
|
/// Tap to open the voice details modal.
|
|
class VoiceStatusChip extends StatelessWidget {
|
|
/// Construct a status chip.
|
|
const VoiceStatusChip({
|
|
super.key,
|
|
required this.transmitMode,
|
|
required this.releaseTailMs,
|
|
required this.pttBoundKeyLabel,
|
|
required this.audioStats,
|
|
required this.isTouchOnly,
|
|
required this.onTap,
|
|
required this.onToggleInputMute,
|
|
required this.onToggleOutputMute,
|
|
this.inputMuted = false,
|
|
this.outputMuted = false,
|
|
this.hardMuteByTalkPower = false,
|
|
this.talkPower,
|
|
this.neededTalkPower,
|
|
this.talkPowerGranted,
|
|
});
|
|
|
|
/// Current transmit mode.
|
|
final rust.BridgeTransmitMode transmitMode;
|
|
|
|
/// Release-tail in milliseconds.
|
|
final int releaseTailMs;
|
|
|
|
/// Bound key label (empty on touch-only hosts).
|
|
final String pttBoundKeyLabel;
|
|
|
|
/// Current audio stats; null while audio engine not running.
|
|
final rust.BridgeAudioStats? audioStats;
|
|
|
|
/// True on iOS / iPadOS / Android.
|
|
final bool isTouchOnly;
|
|
|
|
/// True when local mic is muted (hard mute or permission mute).
|
|
final bool inputMuted;
|
|
|
|
/// True when local speaker is muted.
|
|
final bool outputMuted;
|
|
|
|
/// True when the server talk-power gate forces local hard mute.
|
|
final bool hardMuteByTalkPower;
|
|
|
|
/// Own client's talk power.
|
|
final int? talkPower;
|
|
|
|
/// Talk power required to speak in current channel.
|
|
final int? neededTalkPower;
|
|
|
|
/// True when server granted talk power regardless of numeric value.
|
|
final bool? talkPowerGranted;
|
|
|
|
/// Open the voice details modal.
|
|
final VoidCallback onTap;
|
|
|
|
/// Toggle local input hard mute.
|
|
final VoidCallback onToggleInputMute;
|
|
|
|
/// Toggle local output mute/deafen.
|
|
final VoidCallback onToggleOutputMute;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
final l10n = AppL10n.of(context);
|
|
|
|
final summary = voiceStatusSummary(
|
|
l10n: l10n,
|
|
transmitMode: transmitMode,
|
|
releaseTailMs: releaseTailMs,
|
|
pttBoundKeyLabel: pttBoundKeyLabel,
|
|
isTouchOnly: isTouchOnly,
|
|
inputMuted: inputMuted,
|
|
outputMuted: outputMuted,
|
|
pttActive: audioStats?.pttActive ?? false,
|
|
talkPower: talkPower,
|
|
neededTalkPower: neededTalkPower,
|
|
talkPowerGranted: talkPowerGranted,
|
|
);
|
|
|
|
return Semantics(
|
|
label: '${l10n.voiceSheetTitle}: ${summary.line1}, ${summary.line2}',
|
|
child: Material(
|
|
type: MaterialType.transparency,
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
|
decoration: BoxDecoration(
|
|
color: summary.talkPowerBlocked
|
|
? Colors.amber.withValues(alpha: 0.18)
|
|
: summary.muted
|
|
? theme.colorScheme.errorContainer.withValues(alpha: 0.35)
|
|
: theme.colorScheme.surfaceContainerHigh,
|
|
borderRadius: BorderRadius.circular(12),
|
|
border: Border.all(
|
|
color: summary.talkPowerBlocked
|
|
? Colors.amber.shade700
|
|
: summary.muted
|
|
? theme.colorScheme.error
|
|
: theme.colorScheme.outlineVariant,
|
|
width: summary.talkPowerBlocked || summary.muted ? 1.5 : 0.5,
|
|
),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Icon(
|
|
summary.micOn
|
|
? Icons.fiber_manual_record
|
|
: Icons.fiber_manual_record_outlined,
|
|
size: 12,
|
|
color: summary.micOn
|
|
? theme.colorScheme.primary
|
|
: theme.colorScheme.outline,
|
|
),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: InkWell(
|
|
onTap: () {
|
|
HapticFeedback.lightImpact();
|
|
onTap();
|
|
},
|
|
borderRadius: BorderRadius.circular(8),
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 2),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text(
|
|
summary.line1,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: theme.textTheme.bodyMedium?.copyWith(
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
),
|
|
Text(
|
|
summary.line2,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: theme.textTheme.bodySmall?.copyWith(
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 4),
|
|
IconButton(
|
|
tooltip: hardMuteByTalkPower
|
|
? l10n.voiceTalkPowerBlocked
|
|
: l10n.voiceHardMuteLabel,
|
|
icon: Icon(inputMuted ? Icons.mic_off : Icons.mic),
|
|
color: inputMuted ? theme.colorScheme.error : null,
|
|
onPressed: hardMuteByTalkPower ? null : onToggleInputMute,
|
|
visualDensity: VisualDensity.compact,
|
|
constraints: const BoxConstraints.tightFor(
|
|
width: 40,
|
|
height: 40,
|
|
),
|
|
padding: EdgeInsets.zero,
|
|
style: const ButtonStyle(
|
|
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
|
),
|
|
),
|
|
IconButton(
|
|
tooltip: l10n.voiceOutputMuteLabel,
|
|
icon: Icon(outputMuted ? Icons.headset_off : Icons.headset),
|
|
color: outputMuted ? theme.colorScheme.error : null,
|
|
onPressed: onToggleOutputMute,
|
|
visualDensity: VisualDensity.compact,
|
|
constraints: const BoxConstraints.tightFor(
|
|
width: 40,
|
|
height: 40,
|
|
),
|
|
padding: EdgeInsets.zero,
|
|
style: const ButtonStyle(
|
|
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
|
),
|
|
),
|
|
IconButton(
|
|
tooltip: l10n.voiceSettingsTitle,
|
|
icon: Icon(
|
|
Icons.expand_less,
|
|
size: 18,
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
onPressed: onTap,
|
|
visualDensity: VisualDensity.compact,
|
|
constraints: const BoxConstraints.tightFor(
|
|
width: 40,
|
|
height: 40,
|
|
),
|
|
padding: EdgeInsets.zero,
|
|
style: const ButtonStyle(
|
|
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Wide, bottom-anchored push-to-talk button.
|
|
class VoicePttButton extends StatefulWidget {
|
|
/// Construct a PTT button.
|
|
const VoicePttButton({
|
|
super.key,
|
|
required this.active,
|
|
required this.onHeldChanged,
|
|
this.height = 56,
|
|
this.borderRadius = 16,
|
|
this.iconSize = 28,
|
|
this.iconGap = 12,
|
|
this.blurRadius = 16,
|
|
this.spreadRadius = 2,
|
|
this.listenForPan = false,
|
|
this.labelLetterSpacing = 0.4,
|
|
});
|
|
|
|
/// True while the engine reports the gate open.
|
|
final bool active;
|
|
|
|
/// Called with `true` on finger-down, `false` on finger-up or cancel.
|
|
final ValueChanged<bool> onHeldChanged;
|
|
|
|
/// Button height.
|
|
final double height;
|
|
|
|
/// Outer corner radius.
|
|
final double borderRadius;
|
|
|
|
/// Mic icon size.
|
|
final double iconSize;
|
|
|
|
/// Space between icon and label.
|
|
final double iconGap;
|
|
|
|
/// Active-state shadow blur radius.
|
|
final double blurRadius;
|
|
|
|
/// Active-state shadow spread radius.
|
|
final double spreadRadius;
|
|
|
|
/// Also react to pan start/end/cancel in addition to tap gestures.
|
|
final bool listenForPan;
|
|
|
|
/// Optional label letter spacing.
|
|
final double? labelLetterSpacing;
|
|
|
|
@override
|
|
State<VoicePttButton> createState() => _VoicePttButtonState();
|
|
}
|
|
|
|
class _VoicePttButtonState extends State<VoicePttButton> {
|
|
bool _pressed = false;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
prepareVoiceHaptics();
|
|
}
|
|
|
|
void _setHeld(bool held) {
|
|
if (_pressed == held) return;
|
|
setState(() => _pressed = held);
|
|
widget.onHeldChanged(held);
|
|
playVoicePttHaptic(held);
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
if (_pressed) {
|
|
_pressed = false;
|
|
widget.onHeldChanged(false);
|
|
}
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
final l10n = AppL10n.of(context);
|
|
final activeNow = _pressed || widget.active;
|
|
|
|
return Semantics(
|
|
button: true,
|
|
liveRegion: true,
|
|
label: activeNow ? l10n.pttTransmitting : l10n.pttHoldToTalk,
|
|
hint: l10n.pttHoldToTalkSemanticsHint,
|
|
child: GestureDetector(
|
|
behavior: HitTestBehavior.opaque,
|
|
onTapDown: (_) => _setHeld(true),
|
|
onTapUp: (_) => _setHeld(false),
|
|
onTapCancel: () => _setHeld(false),
|
|
onPanDown: widget.listenForPan ? (_) => _setHeld(true) : null,
|
|
onPanEnd: widget.listenForPan ? (_) => _setHeld(false) : null,
|
|
onPanCancel: widget.listenForPan ? () => _setHeld(false) : null,
|
|
child: ExcludeSemantics(
|
|
child: AnimatedContainer(
|
|
duration: const Duration(milliseconds: 80),
|
|
height: widget.height,
|
|
decoration: BoxDecoration(
|
|
color: activeNow
|
|
? theme.colorScheme.primary
|
|
: theme.colorScheme.primaryContainer,
|
|
borderRadius: BorderRadius.circular(widget.borderRadius),
|
|
boxShadow: activeNow
|
|
? [
|
|
BoxShadow(
|
|
color: theme.colorScheme.primary.withAlpha(100),
|
|
blurRadius: widget.blurRadius,
|
|
spreadRadius: widget.spreadRadius,
|
|
offset: const Offset(0, 2),
|
|
),
|
|
]
|
|
: null,
|
|
),
|
|
child: Center(
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(
|
|
activeNow ? Icons.mic : Icons.mic_none,
|
|
color: activeNow
|
|
? theme.colorScheme.onPrimary
|
|
: theme.colorScheme.onPrimaryContainer,
|
|
size: widget.iconSize,
|
|
),
|
|
SizedBox(width: widget.iconGap),
|
|
Text(
|
|
activeNow ? l10n.voiceMicOn : l10n.voiceModePtt,
|
|
style: theme.textTheme.titleMedium?.copyWith(
|
|
fontWeight: FontWeight.w600,
|
|
letterSpacing: widget.labelLetterSpacing,
|
|
color: activeNow
|
|
? theme.colorScheme.onPrimary
|
|
: theme.colorScheme.onPrimaryContainer,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Show the voice controls modal sheet — the single voice-controls
|
|
/// surface on mobile. Tiles:
|
|
/// 1. Audio output route picker (iOS native AVRoutePickerView /
|
|
/// Android Material 3 list). Mobile only.
|
|
/// 2. Mode radio buttons (PTT / Continuous / Voice Activity).
|
|
/// 3. Release-tail slider (PTT-only).
|
|
/// 4. Mic level meter + frame counts.
|
|
/// 5. Audio processing (NS · AEC · AGC · HPF · VAD) — all modes.
|
|
/// 6. PTT capability badge.
|
|
///
|
|
/// Mode + release-tail are inlined directly here instead of being
|
|
/// hidden behind an "Adjust" button → nested dialog. Single-screen
|
|
/// control panel, zero navigation depth. `onModeChanged` and
|
|
/// `onReleaseTailChanged` are debounced by the caller so users can
|
|
/// drag the slider freely.
|
|
Future<void> showVoiceDetailsSheet(
|
|
BuildContext context, {
|
|
required rust.BridgeTransmitMode transmitMode,
|
|
required int releaseTailMs,
|
|
required String pttBoundKeyLabel,
|
|
required String pttLevel,
|
|
required String pttBackendId,
|
|
required String pttBoundInputClass,
|
|
required bool isTouchOnly,
|
|
required rust.BridgeAudioProcessingConfig initialAudioConfig,
|
|
required ValueChanged<rust.BridgeTransmitMode> onModeChanged,
|
|
required ValueChanged<int> onReleaseTailChanged,
|
|
required ValueChanged<rust.BridgeAudioProcessingConfig> onAudioConfigChanged,
|
|
int? talkPower,
|
|
int? neededTalkPower,
|
|
bool? talkPowerGranted,
|
|
}) async {
|
|
await showModalBottomSheet<void>(
|
|
context: context,
|
|
showDragHandle: true,
|
|
isScrollControlled: true,
|
|
useSafeArea: true,
|
|
builder: (ctx) {
|
|
return DraggableScrollableSheet(
|
|
initialChildSize: 0.6,
|
|
minChildSize: 0.3,
|
|
maxChildSize: 0.95,
|
|
expand: false,
|
|
snap: true,
|
|
snapSizes: const [0.3, 0.6, 0.95],
|
|
builder: (ctx, scrollController) => _VoiceSheetBody(
|
|
scrollController: scrollController,
|
|
initialMode: transmitMode,
|
|
initialReleaseTailMs: releaseTailMs,
|
|
pttBoundKeyLabel: pttBoundKeyLabel,
|
|
pttLevel: pttLevel,
|
|
pttBackendId: pttBackendId,
|
|
pttBoundInputClass: pttBoundInputClass,
|
|
isTouchOnly: isTouchOnly,
|
|
initialAudioConfig: initialAudioConfig,
|
|
onModeChanged: onModeChanged,
|
|
onReleaseTailChanged: onReleaseTailChanged,
|
|
onAudioConfigChanged: onAudioConfigChanged,
|
|
talkPower: talkPower,
|
|
neededTalkPower: neededTalkPower,
|
|
talkPowerGranted: talkPowerGranted,
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
class _VoiceSheetBody extends StatefulWidget {
|
|
const _VoiceSheetBody({
|
|
required this.scrollController,
|
|
required this.initialMode,
|
|
required this.initialReleaseTailMs,
|
|
required this.pttBoundKeyLabel,
|
|
required this.pttLevel,
|
|
required this.pttBackendId,
|
|
required this.pttBoundInputClass,
|
|
required this.isTouchOnly,
|
|
required this.initialAudioConfig,
|
|
required this.onModeChanged,
|
|
required this.onReleaseTailChanged,
|
|
required this.onAudioConfigChanged,
|
|
this.talkPower,
|
|
this.neededTalkPower,
|
|
this.talkPowerGranted,
|
|
});
|
|
|
|
final ScrollController scrollController;
|
|
final rust.BridgeTransmitMode initialMode;
|
|
final int initialReleaseTailMs;
|
|
final String pttBoundKeyLabel;
|
|
final String pttLevel;
|
|
final String pttBackendId;
|
|
final String pttBoundInputClass;
|
|
final bool isTouchOnly;
|
|
final rust.BridgeAudioProcessingConfig initialAudioConfig;
|
|
final ValueChanged<rust.BridgeTransmitMode> onModeChanged;
|
|
final ValueChanged<int> onReleaseTailChanged;
|
|
final ValueChanged<rust.BridgeAudioProcessingConfig> onAudioConfigChanged;
|
|
final int? talkPower;
|
|
final int? neededTalkPower;
|
|
final bool? talkPowerGranted;
|
|
|
|
@override
|
|
State<_VoiceSheetBody> createState() => _VoiceSheetBodyState();
|
|
}
|
|
|
|
class _VoiceSheetBodyState extends State<_VoiceSheetBody> {
|
|
late rust.BridgeTransmitMode _mode = widget.initialMode;
|
|
late int _tail = widget.initialReleaseTailMs;
|
|
|
|
// Live stats — polled by this widget's own timer so TX/RX update
|
|
// in real time while the sheet is open, independent of the parent.
|
|
rust.BridgeAudioStats? _stats;
|
|
Timer? _statsTimer;
|
|
// Previous snapshot for computing per-second rates.
|
|
int _prevSent = 0;
|
|
int _prevReceived = 0;
|
|
int _txRate = 0; // frames/s
|
|
int _rxRate = 0; // frames/s
|
|
int _rateTickCount = 0;
|
|
|
|
late final AudioProcessingConfigState _audioProcessing;
|
|
double? _streamLevel;
|
|
StreamSubscription<double>? _levelSub;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_audioProcessing = AudioProcessingConfigState.fromConfig(
|
|
widget.initialAudioConfig,
|
|
);
|
|
|
|
_levelSub = rust.inputLevelStream().listen((level) {
|
|
if (mounted) setState(() => _streamLevel = level);
|
|
});
|
|
|
|
// Poll audio stats at 250 ms so TX/RX counters update in real time
|
|
// while the sheet is open.
|
|
_statsTimer = Timer.periodic(const Duration(milliseconds: 250), (_) async {
|
|
try {
|
|
final s = await rust.audioStats();
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_stats = s;
|
|
_rateTickCount++;
|
|
if (_rateTickCount >= 4) {
|
|
_txRate = s.framesSent - _prevSent;
|
|
_rxRate = s.framesReceived - _prevReceived;
|
|
_prevSent = s.framesSent;
|
|
_prevReceived = s.framesReceived;
|
|
_rateTickCount = 0;
|
|
}
|
|
});
|
|
} catch (_) {}
|
|
});
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_levelSub?.cancel();
|
|
_statsTimer?.cancel();
|
|
super.dispose();
|
|
}
|
|
|
|
rust.BridgeAudioProcessingConfig _buildConfig() {
|
|
return _audioProcessing.buildConfig(base: widget.initialAudioConfig);
|
|
}
|
|
|
|
void _notifyAudioConfig() {
|
|
widget.onAudioConfigChanged(_buildConfig());
|
|
}
|
|
|
|
void _setMode(rust.BridgeTransmitMode m) {
|
|
if (m == _mode) return;
|
|
setState(() => _mode = m);
|
|
unawaited(Haptics.selection().catchError((_) {}));
|
|
widget.onModeChanged(m);
|
|
}
|
|
|
|
void _setTail(double v) {
|
|
final ms = v.round();
|
|
setState(() => _tail = ms);
|
|
widget.onReleaseTailChanged(ms);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
final l10n = AppL10n.of(context);
|
|
final stats = _stats;
|
|
// Level meter active = transmitting (any mode).
|
|
final levelActive = switch (_mode) {
|
|
rust.BridgeTransmitMode.continuous => true,
|
|
_ => stats?.pttActive ?? false,
|
|
};
|
|
final isPtt = _mode == rust.BridgeTransmitMode.ptt;
|
|
|
|
// Route picker is mobile-only. iOS uses AVAudioSession below;
|
|
// Android uses AudioManager through a MethodChannel.
|
|
final showRoutePicker = !kIsWeb && (Platform.isIOS || Platform.isAndroid);
|
|
|
|
return SafeArea(
|
|
child: SingleChildScrollView(
|
|
controller: widget.scrollController,
|
|
padding: const EdgeInsets.fromLTRB(20, 8, 20, 24),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
Text(l10n.voiceSheetTitle, style: theme.textTheme.titleLarge),
|
|
const SizedBox(height: 12),
|
|
|
|
// 1) Audio output route picker tile (mobile only).
|
|
if (showRoutePicker) ...[
|
|
const AudioOutputTile(),
|
|
const SizedBox(height: 8),
|
|
Divider(height: 1, color: theme.colorScheme.outlineVariant),
|
|
const SizedBox(height: 8),
|
|
],
|
|
|
|
// Transmit mode.
|
|
Text(
|
|
l10n.voiceModeLabel,
|
|
style: theme.textTheme.labelLarge?.copyWith(
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
const SizedBox(height: 4),
|
|
_ModeRow(
|
|
label: l10n.voiceModePtt,
|
|
icon: Icons.radio_button_checked,
|
|
selected: _mode == rust.BridgeTransmitMode.ptt,
|
|
onTap: () => _setMode(rust.BridgeTransmitMode.ptt),
|
|
),
|
|
_ModeRow(
|
|
label: l10n.voiceModeContinuous,
|
|
icon: Icons.podcasts,
|
|
selected: _mode == rust.BridgeTransmitMode.continuous,
|
|
onTap: () => _setMode(rust.BridgeTransmitMode.continuous),
|
|
),
|
|
// Voice-activity transmit is only honoured by the engine on
|
|
// hosts that ship a Chanora-owned VAD pipeline (DEC-030:
|
|
// Windows + Linux desktop and Android). iOS / macOS rely
|
|
// on Apple VoiceProcessingIO and have no VAD bridge, so
|
|
// hiding the row prevents the UI from advertising a
|
|
// transmit mode the engine cannot honour.
|
|
if (voiceActivityTransmitAvailable)
|
|
_ModeRow(
|
|
label: l10n.voiceModeVoiceActivity,
|
|
icon: Icons.graphic_eq,
|
|
selected: _mode == rust.BridgeTransmitMode.voiceActivity,
|
|
onTap: () => _setMode(rust.BridgeTransmitMode.voiceActivity),
|
|
),
|
|
|
|
// 3) Release-tail slider (PTT only).
|
|
if (isPtt) ...[
|
|
const SizedBox(height: 8),
|
|
Row(
|
|
children: [
|
|
Text(
|
|
l10n.voiceReleaseTailLabel,
|
|
style: theme.textTheme.labelLarge?.copyWith(
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
const Spacer(),
|
|
Text(
|
|
'$_tail${l10n.voiceReleaseTailHint}',
|
|
style: theme.textTheme.bodyMedium?.copyWith(
|
|
fontFeatures: const [FontFeature.tabularFigures()],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
Slider(
|
|
value: _tail.toDouble().clamp(0, 500),
|
|
min: 0,
|
|
max: 500,
|
|
divisions: 20,
|
|
label: '$_tail ms',
|
|
onChanged: _setTail,
|
|
),
|
|
// Desktop-only: surface the bound key so the user
|
|
// sees what hardware key is wired. On mobile this
|
|
// row is suppressed (there is no hardware key; the
|
|
// PTT button is the input).
|
|
if (!widget.isTouchOnly &&
|
|
widget.pttBoundKeyLabel.isNotEmpty) ...[
|
|
Padding(
|
|
padding: const EdgeInsets.only(left: 4),
|
|
child: Text(
|
|
'${l10n.voiceBoundKeyLabel}: ${widget.pttBoundKeyLabel}',
|
|
style: theme.textTheme.bodySmall?.copyWith(
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
],
|
|
|
|
const SizedBox(height: 16),
|
|
Divider(height: 1, color: theme.colorScheme.outlineVariant),
|
|
const SizedBox(height: 12),
|
|
|
|
// 4) Level meter + live TX/RX stats.
|
|
VoiceLevelMeter(active: levelActive, level: _streamLevel ?? stats?.inputLevel),
|
|
const SizedBox(height: 6),
|
|
_StatsRow(
|
|
txRate: _txRate,
|
|
rxRate: _rxRate,
|
|
totalSent: stats?.framesSent ?? 0,
|
|
totalReceived: stats?.framesReceived ?? 0,
|
|
transmitting: levelActive,
|
|
),
|
|
|
|
if (isTalkPowerBlocked(
|
|
talkPower: widget.talkPower,
|
|
neededTalkPower: widget.neededTalkPower,
|
|
talkPowerGranted: widget.talkPowerGranted,
|
|
)) ...[
|
|
const SizedBox(height: 8),
|
|
TalkPowerWarning(
|
|
talkPower: widget.talkPower,
|
|
neededTalkPower: widget.neededTalkPower,
|
|
talkPowerGranted: widget.talkPowerGranted,
|
|
),
|
|
],
|
|
|
|
// Audio processing.
|
|
const SizedBox(height: 12),
|
|
Divider(height: 1, color: theme.colorScheme.outlineVariant),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
'Audio processing',
|
|
style: theme.textTheme.labelLarge?.copyWith(
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
const SizedBox(height: 4),
|
|
|
|
// Android HW/SW selector.
|
|
if (Platform.isAndroid) ...[
|
|
const VoiceSubHeader('Processing backend'),
|
|
SegmentedButton<bool>(
|
|
style: voiceSegmentedButtonStyle(theme),
|
|
segments: androidProcessingSegments,
|
|
selected: {_audioProcessing.preferHardware},
|
|
onSelectionChanged: (s) {
|
|
setState(() => _audioProcessing.preferHardware = s.first);
|
|
_notifyAudioConfig();
|
|
},
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
_audioProcessing.preferHardware
|
|
? 'Hardware mode still keeps per-stage WebRTC fallback, so these controls remain effective.'
|
|
: 'Software mode applies the full WebRTC APM stage set.',
|
|
style: theme.textTheme.bodySmall?.copyWith(
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
],
|
|
|
|
if (_isIos) ...[
|
|
Text(
|
|
'iOS uses Apple VoiceProcessingIO. WebRTC APM controls are '
|
|
'hidden here; only settings that still affect the shipping '
|
|
'iOS path are shown.',
|
|
style: theme.textTheme.bodySmall?.copyWith(
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
const SizedBox(height: 4),
|
|
],
|
|
if (!_isIos &&
|
|
(!Platform.isAndroid ||
|
|
androidShowsNsControl(_audioProcessing)))
|
|
AudioProcessingToggleRow(
|
|
dense: true,
|
|
label: 'Noise suppression',
|
|
subtitle: 'Wiener filter',
|
|
value: _audioProcessing.nsEnabled,
|
|
onChanged: (v) {
|
|
setState(() => _audioProcessing.nsEnabled = v);
|
|
_notifyAudioConfig();
|
|
},
|
|
),
|
|
if (!_isIos &&
|
|
(!Platform.isAndroid ||
|
|
androidShowsAecControl(_audioProcessing)))
|
|
AudioProcessingToggleRow(
|
|
dense: true,
|
|
label: 'Echo cancellation',
|
|
subtitle: Platform.isAndroid
|
|
? (_audioProcessing.preferHardware
|
|
? 'Prefers device/OS effect; falls back to WebRTC AEC3'
|
|
: 'WebRTC AEC3 · adaptive filter')
|
|
: (_isMacOS
|
|
? 'Managed by platform VPIO'
|
|
: 'WebRTC AEC3 · adaptive filter'),
|
|
value: _isMacOS ? true : _audioProcessing.aecEnabled,
|
|
onChanged: _isMacOS
|
|
? null
|
|
: (v) {
|
|
setState(() => _audioProcessing.aecEnabled = v);
|
|
_notifyAudioConfig();
|
|
},
|
|
),
|
|
if (!_isIos &&
|
|
(!Platform.isAndroid ||
|
|
androidShowsAgcControl(_audioProcessing)))
|
|
AudioProcessingToggleRow(
|
|
dense: true,
|
|
label: 'Auto gain control',
|
|
subtitle: 'AGC2 · -18 dBFS target',
|
|
value: _audioProcessing.agcEnabled,
|
|
onChanged: (v) {
|
|
setState(() => _audioProcessing.agcEnabled = v);
|
|
_notifyAudioConfig();
|
|
},
|
|
),
|
|
if (!Platform.isAndroid || androidShowsHpfControl(_audioProcessing))
|
|
AudioProcessingToggleRow(
|
|
dense: true,
|
|
label: 'High-pass filter',
|
|
subtitle: '80 Hz · DC removal',
|
|
value: _audioProcessing.hpfEnabled,
|
|
onChanged: (v) {
|
|
setState(() => _audioProcessing.hpfEnabled = v);
|
|
_notifyAudioConfig();
|
|
},
|
|
),
|
|
if (!_isIos &&
|
|
(!Platform.isAndroid ||
|
|
androidShowsLimiterControl(_audioProcessing)))
|
|
AudioProcessingToggleRow(
|
|
dense: true,
|
|
label: 'Peak limiter',
|
|
subtitle: '-1 dBFS soft-knee · 2 ms look-ahead',
|
|
value: _audioProcessing.limiterEnabled,
|
|
onChanged: (v) {
|
|
setState(() => _audioProcessing.limiterEnabled = v);
|
|
_notifyAudioConfig();
|
|
},
|
|
),
|
|
|
|
// VAD backend.
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
'Voice activity detection (VAD)',
|
|
style: theme.textTheme.labelLarge?.copyWith(
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
const SizedBox(height: 2),
|
|
SegmentedButton<rust.BridgeVadBackend>(
|
|
style: voiceSegmentedButtonStyle(theme),
|
|
segments: _isDesktopSileroVadHost
|
|
? desktopVadBackendSegments
|
|
: vadBackendSegments,
|
|
selected: {_audioProcessing.vadBackend},
|
|
onSelectionChanged: (s) {
|
|
setState(() => _audioProcessing.vadBackend = s.first);
|
|
_notifyAudioConfig();
|
|
},
|
|
),
|
|
|
|
// Debug.
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
'Debug',
|
|
style: theme.textTheme.labelLarge?.copyWith(
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
const SizedBox(height: 2),
|
|
AudioProcessingToggleRow(
|
|
dense: true,
|
|
label: 'WAV dump',
|
|
subtitle: 'Record raw/processed mic to temp dir',
|
|
value: _audioProcessing.debugWavDump,
|
|
onChanged: (v) {
|
|
setState(() => _audioProcessing.debugWavDump = v);
|
|
_notifyAudioConfig();
|
|
},
|
|
),
|
|
|
|
// 6) PTT capability badge. On iOS this must remain
|
|
// visible even though the resolved level is always
|
|
// `L0Focused`, because the P0 acceptance flow requires
|
|
// honest capability advertising with an explanation of
|
|
// the sandbox limitation.
|
|
if (isPtt) ...[
|
|
const SizedBox(height: 12),
|
|
PttCapabilityBadge(
|
|
level: widget.pttLevel,
|
|
backendId: widget.pttBackendId,
|
|
boundInputClass: widget.pttBoundInputClass,
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// ── Live TX/RX stats row ──────────────────────────────────────────────────
|
|
|
|
class _StatsRow extends StatelessWidget {
|
|
const _StatsRow({
|
|
required this.txRate,
|
|
required this.rxRate,
|
|
required this.totalSent,
|
|
required this.totalReceived,
|
|
required this.transmitting,
|
|
});
|
|
|
|
final int txRate;
|
|
final int rxRate;
|
|
final int totalSent;
|
|
final int totalReceived;
|
|
final bool transmitting;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
final txColor = transmitting
|
|
? theme.colorScheme.primary
|
|
: theme.colorScheme.onSurfaceVariant;
|
|
return Row(
|
|
children: [
|
|
// TX
|
|
Icon(Icons.upload, size: 12, color: txColor),
|
|
const SizedBox(width: 3),
|
|
Text(
|
|
'TX $txRate/s · $totalSent',
|
|
style: theme.textTheme.bodySmall?.copyWith(color: txColor),
|
|
),
|
|
const SizedBox(width: 12),
|
|
// RX
|
|
Icon(
|
|
Icons.download,
|
|
size: 12,
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
const SizedBox(width: 3),
|
|
Text(
|
|
'RX $rxRate/s · $totalReceived',
|
|
style: theme.textTheme.bodySmall?.copyWith(
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
// ── Audio processing helper widgets ──────────────────────────────────────
|
|
|
|
// ── Mode row ──────────────────────────────────────────────────────────────
|
|
|
|
class _ModeRow extends StatelessWidget {
|
|
const _ModeRow({
|
|
required this.label,
|
|
required this.icon,
|
|
required this.selected,
|
|
required this.onTap,
|
|
});
|
|
|
|
final String label;
|
|
final IconData icon;
|
|
final bool selected;
|
|
final VoidCallback? onTap;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
final disabled = onTap == null;
|
|
final color = disabled
|
|
? theme.colorScheme.onSurfaceVariant.withAlpha(120)
|
|
: selected
|
|
? theme.colorScheme.primary
|
|
: theme.colorScheme.onSurface;
|
|
return Semantics(
|
|
button: true,
|
|
selected: selected,
|
|
enabled: !disabled,
|
|
label: label,
|
|
child: InkWell(
|
|
onTap: onTap,
|
|
borderRadius: BorderRadius.circular(8),
|
|
child: ExcludeSemantics(
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 4),
|
|
child: Row(
|
|
children: [
|
|
Icon(
|
|
selected
|
|
? Icons.radio_button_checked
|
|
: Icons.radio_button_unchecked,
|
|
size: 20,
|
|
color: color,
|
|
),
|
|
const SizedBox(width: 12),
|
|
Icon(icon, size: 18, color: color),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
label,
|
|
style: theme.textTheme.bodyLarge?.copyWith(
|
|
color: color,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|