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

@@ -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);
});
}
}