🐙

【Flutter】flutter_soundを用いた音声録音再生、アップロード機能の実装

2024/08/03に公開

様々な方言を話すおしゃべり猫型ロボット「ミーア」を開発中。

https://mia-cat.com/

前回こちらの記事で、アプリからミーアに話させたい任意のテキストを入力すると、そのフレーズを音声合成してミーアに話させる機能実装を記載した。
https://kazulog.fun/dev/developent-function-test-priority/

今回は、音声録音再生機能を実装したいと思う。

ユーザーがアプリで録音した音声を、再生スケジュール(任意)と共に再生できるようにする。公開非公開をユーザーが設定できる。音声録音だけではなく、任意の音声ファイル(猫の鳴き声や推し活のアイドルの声など)をアップロードできるようにする

アプリ

  • 音声録音パッケージ(flutter_sound)を利用:https://pub.dev/packages/flutter_sound
  • 音声を録音した後、_filePath(ローカスストレージ)に保存。その後、サーバーにアップロード

サーバー

  • 音声データをサーバーに送信する API リクエストを作成(HandleUploadVoice)
  • 音声データを S3 にアップロードして voice_path をレコードに追加。

今回の記事では、アプリ側で音声を録音再生する部分までを記載する。

音声許可をinfo.plistとPodFileに追加

今回、音声録音機能で利用するのは、flutter_soundパッケージ。

https://pub.dev/packages/flutter_sound

flutter_soundパッケージを使って音声録音機能を利用するには、XCodeでマイクへのアクセス許可をリクエストする必要がある。

ios/Runner/info.plitにマイク許可のkeyとvalueを追加

	<key>NSMicrophoneUsageDescription</key>
	<string>音声入力機能を使用するためにマイクへのアクセスが必要です。</string>

これで大丈夫と思い、ビルドしたところ、下記エラーが発生。

マイクへのアクセス許可を試みたが、許可が降りなかったとエラー表示されている。

[ERROR:flutter/runtime/dart_vm_initializer.cc(41)] Unhandled Exception: Instance of 'RecordingPermissionException'
#0      _AddPhraseScreenState._initializeRecorder (package:clocky_app/screens/home/add_phrase_screen.dart:119:7)
<asynchronous suspension>

リサーチしたところ、さらに、PodFileにPERMISSION_MICROPHONE=1を追加する必要があるとのこと。

https://stackoverflow.com/questions/69608443/flutter-ios-can-not-use-microphone-problem-with-permission-handler

ios/Podfile

post_install do |installer|
  installer.pods_project.targets.each do |target|

    # Start of the permission_handler configuration
    target.build_configurations.each do |config|

      #  Preprocessor definitions can be found in: https://github.com/Baseflow/flutter-permission-handler/blob/master/permission_handler_apple/ios/Classes/PermissionHandlerEnums.h
      config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] ||= [
        '$(inherited)',

        ## dart: PermissionGroup.microphone
        'PERMISSION_MICROPHONE=1',
        ]

    end
  end
end

ここまで設定してビルドすると、音声録音画面を表示しようとした際に、マイク許可画面が表示された。

音声録音再生機能の実装

flutter_soundのsimple_recorder.dartのexampleを参考に実装していく。

https://github.com/Canardoux/flutter_sound/blob/master/flutter_sound/example/lib/simple_recorder/simple_recorder.dart

実際の音声録音再生機能がこちら。

録音開始 (_startRecording) と 録音停止 (_stopRecording)

  • 録音を開始すると、指定されたファイル(例: recording.m4a)に音声データを保存する。
  • 録音停止時には、録音を終了し、ファイルのパスをコンソールに出力。

audio_sessionとflutter_sound_platform_interfaceを追加インポート

  • audio_session: 音声の再生や録音の設定を管理するためのパッケージです。例えば、録音中に他の音が鳴らないようにする設定などを行う。
  • flutter_sound_platform_interface: flutter_sound ライブラリのバックエンドで使われるインターフェース。音声の録音・再生の機能をサポート。

AudioSession の設定部分

この部分では、音声セッションの設定を行っている。具体的には

  • AVAudioSessionCategory.playAndRecord: 録音と再生の両方を可能にする設定。
  • allowBluetoothdefaultToSpeaker: Bluetoothヘッドセットやデバイスのスピーカーを利用するためのオプション。Bluetoothヘッドセットやデバイスのスピーカーを使用して音声を録音する場合に対応
  • avAudioSessionMode.spokenAudio: 会話用の音声モードを設定。会話の音声を録音したい場合に対応
  • androidAudioAttributes: Androidデバイスでの音声の属性を設定
import 'dart:io';

import 'package:audio_session/audio_session.dart';
import 'package:flutter/material.dart';
import 'package:flutter_sound/flutter_sound.dart';
import 'package:flutter_sound_platform_interface/flutter_sound_recorder_platform_interface.dart';
import 'package:path_provider/path_provider.dart';
import 'package:permission_handler/permission_handler.dart';

class RecordVoiceScreen extends StatefulWidget {
  
  _RecordVoiceScreenState createState() => _RecordVoiceScreenState();
}

class _RecordVoiceScreenState extends State<RecordVoiceScreen> {
  FlutterSoundRecorder? _recorder;
  FlutterSoundPlayer? _player;
  bool _isRecording = false;
  bool _isPlaying = false;
  bool _recorderInitialized = false;
  bool _playerInitialized = false;
  File? _recordedFile;
  String? _errorText;
  final Codec _codec = Codec.aacMP4;

  
  void initState() {
    super.initState();
    _initializeRecorder();
    _initializePlayer();
  }

  
  void dispose() {
    _recorder?.closeRecorder();
    _player?.closePlayer();
    super.dispose();
  }

  Future<void> _initializeRecorder() async {
    _recorder = FlutterSoundRecorder();

    var status = await Permission.microphone.request();
    if (status != PermissionStatus.granted) {
      setState(() {
        _errorText = "マイクの使用許可がありません。";
      });
      throw RecordingPermissionException("Microphone permission not granted");
    }

    try {
      await _recorder!.openRecorder();
      final session = await AudioSession.instance;
      await session.configure(AudioSessionConfiguration(
        avAudioSessionCategory: AVAudioSessionCategory.playAndRecord,
        avAudioSessionCategoryOptions:
            AVAudioSessionCategoryOptions.allowBluetooth |
                AVAudioSessionCategoryOptions.defaultToSpeaker,
        avAudioSessionMode: AVAudioSessionMode.spokenAudio,
        avAudioSessionRouteSharingPolicy:
            AVAudioSessionRouteSharingPolicy.defaultPolicy,
        avAudioSessionSetActiveOptions: AVAudioSessionSetActiveOptions.none,
        androidAudioAttributes: const AndroidAudioAttributes(
          contentType: AndroidAudioContentType.speech,
          flags: AndroidAudioFlags.none,
          usage: AndroidAudioUsage.voiceCommunication,
        ),
        androidAudioFocusGainType: AndroidAudioFocusGainType.gain,
        androidWillPauseWhenDucked: true,
      ));
      setState(() {
        _recorderInitialized = true;
      });
    } catch (e) {
      setState(() {
        _errorText = "レコーダーの初期化に失敗しました: $e";
      });
    }
  }

  Future<void> _initializePlayer() async {
    _player = FlutterSoundPlayer();
    try {
      await _player!.openPlayer();
      setState(() {
        _playerInitialized = true;
      });
    } catch (e) {
      setState(() {
        _errorText = "プレイヤーの初期化に失敗しました: $e";
      });
    }
  }

  Future<void> _startRecording() async {
    if (!_recorderInitialized) return;

    final directory = await getTemporaryDirectory();
    _recordedFile = File('${directory.path}/recording.m4a');
    await _recorder!.startRecorder(
      toFile: _recordedFile!.path,
      codec: _codec,
      audioSource: AudioSource.microphone,
    );
    setState(() {
      _isRecording = true;
    });
  }

  Future<void> _stopRecording() async {
    if (!_recorderInitialized) return;

    await _recorder!.stopRecorder();
    setState(() {
      _isRecording = false;
    });
    print('Recorded file path: ${_recordedFile!.path}');
  }

  Future<void> _playRecording() async {
    if (!_playerInitialized || _recordedFile == null) return;

    await _player!.startPlayer(
      fromURI: _recordedFile!.path,
      codec: _codec,
      whenFinished: () {
        setState(() {
          _isPlaying = false;
        });
      },
    );
    setState(() {
      _isPlaying = true;
    });
  }

  Future<void> _stopPlaying() async {
    if (!_playerInitialized) return;

    await _player!.stopPlayer();
    setState(() {
      _isPlaying = false;
    });
  }

  void _onComplete() {
    Navigator.of(context).pop(_recordedFile?.path);
  }

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('音声入力'),
      ),
      body: Padding(
        padding: const EdgeInsets.all(16.0),
        child: Column(
          children: [
            Row(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                ElevatedButton(
                  onPressed: _isRecording ? null : _startRecording,
                  child: const Text('録音開始'),
                ),
                const SizedBox(width: 16),
                ElevatedButton(
                  onPressed: _isRecording ? _stopRecording : null,
                  child: const Text('録音停止'),
                ),
              ],
            ),
            const SizedBox(height: 16),
            Row(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                ElevatedButton(
                  onPressed: _isPlaying ? _stopPlaying : _playRecording,
                  child: Text(_isPlaying ? '再生停止' : '再生'),
                ),
              ],
            ),
            const SizedBox(height: 16),
            ElevatedButton(
              onPressed: _recordedFile == null ? null : _onComplete,
              child: const Text('完了'),
            ),
            if (_errorText != null)
              Padding(
                padding: const EdgeInsets.all(8.0),
                child: Text(
                  _errorText!,
                  style: const TextStyle(color: Colors.red),
                ),
              ),
          ],
        ),
      ),
    );
  }
}

続きは、こちらで記載しています。
https://kazulog.fun/others/flutter_sound_upload/

Discussion