Files
chanora/apps/chanora_flutter/lib/widgets/voice_settings.dart
T

346 lines
13 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Voice settings dialog (SDD-097). Surfaces transmit mode, release
// tail, and the full P1 audio processing configuration:
// - Noise suppression (NS)
// - Echo cancellation (AEC3)
// - Automatic gain control (AGC2)
// - High-pass filter (HPF)
// - VAD backend
// - iOS voice processing mode
import 'dart:io' show Platform;
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/material.dart';
import '../l10n/generated/app_localizations.dart';
import 'audio_device_list_tile.dart';
import 'audio_output_tile.dart';
import 'audio_processing_config_state.dart';
import 'ptt_capability_badge.dart';
import 'talk_power_warning.dart';
import 'voice_platform.dart';
import 'voice_settings_controls.dart';
import '../src/rust/api.dart' as rust;
bool get _isAndroid {
if (kIsWeb) return false;
return Platform.isAndroid;
}
bool get _isIos {
if (kIsWeb) return false;
return Platform.isIOS;
}
/// Result returned by [VoiceSettingsDialog].
class VoiceSettingsResult {
const VoiceSettingsResult({
required this.mode,
required this.releaseTailMs,
required this.bindKeyRequested,
required this.audioConfig,
});
final rust.BridgeTransmitMode mode;
final int releaseTailMs;
final bool bindKeyRequested;
final rust.BridgeAudioProcessingConfig audioConfig;
}
/// Voice + audio processing settings dialog.
class VoiceSettingsDialog extends StatefulWidget {
const VoiceSettingsDialog({
super.key,
required this.initialMode,
required this.initialReleaseTailMs,
required this.initialAudioConfig,
this.pttLevel = '',
this.pttBackendId = '',
this.pttBoundInputClass = '',
this.talkPower,
this.neededTalkPower,
this.talkPowerGranted,
});
final rust.BridgeTransmitMode initialMode;
final int initialReleaseTailMs;
final rust.BridgeAudioProcessingConfig initialAudioConfig;
final String pttLevel;
final String pttBackendId;
final String pttBoundInputClass;
final int? talkPower;
final int? neededTalkPower;
final bool? talkPowerGranted;
@override
State<VoiceSettingsDialog> createState() => _VoiceSettingsDialogState();
}
class _VoiceSettingsDialogState extends State<VoiceSettingsDialog> {
late rust.BridgeTransmitMode _mode;
late double _releaseTail;
late final AudioProcessingConfigState _audioProcessing;
@override
void initState() {
super.initState();
_mode = widget.initialMode;
_releaseTail = widget.initialReleaseTailMs.clamp(0, 500).toDouble();
_audioProcessing = AudioProcessingConfigState.fromConfig(
widget.initialAudioConfig,
);
}
rust.BridgeAudioProcessingConfig _buildConfig() {
return _audioProcessing.buildConfig(
base: widget.initialAudioConfig,
isAndroid: _isAndroid,
);
}
@override
Widget build(BuildContext context) {
final l10n = AppL10n.of(context);
final theme = Theme.of(context);
final platformVpio =
_audioProcessing.iosMode ==
rust.BridgeIosVoiceProcessingMode.platformVoiceProcessing;
return AlertDialog(
title: Text(l10n.voiceSettingsTitle),
contentPadding: const EdgeInsets.fromLTRB(24, 16, 24, 0),
content: SizedBox(
width: 400,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// ── Transmit mode ──────────────────────────────────────
VoiceSectionHeader(l10n.voiceModeLabel),
SegmentedButton<rust.BridgeTransmitMode>(
style: voiceSegmentedButtonStyle(theme),
segments: transmitModeSegments,
selected: {_mode},
onSelectionChanged: (s) => setState(() => _mode = s.first),
),
// ── PTT options ────────────────────────────────────────
if (_mode == rust.BridgeTransmitMode.ptt) ...[
const Divider(height: 24),
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,
audioConfig: _buildConfig(),
),
),
),
const SizedBox(height: 8),
],
Text(
l10n.voiceReleaseTailLabel,
style: theme.textTheme.titleSmall,
),
Row(
children: [
Expanded(
child: Slider(
value: _releaseTail,
min: 0,
max: 500,
divisions: 20,
label:
'${_releaseTail.round()}${l10n.voiceReleaseTailHint}',
onChanged: (v) => setState(() => _releaseTail = v),
),
),
SizedBox(
width: 64,
child: Text(
'${_releaseTail.round()}${l10n.voiceReleaseTailHint}',
style: theme.textTheme.bodySmall,
textAlign: TextAlign.end,
),
),
],
),
],
// ── Audio processing ───────────────────────────────────
const Divider(height: 24),
const VoiceSectionHeader('Audio processing'),
// iOS mode selector (iOS only)
if (_isIos) ...[
const VoiceSubHeader('Processing backend'),
SegmentedButton<rust.BridgeIosVoiceProcessingMode>(
style: voiceSegmentedButtonStyle(theme),
segments: iosProcessingSegments,
selected: {_audioProcessing.iosMode},
onSelectionChanged: (s) =>
setState(() => _audioProcessing.iosMode = s.first),
),
const SizedBox(height: 4),
],
// Android HW/SW selector
if (_isAndroid) ...[
const VoiceSubHeader('Processing backend'),
SegmentedButton<bool>(
style: voiceSegmentedButtonStyle(theme),
segments: androidProcessingSegments,
selected: {_audioProcessing.preferHardware},
onSelectionChanged: (s) =>
setState(() => _audioProcessing.preferHardware = s.first),
),
const SizedBox(height: 4),
],
// DSP toggles
const VoiceSubHeader('DSP stages'),
AudioProcessingToggleRow(
label: 'Noise suppression (NS)',
subtitle: 'Wiener filter · stationary noise',
value: _audioProcessing.nsEnabled,
onChanged: (v) =>
setState(() => _audioProcessing.nsEnabled = v),
),
AudioProcessingToggleRow(
label: 'Echo cancellation (AEC3)',
subtitle: _isAndroid
? 'WebRTC AEC3 · adaptive filter'
: platformVpio
? 'Managed by platform VPIO'
: 'Adaptive NLMS · 80 ms tail',
value: _audioProcessing.aecEnabled,
// AEC is always on in VPIO mode — disable the toggle.
onChanged: (_isAndroid || !platformVpio)
? (v) => setState(() => _audioProcessing.aecEnabled = v)
: null,
),
AudioProcessingToggleRow(
label: 'Auto gain control (AGC2)',
subtitle: 'RNN VAD-gated · 18 dBFS target',
value: _audioProcessing.agcEnabled,
onChanged: (v) =>
setState(() => _audioProcessing.agcEnabled = v),
),
AudioProcessingToggleRow(
label: 'High-pass filter (HPF)',
subtitle: '80 Hz Butterworth · DC removal',
value: _audioProcessing.hpfEnabled,
onChanged: (v) =>
setState(() => _audioProcessing.hpfEnabled = v),
),
AudioProcessingToggleRow(
label: 'Peak limiter',
subtitle: '1 dBFS soft-knee · 2 ms look-ahead',
value: _audioProcessing.limiterEnabled,
onChanged: (v) =>
setState(() => _audioProcessing.limiterEnabled = v),
),
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,
),
],
// ── VAD ────────────────────────────────────────────────
const Divider(height: 24),
const VoiceSectionHeader('Voice activity detection (VAD)'),
const VoiceSubHeader('Backend'),
SegmentedButton<rust.BridgeVadBackend>(
style: voiceSegmentedButtonStyle(theme),
segments: vadBackendSegments,
selected: {_audioProcessing.vadBackend},
onSelectionChanged: (s) =>
setState(() => _audioProcessing.vadBackend = s.first),
),
const SizedBox(height: 8),
// ── PTT capability badge ────────────────────────────────
if (_mode == rust.BridgeTransmitMode.ptt &&
widget.pttLevel.isNotEmpty) ...[
const Divider(height: 24),
const VoiceSectionHeader('PTT capability'),
PttCapabilityBadge(
level: widget.pttLevel,
backendId: widget.pttBackendId,
boundInputClass: widget.pttBoundInputClass,
),
],
// ── Audio output route picker (mobile only) ─────────────
if (_isAndroid || _isIos) ...[
const Divider(height: 24),
const VoiceSectionHeader('Audio output'),
const AudioOutputTile(),
],
// ── Audio devices (desktop only, SRS-026) ──────────────
if (!_isAndroid && !_isIos) ...[
const Divider(height: 24),
const VoiceSectionHeader('Audio devices'),
const AudioDeviceListTile(
label: 'Input',
kind: AudioDeviceKind.input,
),
const AudioDeviceListTile(
label: 'Output',
kind: AudioDeviceKind.output,
),
const SizedBox(height: 8),
],
// ── Debug ──────────────────────────────────────────────
const Divider(height: 24),
const VoiceSectionHeader('Debug'),
AudioProcessingToggleRow(
label: 'WAV dump',
subtitle: 'Record raw/processed mic to temp dir',
value: _audioProcessing.debugWavDump,
onChanged: (v) =>
setState(() => _audioProcessing.debugWavDump = v),
),
const SizedBox(height: 8),
],
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(l10n.closeAction),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(
VoiceSettingsResult(
mode: _mode,
releaseTailMs: _releaseTail.round(),
bindKeyRequested: false,
audioConfig: _buildConfig(),
),
),
child: Text(l10n.pttConfigureSaveAction),
),
],
);
}
}