Fix adaptive voice information meter
All checks were successful
Build and deploy Flutter Web / build (push) Successful in 1m41s
All checks were successful
Build and deploy Flutter Web / build (push) Successful in 1m41s
This commit is contained in:
@@ -719,15 +719,17 @@ class AddExperienceFlow extends ConsumerStatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _AddExperienceFlowState extends ConsumerState<AddExperienceFlow> {
|
class _AddExperienceFlowState extends ConsumerState<AddExperienceFlow> {
|
||||||
static const _minimumInformationUnits = 18.0;
|
static const _minimumInformationUnits = 9.0;
|
||||||
static const _nearbyPlaceRadiusMeters = 200;
|
static const _nearbyPlaceRadiusMeters = 200;
|
||||||
|
|
||||||
final _api = MapflowApi();
|
final _api = MapflowApi();
|
||||||
final _recorder = AudioRecorder();
|
final _recorder = AudioRecorder();
|
||||||
final _waveSamples = List<double>.filled(160, 0.0);
|
final _waveSamples = List<double>.filled(160, 0.0);
|
||||||
|
final _visualRandom = math.Random();
|
||||||
|
|
||||||
Future<List<PlaceRecommendation>>? _nearbyPlacesFuture;
|
Future<List<PlaceRecommendation>>? _nearbyPlacesFuture;
|
||||||
Timer? _timer;
|
Timer? _timer;
|
||||||
|
Timer? _visualTimer;
|
||||||
StreamSubscription<Uint8List>? _audioStreamSub;
|
StreamSubscription<Uint8List>? _audioStreamSub;
|
||||||
var _step = 0;
|
var _step = 0;
|
||||||
var _seconds = 0;
|
var _seconds = 0;
|
||||||
@@ -735,6 +737,9 @@ class _AddExperienceFlowState extends ConsumerState<AddExperienceFlow> {
|
|||||||
var _recording = false;
|
var _recording = false;
|
||||||
var _submitting = false;
|
var _submitting = false;
|
||||||
var _micAllowed = true;
|
var _micAllowed = true;
|
||||||
|
var _ambientLevel = 0.008;
|
||||||
|
var _voiceCeiling = 0.045;
|
||||||
|
var _visualPhase = 0.0;
|
||||||
DateTime? _lastAudioChunkAt;
|
DateTime? _lastAudioChunkAt;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -745,6 +750,7 @@ class _AddExperienceFlowState extends ConsumerState<AddExperienceFlow> {
|
|||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_timer?.cancel();
|
_timer?.cancel();
|
||||||
|
_visualTimer?.cancel();
|
||||||
_audioStreamSub?.cancel();
|
_audioStreamSub?.cancel();
|
||||||
_recorder.dispose();
|
_recorder.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
@@ -790,6 +796,7 @@ class _AddExperienceFlowState extends ConsumerState<AddExperienceFlow> {
|
|||||||
);
|
);
|
||||||
_audioStreamSub = stream.listen(_handleAudioChunk);
|
_audioStreamSub = stream.listen(_handleAudioChunk);
|
||||||
_lastAudioChunkAt = DateTime.now();
|
_lastAudioChunkAt = DateTime.now();
|
||||||
|
_startIdleWave();
|
||||||
_timer = Timer.periodic(const Duration(seconds: 1), (_) {
|
_timer = Timer.periodic(const Duration(seconds: 1), (_) {
|
||||||
if (!mounted) {
|
if (!mounted) {
|
||||||
return;
|
return;
|
||||||
@@ -808,6 +815,7 @@ class _AddExperienceFlowState extends ConsumerState<AddExperienceFlow> {
|
|||||||
|
|
||||||
Future<void> _stopRecording() async {
|
Future<void> _stopRecording() async {
|
||||||
_timer?.cancel();
|
_timer?.cancel();
|
||||||
|
_visualTimer?.cancel();
|
||||||
await _audioStreamSub?.cancel();
|
await _audioStreamSub?.cancel();
|
||||||
_audioStreamSub = null;
|
_audioStreamSub = null;
|
||||||
await _recorder.stop();
|
await _recorder.stop();
|
||||||
@@ -818,6 +826,35 @@ class _AddExperienceFlowState extends ConsumerState<AddExperienceFlow> {
|
|||||||
setState(() => _recording = false);
|
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) {
|
void _handleAudioChunk(Uint8List chunk) {
|
||||||
if (chunk.length < 2) {
|
if (chunk.length < 2) {
|
||||||
return;
|
return;
|
||||||
@@ -825,22 +862,32 @@ class _AddExperienceFlowState extends ConsumerState<AddExperienceFlow> {
|
|||||||
|
|
||||||
final bytes = ByteData.sublistView(chunk);
|
final bytes = ByteData.sublistView(chunk);
|
||||||
final sampleCount = chunk.length ~/ 2;
|
final sampleCount = chunk.length ~/ 2;
|
||||||
final bucketCount = 10.clamp(1, sampleCount);
|
final bucketCount = 12.clamp(1, sampleCount);
|
||||||
final bucketSize = (sampleCount / bucketCount).ceil();
|
final bucketSize = (sampleCount / bucketCount).ceil();
|
||||||
final peaks = <double>[];
|
final peaks = <double>[];
|
||||||
|
var totalRms = 0.0;
|
||||||
for (var bucket = 0; bucket < bucketCount; bucket++) {
|
for (var bucket = 0; bucket < bucketCount; bucket++) {
|
||||||
final startSample = bucket * bucketSize;
|
final startSample = bucket * bucketSize;
|
||||||
final endSample = math.min(startSample + bucketSize, sampleCount);
|
final endSample = math.min(startSample + bucketSize, sampleCount);
|
||||||
var peak = 0.0;
|
var peak = 0.0;
|
||||||
|
var sumSquares = 0.0;
|
||||||
|
var bucketSamples = 0;
|
||||||
for (
|
for (
|
||||||
var sampleIndex = startSample;
|
var sampleIndex = startSample;
|
||||||
sampleIndex < endSample;
|
sampleIndex < endSample;
|
||||||
sampleIndex++
|
sampleIndex++
|
||||||
) {
|
) {
|
||||||
final sample = bytes.getInt16(sampleIndex * 2, Endian.little) / 32768.0;
|
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) {
|
if (peaks.isEmpty) {
|
||||||
return;
|
return;
|
||||||
@@ -851,15 +898,21 @@ class _AddExperienceFlowState extends ConsumerState<AddExperienceFlow> {
|
|||||||
_lastAudioChunkAt = now;
|
_lastAudioChunkAt = now;
|
||||||
final deltaSeconds =
|
final deltaSeconds =
|
||||||
now.difference(previousChunkAt).inMilliseconds.clamp(20, 300) / 1000;
|
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 =
|
final voicedAmount =
|
||||||
peaks
|
normalizedPeaks.fold<double>(0, (sum, value) => sum + value) /
|
||||||
.map(
|
normalizedPeaks.length;
|
||||||
(peak) =>
|
|
||||||
((peak - noiseFloor) / (1 - noiseFloor)).clamp(0.0, 1.0),
|
|
||||||
)
|
|
||||||
.fold<double>(0, (sum, value) => sum + value) /
|
|
||||||
peaks.length;
|
|
||||||
final informationDelta = voicedAmount * deltaSeconds;
|
final informationDelta = voicedAmount * deltaSeconds;
|
||||||
|
|
||||||
if (!mounted) {
|
if (!mounted) {
|
||||||
@@ -867,8 +920,13 @@ class _AddExperienceFlowState extends ConsumerState<AddExperienceFlow> {
|
|||||||
}
|
}
|
||||||
setState(() {
|
setState(() {
|
||||||
_waveSamples
|
_waveSamples
|
||||||
..removeRange(0, peaks.length)
|
..removeRange(0, normalizedPeaks.length)
|
||||||
..addAll(peaks);
|
..addAll(
|
||||||
|
normalizedPeaks.map((peak) {
|
||||||
|
final visibleSignal = 0.12 + peak * 0.88;
|
||||||
|
return visibleSignal.clamp(0.0, 1.0);
|
||||||
|
}),
|
||||||
|
);
|
||||||
_informationUnits = math.min(
|
_informationUnits = math.min(
|
||||||
_minimumInformationUnits,
|
_minimumInformationUnits,
|
||||||
_informationUnits + informationDelta,
|
_informationUnits + informationDelta,
|
||||||
|
|||||||
Reference in New Issue
Block a user