From 4a2e458a01bd85d81485beab5322d97b97e0e422 Mon Sep 17 00:00:00 2001 From: Ruslan Bakiev <572431+veikab@users.noreply.github.com> Date: Wed, 13 May 2026 15:31:56 +0700 Subject: [PATCH] Use Web Audio for browser voice meter --- lib/audio/browser_voice_meter.dart | 2 + lib/audio/browser_voice_meter_stub.dart | 16 +++ lib/audio/browser_voice_meter_web.dart | 165 ++++++++++++++++++++++++ lib/screens/mapflow_shell.dart | 77 +++++++++-- 4 files changed, 248 insertions(+), 12 deletions(-) create mode 100644 lib/audio/browser_voice_meter.dart create mode 100644 lib/audio/browser_voice_meter_stub.dart create mode 100644 lib/audio/browser_voice_meter_web.dart diff --git a/lib/audio/browser_voice_meter.dart b/lib/audio/browser_voice_meter.dart new file mode 100644 index 0000000..d34dba6 --- /dev/null +++ b/lib/audio/browser_voice_meter.dart @@ -0,0 +1,2 @@ +export 'browser_voice_meter_stub.dart' + if (dart.library.js_interop) 'browser_voice_meter_web.dart'; diff --git a/lib/audio/browser_voice_meter_stub.dart b/lib/audio/browser_voice_meter_stub.dart new file mode 100644 index 0000000..dfc1cee --- /dev/null +++ b/lib/audio/browser_voice_meter_stub.dart @@ -0,0 +1,16 @@ +class VoiceMeterFrame { + const VoiceMeterFrame({required this.level, required this.samples}); + + final double level; + final List samples; +} + +class BrowserVoiceMeter { + bool get isSupported => false; + + Future start(void Function(VoiceMeterFrame frame) onFrame) async {} + + Future stop() async {} + + void dispose() {} +} diff --git a/lib/audio/browser_voice_meter_web.dart b/lib/audio/browser_voice_meter_web.dart new file mode 100644 index 0000000..058a058 --- /dev/null +++ b/lib/audio/browser_voice_meter_web.dart @@ -0,0 +1,165 @@ +import 'dart:async'; +import 'dart:js_interop'; +import 'dart:math' as math; +import 'dart:typed_data'; + +import 'package:web/web.dart' as web; + +class VoiceMeterFrame { + const VoiceMeterFrame({required this.level, required this.samples}); + + final double level; + final List samples; +} + +class BrowserVoiceMeter { + web.AudioContext? _context; + web.AnalyserNode? _analyser; + web.MediaStream? _stream; + web.MediaStreamAudioSourceNode? _source; + Timer? _timer; + Float32List? _buffer; + var _noiseFloor = 0.012; + var _voiceCeiling = 0.08; + var _smoothedLevel = 0.0; + + bool get isSupported => true; + + Future start(void Function(VoiceMeterFrame frame) onFrame) async { + await stop(); + + final stream = await web.window.navigator.mediaDevices + .getUserMedia( + web.MediaStreamConstraints( + audio: { + 'echoCancellation': true, + 'noiseSuppression': true, + 'autoGainControl': true, + 'channelCount': 1, + }.jsify()!, + video: false.toJS, + ), + ) + .toDart; + final context = web.AudioContext(); + if (context.state == 'suspended') { + await context.resume().toDart; + } + + final source = context.createMediaStreamSource(stream); + final analyser = context.createAnalyser() + ..fftSize = 1024 + ..smoothingTimeConstant = 0.16; + source.connect(analyser); + + _context = context; + _stream = stream; + _source = source; + _analyser = analyser; + _buffer = Float32List(analyser.fftSize); + _noiseFloor = 0.012; + _voiceCeiling = 0.08; + _smoothedLevel = 0.0; + + _timer = Timer.periodic(const Duration(milliseconds: 45), (_) { + final frame = _readFrame(); + onFrame(frame); + }); + } + + Future stop() async { + _timer?.cancel(); + _timer = null; + _source?.disconnect(); + _source = null; + final stream = _stream; + if (stream != null) { + for (final track in stream.getTracks().toDart) { + track.stop(); + } + } + _stream = null; + _analyser = null; + final context = _context; + _context = null; + if (context != null && context.state != 'closed') { + await context.close().toDart; + } + } + + void dispose() { + _timer?.cancel(); + _timer = null; + } + + VoiceMeterFrame _readFrame() { + final analyser = _analyser; + final buffer = _buffer; + if (analyser == null || buffer == null) { + return const VoiceMeterFrame(level: 0, samples: []); + } + + analyser.getFloatTimeDomainData(buffer.toJS); + var sumSquares = 0.0; + var peak = 0.0; + for (final sample in buffer) { + final centered = sample.abs(); + peak = math.max(peak, centered); + sumSquares += centered * centered; + } + + final rms = math.sqrt(sumSquares / buffer.length); + final rawLevel = math.max(peak * 0.70, rms * 2.8); + if (rawLevel < _noiseFloor) { + _noiseFloor = _noiseFloor * 0.88 + rawLevel * 0.12; + } else { + _noiseFloor = _noiseFloor * 0.992 + rawLevel * 0.008; + } + if (rawLevel > _voiceCeiling) { + _voiceCeiling = _voiceCeiling * 0.68 + rawLevel * 0.32; + } else { + _voiceCeiling = math.max( + _noiseFloor + 0.035, + _voiceCeiling * 0.992 + rawLevel * 0.008, + ); + } + + final range = math.max(0.035, _voiceCeiling - _noiseFloor); + final gated = ((rawLevel - _noiseFloor - 0.008) / range).clamp(0.0, 1.0); + final level = math.pow(gated, 0.58).toDouble(); + _smoothedLevel += + (level - _smoothedLevel) * (level > _smoothedLevel ? 0.5 : 0.22); + + return VoiceMeterFrame( + level: _smoothedLevel, + samples: _bucketSamples(buffer, _noiseFloor, range), + ); + } + + List _bucketSamples( + Float32List buffer, + double noiseFloor, + double range, + ) { + const buckets = 12; + final bucketSize = (buffer.length / buckets).floor(); + return List.generate(buckets, (bucket) { + final start = bucket * bucketSize; + final end = bucket == buckets - 1 + ? buffer.length + : math.min(start + bucketSize, buffer.length); + var peak = 0.0; + var sumSquares = 0.0; + for (var index = start; index < end; index++) { + final sample = buffer[index].abs(); + peak = math.max(peak, sample); + sumSquares += sample * sample; + } + final count = math.max(1, end - start); + final rms = math.sqrt(sumSquares / count); + final raw = math.max(peak * 0.75, rms * 2.8); + final normalized = ((raw - noiseFloor) / range).clamp(0.0, 1.0); + return (0.08 + math.pow(normalized, 0.58) * 0.92).clamp(0.0, 1.0); + }); + } +} diff --git a/lib/screens/mapflow_shell.dart b/lib/screens/mapflow_shell.dart index 3a01cc1..9b561bf 100644 --- a/lib/screens/mapflow_shell.dart +++ b/lib/screens/mapflow_shell.dart @@ -11,6 +11,7 @@ import 'package:record/record.dart'; import '../api/mapflow_api.dart'; import '../auth/telegram_login_button.dart'; import '../auth/telegram_session.dart' as telegram_session; +import '../audio/browser_voice_meter.dart'; import '../models/place_models.dart'; import '../state/place_controller.dart'; @@ -724,6 +725,7 @@ class _AddExperienceFlowState extends ConsumerState { final _api = MapflowApi(); final _recorder = AudioRecorder(); + final _browserVoiceMeter = BrowserVoiceMeter(); final _waveSamples = List.filled(160, 0.0); final _visualRandom = math.Random(); @@ -738,6 +740,7 @@ class _AddExperienceFlowState extends ConsumerState { var _recording = false; var _submitting = false; var _micAllowed = true; + var _usingBrowserVoiceMeter = false; var _ambientLevel = 0.008; var _voiceCeiling = 0.045; var _noiseDb = -72.0; @@ -758,6 +761,7 @@ class _AddExperienceFlowState extends ConsumerState { _visualTimer?.cancel(); _amplitudeTimer?.cancel(); _audioStreamSub?.cancel(); + _browserVoiceMeter.dispose(); _recorder.dispose(); super.dispose(); } @@ -784,6 +788,22 @@ class _AddExperienceFlowState extends ConsumerState { } Future _startRecording() async { + if (_browserVoiceMeter.isSupported) { + await _browserVoiceMeter.start(_handleBrowserVoiceFrame); + _lastAudioChunkAt = DateTime.now(); + _lastInformationAt = _lastAudioChunkAt; + _startIdleWave(); + _startDurationTimer(); + setState(() { + _micAllowed = true; + _recording = true; + _usingBrowserVoiceMeter = true; + _liveLevel = 0; + _informationUnits = 0; + }); + return; + } + final hasPermission = await _recorder.hasPermission(); if (!hasPermission) { setState(() => _micAllowed = false); @@ -805,19 +825,12 @@ class _AddExperienceFlowState extends ConsumerState { _lastInformationAt = _lastAudioChunkAt; _startIdleWave(); _startAmplitudePolling(); - _timer = Timer.periodic(const Duration(seconds: 1), (_) { - if (!mounted) { - return; - } - setState(() => _seconds += 1); - ref - .read(placeControllerProvider.notifier) - .setReviewDuration(Duration(seconds: _seconds)); - }); + _startDurationTimer(); setState(() { _micAllowed = true; _recording = true; + _usingBrowserVoiceMeter = false; _liveLevel = 0; _informationUnits = 0; }); @@ -827,9 +840,13 @@ class _AddExperienceFlowState extends ConsumerState { _timer?.cancel(); _visualTimer?.cancel(); _amplitudeTimer?.cancel(); - await _audioStreamSub?.cancel(); - _audioStreamSub = null; - await _recorder.stop(); + if (_usingBrowserVoiceMeter) { + await _browserVoiceMeter.stop(); + } else { + await _audioStreamSub?.cancel(); + _audioStreamSub = null; + await _recorder.stop(); + } _lastAudioChunkAt = null; _lastInformationAt = null; if (!mounted) { @@ -837,10 +854,24 @@ class _AddExperienceFlowState extends ConsumerState { } setState(() { _recording = false; + _usingBrowserVoiceMeter = false; _liveLevel = 0; }); } + void _startDurationTimer() { + _timer?.cancel(); + _timer = Timer.periodic(const Duration(seconds: 1), (_) { + if (!mounted) { + return; + } + setState(() => _seconds += 1); + ref + .read(placeControllerProvider.notifier) + .setReviewDuration(Duration(seconds: _seconds)); + }); + } + void _startIdleWave() { _visualTimer?.cancel(); _visualTimer = Timer.periodic(const Duration(milliseconds: 70), (_) { @@ -910,6 +941,28 @@ class _AddExperienceFlowState extends ConsumerState { }); } + void _handleBrowserVoiceFrame(VoiceMeterFrame frame) { + if (!mounted || !_recording) { + return; + } + + final now = DateTime.now(); + final informationDelta = _consumeInformationDelta(frame.level, now); + _lastAudioChunkAt = now; + setState(() { + if (frame.samples.isNotEmpty) { + _waveSamples + ..removeRange(0, frame.samples.length) + ..addAll(frame.samples); + } + _liveLevel = _smoothLevel(_liveLevel, frame.level); + _informationUnits = math.min( + _minimumInformationUnits, + _informationUnits + informationDelta, + ); + }); + } + double _normalizeDbLevel(double currentDb) { final db = currentDb.clamp(-160.0, 0.0); if (db < _noiseDb) {