Fix adaptive voice information meter
All checks were successful
Build and deploy Flutter Web / build (push) Successful in 1m41s

This commit is contained in:
Ruslan Bakiev
2026-05-09 18:12:00 +07:00
parent adc935b6cf
commit 2c9bcad0cc

View File

@@ -719,15 +719,17 @@ class AddExperienceFlow extends ConsumerStatefulWidget {
} }
class _AddExperienceFlowState extends ConsumerState<AddExperienceFlow> { class _AddExperienceFlowState extends ConsumerState<AddExperienceFlow> {
static const _minimumInformationUnits = 18.0; static const _minimumInformationUnits = 9.0;
static const _nearbyPlaceRadiusMeters = 200; static const _nearbyPlaceRadiusMeters = 200;
final _api = MapflowApi(); final _api = MapflowApi();
final _recorder = AudioRecorder(); final _recorder = AudioRecorder();
final _waveSamples = List<double>.filled(160, 0.0); final _waveSamples = List<double>.filled(160, 0.0);
final _visualRandom = math.Random();
Future<List<PlaceRecommendation>>? _nearbyPlacesFuture; Future<List<PlaceRecommendation>>? _nearbyPlacesFuture;
Timer? _timer; Timer? _timer;
Timer? _visualTimer;
StreamSubscription<Uint8List>? _audioStreamSub; StreamSubscription<Uint8List>? _audioStreamSub;
var _step = 0; var _step = 0;
var _seconds = 0; var _seconds = 0;
@@ -735,6 +737,9 @@ class _AddExperienceFlowState extends ConsumerState<AddExperienceFlow> {
var _recording = false; var _recording = false;
var _submitting = false; var _submitting = false;
var _micAllowed = true; var _micAllowed = true;
var _ambientLevel = 0.008;
var _voiceCeiling = 0.045;
var _visualPhase = 0.0;
DateTime? _lastAudioChunkAt; DateTime? _lastAudioChunkAt;
@override @override
@@ -745,6 +750,7 @@ class _AddExperienceFlowState extends ConsumerState<AddExperienceFlow> {
@override @override
void dispose() { void dispose() {
_timer?.cancel(); _timer?.cancel();
_visualTimer?.cancel();
_audioStreamSub?.cancel(); _audioStreamSub?.cancel();
_recorder.dispose(); _recorder.dispose();
super.dispose(); super.dispose();
@@ -790,6 +796,7 @@ class _AddExperienceFlowState extends ConsumerState<AddExperienceFlow> {
); );
_audioStreamSub = stream.listen(_handleAudioChunk); _audioStreamSub = stream.listen(_handleAudioChunk);
_lastAudioChunkAt = DateTime.now(); _lastAudioChunkAt = DateTime.now();
_startIdleWave();
_timer = Timer.periodic(const Duration(seconds: 1), (_) { _timer = Timer.periodic(const Duration(seconds: 1), (_) {
if (!mounted) { if (!mounted) {
return; return;
@@ -808,6 +815,7 @@ class _AddExperienceFlowState extends ConsumerState<AddExperienceFlow> {
Future<void> _stopRecording() async { Future<void> _stopRecording() async {
_timer?.cancel(); _timer?.cancel();
_visualTimer?.cancel();
await _audioStreamSub?.cancel(); await _audioStreamSub?.cancel();
_audioStreamSub = null; _audioStreamSub = null;
await _recorder.stop(); await _recorder.stop();
@@ -818,6 +826,35 @@ class _AddExperienceFlowState extends ConsumerState<AddExperienceFlow> {
setState(() => _recording = false); 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<double>.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) { void _handleAudioChunk(Uint8List chunk) {
if (chunk.length < 2) { if (chunk.length < 2) {
return; return;
@@ -825,22 +862,32 @@ class _AddExperienceFlowState extends ConsumerState<AddExperienceFlow> {
final bytes = ByteData.sublistView(chunk); final bytes = ByteData.sublistView(chunk);
final sampleCount = chunk.length ~/ 2; final sampleCount = chunk.length ~/ 2;
final bucketCount = 10.clamp(1, sampleCount); final bucketCount = 12.clamp(1, sampleCount);
final bucketSize = (sampleCount / bucketCount).ceil(); final bucketSize = (sampleCount / bucketCount).ceil();
final peaks = <double>[]; final peaks = <double>[];
var totalRms = 0.0;
for (var bucket = 0; bucket < bucketCount; bucket++) { for (var bucket = 0; bucket < bucketCount; bucket++) {
final startSample = bucket * bucketSize; final startSample = bucket * bucketSize;
final endSample = math.min(startSample + bucketSize, sampleCount); final endSample = math.min(startSample + bucketSize, sampleCount);
var peak = 0.0; var peak = 0.0;
var sumSquares = 0.0;
var bucketSamples = 0;
for ( for (
var sampleIndex = startSample; var sampleIndex = startSample;
sampleIndex < endSample; sampleIndex < endSample;
sampleIndex++ sampleIndex++
) { ) {
final sample = bytes.getInt16(sampleIndex * 2, Endian.little) / 32768.0; 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) { if (peaks.isEmpty) {
return; return;
@@ -851,15 +898,21 @@ class _AddExperienceFlowState extends ConsumerState<AddExperienceFlow> {
_lastAudioChunkAt = now; _lastAudioChunkAt = now;
final deltaSeconds = final deltaSeconds =
now.difference(previousChunkAt).inMilliseconds.clamp(20, 300) / 1000; 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 = final voicedAmount =
peaks normalizedPeaks.fold<double>(0, (sum, value) => sum + value) /
.map( normalizedPeaks.length;
(peak) =>
((peak - noiseFloor) / (1 - noiseFloor)).clamp(0.0, 1.0),
)
.fold<double>(0, (sum, value) => sum + value) /
peaks.length;
final informationDelta = voicedAmount * deltaSeconds; final informationDelta = voicedAmount * deltaSeconds;
if (!mounted) { if (!mounted) {
@@ -867,8 +920,13 @@ class _AddExperienceFlowState extends ConsumerState<AddExperienceFlow> {
} }
setState(() { setState(() {
_waveSamples _waveSamples
..removeRange(0, peaks.length) ..removeRange(0, normalizedPeaks.length)
..addAll(peaks); ..addAll(
normalizedPeaks.map((peak) {
final visibleSignal = 0.12 + peak * 0.88;
return visibleSignal.clamp(0.0, 1.0);
}),
);
_informationUnits = math.min( _informationUnits = math.min(
_minimumInformationUnits, _minimumInformationUnits,
_informationUnits + informationDelta, _informationUnits + informationDelta,