Files
chanora/apps/chanora_flutter/lib/widgets/voice_settings.dart
T
Edison Jwa c04aaf4a51 docs(flutter): add dart doc comments to core services and widgets (TODO-085)
Add dart doc comments to poke_notification_service, poke_preferences_service,
link_trust_service, voice_settings, snapshot_view covering public API.
2026-06-11 21:03:19 +09:00

271 lines
9.9 KiB
Dart

// 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
// - platform audio-processing mode selection where available
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;
/// Result returned by [VoiceSettingsDialog] when the user saves or
/// requests a key bind.
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;
}
/// Dialog for configuring voice transmission and audio processing settings.
///
/// Surfaces transmit mode selection (continuous, PTT, voice-activity),
/// PTT release-tail slider, key-bind request, and a full audio processing
/// panel covering noise suppression, echo cancellation, AGC, HPF, and VAD
/// backend selection.
///
/// On mobile hosts, the voice-activity segment is hidden when no
/// Chanora-owned VAD pipeline is available (iOS, macOS, web).
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,
this.onInputDeviceChanged,
this.onOutputDeviceChanged,
});
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;
final ValueChanged<String?>? onInputDeviceChanged;
final ValueChanged<String?>? onOutputDeviceChanged;
@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: isAndroidHost,
);
}
@override
Widget build(BuildContext context) {
final l10n = AppL10n.of(context);
final theme = Theme.of(context);
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),
// DEC-030: hide the voice-activity segment on hosts
// that ship no Chanora-owned VAD pipeline (iOS,
// macOS, web).
segments: transmitModeSegmentsFor(
voiceActivityAvailable: voiceActivityTransmitAvailable,
),
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'),
AudioProcessingPanel(
config: _audioProcessing,
update: (mutation) => setState(mutation),
),
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,
),
],
// ── 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 (isAndroidHost || isIosHost) ...[
const Divider(height: 24),
const VoiceSectionHeader('Audio output'),
const AudioOutputTile(),
],
// ── Audio devices (desktop only, SRS-026) ──────────────
if (!isAndroidHost && !isIosHost) ...[
const Divider(height: 24),
const VoiceSectionHeader('Audio devices'),
AudioDeviceListTile(
label: 'Input',
kind: AudioDeviceKind.input,
onDeviceChanged: widget.onInputDeviceChanged,
),
AudioDeviceListTile(
label: 'Output',
kind: AudioDeviceKind.output,
onDeviceChanged: widget.onOutputDeviceChanged,
),
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),
),
],
);
}
}