DEV Community

Eittipat.K
Eittipat.K

Posted on

Building an Audio Visualizer in Flutter

Creating an engaging audio visualizer enhances the interactivity of any app. This guide will walk you through building a retro arcade-style rainbow visualizer using Flutter and the audio_visualizer package.


Preview

Below is a sneak peek of the final output:

Target Audio Visualizer App


Step 1: Setting Up the Visualizer

We start by creating a RainbowBlockVisualizer widget. This widget will dynamically render audio blocks based on FFT (Fast Fourier Transform) data. Here's the implementation:

RainbowBlockVisualizer Widget

import 'dart:math';
import 'package:flutter/material.dart';

class RainbowBlockVisualizer extends StatelessWidget {
  final List<int> data;
  final int maxSample;
  final double blockHeight;
  final double blockSpacing;

  const RainbowBlockVisualizer({
    super.key,
    this.data = const [],
    this.maxSample = 32,
    this.blockHeight = 8,
    this.blockSpacing = 1,
  });

  @override
  Widget build(BuildContext context) {
    return CustomPaint(
      painter: AudioBlockPainter(
        data: data,
        blockHeight: blockHeight,
        blockSpacing: blockSpacing,
        maxSample: maxSample,
      ),
      size: Size.infinite,
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Custom Painter for Audio Blocks

class AudioBlockPainter extends CustomPainter {
  final List<int> data;
  final double blockHeight;
  final double blockSpacing;
  final int maxSample;

  AudioBlockPainter({
    required this.data,
    required this.blockHeight,
    required this.blockSpacing,
    required this.maxSample,
  });

  int get dataLength => min(maxSample, data.length);

  int getNumBlocks(int audioValue) {
    return (audioValue / 255 * 32).round().clamp(1, 32);
  }

  Color getBlockColor(int barIndex, int blockIndex, int maxBlocks) {
    final hue = (barIndex / dataLength) * 360;
    const lightnessRange = 0.1;
    final lightness = 0.6 +
        (blockIndex / maxBlocks * lightnessRange).clamp(0.0, lightnessRange);
    return HSLColor.fromAHSL(1, hue, 0.8, lightness).toColor();
  }

  @override
  void paint(Canvas canvas, Size size) {
    if (data.isEmpty) return;

    final barWidth = size.width / dataLength;

    for (int i = 0; i < dataLength; i++) {
      final numBlocks = getNumBlocks(data[i]);
      final barX = i * barWidth;

      for (int j = 0; j < numBlocks; j++) {
        final paint = Paint()..color = getBlockColor(i, j, numBlocks);

        final blockY = size.height - ((j + 1) * (blockHeight + blockSpacing));

        final rect = RRect.fromRectAndRadius(
          Rect.fromLTWH(
            barX + blockSpacing / 2,
            blockY,
            barWidth - blockSpacing,
            blockHeight,
          ),
          const Radius.circular(2),
        );
        canvas.drawRRect(rect, paint);

        final highlightPaint = Paint()
          ..color = Colors.white.withOpacity(0.2)
          ..style = PaintingStyle.stroke
          ..strokeWidth = 1;
        canvas.drawLine(
          Offset(rect.left, rect.top),
          Offset(rect.right, rect.top),
          highlightPaint,
        );
        canvas.drawLine(
          Offset(rect.left, rect.top),
          Offset(rect.left, rect.bottom),
          highlightPaint,
        );

        final shadowPaint = Paint()
          ..color = Colors.black.withOpacity(0.2)
          ..style = PaintingStyle.stroke
          ..strokeWidth = 1;
        canvas.drawLine(
          Offset(rect.right, rect.top),
          Offset(rect.right, rect.bottom),
          shadowPaint,
        );
        canvas.drawLine(
          Offset(rect.left, rect.bottom),
          Offset(rect.right, rect.bottom),
          shadowPaint,
        );
      }
    }
  }

  @override
  bool shouldRepaint(AudioBlockPainter oldDelegate) {
    return data != oldDelegate.data ||
        blockHeight != oldDelegate.blockHeight ||
        blockSpacing != oldDelegate.blockSpacing;
  }
}
Enter fullscreen mode Exit fullscreen mode

Step 2: Creating the Main Application

Main Function

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'rainbow_visualizer.dart';

void main() {
  runApp(MaterialApp(
    themeMode: ThemeMode.dark,
    darkTheme: ThemeData.dark(useMaterial3: true),
    home: const MyApp(),
  ));
}
Enter fullscreen mode Exit fullscreen mode

MyApp Widget


class MyApp extends StatefulWidget {
  const MyApp({super.key});

  @override
  State<MyApp> createState() {
    return _MyAppState();
  }
}

class _MyAppState extends State<MyApp> {
  final audioPlayer = VisualizerPlayer();

  final sources = [
    "https://www.soundhelix.com/examples/mp3/SoundHelix-Song-4.mp3",
    "https://files.testfile.org/AUDIO/C/M4A/sample1.m4a",
  ];
  var sourceIndex = 0;

  @override
  void initState() {
    super.initState();
    SystemChrome.setSystemUIOverlayStyle(SystemUiOverlayStyle.dark);
    audioPlayer.initialize();
    audioPlayer.setDataSource(sources[sourceIndex]);
    audioPlayer.addListener(onUpdate);
  }

  void onUpdate() {
    if (audioPlayer.value.status == PlayerStatus.ready) {
      audioPlayer.play(looping: true);
    }
  }

  @override
  void dispose() {
    audioPlayer.removeListener(onUpdate);
    audioPlayer.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Center(
          child: Text('Audio Visualizer'),
        ),
      ),
      body: Column(
        children: [
          const SizedBox(height: 32),
          Padding(
            padding: const EdgeInsets.all(8.0),
            child: ListenableBuilder(
              listenable: audioPlayer,
              builder: (context, child) {
                final duration = audioPlayer.value.duration;
                final position = audioPlayer.value.position;
                final durationText = formatDuration(duration);
                final positionText = formatDuration(position);
                return Text(
                  "$positionText / $durationText",
                  style: Theme.of(context).textTheme.headlineLarge,
                );
              },
            ),
          ),
          Padding(
            padding: const EdgeInsets.all(8.0),
            child: ListenableBuilder(
              listenable: audioPlayer,
              builder: (context, child) {
                return Text(
                  audioPlayer.value.status == PlayerStatus.playing
                      ? "Now Playing"
                      : "",
                  style: Theme.of(context).textTheme.bodyLarge,
                );
              },
            ),
          ),
          Expanded(
            child: ListenableBuilder(
              listenable: audioPlayer,
              builder: (context, child) {
                final data = getMagnitudes(audioPlayer.value.fft);
                return AudioSpectrum(
                  fftMagnitudes: data,
                  bandType: BandType.tenBand,
                  builder: (context, value, child) {
                    return RainbowBlockVisualizer(
                      data: value.levels,
                      maxSample: 32,
                      blockHeight: 14,
                    );
                  },
                );
              },
            ),
          ),
          ListenableBuilder(
            listenable: audioPlayer,
            builder: (context, child) {
              final duration = audioPlayer.value.duration.inMilliseconds;
              final position = audioPlayer.value.position.inMilliseconds;
              return LinearProgressIndicator(
                value: position / max(1, duration),
                minHeight: 8,
              );
            },
          ),
        ],
      ),
      bottomNavigationBar: BottomAppBar(
        child: Row(
          mainAxisAlignment: MainAxisAlignment.spaceEvenly,
          children: [
            IconButton(
              icon: const Icon(Icons.play_arrow),
              onPressed: () {
                audioPlayer.play(looping: true);
              },
            ),
            IconButton(
              icon: const Icon(Icons.pause),
              onPressed: () {
                audioPlayer.pause();
              },
            ),
            IconButton(
              icon: const Icon(Icons.stop),
              onPressed: () {
                audioPlayer.stop();
              },
            ),
            IconButton(
              icon: const Icon(Icons.loop),
              onPressed: () {
                sourceIndex = (sourceIndex + 1) % sources.length;
                audioPlayer.stop();
                audioPlayer.setDataSource(sources[sourceIndex]);
              },
            ),
          ],
        ),
      ),
    );
  }
}

String formatDuration(Duration duration) {
  final minutes = duration.inMinutes;
  final seconds = duration.inSeconds.remainder(60).toString().padLeft(2, '0');
  return "$minutes:$seconds";
}
Enter fullscreen mode Exit fullscreen mode

Final Notes

This app integrates the audio_visualizer package to transform FFT data into a visually appealing audio spectrum. Experiment with different colors, block sizes, and spacing for unique designs!

Top comments (0)