Add live mesh traffic view

This commit is contained in:
Janez T
2026-03-12 20:12:25 +01:00
parent d2e6b692f3
commit 69ec2dfdb5
12 changed files with 952 additions and 836 deletions

1
.gitignore vendored
View File

@@ -80,6 +80,7 @@ create_feature_graphic.py
# Tool caches and local state # Tool caches and local state
.cachebro/ .cachebro/
.osgrep/ .osgrep/
third_party/lpcnet_flutter/
# Claude Code local settings (permissions, personal config) # Claude Code local settings (permissions, personal config)
.claude/settings.local.json .claude/settings.local.json

View File

@@ -57,8 +57,6 @@ 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):
@@ -100,7 +98,6 @@ 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`)
@@ -144,8 +141,6 @@ 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:
@@ -182,7 +177,6 @@ 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

@@ -325,25 +325,7 @@ class VoiceProvider with ChangeNotifier {
} }
Int16List _preparePlaybackPcm(Int16List pcm, VoicePacketMode mode) { Int16List _preparePlaybackPcm(Int16List pcm, VoicePacketMode mode) {
if (pcm.isEmpty) { return pcm;
return pcm;
}
if (mode.codec != VoiceCodecKind.lpcnet) {
return pcm;
}
final output = Int16List(pcm.length);
var dc = 0.0;
const dcAlpha = 0.995;
for (var i = 0; i < pcm.length; i++) {
final sample = pcm[i].toDouble();
dc = (dcAlpha * dc) + ((1.0 - dcAlpha) * sample);
final filtered = sample - dc;
output[i] = filtered.clamp(-32768.0, 32767.0).round();
}
return output;
} }
Future<void> clearStoredVoiceData() async { Future<void> clearStoredVoiceData() async {

File diff suppressed because it is too large Load Diff

View File

@@ -24,7 +24,6 @@ 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';
@@ -72,7 +71,7 @@ class _MessagesTabState extends State<MessagesTab> {
final VoiceRecorderService _voiceRecorder = VoiceRecorderService(); final VoiceRecorderService _voiceRecorder = VoiceRecorderService();
bool _isRecording = false; bool _isRecording = false;
bool _isSendingVoice = false; bool _isSendingVoice = false;
static const Duration _maxVoiceRecordingDuration = Duration(seconds: 4); static const Duration _maxVoiceRecordingDuration = Duration(seconds: 30);
static const double _silenceRmsThreshold = 500.0; static const double _silenceRmsThreshold = 500.0;
static const double _silencePeakThreshold = 1400.0; static const double _silencePeakThreshold = 1400.0;
static const int _maxInteriorSilentChunks = 2; static const int _maxInteriorSilentChunks = 2;
@@ -82,7 +81,6 @@ 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() {
@@ -98,11 +96,9 @@ class _MessagesTabState extends State<MessagesTab> {
Future<void> _loadVoiceSettings() 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;
}); });
} }
@@ -823,15 +819,12 @@ class _MessagesTabState extends State<MessagesTab> {
debugPrint('🎙️ [Voice] _startVoiceRecording called'); debugPrint('🎙️ [Voice] _startVoiceRecording called');
// Read fresh voice preferences 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');
@@ -859,9 +852,9 @@ 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 = _selectedVoiceCodec == VoiceCodecKind.lpcnet _activeVoiceMode = VoiceBitratePreferences.toVoiceMode(
? VoicePacketMode.lpcnet1600 _selectedVoiceBitrate,
: VoiceBitratePreferences.toVoiceMode(_selectedVoiceBitrate); );
final packetDuration = Duration( final packetDuration = Duration(
milliseconds: _activeVoiceMode!.packetDurationMs, milliseconds: _activeVoiceMode!.packetDurationMs,
); );
@@ -878,7 +871,6 @@ class _MessagesTabState extends State<MessagesTab> {
final stream = _voiceRecorder.startCapture( final stream = _voiceRecorder.startCapture(
chunkDuration: packetDuration, chunkDuration: packetDuration,
sampleRateHz: _activeVoiceMode!.sampleRateHz, sampleRateHz: _activeVoiceMode!.sampleRateHz,
codecKind: _activeVoiceMode!.codec,
enableBandPassFilter: appProvider.isVoiceBandPassFilterEnabled, enableBandPassFilter: appProvider.isVoiceBandPassFilterEnabled,
enableCompressor: appProvider.isVoiceCompressorEnabled, enableCompressor: appProvider.isVoiceCompressorEnabled,
enableLimiter: appProvider.isVoiceLimiterEnabled, enableLimiter: appProvider.isVoiceLimiterEnabled,
@@ -1021,15 +1013,10 @@ class _MessagesTabState extends State<MessagesTab> {
debugPrint( debugPrint(
'🎙️ [Voice] encoding $total packets for deferred voice fetch, mode=${mode.label}, session=$sessionId', '🎙️ [Voice] encoding $total packets for deferred voice fetch, mode=${mode.label}, session=$sessionId',
); );
final encodedChunks = mode.codec == VoiceCodecKind.lpcnet
? await _encodeLpcNetChunks(codec, chunks, mode)
: <Uint8List>[];
for (var i = 0; i < total; i++) { for (var i = 0; i < total; i++) {
if (!mounted) return; if (!mounted) return;
try { try {
final codec2Data = mode.codec == VoiceCodecKind.lpcnet final codec2Data = await codec.encode(chunks[i], mode);
? encodedChunks[i]
: await codec.encode(chunks[i], mode);
debugPrint( debugPrint(
'🎙️ [Voice] packet $i/$total encoded: ${codec2Data.length} bytes', '🎙️ [Voice] packet $i/$total encoded: ${codec2Data.length} bytes',
); );
@@ -1133,36 +1120,6 @@ class _MessagesTabState extends State<MessagesTab> {
messagesProvider.markMessageSent(msgId, 0, 0); messagesProvider.markMessageSent(msgId, 0, 0);
} }
Future<List<Uint8List>> _encodeLpcNetChunks(
VoiceCodecService codec,
List<Int16List> chunks,
VoicePacketMode mode,
) async {
final totalSamples = chunks.fold<int>(
0,
(sum, chunk) => sum + chunk.length,
);
final merged = Int16List(totalSamples);
final encodedByteLengths = <int>[];
var sampleOffset = 0;
for (final chunk in chunks) {
merged.setRange(sampleOffset, sampleOffset + chunk.length, chunk);
sampleOffset += chunk.length;
encodedByteLengths.add((chunk.length ~/ 640) * 8);
}
final encoded = await codec.encode(merged, mode);
final encodedChunks = <Uint8List>[];
var byteOffset = 0;
for (final length in encodedByteLengths) {
encodedChunks.add(
Uint8List.sublistView(encoded, byteOffset, byteOffset + length),
);
byteOffset += length;
}
return encodedChunks;
}
List<Int16List> _prepareChunksForSending( List<Int16List> _prepareChunksForSending(
List<Int16List> chunks, List<Int16List> chunks,
VoicePacketMode mode, VoicePacketMode mode,

View File

@@ -19,7 +19,6 @@ 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_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,7 +59,6 @@ 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;
@@ -181,11 +179,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
Future<void> _loadVoicePreferences() 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;
}); });
} }
@@ -197,27 +193,10 @@ 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;
@@ -1179,7 +1158,6 @@ 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,
@@ -1194,22 +1172,12 @@ 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),
enabled: _voiceCodec == VoiceCodecKind.codec2, onTap: _showVoiceBitrateDialog,
onTap: _voiceCodec == VoiceCodecKind.codec2
? _showVoiceBitrateDialog
: null,
), ),
Consumer<AppProvider>( Consumer<AppProvider>(
builder: (context, appProvider, child) => SwitchListTile( builder: (context, appProvider, child) => SwitchListTile(
@@ -1797,7 +1765,6 @@ 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,
@@ -1826,9 +1793,7 @@ 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 = codec == VoiceCodecKind.lpcnet final voiceMode = VoiceBitratePreferences.toVoiceMode(bitrate);
? VoicePacketMode.lpcnet1600
: VoiceBitratePreferences.toVoiceMode(bitrate);
const voicePreviewMs = 10000; // 10-second reference clip const voicePreviewMs = 10000; // 10-second reference clip
final packetDurationMs = voiceMode.packetDurationMs; final packetDurationMs = voiceMode.packetDurationMs;
final voicePacketCount = final voicePacketCount =
@@ -1865,7 +1830,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
'Codec: ${voiceMode.label}${codec == VoiceCodecKind.codec2 ? ' · $bitrate bps' : ' · 1600 bps'}', 'Codec: ${voiceMode.label} · $bitrate bps',
style: Theme.of(context).textTheme.bodySmall, style: Theme.of(context).textTheme.bodySmall,
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
@@ -2219,47 +2184,6 @@ 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

@@ -1,6 +1,5 @@
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';
@@ -20,8 +19,6 @@ Codec2Mode codec2ModeFor(VoicePacketMode pktMode) {
return Codec2Mode.mode1300; return Codec2Mode.mode1300;
case VoicePacketMode.mode2400: case VoicePacketMode.mode2400:
return Codec2Mode.mode2400; return Codec2Mode.mode2400;
case VoicePacketMode.lpcnet1600:
throw ArgumentError('LPCNet mode does not map to Codec2');
} }
} }
@@ -48,22 +45,12 @@ class VoiceCodecService {
Future<Uint8List> encode(Int16List pcm, VoicePacketMode mode) { Future<Uint8List> encode(Int16List pcm, VoicePacketMode mode) {
_ensureCodec2Supported(); _ensureCodec2Supported();
switch (mode.codec) { return Codec2.encodeInIsolate(pcm, codec2ModeFor(mode));
case VoiceCodecKind.codec2:
return Codec2.encodeInIsolate(pcm, codec2ModeFor(mode));
case VoiceCodecKind.lpcnet:
return LpcNet.encodeInIsolate(pcm);
}
} }
Future<Int16List> decode(Uint8List codec2Bytes, VoicePacketMode mode) { Future<Int16List> decode(Uint8List codec2Bytes, VoicePacketMode mode) {
_ensureCodec2Supported(); _ensureCodec2Supported();
switch (mode.codec) { return Codec2.decodeInIsolate(codec2Bytes, codec2ModeFor(mode));
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.
@@ -73,9 +60,6 @@ class VoiceCodecService {
VoicePacketMode mode, VoicePacketMode mode,
) async { ) async {
_ensureCodec2Supported(); _ensureCodec2Supported();
if (mode.codec == VoiceCodecKind.lpcnet) {
return _decodeLpcNetPackets(packets, mode);
}
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) {
@@ -93,38 +77,4 @@ class VoiceCodecService {
} }
return result; return result;
} }
Future<Int16List> _decodeLpcNetPackets(
List<VoicePacket?> packets,
VoicePacketMode mode,
) async {
final segments = <Int16List>[];
final run = <int>[];
Future<void> flushRun() async {
if (run.isEmpty) return;
final decoded = await LpcNet.decodeInIsolate(Uint8List.fromList(run));
segments.add(decoded);
run.clear();
}
for (final pkt in packets) {
if (pkt == null || pkt.codec2Data.isEmpty) {
await flushRun();
segments.add(Int16List(mode.samplesPerPacket));
continue;
}
run.addAll(pkt.codec2Data);
}
await flushRun();
final total = segments.fold<int>(0, (sum, chunk) => sum + chunk.length);
final result = Int16List(total);
var offset = 0;
for (final chunk in segments) {
result.setRange(offset, offset + chunk.length, chunk);
offset += chunk.length;
}
return result;
}
} }

View File

@@ -60,8 +60,6 @@ Codec2Mode codec2ModeFor(VoicePacketMode pktMode) {
return Codec2Mode.mode1300; return Codec2Mode.mode1300;
case VoicePacketMode.mode2400: case VoicePacketMode.mode2400:
return Codec2Mode.mode2400; return Codec2Mode.mode2400;
case VoicePacketMode.lpcnet1600:
throw ArgumentError('LPCNet mode does not map to Codec2');
} }
} }

View File

@@ -3,7 +3,6 @@ import 'dart:math' as math;
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:record/record.dart'; import 'package:record/record.dart';
import '../utils/voice_message_parser.dart';
/// Captures raw PCM audio at a codec-selected sample rate, 16-bit mono. /// Captures raw PCM audio at a codec-selected sample rate, 16-bit mono.
/// ///
@@ -32,7 +31,6 @@ class VoiceRecorderService {
Stream<Int16List> startCapture({ Stream<Int16List> startCapture({
Duration chunkDuration = const Duration(seconds: 1), Duration chunkDuration = const Duration(seconds: 1),
int sampleRateHz = 8000, int sampleRateHz = 8000,
VoiceCodecKind codecKind = VoiceCodecKind.codec2,
bool enableBandPassFilter = true, bool enableBandPassFilter = true,
bool enableCompressor = true, bool enableCompressor = true,
bool enableLimiter = true, bool enableLimiter = true,
@@ -50,7 +48,6 @@ class VoiceRecorderService {
_startRecording( _startRecording(
chunkDuration, chunkDuration,
sampleRateHz: sampleRateHz, sampleRateHz: sampleRateHz,
codecKind: codecKind,
enableBandPassFilter: enableBandPassFilter, enableBandPassFilter: enableBandPassFilter,
enableCompressor: enableCompressor, enableCompressor: enableCompressor,
enableLimiter: enableLimiter, enableLimiter: enableLimiter,
@@ -64,7 +61,6 @@ class VoiceRecorderService {
Future<void> _startRecording( Future<void> _startRecording(
Duration chunkDuration, { Duration chunkDuration, {
required int sampleRateHz, required int sampleRateHz,
required VoiceCodecKind codecKind,
required bool enableBandPassFilter, required bool enableBandPassFilter,
required bool enableCompressor, required bool enableCompressor,
required bool enableLimiter, required bool enableLimiter,
@@ -72,10 +68,9 @@ class VoiceRecorderService {
required bool enableEchoCancellation, required bool enableEchoCancellation,
required bool enableNoiseSuppression, required bool enableNoiseSuppression,
}) async { }) async {
final bypassProcessing = codecKind == VoiceCodecKind.lpcnet; final useBandPassFilter = enableBandPassFilter;
final useBandPassFilter = !bypassProcessing && enableBandPassFilter; final useCompressor = enableCompressor;
final useCompressor = !bypassProcessing && enableCompressor; final useLimiter = enableLimiter;
final useLimiter = !bypassProcessing && enableLimiter;
final config = RecordConfig( final config = RecordConfig(
encoder: AudioEncoder.pcm16bits, encoder: AudioEncoder.pcm16bits,
sampleRate: sampleRateHz, sampleRate: sampleRateHz,
@@ -88,25 +83,21 @@ class VoiceRecorderService {
try { try {
final stream = await _recorder.startStream(config); final stream = await _recorder.startStream(config);
final voiceFilter = bypassProcessing final voiceFilter = _VoiceBandPassFilter(
? null sampleRate: sampleRateHz,
: _VoiceBandPassFilter( lowCutHz: 250.0,
sampleRate: sampleRateHz, highCutHz: 3400.0,
lowCutHz: 250.0, );
highCutHz: 3400.0, final dynamics = _VoiceDynamicsProcessor(
); sampleRate: sampleRateHz,
final dynamics = bypassProcessing thresholdDb: -18.0,
? null ratio: 2.5,
: _VoiceDynamicsProcessor( attackMs: 8.0,
sampleRate: sampleRateHz, releaseMs: 120.0,
thresholdDb: -18.0, makeupGainDb: 4.0,
ratio: 2.5, enableCompressor: useCompressor,
attackMs: 8.0, enableLimiter: useLimiter,
releaseMs: 120.0, );
makeupGainDb: 4.0,
enableCompressor: useCompressor,
enableLimiter: useLimiter,
);
final chunkBytes = final chunkBytes =
sampleRateHz * 2 * chunkDuration.inMilliseconds ~/ 1000; sampleRateHz * 2 * chunkDuration.inMilliseconds ~/ 1000;
final buffer = <int>[]; final buffer = <int>[];
@@ -118,28 +109,16 @@ class VoiceRecorderService {
final chunk = buffer.sublist(0, chunkBytes); final chunk = buffer.sublist(0, chunkBytes);
buffer.removeRange(0, chunkBytes); buffer.removeRange(0, chunkBytes);
final pcm = _bytesToInt16(Uint8List.fromList(chunk)); final pcm = _bytesToInt16(Uint8List.fromList(chunk));
if (bypassProcessing) { final filtered = useBandPassFilter ? voiceFilter.process(pcm) : pcm;
_controller?.add(pcm); _controller?.add(dynamics.process(filtered));
} else {
final filtered = useBandPassFilter
? voiceFilter!.process(pcm)
: pcm;
_controller?.add(dynamics!.process(filtered));
}
} }
}, },
onDone: () { onDone: () {
if (buffer.isNotEmpty) { if (buffer.isNotEmpty) {
final padded = _padToEven(buffer); final padded = _padToEven(buffer);
final pcm = _bytesToInt16(Uint8List.fromList(padded)); final pcm = _bytesToInt16(Uint8List.fromList(padded));
if (bypassProcessing) { final filtered = useBandPassFilter ? voiceFilter.process(pcm) : pcm;
_controller?.add(pcm); _controller?.add(dynamics.process(filtered));
} else {
final filtered = useBandPassFilter
? voiceFilter!.process(pcm)
: pcm;
_controller?.add(dynamics!.process(filtered));
}
} }
_controller?.close(); _controller?.close();
}, },

View File

@@ -12,8 +12,7 @@ const int _defaultLoRaExplicitHeader = 1;
const double _defaultAirtimeBudgetFactor = 1.0; // one half duty-cycle const double _defaultAirtimeBudgetFactor = 1.0; // one half duty-cycle
enum VoiceCodecKind { enum VoiceCodecKind {
codec2(0, 'Codec2'), codec2(0, 'Codec2');
lpcnet(1, 'LPCNet');
const VoiceCodecKind(this.id, this.label); const VoiceCodecKind(this.id, this.label);
final int id; final int id;
@@ -29,8 +28,7 @@ enum VoicePacketMode {
mode1300(3, '1300', VoiceCodecKind.codec2, 8000, 175, 880), mode1300(3, '1300', VoiceCodecKind.codec2, 8000, 175, 880),
mode1400(4, '1400', VoiceCodecKind.codec2, 8000, 175, 880), mode1400(4, '1400', VoiceCodecKind.codec2, 8000, 175, 880),
mode1600(5, '1600', VoiceCodecKind.codec2, 8000, 200, 800), mode1600(5, '1600', VoiceCodecKind.codec2, 8000, 200, 800),
mode3200(6, '3200', VoiceCodecKind.codec2, 8000, 400, 400), mode3200(6, '3200', VoiceCodecKind.codec2, 8000, 400, 400);
lpcnet1600(7, 'LPCNet', VoiceCodecKind.lpcnet, 16000, 200, 40);
const VoicePacketMode( const VoicePacketMode(
this.id, this.id,
@@ -176,8 +174,14 @@ class VoicePacket {
/// Estimated audio duration of this packet in milliseconds. /// Estimated audio duration of this packet in milliseconds.
int get durationMs { int get durationMs {
if (mode.bytesPerSecond == 0) return 0; try {
return (codec2Data.length * 1000 ~/ mode.bytesPerSecond).clamp(0, 1500); final bytesPerSecond = voiceModeBytesPerSecond(mode);
if (bytesPerSecond <= 0) return 0;
return (codec2Data.length * 1000 ~/ bytesPerSecond).clamp(0, 1500);
} catch (_) {
// Be permissive with stale or malformed persisted voice metadata.
return 0;
}
} }
@override @override
@@ -257,7 +261,13 @@ class VoiceEnvelope {
} }
int voiceModeBytesPerSecond(VoicePacketMode mode) => switch (mode) { int voiceModeBytesPerSecond(VoicePacketMode mode) => switch (mode) {
_ => mode.bytesPerSecond, VoicePacketMode.mode700c => 100,
VoicePacketMode.mode1200 => 150,
VoicePacketMode.mode2400 => 300,
VoicePacketMode.mode1300 => 175,
VoicePacketMode.mode1400 => 175,
VoicePacketMode.mode1600 => 200,
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.

View File

@@ -775,15 +775,6 @@ 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: "."
ref: main
resolved-ref: "39fadf011552a785993657506aedff85186b968b"
url: "https://github.com/dz0ny/lpcnet_flutter.git"
source: git
version: "0.1.0"
matcher: matcher:
dependency: transitive dependency: transitive
description: description:

View File

@@ -51,10 +51,6 @@ 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:
git:
url: https://github.com/dz0ny/lpcnet_flutter.git
ref: main
ffi: ^2.1.0 ffi: ^2.1.0
# Voice recording (raw PCM stream at 8000 Hz) # Voice recording (raw PCM stream at 8000 Hz)