voice_anim_kit 0.2.0 copy "voice_anim_kit: ^0.2.0" to clipboard
voice_anim_kit: ^0.2.0 copied to clipboard

A collection of beautiful, customizable recording & voice animation widgets for Flutter. 9 premium styles — Wave, Bar, Circle, Blob, Line, Particle, Ripple, AI Gaze, and Glow Bar — all driven by a sim [...]

example/lib/main.dart

import 'dart:async';
import 'dart:math' as math;

import 'package:flutter/material.dart';
import 'package:path_provider/path_provider.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:record/record.dart';
import 'package:voice_anim_kit/voice_anim_kit.dart';

void main() {
  runApp(const AnimationGalleryApp());
}

// ─── Theme ─────────────────────────────────────────────────────────────────

class AnimationGalleryApp extends StatelessWidget {
  const AnimationGalleryApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Voice Anim Kit Gallery',
      debugShowCheckedModeBanner: false,
      theme: _buildTheme(),
      home: const GalleryPage(),
    );
  }

  ThemeData _buildTheme() {
    const seedColor = Color(0xFF8CA8CD); // Titanium Blue
    return ThemeData(
      useMaterial3: true,
      colorScheme: ColorScheme.fromSeed(
        seedColor: seedColor,
        brightness: Brightness.dark,
        surface: const Color(0xFF080D14),
        primary: seedColor,
      ),
      scaffoldBackgroundColor: const Color(0xFF080D14),
      cardColor: const Color(0xFF101620),
      appBarTheme: const AppBarTheme(
        backgroundColor: Color(0xFF080D14),
        surfaceTintColor: Colors.transparent,
        elevation: 0,
        centerTitle: false,
        titleTextStyle: TextStyle(
          color: Colors.white,
          fontSize: 18,
          fontWeight: FontWeight.w600,
          letterSpacing: 0.2,
        ),
      ),
      sliderTheme: const SliderThemeData(trackHeight: 3),
      chipTheme: ChipThemeData(
        backgroundColor: const Color(0xFF181F2C),
        side: const BorderSide(color: Color(0xFF263145)),
        shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
        labelStyle: const TextStyle(fontSize: 12),
      ),
      switchTheme: SwitchThemeData(
        trackColor: WidgetStateProperty.resolveWith(
          (s) => s.contains(WidgetState.selected)
              ? seedColor.withValues(alpha: 0.5)
              : const Color(0xFF181F2C),
        ),
        thumbColor: WidgetStateProperty.resolveWith(
          (s) => s.contains(WidgetState.selected) ? seedColor : Colors.white38,
        ),
      ),
    );
  }
}

// ─── Color palette ──────────────────────────────────────────────────────────

const List<_ColorOption> _colorOptions = [
  _ColorOption('White', Colors.white),
  _ColorOption('Titanium', Color(0xFF8CA8CD)),
  _ColorOption('Gold', Color(0xFFFFCB47)),
  _ColorOption('Cyan', Color(0xFF00E5FF)),
  _ColorOption('Green', Color(0xFF4DFF91)),
  _ColorOption('Red', Color(0xFFFF3D3D)),
  _ColorOption('Pink', Color(0xFFFF4D9E)),
  _ColorOption('Sky', Color(0xFF40C4FF)),
];

class _ColorOption {
  final String label;
  final Color color;
  const _ColorOption(this.label, this.color);
}

// ─── Gallery page ────────────────────────────────────────────────────────────

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

  @override
  State<GalleryPage> createState() => _GalleryPageState();
}

class _GalleryPageState extends State<GalleryPage> {
  // ── visualizer state ─────────────────────────────────────────────────────
  bool _isRecording = false;
  double _amplitude = 0.0;
  int _selectedIndex = 0;
  Color _selectedColor = Colors.white;

  // ── mic state ────────────────────────────────────────────────────────────
  final AudioRecorder _recorder = AudioRecorder();
  Timer? _ampTimer;
  bool _micActive = false;
  bool _micPermissionDenied = false;

  // ── manual mode ──────────────────────────────────────────────────────────
  bool _manualMode = false; // false = mic mode, true = manual slider
  double _manualAmplitude = 0.5;

  final List<String> _names = [
    'Wave',
    'Bar',
    'Circle',
    'Blob',
    'Line',
    'Particle',
    'Ripple',
    'AI Gaze',
    'Glow Bar',
  ];

  // ─── lifecycle ─────────────────────────────────────────────────────────

  @override
  void dispose() {
    _stopMic();
    _recorder.dispose();
    super.dispose();
  }

  // ─── mic logic ──────────────────────────────────────────────────────────

  Future<void> _toggleMic() async {
    if (_micActive) {
      await _stopMic();
    } else {
      await _startMic();
    }
  }

  Future<void> _startMic() async {
    // Request permission
    final status = await Permission.microphone.request();
    if (!status.isGranted) {
      if (mounted) setState(() => _micPermissionDenied = true);
      return;
    }
    if (mounted) setState(() => _micPermissionDenied = false);

    // record 包需要一个合法的可写路径才能初始化 MediaMuxer,
    // 即使我们只读取振幅也必须提供真实路径。
    final tmpDir = await getTemporaryDirectory();
    final tmpPath = '${tmpDir.path}/voice_anim_tmp.aac';

    await _recorder.start(
      const RecordConfig(encoder: AudioEncoder.aacLc, sampleRate: 44100),
      path: tmpPath,
    );

    // 每 50 ms 轮询一次振幅 → 约 20 fps 更新
    _ampTimer = Timer.periodic(const Duration(milliseconds: 50), (_) async {
      final amp = await _recorder.getAmplitude();
      if (!mounted) return;
      // amp.current 单位为 dB(−160 … 0),转换为 0–1
      final normalized = _dbToNormalized(amp.current);
      setState(() {
        _amplitude = normalized;
        _isRecording = true;
      });
    });

    if (mounted) setState(() => _micActive = true);
  }

  Future<void> _stopMic() async {
    _ampTimer?.cancel();
    _ampTimer = null;
    await _recorder.stop();
    if (mounted) {
      setState(() {
        _micActive = false;
        _isRecording = false;
        _amplitude = 0.0;
      });
    }
  }

  /// Maps dB value (−160 … 0) to a 0–1 amplitude for the visualizers.
  /// Silence floor at −50 dB; anything louder is boosted to fill the range.
  double _dbToNormalized(double db) {
    const double floor = -50.0;
    if (db <= floor) return 0.0;
    final ratio = (db - floor) / floor.abs(); // 0 … 1
    return math.pow(ratio, 0.6).toDouble().clamp(0.0, 1.0);
  }

  // ─── build ──────────────────────────────────────────────────────────────

  @override
  Widget build(BuildContext context) {
    final effectiveAmplitude = _manualMode ? _manualAmplitude : _amplitude;
    final effectiveRecording = _manualMode ? _isRecording : _micActive;

    return Scaffold(
      appBar: AppBar(
        title: const Text('Voice Anim Kit'),
        actions: [
          // Manual / Mic toggle
          Padding(
            padding: const EdgeInsets.only(right: 12),
            child: _ModeToggle(
              manualMode: _manualMode,
              onToggle: (v) {
                setState(() {
                  _manualMode = v;
                  if (v && _micActive) _stopMic();
                  if (!v) {
                    _isRecording = false;
                    _amplitude = 0.0;
                  }
                });
              },
            ),
          ),
        ],
      ),
      body: Column(
        children: [
          // ── preview ──────────────────────────────────────────────────────
          Expanded(
            child: _PreviewFrame(
              child: _buildVisualizer(effectiveAmplitude, effectiveRecording),
            ),
          ),
          // ── controls ────────────────────────────────────────────────────
          _ControlsPanel(
            // shared
            selectedIndex: _selectedIndex,
            names: _names,
            selectedColor: _selectedColor,
            colorOptions: _colorOptions,
            onStyleSelected: (i) => setState(() => _selectedIndex = i),
            onColorSelected: (c) => setState(() => _selectedColor = c),
            // manual mode
            manualMode: _manualMode,
            isRecording: _isRecording,
            manualAmplitude: _manualAmplitude,
            onRecordingToggled: (v) => setState(() => _isRecording = v),
            onManualAmplitudeChanged: (v) =>
                setState(() => _manualAmplitude = v),
            // mic mode
            micActive: _micActive,
            micPermissionDenied: _micPermissionDenied,
            currentAmplitude: _amplitude,
            onMicToggle: _toggleMic,
          ),
        ],
      ),
    );
  }

  Widget _buildVisualizer(double amplitude, bool recording) {
    switch (_selectedIndex) {
      case 0:
        return WaveVisualizer(
          isRecording: recording,
          amplitude: amplitude,
          color: _selectedColor,
        );
      case 1:
        return BarVisualizer(
          isRecording: recording,
          amplitude: amplitude,
          color: _selectedColor,
        );
      case 2:
        return CircleVisualizer(
          isRecording: recording,
          amplitude: amplitude,
          color: _selectedColor,
        );
      case 3:
        return BlobVisualizer(
          isRecording: recording,
          amplitude: amplitude,
          color: _selectedColor,
        );
      case 4:
        return LineVisualizer(
          isRecording: recording,
          amplitude: amplitude,
          color: _selectedColor,
        );
      case 5:
        return ParticleVisualizer(
          isRecording: recording,
          amplitude: amplitude,
          color: _selectedColor,
        );
      case 6:
        return RippleVisualizer(
          isRecording: recording,
          amplitude: amplitude,
          color: _selectedColor,
        );
      case 7:
        return AIGazeVisualizer(
          isRecording: recording,
          amplitude: amplitude,
          color: _selectedColor,
        );
      case 8:
        return GlowBarVisualizer(
          isRecording: recording,
          amplitude: amplitude,
          color: _selectedColor,
        );
      default:
        return const SizedBox();
    }
  }
}

// ─── Sub-widgets ─────────────────────────────────────────────────────────────

/// Dark frame for the visualizer preview
class _PreviewFrame extends StatelessWidget {
  final Widget child;
  const _PreviewFrame({required this.child});

  @override
  Widget build(BuildContext context) {
    return Container(
      width: double.infinity,
      decoration: const BoxDecoration(color: Color(0xFF04060A)),
      child: Center(child: child),
    );
  }
}

/// AppBar mode toggle chip
class _ModeToggle extends StatelessWidget {
  final bool manualMode;
  final ValueChanged<bool> onToggle;
  const _ModeToggle({required this.manualMode, required this.onToggle});

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: () => onToggle(!manualMode),
      child: AnimatedContainer(
        duration: const Duration(milliseconds: 200),
        padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
        decoration: BoxDecoration(
          color: manualMode
              ? const Color(0xFF181F2C)
              : const Color(0xFF8CA8CD).withValues(alpha: 0.2),
          borderRadius: BorderRadius.circular(20),
          border: Border.all(
            color: manualMode
                ? const Color(0xFF263145)
                : const Color(0xFF8CA8CD).withValues(alpha: 0.7),
          ),
        ),
        child: Row(
          mainAxisSize: MainAxisSize.min,
          children: [
            Icon(
              manualMode ? Icons.tune : Icons.mic,
              size: 14,
              color: manualMode ? Colors.white54 : const Color(0xFF8CA8CD),
            ),
            const SizedBox(width: 6),
            Text(
              manualMode ? 'Manual' : 'Mic',
              style: TextStyle(
                fontSize: 12,
                color: manualMode ? Colors.white54 : const Color(0xFF8CA8CD),
                fontWeight: FontWeight.w600,
              ),
            ),
          ],
        ),
      ),
    );
  }
}

/// Full controls panel (scrollable bottom sheet style)
class _ControlsPanel extends StatelessWidget {
  // shared
  final int selectedIndex;
  final List<String> names;
  final Color selectedColor;
  final List<_ColorOption> colorOptions;
  final ValueChanged<int> onStyleSelected;
  final ValueChanged<Color> onColorSelected;

  // manual
  final bool manualMode;
  final bool isRecording;
  final double manualAmplitude;
  final ValueChanged<bool> onRecordingToggled;
  final ValueChanged<double> onManualAmplitudeChanged;

  // mic
  final bool micActive;
  final bool micPermissionDenied;
  final double currentAmplitude;
  final VoidCallback onMicToggle;

  const _ControlsPanel({
    required this.selectedIndex,
    required this.names,
    required this.selectedColor,
    required this.colorOptions,
    required this.onStyleSelected,
    required this.onColorSelected,
    required this.manualMode,
    required this.isRecording,
    required this.manualAmplitude,
    required this.onRecordingToggled,
    required this.onManualAmplitudeChanged,
    required this.micActive,
    required this.micPermissionDenied,
    required this.currentAmplitude,
    required this.onMicToggle,
  });

  @override
  Widget build(BuildContext context) {
    return Container(
      padding: const EdgeInsets.fromLTRB(20, 20, 20, 32),
      decoration: BoxDecoration(
        color: Theme.of(context).cardColor,
        borderRadius: const BorderRadius.vertical(top: Radius.circular(28)),
        boxShadow: [
          BoxShadow(
            color: Colors.black.withValues(alpha: 0.5),
            blurRadius: 24,
            offset: const Offset(0, -6),
          ),
        ],
      ),
      child: Column(
        mainAxisSize: MainAxisSize.min,
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          // drag handle
          Center(
            child: Container(
              width: 36,
              height: 4,
              margin: const EdgeInsets.only(bottom: 16),
              decoration: BoxDecoration(
                color: Colors.white12,
                borderRadius: BorderRadius.circular(2),
              ),
            ),
          ),

          if (manualMode) ..._buildManualControls(context),
          if (!manualMode) ..._buildMicControls(context),

          const SizedBox(height: 16),
          _buildColorRow(),
          const SizedBox(height: 16),
          _buildStyleChips(),
        ],
      ),
    );
  }

  // ── manual controls ────────────────────────────────────────────────────

  List<Widget> _buildManualControls(BuildContext context) {
    return [
      Row(
        children: [
          const Text(
            'Recording',
            style: TextStyle(fontSize: 13, color: Colors.white70),
          ),
          const Spacer(),
          Switch(value: isRecording, onChanged: onRecordingToggled),
        ],
      ),
      const SizedBox(height: 8),
      Row(
        children: [
          const Text(
            'Amplitude',
            style: TextStyle(fontSize: 13, color: Colors.white70),
          ),
          const SizedBox(width: 8),
          Text(
            manualAmplitude.toStringAsFixed(2),
            style: TextStyle(
              fontSize: 13,
              color: selectedColor,
              fontWeight: FontWeight.w600,
            ),
          ),
        ],
      ),
      SliderTheme(
        data: SliderTheme.of(context).copyWith(
          activeTrackColor: selectedColor,
          thumbColor: selectedColor,
          overlayColor: selectedColor.withValues(alpha: 0.15),
          inactiveTrackColor: Colors.white10,
        ),
        child: Slider(
          value: manualAmplitude,
          onChanged: onManualAmplitudeChanged,
        ),
      ),
    ];
  }

  // ── mic controls ───────────────────────────────────────────────────────

  List<Widget> _buildMicControls(BuildContext context) {
    return [
      Row(
        children: [
          // Big mic button
          _MicButton(active: micActive, onTap: onMicToggle),
          const SizedBox(width: 16),
          Expanded(
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                Text(
                  micActive ? 'Listening…' : 'Tap to start',
                  style: TextStyle(
                    fontSize: 15,
                    fontWeight: FontWeight.w600,
                    color: micActive ? const Color(0xFF8CA8CD) : Colors.white54,
                  ),
                ),
                const SizedBox(height: 4),
                if (micPermissionDenied)
                  const Text(
                    'Microphone permission denied',
                    style: TextStyle(fontSize: 12, color: Color(0xFFFF4D4D)),
                  )
                else
                  Text(
                    micActive
                        ? 'Amplitude: ${(currentAmplitude * 100).toInt()}%'
                        : 'Real-time voice visualization',
                    style: const TextStyle(fontSize: 12, color: Colors.white38),
                  ),
              ],
            ),
          ),
          // Amplitude bar indicator
          if (micActive)
            _AmplitudeBar(amplitude: currentAmplitude, color: selectedColor),
        ],
      ),
    ];
  }

  // ── color row ──────────────────────────────────────────────────────────

  Widget _buildColorRow() {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        const Text(
          'Color',
          style: TextStyle(
            fontSize: 12,
            color: Colors.white38,
            letterSpacing: 0.8,
          ),
        ),
        const SizedBox(height: 10),
        Row(
          children: colorOptions.map((opt) {
            final isSelected = opt.color.toARGB32() == selectedColor.toARGB32();
            return Padding(
              padding: const EdgeInsets.only(right: 10),
              child: GestureDetector(
                onTap: () => onColorSelected(opt.color),
                child: Tooltip(
                  message: opt.label,
                  child: AnimatedContainer(
                    duration: const Duration(milliseconds: 180),
                    width: 28,
                    height: 28,
                    decoration: BoxDecoration(
                      color: opt.color,
                      shape: BoxShape.circle,
                      border: Border.all(
                        color: isSelected ? Colors.white : Colors.white24,
                        width: isSelected ? 2.5 : 1.0,
                      ),
                      boxShadow: isSelected
                          ? [
                              BoxShadow(
                                color: opt.color.withValues(alpha: 0.7),
                                blurRadius: 8,
                                spreadRadius: 1,
                              ),
                            ]
                          : [],
                    ),
                    child: isSelected
                        ? const Icon(
                            Icons.check,
                            size: 14,
                            color: Colors.black87,
                          )
                        : null,
                  ),
                ),
              ),
            );
          }).toList(),
        ),
      ],
    );
  }

  // ── style chips ────────────────────────────────────────────────────────

  Widget _buildStyleChips() {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        const Text(
          'Style',
          style: TextStyle(
            fontSize: 12,
            color: Colors.white38,
            letterSpacing: 0.8,
          ),
        ),
        const SizedBox(height: 10),
        Wrap(
          spacing: 8.0,
          runSpacing: 8.0,
          children: List.generate(names.length, (index) {
            final selected = selectedIndex == index;
            return GestureDetector(
              onTap: () => onStyleSelected(index),
              child: AnimatedContainer(
                duration: const Duration(milliseconds: 160),
                padding: const EdgeInsets.symmetric(
                  horizontal: 14,
                  vertical: 8,
                ),
                decoration: BoxDecoration(
                  color: selected
                      ? selectedColor.withValues(alpha: 0.18)
                      : const Color(0xFF181F2C),
                  borderRadius: BorderRadius.circular(10),
                  border: Border.all(
                    color: selected
                        ? selectedColor.withValues(alpha: 0.8)
                        : const Color(0xFF263145),
                    width: selected ? 1.5 : 1.0,
                  ),
                ),
                child: Text(
                  names[index],
                  style: TextStyle(
                    fontSize: 12,
                    color: selected ? selectedColor : Colors.white38,
                    fontWeight: selected ? FontWeight.w600 : FontWeight.normal,
                  ),
                ),
              ),
            );
          }),
        ),
      ],
    );
  }
}

/// Pulsing microphone button
class _MicButton extends StatelessWidget {
  final bool active;
  final VoidCallback onTap;
  const _MicButton({required this.active, required this.onTap});

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: onTap,
      child: AnimatedContainer(
        duration: const Duration(milliseconds: 250),
        width: 56,
        height: 56,
        decoration: BoxDecoration(
          shape: BoxShape.circle,
          color: active ? const Color(0xFF8CA8CD) : const Color(0xFF181F2C),
          boxShadow: active
              ? [
                  BoxShadow(
                    color: const Color(0xFF8CA8CD).withValues(alpha: 0.45),
                    blurRadius: 20,
                    spreadRadius: 2,
                  ),
                ]
              : [],
        ),
        child: Icon(
          active ? Icons.stop_rounded : Icons.mic,
          color: active ? Colors.white : Colors.white38,
          size: 26,
        ),
      ),
    );
  }
}

/// Vertical amplitude level bar (mic mode indicator)
class _AmplitudeBar extends StatelessWidget {
  final double amplitude;
  final Color color;
  const _AmplitudeBar({required this.amplitude, required this.color});

  @override
  Widget build(BuildContext context) {
    return SizedBox(
      width: 6,
      height: 48,
      child: ClipRRect(
        borderRadius: BorderRadius.circular(3),
        child: Stack(
          alignment: Alignment.bottomCenter,
          children: [
            Container(color: Colors.white10),
            FractionallySizedBox(
              heightFactor: amplitude.clamp(0.0, 1.0),
              child: AnimatedContainer(
                duration: const Duration(milliseconds: 80),
                decoration: BoxDecoration(
                  color: color,
                  borderRadius: BorderRadius.circular(3),
                  boxShadow: [
                    BoxShadow(
                      color: color.withValues(alpha: 0.6),
                      blurRadius: 4,
                    ),
                  ],
                ),
              ),
            ),
          ],
        ),
      ),
    );
  }
}
2
likes
150
points
121
downloads

Publisher

unverified uploader

Weekly Downloads

A collection of beautiful, customizable recording & voice animation widgets for Flutter. 9 premium styles — Wave, Bar, Circle, Blob, Line, Particle, Ripple, AI Gaze, and Glow Bar — all driven by a simple amplitude value.

Repository (GitHub)
View/report issues

Topics

#animation #audio #recording #visualizer #widget

Documentation

API reference

License

MIT (license)

Dependencies

flutter

More

Packages that depend on voice_anim_kit