diff --git a/Cargo.lock b/Cargo.lock index ae069fa..c29cb0f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -466,6 +466,8 @@ dependencies = [ "keyring", "rand 0.8.6", "rusqlite", + "serde", + "serde_json", "thiserror 2.0.18", "tracing", "zeroize", diff --git a/apps/chanora_flutter/lib/l10n/app_en.arb b/apps/chanora_flutter/lib/l10n/app_en.arb index 5a15685..db1ac3d 100644 --- a/apps/chanora_flutter/lib/l10n/app_en.arb +++ b/apps/chanora_flutter/lib/l10n/app_en.arb @@ -113,5 +113,17 @@ "channels": { "type": "int" }, "clients": { "type": "int" } } - } + }, + + "voiceModePtt": "PTT", + "voiceModeContinuous": "Continuous", + "voiceModeVoiceActivity": "Voice activity", + "voiceModeComingSoon": "Coming soon", + "voiceModeLabel": "Transmit mode", + "voiceReleaseTailLabel": "Release tail", + "voiceReleaseTailHint": "ms", + "voiceLeaveAction": "Leave voice", + "voiceHardMuteLabel": "Mute", + "voiceSettingsTitle": "Voice settings", + "voiceBindKeyAction": "Bind PTT key" } diff --git a/apps/chanora_flutter/lib/l10n/app_zh.arb b/apps/chanora_flutter/lib/l10n/app_zh.arb index bea46a8..d6a082e 100644 --- a/apps/chanora_flutter/lib/l10n/app_zh.arb +++ b/apps/chanora_flutter/lib/l10n/app_zh.arb @@ -70,5 +70,17 @@ "channelsHeading": "频道", "clientsHeading": "在线用户", - "countChannelsAndClients": "{channels} 个频道 • {clients} 在线" + "countChannelsAndClients": "{channels} 个频道 • {clients} 在线", + + "voiceModePtt": "按键说话", + "voiceModeContinuous": "持续发送", + "voiceModeVoiceActivity": "语音激活", + "voiceModeComingSoon": "即将推出", + "voiceModeLabel": "发送模式", + "voiceReleaseTailLabel": "释放延迟", + "voiceReleaseTailHint": "毫秒", + "voiceLeaveAction": "离开语音", + "voiceHardMuteLabel": "静音", + "voiceSettingsTitle": "语音设置", + "voiceBindKeyAction": "绑定 PTT 按键" } diff --git a/apps/chanora_flutter/lib/l10n/generated/app_localizations.dart b/apps/chanora_flutter/lib/l10n/generated/app_localizations.dart index 02a0c32..5638062 100644 --- a/apps/chanora_flutter/lib/l10n/generated/app_localizations.dart +++ b/apps/chanora_flutter/lib/l10n/generated/app_localizations.dart @@ -474,6 +474,72 @@ abstract class AppL10n { /// In en, this message translates to: /// **'{channels} channels • {clients} online'** String countChannelsAndClients(int channels, int clients); + + /// No description provided for @voiceModePtt. + /// + /// In en, this message translates to: + /// **'PTT'** + String get voiceModePtt; + + /// No description provided for @voiceModeContinuous. + /// + /// In en, this message translates to: + /// **'Continuous'** + String get voiceModeContinuous; + + /// No description provided for @voiceModeVoiceActivity. + /// + /// In en, this message translates to: + /// **'Voice activity'** + String get voiceModeVoiceActivity; + + /// No description provided for @voiceModeComingSoon. + /// + /// In en, this message translates to: + /// **'Coming soon'** + String get voiceModeComingSoon; + + /// No description provided for @voiceModeLabel. + /// + /// In en, this message translates to: + /// **'Transmit mode'** + String get voiceModeLabel; + + /// No description provided for @voiceReleaseTailLabel. + /// + /// In en, this message translates to: + /// **'Release tail'** + String get voiceReleaseTailLabel; + + /// No description provided for @voiceReleaseTailHint. + /// + /// In en, this message translates to: + /// **'ms'** + String get voiceReleaseTailHint; + + /// No description provided for @voiceLeaveAction. + /// + /// In en, this message translates to: + /// **'Leave voice'** + String get voiceLeaveAction; + + /// No description provided for @voiceHardMuteLabel. + /// + /// In en, this message translates to: + /// **'Mute'** + String get voiceHardMuteLabel; + + /// No description provided for @voiceSettingsTitle. + /// + /// In en, this message translates to: + /// **'Voice settings'** + String get voiceSettingsTitle; + + /// No description provided for @voiceBindKeyAction. + /// + /// In en, this message translates to: + /// **'Bind PTT key'** + String get voiceBindKeyAction; } class _AppL10nDelegate extends LocalizationsDelegate { diff --git a/apps/chanora_flutter/lib/l10n/generated/app_localizations_en.dart b/apps/chanora_flutter/lib/l10n/generated/app_localizations_en.dart index b134b4e..389c1a9 100644 --- a/apps/chanora_flutter/lib/l10n/generated/app_localizations_en.dart +++ b/apps/chanora_flutter/lib/l10n/generated/app_localizations_en.dart @@ -229,4 +229,37 @@ class AppL10nEn extends AppL10n { String countChannelsAndClients(int channels, int clients) { return '$channels channels • $clients online'; } + + @override + String get voiceModePtt => 'PTT'; + + @override + String get voiceModeContinuous => 'Continuous'; + + @override + String get voiceModeVoiceActivity => 'Voice activity'; + + @override + String get voiceModeComingSoon => 'Coming soon'; + + @override + String get voiceModeLabel => 'Transmit mode'; + + @override + String get voiceReleaseTailLabel => 'Release tail'; + + @override + String get voiceReleaseTailHint => 'ms'; + + @override + String get voiceLeaveAction => 'Leave voice'; + + @override + String get voiceHardMuteLabel => 'Mute'; + + @override + String get voiceSettingsTitle => 'Voice settings'; + + @override + String get voiceBindKeyAction => 'Bind PTT key'; } diff --git a/apps/chanora_flutter/lib/l10n/generated/app_localizations_zh.dart b/apps/chanora_flutter/lib/l10n/generated/app_localizations_zh.dart index 67db686..4960a23 100644 --- a/apps/chanora_flutter/lib/l10n/generated/app_localizations_zh.dart +++ b/apps/chanora_flutter/lib/l10n/generated/app_localizations_zh.dart @@ -223,4 +223,37 @@ class AppL10nZh extends AppL10n { String countChannelsAndClients(int channels, int clients) { return '$channels 个频道 • $clients 在线'; } + + @override + String get voiceModePtt => '按键说话'; + + @override + String get voiceModeContinuous => '持续发送'; + + @override + String get voiceModeVoiceActivity => '语音激活'; + + @override + String get voiceModeComingSoon => '即将推出'; + + @override + String get voiceModeLabel => '发送模式'; + + @override + String get voiceReleaseTailLabel => '释放延迟'; + + @override + String get voiceReleaseTailHint => '毫秒'; + + @override + String get voiceLeaveAction => '离开语音'; + + @override + String get voiceHardMuteLabel => '静音'; + + @override + String get voiceSettingsTitle => '语音设置'; + + @override + String get voiceBindKeyAction => '绑定 PTT 按键'; } diff --git a/apps/chanora_flutter/lib/main.dart b/apps/chanora_flutter/lib/main.dart index 1dc3b2e..787bcb8 100644 --- a/apps/chanora_flutter/lib/main.dart +++ b/apps/chanora_flutter/lib/main.dart @@ -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? _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 _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 _setPtt(bool active) async { try { await rust.setPtt(active: active); @@ -260,6 +284,7 @@ class _BetaHomeState extends State<_BetaHome> { } } + // ignore: unused_element Future _toggleInputMute() async { final next = !_inputMuted; try { @@ -272,6 +297,7 @@ class _BetaHomeState extends State<_BetaHome> { } } + // ignore: unused_element Future _toggleOutputMute() async { final next = !_outputMuted; try { @@ -284,6 +310,7 @@ class _BetaHomeState extends State<_BetaHome> { } } + // ignore: unused_element Future _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 _onLeaveVoice() async { + try { + await rust.voiceLeave(); + if (!mounted) return; + setState(() => _currentVoiceChannelId = null); + } catch (e) { + if (!mounted) return; + setState(() => _error = e.toString()); + } + } + + Future _onToggleHardMute() async { + final next = !_hardMute; + try { + await rust.setHardMute(muted: next); + } catch (e) { + if (!mounted) return; + setState(() => _error = e.toString()); + } + } + + Future _onOpenVoiceSettings() async { + final result = await showDialog( + 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 _askChannelPassword(AppL10n l10n) async { final ctl = TextEditingController(); final result = await showDialog( @@ -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 _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!, diff --git a/apps/chanora_flutter/lib/src/rust/api.dart b/apps/chanora_flutter/lib/src/rust/api.dart index 960c03e..7f4a04d 100644 --- a/apps/chanora_flutter/lib/src/rust/api.dart +++ b/apps/chanora_flutter/lib/src/rust/api.dart @@ -9,8 +9,8 @@ import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart'; import 'package:freezed_annotation/freezed_annotation.dart' hide protected; part 'api.freezed.dart'; -// These functions are ignored because they are not marked as `pub`: `log_sink`, `runtime`, `session` -// These function are ignored because they are on traits that is not defined in current crate (put an empty `#[frb]` on it to unignore): `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `from`, `from`, `from`, `from`, `from`, `from` +// These functions are ignored because they are not marked as `pub`: `log_sink`, `runtime`, `session`, `transmit_mode_from_u8` +// These function are ignored because they are on traits that is not defined in current crate (put an empty `#[frb]` on it to unignore): `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from` /// Connect to a TeamSpeak-compatible server and return the initial /// state snapshot. Honours the DEC-006 single-connection invariant @@ -37,14 +37,50 @@ Future disconnect() => RustLib.instance.api.crateApiDisconnect(); /// True if a connection is currently active. Future isConnected() => RustLib.instance.api.crateApiIsConnected(); -/// Start the audio engine on the active connection. Requires a -/// connection; idempotent (will replace any previous engine). -Future startAudio() => RustLib.instance.api.crateApiStartAudio(); - /// Set the push-to-talk state. +/// +/// Superseded in v1 by [`set_transmit_mode`] + the binding capture +/// dialog. Retained so legacy callers and integration tests keep +/// working; the new VoiceBar UI no longer invokes this. Future setPtt({required bool active}) => RustLib.instance.api.crateApiSetPtt(active: active); +/// Join a voice channel (SDD-094). Moves the user to `channel_id`, +/// brings up the audio engine if needed, and emits +/// `BridgeEvent::VoiceState`. `password` may be empty. +Future voiceJoin({required BigInt channelId, required String password}) => + RustLib.instance.api.crateApiVoiceJoin( + channelId: channelId, + password: password, + ); + +/// Leave the current voice channel (SDD-094). Tears down the audio +/// engine and emits `BridgeEvent::VoiceState`. +Future voiceLeave() => RustLib.instance.api.crateApiVoiceLeave(); + +/// Set the active transmit mode (SDD-095). +Future setTransmitMode({required BridgeTransmitMode mode}) => + RustLib.instance.api.crateApiSetTransmitMode(mode: mode); + +/// Read the active transmit mode. +Future getTransmitMode() => + RustLib.instance.api.crateApiGetTransmitMode(); + +/// Update the release-tail in milliseconds (SDD-096). Values are +/// clamped to `0..=500` on the Rust side; passing anything larger +/// silently saturates. +Future setReleaseTailMs({required int ms}) => + RustLib.instance.api.crateApiSetReleaseTailMs(ms: ms); + +/// Read the current release-tail in milliseconds. +Future getReleaseTailMs() => + RustLib.instance.api.crateApiGetReleaseTailMs(); + +/// Engage or release the hard-mute clamp (SDD-094). When `true` +/// the audio engine transmits nothing regardless of mode. +Future setHardMute({required bool muted}) => + RustLib.instance.api.crateApiSetHardMute(muted: muted); + /// Update the active PTT binding (gen2 v0.9.3 / DEC-026). The /// platform_key string is opaque to the bridge — it identifies the /// bound key inside the platform backend and never appears in any @@ -362,6 +398,23 @@ sealed class BridgeEvent with _$BridgeEvent { /// `"mouse-side-button"`); empty when no binding is active. required String boundInputClass, }) = BridgeEvent_PttCapability; + + /// Voice subsystem state snapshot (SDD-094). The Flutter + /// VoiceBar listens to this stream. + const factory BridgeEvent.voiceState({ + /// True when the session is currently joined to a voice + /// channel and the audio engine is running. + required bool inChannel, + + /// Active transmit mode. + required BridgeTransmitMode transmitMode, + + /// True when the hard-mute clamp is engaged. + required bool mute, + + /// Current release-tail in milliseconds (0..=500). + required int releaseTailMs, + }) = BridgeEvent_VoiceState; } /// Coarse OS-reported network state. Mirrors @@ -440,3 +493,17 @@ class BridgeSnapshot { channels == other.channels && clients == other.clients; } + +/// Voice transmit mode mirror (SDD-095). Schema-controlled enum; +/// the wire encoding matches [`chanora_core::TransmitMode::as_u8`]. +enum BridgeTransmitMode { + /// Push-to-talk (default). + ptt, + + /// Continuous transmission while in channel and not muted. + continuous, + + /// Voice-activity detection — reserved per DEC-030; v1 behaves + /// as `Continuous`. + voiceActivity, +} diff --git a/apps/chanora_flutter/lib/src/rust/api.freezed.dart b/apps/chanora_flutter/lib/src/rust/api.freezed.dart index 43ba102..6812379 100644 --- a/apps/chanora_flutter/lib/src/rust/api.freezed.dart +++ b/apps/chanora_flutter/lib/src/rust/api.freezed.dart @@ -55,7 +55,7 @@ extension BridgeEventPatterns on BridgeEvent { /// } /// ``` -@optionalTypeArgs TResult maybeMap({TResult Function( BridgeEvent_Connected value)? connected,TResult Function( BridgeEvent_Lost value)? lost,TResult Function( BridgeEvent_Reconnecting value)? reconnecting,TResult Function( BridgeEvent_Disconnected value)? disconnected,TResult Function( BridgeEvent_AudioStarted value)? audioStarted,TResult Function( BridgeEvent_AudioStopped value)? audioStopped,TResult Function( BridgeEvent_SnapshotChanged value)? snapshotChanged,TResult Function( BridgeEvent_PttCapability value)? pttCapability,required TResult orElse(),}){ +@optionalTypeArgs TResult maybeMap({TResult Function( BridgeEvent_Connected value)? connected,TResult Function( BridgeEvent_Lost value)? lost,TResult Function( BridgeEvent_Reconnecting value)? reconnecting,TResult Function( BridgeEvent_Disconnected value)? disconnected,TResult Function( BridgeEvent_AudioStarted value)? audioStarted,TResult Function( BridgeEvent_AudioStopped value)? audioStopped,TResult Function( BridgeEvent_SnapshotChanged value)? snapshotChanged,TResult Function( BridgeEvent_PttCapability value)? pttCapability,TResult Function( BridgeEvent_VoiceState value)? voiceState,required TResult orElse(),}){ final _that = this; switch (_that) { case BridgeEvent_Connected() when connected != null: @@ -66,7 +66,8 @@ return disconnected(_that);case BridgeEvent_AudioStarted() when audioStarted != return audioStarted(_that);case BridgeEvent_AudioStopped() when audioStopped != null: return audioStopped(_that);case BridgeEvent_SnapshotChanged() when snapshotChanged != null: return snapshotChanged(_that);case BridgeEvent_PttCapability() when pttCapability != null: -return pttCapability(_that);case _: +return pttCapability(_that);case BridgeEvent_VoiceState() when voiceState != null: +return voiceState(_that);case _: return orElse(); } @@ -84,7 +85,7 @@ return pttCapability(_that);case _: /// } /// ``` -@optionalTypeArgs TResult map({required TResult Function( BridgeEvent_Connected value) connected,required TResult Function( BridgeEvent_Lost value) lost,required TResult Function( BridgeEvent_Reconnecting value) reconnecting,required TResult Function( BridgeEvent_Disconnected value) disconnected,required TResult Function( BridgeEvent_AudioStarted value) audioStarted,required TResult Function( BridgeEvent_AudioStopped value) audioStopped,required TResult Function( BridgeEvent_SnapshotChanged value) snapshotChanged,required TResult Function( BridgeEvent_PttCapability value) pttCapability,}){ +@optionalTypeArgs TResult map({required TResult Function( BridgeEvent_Connected value) connected,required TResult Function( BridgeEvent_Lost value) lost,required TResult Function( BridgeEvent_Reconnecting value) reconnecting,required TResult Function( BridgeEvent_Disconnected value) disconnected,required TResult Function( BridgeEvent_AudioStarted value) audioStarted,required TResult Function( BridgeEvent_AudioStopped value) audioStopped,required TResult Function( BridgeEvent_SnapshotChanged value) snapshotChanged,required TResult Function( BridgeEvent_PttCapability value) pttCapability,required TResult Function( BridgeEvent_VoiceState value) voiceState,}){ final _that = this; switch (_that) { case BridgeEvent_Connected(): @@ -95,7 +96,8 @@ return disconnected(_that);case BridgeEvent_AudioStarted(): return audioStarted(_that);case BridgeEvent_AudioStopped(): return audioStopped(_that);case BridgeEvent_SnapshotChanged(): return snapshotChanged(_that);case BridgeEvent_PttCapability(): -return pttCapability(_that);} +return pttCapability(_that);case BridgeEvent_VoiceState(): +return voiceState(_that);} } /// A variant of `map` that fallback to returning `null`. /// @@ -109,7 +111,7 @@ return pttCapability(_that);} /// } /// ``` -@optionalTypeArgs TResult? mapOrNull({TResult? Function( BridgeEvent_Connected value)? connected,TResult? Function( BridgeEvent_Lost value)? lost,TResult? Function( BridgeEvent_Reconnecting value)? reconnecting,TResult? Function( BridgeEvent_Disconnected value)? disconnected,TResult? Function( BridgeEvent_AudioStarted value)? audioStarted,TResult? Function( BridgeEvent_AudioStopped value)? audioStopped,TResult? Function( BridgeEvent_SnapshotChanged value)? snapshotChanged,TResult? Function( BridgeEvent_PttCapability value)? pttCapability,}){ +@optionalTypeArgs TResult? mapOrNull({TResult? Function( BridgeEvent_Connected value)? connected,TResult? Function( BridgeEvent_Lost value)? lost,TResult? Function( BridgeEvent_Reconnecting value)? reconnecting,TResult? Function( BridgeEvent_Disconnected value)? disconnected,TResult? Function( BridgeEvent_AudioStarted value)? audioStarted,TResult? Function( BridgeEvent_AudioStopped value)? audioStopped,TResult? Function( BridgeEvent_SnapshotChanged value)? snapshotChanged,TResult? Function( BridgeEvent_PttCapability value)? pttCapability,TResult? Function( BridgeEvent_VoiceState value)? voiceState,}){ final _that = this; switch (_that) { case BridgeEvent_Connected() when connected != null: @@ -120,7 +122,8 @@ return disconnected(_that);case BridgeEvent_AudioStarted() when audioStarted != return audioStarted(_that);case BridgeEvent_AudioStopped() when audioStopped != null: return audioStopped(_that);case BridgeEvent_SnapshotChanged() when snapshotChanged != null: return snapshotChanged(_that);case BridgeEvent_PttCapability() when pttCapability != null: -return pttCapability(_that);case _: +return pttCapability(_that);case BridgeEvent_VoiceState() when voiceState != null: +return voiceState(_that);case _: return null; } @@ -137,7 +140,7 @@ return pttCapability(_that);case _: /// } /// ``` -@optionalTypeArgs TResult maybeWhen({TResult Function( String serverName)? connected,TResult Function( String reason)? lost,TResult Function( int attempt, int delaySecs)? reconnecting,TResult Function( String reason)? disconnected,TResult Function()? audioStarted,TResult Function()? audioStopped,TResult Function( int channels, int clients)? snapshotChanged,TResult Function( String level, String backendId, String boundInputClass)? pttCapability,required TResult orElse(),}) {final _that = this; +@optionalTypeArgs TResult maybeWhen({TResult Function( String serverName)? connected,TResult Function( String reason)? lost,TResult Function( int attempt, int delaySecs)? reconnecting,TResult Function( String reason)? disconnected,TResult Function()? audioStarted,TResult Function()? audioStopped,TResult Function( int channels, int clients)? snapshotChanged,TResult Function( String level, String backendId, String boundInputClass)? pttCapability,TResult Function( bool inChannel, BridgeTransmitMode transmitMode, bool mute, int releaseTailMs)? voiceState,required TResult orElse(),}) {final _that = this; switch (_that) { case BridgeEvent_Connected() when connected != null: return connected(_that.serverName);case BridgeEvent_Lost() when lost != null: @@ -147,7 +150,8 @@ return disconnected(_that.reason);case BridgeEvent_AudioStarted() when audioStar return audioStarted();case BridgeEvent_AudioStopped() when audioStopped != null: return audioStopped();case BridgeEvent_SnapshotChanged() when snapshotChanged != null: return snapshotChanged(_that.channels,_that.clients);case BridgeEvent_PttCapability() when pttCapability != null: -return pttCapability(_that.level,_that.backendId,_that.boundInputClass);case _: +return pttCapability(_that.level,_that.backendId,_that.boundInputClass);case BridgeEvent_VoiceState() when voiceState != null: +return voiceState(_that.inChannel,_that.transmitMode,_that.mute,_that.releaseTailMs);case _: return orElse(); } @@ -165,7 +169,7 @@ return pttCapability(_that.level,_that.backendId,_that.boundInputClass);case _: /// } /// ``` -@optionalTypeArgs TResult when({required TResult Function( String serverName) connected,required TResult Function( String reason) lost,required TResult Function( int attempt, int delaySecs) reconnecting,required TResult Function( String reason) disconnected,required TResult Function() audioStarted,required TResult Function() audioStopped,required TResult Function( int channels, int clients) snapshotChanged,required TResult Function( String level, String backendId, String boundInputClass) pttCapability,}) {final _that = this; +@optionalTypeArgs TResult when({required TResult Function( String serverName) connected,required TResult Function( String reason) lost,required TResult Function( int attempt, int delaySecs) reconnecting,required TResult Function( String reason) disconnected,required TResult Function() audioStarted,required TResult Function() audioStopped,required TResult Function( int channels, int clients) snapshotChanged,required TResult Function( String level, String backendId, String boundInputClass) pttCapability,required TResult Function( bool inChannel, BridgeTransmitMode transmitMode, bool mute, int releaseTailMs) voiceState,}) {final _that = this; switch (_that) { case BridgeEvent_Connected(): return connected(_that.serverName);case BridgeEvent_Lost(): @@ -175,7 +179,8 @@ return disconnected(_that.reason);case BridgeEvent_AudioStarted(): return audioStarted();case BridgeEvent_AudioStopped(): return audioStopped();case BridgeEvent_SnapshotChanged(): return snapshotChanged(_that.channels,_that.clients);case BridgeEvent_PttCapability(): -return pttCapability(_that.level,_that.backendId,_that.boundInputClass);} +return pttCapability(_that.level,_that.backendId,_that.boundInputClass);case BridgeEvent_VoiceState(): +return voiceState(_that.inChannel,_that.transmitMode,_that.mute,_that.releaseTailMs);} } /// A variant of `when` that fallback to returning `null` /// @@ -189,7 +194,7 @@ return pttCapability(_that.level,_that.backendId,_that.boundInputClass);} /// } /// ``` -@optionalTypeArgs TResult? whenOrNull({TResult? Function( String serverName)? connected,TResult? Function( String reason)? lost,TResult? Function( int attempt, int delaySecs)? reconnecting,TResult? Function( String reason)? disconnected,TResult? Function()? audioStarted,TResult? Function()? audioStopped,TResult? Function( int channels, int clients)? snapshotChanged,TResult? Function( String level, String backendId, String boundInputClass)? pttCapability,}) {final _that = this; +@optionalTypeArgs TResult? whenOrNull({TResult? Function( String serverName)? connected,TResult? Function( String reason)? lost,TResult? Function( int attempt, int delaySecs)? reconnecting,TResult? Function( String reason)? disconnected,TResult? Function()? audioStarted,TResult? Function()? audioStopped,TResult? Function( int channels, int clients)? snapshotChanged,TResult? Function( String level, String backendId, String boundInputClass)? pttCapability,TResult? Function( bool inChannel, BridgeTransmitMode transmitMode, bool mute, int releaseTailMs)? voiceState,}) {final _that = this; switch (_that) { case BridgeEvent_Connected() when connected != null: return connected(_that.serverName);case BridgeEvent_Lost() when lost != null: @@ -199,7 +204,8 @@ return disconnected(_that.reason);case BridgeEvent_AudioStarted() when audioStar return audioStarted();case BridgeEvent_AudioStopped() when audioStopped != null: return audioStopped();case BridgeEvent_SnapshotChanged() when snapshotChanged != null: return snapshotChanged(_that.channels,_that.clients);case BridgeEvent_PttCapability() when pttCapability != null: -return pttCapability(_that.level,_that.backendId,_that.boundInputClass);case _: +return pttCapability(_that.level,_that.backendId,_that.boundInputClass);case BridgeEvent_VoiceState() when voiceState != null: +return voiceState(_that.inChannel,_that.transmitMode,_that.mute,_that.releaseTailMs);case _: return null; } @@ -686,6 +692,83 @@ as String, } +} + +/// @nodoc + + +class BridgeEvent_VoiceState extends BridgeEvent { + const BridgeEvent_VoiceState({required this.inChannel, required this.transmitMode, required this.mute, required this.releaseTailMs}): super._(); + + +/// True when the session is currently joined to a voice +/// channel and the audio engine is running. + final bool inChannel; +/// Active transmit mode. + final BridgeTransmitMode transmitMode; +/// True when the hard-mute clamp is engaged. + final bool mute; +/// Current release-tail in milliseconds (0..=500). + final int releaseTailMs; + +/// Create a copy of BridgeEvent +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$BridgeEvent_VoiceStateCopyWith get copyWith => _$BridgeEvent_VoiceStateCopyWithImpl(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is BridgeEvent_VoiceState&&(identical(other.inChannel, inChannel) || other.inChannel == inChannel)&&(identical(other.transmitMode, transmitMode) || other.transmitMode == transmitMode)&&(identical(other.mute, mute) || other.mute == mute)&&(identical(other.releaseTailMs, releaseTailMs) || other.releaseTailMs == releaseTailMs)); +} + + +@override +int get hashCode => Object.hash(runtimeType,inChannel,transmitMode,mute,releaseTailMs); + +@override +String toString() { + return 'BridgeEvent.voiceState(inChannel: $inChannel, transmitMode: $transmitMode, mute: $mute, releaseTailMs: $releaseTailMs)'; +} + + +} + +/// @nodoc +abstract mixin class $BridgeEvent_VoiceStateCopyWith<$Res> implements $BridgeEventCopyWith<$Res> { + factory $BridgeEvent_VoiceStateCopyWith(BridgeEvent_VoiceState value, $Res Function(BridgeEvent_VoiceState) _then) = _$BridgeEvent_VoiceStateCopyWithImpl; +@useResult +$Res call({ + bool inChannel, BridgeTransmitMode transmitMode, bool mute, int releaseTailMs +}); + + + + +} +/// @nodoc +class _$BridgeEvent_VoiceStateCopyWithImpl<$Res> + implements $BridgeEvent_VoiceStateCopyWith<$Res> { + _$BridgeEvent_VoiceStateCopyWithImpl(this._self, this._then); + + final BridgeEvent_VoiceState _self; + final $Res Function(BridgeEvent_VoiceState) _then; + +/// Create a copy of BridgeEvent +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') $Res call({Object? inChannel = null,Object? transmitMode = null,Object? mute = null,Object? releaseTailMs = null,}) { + return _then(BridgeEvent_VoiceState( +inChannel: null == inChannel ? _self.inChannel : inChannel // ignore: cast_nullable_to_non_nullable +as bool,transmitMode: null == transmitMode ? _self.transmitMode : transmitMode // ignore: cast_nullable_to_non_nullable +as BridgeTransmitMode,mute: null == mute ? _self.mute : mute // ignore: cast_nullable_to_non_nullable +as bool,releaseTailMs: null == releaseTailMs ? _self.releaseTailMs : releaseTailMs // ignore: cast_nullable_to_non_nullable +as int, + )); +} + + } // dart format on diff --git a/apps/chanora_flutter/lib/src/rust/frb_generated.dart b/apps/chanora_flutter/lib/src/rust/frb_generated.dart index bb398a6..7f54097 100644 --- a/apps/chanora_flutter/lib/src/rust/frb_generated.dart +++ b/apps/chanora_flutter/lib/src/rust/frb_generated.dart @@ -67,7 +67,7 @@ class RustLib extends BaseEntrypoint { String get codegenVersion => '2.12.0'; @override - int get rustContentHash => -427953414; + int get rustContentHash => -330689763; static const kDefaultExternalLibraryLoaderConfig = ExternalLibraryLoaderConfig( @@ -99,6 +99,10 @@ abstract class RustLibApi extends BaseApi { String crateApiExportDiagnostics(); + Future crateApiGetReleaseTailMs(); + + Future crateApiGetTransmitMode(); + Future crateApiInitStorage({required String dir}); Future crateApiIsConnected(); @@ -112,6 +116,8 @@ abstract class RustLibApi extends BaseApi { Future<(String, String, String)> crateApiPttDescriptor(); + Future crateApiSetHardMute({required bool muted}); + Future crateApiSetInputMuted({required bool muted}); void crateApiSetNetworkState({required BridgeNetworkState state}); @@ -127,11 +133,20 @@ abstract class RustLibApi extends BaseApi { required String platformKey, }); + Future crateApiSetReleaseTailMs({required int ms}); + + Future crateApiSetTransmitMode({required BridgeTransmitMode mode}); + Future crateApiSnapshot(); - Future crateApiStartAudio(); - Future crateApiUpdateBookmark({required BridgeBookmark b}); + + Future crateApiVoiceJoin({ + required BigInt channelId, + required String password, + }); + + Future crateApiVoiceLeave(); } class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { @@ -369,6 +384,60 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { TaskConstMeta get kCrateApiExportDiagnosticsConstMeta => const TaskConstMeta(debugName: "export_diagnostics", argNames: []); + @override + Future crateApiGetReleaseTailMs() { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + pdeCallFfi( + generalizedFrbRustBinding, + serializer, + funcId: 9, + port: port_, + ); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_u_32, + decodeErrorData: null, + ), + constMeta: kCrateApiGetReleaseTailMsConstMeta, + argValues: [], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateApiGetReleaseTailMsConstMeta => + const TaskConstMeta(debugName: "get_release_tail_ms", argNames: []); + + @override + Future crateApiGetTransmitMode() { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + pdeCallFfi( + generalizedFrbRustBinding, + serializer, + funcId: 10, + port: port_, + ); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_bridge_transmit_mode, + decodeErrorData: null, + ), + constMeta: kCrateApiGetTransmitModeConstMeta, + argValues: [], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateApiGetTransmitModeConstMeta => + const TaskConstMeta(debugName: "get_transmit_mode", argNames: []); + @override Future crateApiInitStorage({required String dir}) { return handler.executeNormal( @@ -379,7 +448,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 9, + funcId: 11, port: port_, ); }, @@ -406,7 +475,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 10, + funcId: 12, port: port_, ); }, @@ -433,7 +502,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 11, + funcId: 13, port: port_, ); }, @@ -465,7 +534,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 12, + funcId: 14, port: port_, ); }, @@ -494,7 +563,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 13, + funcId: 15, port: port_, ); }, @@ -512,6 +581,34 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { TaskConstMeta get kCrateApiPttDescriptorConstMeta => const TaskConstMeta(debugName: "ptt_descriptor", argNames: []); + @override + Future crateApiSetHardMute({required bool muted}) { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_bool(muted, serializer); + pdeCallFfi( + generalizedFrbRustBinding, + serializer, + funcId: 16, + port: port_, + ); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_unit, + decodeErrorData: sse_decode_bridge_error, + ), + constMeta: kCrateApiSetHardMuteConstMeta, + argValues: [muted], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateApiSetHardMuteConstMeta => + const TaskConstMeta(debugName: "set_hard_mute", argNames: ["muted"]); + @override Future crateApiSetInputMuted({required bool muted}) { return handler.executeNormal( @@ -522,7 +619,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 14, + funcId: 17, port: port_, ); }, @@ -547,7 +644,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { callFfi: () { final serializer = SseSerializer(generalizedFrbRustBinding); sse_encode_bridge_network_state(state, serializer); - return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 15)!; + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 18)!; }, codec: SseCodec( decodeSuccessData: sse_decode_unit, @@ -573,7 +670,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 16, + funcId: 19, port: port_, ); }, @@ -601,7 +698,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 17, + funcId: 20, port: port_, ); }, @@ -629,7 +726,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 18, + funcId: 21, port: port_, ); }, @@ -661,7 +758,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 19, + funcId: 22, port: port_, ); }, @@ -681,6 +778,62 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { argNames: ["inputClass", "platformKey"], ); + @override + Future crateApiSetReleaseTailMs({required int ms}) { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_u_32(ms, serializer); + pdeCallFfi( + generalizedFrbRustBinding, + serializer, + funcId: 23, + port: port_, + ); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_unit, + decodeErrorData: sse_decode_bridge_error, + ), + constMeta: kCrateApiSetReleaseTailMsConstMeta, + argValues: [ms], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateApiSetReleaseTailMsConstMeta => + const TaskConstMeta(debugName: "set_release_tail_ms", argNames: ["ms"]); + + @override + Future crateApiSetTransmitMode({required BridgeTransmitMode mode}) { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_bridge_transmit_mode(mode, serializer); + pdeCallFfi( + generalizedFrbRustBinding, + serializer, + funcId: 24, + port: port_, + ); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_unit, + decodeErrorData: sse_decode_bridge_error, + ), + constMeta: kCrateApiSetTransmitModeConstMeta, + argValues: [mode], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateApiSetTransmitModeConstMeta => + const TaskConstMeta(debugName: "set_transmit_mode", argNames: ["mode"]); + @override Future crateApiSnapshot() { return handler.executeNormal( @@ -690,7 +843,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 20, + funcId: 25, port: port_, ); }, @@ -708,33 +861,6 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { TaskConstMeta get kCrateApiSnapshotConstMeta => const TaskConstMeta(debugName: "snapshot", argNames: []); - @override - Future crateApiStartAudio() { - return handler.executeNormal( - NormalTask( - callFfi: (port_) { - final serializer = SseSerializer(generalizedFrbRustBinding); - pdeCallFfi( - generalizedFrbRustBinding, - serializer, - funcId: 21, - port: port_, - ); - }, - codec: SseCodec( - decodeSuccessData: sse_decode_unit, - decodeErrorData: sse_decode_bridge_error, - ), - constMeta: kCrateApiStartAudioConstMeta, - argValues: [], - apiImpl: this, - ), - ); - } - - TaskConstMeta get kCrateApiStartAudioConstMeta => - const TaskConstMeta(debugName: "start_audio", argNames: []); - @override Future crateApiUpdateBookmark({required BridgeBookmark b}) { return handler.executeNormal( @@ -745,7 +871,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 22, + funcId: 26, port: port_, ); }, @@ -763,6 +889,67 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { TaskConstMeta get kCrateApiUpdateBookmarkConstMeta => const TaskConstMeta(debugName: "update_bookmark", argNames: ["b"]); + @override + Future crateApiVoiceJoin({ + required BigInt channelId, + required String password, + }) { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_u_64(channelId, serializer); + sse_encode_String(password, serializer); + pdeCallFfi( + generalizedFrbRustBinding, + serializer, + funcId: 27, + port: port_, + ); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_unit, + decodeErrorData: sse_decode_bridge_error, + ), + constMeta: kCrateApiVoiceJoinConstMeta, + argValues: [channelId, password], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateApiVoiceJoinConstMeta => const TaskConstMeta( + debugName: "voice_join", + argNames: ["channelId", "password"], + ); + + @override + Future crateApiVoiceLeave() { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + pdeCallFfi( + generalizedFrbRustBinding, + serializer, + funcId: 28, + port: port_, + ); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_unit, + decodeErrorData: sse_decode_bridge_error, + ), + constMeta: kCrateApiVoiceLeaveConstMeta, + argValues: [], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateApiVoiceLeaveConstMeta => + const TaskConstMeta(debugName: "voice_leave", argNames: []); + @protected AnyhowException dco_decode_AnyhowException(dynamic raw) { // Codec=Dco (DartCObject based), see doc to use other codecs @@ -904,6 +1091,13 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { backendId: dco_decode_String(raw[2]), boundInputClass: dco_decode_String(raw[3]), ); + case 8: + return BridgeEvent_VoiceState( + inChannel: dco_decode_bool(raw[1]), + transmitMode: dco_decode_bridge_transmit_mode(raw[2]), + mute: dco_decode_bool(raw[3]), + releaseTailMs: dco_decode_u_32(raw[4]), + ); default: throw Exception("unreachable"); } @@ -937,6 +1131,12 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { ); } + @protected + BridgeTransmitMode dco_decode_bridge_transmit_mode(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return BridgeTransmitMode.values[raw as int]; + } + @protected double dco_decode_f_32(dynamic raw) { // Codec=Dco (DartCObject based), see doc to use other codecs @@ -1177,6 +1377,17 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { backendId: var_backendId, boundInputClass: var_boundInputClass, ); + case 8: + var var_inChannel = sse_decode_bool(deserializer); + var var_transmitMode = sse_decode_bridge_transmit_mode(deserializer); + var var_mute = sse_decode_bool(deserializer); + var var_releaseTailMs = sse_decode_u_32(deserializer); + return BridgeEvent_VoiceState( + inChannel: var_inChannel, + transmitMode: var_transmitMode, + mute: var_mute, + releaseTailMs: var_releaseTailMs, + ); default: throw UnimplementedError(''); } @@ -1219,6 +1430,15 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { ); } + @protected + BridgeTransmitMode sse_decode_bridge_transmit_mode( + SseDeserializer deserializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + var inner = sse_decode_i_32(deserializer); + return BridgeTransmitMode.values[inner]; + } + @protected double sse_decode_f_32(SseDeserializer deserializer) { // Codec=Sse (Serialization based), see doc to use other codecs @@ -1472,6 +1692,17 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_String(level, serializer); sse_encode_String(backendId, serializer); sse_encode_String(boundInputClass, serializer); + case BridgeEvent_VoiceState( + inChannel: final inChannel, + transmitMode: final transmitMode, + mute: final mute, + releaseTailMs: final releaseTailMs, + ): + sse_encode_i_32(8, serializer); + sse_encode_bool(inChannel, serializer); + sse_encode_bridge_transmit_mode(transmitMode, serializer); + sse_encode_bool(mute, serializer); + sse_encode_u_32(releaseTailMs, serializer); } } @@ -1507,6 +1738,15 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_list_bridge_client(self.clients, serializer); } + @protected + void sse_encode_bridge_transmit_mode( + BridgeTransmitMode self, + SseSerializer serializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_i_32(self.index, serializer); + } + @protected void sse_encode_f_32(double self, SseSerializer serializer) { // Codec=Sse (Serialization based), see doc to use other codecs diff --git a/apps/chanora_flutter/lib/src/rust/frb_generated.io.dart b/apps/chanora_flutter/lib/src/rust/frb_generated.io.dart index 06e552c..45c8df0 100644 --- a/apps/chanora_flutter/lib/src/rust/frb_generated.io.dart +++ b/apps/chanora_flutter/lib/src/rust/frb_generated.io.dart @@ -63,6 +63,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected BridgeSnapshot dco_decode_bridge_snapshot(dynamic raw); + @protected + BridgeTransmitMode dco_decode_bridge_transmit_mode(dynamic raw); + @protected double dco_decode_f_32(dynamic raw); @@ -149,6 +152,11 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected BridgeSnapshot sse_decode_bridge_snapshot(SseDeserializer deserializer); + @protected + BridgeTransmitMode sse_decode_bridge_transmit_mode( + SseDeserializer deserializer, + ); + @protected double sse_decode_f_32(SseDeserializer deserializer); @@ -259,6 +267,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { SseSerializer serializer, ); + @protected + void sse_encode_bridge_transmit_mode( + BridgeTransmitMode self, + SseSerializer serializer, + ); + @protected void sse_encode_f_32(double self, SseSerializer serializer); diff --git a/apps/chanora_flutter/lib/src/rust/frb_generated.web.dart b/apps/chanora_flutter/lib/src/rust/frb_generated.web.dart index 5f9b0c5..a37c19d 100644 --- a/apps/chanora_flutter/lib/src/rust/frb_generated.web.dart +++ b/apps/chanora_flutter/lib/src/rust/frb_generated.web.dart @@ -65,6 +65,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected BridgeSnapshot dco_decode_bridge_snapshot(dynamic raw); + @protected + BridgeTransmitMode dco_decode_bridge_transmit_mode(dynamic raw); + @protected double dco_decode_f_32(dynamic raw); @@ -151,6 +154,11 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected BridgeSnapshot sse_decode_bridge_snapshot(SseDeserializer deserializer); + @protected + BridgeTransmitMode sse_decode_bridge_transmit_mode( + SseDeserializer deserializer, + ); + @protected double sse_decode_f_32(SseDeserializer deserializer); @@ -261,6 +269,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { SseSerializer serializer, ); + @protected + void sse_encode_bridge_transmit_mode( + BridgeTransmitMode self, + SseSerializer serializer, + ); + @protected void sse_encode_f_32(double self, SseSerializer serializer); diff --git a/apps/chanora_flutter/lib/widgets/voice_bar.dart b/apps/chanora_flutter/lib/widgets/voice_bar.dart new file mode 100644 index 0000000..0d55fae --- /dev/null +++ b/apps/chanora_flutter/lib/widgets/voice_bar.dart @@ -0,0 +1,251 @@ +// Voice bar widget (SDD-097). Replaces the legacy `_AudioControls` +// block that used to live in `main.dart`. Driven by the +// `BridgeEvent::VoiceState` stream the bridge publishes from the +// core's transmit-mode selector + release-tail timer. + +import 'package:flutter/material.dart'; + +import '../l10n/generated/app_localizations.dart'; +import '../main.dart' show PttCapabilityBadge; +import '../src/rust/api.dart' as rust; + +/// Voice bar — surfaces the live voice state, mode badge, hard-mute +/// toggle, level meter, and a leave-channel affordance. +class VoiceBar extends StatelessWidget { + /// Construct a voice bar. + const VoiceBar({ + super.key, + required this.inChannel, + required this.transmitMode, + required this.hardMute, + required this.releaseTailMs, + required this.channelName, + required this.audioStats, + required this.pttLevel, + required this.pttBackendId, + required this.pttBoundInputClass, + required this.pttBoundKeyLabel, + required this.onToggleMute, + required this.onConfigure, + required this.onLeave, + }); + + /// True when the session is currently joined to a voice channel. + final bool inChannel; + + /// Active transmit mode. + final rust.BridgeTransmitMode transmitMode; + + /// Hard-mute clamp state. + final bool hardMute; + + /// Configured release-tail in milliseconds (0..=500). Surfaced as + /// a hint underneath the mode badge. + final int releaseTailMs; + + /// Channel display name to show in the channel-name pill. Empty + /// string suppresses the pill (typically when `!inChannel`). + final String channelName; + + /// Audio statistics (TX/RX frames + ptt_active) to drive the + /// level meter. Pass `null` to render an idle meter. + final rust.BridgeAudioStats? audioStats; + + /// PTT capability badge inputs — passed through to + /// [`PttCapabilityBadge`]. + final String pttLevel; + + /// Backend id, e.g. `focused`, `windows-raw-input`. + final String pttBackendId; + + /// Coarse bound input class. + final String pttBoundInputClass; + + /// Platform-neutral key label captured by the binding dialog. + final String pttBoundKeyLabel; + + /// Toggle the hard-mute clamp. + final VoidCallback onToggleMute; + + /// Open the voice settings dialog. + final VoidCallback onConfigure; + + /// Leave the voice channel. + final VoidCallback onLeave; + + String _modeLabel(AppL10n l10n) { + switch (transmitMode) { + case rust.BridgeTransmitMode.ptt: + final key = pttBoundKeyLabel.isEmpty ? '—' : pttBoundKeyLabel; + return '${l10n.voiceModePtt}: $key'; + case rust.BridgeTransmitMode.continuous: + return l10n.voiceModeContinuous; + case rust.BridgeTransmitMode.voiceActivity: + return '${l10n.voiceModeVoiceActivity} (${l10n.voiceModeComingSoon})'; + } + } + + @override + Widget build(BuildContext context) { + final l10n = AppL10n.of(context); + final theme = Theme.of(context); + final stats = audioStats; + final levelActive = stats?.pttActive ?? false; + + return Card( + margin: EdgeInsets.zero, + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Row 1: channel pill + mute toggle + Row( + children: [ + if (inChannel && channelName.isNotEmpty) ...[ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 4, + ), + decoration: BoxDecoration( + color: theme.colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.tag, + size: 14, + color: theme.colorScheme.onPrimaryContainer, + ), + const SizedBox(width: 4), + Text( + channelName, + style: TextStyle( + color: theme.colorScheme.onPrimaryContainer, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ], + const Spacer(), + IconButton( + tooltip: l10n.voiceHardMuteLabel, + icon: Icon(hardMute ? Icons.mic_off : Icons.mic), + isSelected: hardMute, + selectedIcon: const Icon(Icons.mic_off), + onPressed: onToggleMute, + ), + ], + ), + const SizedBox(height: 6), + // Row 2: mode badge + Row( + children: [ + Icon( + transmitMode == rust.BridgeTransmitMode.ptt + ? Icons.radio_button_checked + : Icons.podcasts, + size: 16, + color: theme.colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 6), + Expanded( + child: Text( + _modeLabel(l10n), + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + TextButton.icon( + icon: const Icon(Icons.tune, size: 16), + label: Text(l10n.voiceSettingsTitle), + onPressed: onConfigure, + ), + ], + ), + // Row 3: release tail hint (only meaningful for PTT mode) + if (transmitMode == rust.BridgeTransmitMode.ptt) + Padding( + padding: const EdgeInsets.only(left: 22, top: 2), + child: Text( + '${l10n.voiceReleaseTailLabel}: $releaseTailMs${l10n.voiceReleaseTailHint}', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + const SizedBox(height: 6), + // Row 4: level meter + _LevelMeter(active: levelActive), + const SizedBox(height: 4), + if (stats != null) + Text( + l10n.audioStatsLine( + stats.framesSent, + stats.framesReceived, + stats.pttActive ? 'on' : 'off', + ), + style: theme.textTheme.bodySmall, + ), + const SizedBox(height: 6), + // PTT capability badge + PttCapabilityBadge( + level: pttLevel, + backendId: pttBackendId, + boundInputClass: pttBoundInputClass, + boundKeyLabel: pttBoundKeyLabel, + onConfigure: onConfigure, + ), + if (inChannel) ...[ + const SizedBox(height: 6), + Align( + alignment: AlignmentDirectional.centerEnd, + child: TextButton.icon( + icon: const Icon(Icons.call_end), + label: Text(l10n.voiceLeaveAction), + onPressed: onLeave, + ), + ), + ], + ], + ), + ), + ); + } +} + +class _LevelMeter extends StatelessWidget { + const _LevelMeter({required this.active}); + + final bool active; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Container( + height: 8, + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(4), + ), + child: FractionallySizedBox( + alignment: AlignmentDirectional.centerStart, + widthFactor: active ? 0.75 : 0.05, + child: Container( + decoration: BoxDecoration( + color: active + ? theme.colorScheme.primary + : theme.colorScheme.outlineVariant, + borderRadius: BorderRadius.circular(4), + ), + ), + ), + ); + } +} diff --git a/apps/chanora_flutter/lib/widgets/voice_settings.dart b/apps/chanora_flutter/lib/widgets/voice_settings.dart new file mode 100644 index 0000000..f4f213b --- /dev/null +++ b/apps/chanora_flutter/lib/widgets/voice_settings.dart @@ -0,0 +1,167 @@ +// Voice settings dialog (SDD-097). Surfaces a TransmitMode radio +// group, a bind-key button, and a release-tail slider. + +import 'package:flutter/material.dart'; + +import '../l10n/generated/app_localizations.dart'; +import '../src/rust/api.dart' as rust; + +/// Result returned by [`VoiceSettingsDialog`]. `null` indicates a +/// cancelled dialog. +class VoiceSettingsResult { + /// Construct a result snapshot. + const VoiceSettingsResult({ + required this.mode, + required this.releaseTailMs, + required this.bindKeyRequested, + }); + + /// Selected transmit mode. + final rust.BridgeTransmitMode mode; + + /// Chosen release-tail in milliseconds (0..=500, step 25). + final int releaseTailMs; + + /// True when the user tapped the "bind key" button. The caller + /// is expected to open the focus-scoped capture dialog + /// afterwards. + final bool bindKeyRequested; +} + +/// Voice settings dialog widget. +class VoiceSettingsDialog extends StatefulWidget { + /// Construct a dialog seeded with the current settings. + const VoiceSettingsDialog({ + super.key, + required this.initialMode, + required this.initialReleaseTailMs, + }); + + /// Currently active transmit mode. + final rust.BridgeTransmitMode initialMode; + + /// Currently configured release tail in milliseconds. + final int initialReleaseTailMs; + + @override + State createState() => _VoiceSettingsDialogState(); +} + +class _VoiceSettingsDialogState extends State { + late rust.BridgeTransmitMode _mode; + late double _releaseTail; + + @override + void initState() { + super.initState(); + _mode = widget.initialMode; + _releaseTail = widget.initialReleaseTailMs.clamp(0, 500).toDouble(); + } + + @override + Widget build(BuildContext context) { + final l10n = AppL10n.of(context); + final theme = Theme.of(context); + return AlertDialog( + title: Text(l10n.voiceSettingsTitle), + content: SizedBox( + width: 360, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.voiceModeLabel, + style: theme.textTheme.titleSmall, + ), + const SizedBox(height: 4), + RadioListTile( + dense: true, + value: rust.BridgeTransmitMode.ptt, + groupValue: _mode, + title: Text(l10n.voiceModePtt), + onChanged: (v) => setState(() => _mode = v!), + ), + RadioListTile( + dense: true, + value: rust.BridgeTransmitMode.continuous, + groupValue: _mode, + title: Text(l10n.voiceModeContinuous), + onChanged: (v) => setState(() => _mode = v!), + ), + RadioListTile( + dense: true, + value: rust.BridgeTransmitMode.voiceActivity, + groupValue: _mode, + title: Text(l10n.voiceModeVoiceActivity), + secondary: Text( + l10n.voiceModeComingSoon, + style: theme.textTheme.bodySmall, + ), + // VoiceActivity is reserved per DEC-030 — keep the + // tile visible but disabled per SDD-095. + onChanged: null, + ), + const Divider(), + OutlinedButton.icon( + icon: const Icon(Icons.keyboard), + label: Text(l10n.voiceBindKeyAction), + onPressed: () { + Navigator.of(context).pop( + VoiceSettingsResult( + mode: _mode, + releaseTailMs: _releaseTail.round(), + bindKeyRequested: true, + ), + ); + }, + ), + const SizedBox(height: 8), + Text( + l10n.voiceReleaseTailLabel, + style: theme.textTheme.titleSmall, + ), + Row( + children: [ + Expanded( + child: Slider( + value: _releaseTail, + min: 0, + max: 500, + divisions: 20, // step 25 ms + 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, + ), + ), + ], + ), + ], + ), + ), + 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, + ), + ), + child: Text(l10n.pttConfigureSaveAction), + ), + ], + ); + } +} diff --git a/apps/chanora_flutter/test/beta_e2e_test.dart b/apps/chanora_flutter/test/beta_e2e_test.dart index 8137906..fd3c3d1 100644 --- a/apps/chanora_flutter/test/beta_e2e_test.dart +++ b/apps/chanora_flutter/test/beta_e2e_test.dart @@ -35,7 +35,7 @@ void main() { expect(snap.serverName, isNotEmpty); expect(snap.channels, isNotEmpty); - await rust.startAudio(); + await rust.voiceJoin(channelId: snap.channels.first.id, password: ''); // Initial stats: PTT off, no frames sent yet. final s0 = await rust.audioStats(); diff --git a/core/chanora_core/src/lib.rs b/core/chanora_core/src/lib.rs index 3fa2386..86695c6 100644 --- a/core/chanora_core/src/lib.rs +++ b/core/chanora_core/src/lib.rs @@ -48,7 +48,8 @@ use tracing::{info, warn}; pub mod ptt; pub use chanora_audio::{ - AudioEngine, AudioEngineConfig, PttBackendDescriptor, PttCapabilityLevel, + AudioEngine, AudioEngineConfig, AudioTransmitGate, PttBackendDescriptor, + PttCapabilityLevel, ReleaseTailTimer, TransmitMode, TransmitModeSelector, }; pub use chanora_audio::{PttBinding, PttInputClass}; pub use chanora_diagnostics::{ @@ -156,6 +157,21 @@ pub enum SessionEvent { /// no binding is active. bound_input_class: String, }, + /// Voice subsystem state snapshot (SDD-094). Emitted on + /// `voice_join` / `voice_leave`, transmit-mode changes, + /// hard-mute toggles, and release-tail edits. + VoiceState { + /// True when the user has joined a voice channel via + /// `voice_join` and the audio engine is running. + in_channel: bool, + /// Active transmit mode encoded as + /// [`chanora_audio::TransmitMode::as_u8`]. + transmit_mode: u8, + /// True when the hard-mute clamp is engaged. + mute: bool, + /// Current release-tail in milliseconds (0..=500). + release_tail_ms: u32, + }, } /// Coarse OS-reported network state. Populated by the Flutter side @@ -231,6 +247,16 @@ pub struct ChanoraSession { /// extension). Lives alongside the identity file. Wired by /// [`Self::init_storage`]. bookmark_store: Arc>>, + /// Single transmit-mode selector for the whole session + /// lifetime (SAD-083). Re-wired to a fresh + /// [`AudioTransmitGate`] every time the audio engine starts; + /// in between it caches the user's chosen mode and hard-mute + /// so that `set_transmit_mode` / `set_hard_mute` work even + /// before any audio is running. + voice_selector: Arc, + /// Release-tail timer (SDD-096). Drives the selector's + /// `ptt_held` input from PTT key edges. + release_tail: Arc, } impl ChanoraSession { @@ -238,12 +264,24 @@ impl ChanoraSession { pub fn new() -> Self { let (events_tx, _) = broadcast::channel(EVENT_CHANNEL_CAPACITY); let (network_tx, _) = watch::channel(NetworkState::Unknown); + // Initial selector wired to a standalone gate. Once the + // audio engine starts, `start_audio` constructs a fresh + // selector wired to the engine's gate and migrates the + // cached mode + hard-mute into it. + let initial_gate = AudioTransmitGate::new(false); + let selector = Arc::new(TransmitModeSelector::new(initial_gate)); + let release_tail = Arc::new(ReleaseTailTimer::new( + selector.clone(), + chanora_audio::DEFAULT_TAIL_MS, + )); Self { inner: Arc::new(Mutex::new(None)), events_tx, network_tx, identity_store: Arc::new(Mutex::new(None)), bookmark_store: Arc::new(Mutex::new(None)), + voice_selector: selector, + release_tail, } } @@ -277,6 +315,13 @@ impl ChanoraSession { } }; let encrypts = bookmarks.encrypts_passwords(); + // Restore persisted v1 audio settings (SDD-095/096). + let persisted_mode = store.get_transmit_mode(); + if let Some(m) = TransmitMode::from_u8(persisted_mode) { + self.voice_selector.set_mode(m); + } + self.release_tail + .set_tail_ms(store.get_release_tail_ms().min(chanora_audio::MAX_TAIL_MS)); *self.identity_store.lock().await = Some(store); *self.bookmark_store.lock().await = Some(bookmarks); info!( @@ -408,6 +453,7 @@ impl ChanoraSession { cancel_rx, sup_inner.clone(), self.network_tx.subscribe(), + self.voice_selector.clone(), )); let _ = self.events_tx.send(SessionEvent::Connected { @@ -461,8 +507,14 @@ impl ChanoraSession { .protocol .take_voice_in() .ok_or(CoreError::Invariant("voice_in already taken"))?; - let engine = chanora_audio::AudioEngine::start(cfg.clone(), voice_out, voice_in)?; - let gate = engine.transmit_gate().clone(); + // Build a fresh gate, give it to the engine, and rewire + // the session's long-lived selector to it (SAD-083). The + // selector retains cached mode / hard-mute / ptt_held so + // settings set before audio-start take effect immediately. + let gate = AudioTransmitGate::new(cfg.ptt_initial); + let engine = + chanora_audio::AudioEngine::start_with_gate(cfg.clone(), voice_out, voice_in, gate.clone())?; + self.voice_selector.replace_gate(gate.clone()); state.audio = Some(engine); // Wire the PTT controller (SDD-088). It owns the platform @@ -611,6 +663,161 @@ impl ChanoraSession { Ok((audio.frames_sent(), audio.frames_received(), audio.ptt())) } + // ---------- v1 audio + PTT lifecycle (SDD-094/095/096) ---------- + + /// Idempotent helper that ensures the audio engine is running + /// (SDD-094). Starts a fresh engine with the default + /// [`AudioEngineConfig`] if none is active; otherwise leaves + /// the current engine in place. + async fn ensure_audio_running(&self) -> Result<(), CoreError> { + let need_start = { + let guard = self.inner.lock().await; + let state = guard.as_ref().ok_or(CoreError::NotConnected)?; + state.audio.is_none() + }; + if need_start { + self.start_audio(AudioEngineConfig::default()).await?; + } + Ok(()) + } + + /// Tear down the audio engine when it is no longer needed + /// (SDD-094). Called by `voice_leave`. The PTT controller is + /// torn down with it. + async fn shutdown_audio_if_idle(&self) { + let mut guard = self.inner.lock().await; + if let Some(state) = guard.as_mut() { + if let Some(controller) = state.ptt_controller.take() { + controller.stop().await; + } + if let Some(mut engine) = state.audio.take() { + engine.stop(); + let _ = self.events_tx.send(SessionEvent::AudioStopped); + } + // Record that audio is no longer desired so the + // supervisor does not re-arm it on the next reconnect. + let mut sup = state.sup_inner.lock().await; + sup.audio_running = false; + } + } + + /// Join a voice channel (SDD-094). Moves the user to + /// `channel_id`, starts the audio engine if needed, marks the + /// transmit-mode selector as in-channel, and emits + /// [`SessionEvent::VoiceState`]. + /// + /// `password` is optional; empty string is treated as none. + pub async fn voice_join( + &self, + channel_id: u64, + password: Option, + ) -> Result<(), CoreError> { + self.move_to_channel(channel_id, password).await?; + self.ensure_audio_running().await?; + self.voice_selector.set_in_channel(true); + self.emit_voice_state(true).await; + Ok(()) + } + + /// Leave the current voice channel (SDD-094). Marks the + /// selector as out-of-channel (which clamps `transmit_active` + /// to false), tears down the audio engine, and emits + /// [`SessionEvent::VoiceState`]. + pub async fn voice_leave(&self) -> Result<(), CoreError> { + self.voice_selector.set_in_channel(false); + self.shutdown_audio_if_idle().await; + self.emit_voice_state(false).await; + Ok(()) + } + + /// Update the active transmit mode (SDD-095). Persists the new + /// value to the identity store when one is wired, and re-emits + /// [`SessionEvent::VoiceState`]. + pub async fn set_transmit_mode(&self, mode: TransmitMode) -> Result<(), CoreError> { + self.voice_selector.set_mode(mode); + if let Some(store) = self.identity_store.lock().await.as_ref() { + // Persist best-effort; a missing meta file is a recoverable error. + if let Err(e) = store.set_transmit_mode(mode.as_u8()) { + warn!( + target: "chanora_core", + error = %e, + "failed to persist transmit_mode" + ); + } + } + let in_channel = self.voice_selector.in_channel(); + self.emit_voice_state(in_channel).await; + Ok(()) + } + + /// Read the active transmit mode. + pub fn transmit_mode(&self) -> TransmitMode { + self.voice_selector.mode() + } + + /// Engage or release the hard-mute clamp (SDD-094). Hard-mute + /// is the final clamp applied by the [`TransmitModeSelector`] + /// — when engaged, no audio is transmitted regardless of + /// channel or PTT state. + pub async fn set_hard_mute(&self, muted: bool) -> Result<(), CoreError> { + self.voice_selector.set_hard_mute(muted); + let in_channel = self.voice_selector.in_channel(); + self.emit_voice_state(in_channel).await; + Ok(()) + } + + /// True if hard-mute is currently engaged. + pub fn hard_mute(&self) -> bool { + self.voice_selector.hard_mute() + } + + /// Update the release-tail (SDD-096). Clamped to `0..=500` ms + /// inclusive. Persists best-effort and re-emits voice state. + pub async fn set_release_tail_ms(&self, ms: u32) -> Result<(), CoreError> { + let clamped = ms.min(chanora_audio::MAX_TAIL_MS); + self.release_tail.set_tail_ms(clamped); + if let Some(store) = self.identity_store.lock().await.as_ref() { + if let Err(e) = store.set_release_tail_ms(clamped) { + warn!( + target: "chanora_core", + error = %e, + "failed to persist release_tail_ms" + ); + } + } + let in_channel = self.voice_selector.in_channel(); + self.emit_voice_state(in_channel).await; + Ok(()) + } + + /// Current release-tail in milliseconds. + pub fn release_tail_ms(&self) -> u32 { + self.release_tail.tail_ms() + } + + /// Shared handle to the session's [`TransmitModeSelector`]. + /// Exposed for diagnostics / tests; the bridge mutates via the + /// dedicated `set_*` methods above. + pub fn transmit_selector(&self) -> Arc { + self.voice_selector.clone() + } + + /// Shared handle to the session's [`ReleaseTailTimer`]. PTT + /// input backends drive this timer's `key_down` / `key_up` + /// edges (SDD-088). + pub fn release_tail_timer(&self) -> Arc { + self.release_tail.clone() + } + + async fn emit_voice_state(&self, in_channel: bool) { + let _ = self.events_tx.send(SessionEvent::VoiceState { + in_channel, + transmit_mode: self.voice_selector.mode().as_u8(), + mute: self.voice_selector.hard_mute(), + release_tail_ms: self.release_tail.tail_ms(), + }); + } + /// Disconnect from the server. No-op if not connected. pub async fn disconnect(&self) -> Result<(), CoreError> { let mut guard = self.inner.lock().await; @@ -679,6 +886,7 @@ async fn supervisor_loop( mut cancel_rx: oneshot::Receiver<()>, sup_inner: Arc>, mut network_rx: watch::Receiver, + voice_selector: Arc, ) { let mut lost_rx = initial_lost_rx; let mut probe = initial_probe; @@ -959,12 +1167,13 @@ async fn supervisor_loop( if let Some(state) = guard.as_mut() { let voice_out = state.protocol.voice_out(); if let Some(voice_in) = state.protocol.take_voice_in() { - match chanora_audio::AudioEngine::start( - audio_cfg, voice_out, voice_in, + let gate = chanora_audio::AudioTransmitGate::new(audio_cfg.ptt_initial); + match chanora_audio::AudioEngine::start_with_gate( + audio_cfg, voice_out, voice_in, gate.clone(), ) { Ok(engine) => { - let gate = engine.transmit_gate().clone(); state.audio = Some(engine); + voice_selector.replace_gate(gate.clone()); // Re-arm the PTT controller against // the new engine's gate (SDD-088). let controller = ptt::PttController::new(gate); diff --git a/crates/chanora_audio/src/engine.rs b/crates/chanora_audio/src/engine.rs index b071141..799fbd3 100644 --- a/crates/chanora_audio/src/engine.rs +++ b/crates/chanora_audio/src/engine.rs @@ -137,9 +137,23 @@ impl AudioEngine { /// Start the engine: open capture + playback streams, spawn the /// inbound-voice forwarder, return a handle. pub fn start( + cfg: AudioEngineConfig, + voice_out_tx: mpsc::Sender, + voice_in_rx: mpsc::Receiver, + ) -> Result { + let gate = crate::ptt::AudioTransmitGate::new(cfg.ptt_initial); + Self::start_with_gate(cfg, voice_out_tx, voice_in_rx, gate) + } + + /// Start the engine using an externally-owned + /// [`AudioTransmitGate`]. The gate is shared with whatever + /// upstream (typically [`crate::TransmitModeSelector`]) is the + /// authoritative writer of `transmit_active`. See SAD-083. + pub fn start_with_gate( cfg: AudioEngineConfig, voice_out_tx: mpsc::Sender, mut voice_in_rx: mpsc::Receiver, + transmit_gate: crate::ptt::AudioTransmitGate, ) -> Result { let host = cpal::default_host(); info!( @@ -237,7 +251,6 @@ impl AudioEngine { } } - let transmit_gate = crate::ptt::AudioTransmitGate::new(cfg.ptt_initial); let transmit_flag_for_capture = transmit_gate.flag_arc(); let frames_sent = Arc::new(AtomicU32::new(0)); let frames_received = Arc::new(AtomicU32::new(0)); diff --git a/crates/chanora_audio/src/lib.rs b/crates/chanora_audio/src/lib.rs index a875059..9a8a2b8 100644 --- a/crates/chanora_audio/src/lib.rs +++ b/crates/chanora_audio/src/lib.rs @@ -31,6 +31,9 @@ mod engine; pub mod ptt; pub mod ptt_backends; +pub mod release_tail; +pub mod transmit_mode; +pub mod transmit_selector; pub use engine::{AudioEngine, AudioEngineConfig}; pub use ptt::{ @@ -40,6 +43,9 @@ pub use ptt_backends::{ select as select_ptt_backend, DesktopPttBackend, FocusedPttBackend, PttBackendError, PttBinding, PttInputClass, }; +pub use release_tail::{ReleaseTailTimer, DEFAULT_TAIL_MS, MAX_TAIL_MS}; +pub use transmit_mode::TransmitMode; +pub use transmit_selector::TransmitModeSelector; use thiserror::Error; diff --git a/crates/chanora_audio/src/release_tail.rs b/crates/chanora_audio/src/release_tail.rs new file mode 100644 index 0000000..0a65b23 --- /dev/null +++ b/crates/chanora_audio/src/release_tail.rs @@ -0,0 +1,181 @@ +//! Release-tail timer (SDD-096). +//! +//! When a PTT key is released we don't immediately cut transmission +//! — we keep the gate open for a short configurable tail (0–500 ms, +//! default 200 ms) so room reverb and the trailing edge of words +//! aren't clipped. A subsequent `key_down` within the tail window +//! cancels the pending release so transmission stays continuous. +//! +//! The timer drives the `ptt_held` input of a +//! [`crate::transmit_selector::TransmitModeSelector`] rather than +//! the [`crate::AudioTransmitGate`] directly — the selector then +//! decides whether the desired gate state is `true` or `false` +//! based on the current [`crate::TransmitMode`]. This keeps a +//! single owner of `transmit_active` (SAD-083). +//! +//! Threading model: +//! +//! * `tail_ms` is an [`AtomicU32`] so config changes are visible +//! immediately to any in-flight release task. +//! * The pending [`JoinHandle`] is held in a [`std::sync::Mutex`]. +//! The mutex is only ever touched on PTT *edge* transitions +//! (`key_down` / `key_up`) — never on the audio frame hot path +//! — so the brief acquisition is acceptable. + +use std::sync::atomic::{AtomicU32, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use tokio::task::JoinHandle; + +use crate::transmit_selector::TransmitModeSelector; + +/// Maximum configurable release-tail, in milliseconds. +pub const MAX_TAIL_MS: u32 = 500; + +/// Default release-tail (SDD-096). +pub const DEFAULT_TAIL_MS: u32 = 200; + +/// Coalesces a PTT key release into a deferred selector update. +/// +/// Cheap to clone via `Arc`. +pub struct ReleaseTailTimer { + selector: Arc, + pending: Arc>>>, + tail_ms: AtomicU32, +} + +impl ReleaseTailTimer { + /// Construct a new timer wired to `selector`. `tail_ms` is + /// clamped to `0..=MAX_TAIL_MS` (SDD-096). + pub fn new(selector: Arc, tail_ms: u32) -> Self { + Self { + selector, + pending: Arc::new(Mutex::new(None)), + tail_ms: AtomicU32::new(tail_ms.min(MAX_TAIL_MS)), + } + } + + /// Update the configured tail, clamped to `0..=MAX_TAIL_MS`. + pub fn set_tail_ms(&self, ms: u32) { + self.tail_ms.store(ms.min(MAX_TAIL_MS), Ordering::Relaxed); + } + + /// Current configured tail (post-clamp). + pub fn tail_ms(&self) -> u32 { + self.tail_ms.load(Ordering::Relaxed) + } + + /// Notify the timer that the PTT key went down. Cancels any + /// pending release and immediately marks the selector's + /// `ptt_held` input as `true`. + pub fn key_down(&self) { + self.cancel_pending(); + self.selector.set_ptt_held(true); + } + + /// Notify the timer that the PTT key went up. Spawns a task + /// that sleeps for `tail_ms` and then clears the selector's + /// `ptt_held` input. A subsequent [`Self::key_down`] within + /// the window cancels this task. + pub fn key_up(&self) { + let tail = self.tail_ms(); + let selector = self.selector.clone(); + let new_handle = tokio::spawn(async move { + if tail > 0 { + tokio::time::sleep(Duration::from_millis(tail as u64)).await; + } + selector.set_ptt_held(false); + }); + if let Ok(mut g) = self.pending.lock() { + if let Some(prev) = g.replace(new_handle) { + prev.abort(); + } + } + } + + /// Cancel any pending release task and leave the selector's + /// `ptt_held` flag at whatever value it currently holds. + pub fn cancel(&self) { + self.cancel_pending(); + } + + fn cancel_pending(&self) { + if let Ok(mut g) = self.pending.lock() { + if let Some(h) = g.take() { + h.abort(); + } + } + } +} + +impl Drop for ReleaseTailTimer { + fn drop(&mut self) { + self.cancel_pending(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ptt::AudioTransmitGate; + use crate::transmit_mode::TransmitMode; + + fn setup(tail_ms: u32) -> (AudioTransmitGate, Arc, ReleaseTailTimer) { + let gate = AudioTransmitGate::new(false); + let sel = Arc::new(TransmitModeSelector::new(gate.clone())); + sel.set_mode(TransmitMode::Ptt); + sel.set_in_channel(true); + let timer = ReleaseTailTimer::new(sel.clone(), tail_ms); + (gate, sel, timer) + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn release_clears_after_tail() { + let (gate, _sel, timer) = setup(80); + timer.key_down(); + assert!(gate.load()); + timer.key_up(); + // Still transmitting during the tail. + tokio::time::sleep(Duration::from_millis(20)).await; + assert!(gate.load(), "should still be true during tail"); + // After the tail elapses, gate clears. + tokio::time::sleep(Duration::from_millis(120)).await; + assert!(!gate.load(), "gate should clear after tail"); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn redown_within_tail_cancels_release() { + let (gate, _sel, timer) = setup(200); + timer.key_down(); + timer.key_up(); + tokio::time::sleep(Duration::from_millis(20)).await; + timer.key_down(); + // Wait past the original tail; gate must still be true. + tokio::time::sleep(Duration::from_millis(250)).await; + assert!(gate.load(), "subsequent key_down should cancel pending release"); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn zero_tail_clears_immediately() { + let (gate, _sel, timer) = setup(0); + timer.key_down(); + assert!(gate.load()); + timer.key_up(); + tokio::time::sleep(Duration::from_millis(30)).await; + assert!(!gate.load()); + } + + #[test] + fn set_tail_ms_clamps_to_max() { + let gate = AudioTransmitGate::new(false); + let sel = Arc::new(TransmitModeSelector::new(gate)); + let timer = ReleaseTailTimer::new(sel, 100); + timer.set_tail_ms(99999); + assert_eq!(timer.tail_ms(), MAX_TAIL_MS); + timer.set_tail_ms(0); + assert_eq!(timer.tail_ms(), 0); + timer.set_tail_ms(MAX_TAIL_MS); + assert_eq!(timer.tail_ms(), MAX_TAIL_MS); + } +} diff --git a/crates/chanora_audio/src/transmit_mode.rs b/crates/chanora_audio/src/transmit_mode.rs new file mode 100644 index 0000000..ba29c13 --- /dev/null +++ b/crates/chanora_audio/src/transmit_mode.rs @@ -0,0 +1,90 @@ +//! Voice transmit mode (SDD-095). +//! +//! Selects how `transmit_active` is driven from the user's input +//! signals. Persisted per-identity in +//! [`chanora_storage::IdentityFileStore`] under the `transmit_mode` +//! metadata key (default [`TransmitMode::Ptt`]). +//! +//! `VoiceActivity` is reserved per DEC-030 — for v1 the +//! [`crate::transmit_selector::TransmitModeSelector`] treats it +//! exactly like [`TransmitMode::Continuous`] until a real VAD +//! implementation lands. + +/// User-visible voice transmit mode. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[repr(u8)] +pub enum TransmitMode { + /// Push-to-talk: transmit only while the bound key is held + /// (with release-tail per SDD-096). + Ptt = 0, + /// Continuous: transmit whenever the user is in a voice + /// channel and not hard-muted. + Continuous = 1, + /// Voice activity detection. Reserved per DEC-030; v1 behaves + /// as [`TransmitMode::Continuous`] until a VAD implementation + /// is allocated. + VoiceActivity = 2, +} + +impl Default for TransmitMode { + fn default() -> Self { + Self::Ptt + } +} + +impl TransmitMode { + /// Encode as the persisted single-byte value. + pub fn as_u8(self) -> u8 { + self as u8 + } + + /// Decode from the persisted single-byte value. Returns + /// `None` for unknown encodings (the storage layer should + /// fall back to [`TransmitMode::default`] in that case). + pub fn from_u8(v: u8) -> Option { + match v { + 0 => Some(Self::Ptt), + 1 => Some(Self::Continuous), + 2 => Some(Self::VoiceActivity), + _ => None, + } + } + + /// Stable diagnostic identifier (never localised). + pub fn as_str(self) -> &'static str { + match self { + Self::Ptt => "ptt", + Self::Continuous => "continuous", + Self::VoiceActivity => "voice-activity", + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_is_ptt() { + assert_eq!(TransmitMode::default(), TransmitMode::Ptt); + } + + #[test] + fn round_trip_u8() { + for m in [ + TransmitMode::Ptt, + TransmitMode::Continuous, + TransmitMode::VoiceActivity, + ] { + assert_eq!(TransmitMode::from_u8(m.as_u8()), Some(m)); + } + assert_eq!(TransmitMode::from_u8(99), None); + } + + #[test] + fn as_str_is_stable() { + assert_eq!(TransmitMode::Ptt.as_str(), "ptt"); + assert_eq!(TransmitMode::Continuous.as_str(), "continuous"); + assert_eq!(TransmitMode::VoiceActivity.as_str(), "voice-activity"); + } +} diff --git a/crates/chanora_audio/src/transmit_selector.rs b/crates/chanora_audio/src/transmit_selector.rs new file mode 100644 index 0000000..e4574b5 --- /dev/null +++ b/crates/chanora_audio/src/transmit_selector.rs @@ -0,0 +1,206 @@ +//! Cross-platform transmit-mode selector (SAD-083). +//! +//! Single writer of `transmit_active` other than the missed-key-up +//! watchdog (SAD-079). Computes the desired gate state from four +//! lock-free inputs: +//! +//! * `mode` — current [`TransmitMode`] +//! * `in_channel` — true when the session is in a voice channel +//! * `hard_mute` — final clamp; forces `false` regardless of mode +//! * `ptt_held` — raw key state (via [`crate::ReleaseTailTimer`] +//! on PTT mode) +//! +//! Hard-mute is a final clamp; leaving the channel forces the gate +//! to `false`. `VoiceActivity` is treated identically to +//! `Continuous` per DEC-030 until a VAD implementation lands. +//! +//! All four inputs are stored as atomics so any thread can update +//! them without taking a lock. After each update we call +//! [`TransmitModeSelector::recompute`] which writes the resolved +//! desired value through the [`AudioTransmitGate`]. + +use std::sync::atomic::{AtomicBool, AtomicU8, Ordering}; + +use crate::ptt::AudioTransmitGate; +use crate::transmit_mode::TransmitMode; + +/// Selector that maps user/session state to the `transmit_active` +/// gate (SAD-083). +pub struct TransmitModeSelector { + gate: std::sync::RwLock, + mode: AtomicU8, + in_channel: AtomicBool, + hard_mute: AtomicBool, + ptt_held: AtomicBool, +} + +impl TransmitModeSelector { + /// Construct a selector wired to `gate`. Defaults: + /// [`TransmitMode::Ptt`], not in channel, not muted, key + /// released. The gate's initial value is left untouched until + /// the first mutating call (which then writes the resolved + /// value). + pub fn new(gate: AudioTransmitGate) -> Self { + Self { + gate: std::sync::RwLock::new(gate), + mode: AtomicU8::new(TransmitMode::default().as_u8()), + in_channel: AtomicBool::new(false), + hard_mute: AtomicBool::new(false), + ptt_held: AtomicBool::new(false), + } + } + + /// Rewire the selector to a fresh [`AudioTransmitGate`] + /// (typically the gate exposed by a newly-started + /// [`crate::AudioEngine`]). Cached mode / channel / mute / + /// ptt_held are preserved; the new gate is immediately + /// updated to the resolved value. + pub fn replace_gate(&self, gate: AudioTransmitGate) { + if let Ok(mut g) = self.gate.write() { + *g = gate; + } + self.recompute(); + } + + /// Update the selected mode and re-evaluate the gate. + pub fn set_mode(&self, m: TransmitMode) { + self.mode.store(m.as_u8(), Ordering::Relaxed); + self.recompute(); + } + + /// Current selected mode. + pub fn mode(&self) -> TransmitMode { + TransmitMode::from_u8(self.mode.load(Ordering::Relaxed)).unwrap_or_default() + } + + /// Update channel membership and re-evaluate. + pub fn set_in_channel(&self, v: bool) { + self.in_channel.store(v, Ordering::Relaxed); + self.recompute(); + } + + /// Current channel-membership flag. + pub fn in_channel(&self) -> bool { + self.in_channel.load(Ordering::Relaxed) + } + + /// Final-clamp hard mute. When `true` the gate is forced to + /// `false` regardless of mode. + pub fn set_hard_mute(&self, v: bool) { + self.hard_mute.store(v, Ordering::Relaxed); + self.recompute(); + } + + /// Current hard-mute flag. + pub fn hard_mute(&self) -> bool { + self.hard_mute.load(Ordering::Relaxed) + } + + /// PTT key state (set by the platform input backend through + /// [`crate::ReleaseTailTimer`]). + pub fn set_ptt_held(&self, v: bool) { + self.ptt_held.store(v, Ordering::Relaxed); + self.recompute(); + } + + /// Current PTT-held flag. + pub fn ptt_held(&self) -> bool { + self.ptt_held.load(Ordering::Relaxed) + } + + /// Shared snapshot of the underlying gate. Provided for the + /// audio engine's hot read path. Returns a clone so callers + /// don't hold the internal lock. + pub fn gate(&self) -> AudioTransmitGate { + self.gate.read().expect("selector gate lock poisoned").clone() + } + + fn compute(&self) -> bool { + if self.hard_mute.load(Ordering::Relaxed) { + return false; + } + if !self.in_channel.load(Ordering::Relaxed) { + return false; + } + match self.mode() { + TransmitMode::Ptt => self.ptt_held.load(Ordering::Relaxed), + // DEC-030: VoiceActivity behaves as Continuous in v1. + TransmitMode::Continuous | TransmitMode::VoiceActivity => true, + } + } + + /// Recompute the desired gate state and publish it. Exposed + /// for tests; callers normally trigger this implicitly through + /// the setter methods. + pub fn recompute(&self) { + let desired = self.compute(); + if let Ok(g) = self.gate.read() { + g.set(desired); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn fresh() -> (AudioTransmitGate, TransmitModeSelector) { + let g = AudioTransmitGate::new(false); + let s = TransmitModeSelector::new(g.clone()); + (g, s) + } + + #[test] + fn ptt_requires_channel_and_key() { + let (g, s) = fresh(); + s.set_mode(TransmitMode::Ptt); + s.set_ptt_held(true); + assert!(!g.load(), "no channel -> false"); + s.set_in_channel(true); + assert!(g.load(), "channel + key -> true"); + s.set_ptt_held(false); + assert!(!g.load(), "key released -> false"); + } + + #[test] + fn continuous_ignores_ptt_held() { + let (g, s) = fresh(); + s.set_mode(TransmitMode::Continuous); + s.set_in_channel(true); + assert!(g.load(), "channel + continuous -> true"); + s.set_ptt_held(true); + assert!(g.load()); + s.set_ptt_held(false); + assert!(g.load(), "continuous independent of key state"); + } + + #[test] + fn voice_activity_matches_continuous_v1() { + let (g, s) = fresh(); + s.set_mode(TransmitMode::VoiceActivity); + s.set_in_channel(true); + assert!(g.load()); + } + + #[test] + fn hard_mute_clamps() { + let (g, s) = fresh(); + s.set_mode(TransmitMode::Continuous); + s.set_in_channel(true); + assert!(g.load()); + s.set_hard_mute(true); + assert!(!g.load(), "hard mute clamps to false"); + s.set_hard_mute(false); + assert!(g.load()); + } + + #[test] + fn leaving_channel_forces_false() { + let (g, s) = fresh(); + s.set_mode(TransmitMode::Continuous); + s.set_in_channel(true); + assert!(g.load()); + s.set_in_channel(false); + assert!(!g.load()); + } +} diff --git a/crates/chanora_bridge/src/api.rs b/crates/chanora_bridge/src/api.rs index 211d5bd..7231457 100644 --- a/crates/chanora_bridge/src/api.rs +++ b/crates/chanora_bridge/src/api.rs @@ -232,24 +232,128 @@ pub async fn is_connected() -> bool { // ---------- Audio commands (Beta) ---------- -/// Start the audio engine on the active connection. Requires a -/// connection; idempotent (will replace any previous engine). -pub async fn start_audio() -> Result<(), BridgeError> { +// `start_audio` was removed per SDD-094 — voice activation now +// flows through `voice_join` / `voice_leave`, which transparently +// drive `AudioEngine::ensure_running` / `shutdown_if_idle`. + +/// Set the push-to-talk state. +/// +/// Superseded in v1 by [`set_transmit_mode`] + the binding capture +/// dialog. Retained so legacy callers and integration tests keep +/// working; the new VoiceBar UI no longer invokes this. +pub async fn set_ptt(active: bool) -> Result<(), BridgeError> { runtime() - .spawn(async { - session() - .start_audio(chanora_core::AudioEngineConfig::default()) - .await - }) + .spawn(async move { session().set_ptt(active).await }) .await .map_err(|e| BridgeError::Unmapped(format!("join: {e}")))??; Ok(()) } -/// Set the push-to-talk state. -pub async fn set_ptt(active: bool) -> Result<(), BridgeError> { +// ---------- v1 voice lifecycle (SDD-094/095/096) ---------- + +/// Voice transmit mode mirror (SDD-095). Schema-controlled enum; +/// the wire encoding matches [`chanora_core::TransmitMode::as_u8`]. +#[derive(Debug, Clone, Copy)] +pub enum BridgeTransmitMode { + /// Push-to-talk (default). + Ptt, + /// Continuous transmission while in channel and not muted. + Continuous, + /// Voice-activity detection — reserved per DEC-030; v1 behaves + /// as `Continuous`. + VoiceActivity, +} + +impl From for chanora_core::TransmitMode { + fn from(m: BridgeTransmitMode) -> Self { + match m { + BridgeTransmitMode::Ptt => Self::Ptt, + BridgeTransmitMode::Continuous => Self::Continuous, + BridgeTransmitMode::VoiceActivity => Self::VoiceActivity, + } + } +} + +impl From for BridgeTransmitMode { + fn from(m: chanora_core::TransmitMode) -> Self { + match m { + chanora_core::TransmitMode::Ptt => Self::Ptt, + chanora_core::TransmitMode::Continuous => Self::Continuous, + chanora_core::TransmitMode::VoiceActivity => Self::VoiceActivity, + } + } +} + +fn transmit_mode_from_u8(v: u8) -> BridgeTransmitMode { + chanora_core::TransmitMode::from_u8(v) + .unwrap_or_default() + .into() +} + +/// Join a voice channel (SDD-094). Moves the user to `channel_id`, +/// brings up the audio engine if needed, and emits +/// `BridgeEvent::VoiceState`. `password` may be empty. +pub async fn voice_join(channel_id: u64, password: String) -> Result<(), BridgeError> { + let pw = if password.is_empty() { None } else { Some(password) }; runtime() - .spawn(async move { session().set_ptt(active).await }) + .spawn(async move { session().voice_join(channel_id, pw).await }) + .await + .map_err(|e| BridgeError::Unmapped(format!("join: {e}")))??; + Ok(()) +} + +/// Leave the current voice channel (SDD-094). Tears down the audio +/// engine and emits `BridgeEvent::VoiceState`. +pub async fn voice_leave() -> Result<(), BridgeError> { + runtime() + .spawn(async { session().voice_leave().await }) + .await + .map_err(|e| BridgeError::Unmapped(format!("join: {e}")))??; + Ok(()) +} + +/// Set the active transmit mode (SDD-095). +pub async fn set_transmit_mode(mode: BridgeTransmitMode) -> Result<(), BridgeError> { + runtime() + .spawn(async move { session().set_transmit_mode(mode.into()).await }) + .await + .map_err(|e| BridgeError::Unmapped(format!("join: {e}")))??; + Ok(()) +} + +/// Read the active transmit mode. +pub async fn get_transmit_mode() -> BridgeTransmitMode { + runtime() + .spawn(async { session().transmit_mode() }) + .await + .unwrap_or(chanora_core::TransmitMode::Ptt) + .into() +} + +/// Update the release-tail in milliseconds (SDD-096). Values are +/// clamped to `0..=500` on the Rust side; passing anything larger +/// silently saturates. +pub async fn set_release_tail_ms(ms: u32) -> Result<(), BridgeError> { + runtime() + .spawn(async move { session().set_release_tail_ms(ms).await }) + .await + .map_err(|e| BridgeError::Unmapped(format!("join: {e}")))??; + Ok(()) +} + +/// Read the current release-tail in milliseconds. +pub async fn get_release_tail_ms() -> u32 { + runtime() + .spawn(async { session().release_tail_ms() }) + .await + .unwrap_or(200) +} + +/// Engage or release the hard-mute clamp (SDD-094). When `true` +/// the audio engine transmits nothing regardless of mode. +pub async fn set_hard_mute(muted: bool) -> Result<(), BridgeError> { + runtime() + .spawn(async move { session().set_hard_mute(muted).await }) .await .map_err(|e| BridgeError::Unmapped(format!("join: {e}")))??; Ok(()) @@ -579,6 +683,19 @@ pub enum BridgeEvent { /// `"mouse-side-button"`); empty when no binding is active. bound_input_class: String, }, + /// Voice subsystem state snapshot (SDD-094). The Flutter + /// VoiceBar listens to this stream. + VoiceState { + /// True when the session is currently joined to a voice + /// channel and the audio engine is running. + in_channel: bool, + /// Active transmit mode. + transmit_mode: BridgeTransmitMode, + /// True when the hard-mute clamp is engaged. + mute: bool, + /// Current release-tail in milliseconds (0..=500). + release_tail_ms: u32, + }, } impl From for BridgeEvent { @@ -612,6 +729,17 @@ impl From for BridgeEvent { backend_id, bound_input_class, }, + chanora_core::SessionEvent::VoiceState { + in_channel, + transmit_mode, + mute, + release_tail_ms, + } => BridgeEvent::VoiceState { + in_channel, + transmit_mode: transmit_mode_from_u8(transmit_mode), + mute, + release_tail_ms, + }, } } } diff --git a/crates/chanora_bridge/src/frb_generated.rs b/crates/chanora_bridge/src/frb_generated.rs index bc0046f..c9cc08b 100644 --- a/crates/chanora_bridge/src/frb_generated.rs +++ b/crates/chanora_bridge/src/frb_generated.rs @@ -38,7 +38,7 @@ flutter_rust_bridge::frb_generated_boilerplate!( default_rust_auto_opaque = RustAutoOpaqueMoi, ); pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_VERSION: &str = "2.12.0"; -pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = -427953414; +pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = -330689763; // Section: executor @@ -326,6 +326,77 @@ fn wire__crate__api__export_diagnostics_impl( }, ) } +fn wire__crate__api__get_release_tail_ms_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "get_release_tail_ms", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + deserializer.end(); + move |context| async move { + transform_result_sse::<_, ()>( + (move || async move { + let output_ok = + Result::<_, ()>::Ok(crate::api::get_release_tail_ms().await)?; + Ok(output_ok) + })() + .await, + ) + } + }, + ) +} +fn wire__crate__api__get_transmit_mode_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "get_transmit_mode", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + deserializer.end(); + move |context| async move { + transform_result_sse::<_, ()>( + (move || async move { + let output_ok = Result::<_, ()>::Ok(crate::api::get_transmit_mode().await)?; + Ok(output_ok) + })() + .await, + ) + } + }, + ) +} fn wire__crate__api__init_storage_impl( port_: flutter_rust_bridge::for_generated::MessagePort, ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, @@ -505,6 +576,42 @@ fn wire__crate__api__ptt_descriptor_impl( }, ) } +fn wire__crate__api__set_hard_mute_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "set_hard_mute", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_muted = ::sse_decode(&mut deserializer); + deserializer.end(); + move |context| async move { + transform_result_sse::<_, crate::BridgeError>( + (move || async move { + let output_ok = crate::api::set_hard_mute(api_muted).await?; + Ok(output_ok) + })() + .await, + ) + } + }, + ) +} fn wire__crate__api__set_input_muted_impl( port_: flutter_rust_bridge::for_generated::MessagePort, ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, @@ -719,6 +826,78 @@ fn wire__crate__api__set_ptt_binding_impl( }, ) } +fn wire__crate__api__set_release_tail_ms_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "set_release_tail_ms", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_ms = ::sse_decode(&mut deserializer); + deserializer.end(); + move |context| async move { + transform_result_sse::<_, crate::BridgeError>( + (move || async move { + let output_ok = crate::api::set_release_tail_ms(api_ms).await?; + Ok(output_ok) + })() + .await, + ) + } + }, + ) +} +fn wire__crate__api__set_transmit_mode_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "set_transmit_mode", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_mode = ::sse_decode(&mut deserializer); + deserializer.end(); + move |context| async move { + transform_result_sse::<_, crate::BridgeError>( + (move || async move { + let output_ok = crate::api::set_transmit_mode(api_mode).await?; + Ok(output_ok) + })() + .await, + ) + } + }, + ) +} fn wire__crate__api__snapshot_impl( port_: flutter_rust_bridge::for_generated::MessagePort, ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, @@ -754,41 +933,6 @@ fn wire__crate__api__snapshot_impl( }, ) } -fn wire__crate__api__start_audio_impl( - port_: flutter_rust_bridge::for_generated::MessagePort, - ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, - rust_vec_len_: i32, - data_len_: i32, -) { - FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::( - flutter_rust_bridge::for_generated::TaskInfo { - debug_name: "start_audio", - port: Some(port_), - mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, - }, - move || { - let message = unsafe { - flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( - ptr_, - rust_vec_len_, - data_len_, - ) - }; - let mut deserializer = - flutter_rust_bridge::for_generated::SseDeserializer::new(message); - deserializer.end(); - move |context| async move { - transform_result_sse::<_, crate::BridgeError>( - (move || async move { - let output_ok = crate::api::start_audio().await?; - Ok(output_ok) - })() - .await, - ) - } - }, - ) -} fn wire__crate__api__update_bookmark_impl( port_: flutter_rust_bridge::for_generated::MessagePort, ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, @@ -825,6 +969,79 @@ fn wire__crate__api__update_bookmark_impl( }, ) } +fn wire__crate__api__voice_join_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "voice_join", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_channel_id = ::sse_decode(&mut deserializer); + let api_password = ::sse_decode(&mut deserializer); + deserializer.end(); + move |context| async move { + transform_result_sse::<_, crate::BridgeError>( + (move || async move { + let output_ok = + crate::api::voice_join(api_channel_id, api_password).await?; + Ok(output_ok) + })() + .await, + ) + } + }, + ) +} +fn wire__crate__api__voice_leave_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "voice_leave", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + deserializer.end(); + move |context| async move { + transform_result_sse::<_, crate::BridgeError>( + (move || async move { + let output_ok = crate::api::voice_leave().await?; + Ok(output_ok) + })() + .await, + ) + } + }, + ) +} // Section: dart2rust @@ -1012,6 +1229,19 @@ impl SseDecode for crate::api::BridgeEvent { bound_input_class: var_boundInputClass, }; } + 8 => { + let mut var_inChannel = ::sse_decode(deserializer); + let mut var_transmitMode = + ::sse_decode(deserializer); + let mut var_mute = ::sse_decode(deserializer); + let mut var_releaseTailMs = ::sse_decode(deserializer); + return crate::api::BridgeEvent::VoiceState { + in_channel: var_inChannel, + transmit_mode: var_transmitMode, + mute: var_mute, + release_tail_ms: var_releaseTailMs, + }; + } _ => { unimplemented!(""); } @@ -1065,6 +1295,19 @@ impl SseDecode for crate::api::BridgeSnapshot { } } +impl SseDecode for crate::api::BridgeTransmitMode { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut inner = ::sse_decode(deserializer); + return match inner { + 0 => crate::api::BridgeTransmitMode::Ptt, + 1 => crate::api::BridgeTransmitMode::Continuous, + 2 => crate::api::BridgeTransmitMode::VoiceActivity, + _ => unreachable!("Invalid variant for BridgeTransmitMode: {}", inner), + }; + } +} + impl SseDecode for f32 { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { @@ -1186,19 +1429,25 @@ fn pde_ffi_dispatcher_primary_impl( 5 => wire__crate__api__delete_bookmark_impl(port, ptr, rust_vec_len, data_len), 6 => wire__crate__api__disconnect_impl(port, ptr, rust_vec_len, data_len), 7 => wire__crate__api__events_stream_impl(port, ptr, rust_vec_len, data_len), - 9 => wire__crate__api__init_storage_impl(port, ptr, rust_vec_len, data_len), - 10 => wire__crate__api__is_connected_impl(port, ptr, rust_vec_len, data_len), - 11 => wire__crate__api__list_bookmarks_impl(port, ptr, rust_vec_len, data_len), - 12 => wire__crate__api__move_to_channel_impl(port, ptr, rust_vec_len, data_len), - 13 => wire__crate__api__ptt_descriptor_impl(port, ptr, rust_vec_len, data_len), - 14 => wire__crate__api__set_input_muted_impl(port, ptr, rust_vec_len, data_len), - 16 => wire__crate__api__set_output_gain_impl(port, ptr, rust_vec_len, data_len), - 17 => wire__crate__api__set_output_muted_impl(port, ptr, rust_vec_len, data_len), - 18 => wire__crate__api__set_ptt_impl(port, ptr, rust_vec_len, data_len), - 19 => wire__crate__api__set_ptt_binding_impl(port, ptr, rust_vec_len, data_len), - 20 => wire__crate__api__snapshot_impl(port, ptr, rust_vec_len, data_len), - 21 => wire__crate__api__start_audio_impl(port, ptr, rust_vec_len, data_len), - 22 => wire__crate__api__update_bookmark_impl(port, ptr, rust_vec_len, data_len), + 9 => wire__crate__api__get_release_tail_ms_impl(port, ptr, rust_vec_len, data_len), + 10 => wire__crate__api__get_transmit_mode_impl(port, ptr, rust_vec_len, data_len), + 11 => wire__crate__api__init_storage_impl(port, ptr, rust_vec_len, data_len), + 12 => wire__crate__api__is_connected_impl(port, ptr, rust_vec_len, data_len), + 13 => wire__crate__api__list_bookmarks_impl(port, ptr, rust_vec_len, data_len), + 14 => wire__crate__api__move_to_channel_impl(port, ptr, rust_vec_len, data_len), + 15 => wire__crate__api__ptt_descriptor_impl(port, ptr, rust_vec_len, data_len), + 16 => wire__crate__api__set_hard_mute_impl(port, ptr, rust_vec_len, data_len), + 17 => wire__crate__api__set_input_muted_impl(port, ptr, rust_vec_len, data_len), + 19 => wire__crate__api__set_output_gain_impl(port, ptr, rust_vec_len, data_len), + 20 => wire__crate__api__set_output_muted_impl(port, ptr, rust_vec_len, data_len), + 21 => wire__crate__api__set_ptt_impl(port, ptr, rust_vec_len, data_len), + 22 => wire__crate__api__set_ptt_binding_impl(port, ptr, rust_vec_len, data_len), + 23 => wire__crate__api__set_release_tail_ms_impl(port, ptr, rust_vec_len, data_len), + 24 => wire__crate__api__set_transmit_mode_impl(port, ptr, rust_vec_len, data_len), + 25 => wire__crate__api__snapshot_impl(port, ptr, rust_vec_len, data_len), + 26 => wire__crate__api__update_bookmark_impl(port, ptr, rust_vec_len, data_len), + 27 => wire__crate__api__voice_join_impl(port, ptr, rust_vec_len, data_len), + 28 => wire__crate__api__voice_leave_impl(port, ptr, rust_vec_len, data_len), _ => unreachable!(), } } @@ -1212,7 +1461,7 @@ fn pde_ffi_dispatcher_sync_impl( // Codec=Pde (Serialization + dispatch), see doc to use other codecs match func_id { 8 => wire__crate__api__export_diagnostics_impl(ptr, rust_vec_len, data_len), - 15 => wire__crate__api__set_network_state_impl(ptr, rust_vec_len, data_len), + 18 => wire__crate__api__set_network_state_impl(ptr, rust_vec_len, data_len), _ => unreachable!(), } } @@ -1366,6 +1615,19 @@ impl flutter_rust_bridge::IntoDart for crate::api::BridgeEvent { bound_input_class.into_into_dart().into_dart(), ] .into_dart(), + crate::api::BridgeEvent::VoiceState { + in_channel, + transmit_mode, + mute, + release_tail_ms, + } => [ + 8.into_dart(), + in_channel.into_into_dart().into_dart(), + transmit_mode.into_into_dart().into_dart(), + mute.into_into_dart().into_dart(), + release_tail_ms.into_into_dart().into_dart(), + ] + .into_dart(), _ => { unimplemented!(""); } @@ -1442,6 +1704,28 @@ impl flutter_rust_bridge::IntoIntoDart for crate::ap self } } +// Codec=Dco (DartCObject based), see doc to use other codecs +impl flutter_rust_bridge::IntoDart for crate::api::BridgeTransmitMode { + fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi { + match self { + Self::Ptt => 0.into_dart(), + Self::Continuous => 1.into_dart(), + Self::VoiceActivity => 2.into_dart(), + _ => unreachable!(), + } + } +} +impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive + for crate::api::BridgeTransmitMode +{ +} +impl flutter_rust_bridge::IntoIntoDart + for crate::api::BridgeTransmitMode +{ + fn into_into_dart(self) -> crate::api::BridgeTransmitMode { + self + } +} impl SseEncode for flutter_rust_bridge::for_generated::anyhow::Error { // Codec=Sse (Serialization based), see doc to use other codecs @@ -1591,6 +1875,18 @@ impl SseEncode for crate::api::BridgeEvent { ::sse_encode(backend_id, serializer); ::sse_encode(bound_input_class, serializer); } + crate::api::BridgeEvent::VoiceState { + in_channel, + transmit_mode, + mute, + release_tail_ms, + } => { + ::sse_encode(8, serializer); + ::sse_encode(in_channel, serializer); + ::sse_encode(transmit_mode, serializer); + ::sse_encode(mute, serializer); + ::sse_encode(release_tail_ms, serializer); + } _ => { unimplemented!(""); } @@ -1644,6 +1940,23 @@ impl SseEncode for crate::api::BridgeSnapshot { } } +impl SseEncode for crate::api::BridgeTransmitMode { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + ::sse_encode( + match self { + crate::api::BridgeTransmitMode::Ptt => 0, + crate::api::BridgeTransmitMode::Continuous => 1, + crate::api::BridgeTransmitMode::VoiceActivity => 2, + _ => { + unimplemented!(""); + } + }, + serializer, + ); + } +} + impl SseEncode for f32 { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { diff --git a/crates/chanora_storage/Cargo.toml b/crates/chanora_storage/Cargo.toml index fa565fb..6ac4294 100644 --- a/crates/chanora_storage/Cargo.toml +++ b/crates/chanora_storage/Cargo.toml @@ -19,6 +19,8 @@ zeroize = "1" # `base64` is needed to serialise the DEK as a string for the # keyring API (which is text-only on most platforms). base64 = "0.22" +serde = { version = "1", features = ["derive"] } +serde_json = "1" # Platform keyring abstraction: Secret Service / kernel keyutils on # Linux (DEC-013.2); macOS Keychain; Windows Credential Manager; diff --git a/crates/chanora_storage/src/lib.rs b/crates/chanora_storage/src/lib.rs index 596c848..113bfea 100644 --- a/crates/chanora_storage/src/lib.rs +++ b/crates/chanora_storage/src/lib.rs @@ -49,6 +49,7 @@ use chacha20poly1305::aead::{Aead, KeyInit, OsRng}; use chacha20poly1305::{ChaCha20Poly1305, Key, Nonce}; use rand::RngCore; use rusqlite::{params, Connection}; +use serde::{Deserialize, Serialize}; use thiserror::Error; use tracing::{info, warn}; use zeroize::Zeroize; @@ -88,6 +89,34 @@ pub trait LocalDatabaseRepository: Send + Sync {} /// promoted from the secure-storage PoC. pub trait SecretStorageRepository: Send + Sync {} +/// Audio-related per-identity settings persisted alongside the +/// identity file as a small JSON blob (SDD-095 / SDD-096). These +/// are *not* secrets; they sit beside the encrypted identity in +/// app-private storage. +#[derive(Debug, Clone, Serialize, Deserialize)] +struct AudioMeta { + /// Encoded [`chanora_audio::TransmitMode`]. Default is + /// `TransmitMode::Ptt as u8 == 0`. + #[serde(default)] + transmit_mode: u8, + /// Release-tail in milliseconds (SDD-096). Default 200. + #[serde(default = "default_release_tail_ms")] + release_tail_ms: u32, +} + +fn default_release_tail_ms() -> u32 { + 200 +} + +impl Default for AudioMeta { + fn default() -> Self { + Self { + transmit_mode: 0, + release_tail_ms: 200, + } + } +} + /// Beta identity store: a single ChaCha20-Poly1305-encrypted file /// containing the base64 TS3 identity string. The Data Encryption /// Key (DEK) is 32 random bytes stored in the platform keyring @@ -410,6 +439,72 @@ impl IdentityFileStore { Ok(()) } + /// Path to the small JSON metadata file that sits alongside + /// the identity. Holds audio settings (`transmit_mode`, + /// `release_tail_ms`) per SDD-095/096. Stored in plaintext — + /// these values are not secrets. + fn meta_path(&self) -> PathBuf { + let dir = self + .path + .parent() + .map(Path::to_path_buf) + .unwrap_or_else(|| PathBuf::from(".")); + dir.join("audio_meta.json") + } + + fn read_meta(&self) -> AudioMeta { + match fs::read_to_string(self.meta_path()) { + Ok(s) => serde_json::from_str(&s).unwrap_or_default(), + Err(_) => AudioMeta::default(), + } + } + + fn write_meta(&self, m: &AudioMeta) -> Result<(), StorageError> { + let path = self.meta_path(); + let tmp = path.with_extension("json.tmp"); + let body = serde_json::to_vec_pretty(m) + .map_err(|e| StorageError::Io(format!("meta serialize: {e}")))?; + { + let mut f = fs::File::create(&tmp) + .map_err(|e| StorageError::Io(format!("open meta {tmp:?}: {e}")))?; + f.write_all(&body) + .map_err(|e| StorageError::Io(format!("write meta: {e}")))?; + f.sync_all() + .map_err(|e| StorageError::Io(format!("sync meta: {e}")))?; + } + fs::rename(&tmp, &path) + .map_err(|e| StorageError::Io(format!("rename meta {tmp:?} -> {path:?}: {e}")))?; + Ok(()) + } + + /// Persist the user's chosen transmit mode (SDD-095). The + /// encoding matches `chanora_audio::TransmitMode::as_u8()`. + pub fn set_transmit_mode(&self, mode: u8) -> Result<(), StorageError> { + let mut m = self.read_meta(); + m.transmit_mode = mode; + self.write_meta(&m) + } + + /// Read the persisted transmit mode. Defaults to `0` + /// (`TransmitMode::Ptt`) when no value has been written. + pub fn get_transmit_mode(&self) -> u8 { + self.read_meta().transmit_mode + } + + /// Persist the user's chosen release-tail (SDD-096). Clamped + /// to `0..=500` ms inclusive on write. + pub fn set_release_tail_ms(&self, ms: u32) -> Result<(), StorageError> { + let mut m = self.read_meta(); + m.release_tail_ms = ms.min(500); + self.write_meta(&m) + } + + /// Read the persisted release-tail in milliseconds. Defaults + /// to `200` (SDD-096 default) when no value has been written. + pub fn get_release_tail_ms(&self) -> u32 { + self.read_meta().release_tail_ms + } + /// Remove any persisted identity. No-op if none exists. Leaves /// the DEK in place so future saves don't generate a new one. pub fn clear(&self) -> Result<(), StorageError> { @@ -1034,6 +1129,37 @@ mod tests { assert!(row.1.is_some(), "blob should be set after upgrade"); } + #[test] + fn audio_meta_defaults_and_persists() { + force_keyring_off(); + let tmp = tempdir(); + let store = IdentityFileStore::new(&tmp).unwrap(); + // Defaults before any write. + assert_eq!(store.get_transmit_mode(), 0); + assert_eq!(store.get_release_tail_ms(), 200); + // Persist values. + store.set_transmit_mode(1).unwrap(); + store.set_release_tail_ms(75).unwrap(); + assert_eq!(store.get_transmit_mode(), 1); + assert_eq!(store.get_release_tail_ms(), 75); + // Reopen the store — values survive. + drop(store); + let store2 = IdentityFileStore::new(&tmp).unwrap(); + assert_eq!(store2.get_transmit_mode(), 1); + assert_eq!(store2.get_release_tail_ms(), 75); + } + + #[test] + fn release_tail_ms_clamped_on_write() { + force_keyring_off(); + let tmp = tempdir(); + let store = IdentityFileStore::new(&tmp).unwrap(); + store.set_release_tail_ms(9999).unwrap(); + assert_eq!(store.get_release_tail_ms(), 500); + store.set_release_tail_ms(0).unwrap(); + assert_eq!(store.get_release_tail_ms(), 0); + } + fn tempdir() -> PathBuf { let p = std::env::temp_dir() .join("chanora_storage_test")