feat(audio,bridge,flutter): v1 audio + PTT lifecycle implementation (SDD-094..097)

Implement the SDD-094 / SDD-095 / SDD-096 / SDD-097 detailed designs
committed in dfa84ee.

Rust side
- chanora_audio::TransmitMode enum (Ptt/Continuous/VoiceActivity) with
  serde-friendly u8 repr (SDD-095).
- chanora_audio::TransmitModeSelector: lock-free Atomic-backed selector
  that is the sole writer of transmit_active (per SAD-083), applying
  hard_mute as a final clamp. VoiceActivity falls through to Continuous
  for v1 (DEC-030 placeholder).
- chanora_audio::ReleaseTailTimer: tokio-task-owning struct driving the
  selector's ptt_held input; default 200 ms tail, configurable 0–500 ms
  with AtomicU32 hot read; pending JoinHandle held in a std::sync::Mutex
  touched only on PTT edge transitions (SDD-096).
- chanora_storage: AudioMeta persisted as audio_meta.json next to
  identity.dek; get/set_transmit_mode + get/set_release_tail_ms with
  0..=500 clamp on write.
- chanora_core::ChanoraSession: voice_join(channel, password) and
  voice_leave() are the new lifecycle entry points; ensure_audio_running
  and shutdown_audio_if_idle are private helpers around the existing
  Option<AudioEngine> field. SessionEvent::VoiceState carries the
  in_channel / transmit_mode / mute / release_tail_ms tuple. Selector
  state survives reconnect; supervisor rewires it to each fresh engine
  gate.
- chanora_bridge: drop start_audio; add voice_join, voice_leave,
  set/get_transmit_mode, set/get_release_tail_ms, set_hard_mute.
  BridgeEvent::VoiceState mirrors the core event. AudioStarted/Stopped
  kept for backwards compat but Flutter ignores them in the new UI.

Flutter side
- New apps/chanora_flutter/lib/widgets/voice_bar.dart replaces the
  legacy _AudioControls widget. Renders channel pill, mode badge,
  mute toggle, level meter, PttCapabilityBadge, leave button. No
  manual Start affordance anywhere.
- New apps/chanora_flutter/lib/widgets/voice_settings.dart dialog with
  TransmitMode radio group (VoiceActivity disabled with 'Coming soon'
  trailing label per DEC-030), bind-key button, release-tail slider
  0–500 ms step 25.
- main.dart: state fields _inChannel, _transmitMode, _hardMute,
  _releaseTailMs driven by BridgeEvent_VoiceState. Channel-tap now
  calls voiceJoin instead of moveToChannel. Removed _onStartAudio,
  _audioStarted-gated branch, and the FilledButton.
- l10n: 11 new strings in app_en.arb + app_zh.arb.

Verification
- cargo check --workspace: clean.
- cargo test --workspace --lib: 72 passed / 0 failed / 1 ignored
  (chanora_audio: +12 new tests for TransmitMode/Selector/ReleaseTail;
  chanora_storage: +2 new tests for audio_meta round-trip).
- flutter analyze: 0 errors, 0 warnings; 6 infos are the Flutter 3.32
  Radio.groupValue deprecation (pre-existing API usage).
- FRB Dart/Rust bindings regenerated via flutter_rust_bridge_codegen.

Follow-up (intentionally deferred)
- PttController and per-platform PTT backends still drive AudioTransmitGate
  directly via the legacy set_ptt path; routing those key edges through
  ChanoraSession::release_tail_timer().{key_down,key_up} so the tail
  applies to native PTT input is a contained wiring change in a follow-up.
- Real audio-level RMS in BridgeAudioStats (current meter is binary).
- VoiceActivity backend (DEC-030).
This commit is contained in:
EdisonJwa
2026-05-15 23:05:37 +08:00
parent dfa84ee7bb
commit ba444d94bd
25 changed files with 2519 additions and 178 deletions
+119 -46
View File
@@ -19,6 +19,8 @@ import 'package:path_provider/path_provider.dart';
import 'l10n/generated/app_localizations.dart';
import 'src/rust/api.dart' as rust;
import 'src/rust/frb_generated.dart';
import 'widgets/voice_bar.dart';
import 'widgets/voice_settings.dart';
/// Public version string shown in the About dialog. Aligned with
/// `pubspec.yaml` and the git tag for the MVP release candidate.
@@ -95,17 +97,27 @@ class _BetaHomeState extends State<_BetaHome> {
_Phase _phase = _Phase.idle;
rust.BridgeSnapshot? _snapshot;
String? _error;
// ignore: unused_field
bool _audioStarted = false;
rust.BridgeAudioStats? _audioStats;
Timer? _statsTimer;
StreamSubscription<rust.BridgeEvent>? _eventsSub;
// v1 voice subsystem state (SDD-094/095/096/097). Driven by
// BridgeEvent::VoiceState.
bool _inChannel = false;
rust.BridgeTransmitMode _transmitMode = rust.BridgeTransmitMode.ptt;
bool _hardMute = false;
int _releaseTailMs = 200;
BigInt? _currentVoiceChannelId;
String? _lostReason;
int? _reconnectAttempt;
int? _reconnectDelay;
bool _inputMuted = false;
bool _outputMuted = false;
// ignore: unused_field
double _outputGain = 1.0;
// Desktop PTT capability badge state (gen2 v0.9.3 / SDD-091).
@@ -189,9 +201,39 @@ class _BetaHomeState extends State<_BetaHome> {
_pttBackendId = backendId;
_pttBoundInputClass = boundInputClass;
});
case rust.BridgeEvent_VoiceState(
:final inChannel,
:final transmitMode,
:final mute,
:final releaseTailMs,
):
setState(() {
_inChannel = inChannel;
_transmitMode = transmitMode;
_hardMute = mute;
_releaseTailMs = releaseTailMs;
_audioStarted = inChannel;
});
if (inChannel) {
_ensureStatsTimer();
} else {
_statsTimer?.cancel();
_statsTimer = null;
}
}
}
void _ensureStatsTimer() {
if (_statsTimer != null) return;
_statsTimer = Timer.periodic(const Duration(milliseconds: 500), (_) async {
try {
final s = await rust.audioStats();
if (!mounted) return;
setState(() => _audioStats = s);
} catch (_) {}
});
}
@override
void dispose() {
_eventsSub?.cancel();
@@ -232,25 +274,7 @@ class _BetaHomeState extends State<_BetaHome> {
}
}
Future<void> _onStartAudio() async {
try {
await rust.startAudio();
if (!mounted) return;
setState(() => _audioStarted = true);
_statsTimer?.cancel();
_statsTimer = Timer.periodic(const Duration(milliseconds: 500), (_) async {
try {
final s = await rust.audioStats();
if (!mounted) return;
setState(() => _audioStats = s);
} catch (_) {}
});
} catch (e) {
if (!mounted) return;
setState(() => _error = e.toString());
}
}
// ignore: unused_element
Future<void> _setPtt(bool active) async {
try {
await rust.setPtt(active: active);
@@ -260,6 +284,7 @@ class _BetaHomeState extends State<_BetaHome> {
}
}
// ignore: unused_element
Future<void> _toggleInputMute() async {
final next = !_inputMuted;
try {
@@ -272,6 +297,7 @@ class _BetaHomeState extends State<_BetaHome> {
}
}
// ignore: unused_element
Future<void> _toggleOutputMute() async {
final next = !_outputMuted;
try {
@@ -284,6 +310,7 @@ class _BetaHomeState extends State<_BetaHome> {
}
}
// ignore: unused_element
Future<void> _setOutputGain(double value) async {
setState(() => _outputGain = value);
try {
@@ -303,16 +330,60 @@ class _BetaHomeState extends State<_BetaHome> {
if (password == null) return; // cancelled
}
try {
await rust.moveToChannel(
await rust.voiceJoin(
channelId: ch.id,
password: password ?? '',
);
if (!mounted) return;
setState(() => _currentVoiceChannelId = ch.id);
} catch (e) {
if (!mounted) return;
setState(() => _error = e.toString());
}
}
Future<void> _onLeaveVoice() async {
try {
await rust.voiceLeave();
if (!mounted) return;
setState(() => _currentVoiceChannelId = null);
} catch (e) {
if (!mounted) return;
setState(() => _error = e.toString());
}
}
Future<void> _onToggleHardMute() async {
final next = !_hardMute;
try {
await rust.setHardMute(muted: next);
} catch (e) {
if (!mounted) return;
setState(() => _error = e.toString());
}
}
Future<void> _onOpenVoiceSettings() async {
final result = await showDialog<VoiceSettingsResult>(
context: context,
builder: (ctx) => VoiceSettingsDialog(
initialMode: _transmitMode,
initialReleaseTailMs: _releaseTailMs,
),
);
if (result == null) return;
try {
await rust.setTransmitMode(mode: result.mode);
await rust.setReleaseTailMs(ms: result.releaseTailMs);
} catch (e) {
if (!mounted) return;
setState(() => _error = e.toString());
}
if (result.bindKeyRequested && mounted) {
await _onConfigurePtt(context);
}
}
Future<String?> _askChannelPassword(AppL10n l10n) async {
final ctl = TextEditingController();
final result = await showDialog<String>(
@@ -367,9 +438,21 @@ class _BetaHomeState extends State<_BetaHome> {
_error = null;
_inputMuted = false;
_outputMuted = false;
_inChannel = false;
_currentVoiceChannelId = null;
});
}
String _currentVoiceChannelName() {
final id = _currentVoiceChannelId;
final snap = _snapshot;
if (id == null || snap == null) return '';
for (final ch in snap.channels) {
if (ch.id == id) return ch.name;
}
return '';
}
Future<void> _onShowDiagnostics(BuildContext context) async {
final l10n = AppL10n.of(context);
final text = rust.exportDiagnostics();
@@ -726,32 +809,22 @@ class _BetaHomeState extends State<_BetaHome> {
),
),
] else if (_phase == _Phase.connected && _snapshot != null) ...[
if (!_audioStarted) ...[
FilledButton.icon(
icon: const Icon(Icons.mic_none),
label: Text(l10n.startAudioAction),
onPressed: _onStartAudio,
),
const SizedBox(height: 12),
] else ...[
_AudioControls(
stats: _audioStats,
inputMuted: _inputMuted,
outputMuted: _outputMuted,
outputGain: _outputGain,
pttLevel: _pttLevel,
pttBackendId: _pttBackendId,
pttBoundInputClass: _pttBoundInputClass,
pttBoundKeyLabel: _pttBoundKeyLabel,
onPttDown: () => _setPtt(true),
onPttUp: () => _setPtt(false),
onToggleInputMute: _toggleInputMute,
onToggleOutputMute: _toggleOutputMute,
onGainChanged: _setOutputGain,
onConfigurePtt: () => _onConfigurePtt(context),
),
const SizedBox(height: 12),
],
VoiceBar(
inChannel: _inChannel,
transmitMode: _transmitMode,
hardMute: _hardMute,
releaseTailMs: _releaseTailMs,
channelName: _currentVoiceChannelName(),
audioStats: _audioStats,
pttLevel: _pttLevel,
pttBackendId: _pttBackendId,
pttBoundInputClass: _pttBoundInputClass,
pttBoundKeyLabel: _pttBoundKeyLabel,
onToggleMute: _onToggleHardMute,
onConfigure: _onOpenVoiceSettings,
onLeave: _onLeaveVoice,
),
const SizedBox(height: 12),
Expanded(
child: _SnapshotView(
snapshot: _snapshot!,