diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 593f051..095c0e0 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -57,6 +57,8 @@ PODS: - FlutterMacOS - image_picker_ios (0.0.1): - Flutter + - lpcnet_flutter (0.1.0): + - Flutter - nsd_ios (0.0.1): - Flutter - package_info_plus (0.4.5): @@ -98,6 +100,7 @@ DEPENDENCIES: - flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`) - geolocator_apple (from `.symlinks/plugins/geolocator_apple/darwin`) - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`) + - lpcnet_flutter (from `.symlinks/plugins/lpcnet_flutter/ios`) - nsd_ios (from `.symlinks/plugins/nsd_ios/ios`) - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) @@ -141,6 +144,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/geolocator_apple/darwin" image_picker_ios: :path: ".symlinks/plugins/image_picker_ios/ios" + lpcnet_flutter: + :path: ".symlinks/plugins/lpcnet_flutter/ios" nsd_ios: :path: ".symlinks/plugins/nsd_ios/ios" package_info_plus: @@ -177,6 +182,7 @@ SPEC CHECKSUMS: flutter_local_notifications: 643a3eda1ce1c0599413ca31672536d423dee214 geolocator_apple: ab36aa0e8b7d7a2d7639b3b4e48308394e8cef5e image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326 + lpcnet_flutter: 67a3c72ea4caa511373ee9b028223e33f0e85b97 nsd_ios: 596ad79109ddd3e52d665f650f36428049e3653e package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880 diff --git a/lib/providers/contacts_provider.dart b/lib/providers/contacts_provider.dart index 97dbe4e..80cfde7 100644 --- a/lib/providers/contacts_provider.dart +++ b/lib/providers/contacts_provider.dart @@ -516,8 +516,8 @@ class ContactsProvider with ChangeNotifier { return null; } - final firstHopHex = routeBytes - .sublist(0, routeHashSize) + final lastHopHex = routeBytes + .sublist(routeBytes.length - routeHashSize) .map((byte) => byte.toRadixString(16).padLeft(2, '0')) .join(); final repeaterCandidates = _contacts.values.where((candidate) { @@ -526,7 +526,7 @@ class ContactsProvider with ChangeNotifier { return false; } final location = candidate.displayLocation; - return location != null && candidate.publicKeyHex.startsWith(firstHopHex); + return location != null && candidate.publicKeyHex.startsWith(lastHopHex); }).toList()..sort((a, b) => b.lastAdvert.compareTo(a.lastAdvert)); if (repeaterCandidates.isEmpty) { return null; diff --git a/lib/providers/voice_provider.dart b/lib/providers/voice_provider.dart index adcd223..4ebdf81 100644 --- a/lib/providers/voice_provider.dart +++ b/lib/providers/voice_provider.dart @@ -303,7 +303,7 @@ class VoiceProvider with ChangeNotifier { debugPrint('๐ŸŽ™๏ธ [VoiceProvider] decoded ${pcm.length} PCM samples'); _playingSessionId = sessionId; notifyListeners(); - await _player.play(pcm); + await _player.play(pcm, sampleRateHz: session.mode.sampleRateHz); } catch (e, st) { debugPrint('โŒ [VoiceProvider] Playback error: $e\n$st'); if (_playingSessionId == sessionId) { diff --git a/lib/screens/messages_tab.dart b/lib/screens/messages_tab.dart index 4f31a1d..a7c3fea 100644 --- a/lib/screens/messages_tab.dart +++ b/lib/screens/messages_tab.dart @@ -24,6 +24,7 @@ import '../widgets/messages/messages_content.dart'; import '../widgets/common/contact_avatar.dart'; import '../services/message_destination_preferences.dart'; import '../services/voice_bitrate_preferences.dart'; +import '../services/voice_codec_preferences.dart'; import '../services/voice_recorder_service.dart'; import '../services/voice_codec_service.dart'; import '../utils/toast_logger.dart'; @@ -81,6 +82,7 @@ class _MessagesTabState extends State { final List _recordedChunks = []; VoicePacketMode? _activeVoiceMode; int _selectedVoiceBitrate = VoiceBitratePreferences.defaultBitrate; + VoiceCodecKind _selectedVoiceCodec = VoiceCodecPreferences.defaultCodec; @override void initState() { @@ -88,17 +90,19 @@ class _MessagesTabState extends State { _textController.addListener(_updateCharacterCount); // Load saved message destination _loadSavedDestination(); - _loadVoiceBitrate(); + _loadVoiceSettings(); WidgetsBinding.instance.addPostFrameCallback((_) { _checkForNavigationRequest(); }); } - Future _loadVoiceBitrate() async { + Future _loadVoiceSettings() async { final bitrate = await VoiceBitratePreferences.getBitrate(); + final codec = await VoiceCodecPreferences.getCodec(); if (!mounted) return; setState(() { _selectedVoiceBitrate = bitrate; + _selectedVoiceCodec = codec; }); } @@ -817,14 +821,17 @@ class _MessagesTabState extends State { Future _startVoiceRecording() async { if (_isSendingVoice || _isRecording) return; debugPrint('๐ŸŽ™๏ธ [Voice] _startVoiceRecording called'); - // Read fresh bitrate preference so settings changes apply immediately. + // Read fresh voice preferences so settings changes apply immediately. final selectedBitrate = await VoiceBitratePreferences.getBitrate(); + final selectedCodec = await VoiceCodecPreferences.getCodec(); if (mounted) { setState(() { _selectedVoiceBitrate = selectedBitrate; + _selectedVoiceCodec = selectedCodec; }); } else { _selectedVoiceBitrate = selectedBitrate; + _selectedVoiceCodec = selectedCodec; } final hasPermission = await _voiceRecorder.requestPermission(); debugPrint('๐ŸŽ™๏ธ [Voice] hasPermission=$hasPermission'); @@ -852,11 +859,11 @@ class _MessagesTabState extends State { (_) => rng.nextInt(256), ).map((b) => b.toRadixString(16).padLeft(2, '0')).join(); - _activeVoiceMode = VoiceBitratePreferences.toVoiceMode( - _selectedVoiceBitrate, - ); + _activeVoiceMode = _selectedVoiceCodec == VoiceCodecKind.lpcnet + ? VoicePacketMode.lpcnet1600 + : VoiceBitratePreferences.toVoiceMode(_selectedVoiceBitrate); final packetDuration = Duration( - milliseconds: codec2ModeFor(_activeVoiceMode!).packetDurationMs, + milliseconds: _activeVoiceMode!.packetDurationMs, ); debugPrint( @@ -869,6 +876,7 @@ class _MessagesTabState extends State { try { final stream = _voiceRecorder.startCapture( chunkDuration: packetDuration, + sampleRateHz: _activeVoiceMode!.sampleRateHz, enableBandPassFilter: appProvider.isVoiceBandPassFilterEnabled, enableCompressor: appProvider.isVoiceCompressorEnabled, enableLimiter: appProvider.isVoiceLimiterEnabled, diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 258dca6..e8bb09d 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -18,8 +18,8 @@ import '../services/location_tracking_service.dart'; import '../services/locale_preferences.dart'; import '../services/mesh_map_nodes_service.dart'; import '../services/update_checker_service.dart'; -import '../services/voice_codec_service.dart'; import '../services/voice_bitrate_preferences.dart'; +import '../services/voice_codec_preferences.dart'; import '../services/image_preferences.dart'; import '../services/route_hash_preferences.dart'; import '../services/image_codec_service.dart'; @@ -60,6 +60,7 @@ class _SettingsScreenState extends State { bool _showRxTxIndicators = true; bool _isCheckingForUpdates = false; int _voiceBitrate = VoiceBitratePreferences.defaultBitrate; + VoiceCodecKind _voiceCodec = VoiceCodecPreferences.defaultCodec; int _routeHashSize = RouteHashPreferences.defaultHashSize; int _imageMaxSize = ImagePreferences.defaultMaxSize; int _imageCompression = ImagePreferences.defaultQuality; @@ -88,7 +89,7 @@ class _SettingsScreenState extends State { _loadPackageInfo(); _initializeLocationService(); _loadRxTxPreference(); - _loadVoiceBitratePreference(); + _loadVoicePreferences(); _loadRouteHashSizePreference(); _loadImagePreferences(); _loadFastLocationSettings(); @@ -178,11 +179,13 @@ class _SettingsScreenState extends State { await prefs.setBool('show_rx_tx_indicators', value); } - Future _loadVoiceBitratePreference() async { + Future _loadVoicePreferences() async { final value = await VoiceBitratePreferences.getBitrate(); + final codec = await VoiceCodecPreferences.getCodec(); if (!mounted) return; setState(() { _voiceBitrate = value; + _voiceCodec = codec; }); } @@ -194,10 +197,27 @@ class _SettingsScreenState extends State { }); } + Future _saveVoiceCodecPreference(VoiceCodecKind value) async { + await VoiceCodecPreferences.setCodec(value); + if (!mounted) return; + setState(() { + _voiceCodec = value; + }); + } + String _voiceBitrateSubtitle(int bitrate) { + if (_voiceCodec == VoiceCodecKind.lpcnet) { + return 'Fixed 1600 bps'; + } return '$bitrate bps'; } + String _voiceCodecSubtitle() { + return _voiceCodec == VoiceCodecKind.codec2 + ? 'Selectable low-bitrate modes' + : 'Fixed 16 kHz / 1.6 kbps mode'; + } + Future _loadRouteHashSizePreference() async { final value = await RouteHashPreferences.getHashSize(); if (!mounted) return; @@ -1159,6 +1179,7 @@ class _SettingsScreenState extends State { Consumer2( builder: (context, appProvider, connectionProvider, child) => _buildVoiceStatsCard( + codec: _voiceCodec, bitrate: _voiceBitrate, connectionProvider: connectionProvider, bandPassEnabled: appProvider.isVoiceBandPassFilterEnabled, @@ -1168,12 +1189,22 @@ class _SettingsScreenState extends State { ), ), _buildSettingsCard([ + ListTile( + leading: const Icon(Icons.record_voice_over), + title: const Text('Voice codec'), + subtitle: Text(_voiceCodecSubtitle()), + trailing: const Icon(Icons.chevron_right), + onTap: _showVoiceCodecDialog, + ), ListTile( leading: const Icon(Icons.graphic_eq), title: const Text('Voice bitrate'), subtitle: Text(_voiceBitrateSubtitle(_voiceBitrate)), trailing: const Icon(Icons.chevron_right), - onTap: _showVoiceBitrateDialog, + enabled: _voiceCodec == VoiceCodecKind.codec2, + onTap: _voiceCodec == VoiceCodecKind.codec2 + ? _showVoiceBitrateDialog + : null, ), Consumer( builder: (context, appProvider, child) => SwitchListTile( @@ -1724,6 +1755,7 @@ class _SettingsScreenState extends State { } Widget _buildVoiceStatsCard({ + required VoiceCodecKind codec, required int bitrate, required ConnectionProvider connectionProvider, required bool bandPassEnabled, @@ -1746,9 +1778,11 @@ class _SettingsScreenState extends State { final radioSf = connectionProvider.deviceInfo.radioSf; final radioCr = connectionProvider.deviceInfo.radioCr; final bwHz = _resolveBandwidthHz(radioBw); - final voiceMode = VoiceBitratePreferences.toVoiceMode(bitrate); + final voiceMode = codec == VoiceCodecKind.lpcnet + ? VoicePacketMode.lpcnet1600 + : VoiceBitratePreferences.toVoiceMode(bitrate); const voicePreviewMs = 10000; // 10-second reference clip - final packetDurationMs = codec2ModeFor(voiceMode).packetDurationMs; + final packetDurationMs = voiceMode.packetDurationMs; final voicePacketCount = (voicePreviewMs + packetDurationMs - 1) ~/ packetDurationMs; final voiceDirect = estimateVoiceTransmitDuration( @@ -1783,7 +1817,7 @@ class _SettingsScreenState extends State { ), const SizedBox(height: 8), Text( - 'Bitrate: $bitrate bps', + 'Codec: ${voiceMode.label}${codec == VoiceCodecKind.codec2 ? ' ยท $bitrate bps' : ' ยท 1600 bps'}', style: Theme.of(context).textTheme.bodySmall, ), const SizedBox(height: 4), @@ -2112,6 +2146,47 @@ class _SettingsScreenState extends State { ); } + void _showVoiceCodecDialog() { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Voice codec'), + content: SingleChildScrollView( + child: RadioGroup( + groupValue: _voiceCodec, + onChanged: (value) { + if (value != null) { + _saveVoiceCodecPreference(value); + } + Navigator.pop(context); + }, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + RadioListTile( + value: VoiceCodecKind.codec2, + title: const Text('Codec2'), + subtitle: const Text('Selectable ultra-low bitrate modes'), + ), + RadioListTile( + value: VoiceCodecKind.lpcnet, + title: const Text('LPCNet'), + subtitle: const Text('Fixed 16 kHz / 1.6 kbps neural mode'), + ), + ], + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(AppLocalizations.of(context)!.cancel), + ), + ], + ), + ); + } + void _showAboutDialog() { showDialog( context: context, diff --git a/lib/services/voice_codec_preferences.dart b/lib/services/voice_codec_preferences.dart new file mode 100644 index 0000000..90adee0 --- /dev/null +++ b/lib/services/voice_codec_preferences.dart @@ -0,0 +1,21 @@ +import 'package:shared_preferences/shared_preferences.dart'; +import '../utils/voice_message_parser.dart'; + +class VoiceCodecPreferences { + static const String _codecKey = 'voice_codec'; + static const VoiceCodecKind defaultCodec = VoiceCodecKind.codec2; + + static Future getCodec() async { + final prefs = await SharedPreferences.getInstance(); + final raw = prefs.getString(_codecKey); + return VoiceCodecKind.values.firstWhere( + (codec) => codec.name == raw, + orElse: () => defaultCodec, + ); + } + + static Future setCodec(VoiceCodecKind codec) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_codecKey, codec.name); + } +} diff --git a/lib/services/voice_codec_service_io.dart b/lib/services/voice_codec_service_io.dart index cebaba5..2f41a5f 100644 --- a/lib/services/voice_codec_service_io.dart +++ b/lib/services/voice_codec_service_io.dart @@ -1,20 +1,27 @@ import 'dart:typed_data'; import 'package:codec2_flutter/codec2_flutter.dart'; +import 'package:lpcnet_flutter/lpcnet_flutter.dart'; import 'package:flutter/foundation.dart'; import '../utils/voice_message_parser.dart'; -export 'package:codec2_flutter/codec2_flutter.dart' show Codec2Mode; - -/// Maps [VoicePacketMode] to the [Codec2Mode] enum from the FFI plugin. Codec2Mode codec2ModeFor(VoicePacketMode pktMode) { switch (pktMode) { - case VoicePacketMode.mode3200: return Codec2Mode.mode3200; - case VoicePacketMode.mode1600: return Codec2Mode.mode1600; - case VoicePacketMode.mode1400: return Codec2Mode.mode1400; - case VoicePacketMode.mode700c: return Codec2Mode.mode700c; - case VoicePacketMode.mode1200: return Codec2Mode.mode1200; - case VoicePacketMode.mode1300: return Codec2Mode.mode1300; - case VoicePacketMode.mode2400: return Codec2Mode.mode2400; + case VoicePacketMode.mode3200: + return Codec2Mode.mode3200; + case VoicePacketMode.mode1600: + return Codec2Mode.mode1600; + case VoicePacketMode.mode1400: + return Codec2Mode.mode1400; + case VoicePacketMode.mode700c: + return Codec2Mode.mode700c; + case VoicePacketMode.mode1200: + return Codec2Mode.mode1200; + case VoicePacketMode.mode1300: + return Codec2Mode.mode1300; + case VoicePacketMode.mode2400: + return Codec2Mode.mode2400; + case VoicePacketMode.lpcnet1600: + throw ArgumentError('LPCNet mode does not map to Codec2'); } } @@ -39,17 +46,24 @@ class VoiceCodecService { } } - /// Encode [pcm] (Int16 samples, 8000 Hz mono) with [mode]. - /// Returns the raw Codec2-encoded bytes. Future encode(Int16List pcm, VoicePacketMode mode) { _ensureCodec2Supported(); - return Codec2.encodeInIsolate(pcm, codec2ModeFor(mode)); + switch (mode.codec) { + case VoiceCodecKind.codec2: + return Codec2.encodeInIsolate(pcm, codec2ModeFor(mode)); + case VoiceCodecKind.lpcnet: + return LpcNet.encodeInIsolate(pcm); + } } - /// Decode [codec2Bytes] back to Int16 PCM (8000 Hz mono) with [mode]. Future decode(Uint8List codec2Bytes, VoicePacketMode mode) { _ensureCodec2Supported(); - return Codec2.decodeInIsolate(codec2Bytes, codec2ModeFor(mode)); + switch (mode.codec) { + case VoiceCodecKind.codec2: + return Codec2.decodeInIsolate(codec2Bytes, codec2ModeFor(mode)); + case VoiceCodecKind.lpcnet: + return LpcNet.decodeInIsolate(codec2Bytes); + } } /// Decode and concatenate multiple [packets] into a single PCM Int16List. @@ -59,24 +73,14 @@ class VoiceCodecService { VoicePacketMode mode, ) async { _ensureCodec2Supported(); - final c2Mode = codec2ModeFor(mode); - final c2 = Codec2.create(c2Mode); - final spf = c2.samplesPerFrame; - c2.destroy(); - - // Estimate total samples (use actual data or silence per missing packet) final all = []; for (final pkt in packets) { if (pkt == null || pkt.codec2Data.isEmpty) { - // Silence for missing packet โ€” duration approximated by mode - final silenceSamples = (codec2ModeFor(mode).framesPerSecond) * spf; - all.add(Int16List(silenceSamples)); + all.add(Int16List(mode.samplesPerPacket)); } else { - final decoded = await Codec2.decodeInIsolate(pkt.codec2Data, c2Mode); - all.add(decoded); + all.add(await decode(pkt.codec2Data, mode)); } } - final total = all.fold(0, (sum, l) => sum + l.length); final result = Int16List(total); var offset = 0; diff --git a/lib/services/voice_codec_service_stub.dart b/lib/services/voice_codec_service_stub.dart index 0655179..4dd2072 100644 --- a/lib/services/voice_codec_service_stub.dart +++ b/lib/services/voice_codec_service_stub.dart @@ -1,7 +1,6 @@ import 'dart:typed_data'; import '../utils/voice_message_parser.dart'; -/// Web/unsupported platform stub โ€” Codec2 FFI is not available. enum Codec2Mode { mode3200(0), mode2400(1), @@ -19,13 +18,20 @@ enum Codec2Mode { int get bytesPerSecond { switch (this) { - case mode3200: return 400; - case mode700c: return 100; - case mode1200: return 150; - case mode1300: return 175; - case mode1400: return 175; - case mode1600: return 200; - case mode2400: return 300; + case mode3200: + return 400; + case mode700c: + return 100; + case mode1200: + return 150; + case mode1300: + return 175; + case mode1400: + return 175; + case mode1600: + return 200; + case mode2400: + return 300; } } @@ -40,13 +46,22 @@ enum Codec2Mode { Codec2Mode codec2ModeFor(VoicePacketMode pktMode) { switch (pktMode) { - case VoicePacketMode.mode3200: return Codec2Mode.mode3200; - case VoicePacketMode.mode1600: return Codec2Mode.mode1600; - case VoicePacketMode.mode1400: return Codec2Mode.mode1400; - case VoicePacketMode.mode700c: return Codec2Mode.mode700c; - case VoicePacketMode.mode1200: return Codec2Mode.mode1200; - case VoicePacketMode.mode1300: return Codec2Mode.mode1300; - case VoicePacketMode.mode2400: return Codec2Mode.mode2400; + case VoicePacketMode.mode3200: + return Codec2Mode.mode3200; + case VoicePacketMode.mode1600: + return Codec2Mode.mode1600; + case VoicePacketMode.mode1400: + return Codec2Mode.mode1400; + case VoicePacketMode.mode700c: + return Codec2Mode.mode700c; + case VoicePacketMode.mode1200: + return Codec2Mode.mode1200; + case VoicePacketMode.mode1300: + return Codec2Mode.mode1300; + case VoicePacketMode.mode2400: + return Codec2Mode.mode2400; + case VoicePacketMode.lpcnet1600: + throw ArgumentError('LPCNet mode does not map to Codec2'); } } @@ -66,6 +81,5 @@ class VoiceCodecService { Future decodePackets( List packets, VoicePacketMode mode, - ) => - Future.error(UnsupportedError('Voice not supported on web')); + ) => Future.error(UnsupportedError('Voice not supported on web')); } diff --git a/lib/services/voice_player_service.dart b/lib/services/voice_player_service.dart index a3d3374..cce5f95 100644 --- a/lib/services/voice_player_service.dart +++ b/lib/services/voice_player_service.dart @@ -5,7 +5,7 @@ import 'package:audioplayers/audioplayers.dart'; import 'package:flutter/foundation.dart'; import 'package:path_provider/path_provider.dart'; -/// Plays decoded 8000 Hz / 16-bit mono PCM samples by writing a WAV file +/// Plays decoded mono PCM samples by writing a WAV file /// to the system temp directory and using [AudioPlayer]. class VoicePlayerService { final AudioPlayer _player = AudioPlayer(); @@ -49,16 +49,17 @@ class VoicePlayerService { }); } - /// Play [pcmSamples] (Int16, 8000 Hz, mono). - Future play(Int16List pcmSamples) async { + Future play(Int16List pcmSamples, {required int sampleRateHz}) async { debugPrint('๐Ÿ”Š [VoicePlayer] play() called, ${pcmSamples.length} samples'); if (_isPlaying) await stop(); _position = Duration.zero; - _duration = Duration(milliseconds: (pcmSamples.length * 1000) ~/ 8000); + _duration = Duration( + milliseconds: (pcmSamples.length * 1000) ~/ sampleRateHz, + ); _playbackStartedAt = DateTime.now(); _events.add(null); - final wavBytes = _buildWav(pcmSamples, sampleRate: 8000); + final wavBytes = _buildWav(pcmSamples, sampleRate: sampleRateHz); final tmpDir = await getTemporaryDirectory(); final file = File('${tmpDir.path}/vc_voice.wav'); await file.writeAsBytes(wavBytes); diff --git a/lib/services/voice_recorder_service.dart b/lib/services/voice_recorder_service.dart index fe378b7..0961c2f 100644 --- a/lib/services/voice_recorder_service.dart +++ b/lib/services/voice_recorder_service.dart @@ -4,7 +4,7 @@ import 'dart:typed_data'; import 'package:flutter/foundation.dart'; import 'package:record/record.dart'; -/// Captures raw PCM audio at 8000 Hz, 16-bit mono. +/// Captures raw PCM audio at a codec-selected sample rate, 16-bit mono. /// /// [startCapture] returns a [Stream] that emits chunks of PCM /// samples every [chunkDuration]. Call [stopCapture] to end recording. @@ -27,9 +27,10 @@ class VoiceRecorderService { /// [enableBandPassFilter] applies voice-tuned band-pass filtering when true. /// [enableCompressor] normalizes speech dynamics before encoding. /// [enableLimiter] protects against clipping peaks before encoding. - /// The returned stream emits [Int16List] chunks that are ready for Codec2 encoding. + /// The returned stream emits [Int16List] chunks that are ready for voice encoding. Stream startCapture({ Duration chunkDuration = const Duration(seconds: 1), + int sampleRateHz = 8000, bool enableBandPassFilter = true, bool enableCompressor = true, bool enableLimiter = true, @@ -43,6 +44,7 @@ class VoiceRecorderService { _startRecording( chunkDuration, + sampleRateHz: sampleRateHz, enableBandPassFilter: enableBandPassFilter, enableCompressor: enableCompressor, enableLimiter: enableLimiter, @@ -52,13 +54,14 @@ class VoiceRecorderService { Future _startRecording( Duration chunkDuration, { + required int sampleRateHz, required bool enableBandPassFilter, required bool enableCompressor, required bool enableLimiter, }) async { - final config = const RecordConfig( + final config = RecordConfig( encoder: AudioEncoder.pcm16bits, - sampleRate: 8000, + sampleRate: sampleRateHz, numChannels: 1, bitRate: 128000, // ignored for PCM, but required by API ); @@ -66,16 +69,17 @@ class VoiceRecorderService { try { final stream = await _recorder.startStream(config); final voiceFilter = _VoiceBandPassFilter( - sampleRate: 8000, + sampleRate: sampleRateHz, lowCutHz: 250.0, highCutHz: 3400.0, ); final dynamics = _VoiceDynamicsProcessor( - sampleRate: 8000, + sampleRate: sampleRateHz, enableCompressor: enableCompressor, enableLimiter: enableLimiter, ); - final chunkBytes = 8000 * 2 * chunkDuration.inMilliseconds ~/ 1000; + final chunkBytes = + sampleRateHz * 2 * chunkDuration.inMilliseconds ~/ 1000; final buffer = []; _sub = stream.listen( diff --git a/lib/utils/voice_message_parser.dart b/lib/utils/voice_message_parser.dart index 0d093ff..6e36718 100644 --- a/lib/utils/voice_message_parser.dart +++ b/lib/utils/voice_message_parser.dart @@ -11,20 +11,43 @@ const int _defaultLoRaCrcEnabled = 1; const int _defaultLoRaExplicitHeader = 1; const double _defaultAirtimeBudgetFactor = 1.0; // one half duty-cycle -/// Identifies which Codec2 mode was used for a voice packet. -/// Matches the modeId byte in the text/binary packet header. -enum VoicePacketMode { - mode700c(0, '700C'), - mode1200(1, '1200'), - mode2400(2, '2400'), - mode1300(3, '1300'), - mode1400(4, '1400'), - mode1600(5, '1600'), - mode3200(6, '3200'); +enum VoiceCodecKind { + codec2(0, 'Codec2'), + lpcnet(1, 'LPCNet'); - const VoicePacketMode(this.id, this.label); + const VoiceCodecKind(this.id, this.label); final int id; final String label; +} + +/// Identifies which voice codec/mode was used for a packet. +/// Matches the modeId byte in the text/binary packet header. +enum VoicePacketMode { + mode700c(0, '700C', VoiceCodecKind.codec2, 8000, 100, 1600), + mode1200(1, '1200', VoiceCodecKind.codec2, 8000, 150, 1040), + mode2400(2, '2400', VoiceCodecKind.codec2, 8000, 300, 520), + mode1300(3, '1300', VoiceCodecKind.codec2, 8000, 175, 880), + mode1400(4, '1400', VoiceCodecKind.codec2, 8000, 175, 880), + mode1600(5, '1600', VoiceCodecKind.codec2, 8000, 200, 800), + mode3200(6, '3200', VoiceCodecKind.codec2, 8000, 400, 400), + lpcnet1600(7, 'LPCNet', VoiceCodecKind.lpcnet, 16000, 200, 40); + + const VoicePacketMode( + this.id, + this.label, + this.codec, + this.sampleRateHz, + this.bytesPerSecond, + this.packetDurationMs, + ); + final int id; + final String label; + final VoiceCodecKind codec; + final int sampleRateHz; + final int bytesPerSecond; + final int packetDurationMs; + + int get samplesPerPacket => sampleRateHz * packetDurationMs ~/ 1000; static VoicePacketMode fromId(int id) => VoicePacketMode.values.firstWhere( (m) => m.id == id, @@ -153,23 +176,15 @@ class VoicePacket { /// Estimated audio duration of this packet in milliseconds. int get durationMs { - // bytesPerSecond for each mode - final bps = switch (mode) { - VoicePacketMode.mode700c => 100, - VoicePacketMode.mode1200 => 150, - VoicePacketMode.mode1300 => 175, - VoicePacketMode.mode1400 => 175, - VoicePacketMode.mode1600 => 200, - VoicePacketMode.mode2400 => 300, - VoicePacketMode.mode3200 => 400, - }; - if (bps == 0) return 0; - return (codec2Data.length * 1000 ~/ bps).clamp(0, 1500); + if (mode.bytesPerSecond == 0) return 0; + return (codec2Data.length * 1000 ~/ mode.bytesPerSecond).clamp(0, 1500); } @override String toString() { - final suffix = total > 0 ? ' ${mode.label} [$index/${total - 1}]' : ' [$index]'; + final suffix = total > 0 + ? ' ${mode.label} [$index/${total - 1}]' + : ' [$index]'; return 'VoicePacket($sessionId$suffix ${codec2Data.length}B)'; } } @@ -242,13 +257,7 @@ class VoiceEnvelope { } int voiceModeBytesPerSecond(VoicePacketMode mode) => switch (mode) { - VoicePacketMode.mode700c => 100, - VoicePacketMode.mode1200 => 150, - VoicePacketMode.mode1300 => 175, - VoicePacketMode.mode1400 => 175, - VoicePacketMode.mode1600 => 200, - VoicePacketMode.mode2400 => 300, - VoicePacketMode.mode3200 => 400, + _ => mode.bytesPerSecond, }; /// Approximate end-to-end transmit time for a voice session over MeshCore LoRa. @@ -419,8 +428,7 @@ class VoiceFetchRequest { this.version = 3, }); - static bool isVoiceFetchRequestText(String text) => - text.startsWith(_prefix); + static bool isVoiceFetchRequestText(String text) => text.startsWith(_prefix); static bool isVoiceFetchRequestBinary(Uint8List payload) => payload.isNotEmpty && payload[0] == _binaryMagic; @@ -473,9 +481,7 @@ class VoiceFetchRequest { final requesterKey6 = parts[2]; final normalizedWant = wantToken == 'a' ? 'all' - : ((wantToken.startsWith('m')) - ? 'missing' - : wantToken); + : ((wantToken.startsWith('m')) ? 'missing' : wantToken); if (sid == null) { return null; @@ -602,7 +608,11 @@ String _toBase36(int value) => value.toRadixString(36); String _encodeSessionId(String sessionIdHex) { if (!RegExp(r'^[0-9a-fA-F]{8}$').hasMatch(sessionIdHex)) { - throw ArgumentError.value(sessionIdHex, 'sessionIdHex', 'Expected 8 hex chars'); + throw ArgumentError.value( + sessionIdHex, + 'sessionIdHex', + 'Expected 8 hex chars', + ); } final value = int.parse(sessionIdHex, radix: 16); return value.toRadixString(36); @@ -616,10 +626,7 @@ String? _decodeSessionId(String token) { } String _encodeMissingIndicesCompact(List indices) { - final sorted = indices - .where((v) => v >= 0 && v <= 254) - .toSet() - .toList() + final sorted = indices.where((v) => v >= 0 && v <= 254).toSet().toList() ..sort(); if (sorted.isEmpty) return ''; final chunks = []; @@ -632,7 +639,9 @@ String _encodeMissingIndicesCompact(List indices) { continue; } chunks.add( - start == prev ? _toBase36(start) : '${_toBase36(start)}-${_toBase36(prev)}', + start == prev + ? _toBase36(start) + : '${_toBase36(start)}-${_toBase36(prev)}', ); start = curr; prev = curr; diff --git a/lib/widgets/messages/message_trace_sheet.dart b/lib/widgets/messages/message_trace_sheet.dart index fe1ec4a..4b4b858 100644 --- a/lib/widgets/messages/message_trace_sheet.dart +++ b/lib/widgets/messages/message_trace_sheet.dart @@ -10,6 +10,7 @@ import '../../models/ble_packet_log.dart'; import '../../models/message.dart'; import '../../providers/connection_provider.dart'; import '../../providers/contacts_provider.dart'; +import '../../providers/messages_provider.dart'; import '../../services/mesh_map_nodes_service.dart'; import '../../services/route_hash_preferences.dart'; import '../../utils/log_rx_route_decoder.dart'; @@ -35,11 +36,17 @@ class _MessageTraceSheetState extends State { Future<_TraceResult> _loadTrace() async { final connectionProvider = context.read(); final contactsProvider = context.read(); + final messagesProvider = context.read(); final preferredHashSize = await RouteHashPreferences.getHashSize(); - final packetPath = _extractPathFromPacketLogs( - logs: connectionProvider.bleService.packetLogs, - message: widget.message, - ); + final storedPath = + messagesProvider.getMessageReceptionDetails(widget.message.id)?.pathBytes; + final packetPath = + (storedPath != null && storedPath.isNotEmpty) + ? storedPath + : _extractPathFromPacketLogs( + logs: connectionProvider.bleService.packetLogs, + message: widget.message, + ); final senderPrefix = _toPrefixHex(widget.message.senderPublicKeyPrefix); final recipientPrefix = widget.message.recipientPublicKey != null @@ -456,7 +463,7 @@ class _MessageTraceSheetState extends State { final hopHashes = LogRxRouteDecoder.splitHopHashes( packetPath, hashSize: hashSize, - ); + ).reversed.toList(); final matched = _matchNodesFromPathHashes( nodes: nodes, pathHashes: hopHashes, @@ -568,6 +575,12 @@ class _MessageTraceSheetState extends State { .toList(); if (senderMatches.isNotEmpty) filtered = senderMatches; } + if (i == pathHashes.length - 1 && recipientPrefix != null) { + final recipientMatches = filtered + .where((n) => n.publicKey.startsWith(recipientPrefix)) + .toList(); + if (recipientMatches.isNotEmpty) filtered = recipientMatches; + } filtered.sort((a, b) => b.updatedAtMs.compareTo(a.updatedAtMs)); result.add(filtered.first); diff --git a/pubspec.lock b/pubspec.lock index 4d84f84..25ce903 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -775,6 +775,13 @@ packages: url: "https://pub.dev" source: hosted version: "6.1.0" + lpcnet_flutter: + dependency: "direct main" + description: + path: "../packages/lpcnet_flutter" + relative: true + source: path + version: "0.1.0" matcher: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 7f755e5..c31de2f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -51,6 +51,8 @@ dependencies: git: url: https://github.com/dz0ny/codec2_flutter.git ref: 03998a2 + lpcnet_flutter: + path: ../packages/lpcnet_flutter ffi: ^2.1.0 # Voice recording (raw PCM stream at 8000 Hz) diff --git a/test/providers/contacts_provider_test.dart b/test/providers/contacts_provider_test.dart index ba57646..cea9df9 100644 --- a/test/providers/contacts_provider_test.dart +++ b/test/providers/contacts_provider_test.dart @@ -524,10 +524,10 @@ void main() { ); }); - test('infers a fallback location 100m from first-hop repeater', () { + test('infers a fallback location 100m from last-hop repeater', () { final repeaterKey = Uint8List.fromList([ - 0xAA, - 0xBB, + 0xCC, + 0xDD, 0x10, 0x11, 0x12, @@ -575,6 +575,47 @@ void main() { ); expect(distanceMeters, closeTo(100.0, 8.0)); }); + + test('does not infer a fallback location when the contact advertises one', () { + final repeaterKey = Uint8List.fromList([ + 0xCC, + 0xDD, + 0x10, + 0x11, + 0x12, + 0x13, + ...List.generate(26, (index) => index + 20), + ]); + provider.addOrUpdateContact( + createContact( + key: repeaterKey, + type: ContactType.repeater, + name: 'Relay Alpha', + ), + ); + + final targetKey = createPublicKey(121); + final route = ContactRouteCodec.parse('AABB,CCDD'); + provider.addOrUpdateContact( + Contact( + publicKey: targetKey, + type: ContactType.chat, + flags: 0, + outPathLen: route.signedEncodedPathLen, + outPath: route.paddedPathBytes, + advName: 'Has Advert', + lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000, + advLat: (45.1234 * 1e6).toInt(), + advLon: (13.8765 * 1e6).toInt(), + lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000, + ), + ); + + final updated = provider.findContactByKey(targetKey)!; + expect(updated.displayLocation, isNotNull); + expect(updated.displayLocation!.latitude, closeTo(45.1234, 0.000001)); + expect(updated.displayLocation!.longitude, closeTo(13.8765, 0.000001)); + }); }); group('ContactsProvider.updateFastGps', () {