Fix voice quality regression

This commit is contained in:
Janez T
2026-03-12 19:38:41 +01:00
parent baa1aa6edd
commit d2e6b692f3
14 changed files with 1023 additions and 301 deletions

View File

@@ -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,

View File

@@ -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,
),
),
],
),
);
}
}

View File

@@ -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() {

View File

@@ -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,
),
],