Rework voice meter signal visualization
Some checks failed
Build and deploy Flutter Web / build (push) Failing after 2m20s
Some checks failed
Build and deploy Flutter Web / build (push) Failing after 2m20s
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user