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:
@@ -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!,
|
||||
|
||||
Reference in New Issue
Block a user