mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 16:30:28 +00:00
Add live mesh traffic view
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -80,10 +80,11 @@ 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
|
||||||
|
|
||||||
# Dart code coverage
|
# Dart code coverage
|
||||||
coverage/
|
coverage/
|
||||||
lcov.info
|
lcov.info
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user