Use Web Audio for browser voice meter
All checks were successful
Build and deploy Flutter Web / build (push) Successful in 2m22s
All checks were successful
Build and deploy Flutter Web / build (push) Successful in 2m22s
This commit is contained in:
2
lib/audio/browser_voice_meter.dart
Normal file
2
lib/audio/browser_voice_meter.dart
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export 'browser_voice_meter_stub.dart'
|
||||||
|
if (dart.library.js_interop) 'browser_voice_meter_web.dart';
|
||||||
16
lib/audio/browser_voice_meter_stub.dart
Normal file
16
lib/audio/browser_voice_meter_stub.dart
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
class VoiceMeterFrame {
|
||||||
|
const VoiceMeterFrame({required this.level, required this.samples});
|
||||||
|
|
||||||
|
final double level;
|
||||||
|
final List<double> samples;
|
||||||
|
}
|
||||||
|
|
||||||
|
class BrowserVoiceMeter {
|
||||||
|
bool get isSupported => false;
|
||||||
|
|
||||||
|
Future<void> start(void Function(VoiceMeterFrame frame) onFrame) async {}
|
||||||
|
|
||||||
|
Future<void> stop() async {}
|
||||||
|
|
||||||
|
void dispose() {}
|
||||||
|
}
|
||||||
165
lib/audio/browser_voice_meter_web.dart
Normal file
165
lib/audio/browser_voice_meter_web.dart
Normal file
@@ -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<double> 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<void> 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<void> 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<double> _bucketSamples(
|
||||||
|
Float32List buffer,
|
||||||
|
double noiseFloor,
|
||||||
|
double range,
|
||||||
|
) {
|
||||||
|
const buckets = 12;
|
||||||
|
final bucketSize = (buffer.length / buckets).floor();
|
||||||
|
return List<double>.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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import 'package:record/record.dart';
|
|||||||
import '../api/mapflow_api.dart';
|
import '../api/mapflow_api.dart';
|
||||||
import '../auth/telegram_login_button.dart';
|
import '../auth/telegram_login_button.dart';
|
||||||
import '../auth/telegram_session.dart' as telegram_session;
|
import '../auth/telegram_session.dart' as telegram_session;
|
||||||
|
import '../audio/browser_voice_meter.dart';
|
||||||
import '../models/place_models.dart';
|
import '../models/place_models.dart';
|
||||||
import '../state/place_controller.dart';
|
import '../state/place_controller.dart';
|
||||||
|
|
||||||
@@ -724,6 +725,7 @@ class _AddExperienceFlowState extends ConsumerState<AddExperienceFlow> {
|
|||||||
|
|
||||||
final _api = MapflowApi();
|
final _api = MapflowApi();
|
||||||
final _recorder = AudioRecorder();
|
final _recorder = AudioRecorder();
|
||||||
|
final _browserVoiceMeter = BrowserVoiceMeter();
|
||||||
final _waveSamples = List<double>.filled(160, 0.0);
|
final _waveSamples = List<double>.filled(160, 0.0);
|
||||||
final _visualRandom = math.Random();
|
final _visualRandom = math.Random();
|
||||||
|
|
||||||
@@ -738,6 +740,7 @@ class _AddExperienceFlowState extends ConsumerState<AddExperienceFlow> {
|
|||||||
var _recording = false;
|
var _recording = false;
|
||||||
var _submitting = false;
|
var _submitting = false;
|
||||||
var _micAllowed = true;
|
var _micAllowed = true;
|
||||||
|
var _usingBrowserVoiceMeter = false;
|
||||||
var _ambientLevel = 0.008;
|
var _ambientLevel = 0.008;
|
||||||
var _voiceCeiling = 0.045;
|
var _voiceCeiling = 0.045;
|
||||||
var _noiseDb = -72.0;
|
var _noiseDb = -72.0;
|
||||||
@@ -758,6 +761,7 @@ class _AddExperienceFlowState extends ConsumerState<AddExperienceFlow> {
|
|||||||
_visualTimer?.cancel();
|
_visualTimer?.cancel();
|
||||||
_amplitudeTimer?.cancel();
|
_amplitudeTimer?.cancel();
|
||||||
_audioStreamSub?.cancel();
|
_audioStreamSub?.cancel();
|
||||||
|
_browserVoiceMeter.dispose();
|
||||||
_recorder.dispose();
|
_recorder.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
@@ -784,6 +788,22 @@ class _AddExperienceFlowState extends ConsumerState<AddExperienceFlow> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _startRecording() async {
|
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();
|
final hasPermission = await _recorder.hasPermission();
|
||||||
if (!hasPermission) {
|
if (!hasPermission) {
|
||||||
setState(() => _micAllowed = false);
|
setState(() => _micAllowed = false);
|
||||||
@@ -805,19 +825,12 @@ class _AddExperienceFlowState extends ConsumerState<AddExperienceFlow> {
|
|||||||
_lastInformationAt = _lastAudioChunkAt;
|
_lastInformationAt = _lastAudioChunkAt;
|
||||||
_startIdleWave();
|
_startIdleWave();
|
||||||
_startAmplitudePolling();
|
_startAmplitudePolling();
|
||||||
_timer = Timer.periodic(const Duration(seconds: 1), (_) {
|
_startDurationTimer();
|
||||||
if (!mounted) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setState(() => _seconds += 1);
|
|
||||||
ref
|
|
||||||
.read(placeControllerProvider.notifier)
|
|
||||||
.setReviewDuration(Duration(seconds: _seconds));
|
|
||||||
});
|
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_micAllowed = true;
|
_micAllowed = true;
|
||||||
_recording = true;
|
_recording = true;
|
||||||
|
_usingBrowserVoiceMeter = false;
|
||||||
_liveLevel = 0;
|
_liveLevel = 0;
|
||||||
_informationUnits = 0;
|
_informationUnits = 0;
|
||||||
});
|
});
|
||||||
@@ -827,9 +840,13 @@ class _AddExperienceFlowState extends ConsumerState<AddExperienceFlow> {
|
|||||||
_timer?.cancel();
|
_timer?.cancel();
|
||||||
_visualTimer?.cancel();
|
_visualTimer?.cancel();
|
||||||
_amplitudeTimer?.cancel();
|
_amplitudeTimer?.cancel();
|
||||||
|
if (_usingBrowserVoiceMeter) {
|
||||||
|
await _browserVoiceMeter.stop();
|
||||||
|
} else {
|
||||||
await _audioStreamSub?.cancel();
|
await _audioStreamSub?.cancel();
|
||||||
_audioStreamSub = null;
|
_audioStreamSub = null;
|
||||||
await _recorder.stop();
|
await _recorder.stop();
|
||||||
|
}
|
||||||
_lastAudioChunkAt = null;
|
_lastAudioChunkAt = null;
|
||||||
_lastInformationAt = null;
|
_lastInformationAt = null;
|
||||||
if (!mounted) {
|
if (!mounted) {
|
||||||
@@ -837,10 +854,24 @@ class _AddExperienceFlowState extends ConsumerState<AddExperienceFlow> {
|
|||||||
}
|
}
|
||||||
setState(() {
|
setState(() {
|
||||||
_recording = false;
|
_recording = false;
|
||||||
|
_usingBrowserVoiceMeter = false;
|
||||||
_liveLevel = 0;
|
_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() {
|
void _startIdleWave() {
|
||||||
_visualTimer?.cancel();
|
_visualTimer?.cancel();
|
||||||
_visualTimer = Timer.periodic(const Duration(milliseconds: 70), (_) {
|
_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) {
|
double _normalizeDbLevel(double currentDb) {
|
||||||
final db = currentDb.clamp(-160.0, 0.0);
|
final db = currentDb.clamp(-160.0, 0.0);
|
||||||
if (db < _noiseDb) {
|
if (db < _noiseDb) {
|
||||||
|
|||||||
Reference in New Issue
Block a user