mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 08:20:36 +00:00
Fix voice quality regression
This commit is contained in:
@@ -39,6 +39,7 @@ class _ContactsTabState extends State<ContactsTab> {
|
||||
ContactSection.rooms: '',
|
||||
ContactSection.channels: '',
|
||||
};
|
||||
late final Map<ContactSection, TextEditingController> _filterControllers;
|
||||
final Map<ContactSection, ContactSortMode> _sortModes = {
|
||||
ContactSection.teamMembers: ContactSortMode.lastSeen,
|
||||
ContactSection.repeaters: ContactSortMode.lastSeen,
|
||||
@@ -48,6 +49,10 @@ class _ContactsTabState extends State<ContactsTab> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_filterControllers = {
|
||||
for (final section in ContactSection.values)
|
||||
section: TextEditingController(text: _sectionFilters[section] ?? ''),
|
||||
};
|
||||
_getCurrentLocation();
|
||||
// Mark all contacts as viewed when tab is opened
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
@@ -55,6 +60,14 @@ class _ContactsTabState extends State<ContactsTab> {
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
for (final controller in _filterControllers.values) {
|
||||
controller.dispose();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _getCurrentLocation() async {
|
||||
try {
|
||||
final position = await Geolocator.getCurrentPosition(
|
||||
@@ -513,6 +526,7 @@ class _ContactsTabState extends State<ContactsTab> {
|
||||
) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
final controller = _filterControllers[section]!;
|
||||
final hasFilter = (_sectionFilters[section] ?? '').isNotEmpty;
|
||||
|
||||
return Padding(
|
||||
@@ -555,10 +569,7 @@ class _ContactsTabState extends State<ContactsTab> {
|
||||
),
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
key: ValueKey(
|
||||
'${section.name}:${_sectionFilters[section] ?? ''}',
|
||||
),
|
||||
initialValue: _sectionFilters[section] ?? '',
|
||||
controller: controller,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_sectionFilters[section] = value;
|
||||
@@ -598,6 +609,7 @@ class _ContactsTabState extends State<ContactsTab> {
|
||||
child: InkWell(
|
||||
customBorder: const CircleBorder(),
|
||||
onTap: () {
|
||||
controller.clear();
|
||||
setState(() {
|
||||
_sectionFilters[section] = '';
|
||||
});
|
||||
@@ -856,6 +868,7 @@ class _InferredContactGroupCard extends StatelessWidget {
|
||||
...contacts.map(
|
||||
(contact) => ContactTile(
|
||||
contact: contact,
|
||||
groupLabel: label,
|
||||
currentPosition: currentPosition,
|
||||
calculateDistance: calculateDistance,
|
||||
formatDistance: formatDistance,
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../models/device_info.dart';
|
||||
import '../providers/connection_provider.dart';
|
||||
import '../services/validation_service.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
@@ -97,6 +98,10 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
|
||||
context.read<ConnectionProvider>().getAllowedRepeatFreq();
|
||||
});
|
||||
}
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
context.read<ConnectionProvider>().getBatteryAndStorage();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -432,6 +437,13 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
|
||||
icon: Icons.key,
|
||||
label: 'FW v${deviceInfo.firmwareVersion?.toString() ?? "?"}',
|
||||
),
|
||||
if (deviceInfo.storageUsedKb != null &&
|
||||
deviceInfo.storageTotalKb != null)
|
||||
_StatusChipData(
|
||||
icon: Icons.storage_rounded,
|
||||
label:
|
||||
'${_formatStorage(deviceInfo.storageUsedKb!)} / ${_formatStorage(deviceInfo.storageTotalKb!)}',
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
@@ -485,6 +497,19 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
|
||||
deviceInfo.maxChannels?.toString() ??
|
||||
AppLocalizations.of(context)!.unknown,
|
||||
),
|
||||
_InfoRow(
|
||||
'Storage used',
|
||||
_formatStorageValue(deviceInfo.storageUsedKb),
|
||||
),
|
||||
_InfoRow(
|
||||
'Storage limit',
|
||||
_formatStorageValue(deviceInfo.storageTotalKb),
|
||||
),
|
||||
_InfoRow('Storage status', _formatStorageStatus(deviceInfo)),
|
||||
if (deviceInfo.storageUsedPercent != null) ...[
|
||||
const SizedBox(height: 10),
|
||||
_StorageUsageMeter(deviceInfo: deviceInfo),
|
||||
],
|
||||
_CopyableInfoRow(
|
||||
AppLocalizations.of(context)!.publicKey,
|
||||
_getPublicKeyHex(deviceInfo.publicKey),
|
||||
@@ -793,6 +818,35 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
|
||||
if (publicKey == null || publicKey.isEmpty) return 'N/A';
|
||||
return publicKey.map((b) => b.toRadixString(16).padLeft(2, '0')).join('');
|
||||
}
|
||||
|
||||
String _formatStorageValue(int? storageKb) {
|
||||
if (storageKb == null) {
|
||||
return AppLocalizations.of(context)!.unknown;
|
||||
}
|
||||
return _formatStorage(storageKb);
|
||||
}
|
||||
|
||||
String _formatStorageStatus(DeviceInfo deviceInfo) {
|
||||
final used = deviceInfo.storageUsedKb;
|
||||
final total = deviceInfo.storageTotalKb;
|
||||
final percent = deviceInfo.storageUsedPercent;
|
||||
|
||||
if (used == null || total == null || percent == null) {
|
||||
return AppLocalizations.of(context)!.unknown;
|
||||
}
|
||||
|
||||
return '${percent.toStringAsFixed(0)}% full (${_formatStorage(total - used)} free)';
|
||||
}
|
||||
|
||||
String _formatStorage(int storageKb) {
|
||||
if (storageKb >= 1024 * 1024) {
|
||||
return '${(storageKb / (1024 * 1024)).toStringAsFixed(2)} GB';
|
||||
}
|
||||
if (storageKb >= 1024) {
|
||||
return '${(storageKb / 1024).toStringAsFixed(1)} MB';
|
||||
}
|
||||
return '$storageKb KB';
|
||||
}
|
||||
}
|
||||
|
||||
class _ConfigHeroCard extends StatelessWidget {
|
||||
@@ -1162,3 +1216,60 @@ class _CopyableInfoRow extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StorageUsageMeter extends StatelessWidget {
|
||||
final DeviceInfo deviceInfo;
|
||||
|
||||
const _StorageUsageMeter({required this.deviceInfo});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
final percent = ((deviceInfo.storageUsedPercent ?? 0) / 100).clamp(
|
||||
0.0,
|
||||
1.0,
|
||||
);
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.45),
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.storage_rounded, color: colorScheme.primary, size: 18),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Device storage',
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
child: LinearProgressIndicator(
|
||||
value: percent,
|
||||
minHeight: 10,
|
||||
backgroundColor: colorScheme.surface,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
'${(deviceInfo.storageUsedPercent ?? 0).toStringAsFixed(0)}% used',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,7 +72,7 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
final VoiceRecorderService _voiceRecorder = VoiceRecorderService();
|
||||
bool _isRecording = false;
|
||||
bool _isSendingVoice = false;
|
||||
static const int _maxVoicePackets = 10;
|
||||
static const Duration _maxVoiceRecordingDuration = Duration(seconds: 4);
|
||||
static const double _silenceRmsThreshold = 500.0;
|
||||
static const double _silencePeakThreshold = 1400.0;
|
||||
static const int _maxInteriorSilentChunks = 2;
|
||||
@@ -865,9 +865,10 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
final packetDuration = Duration(
|
||||
milliseconds: _activeVoiceMode!.packetDurationMs,
|
||||
);
|
||||
final maxVoicePackets = _maxVoicePacketsForMode(_activeVoiceMode!);
|
||||
|
||||
debugPrint(
|
||||
'🎙️ [Voice] session=$_currentVoiceSessionId mode=$_activeVoiceMode chunkDuration=${packetDuration.inMilliseconds}ms',
|
||||
'🎙️ [Voice] session=$_currentVoiceSessionId mode=$_activeVoiceMode chunkDuration=${packetDuration.inMilliseconds}ms maxPackets=$maxVoicePackets',
|
||||
);
|
||||
|
||||
_recordedChunks.clear();
|
||||
@@ -877,9 +878,13 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
final stream = _voiceRecorder.startCapture(
|
||||
chunkDuration: packetDuration,
|
||||
sampleRateHz: _activeVoiceMode!.sampleRateHz,
|
||||
codecKind: _activeVoiceMode!.codec,
|
||||
enableBandPassFilter: appProvider.isVoiceBandPassFilterEnabled,
|
||||
enableCompressor: appProvider.isVoiceCompressorEnabled,
|
||||
enableLimiter: appProvider.isVoiceLimiterEnabled,
|
||||
enableAutoGain: appProvider.isVoiceAutoGainEnabled,
|
||||
enableEchoCancellation: appProvider.isVoiceEchoCancellationEnabled,
|
||||
enableNoiseSuppression: appProvider.isVoiceNoiseSuppressionEnabled,
|
||||
);
|
||||
debugPrint('🎙️ [Voice] capture started, listening for chunks...');
|
||||
_voiceStreamSub = stream.listen(
|
||||
@@ -890,7 +895,7 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
'🎙️ [Voice] chunk #${_recordedChunks.length} received: ${pcmChunk.length} samples',
|
||||
);
|
||||
setState(() {});
|
||||
if (_recordedChunks.length >= _maxVoicePackets) {
|
||||
if (_recordedChunks.length >= maxVoicePackets) {
|
||||
debugPrint('🎙️ [Voice] max packets reached, stopping');
|
||||
_stopAndSendVoice();
|
||||
}
|
||||
@@ -907,6 +912,12 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
}
|
||||
}
|
||||
|
||||
int _maxVoicePacketsForMode(VoicePacketMode mode) {
|
||||
final packets =
|
||||
_maxVoiceRecordingDuration.inMilliseconds ~/ mode.packetDurationMs;
|
||||
return packets < 1 ? 1 : packets;
|
||||
}
|
||||
|
||||
Future<void> _stopAndSendVoice() async {
|
||||
if (!_isRecording) return;
|
||||
final trimSilenceEnabled = context
|
||||
@@ -921,9 +932,14 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
await _voiceRecorder.stopCapture();
|
||||
|
||||
final rawChunks = List<Int16List>.from(_recordedChunks);
|
||||
final chunks = trimSilenceEnabled ? _trimSilence(rawChunks) : rawChunks;
|
||||
final trimmedChunks = trimSilenceEnabled
|
||||
? _trimSilence(rawChunks)
|
||||
: rawChunks;
|
||||
final sessionId = _currentVoiceSessionId;
|
||||
final mode = _activeVoiceMode;
|
||||
final chunks = mode == null
|
||||
? trimmedChunks
|
||||
: _prepareChunksForSending(trimmedChunks, mode);
|
||||
_recordedChunks.clear();
|
||||
|
||||
debugPrint(
|
||||
@@ -1005,10 +1021,15 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
debugPrint(
|
||||
'🎙️ [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++) {
|
||||
if (!mounted) return;
|
||||
try {
|
||||
final codec2Data = await codec.encode(chunks[i], mode);
|
||||
final codec2Data = mode.codec == VoiceCodecKind.lpcnet
|
||||
? encodedChunks[i]
|
||||
: await codec.encode(chunks[i], mode);
|
||||
debugPrint(
|
||||
'🎙️ [Voice] packet $i/$total encoded: ${codec2Data.length} bytes',
|
||||
);
|
||||
@@ -1112,6 +1133,43 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
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> chunks,
|
||||
VoicePacketMode mode,
|
||||
) {
|
||||
return chunks;
|
||||
}
|
||||
|
||||
List<Int16List> _trimSilence(List<Int16List> chunks) {
|
||||
if (chunks.isEmpty) return chunks;
|
||||
|
||||
@@ -1141,16 +1199,28 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
bool _isSilentChunk(Int16List chunk) {
|
||||
if (chunk.isEmpty) return true;
|
||||
|
||||
final rms = _chunkRms(chunk);
|
||||
final peak = _chunkPeak(chunk);
|
||||
return rms < _silenceRmsThreshold && peak < _silencePeakThreshold;
|
||||
}
|
||||
|
||||
double _chunkRms(Int16List chunk) {
|
||||
var sumSquares = 0.0;
|
||||
for (final sample in chunk) {
|
||||
sumSquares += sample * sample;
|
||||
}
|
||||
return math.sqrt(sumSquares / chunk.length);
|
||||
}
|
||||
|
||||
int _chunkPeak(Int16List chunk) {
|
||||
var peak = 0;
|
||||
for (final sample in chunk) {
|
||||
final absSample = sample.abs();
|
||||
if (absSample > peak) peak = absSample;
|
||||
sumSquares += sample * sample;
|
||||
if (absSample > peak) {
|
||||
peak = absSample;
|
||||
}
|
||||
}
|
||||
|
||||
final rms = math.sqrt(sumSquares / chunk.length);
|
||||
return rms < _silenceRmsThreshold && peak < _silencePeakThreshold;
|
||||
return peak;
|
||||
}
|
||||
|
||||
bool _isPublicChannelSelected() {
|
||||
|
||||
@@ -1185,6 +1185,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
bandPassEnabled: appProvider.isVoiceBandPassFilterEnabled,
|
||||
compressorEnabled: appProvider.isVoiceCompressorEnabled,
|
||||
limiterEnabled: appProvider.isVoiceLimiterEnabled,
|
||||
autoGainEnabled: appProvider.isVoiceAutoGainEnabled,
|
||||
echoCancellationEnabled:
|
||||
appProvider.isVoiceEchoCancellationEnabled,
|
||||
noiseSuppressionEnabled:
|
||||
appProvider.isVoiceNoiseSuppressionEnabled,
|
||||
silenceTrimEnabled: appProvider.isVoiceSilenceTrimmingEnabled,
|
||||
),
|
||||
),
|
||||
@@ -1241,6 +1246,43 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
},
|
||||
),
|
||||
),
|
||||
Consumer<AppProvider>(
|
||||
builder: (context, appProvider, child) => SwitchListTile(
|
||||
secondary: const Icon(Icons.auto_fix_high),
|
||||
title: const Text('Mic auto gain'),
|
||||
subtitle: const Text('Lets the recorder adjust input level'),
|
||||
value: appProvider.isVoiceAutoGainEnabled,
|
||||
onChanged: (value) async {
|
||||
await appProvider.toggleVoiceAutoGainEnabled(value);
|
||||
},
|
||||
),
|
||||
),
|
||||
Consumer<AppProvider>(
|
||||
builder: (context, appProvider, child) => SwitchListTile(
|
||||
secondary: const Icon(Icons.hearing_disabled),
|
||||
title: const Text('Echo cancellation'),
|
||||
subtitle: const Text(
|
||||
'Uses recorder echo cancellation if available',
|
||||
),
|
||||
value: appProvider.isVoiceEchoCancellationEnabled,
|
||||
onChanged: (value) async {
|
||||
await appProvider.toggleVoiceEchoCancellationEnabled(value);
|
||||
},
|
||||
),
|
||||
),
|
||||
Consumer<AppProvider>(
|
||||
builder: (context, appProvider, child) => SwitchListTile(
|
||||
secondary: const Icon(Icons.noise_control_off),
|
||||
title: const Text('Noise suppression'),
|
||||
subtitle: const Text(
|
||||
'Uses recorder noise suppression if available',
|
||||
),
|
||||
value: appProvider.isVoiceNoiseSuppressionEnabled,
|
||||
onChanged: (value) async {
|
||||
await appProvider.toggleVoiceNoiseSuppressionEnabled(value);
|
||||
},
|
||||
),
|
||||
),
|
||||
Consumer<AppProvider>(
|
||||
builder: (context, appProvider, child) => SwitchListTile(
|
||||
secondary: const Icon(Icons.content_cut),
|
||||
@@ -1761,6 +1803,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
required bool bandPassEnabled,
|
||||
required bool compressorEnabled,
|
||||
required bool limiterEnabled,
|
||||
required bool autoGainEnabled,
|
||||
required bool echoCancellationEnabled,
|
||||
required bool noiseSuppressionEnabled,
|
||||
required bool silenceTrimEnabled,
|
||||
}) {
|
||||
final supported = VoiceBitratePreferences.supportedBitrates;
|
||||
@@ -1773,6 +1818,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
(bandPassEnabled ? 1 : 0) +
|
||||
(compressorEnabled ? 1 : 0) +
|
||||
(limiterEnabled ? 1 : 0) +
|
||||
(autoGainEnabled ? 1 : 0) +
|
||||
(echoCancellationEnabled ? 1 : 0) +
|
||||
(noiseSuppressionEnabled ? 1 : 0) +
|
||||
(silenceTrimEnabled ? 1 : 0);
|
||||
final radioBw = connectionProvider.deviceInfo.radioBw;
|
||||
final radioSf = connectionProvider.deviceInfo.radioSf;
|
||||
@@ -1864,6 +1912,31 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _voiceStatChip(
|
||||
label: 'Auto gain',
|
||||
enabled: autoGainEnabled,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: _voiceStatChip(
|
||||
label: 'Echo cancel',
|
||||
enabled: echoCancellationEnabled,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: _voiceStatChip(
|
||||
label: 'Noise suppress',
|
||||
enabled: noiseSuppressionEnabled,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
@@ -1876,7 +1949,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Processing enabled: $enabledCount/4',
|
||||
'Processing enabled: $enabledCount/7',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user