spectrum_seekbar 1.0.2 copy "spectrum_seekbar: ^1.0.2" to clipboard
spectrum_seekbar: ^1.0.2 copied to clipboard

A customizable spectrum waveform seekbar for Flutter.

example/lib/main.dart

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);
  }
}
2
likes
150
points
326
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

A customizable spectrum waveform seekbar for Flutter.

Repository (GitHub)
View/report issues

Topics

#audio #music #seekbar #waveform #spectrum

License

MIT (license)

Dependencies

flutter

More

Packages that depend on spectrum_seekbar