Rework voice meter signal visualization
Some checks failed
Build and deploy Flutter Web / build (push) Failing after 2m20s

This commit is contained in:
Ruslan Bakiev
2026-05-13 14:16:18 +07:00
parent 906c23366f
commit 765219cc20

View File

@@ -719,7 +719,7 @@ class AddExperienceFlow extends ConsumerStatefulWidget {
} }
class _AddExperienceFlowState extends ConsumerState<AddExperienceFlow> { class _AddExperienceFlowState extends ConsumerState<AddExperienceFlow> {
static const _minimumInformationUnits = 9.0; static const _minimumInformationUnits = 8.0;
static const _nearbyPlaceRadiusMeters = 200; static const _nearbyPlaceRadiusMeters = 200;
final _api = MapflowApi(); final _api = MapflowApi();
@@ -740,6 +740,9 @@ class _AddExperienceFlowState extends ConsumerState<AddExperienceFlow> {
var _micAllowed = true; var _micAllowed = true;
var _ambientLevel = 0.008; var _ambientLevel = 0.008;
var _voiceCeiling = 0.045; var _voiceCeiling = 0.045;
var _noiseDb = -72.0;
var _voicePeakDb = -34.0;
var _liveLevel = 0.0;
var _visualPhase = 0.0; var _visualPhase = 0.0;
DateTime? _lastAudioChunkAt; DateTime? _lastAudioChunkAt;
DateTime? _lastInformationAt; DateTime? _lastInformationAt;
@@ -815,6 +818,8 @@ class _AddExperienceFlowState extends ConsumerState<AddExperienceFlow> {
setState(() { setState(() {
_micAllowed = true; _micAllowed = true;
_recording = true; _recording = true;
_liveLevel = 0;
_informationUnits = 0;
}); });
} }
@@ -830,7 +835,10 @@ class _AddExperienceFlowState extends ConsumerState<AddExperienceFlow> {
if (!mounted) { if (!mounted) {
return; return;
} }
setState(() => _recording = false); setState(() {
_recording = false;
_liveLevel = 0;
});
} }
void _startIdleWave() { void _startIdleWave() {
@@ -882,22 +890,19 @@ class _AddExperienceFlowState extends ConsumerState<AddExperienceFlow> {
void _handleAmplitude(double currentDb) { void _handleAmplitude(double currentDb) {
final now = DateTime.now(); final now = DateTime.now();
final normalized = ((currentDb + 54) / 54).clamp(0.0, 1.0); final level = _normalizeDbLevel(currentDb);
final voicedAmount = math.pow(normalized, 0.72).toDouble(); final informationDelta = _consumeInformationDelta(level, now);
final informationDelta = _consumeInformationDelta(voicedAmount, now);
_visualPhase += 0.38; _visualPhase += 0.38;
final samples = List<double>.generate(8, (index) { final samples = List<double>.generate(8, (index) {
final wave = math.sin(_visualPhase + index * 0.68) * 0.5 + 0.5; final wave = math.sin(_visualPhase + index * 0.68) * 0.5 + 0.5;
return (0.10 + voicedAmount * 0.68 + wave * voicedAmount * 0.22).clamp( return (0.08 + level * 0.70 + wave * level * 0.24).clamp(0.0, 1.0);
0.0,
1.0,
);
}); });
setState(() { setState(() {
_waveSamples _waveSamples
..removeRange(0, samples.length) ..removeRange(0, samples.length)
..addAll(samples); ..addAll(samples);
_liveLevel = _smoothLevel(_liveLevel, level);
_informationUnits = math.min( _informationUnits = math.min(
_minimumInformationUnits, _minimumInformationUnits,
_informationUnits + informationDelta, _informationUnits + informationDelta,
@@ -905,6 +910,29 @@ class _AddExperienceFlowState extends ConsumerState<AddExperienceFlow> {
}); });
} }
double _normalizeDbLevel(double currentDb) {
final db = currentDb.clamp(-160.0, 0.0);
if (db < _noiseDb) {
_noiseDb = _noiseDb * 0.90 + db * 0.10;
} else {
_noiseDb = _noiseDb * 0.995 + db * 0.005;
}
if (db > _voicePeakDb) {
_voicePeakDb = _voicePeakDb * 0.72 + db * 0.28;
} else {
_voicePeakDb = math.max(_noiseDb + 18, _voicePeakDb * 0.998 + db * 0.002);
}
final range = math.max(18.0, _voicePeakDb - _noiseDb);
final gated = ((db - _noiseDb - 5) / range).clamp(0.0, 1.0);
return math.pow(gated, 0.62).toDouble();
}
double _smoothLevel(double current, double next) {
final weight = next > current ? 0.46 : 0.18;
return current + (next - current) * weight;
}
double _consumeInformationDelta(double voicedAmount, DateTime now) { double _consumeInformationDelta(double voicedAmount, DateTime now) {
final previous = _lastInformationAt ?? now; final previous = _lastInformationAt ?? now;
_lastInformationAt = now; _lastInformationAt = now;
@@ -987,6 +1015,7 @@ class _AddExperienceFlowState extends ConsumerState<AddExperienceFlow> {
return visibleSignal.clamp(0.0, 1.0); return visibleSignal.clamp(0.0, 1.0);
}), }),
); );
_liveLevel = _smoothLevel(_liveLevel, voicedAmount);
_informationUnits = math.min( _informationUnits = math.min(
_minimumInformationUnits, _minimumInformationUnits,
_informationUnits + informationDelta, _informationUnits + informationDelta,
@@ -1009,6 +1038,7 @@ class _AddExperienceFlowState extends ConsumerState<AddExperienceFlow> {
isSubmitting: _submitting, isSubmitting: _submitting,
micAllowed: _micAllowed, micAllowed: _micAllowed,
samples: _waveSamples, samples: _waveSamples,
liveLevel: _liveLevel,
canContinue: widget.hasTelegramAuth && informationProgress >= 1, canContinue: widget.hasTelegramAuth && informationProgress >= 1,
onToggleRecording: _toggleRecording, onToggleRecording: _toggleRecording,
onNext: () async { onNext: () async {
@@ -1207,6 +1237,7 @@ class _VoiceStep extends StatelessWidget {
required this.isSubmitting, required this.isSubmitting,
required this.micAllowed, required this.micAllowed,
required this.samples, required this.samples,
required this.liveLevel,
required this.canContinue, required this.canContinue,
required this.onToggleRecording, required this.onToggleRecording,
required this.onNext, required this.onNext,
@@ -1219,6 +1250,7 @@ class _VoiceStep extends StatelessWidget {
final bool isSubmitting; final bool isSubmitting;
final bool micAllowed; final bool micAllowed;
final List<double> samples; final List<double> samples;
final double liveLevel;
final bool canContinue; final bool canContinue;
final Future<void> Function() onToggleRecording; final Future<void> Function() onToggleRecording;
final VoidCallback onNext; final VoidCallback onNext;
@@ -1247,6 +1279,7 @@ class _VoiceStep extends StatelessWidget {
samples: samples, samples: samples,
active: isRecording, active: isRecording,
progress: informationProgress, progress: informationProgress,
liveLevel: liveLevel,
), ),
), ),
), ),
@@ -1278,6 +1311,7 @@ class _VoiceStep extends StatelessWidget {
), ),
_VoiceRecordButton( _VoiceRecordButton(
progress: informationProgress, progress: informationProgress,
liveLevel: liveLevel,
isRecording: isRecording, isRecording: isRecording,
enabled: hasTelegramAuth && !isSubmitting, enabled: hasTelegramAuth && !isSubmitting,
onPressed: onToggleRecording, onPressed: onToggleRecording,
@@ -1290,12 +1324,14 @@ class _VoiceStep extends StatelessWidget {
class _VoiceRecordButton extends StatefulWidget { class _VoiceRecordButton extends StatefulWidget {
const _VoiceRecordButton({ const _VoiceRecordButton({
required this.progress, required this.progress,
required this.liveLevel,
required this.isRecording, required this.isRecording,
required this.enabled, required this.enabled,
required this.onPressed, required this.onPressed,
}); });
final double progress; final double progress;
final double liveLevel;
final bool isRecording; final bool isRecording;
final bool enabled; final bool enabled;
final Future<void> Function() onPressed; final Future<void> Function() onPressed;
@@ -1349,12 +1385,16 @@ class _VoiceRecordButtonState extends State<_VoiceRecordButton>
animation: _pulseController, animation: _pulseController,
builder: (context, child) { builder: (context, child) {
final pulse = widget.isRecording ? _pulseController.value : 0.0; final pulse = widget.isRecording ? _pulseController.value : 0.0;
final level = widget.isRecording ? widget.liveLevel : 0.0;
return Stack( return Stack(
alignment: Alignment.center, alignment: Alignment.center,
children: [ children: [
for (final offset in const [0.0, 0.32]) for (final offset in const [0.0, 0.32, 0.64])
Transform.scale( Transform.scale(
scale: 1 + ((pulse + offset) % 1) * 0.28, scale:
1 +
((pulse + offset) % 1) * (0.10 + level * 0.32) +
level * 0.10,
child: Container( child: Container(
width: 130, width: 130,
height: 130, height: 130,
@@ -1363,10 +1403,11 @@ class _VoiceRecordButtonState extends State<_VoiceRecordButton>
border: Border.all( border: Border.all(
color: const Color(0xFFFF2D75).withValues( color: const Color(0xFFFF2D75).withValues(
alpha: widget.isRecording alpha: widget.isRecording
? (0.22 * (1 - ((pulse + offset) % 1))) ? ((0.06 + level * 0.26) *
(1 - ((pulse + offset) % 1)))
: 0, : 0,
), ),
width: 3, width: 2.5 + level * 2.5,
), ),
), ),
), ),
@@ -1416,11 +1457,13 @@ class _VoiceInformationField extends StatelessWidget {
required this.samples, required this.samples,
required this.active, required this.active,
required this.progress, required this.progress,
required this.liveLevel,
}); });
final List<double> samples; final List<double> samples;
final bool active; final bool active;
final double progress; final double progress;
final double liveLevel;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -1462,6 +1505,7 @@ class _VoiceInformationField extends StatelessWidget {
samples: samples, samples: samples,
active: active, active: active,
progress: progress, progress: progress,
liveLevel: liveLevel,
), ),
); );
}, },
@@ -1476,32 +1520,42 @@ class _VoiceInformationPainter extends CustomPainter {
required this.samples, required this.samples,
required this.active, required this.active,
required this.progress, required this.progress,
required this.liveLevel,
}); });
final List<double> samples; final List<double> samples;
final bool active; final bool active;
final double progress; final double progress;
final double liveLevel;
@override @override
void paint(Canvas canvas, Size size) { void paint(Canvas canvas, Size size) {
const columns = 16; const columns = 18;
const rows = 10; const rows = 12;
const gap = 7.0; const gap = 6.0;
final cellWidth = (size.width - gap * (columns - 1)) / columns; final cellSize = math
final cellHeight = math.min(24.0, (size.height - gap * (rows - 1)) / rows); .min(
(size.width - gap * (columns - 1)) / columns,
(size.height - gap * (rows - 1)) / rows,
)
.clamp(8.0, 22.0);
final cellWidth = cellSize;
final cellHeight = cellSize;
final gridWidth = columns * cellWidth + (columns - 1) * gap;
final gridHeight = rows * cellHeight + (rows - 1) * gap; final gridHeight = rows * cellHeight + (rows - 1) * gap;
final startX = (size.width - gridWidth) / 2;
final startY = (size.height - gridHeight) / 2; final startY = (size.height - gridHeight) / 2;
final activeCells = (progress * columns * rows).round(); final activeCells = (progress * columns * rows).round();
final backgroundPaint = Paint() final backgroundPaint = Paint()
..color = Colors.white.withValues(alpha: 0.055) ..color = Colors.white.withValues(alpha: 0.105)
..style = PaintingStyle.fill; ..style = PaintingStyle.fill;
final borderPaint = Paint() final borderPaint = Paint()
..color = Colors.white.withValues(alpha: 0.08) ..color = Colors.white.withValues(alpha: 0.045)
..style = PaintingStyle.stroke ..style = PaintingStyle.stroke
..strokeWidth = 1; ..strokeWidth = 1;
final glowPaint = Paint() final glowPaint = Paint()
..color = const Color(0xFFFF2D75).withValues(alpha: active ? 0.22 : 0.10) ..color = const Color(0xFFE11D48).withValues(alpha: active ? 0.18 : 0.08)
..maskFilter = const MaskFilter.blur(BlurStyle.normal, 18); ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 18);
for (var row = 0; row < rows; row++) { for (var row = 0; row < rows; row++) {
@@ -1514,65 +1568,68 @@ class _VoiceInformationPainter extends CustomPainter {
final signal = samples.isEmpty final signal = samples.isEmpty
? 0.0 ? 0.0
: samples[sampleIndex].clamp(0.0, 1.0); : samples[sampleIndex].clamp(0.0, 1.0);
final filled = cellIndex < activeCells; final fillOrder = _cellOrder(cellIndex, columns * rows);
final x = column * (cellWidth + gap); final filled = fillOrder < activeCells;
final x = startX + column * (cellWidth + gap);
final y = startY + row * (cellHeight + gap); final y = startY + row * (cellHeight + gap);
final rect = RRect.fromRectAndRadius( final rect = RRect.fromRectAndRadius(
Rect.fromLTWH(x, y, cellWidth, cellHeight), Rect.fromLTWH(x, y, cellWidth, cellHeight),
const Radius.circular(5), const Radius.circular(4),
); );
canvas.drawRRect(rect, backgroundPaint); canvas.drawRRect(rect, backgroundPaint);
canvas.drawRRect(rect, borderPaint); canvas.drawRRect(rect, borderPaint);
final centerDistance = (row - (rows - 1) / 2).abs(); final centerDistance = (row - (rows - 1) / 2).abs();
final waveReach = 0.55 + signal * rows * 0.52; final columnSignal = (signal * 0.78 + liveLevel * 0.22).clamp(0.0, 1.0);
final inWave = centerDistance <= waveReach; final waveReach = 0.45 + columnSignal * rows * 0.44;
final hashLift = _hashUnit(cellIndex) * 0.55;
final inWave = centerDistance <= waveReach + hashLift;
if (inWave) { if (inWave) {
final waveAlpha = active final waveAlpha = active
? 0.14 + signal * 0.26 ? 0.10 + columnSignal * 0.18
: 0.07 + signal * 0.08; : 0.08 + columnSignal * 0.06;
final wavePaint = Paint() final wavePaint = Paint()
..shader = LinearGradient( ..color = Colors.white.withValues(alpha: waveAlpha)
colors: [
const Color(0xFF38F5D3).withValues(alpha: waveAlpha),
const Color(0xFFFF2D75).withValues(alpha: waveAlpha * 0.92),
],
).createShader(rect.outerRect)
..style = PaintingStyle.fill; ..style = PaintingStyle.fill;
canvas.drawRRect(rect, wavePaint); canvas.drawRRect(rect, wavePaint);
} }
if (filled) { if (filled) {
final warmth = (0.38 + signal * 0.62).clamp(0.0, 1.0);
final paint = Paint() final paint = Paint()
..shader = LinearGradient( ..shader = LinearGradient(
colors: [ colors: [
Color.lerp( const Color(
const Color(0xFF38F5D3), 0xFFE11D48,
const Color(0xFFFF2D75), ).withValues(alpha: 0.78 + columnSignal * 0.18),
warmth, const Color(
)!.withValues(alpha: 0.72 + signal * 0.24), 0xFFFF6B8A,
Color.lerp( ).withValues(alpha: 0.58 + columnSignal * 0.18),
const Color(0xFF7C5CFF),
const Color(0xFFFFE4EF),
warmth,
)!.withValues(alpha: 0.42 + signal * 0.22),
], ],
).createShader(rect.outerRect) ).createShader(rect.outerRect)
..style = PaintingStyle.fill; ..style = PaintingStyle.fill;
canvas.drawRRect(rect.inflate(signal * 2.2), glowPaint); canvas.drawRRect(rect.inflate(1.2 + columnSignal * 1.8), glowPaint);
canvas.drawRRect(rect, paint); canvas.drawRRect(rect, paint);
} }
} }
} }
} }
int _cellOrder(int index, int total) {
return (((index + 11) * 73) % total).toInt();
}
double _hashUnit(int index) {
final value = math.sin(index * 12.9898 + 78.233) * 43758.5453;
return value - value.floorToDouble();
}
@override @override
bool shouldRepaint(covariant _VoiceInformationPainter oldDelegate) { bool shouldRepaint(covariant _VoiceInformationPainter oldDelegate) {
return oldDelegate.samples != samples || return oldDelegate.samples != samples ||
oldDelegate.active != active || oldDelegate.active != active ||
oldDelegate.progress != progress; oldDelegate.progress != progress ||
oldDelegate.liveLevel != liveLevel;
} }
} }