mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 08:20:36 +00:00
Add LPCNet voice mode support
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
21
lib/services/voice_codec_preferences.dart
Normal file
21
lib/services/voice_codec_preferences.dart
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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'));
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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', () {
|
||||||
|
|||||||
Reference in New Issue
Block a user