Use Web Audio for browser voice meter
All checks were successful
Build and deploy Flutter Web / build (push) Successful in 2m22s

This commit is contained in:
Ruslan Bakiev
2026-05-13 15:31:56 +07:00
parent c9be8b5e75
commit 4a2e458a01
4 changed files with 248 additions and 12 deletions

View File

@@ -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<AddExperienceFlow> {
final _api = MapflowApi();
final _recorder = AudioRecorder();
final _browserVoiceMeter = BrowserVoiceMeter();
final _waveSamples = List<double>.filled(160, 0.0);
final _visualRandom = math.Random();
@@ -738,6 +740,7 @@ class _AddExperienceFlowState extends ConsumerState<AddExperienceFlow> {
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<AddExperienceFlow> {
_visualTimer?.cancel();
_amplitudeTimer?.cancel();
_audioStreamSub?.cancel();
_browserVoiceMeter.dispose();
_recorder.dispose();
super.dispose();
}
@@ -784,6 +788,22 @@ class _AddExperienceFlowState extends ConsumerState<AddExperienceFlow> {
}
Future<void> _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<AddExperienceFlow> {
_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<AddExperienceFlow> {
_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<AddExperienceFlow> {
}
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<AddExperienceFlow> {
});
}
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) {