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> {
static const _minimumInformationUnits = 18.0;
static const _minimumInformationUnits = 9.0;
static const _nearbyPlaceRadiusMeters = 200;
final _api = MapflowApi();
final _recorder = AudioRecorder();
final _waveSamples = List<double>.filled(160, 0.0);
final _visualRandom = math.Random();
Future<List<PlaceRecommendation>>? _nearbyPlacesFuture;
Timer? _timer;
Timer? _visualTimer;
StreamSubscription<Uint8List>? _audioStreamSub;
var _step = 0;
var _seconds = 0;
@@ -735,6 +737,9 @@ class _AddExperienceFlowState extends ConsumerState<AddExperienceFlow> {
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<AddExperienceFlow> {
@override
void dispose() {
_timer?.cancel();
_visualTimer?.cancel();
_audioStreamSub?.cancel();
_recorder.dispose();
super.dispose();
@@ -790,6 +796,7 @@ class _AddExperienceFlowState extends ConsumerState<AddExperienceFlow> {
);
_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<AddExperienceFlow> {
Future<void> _stopRecording() async {
_timer?.cancel();
_visualTimer?.cancel();
await _audioStreamSub?.cancel();
_audioStreamSub = null;
await _recorder.stop();
@@ -818,6 +826,35 @@ class _AddExperienceFlowState extends ConsumerState<AddExperienceFlow> {
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) {
if (chunk.length < 2) {
return;
@@ -825,22 +862,32 @@ class _AddExperienceFlowState extends ConsumerState<AddExperienceFlow> {
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 = <double>[];
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<AddExperienceFlow> {
_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<double>(0, (sum, value) => sum + value) /
peaks.length;
normalizedPeaks.fold<double>(0, (sum, value) => sum + value) /
normalizedPeaks.length;
final informationDelta = voicedAmount * deltaSeconds;
if (!mounted) {
@@ -867,8 +920,13 @@ class _AddExperienceFlowState extends ConsumerState<AddExperienceFlow> {
}
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,