DEV Community

Eittipat.K
Eittipat.K

Posted on

3

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!

Sentry mobile image

Improving mobile performance, from slow screens to app start time

Based on our experience working with thousands of mobile developer teams, we developed a mobile monitoring maturity curve.

Read more

Top comments (0)

Sentry growth stunted Image

If you are wasting time trying to track down the cause of a crash, it’s time for a better solution. Get your crash rates to zero (or close to zero as possible) with less time and effort.

Try Sentry for more visibility into crashes, better workflow tools, and customizable alerts and reporting.

Switch Tools