写在前面

成品展示

开始录制

录音机

播放器

相关依赖

  • get
  • path_provider
  • uuid
  • record
  • audioplayers
  • permission_handler

录音组件

import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:mood_diary/components/wave_form/wave_form_view.dart';

import 'record_sheet_logic.dart';

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

  @override
  Widget build(BuildContext context) {
    final logic = Get.put(RecordSheetLogic());
    final state = Bind.find<RecordSheetLogic>().state;
    final colorScheme = Theme.of(context).colorScheme;
    return GetBuilder<RecordSheetLogic>(
      init: logic,
      assignId: true,
      builder: (logic) {
        return SafeArea(
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            mainAxisSize: MainAxisSize.min,
            children: [
              Padding(
                padding: const EdgeInsets.all(16.0),
                child: Row(
                  mainAxisAlignment: MainAxisAlignment.center,
                  children: [
                    SizedBox(
                        width: 48.0,
                        child: ClipRRect(
                          borderRadius: BorderRadius.circular(8.0),
                          child: Divider(
                            thickness: 8.0,
                            height: 8.0,
                            color: colorScheme.outline,
                          ),
                        )),
                  ],
                ),
              ),
              Obx(() {
                return AnimatedSwitcher(
                  duration: const Duration(milliseconds: 100),
                  child: state.isStarted.value
                      ? SizedBox(
                          height: 100,
                          key: const ValueKey('wave'),
                          child: Obx(() {
                            return WaveFormComponent(
                              amplitudes: state.amplitudes.value,
                              height: 100,
                            );
                          }),
                        )
                      : SizedBox(
                          height: 100,
                          key: const ValueKey('start'),
                          child: Center(
                              child: Container(
                            decoration: BoxDecoration(
                                border: Border.all(color: colorScheme.outline, width: 4.0), shape: BoxShape.circle),
                            child: IconButton(
                                onPressed: () {
                                  logic.startRecorder();
                                },
                                padding: EdgeInsets.zero,
                                icon: const Icon(
                                  Icons.circle,
                                  size: 48,
                                  color: Colors.redAccent,
                                )),
                          )),
                        ),
                );
              }),
              Obx(() {
                return AnimatedContainer(
                  duration: const Duration(milliseconds: 100),
                  height: state.height.value,
                  child: OverflowBox(
                    minHeight: 0,
                    maxHeight: state.height.value,
                    alignment: Alignment.center,
                    child: state.isStarted.value
                        ? Column(
                            mainAxisAlignment: MainAxisAlignment.spaceEvenly,
                            key: const ValueKey('playButton'),
                            children: [
                              Row(
                                mainAxisAlignment: MainAxisAlignment.center,
                                children: [
                                  Obx(() {
                                    return Text(state.durationTime.value.toString().split('.')[0]);
                                  }),
                                ],
                              ),
                              Row(
                                mainAxisAlignment: MainAxisAlignment.center,
                                children: [
                                  TextButton(
                                      onPressed: () {
                                        logic.cancelRecorder();
                                      },
                                      child: const Text('取消')),
                                  FilledButton(
                                      onPressed: () {
                                        state.isRecording.value ? logic.pauseRecorder() : logic.resumeRecorder();
                                      },
                                      child: AnimatedIcon(
                                        icon: AnimatedIcons.play_pause,
                                        progress: logic.animationController,
                                      )),
                                  TextButton(
                                      onPressed: () {
                                        logic.stopRecorder();
                                      },
                                      child: const Text('保存')),
                                ],
                              )
                            ],
                          )
                        : null,
                  ),
                );
              }),
            ],
          ),
        );
      },
    );
  }
}

import 'package:get/get.dart';

class RecordSheetState {
  late RxList<double> amplitudes;
  late Rx<Duration> durationTime;
  late RxBool isRecording;
  late RxBool isStarted;
  late String fileName;
  late RxDouble height;

  late bool isStop;

  RecordSheetState() {
    amplitudes = <double>[].obs;
    isRecording = false.obs;
    isStarted = false.obs;
    fileName = '';
    height = .0.obs;
    isStop = false;
    durationTime = const Duration().obs;

    ///Initialize variables
  }
}

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

import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:mood_diary/pages/edit/edit_logic.dart';
import 'package:mood_diary/utils/utils.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:record/record.dart';
import 'package:uuid/uuid.dart';

import 'record_sheet_state.dart';

class RecordSheetLogic extends GetxController with GetSingleTickerProviderStateMixin {
  final RecordSheetState state = RecordSheetState();
  final AudioRecorder audioRecorder = AudioRecorder();
  late AnimationController animationController =
      AnimationController(vsync: this, duration: const Duration(milliseconds: 100), lowerBound: 0, upperBound: 1.0);
  final size = MediaQuery.sizeOf(Get.context!);

  final editLogic = Bind.find<EditLogic>();

  late double baseline = .0;

  @override
  void onReady() {
    // TODO: implement onReady
    super.onReady();
    listenAmplitude();
  }

  @override
  void onClose() async {
    // TODO: implement onClose
    if (state.isStop == false) {
      await audioRecorder.cancel();
    }
    audioRecorder.dispose();
    animationController.dispose();
    super.onClose();
  }

  Future<void> startRecorder() async {
    if (await Utils().permissionUtil.checkPermission(Permission.microphone)) {
      await animationController.forward();
      state.isRecording.value = true;
      state.isStarted.value = true;
      state.height.value = 140.0;
      state.fileName = 'audio-${const Uuid().v7()}.m4a';
      //暂时保存在缓存目录中
      await audioRecorder.start(const RecordConfig(encoder: AudioEncoder.aacLc),
          path: Utils().fileUtil.getCachePath(state.fileName));
    }
  }

  void listenAmplitude() {
    final amplitudeStream = audioRecorder.onAmplitudeChanged(const Duration(milliseconds: 100));
    amplitudeStream.listen((amplitude) {
      if (amplitude.current.isFinite && amplitude.current != amplitude.max) {
        maxLengthAdd(amplitude.current);
      }
      timeIncrease();
    });
  }

  double normalizeAmplitude(double amplitude) {
    baseline = min(baseline, amplitude);
    return (amplitude + baseline.abs()) / baseline.abs();
  }

  void maxLengthAdd(value) {
    if (state.amplitudes.length > size.width ~/ 6.0) {
      state.amplitudes.removeAt(0);
    }
    state.amplitudes.add(normalizeAmplitude(value));
  }

  void timeIncrease() {
    state.durationTime.value += const Duration(milliseconds: 100);
  }

  Future<void> stopRecorder() async {
    state.isStop = true;
    await audioRecorder.stop();
    animationController.reset();
    editLogic.setAudioName(state.fileName);
    Get.backLegacy();
  }

  Future<void> pauseRecorder() async {
    state.isRecording.value = false;
    await audioRecorder.pause();
    await animationController.reverse();
  }

  Future<void> resumeRecorder() async {
    await animationController.forward();
    state.isRecording.value = true;
    await audioRecorder.resume();
  }

  Future<void> cancelRecorder() async {
    state.amplitudes.value = <double>[];
    state.durationTime.value = const Duration();
    animationController.reset();
    state.isStarted.value = false;
    state.isRecording.value = false;
    state.height.value = 0;
    await audioRecorder.cancel();
  }
}

频谱组件

import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:mood_diary/components/record_sheet/record_sheet_logic.dart';

class WaveFormPainter extends CustomPainter {
  final double barWidth;
  final double spaceWidth;
  final Color color;
  final List<double> amplitudes;

  final recordLogic = Bind.find<RecordSheetLogic>();

  WaveFormPainter(
    this.amplitudes, {
    required this.barWidth,
    required this.spaceWidth,
    this.color = Colors.white,
  });

  @override
  void paint(Canvas canvas, Size size) {
    final paint = Paint()
      ..color = color
      ..strokeCap = StrokeCap.round
      ..strokeWidth = barWidth
      ..style = PaintingStyle.fill;

    final barHeightFactor = size.height - barWidth;
    for (int i = 0; i < amplitudes.length; i++) {
      final x = i * (barWidth + spaceWidth) + barWidth / 2;
      final y = barHeightFactor * (1 - amplitudes[i]);
      canvas.drawLine(Offset(x, barHeightFactor), Offset(x, y), paint);
    }
  }

  @override
  bool shouldRepaint(covariant CustomPainter oldDelegate) {
    return true;
  }
}

class WaveFormComponent extends StatelessWidget {
  const WaveFormComponent({
    super.key,
    required this.amplitudes,
    this.barWidth = 4.0,
    this.spaceWidth = 2.0,
    required this.height,
  });

  final List<double> amplitudes;
  final double barWidth;
  final double spaceWidth;
  final double height;

  @override
  Widget build(BuildContext context) {
    final colorScheme = Theme.of(context).colorScheme;
    return CustomPaint(
      painter: WaveFormPainter(amplitudes, color: colorScheme.primary, barWidth: barWidth, spaceWidth: spaceWidth),
      size: Size(amplitudes.length * (barWidth + spaceWidth), height),
    );
  }
}

播放组件

import 'package:audioplayers/audioplayers.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';

import 'audio_player_logic.dart';

class AudioPlayerComponent extends StatelessWidget {
  const AudioPlayerComponent({super.key, required this.path, required this.index, this.isEdit = false});

  final String path;
  final String index;

  final bool isEdit;

  @override
  Widget build(BuildContext context) {
    final logic = Get.put(AudioPlayerLogic(), tag: index);
    final state = Bind.find<AudioPlayerLogic>(tag: index).state;

    final colorScheme = Theme.of(context).colorScheme;
    return GetBuilder<AudioPlayerLogic>(
      init: logic,
      tag: index,
      assignId: true,
      initState: (_) async {
        await logic.initAudioPlayer(path);
      },
      builder: (logic) {
        return Container(
          decoration: BoxDecoration(color: colorScheme.secondaryContainer, borderRadius: BorderRadius.circular(8.0)),
          constraints: const BoxConstraints(maxWidth: 400),
          padding: const EdgeInsets.all(8.0),
          child: Row(
            children: [
              IconButton.filled(
                  onPressed: () {
                    logic.audioPlayer.state == PlayerState.playing ? logic.pause() : logic.play(path);
                  },
                  icon: AnimatedIcon(
                    icon: AnimatedIcons.play_pause,
                    progress: logic.animationController,
                  )),
              Expanded(
                child: Column(
                  children: [
                    Obx(() {
                      return Slider(
                        value: state.totalDuration.value != Duration.zero
                            ? ((state.currentDuration.value.inMilliseconds / state.totalDuration.value.inMilliseconds)
                                .clamp(0, 1))
                            : 0,
                        onChangeEnd: (value) {
                          logic.to(value);
                        },
                        onChanged: (double value) {
                          logic.changeValue(value);
                        },
                        inactiveColor: colorScheme.outline,
                      );
                    }),
                    Padding(
                      padding: const EdgeInsets.symmetric(horizontal: 24.0),
                      child: Row(
                        mainAxisAlignment: MainAxisAlignment.spaceBetween,
                        children: [
                          Obx(() {
                            return Text(
                              state.totalDuration.value.toString().split('.')[0].padLeft(8, '0'),
                              style: TextStyle(color: colorScheme.onSecondaryContainer),
                            );
                          }),
                          Obx(() {
                            return Text(
                              state.currentDuration.value.toString().split('.')[0].padLeft(8, '0'),
                              style: TextStyle(color: colorScheme.onSecondaryContainer),
                            );
                          }),
                        ],
                      ),
                    ),
                  ],
                ),
              ),
              if (isEdit) ...[
                IconButton(
                  onPressed: () {
                    logic.editLogic.deleteAudio(int.parse(index));
                  },
                  icon: const Icon(Icons.cancel),
                  style: const ButtonStyle(tapTargetSize: MaterialTapTargetSize.shrinkWrap),
                )
              ]
            ],
          ),
        );
      },
    );
  }
}

import 'package:get/get.dart';

class AudioPlayerState {
  late String audioPath;
  late Rx<Duration> totalDuration;
  late Rx<Duration> currentDuration;
  late RxBool handleChange;

  AudioPlayerState() {
    audioPath = '';
    handleChange = false.obs;
    totalDuration = Duration.zero.obs;
    currentDuration = Duration.zero.obs;

    ///Initialize variables
  }
}

import 'package:audioplayers/audioplayers.dart';
import 'package:flutter/animation.dart';
import 'package:get/get.dart';
import 'package:mood_diary/pages/edit/edit_logic.dart';

import 'audio_player_state.dart';

class AudioPlayerLogic extends GetxController with GetSingleTickerProviderStateMixin {
  final AudioPlayerState state = AudioPlayerState();
  final AudioPlayer audioPlayer = AudioPlayer();
  late final AnimationController animationController =
      AnimationController(vsync: this, duration: const Duration(milliseconds: 100));
  final EditLogic editLogic = Bind.find<EditLogic>();

  @override
  void onInit() {
    // TODO: implement onInit
    audioPlayer.eventStream.listen((event) {
      //读取时间完成
      if (event.eventType == AudioEventType.duration) {
        state.totalDuration.value = event.duration!;
      }
      //播放完成
      if (event.eventType == AudioEventType.complete) {
        animationController.reset();
        state.currentDuration.value = Duration.zero;
        audioPlayer.stop();
      }
    });

    audioPlayer.onPositionChanged.listen((duration) {
      if (state.handleChange.value == false) {
        state.currentDuration.value = duration;
      }
    });

    super.onInit();
  }

  @override
  void onReady() {
    // TODO: implement onReady

    super.onReady();
  }

  @override
  void onClose() {
    // TODO: implement onClose
    animationController.dispose();
    audioPlayer.dispose();
    super.onClose();
  }

  //初始化获取时长信息
  Future<void> initAudioPlayer(String value) async {
    await audioPlayer.setSourceDeviceFile(value);
  }

  Future<void> play(String path) async {
    await animationController.forward();
    await audioPlayer.play(DeviceFileSource(path));
  }

  Future<void> pause() async {
    await animationController.reverse();
    await audioPlayer.pause();
  }

  Future<void> to(value) async {
    await audioPlayer.seek(state.currentDuration.value);
    state.handleChange.value = false;
  }

  Future<void> changeValue(value) async {
    state.handleChange.value = true;
    state.currentDuration.value = Duration(milliseconds: (state.totalDuration.value.inMilliseconds * value).toInt());
  }
}