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