👋

SwiftUI+TCAで作るイマドキ!?な音声アプリを作った話

2022/11/01に公開

OverView

2022年9月にiOSDCで「音声配信アプリにおけるiOSを使った音声配信の全てと裏側」というタイトルでお話しさせていただきました。セッション内では現状のその際に今新しい技術でサンプルアプリを作ったらどうなるんだろうと思い作成したアプリについて解説したいと思います

https://speakerdeck.com/entaku/yin-sheng-pei-xin-ahuriniokeruioswoshi-tutayin-sheng-pei-xin-noquan-tetoli-ce-bc19d655-874e-4123-80af-69ac70a0712a

対象読者

  • TCAやSwiftUIが何となくわかっているが実際に書く場合どうするんだろうと思ってる人
  • 録音アプリを一度触ったりしてみたことがある人

サンプルアプリの概要

まずはサンプルアプリの全体像を見ていきましょう
今回は簡単な音声収録と再生の仕組みを利用し音声メモできるアプリを作りました
1つ目
2つ目
3つ目

今回はこの中でもメインの音声収録機能でSwiftUI+TCAでどのように実装するのかをみていきましょう。

今回説明するGitHubはこちら: https://github.com/entaku0818/VoiceMemo

音声収録は 基本的にrecordingMemoReducer と AudioRecorderClientで実現しています。特にReducerは長く読み応えがありますが分割していくと大きく難しいことはありません順番に見ていきましょう。

AudioRecorderからの処理の検知するReducer

iOSで音声処理をしたことある方はご存じだと思いますがAVFAudioの処理をする場合は delegate処理 がつきものです。ここではdelegate処理を受け取って、recordingMemoReducerが持っている task を呼び出しています。ここでは何が起こっているのか詳しくはわかりませんが、delegate処理が検知されたときにtaskが動くのだなと言う部分を覚えてください。

  case .audioRecorderDidFinish(.success(true)):
    return .task { [state] in .delegate(.didFinish(.success(state))) }

  case .audioRecorderDidFinish(.success(false)):
    return .task { .delegate(.didFinish(.failure(RecordingMemoFailed()))) }

  case let .audioRecorderDidFinish(.failure(error)):
    return .task { .delegate(.didFinish(.failure(error))) }

  case .delegate:
    return .none

録音中の状態の管理

recordingMemoReducerでは基本的にtaskで状態の更新処理を定義しています。
まずstartRecordingでは AudioRecorderClient つまり録音処理本体の処理の呼び出しstartRecording をおこなっています。
startRecordingを実施した後一秒間隔でtimerが回っていますここでReducerへ定義されているActionを呼び出します。

case .task:
    return .run { [url = state.url] send in
      async let startRecording: Void = send(
        .audioRecorderDidFinish(
          TaskResult { try await environment.audioRecorder.startRecording(url) }
        )
      )

      for await _ in environment.mainRunLoop.timer(interval: .seconds(1)) {
        await send(.timerUpdated)
        await send(.getVolumes)
        await send(.getResultText)
      }
    }

今回呼び出すActionは音量情報をgetVolumesとupdateVolumesで音声から自動生成した結果をgetResultTextとupdateResultTextでそれぞれ audioRecorderから取得しTCAで管理できるようstateをアップデートしています。
このほかにもしAudioRecoderからの処理を状態管理したい場合は同じように書けば良いということです。

  case .timerUpdated:
    state.duration += 1
    return .none
  case let .updateVolumes(volume):
    state.volumes.append(volume)
    return .none
  case let .updateResultText(text):
    state.resultText = text
    return .none
  case .getVolumes:
    return .run { send in
        let volume = await environment.audioRecorder.volumes()
        await send(.updateVolumes(volume))
    }
  case .getResultText:
      return .run { send in
          let text = await environment.audioRecorder.resultText()
          await send(.updateResultText(text))
      }
  }

音声収録

ここから音声収録の処理を見ていきましょう
ここでは@Sendableで公開されている処理の定義を行い、中ではaudioRecorderの処理を呼び出しています。これによって非同期な audioRecorderの処理をReducer側に通知しています。

struct AudioRecorderClient {
  var currentTime: @Sendable () async -> TimeInterval?
  var requestRecordPermission: @Sendable () async -> Bool
  var startRecording: @Sendable (URL) async throws -> Bool
  var stopRecording: @Sendable () async -> Void
  var volumes: @Sendable () async -> Float
  var resultText: @Sendable () async -> String
}

extension AudioRecorderClient {
  static var live: Self {
    let audioRecorder = AudioRecorder()
    return Self(
      currentTime: { await audioRecorder.currentTime },
      requestRecordPermission: { await AudioRecorder.requestPermission() },
      startRecording: { url in try await audioRecorder.start(url: url) },
      stopRecording: { await audioRecorder.stop() },
      volumes: { await audioRecorder.amplitude() },
      resultText: { await audioRecorder.fetchResultText() }
    )
  }
}

audioRecorderの処理は多いのですがここではstart処理に注目して追っていきましょう。
まずはstart内でAsyncThrowingStreamで録音処理を定義しています。これによって録音開始から意図的な停止もしくはなんらかの原因でエラー処理がなされるまでは録音処理による結果を非同期で取得することができます。

let stream = AsyncThrowingStream<Bool, Error> { continuation in

startの処理の中では主に下記の2つを実施しています。順に見ていきましょう。

音声認識結果の取得

SFSpeechAudioBufferRecognitionRequestの結果を取得しています。
少しエラー処理を書いていて見にくい(というか処理が冗長)ですが、重要なことは結果が出た時に resultText を取得することです。ここで結果を取得しておくことでReducer側から音声認識結果を取得することができます。

              self.recognitionTask = speechRecognizer?.recognitionTask(with: recognitionRequest) { result, error in

                  // 取得した認識結果の処理
                  if let result = result {
                      self.isFinal = result.isFinal
                      // 認識結果をプリント
                      print("RecognizedText: \(result.bestTranscription.formattedString)")
                      self.resultText = result.bestTranscription.formattedString
                  }

                  // 音声認識できない場合のエラー
                  if let error = error as? NSError {
                      // 一言も発しない場合もエラーとなるので、認識結果が0件の場合はエラーを投げない

                      continuation.yield(true)
                      continuation.finish()
                  }
                  if self.isFinal {

                      self.recognitionTask = nil
                      continuation.yield(true)
                      continuation.finish()
                  }

              }

録音結果の取得

ここでは録音結果をAVAudioInputNodeを使用し取得しています。
AVAudioRecoderでも録音処理は可能なのですが、リアルタイムで録音処理を処理しようとする場合はAVAudioInputNodeを利用する必要があります。今回は音声認識の処理をリアルタイムで行いたいためAVAudioInputNodeを利用して録音処理も同時にしています。

              let audioFile = try AVAudioFile(forWriting: url, settings: AVAudioFormat(commonFormat: .pcmFormatFloat32, sampleRate: sampleRate, channels: 1, interleaved: true)!.settings)

              inputNode?.installTap(onBus: 0, bufferSize: 1024, format: nil) { (buffer: AVAudioPCMBuffer, _: AVAudioTime) in
                  // 音声を取得したら
                  self.currentTime = Double(audioFile.length) / self.sampleRate

                  self.recognitionRequest?.append(buffer) // 音声認識リクエストに取得した音声を加える
                  do {
                    // audioFileにバッファを書き込む
                    try audioFile.write(from: buffer)
                  } catch let error {
                    print("audioFile.writeFromBuffer error:", error)
                    continuation.finish(throwing: error)
                  }
              }

SwiftUIへの表示

最後にSwiftUIへの表示です。
先ほど定義したReducerで設定していた RecordingMemoState RecordingMemoActionをStoreで設定し、これを WithViewStore でStore内のstateを表示していきます

  let store: Store<RecordingMemoState, RecordingMemoAction>


  var body: some View {
    WithViewStore(self.store) { viewStore in
    // 中略
    }

またTCAを利用する場合のSwiftUIのPreview機能が素晴らしく、reducerでいくつかのパターンで値が返ってきた時の UI処理を試すことができます。
これを初めて見た時は感動しました。

struct RecordingMemoView_Previews: PreviewProvider {
    static var previews: some View {
        RecordingMemoView(store: Store(initialState: RecordingMemoState(
            date: Date(),
            duration: 5,
            mode: .recording,
            url: URL(string: "https://www.pointfree.co/functions")!,
            themaText: "個人開発どうですか?"
        ), reducer: recordingMemoReducer, environment: RecordingMemoEnvironment(audioRecorder: .mock, mainRunLoop: .main

          )
        ))
    }
}

音声アプリの利用イメージ

ここまでできたら完成です!こんな感じに動くんだなーと思ってgifを見てみてください。

参考資料

TCAの公式サイトに多くのサンプルがあるのでこちらをご覧ください。
https://github.com/pointfreeco/swift-composable-architecture

まとめ

ここまで読んでいただきありがとうございました。
さまざまな状態管理手法や技術がありますが、自分の体で覚えた技術は必ず身になると思います。
今回大まかな説明に終始しましたが、自分がTCAを触れる前にどんな説明が欲しかったかな?と思い言語化しました。TCAの公式サイトのsampleはとても充実しているので、ぜひみなさんの手でTCAを確かめてみてください!
ご意見ご感想ご質問をお待ちしています!

Discussion