spectrum_seekbar 1.0.2
spectrum_seekbar: ^1.0.2 copied to clipboard
A customizable spectrum waveform seekbar for Flutter.
import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter_soloud/flutter_soloud.dart';
import 'package:spectrum_seekbar/spectrum_seekbar.dart';
import 'package:spectrum_seekbar_example/waveform_data.dart';
void main() {
runApp(const SpectrumSeekbarExampleApp());
}
class SpectrumSeekbarExampleApp extends StatelessWidget {
const SpectrumSeekbarExampleApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Spectrum Seekbar Example',
debugShowCheckedModeBanner: false,
theme: ThemeData.light(useMaterial3: true),
home: const DemoPage(),
);
}
}
class DemoPage extends StatefulWidget {
const DemoPage({super.key});
@override
State<DemoPage> createState() => _DemoPageState();
}
class _DemoPageState extends State<DemoPage>
with SingleTickerProviderStateMixin {
static const _audioSource = 'https://samplelib.com/mp3/sample-speech-5m.mp3';
final SoLoud _soloud = SoLoud.instance;
final StreamController<List<double>> _spectrumController =
StreamController<List<double>>.broadcast();
final _spectrumAnalyzer = _RealtimeSpectrumAnalyzer(bandCount: 96);
late final Ticker _ticker;
late final AudioData _audioData;
AudioSource? _source;
SoundHandle? _handle;
Duration _position = Duration.zero;
Duration _duration = Duration.zero;
Object? _loadError;
bool _ready = false;
bool _isPlaying = false;
Duration _lastUiUpdate = Duration.zero;
@override
void initState() {
super.initState();
_audioData = AudioData(GetSamplesKind.linear);
_ticker = createTicker(_tick);
unawaited(_initialize());
}
Future<void> _initialize() async {
try {
if (!_soloud.isInitialized) {
await _soloud.init(
sampleRate: 44100,
bufferSize: 1024,
channels: Channels.stereo,
lowLatency: true,
);
if (!mounted) return;
}
_soloud.setVisualizationEnabled(true);
_soloud.setFftSmoothing(0.0);
final source = await _soloud.loadUrl(_audioSource);
if (!mounted) return;
final duration = _soloud.getLength(source);
final handle = _soloud.play(source, paused: true);
_source = source;
_duration = duration;
_handle = handle;
_ready = true;
_spectrumAnalyzer.reset();
_ticker.start();
// The engine is paused initially; the first meaningful live frame is
// emitted after playback starts or after a seek.
if (mounted) setState(() {});
} catch (error) {
_loadError = error;
if (mounted) setState(() {});
}
}
void _tick(Duration elapsed) {
if (!_ready || !mounted) return;
final handle = _handle;
if (handle == null) return;
try {
if (!_soloud.getIsValidVoiceHandle(handle)) {
if (_isPlaying) {
_isPlaying = false;
_position = _duration;
_emitSilentFrame();
_publishUi(elapsed, force: true);
}
return;
}
final paused = _soloud.getPause(handle);
_position = _soloud.getPosition(handle);
final wasPlaying = _isPlaying;
_isPlaying = !paused;
if (!paused) {
_emitCurrentFrame();
}
_publishUi(elapsed, force: wasPlaying != _isPlaying);
} catch (error) {
_loadError ??= error;
_publishUi(elapsed, force: true);
}
}
void _publishUi(Duration elapsed, {bool force = false}) {
if (!mounted) return;
if (force || elapsed - _lastUiUpdate >= const Duration(milliseconds: 50)) {
_lastUiUpdate = elapsed;
setState(() {});
}
}
void _emitCurrentFrame() {
final handle = _handle;
if (handle == null || !_soloud.getIsValidVoiceHandle(handle)) return;
_audioData.updateSamples();
final data = _audioData.getAudioData();
final spectrum = _spectrumAnalyzer.process(data);
_spectrumController.add(spectrum);
}
void _emitSilentFrame() {
_spectrumAnalyzer.reset();
_spectrumController.add(
List<double>.filled(_spectrumAnalyzer.bandCount, 0.0, growable: false),
);
}
Future<void> _togglePlayback() async {
var handle = _handle;
if (handle == null || !_soloud.getIsValidVoiceHandle(handle)) {
final source = _source;
if (source == null) return;
handle = _soloud.play(source, paused: true);
_handle = handle;
_position = Duration.zero;
_spectrumAnalyzer.reset();
}
final activeHandle = handle;
if (_soloud.getPause(activeHandle)) {
_soloud.setPause(activeHandle, false);
_emitCurrentFrame();
} else {
_soloud.setPause(activeHandle, true);
}
if (mounted) setState(() {});
}
void _seek(double value) {
final source = _source;
if (source == null || _duration <= Duration.zero) return;
var handle = _handle;
if (handle == null || !_soloud.getIsValidVoiceHandle(handle)) {
handle = _soloud.play(source, paused: true);
_handle = handle;
}
final activeHandle = handle;
final position = Duration(
microseconds: (_duration.inMicroseconds * value).round(),
);
_soloud.seek(activeHandle, position);
_position = position;
_spectrumAnalyzer.reset();
// If currently playing, the subsequent live FFT frames originate from the
// new playback position. While paused, request the current engine buffer
// once so an old frame is not left displayed after the seek.
_emitCurrentFrame();
if (mounted) setState(() {});
}
@override
void dispose() {
_ticker.dispose();
_audioData.dispose();
_spectrumController.close();
if (_soloud.isInitialized) {
_soloud.deinit();
}
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final progress = _duration.inMicroseconds > 0
? (_position.inMicroseconds / _duration.inMicroseconds)
.clamp(0.0, 1.0)
.toDouble()
: 0.0;
return Scaffold(
appBar: AppBar(title: const Text('Spectrum Seekbar')),
body: ListView(
padding: const EdgeInsets.all(24),
children: [
Text('Live Spectrum', style: theme.textTheme.labelLarge),
const SizedBox(height: 8),
Container(
height: 50,
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0),
borderRadius: BorderRadius.circular(18),
),
child: SpectrumSeekBar(
mode: SpectrumSeekBarMode.liveSpectrum,
spectrumStream: _spectrumController.stream,
progress: progress,
position: _position,
duration: _duration,
isPlaying: _isPlaying,
smoothing: const SpectrumSmoothing(attack: 0.7, release: 0.25),
animationDuration: const Duration(milliseconds: 16),
style: SpectrumSeekBarStyle(
playedColor: Color(0xFF8765FF),
unplayedColor: Color(0xFFC2c2c2),
barWidth: 3.0,
barSpacing: 2.0,
minBarHeight: 6,
mirror: true,
barRadius: 999.0,
progressIndicator: ProgressIndicatorStyle.none,
),
onChangeEnd: _seek,
),
),
const SizedBox(height: 24),
Text(
'Live Spectrum (with handle)',
style: theme.textTheme.labelLarge,
),
const SizedBox(height: 8),
Container(
height: 50,
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0),
borderRadius: BorderRadius.circular(18),
),
child: SpectrumSeekBar(
mode: SpectrumSeekBarMode.liveSpectrum,
spectrumStream: _spectrumController.stream,
progress: progress,
position: _position,
duration: _duration,
isPlaying: _isPlaying,
smoothing: const SpectrumSmoothing(attack: 0.7, release: 0.25),
animationDuration: const Duration(milliseconds: 16),
style: SpectrumSeekBarStyle(
playedColor: Color(0xFF8765FF),
unplayedColor: Color(0xFFC2c2c2),
barWidth: 3.0,
barSpacing: 2.0,
minBarHeight: 6,
mirror: true,
barRadius: 999.0,
progressIndicator: ProgressIndicatorStyle.lineAndDot,
progressIndicatorColor: Color(0xFF8765FF),
progressIndicatorWidth: 2,
),
onChangeEnd: _seek,
),
),
const SizedBox(height: 24),
Text(
'Live Spectrum (flat bottom)',
style: theme.textTheme.labelLarge,
),
const SizedBox(height: 8),
Container(
height: 50,
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0),
borderRadius: BorderRadius.circular(18),
),
child: SpectrumSeekBar(
mode: SpectrumSeekBarMode.liveSpectrum,
spectrumStream: _spectrumController.stream,
progress: progress,
position: _position,
duration: _duration,
isPlaying: _isPlaying,
smoothing: const SpectrumSmoothing(attack: 0.7, release: 0.25),
animationDuration: const Duration(milliseconds: 16),
style: SpectrumSeekBarStyle(
playedColor: Color(0xFF8765FF),
unplayedColor: Color(0xFFC2c2c2),
barWidth: 3.0,
barSpacing: 2.0,
minBarHeight: 6,
mirror: false,
barRadius: 999.0,
progressIndicator: ProgressIndicatorStyle.none,
),
onChangeEnd: _seek,
),
),
const SizedBox(height: 24),
Text(
'Live Spectrum (center line)',
style: theme.textTheme.labelLarge,
),
const SizedBox(height: 8),
Container(
height: 50,
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0),
borderRadius: BorderRadius.circular(18),
),
child: SpectrumSeekBar(
mode: SpectrumSeekBarMode.liveSpectrum,
spectrumStream: _spectrumController.stream,
progress: progress,
position: _position,
duration: _duration,
isPlaying: _isPlaying,
smoothing: const SpectrumSmoothing(attack: 0.7, release: 0.25),
animationDuration: const Duration(milliseconds: 16),
style: SpectrumSeekBarStyle(
playedColor: Color(0xFF8765FF),
unplayedColor: Color(0xFFC2c2c2),
barWidth: 3.0,
barSpacing: 2.0,
minBarHeight: 6,
mirror: true,
barRadius: 999.0,
centerLine: true,
progressIndicator: ProgressIndicatorStyle.none,
),
onChangeEnd: _seek,
),
),
const SizedBox(height: 24),
Text('Waveform', style: theme.textTheme.labelLarge),
const SizedBox(height: 8),
Container(
height: 50,
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0),
borderRadius: BorderRadius.circular(18),
),
child: SpectrumSeekBar(
mode: SpectrumSeekBarMode.waveform,
values: waveform_data,
progress: progress,
position: _position,
duration: _duration,
isPlaying: _isPlaying,
smoothing: const SpectrumSmoothing(attack: 0.7, release: 0.25),
animationDuration: const Duration(milliseconds: 16),
style: SpectrumSeekBarStyle(
playedColor: Color(0xFF8765FF),
unplayedColor: Color(0xFFC2c2c2),
barWidth: 3.0,
barSpacing: 2.0,
minBarHeight: 6,
mirror: true,
barRadius: 999.0,
progressIndicator: ProgressIndicatorStyle.lineAndDot,
progressIndicatorColor: Color(0xFF8765FF),
progressIndicatorWidth: 2,
),
onChangeEnd: _seek,
),
),
const SizedBox(height: 20),
Center(
child: IconButton.filled(
iconSize: 32,
onPressed: _ready ? _togglePlayback : null,
icon: Icon(_isPlaying ? Icons.pause : Icons.play_arrow),
),
),
],
),
);
}
String _format(Duration value) {
final seconds = value.inSeconds < 0 ? 0 : value.inSeconds;
return '${seconds ~/ 60}:${(seconds % 60).toString().padLeft(2, '0')}';
}
}
/// Turns the raw FFT returned by SoLoud into display bands.
///
/// The output is always derived from the current audio-data buffer. The
/// normalization only changes visual dynamic range; it does not invent time
/// values or wave shapes.
class _RealtimeSpectrumAnalyzer {
_RealtimeSpectrumAnalyzer({required this.bandCount});
final int bandCount;
double _floor = 0.005;
double _ceiling = 0.05;
void reset() {
_floor = 0.005;
_ceiling = 0.05;
}
List<double> process(List<double> data) {
if (bandCount <= 0 || data.length < 3) {
return const <double>[];
}
final fftLength = math.min(256, data.length);
final maxBin = fftLength - 1;
if (maxBin <= 2) {
return List<double>.filled(bandCount, 0.0, growable: false);
}
final raw = List<double>.filled(bandCount, 0.0, growable: false);
for (var band = 0; band < bandCount; band++) {
final startT = band / bandCount;
final endT = (band + 1) / bandCount;
final startBin = math.max(
1,
1 + (math.pow(startT, 2.15) * (maxBin - 1)).floor(),
);
final endBin = math.min(
maxBin + 1,
math.max(
startBin + 1,
1 + (math.pow(endT, 2.15) * (maxBin - 1)).ceil(),
),
);
var energy = 0.0;
var count = 0;
for (var bin = startBin; bin < endBin; bin++) {
final magnitude = data[bin].abs();
energy += magnitude * magnitude;
count++;
}
raw[band] = count == 0 ? 0.0 : math.sqrt(energy / count);
}
final sorted = List<double>.from(raw)..sort();
final floorIndex = (sorted.length * 0.18)
.floor()
.clamp(0, sorted.length - 1)
.toInt();
final peakIndex = (sorted.length * 0.96)
.floor()
.clamp(0, sorted.length - 1)
.toInt();
final floorTarget = sorted[floorIndex];
final peakTarget = sorted[peakIndex];
final floorBlend = floorTarget > _floor ? 0.18 : 0.08;
final ceilingBlend = peakTarget > _ceiling ? 0.32 : 0.06;
_floor += (floorTarget - _floor) * floorBlend;
_ceiling += (peakTarget - _ceiling) * ceilingBlend;
if (_ceiling <= _floor + 1e-9) {
_ceiling = _floor + 1e-9;
}
return List<double>.generate(bandCount, (index) {
final normalized = ((raw[index] - _floor) / (_ceiling - _floor))
.clamp(0.0, 1.0)
.toDouble();
return math.pow(normalized, 0.62).toDouble();
}, growable: false);
}
}