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> {
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user