From 2c9bcad0cc05693fcf67c97db639d53f6781ac7b Mon Sep 17 00:00:00 2001 From: Ruslan Bakiev <572431+veikab@users.noreply.github.com> Date: Sat, 9 May 2026 18:12:00 +0700 Subject: [PATCH] Fix adaptive voice information meter --- lib/screens/mapflow_shell.dart | 86 ++++++++++++++++++++++++++++------ 1 file changed, 72 insertions(+), 14 deletions(-) diff --git a/lib/screens/mapflow_shell.dart b/lib/screens/mapflow_shell.dart index 4ff3c3c..b8099d3 100644 --- a/lib/screens/mapflow_shell.dart +++ b/lib/screens/mapflow_shell.dart @@ -719,15 +719,17 @@ class AddExperienceFlow extends ConsumerStatefulWidget { } class _AddExperienceFlowState extends ConsumerState { - static const _minimumInformationUnits = 18.0; + static const _minimumInformationUnits = 9.0; static const _nearbyPlaceRadiusMeters = 200; final _api = MapflowApi(); final _recorder = AudioRecorder(); final _waveSamples = List.filled(160, 0.0); + final _visualRandom = math.Random(); Future>? _nearbyPlacesFuture; Timer? _timer; + Timer? _visualTimer; StreamSubscription? _audioStreamSub; var _step = 0; var _seconds = 0; @@ -735,6 +737,9 @@ class _AddExperienceFlowState extends ConsumerState { var _recording = false; var _submitting = false; var _micAllowed = true; + var _ambientLevel = 0.008; + var _voiceCeiling = 0.045; + var _visualPhase = 0.0; DateTime? _lastAudioChunkAt; @override @@ -745,6 +750,7 @@ class _AddExperienceFlowState extends ConsumerState { @override void dispose() { _timer?.cancel(); + _visualTimer?.cancel(); _audioStreamSub?.cancel(); _recorder.dispose(); super.dispose(); @@ -790,6 +796,7 @@ class _AddExperienceFlowState extends ConsumerState { ); _audioStreamSub = stream.listen(_handleAudioChunk); _lastAudioChunkAt = DateTime.now(); + _startIdleWave(); _timer = Timer.periodic(const Duration(seconds: 1), (_) { if (!mounted) { return; @@ -808,6 +815,7 @@ class _AddExperienceFlowState extends ConsumerState { Future _stopRecording() async { _timer?.cancel(); + _visualTimer?.cancel(); await _audioStreamSub?.cancel(); _audioStreamSub = null; await _recorder.stop(); @@ -818,6 +826,35 @@ class _AddExperienceFlowState extends ConsumerState { setState(() => _recording = false); } + void _startIdleWave() { + _visualTimer?.cancel(); + _visualTimer = Timer.periodic(const Duration(milliseconds: 70), (_) { + if (!mounted || !_recording) { + return; + } + + final lastSignalAt = _lastAudioChunkAt; + final hasFreshSignal = + lastSignalAt != null && + DateTime.now().difference(lastSignalAt).inMilliseconds < 220; + if (hasFreshSignal) { + return; + } + + _visualPhase += 0.24; + final idleSamples = List.generate(8, (index) { + final wave = math.sin(_visualPhase + index * 0.72) * 0.5 + 0.5; + final noise = _visualRandom.nextDouble() * 0.16; + return (0.10 + wave * 0.18 + noise).clamp(0.0, 1.0); + }); + setState(() { + _waveSamples + ..removeRange(0, idleSamples.length) + ..addAll(idleSamples); + }); + }); + } + void _handleAudioChunk(Uint8List chunk) { if (chunk.length < 2) { return; @@ -825,22 +862,32 @@ class _AddExperienceFlowState extends ConsumerState { final bytes = ByteData.sublistView(chunk); final sampleCount = chunk.length ~/ 2; - final bucketCount = 10.clamp(1, sampleCount); + final bucketCount = 12.clamp(1, sampleCount); final bucketSize = (sampleCount / bucketCount).ceil(); final peaks = []; + var totalRms = 0.0; for (var bucket = 0; bucket < bucketCount; bucket++) { final startSample = bucket * bucketSize; final endSample = math.min(startSample + bucketSize, sampleCount); var peak = 0.0; + var sumSquares = 0.0; + var bucketSamples = 0; for ( var sampleIndex = startSample; sampleIndex < endSample; sampleIndex++ ) { final sample = bytes.getInt16(sampleIndex * 2, Endian.little) / 32768.0; - peak = math.max(peak, sample.abs()); + final absoluteSample = sample.abs(); + peak = math.max(peak, absoluteSample); + sumSquares += absoluteSample * absoluteSample; + bucketSamples += 1; } - peaks.add((peak * 3.4).clamp(0.0, 1.0)); + final rms = bucketSamples == 0 + ? 0.0 + : math.sqrt(sumSquares / bucketSamples); + totalRms += rms; + peaks.add(math.max(peak, rms * 2.6)); } if (peaks.isEmpty) { return; @@ -851,15 +898,21 @@ class _AddExperienceFlowState extends ConsumerState { _lastAudioChunkAt = now; final deltaSeconds = now.difference(previousChunkAt).inMilliseconds.clamp(20, 300) / 1000; - const noiseFloor = 0.08; + final currentRms = totalRms / peaks.length; + _ambientLevel = currentRms < _ambientLevel + ? (_ambientLevel * 0.86 + currentRms * 0.14) + : (_ambientLevel * 0.985 + currentRms * 0.015); + _voiceCeiling = currentRms > _voiceCeiling + ? (_voiceCeiling * 0.68 + currentRms * 0.32) + : math.max(_ambientLevel + 0.018, _voiceCeiling * 0.992); + final dynamicRange = math.max(0.012, _voiceCeiling - _ambientLevel); + final normalizedPeaks = peaks + .map((peak) => ((peak - _ambientLevel) / dynamicRange).clamp(0.0, 1.0)) + .map((peak) => math.pow(peak, 0.62).toDouble()) + .toList(); final voicedAmount = - peaks - .map( - (peak) => - ((peak - noiseFloor) / (1 - noiseFloor)).clamp(0.0, 1.0), - ) - .fold(0, (sum, value) => sum + value) / - peaks.length; + normalizedPeaks.fold(0, (sum, value) => sum + value) / + normalizedPeaks.length; final informationDelta = voicedAmount * deltaSeconds; if (!mounted) { @@ -867,8 +920,13 @@ class _AddExperienceFlowState extends ConsumerState { } setState(() { _waveSamples - ..removeRange(0, peaks.length) - ..addAll(peaks); + ..removeRange(0, normalizedPeaks.length) + ..addAll( + normalizedPeaks.map((peak) { + final visibleSignal = 0.12 + peak * 0.88; + return visibleSignal.clamp(0.0, 1.0); + }), + ); _informationUnits = math.min( _minimumInformationUnits, _informationUnits + informationDelta,