Add LPCNet voice mode support

This commit is contained in:
Janez T
2026-03-11 21:16:08 +01:00
parent 1b6ff59af3
commit ca872f2049
15 changed files with 329 additions and 124 deletions

View File

@@ -57,6 +57,8 @@ PODS:
- FlutterMacOS - FlutterMacOS
- image_picker_ios (0.0.1): - image_picker_ios (0.0.1):
- Flutter - Flutter
- lpcnet_flutter (0.1.0):
- Flutter
- nsd_ios (0.0.1): - nsd_ios (0.0.1):
- Flutter - Flutter
- package_info_plus (0.4.5): - package_info_plus (0.4.5):
@@ -98,6 +100,7 @@ DEPENDENCIES:
- flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`) - flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`)
- geolocator_apple (from `.symlinks/plugins/geolocator_apple/darwin`) - geolocator_apple (from `.symlinks/plugins/geolocator_apple/darwin`)
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`) - 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`) - nsd_ios (from `.symlinks/plugins/nsd_ios/ios`)
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
@@ -141,6 +144,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/geolocator_apple/darwin" :path: ".symlinks/plugins/geolocator_apple/darwin"
image_picker_ios: image_picker_ios:
:path: ".symlinks/plugins/image_picker_ios/ios" :path: ".symlinks/plugins/image_picker_ios/ios"
lpcnet_flutter:
:path: ".symlinks/plugins/lpcnet_flutter/ios"
nsd_ios: nsd_ios:
:path: ".symlinks/plugins/nsd_ios/ios" :path: ".symlinks/plugins/nsd_ios/ios"
package_info_plus: package_info_plus:
@@ -177,6 +182,7 @@ SPEC CHECKSUMS:
flutter_local_notifications: 643a3eda1ce1c0599413ca31672536d423dee214 flutter_local_notifications: 643a3eda1ce1c0599413ca31672536d423dee214
geolocator_apple: ab36aa0e8b7d7a2d7639b3b4e48308394e8cef5e geolocator_apple: ab36aa0e8b7d7a2d7639b3b4e48308394e8cef5e
image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326 image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326
lpcnet_flutter: 67a3c72ea4caa511373ee9b028223e33f0e85b97
nsd_ios: 596ad79109ddd3e52d665f650f36428049e3653e nsd_ios: 596ad79109ddd3e52d665f650f36428049e3653e
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880 path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880

View File

@@ -516,8 +516,8 @@ class ContactsProvider with ChangeNotifier {
return null; return null;
} }
final firstHopHex = routeBytes final lastHopHex = routeBytes
.sublist(0, routeHashSize) .sublist(routeBytes.length - routeHashSize)
.map((byte) => byte.toRadixString(16).padLeft(2, '0')) .map((byte) => byte.toRadixString(16).padLeft(2, '0'))
.join(); .join();
final repeaterCandidates = _contacts.values.where((candidate) { final repeaterCandidates = _contacts.values.where((candidate) {
@@ -526,7 +526,7 @@ class ContactsProvider with ChangeNotifier {
return false; return false;
} }
final location = candidate.displayLocation; 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)); }).toList()..sort((a, b) => b.lastAdvert.compareTo(a.lastAdvert));
if (repeaterCandidates.isEmpty) { if (repeaterCandidates.isEmpty) {
return null; return null;

View File

@@ -303,7 +303,7 @@ class VoiceProvider with ChangeNotifier {
debugPrint('🎙️ [VoiceProvider] decoded ${pcm.length} PCM samples'); debugPrint('🎙️ [VoiceProvider] decoded ${pcm.length} PCM samples');
_playingSessionId = sessionId; _playingSessionId = sessionId;
notifyListeners(); notifyListeners();
await _player.play(pcm); await _player.play(pcm, sampleRateHz: session.mode.sampleRateHz);
} catch (e, st) { } catch (e, st) {
debugPrint('❌ [VoiceProvider] Playback error: $e\n$st'); debugPrint('❌ [VoiceProvider] Playback error: $e\n$st');
if (_playingSessionId == sessionId) { if (_playingSessionId == sessionId) {

View File

@@ -24,6 +24,7 @@ import '../widgets/messages/messages_content.dart';
import '../widgets/common/contact_avatar.dart'; import '../widgets/common/contact_avatar.dart';
import '../services/message_destination_preferences.dart'; import '../services/message_destination_preferences.dart';
import '../services/voice_bitrate_preferences.dart'; import '../services/voice_bitrate_preferences.dart';
import '../services/voice_codec_preferences.dart';
import '../services/voice_recorder_service.dart'; import '../services/voice_recorder_service.dart';
import '../services/voice_codec_service.dart'; import '../services/voice_codec_service.dart';
import '../utils/toast_logger.dart'; import '../utils/toast_logger.dart';
@@ -81,6 +82,7 @@ class _MessagesTabState extends State<MessagesTab> {
final List<Int16List> _recordedChunks = []; final List<Int16List> _recordedChunks = [];
VoicePacketMode? _activeVoiceMode; VoicePacketMode? _activeVoiceMode;
int _selectedVoiceBitrate = VoiceBitratePreferences.defaultBitrate; int _selectedVoiceBitrate = VoiceBitratePreferences.defaultBitrate;
VoiceCodecKind _selectedVoiceCodec = VoiceCodecPreferences.defaultCodec;
@override @override
void initState() { void initState() {
@@ -88,17 +90,19 @@ class _MessagesTabState extends State<MessagesTab> {
_textController.addListener(_updateCharacterCount); _textController.addListener(_updateCharacterCount);
// Load saved message destination // Load saved message destination
_loadSavedDestination(); _loadSavedDestination();
_loadVoiceBitrate(); _loadVoiceSettings();
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
_checkForNavigationRequest(); _checkForNavigationRequest();
}); });
} }
Future<void> _loadVoiceBitrate() async { Future<void> _loadVoiceSettings() async {
final bitrate = await VoiceBitratePreferences.getBitrate(); final bitrate = await VoiceBitratePreferences.getBitrate();
final codec = await VoiceCodecPreferences.getCodec();
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
_selectedVoiceBitrate = bitrate; _selectedVoiceBitrate = bitrate;
_selectedVoiceCodec = codec;
}); });
} }
@@ -817,14 +821,17 @@ class _MessagesTabState extends State<MessagesTab> {
Future<void> _startVoiceRecording() async { Future<void> _startVoiceRecording() async {
if (_isSendingVoice || _isRecording) return; if (_isSendingVoice || _isRecording) return;
debugPrint('🎙️ [Voice] _startVoiceRecording called'); 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 selectedBitrate = await VoiceBitratePreferences.getBitrate();
final selectedCodec = await VoiceCodecPreferences.getCodec();
if (mounted) { if (mounted) {
setState(() { setState(() {
_selectedVoiceBitrate = selectedBitrate; _selectedVoiceBitrate = selectedBitrate;
_selectedVoiceCodec = selectedCodec;
}); });
} else { } else {
_selectedVoiceBitrate = selectedBitrate; _selectedVoiceBitrate = selectedBitrate;
_selectedVoiceCodec = selectedCodec;
} }
final hasPermission = await _voiceRecorder.requestPermission(); final hasPermission = await _voiceRecorder.requestPermission();
debugPrint('🎙️ [Voice] hasPermission=$hasPermission'); debugPrint('🎙️ [Voice] hasPermission=$hasPermission');
@@ -852,11 +859,11 @@ class _MessagesTabState extends State<MessagesTab> {
(_) => rng.nextInt(256), (_) => rng.nextInt(256),
).map((b) => b.toRadixString(16).padLeft(2, '0')).join(); ).map((b) => b.toRadixString(16).padLeft(2, '0')).join();
_activeVoiceMode = VoiceBitratePreferences.toVoiceMode( _activeVoiceMode = _selectedVoiceCodec == VoiceCodecKind.lpcnet
_selectedVoiceBitrate, ? VoicePacketMode.lpcnet1600
); : VoiceBitratePreferences.toVoiceMode(_selectedVoiceBitrate);
final packetDuration = Duration( final packetDuration = Duration(
milliseconds: codec2ModeFor(_activeVoiceMode!).packetDurationMs, milliseconds: _activeVoiceMode!.packetDurationMs,
); );
debugPrint( debugPrint(
@@ -869,6 +876,7 @@ class _MessagesTabState extends State<MessagesTab> {
try { try {
final stream = _voiceRecorder.startCapture( final stream = _voiceRecorder.startCapture(
chunkDuration: packetDuration, chunkDuration: packetDuration,
sampleRateHz: _activeVoiceMode!.sampleRateHz,
enableBandPassFilter: appProvider.isVoiceBandPassFilterEnabled, enableBandPassFilter: appProvider.isVoiceBandPassFilterEnabled,
enableCompressor: appProvider.isVoiceCompressorEnabled, enableCompressor: appProvider.isVoiceCompressorEnabled,
enableLimiter: appProvider.isVoiceLimiterEnabled, enableLimiter: appProvider.isVoiceLimiterEnabled,

View File

@@ -18,8 +18,8 @@ import '../services/location_tracking_service.dart';
import '../services/locale_preferences.dart'; import '../services/locale_preferences.dart';
import '../services/mesh_map_nodes_service.dart'; import '../services/mesh_map_nodes_service.dart';
import '../services/update_checker_service.dart'; import '../services/update_checker_service.dart';
import '../services/voice_codec_service.dart';
import '../services/voice_bitrate_preferences.dart'; import '../services/voice_bitrate_preferences.dart';
import '../services/voice_codec_preferences.dart';
import '../services/image_preferences.dart'; import '../services/image_preferences.dart';
import '../services/route_hash_preferences.dart'; import '../services/route_hash_preferences.dart';
import '../services/image_codec_service.dart'; import '../services/image_codec_service.dart';
@@ -60,6 +60,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
bool _showRxTxIndicators = true; bool _showRxTxIndicators = true;
bool _isCheckingForUpdates = false; bool _isCheckingForUpdates = false;
int _voiceBitrate = VoiceBitratePreferences.defaultBitrate; int _voiceBitrate = VoiceBitratePreferences.defaultBitrate;
VoiceCodecKind _voiceCodec = VoiceCodecPreferences.defaultCodec;
int _routeHashSize = RouteHashPreferences.defaultHashSize; int _routeHashSize = RouteHashPreferences.defaultHashSize;
int _imageMaxSize = ImagePreferences.defaultMaxSize; int _imageMaxSize = ImagePreferences.defaultMaxSize;
int _imageCompression = ImagePreferences.defaultQuality; int _imageCompression = ImagePreferences.defaultQuality;
@@ -88,7 +89,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
_loadPackageInfo(); _loadPackageInfo();
_initializeLocationService(); _initializeLocationService();
_loadRxTxPreference(); _loadRxTxPreference();
_loadVoiceBitratePreference(); _loadVoicePreferences();
_loadRouteHashSizePreference(); _loadRouteHashSizePreference();
_loadImagePreferences(); _loadImagePreferences();
_loadFastLocationSettings(); _loadFastLocationSettings();
@@ -178,11 +179,13 @@ class _SettingsScreenState extends State<SettingsScreen> {
await prefs.setBool('show_rx_tx_indicators', value); await prefs.setBool('show_rx_tx_indicators', value);
} }
Future<void> _loadVoiceBitratePreference() async { Future<void> _loadVoicePreferences() async {
final value = await VoiceBitratePreferences.getBitrate(); final value = await VoiceBitratePreferences.getBitrate();
final codec = await VoiceCodecPreferences.getCodec();
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
_voiceBitrate = value; _voiceBitrate = value;
_voiceCodec = codec;
}); });
} }
@@ -194,10 +197,27 @@ class _SettingsScreenState extends State<SettingsScreen> {
}); });
} }
Future<void> _saveVoiceCodecPreference(VoiceCodecKind value) async {
await VoiceCodecPreferences.setCodec(value);
if (!mounted) return;
setState(() {
_voiceCodec = value;
});
}
String _voiceBitrateSubtitle(int bitrate) { String _voiceBitrateSubtitle(int bitrate) {
if (_voiceCodec == VoiceCodecKind.lpcnet) {
return 'Fixed 1600 bps';
}
return '$bitrate bps'; return '$bitrate bps';
} }
String _voiceCodecSubtitle() {
return _voiceCodec == VoiceCodecKind.codec2
? 'Selectable low-bitrate modes'
: 'Fixed 16 kHz / 1.6 kbps mode';
}
Future<void> _loadRouteHashSizePreference() async { Future<void> _loadRouteHashSizePreference() async {
final value = await RouteHashPreferences.getHashSize(); final value = await RouteHashPreferences.getHashSize();
if (!mounted) return; if (!mounted) return;
@@ -1159,6 +1179,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
Consumer2<AppProvider, ConnectionProvider>( Consumer2<AppProvider, ConnectionProvider>(
builder: (context, appProvider, connectionProvider, child) => builder: (context, appProvider, connectionProvider, child) =>
_buildVoiceStatsCard( _buildVoiceStatsCard(
codec: _voiceCodec,
bitrate: _voiceBitrate, bitrate: _voiceBitrate,
connectionProvider: connectionProvider, connectionProvider: connectionProvider,
bandPassEnabled: appProvider.isVoiceBandPassFilterEnabled, bandPassEnabled: appProvider.isVoiceBandPassFilterEnabled,
@@ -1168,12 +1189,22 @@ class _SettingsScreenState extends State<SettingsScreen> {
), ),
), ),
_buildSettingsCard([ _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( ListTile(
leading: const Icon(Icons.graphic_eq), leading: const Icon(Icons.graphic_eq),
title: const Text('Voice bitrate'), title: const Text('Voice bitrate'),
subtitle: Text(_voiceBitrateSubtitle(_voiceBitrate)), subtitle: Text(_voiceBitrateSubtitle(_voiceBitrate)),
trailing: const Icon(Icons.chevron_right), trailing: const Icon(Icons.chevron_right),
onTap: _showVoiceBitrateDialog, enabled: _voiceCodec == VoiceCodecKind.codec2,
onTap: _voiceCodec == VoiceCodecKind.codec2
? _showVoiceBitrateDialog
: null,
), ),
Consumer<AppProvider>( Consumer<AppProvider>(
builder: (context, appProvider, child) => SwitchListTile( builder: (context, appProvider, child) => SwitchListTile(
@@ -1724,6 +1755,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
} }
Widget _buildVoiceStatsCard({ Widget _buildVoiceStatsCard({
required VoiceCodecKind codec,
required int bitrate, required int bitrate,
required ConnectionProvider connectionProvider, required ConnectionProvider connectionProvider,
required bool bandPassEnabled, required bool bandPassEnabled,
@@ -1746,9 +1778,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
final radioSf = connectionProvider.deviceInfo.radioSf; final radioSf = connectionProvider.deviceInfo.radioSf;
final radioCr = connectionProvider.deviceInfo.radioCr; final radioCr = connectionProvider.deviceInfo.radioCr;
final bwHz = _resolveBandwidthHz(radioBw); 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 const voicePreviewMs = 10000; // 10-second reference clip
final packetDurationMs = codec2ModeFor(voiceMode).packetDurationMs; final packetDurationMs = voiceMode.packetDurationMs;
final voicePacketCount = final voicePacketCount =
(voicePreviewMs + packetDurationMs - 1) ~/ packetDurationMs; (voicePreviewMs + packetDurationMs - 1) ~/ packetDurationMs;
final voiceDirect = estimateVoiceTransmitDuration( final voiceDirect = estimateVoiceTransmitDuration(
@@ -1783,7 +1817,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
'Bitrate: $bitrate bps', 'Codec: ${voiceMode.label}${codec == VoiceCodecKind.codec2 ? ' · $bitrate bps' : ' · 1600 bps'}',
style: Theme.of(context).textTheme.bodySmall, style: Theme.of(context).textTheme.bodySmall,
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
@@ -2112,6 +2146,47 @@ class _SettingsScreenState extends State<SettingsScreen> {
); );
} }
void _showVoiceCodecDialog() {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Voice codec'),
content: SingleChildScrollView(
child: RadioGroup<VoiceCodecKind>(
groupValue: _voiceCodec,
onChanged: (value) {
if (value != null) {
_saveVoiceCodecPreference(value);
}
Navigator.pop(context);
},
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
RadioListTile<VoiceCodecKind>(
value: VoiceCodecKind.codec2,
title: const Text('Codec2'),
subtitle: const Text('Selectable ultra-low bitrate modes'),
),
RadioListTile<VoiceCodecKind>(
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() { void _showAboutDialog() {
showDialog( showDialog(
context: context, context: context,

View File

@@ -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<VoiceCodecKind> getCodec() async {
final prefs = await SharedPreferences.getInstance();
final raw = prefs.getString(_codecKey);
return VoiceCodecKind.values.firstWhere(
(codec) => codec.name == raw,
orElse: () => defaultCodec,
);
}
static Future<void> setCodec(VoiceCodecKind codec) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_codecKey, codec.name);
}
}

View File

@@ -1,20 +1,27 @@
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:codec2_flutter/codec2_flutter.dart'; import 'package:codec2_flutter/codec2_flutter.dart';
import 'package:lpcnet_flutter/lpcnet_flutter.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import '../utils/voice_message_parser.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) { Codec2Mode codec2ModeFor(VoicePacketMode pktMode) {
switch (pktMode) { switch (pktMode) {
case VoicePacketMode.mode3200: return Codec2Mode.mode3200; case VoicePacketMode.mode3200:
case VoicePacketMode.mode1600: return Codec2Mode.mode1600; return Codec2Mode.mode3200;
case VoicePacketMode.mode1400: return Codec2Mode.mode1400; case VoicePacketMode.mode1600:
case VoicePacketMode.mode700c: return Codec2Mode.mode700c; return Codec2Mode.mode1600;
case VoicePacketMode.mode1200: return Codec2Mode.mode1200; case VoicePacketMode.mode1400:
case VoicePacketMode.mode1300: return Codec2Mode.mode1300; return Codec2Mode.mode1400;
case VoicePacketMode.mode2400: return Codec2Mode.mode2400; 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<Uint8List> encode(Int16List pcm, VoicePacketMode mode) { Future<Uint8List> encode(Int16List pcm, VoicePacketMode mode) {
_ensureCodec2Supported(); _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<Int16List> decode(Uint8List codec2Bytes, VoicePacketMode mode) { Future<Int16List> decode(Uint8List codec2Bytes, VoicePacketMode mode) {
_ensureCodec2Supported(); _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. /// Decode and concatenate multiple [packets] into a single PCM Int16List.
@@ -59,24 +73,14 @@ class VoiceCodecService {
VoicePacketMode mode, VoicePacketMode mode,
) async { ) async {
_ensureCodec2Supported(); _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 = <Int16List>[]; final all = <Int16List>[];
for (final pkt in packets) { for (final pkt in packets) {
if (pkt == null || pkt.codec2Data.isEmpty) { if (pkt == null || pkt.codec2Data.isEmpty) {
// Silence for missing packet — duration approximated by mode all.add(Int16List(mode.samplesPerPacket));
final silenceSamples = (codec2ModeFor(mode).framesPerSecond) * spf;
all.add(Int16List(silenceSamples));
} else { } else {
final decoded = await Codec2.decodeInIsolate(pkt.codec2Data, c2Mode); all.add(await decode(pkt.codec2Data, mode));
all.add(decoded);
} }
} }
final total = all.fold<int>(0, (sum, l) => sum + l.length); final total = all.fold<int>(0, (sum, l) => sum + l.length);
final result = Int16List(total); final result = Int16List(total);
var offset = 0; var offset = 0;

View File

@@ -1,7 +1,6 @@
import 'dart:typed_data'; import 'dart:typed_data';
import '../utils/voice_message_parser.dart'; import '../utils/voice_message_parser.dart';
/// Web/unsupported platform stub — Codec2 FFI is not available.
enum Codec2Mode { enum Codec2Mode {
mode3200(0), mode3200(0),
mode2400(1), mode2400(1),
@@ -19,13 +18,20 @@ enum Codec2Mode {
int get bytesPerSecond { int get bytesPerSecond {
switch (this) { switch (this) {
case mode3200: return 400; case mode3200:
case mode700c: return 100; return 400;
case mode1200: return 150; case mode700c:
case mode1300: return 175; return 100;
case mode1400: return 175; case mode1200:
case mode1600: return 200; return 150;
case mode2400: return 300; 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) { Codec2Mode codec2ModeFor(VoicePacketMode pktMode) {
switch (pktMode) { switch (pktMode) {
case VoicePacketMode.mode3200: return Codec2Mode.mode3200; case VoicePacketMode.mode3200:
case VoicePacketMode.mode1600: return Codec2Mode.mode1600; return Codec2Mode.mode3200;
case VoicePacketMode.mode1400: return Codec2Mode.mode1400; case VoicePacketMode.mode1600:
case VoicePacketMode.mode700c: return Codec2Mode.mode700c; return Codec2Mode.mode1600;
case VoicePacketMode.mode1200: return Codec2Mode.mode1200; case VoicePacketMode.mode1400:
case VoicePacketMode.mode1300: return Codec2Mode.mode1300; return Codec2Mode.mode1400;
case VoicePacketMode.mode2400: return Codec2Mode.mode2400; 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<Int16List> decodePackets( Future<Int16List> decodePackets(
List<VoicePacket?> packets, List<VoicePacket?> packets,
VoicePacketMode mode, VoicePacketMode mode,
) => ) => Future.error(UnsupportedError('Voice not supported on web'));
Future.error(UnsupportedError('Voice not supported on web'));
} }

View File

@@ -5,7 +5,7 @@ import 'package:audioplayers/audioplayers.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:path_provider/path_provider.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]. /// to the system temp directory and using [AudioPlayer].
class VoicePlayerService { class VoicePlayerService {
final AudioPlayer _player = AudioPlayer(); final AudioPlayer _player = AudioPlayer();
@@ -49,16 +49,17 @@ class VoicePlayerService {
}); });
} }
/// Play [pcmSamples] (Int16, 8000 Hz, mono). Future<void> play(Int16List pcmSamples, {required int sampleRateHz}) async {
Future<void> play(Int16List pcmSamples) async {
debugPrint('🔊 [VoicePlayer] play() called, ${pcmSamples.length} samples'); debugPrint('🔊 [VoicePlayer] play() called, ${pcmSamples.length} samples');
if (_isPlaying) await stop(); if (_isPlaying) await stop();
_position = Duration.zero; _position = Duration.zero;
_duration = Duration(milliseconds: (pcmSamples.length * 1000) ~/ 8000); _duration = Duration(
milliseconds: (pcmSamples.length * 1000) ~/ sampleRateHz,
);
_playbackStartedAt = DateTime.now(); _playbackStartedAt = DateTime.now();
_events.add(null); _events.add(null);
final wavBytes = _buildWav(pcmSamples, sampleRate: 8000); final wavBytes = _buildWav(pcmSamples, sampleRate: sampleRateHz);
final tmpDir = await getTemporaryDirectory(); final tmpDir = await getTemporaryDirectory();
final file = File('${tmpDir.path}/vc_voice.wav'); final file = File('${tmpDir.path}/vc_voice.wav');
await file.writeAsBytes(wavBytes); await file.writeAsBytes(wavBytes);

View File

@@ -4,7 +4,7 @@ import 'dart:typed_data';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:record/record.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<Int16List>] that emits chunks of PCM /// [startCapture] returns a [Stream<Int16List>] that emits chunks of PCM
/// samples every [chunkDuration]. Call [stopCapture] to end recording. /// samples every [chunkDuration]. Call [stopCapture] to end recording.
@@ -27,9 +27,10 @@ class VoiceRecorderService {
/// [enableBandPassFilter] applies voice-tuned band-pass filtering when true. /// [enableBandPassFilter] applies voice-tuned band-pass filtering when true.
/// [enableCompressor] normalizes speech dynamics before encoding. /// [enableCompressor] normalizes speech dynamics before encoding.
/// [enableLimiter] protects against clipping peaks 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<Int16List> startCapture({ Stream<Int16List> startCapture({
Duration chunkDuration = const Duration(seconds: 1), Duration chunkDuration = const Duration(seconds: 1),
int sampleRateHz = 8000,
bool enableBandPassFilter = true, bool enableBandPassFilter = true,
bool enableCompressor = true, bool enableCompressor = true,
bool enableLimiter = true, bool enableLimiter = true,
@@ -43,6 +44,7 @@ class VoiceRecorderService {
_startRecording( _startRecording(
chunkDuration, chunkDuration,
sampleRateHz: sampleRateHz,
enableBandPassFilter: enableBandPassFilter, enableBandPassFilter: enableBandPassFilter,
enableCompressor: enableCompressor, enableCompressor: enableCompressor,
enableLimiter: enableLimiter, enableLimiter: enableLimiter,
@@ -52,13 +54,14 @@ class VoiceRecorderService {
Future<void> _startRecording( Future<void> _startRecording(
Duration chunkDuration, { Duration chunkDuration, {
required int sampleRateHz,
required bool enableBandPassFilter, required bool enableBandPassFilter,
required bool enableCompressor, required bool enableCompressor,
required bool enableLimiter, required bool enableLimiter,
}) async { }) async {
final config = const RecordConfig( final config = RecordConfig(
encoder: AudioEncoder.pcm16bits, encoder: AudioEncoder.pcm16bits,
sampleRate: 8000, sampleRate: sampleRateHz,
numChannels: 1, numChannels: 1,
bitRate: 128000, // ignored for PCM, but required by API bitRate: 128000, // ignored for PCM, but required by API
); );
@@ -66,16 +69,17 @@ class VoiceRecorderService {
try { try {
final stream = await _recorder.startStream(config); final stream = await _recorder.startStream(config);
final voiceFilter = _VoiceBandPassFilter( final voiceFilter = _VoiceBandPassFilter(
sampleRate: 8000, sampleRate: sampleRateHz,
lowCutHz: 250.0, lowCutHz: 250.0,
highCutHz: 3400.0, highCutHz: 3400.0,
); );
final dynamics = _VoiceDynamicsProcessor( final dynamics = _VoiceDynamicsProcessor(
sampleRate: 8000, sampleRate: sampleRateHz,
enableCompressor: enableCompressor, enableCompressor: enableCompressor,
enableLimiter: enableLimiter, enableLimiter: enableLimiter,
); );
final chunkBytes = 8000 * 2 * chunkDuration.inMilliseconds ~/ 1000; final chunkBytes =
sampleRateHz * 2 * chunkDuration.inMilliseconds ~/ 1000;
final buffer = <int>[]; final buffer = <int>[];
_sub = stream.listen( _sub = stream.listen(

View File

@@ -11,20 +11,43 @@ const int _defaultLoRaCrcEnabled = 1;
const int _defaultLoRaExplicitHeader = 1; const int _defaultLoRaExplicitHeader = 1;
const double _defaultAirtimeBudgetFactor = 1.0; // one half duty-cycle const double _defaultAirtimeBudgetFactor = 1.0; // one half duty-cycle
/// Identifies which Codec2 mode was used for a voice packet. enum VoiceCodecKind {
/// Matches the modeId byte in the text/binary packet header. codec2(0, 'Codec2'),
enum VoicePacketMode { lpcnet(1, 'LPCNet');
mode700c(0, '700C'),
mode1200(1, '1200'),
mode2400(2, '2400'),
mode1300(3, '1300'),
mode1400(4, '1400'),
mode1600(5, '1600'),
mode3200(6, '3200');
const VoicePacketMode(this.id, this.label); const VoiceCodecKind(this.id, this.label);
final int id; final int id;
final String label; 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( static VoicePacketMode fromId(int id) => VoicePacketMode.values.firstWhere(
(m) => m.id == id, (m) => m.id == id,
@@ -153,23 +176,15 @@ class VoicePacket {
/// Estimated audio duration of this packet in milliseconds. /// Estimated audio duration of this packet in milliseconds.
int get durationMs { int get durationMs {
// bytesPerSecond for each mode if (mode.bytesPerSecond == 0) return 0;
final bps = switch (mode) { return (codec2Data.length * 1000 ~/ mode.bytesPerSecond).clamp(0, 1500);
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);
} }
@override @override
String toString() { 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)'; return 'VoicePacket($sessionId$suffix ${codec2Data.length}B)';
} }
} }
@@ -242,13 +257,7 @@ class VoiceEnvelope {
} }
int voiceModeBytesPerSecond(VoicePacketMode mode) => switch (mode) { int voiceModeBytesPerSecond(VoicePacketMode mode) => switch (mode) {
VoicePacketMode.mode700c => 100, _ => mode.bytesPerSecond,
VoicePacketMode.mode1200 => 150,
VoicePacketMode.mode1300 => 175,
VoicePacketMode.mode1400 => 175,
VoicePacketMode.mode1600 => 200,
VoicePacketMode.mode2400 => 300,
VoicePacketMode.mode3200 => 400,
}; };
/// Approximate end-to-end transmit time for a voice session over MeshCore LoRa. /// Approximate end-to-end transmit time for a voice session over MeshCore LoRa.
@@ -419,8 +428,7 @@ class VoiceFetchRequest {
this.version = 3, this.version = 3,
}); });
static bool isVoiceFetchRequestText(String text) => static bool isVoiceFetchRequestText(String text) => text.startsWith(_prefix);
text.startsWith(_prefix);
static bool isVoiceFetchRequestBinary(Uint8List payload) => static bool isVoiceFetchRequestBinary(Uint8List payload) =>
payload.isNotEmpty && payload[0] == _binaryMagic; payload.isNotEmpty && payload[0] == _binaryMagic;
@@ -473,9 +481,7 @@ class VoiceFetchRequest {
final requesterKey6 = parts[2]; final requesterKey6 = parts[2];
final normalizedWant = wantToken == 'a' final normalizedWant = wantToken == 'a'
? 'all' ? 'all'
: ((wantToken.startsWith('m')) : ((wantToken.startsWith('m')) ? 'missing' : wantToken);
? 'missing'
: wantToken);
if (sid == null) { if (sid == null) {
return null; return null;
@@ -602,7 +608,11 @@ String _toBase36(int value) => value.toRadixString(36);
String _encodeSessionId(String sessionIdHex) { String _encodeSessionId(String sessionIdHex) {
if (!RegExp(r'^[0-9a-fA-F]{8}$').hasMatch(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); final value = int.parse(sessionIdHex, radix: 16);
return value.toRadixString(36); return value.toRadixString(36);
@@ -616,10 +626,7 @@ String? _decodeSessionId(String token) {
} }
String _encodeMissingIndicesCompact(List<int> indices) { String _encodeMissingIndicesCompact(List<int> indices) {
final sorted = indices final sorted = indices.where((v) => v >= 0 && v <= 254).toSet().toList()
.where((v) => v >= 0 && v <= 254)
.toSet()
.toList()
..sort(); ..sort();
if (sorted.isEmpty) return ''; if (sorted.isEmpty) return '';
final chunks = <String>[]; final chunks = <String>[];
@@ -632,7 +639,9 @@ String _encodeMissingIndicesCompact(List<int> indices) {
continue; continue;
} }
chunks.add( chunks.add(
start == prev ? _toBase36(start) : '${_toBase36(start)}-${_toBase36(prev)}', start == prev
? _toBase36(start)
: '${_toBase36(start)}-${_toBase36(prev)}',
); );
start = curr; start = curr;
prev = curr; prev = curr;

View File

@@ -10,6 +10,7 @@ import '../../models/ble_packet_log.dart';
import '../../models/message.dart'; import '../../models/message.dart';
import '../../providers/connection_provider.dart'; import '../../providers/connection_provider.dart';
import '../../providers/contacts_provider.dart'; import '../../providers/contacts_provider.dart';
import '../../providers/messages_provider.dart';
import '../../services/mesh_map_nodes_service.dart'; import '../../services/mesh_map_nodes_service.dart';
import '../../services/route_hash_preferences.dart'; import '../../services/route_hash_preferences.dart';
import '../../utils/log_rx_route_decoder.dart'; import '../../utils/log_rx_route_decoder.dart';
@@ -35,11 +36,17 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
Future<_TraceResult> _loadTrace() async { Future<_TraceResult> _loadTrace() async {
final connectionProvider = context.read<ConnectionProvider>(); final connectionProvider = context.read<ConnectionProvider>();
final contactsProvider = context.read<ContactsProvider>(); final contactsProvider = context.read<ContactsProvider>();
final messagesProvider = context.read<MessagesProvider>();
final preferredHashSize = await RouteHashPreferences.getHashSize(); final preferredHashSize = await RouteHashPreferences.getHashSize();
final packetPath = _extractPathFromPacketLogs( final storedPath =
logs: connectionProvider.bleService.packetLogs, messagesProvider.getMessageReceptionDetails(widget.message.id)?.pathBytes;
message: widget.message, final packetPath =
); (storedPath != null && storedPath.isNotEmpty)
? storedPath
: _extractPathFromPacketLogs(
logs: connectionProvider.bleService.packetLogs,
message: widget.message,
);
final senderPrefix = _toPrefixHex(widget.message.senderPublicKeyPrefix); final senderPrefix = _toPrefixHex(widget.message.senderPublicKeyPrefix);
final recipientPrefix = widget.message.recipientPublicKey != null final recipientPrefix = widget.message.recipientPublicKey != null
@@ -456,7 +463,7 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
final hopHashes = LogRxRouteDecoder.splitHopHashes( final hopHashes = LogRxRouteDecoder.splitHopHashes(
packetPath, packetPath,
hashSize: hashSize, hashSize: hashSize,
); ).reversed.toList();
final matched = _matchNodesFromPathHashes( final matched = _matchNodesFromPathHashes(
nodes: nodes, nodes: nodes,
pathHashes: hopHashes, pathHashes: hopHashes,
@@ -568,6 +575,12 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
.toList(); .toList();
if (senderMatches.isNotEmpty) filtered = senderMatches; 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)); filtered.sort((a, b) => b.updatedAtMs.compareTo(a.updatedAtMs));
result.add(filtered.first); result.add(filtered.first);

View File

@@ -775,6 +775,13 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.1.0" version: "6.1.0"
lpcnet_flutter:
dependency: "direct main"
description:
path: "../packages/lpcnet_flutter"
relative: true
source: path
version: "0.1.0"
matcher: matcher:
dependency: transitive dependency: transitive
description: description:

View File

@@ -51,6 +51,8 @@ dependencies:
git: git:
url: https://github.com/dz0ny/codec2_flutter.git url: https://github.com/dz0ny/codec2_flutter.git
ref: 03998a2 ref: 03998a2
lpcnet_flutter:
path: ../packages/lpcnet_flutter
ffi: ^2.1.0 ffi: ^2.1.0
# Voice recording (raw PCM stream at 8000 Hz) # Voice recording (raw PCM stream at 8000 Hz)

View File

@@ -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([ final repeaterKey = Uint8List.fromList([
0xAA, 0xCC,
0xBB, 0xDD,
0x10, 0x10,
0x11, 0x11,
0x12, 0x12,
@@ -575,6 +575,47 @@ void main() {
); );
expect(distanceMeters, closeTo(100.0, 8.0)); 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<int>.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', () { group('ContactsProvider.updateFastGps', () {