(Swift)iOS 16で導入されたカスタム読み上げ音声を実装する方法

2023/07/01に公開

はじめに

大変です!iOS 16に激アツな機能が搭載されたのをご存知でしょうか?なんとカスタム読み上げ音声をインストールできるようになったのです!

この記事ではiOS向けのカスタム読み上げ音声を実装する手順を説明します。音声合成エンジンを一から実装するのはハードルが高すぎるため、今回は特定のキーワードにマッチした場合にあらかじめ作成した音声ファイルを再生するアプリを作成します。

なお、記事のタイトルはiOS 16としましたが、カスタム読み上げ音声はipadOS 16とmacOS 13 Ventura以降のバージョンで利用可能です。また、インストールしたカスタム読み上げ音声はVoiceOverの他にアクセシビリティの読み上げコンテンツなどから利用できます。

環境

記事の投稿にあたり、以下の環境で動作確認しました。

  • Xcode 14.3.1 (14E300c)
  • macOS Ventura 13.4.1
  • iOS 16.5.1

プロジェクトの作成

カスタム読み上げ音声アプリは2つのアプリで構成されます。

  1. 音声合成アプリ: AudioUnit Extensionとして実装されたアプリ。テキストを受け取り音声波形を合成する。通常はGUIを持たない。
  2. コンテナアプリ: 音声合成アプリを埋め込んだアプリ。AudioUnit Extension単体をAppStoreで配布することは可能だが、楽器アプリとして認識されるのを防ぐためコンテナアプリに埋め込む形で配布する。

通常はAudioUnit Extensionの実装言語としてC++を利用します。今回はあらかじめ用意した音声ファイルを再生するだけですから、言語はSwiftのみで実装します。

それではプロジェクトの作成手順について説明します。

(Step 1)コンテナアプリの作成

  1. Xcodeを起動し、メニューのFile→New→Projectを選びます。
  2. プラットフォームはiOS、テンプレートはAppを選びます。
  3. プロダクト名は何でも構いません。ここでは仮に「HelloSpeech」とします。
  4. インターフェースはSwiftUI、言語はSwiftを選びます。「Use Core Data」と「Include Tests」のチェックは外してください。
  5. プロジェクトが作成されたら完了です。

コンテナアプリはあくまでAudioUnit Extensionを配布するための入れ物です。生成されたContentView.swiftとHelloSpeechApp.swiftの修正は不要です。

(Step 2)音声合成アプリの作成

  1. Project NavigatorでHelloSpeechプロジェクトが選択された状態であることを確認します。
  2. Add Targetボタンを押します。プラットフォームはiOS、テンプレートはAudio Unit Extensionを選びます。
  3. プロダクト名は何でも構いません。ここでは仮に「Synth」とします。
  4. Organization Nameを設定します。この値が音声一覧に表示されます。今回は「カスタム音声」とします。
  5. Audio Unit Typeとして「Speech Synthesizer」を選びます。Subtype CodeとManufacturer Codeは何でも構いません。ここでは仮に「demo」と設定します。
  6. ユーザーインターフェースを設定します。初期値として「Presents User Interface」が設定されている場合は「No User Interface」に変更します。
  7. Project / Embed in Applicationを設定します。どちらも「HelloSpeech」を選びます。
  8. Finishボタンを押して完了です。このとき「Activate “Synth” scheme?」というダイアログが表示された場合はActivateボタンを押します。

(Step 3)アプリの埋め込み設定

  1. ターゲット一覧に「HelloSpeech」と「Synth」が表示されていることを確認します。その後、HelloSpeechターゲットを選びます。
  2. General / Signing & Capabilities / Resource ...と並んでいるメニューの中からGeneralを選びます。
  3. Frameworks, Libraries, and Embedded Contentを開きます。
  4. Linked Binaries一覧の中にSynth.appexが表示されていることを確認します。
  5. もし表示されていなかったらAddボタンを押して「Synth.appex」を追加します。

(Step 4)コードの修正

2つのファイルを修正します。

  1. Synth/Common/AudioUnitFactory/AudioUnitFactory.swift
  2. Synth/Common/Audio Unit/SynthAudioUnit.swift

元のコードは消して、それぞれ次の内容に書き換えてください。

AudioUnitFactory.swift

import CoreAudioKit

public class AudioUnitFactory: NSObject, AUAudioUnitFactory {
  var audioUnit: AUAudioUnit?

  public func beginRequest(with context: NSExtensionContext) {
  }
  @objc
  public func createAudioUnit(with componentDescription: AudioComponentDescription) throws
    -> AUAudioUnit
  {
    self.audioUnit = try SynthAudioUnit(componentDescription: componentDescription, options: [])

    return self.audioUnit!
  }
}

SynthAudioUnit.swift

import AVFoundation

public class SynthAudioUnit: AVSpeechSynthesisProviderAudioUnit {
  private var request: AVSpeechSynthesisProviderRequest?
  private var outputBus: AUAudioUnitBus
  private var _outputBusses: AUAudioUnitBusArray!
  private var currentBuffer: AVAudioPCMBuffer?
  private var framePosition: AVAudioFramePosition = 0
  private var format: AVAudioFormat

  @objc
  override init(
    componentDescription: AudioComponentDescription,
    options: AudioComponentInstantiationOptions
  ) throws {
    let basicDescription = AudioStreamBasicDescription(
      mSampleRate: 22050,
      mFormatID: kAudioFormatLinearPCM,
      mFormatFlags: kAudioFormatFlagsNativeFloatPacked | kAudioFormatFlagIsNonInterleaved,
      mBytesPerPacket: 4,
      mFramesPerPacket: 1,
      mBytesPerFrame: 4,
      mChannelsPerFrame: 1,
      mBitsPerChannel: 32,
      mReserved: 0)

    self.format = AVAudioFormat(
      cmAudioFormatDescription: try! CMAudioFormatDescription(
        audioStreamBasicDescription: basicDescription))
    self.outputBus = try AUAudioUnitBus(format: self.format)

    try super.init(
      componentDescription: componentDescription,
      options: options)
    self._outputBusses = AUAudioUnitBusArray(
      audioUnit: self,
      busType: AUAudioUnitBusType.output,
      busses: [outputBus])
  }

  public override var speechVoices: [AVSpeechSynthesisProviderVoice] {
    get {
      return [
        AVSpeechSynthesisProviderVoice(
          name: "声1", identifier: "com.HelloSpeech.Synth.Voice1", primaryLanguages: ["ja-JP"],
          supportedLanguages: ["ja-JP"])
      ]
    }
    set {}
  }

  public override var outputBusses: AUAudioUnitBusArray {
    return self._outputBusses
  }
  public override func allocateRenderResources() throws {
    try super.allocateRenderResources()
  }
  public override var internalRenderBlock: AUInternalRenderBlock {
    return { actionFlags, timestamp, frameCount, outputBusNumber, outputAudioBufferList, _, _ in
      let unsafeBuffer = UnsafeMutableAudioBufferListPointer(outputAudioBufferList)[0]
      let frames = unsafeBuffer.mData!.assumingMemoryBound(to: Float32.self)
      let sourceBuffer = UnsafeMutableAudioBufferListPointer(
        self.currentBuffer!.mutableAudioBufferList)[0]
      let sourceFrames = sourceBuffer.mData!.assumingMemoryBound(to: Float32.self)

      for frame in 0..<frameCount {
        frames[Int(frame)] = 0.0
      }
      for frame in 0..<frameCount {
        frames[Int(frame)] = sourceFrames[Int(self.framePosition)]
        self.framePosition += 1

        if self.framePosition >= self.currentBuffer!.frameLength {
          actionFlags.pointee = .offlineUnitRenderAction_Complete
          break
        }
      }

      return noErr
    }
  }

  public override func synthesizeSpeechRequest(_ speechRequest: AVSpeechSynthesisProviderRequest) {
    self.request = speechRequest
    self.currentBuffer = getAudioBufferForSSML(speechRequest.ssmlRepresentation)
    self.framePosition = 0
  }
  public override func cancelSpeechRequest() {
    self.request = nil
  }
  func getAudioBufferForSSML(_ ssml: String) -> AVAudioPCMBuffer? {
    let audioFileName = ssml.contains("Hello") ? "Hello" : "Goodbye"

    guard
      let fileUrl = Bundle.main.url(
        forResource: audioFileName,
        withExtension: "aiff")
    else {
      return nil
    }
    do {
      let file = try AVAudioFile(forReading: fileUrl)
      let buffer = AVAudioPCMBuffer(
        pcmFormat: self.format,
        frameCapacity: AVAudioFrameCount(file.length))
      try file.read(into: buffer!)

      return buffer
    } catch {
      return nil
    }
  }
}

コードの解説

要点を絞って解説します。詳細についてはApple Developerのドキュメントを参照してください。

  1. AudioFactoryクラスはAudioUnit Extensionの初期化を担当します。通常のAudioUnit Extensionと同様に、createAudioUnit()メソッドが処理の起点になります。
  2. SynthAudioUnitクラスに実装された処理がメインの処理になります。AVSpeechSynthesisProviderAudioUnitはAUAudioUnitを継承したクラスです。音声合成のために追加されたメソッドはsynthesizeSpeechRequest()cancelSpeechRequest()の2つだけです。
  3. speechVoicesプロパティのゲッターが返す文字列が設定アプリの読み上げ音声一覧に表示されます。1つのカスタム読み上げ音声に複数の話者を含めることができるため、戻り値の型は配列になります。
  4. VoiceOverカーソルが移動した際など、読み上げの要求があるたびにsynthesizeSpeechRequest()が呼ばれます。読み上げの内容はSSML文字列として渡されます。

(Step 5)音声ファイルの取り込み

最後のステップです。プロジェクトへ音声ファイルを追加しましょう。「ハロー」と「グッバイ」と読み上げた音声ファイル、Hello.aiffGoodbye.aiffをプロジェクトに追加します。

まず、AIFF形式の音声ファイルを用意します。PCMフォーマットは16 bit / 24 kHz / 1 chモノラルの音声ファイルを用意してください。音声の再生時間は自由ですが、長すぎるとファイルの読み込みに遅延が生じるため、数秒程度にしてください。

音声ファイルを用意するのが面倒な場合、macOSに搭載されたsayコマンドを使いましょう。ターミナルを開き、以下のコマンドを実行します。成功するとHello.aiffとGoodbye.aiffファイルが作成されます。

$ say -o Hello.aiff 'Hello'
$ say -o Goodbye.aiff 'Goodbye'

それではXcodeに戻り、音声ファイルを追加しましょう。手順は次のとおりです。

  1. Xcodeを開きます。Project NavigatorでSynthグループを選びます。
  2. Synthグループの上で右クリックしてメニューを開き、New Groupを選びます。名前は「Audio」を入力してください。
  3. Audioグループの上で右クリックしてメニューを開き、Add Files to “HelloSpeech“ …を選びます。
  4. ファイル選択のダイアログが表示されます。先ほど作成したHello.aiffとGoodbye.aiffを選びます。
  5. ファイル追加先の一覧が表示されます。HelloSpeechのチェックは外し、Synthにチェックをつけます。
  6. Copy items if neededにチェックをつけます。
  7. フォルダの設定は「Create folder references」を選びます。
  8. Addボタンを押して完了です。

試運転

準備は整いました。実機を使って動作確認をしましょう!

手順は次のとおりです。

  1. Xcodeのメニューを開き、Product→Scheme→Manage Schemesを選びます。
  2. Autocreate Schemes Nowボタンを押します。完了したらCloseボタンを押してダイアログを閉じてください。
  3. Xcodeのメニューを開き、Product→Scheme→HelloSpeechを選びます。
  4. Command + Rキーを押してプロジェクトを実行します。
  5. 実機でアプリが起動したら、設定アプリを開き、アクセシビリティを選びます。
  6. VoiceOver→読み上げ→声と進み、カスタムボイスを選びます。
  7. 「声1」を選択するとカスタム音声で読み上げが開始されます。

以上がカスタム読み上げ音声の実装手順です。今回は「ハロー」と「グッバイ」の音声しか用意していないため、項目にフォーカスが当たると「グッバイ、グッバイ、グッバイ...」のように連呼します。

よくある質問

カスタム読み上げ音声を実装する上で生じる疑問についてまとめます。

なお、以下で説明する挙動はiOS 16.5.1の実機で確認したものです。今後のバージョンで挙動が変わるかもしれませんので、その点ご留意ください。

(Q. 1)設定アプリに表示されるカスタム音声の名前を変更したい

Synth/Info.plistnameキーの値を変更するとカスタム音声の表示名が変更できます。注意点としては表示名: Synthのようにコロンの後ろに空白1文字とSynthの文字列を含めるようにしてください。

(例)表示名として「ゆっくりボイス」を設定する

<key>name</key>
<string>ゆっくりボイス: Synth</string>

なお、表示名に言語の制限はありません。例えば英語の読み上げ音声の表示名として日本語の表示名を設定しても構いません。ただし、音声合成エンジンによっては正しく読み上げされない可能性があります。基本的には読み上げ音声の言語と同じ言語で表示名を設定するか、ASCII文字列で表示名を設定するのが親切です。

(Q. 2)同じ表示名のカスタム読み上げ音声を複数インストールするとどうなる?

まとめて表示されます。例えば、アプリAがCustomVoiceとしてVoice1、アプリBがCustomVoiceとしてVoice2を提供する場合、設定アプリにはCustomVoiceとしてまとめて表示され、その中にVoice1とVoice2が表示されます。

(Q. 3)synthesizeSpeechRequest()メソッドが実装されていない場合どうなる?

システム組み込みのデフォルト音声にフォールバックされて読み上げされます。例えば日本語の場合はKyoko、英語(US English)の場合はSamanthaの声で読み上げされます。

(Q. 4)サンプルコードのcancelSpeechRequest()メソッドが実装されていない

鋭い質問です。SynthAudioUnitクラスの実装は次のようになっていました。

public class SynthAudioUnit: AVSpeechSynthesisProviderAudioUnit {
  // ...

  public override func cancelSpeechRequest() {
    request = nil
  }

  // ...
}

結論から述べると、cancelSpeechRequest()メソッドは実装しなくても構いません。

例えば、VoiceOverの読み上げ中に2本指でシングルタップすると、読み上げが一時停止します。続けて2本指でシングルタップすると、一時停止した箇所から読み上げが再開します。読み上げ音声の一時停止と再開処理はVoiceOver側でハンドリングしてくれるため、基本的には実装不要です。より具体的には、読み上げの一時停止中はinternalRenderBlockプロパティの読み取りをしない振る舞いになります。

また、読み上げ中に他の項目へフォーカスが移動した場合、読み上げている最中だった音声は停止して、次の読み上げが開始されます。「ここんんににちちわわ」のように音声が重なって再生されることはありません。

ただし、実装が必要になる場合も考えられます。今回はsynthesizeSpeechRequest()が呼ばれるたびにバッファの読み取りインデックスを0にリセットする形で実装しています。音声合成エンジンの実装によっては読み上げが中断した際のフックとしてcancelSpeechRequest()が必要になるかもしれません。

(Q. 5)読み上げの開始と終了の際にフェードイン・フェードアウトするべき?

実装しても構いませんが、基本的には不要です。

楽器系アプリの場合、いきなり音声を再生・停止するとポップノイズが発生する恐れがあります。一方で読み上げ音声は自動的にフェードイン・フェードアウトしてくれるため、そのような配慮は不要です。

(Q. 6)synthesizeSpeechRequest()メソッドの中でAVSpeechSynthesizerのspeakメソッドあるいはwriteメソッドを呼び出すとどうなる?

エラーを吐かずサイレントに失敗します。アプリがクラッシュすることはなく、ログにも記録されません。つまり、メソッドを呼び出していないかのような振る舞いをします。

例えば以下のようなコードを実装しても音声は読み上げされません。

public override func synthesizeSpeechRequest(_ speechRequest: AVSpeechSynthesisProviderRequest) {
  let synthesizer = AVSpeechSynthesizer()
  let voice = AVSpeechSynthesisVoice.init(language: "ja-JP")
  let utterance = AVSpeechUtterance.init(string: speechRequest.ssmlRepresentation)

  utterance.voice = voice
  synthesizer.speak(utterance)
}

仮にAVSpeechSynthesizerのspeak()メソッドとwrite()メソッドが許可されていたら、読み上げ音声として自分自身を設定した場合に処理が循環してしまいます。そのような動作を防ぐための挙動と思われます。

ところで、iOS 16からAVSpeechSynthesizerの挙動がおかしい、というバグ報告があります。システム組み込みの音声を利用するのであれば処理の循環は発生しませんし、もしかしたら仕様ではなくバグということでiOS 17以降で修正されるかもしれません。

(Q. 7)AudioUnit Extension内からインターネットにアクセスできない

仕様です。セキュリティの都合でAudioUnit Extensionから直接インターネットを経由した通信は行えません。

そのため、例えばサブスクリプションとしてカスタム読み上げ音声を販売する場合は、コンテナアプリに課金ロジックを実装することになります。コンテナアプリと音声合成アプリ間の通信についてはAppGroupsを使って値の読み書きをすることになります。

(Q. 8)print文の内容が出力されない

音声合成とは関係ない話題ですが、つまずくポイントですので説明します。AudioUnit Extensionはアウトプロセスとして実行されます。従って、print文の内容はXcodeのデバッグコンソールに表示されません。

この問題の回避策としてはUnified loggingを利用するのが簡単です。以下のようにimport osを追加し、Loggerのlog()メソッドで文字列を出力してください。

// ynth/Common/Audio Unit/SynthAudioUnit.swift
import AVFoundation
import os

private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "SynthAudioUnit")

public class SynthAudioUnit: AVSpeechSynthesisProviderAudioUnit {
  // ...

  public override func synthesizeSpeechRequest(_ speechRequest: AVSpeechSynthesisProviderRequest) {
    logger.log("Synth: ssmlRepresentation: \(speechRequest.ssmlRepresentation, privacy: .public)")
    // ...
  }

  // ...
}

log()メソッドに渡している文字列に注目してください。引数のprivacy: .publicは必須です。Unified loggingでは動的に生成された値はプライバシー保護のため表示されません。ここでは文字列を表示して構わないことをシステムに伝えるため、privacy: .publicを設定しています。

ログを取得するには、macOSに搭載されたlogコマンドを利用します。Xcodeを開いてCommand + Rでアプリを起動し、その後に以下のコマンドを実行します。

$ sudo log collect \
  --device-name 'XcodeのProduct→Destinationに表示されるデバイス名' \
  --last 10m

--last 10mは直近10分間のログを収集することを意味します。その他にもログ取得のオプションが用意されていますので、log collect -hを実行して確認してみてください。

コマンドの実行が完了するとsystem_logs.logarchiveという名前のディレクトリが作成されます。ログの量にもよりますが、コマンドが完了するまで数十秒から数分かかります。

収集されたログはlog show system_logs.logarchiveコマンドを実行することでテキストとして閲覧できます。あるいはopen system_logs01.logarchiveコマンドを実行するとConsole.appが起動して読みやすい形で表示されます。

(Q. 9)リクエストとして渡されるSSMLの仕様を詳しく知りたい

私も知りたいです。残念ながら、AVSpeechSynthesisProviderRequestのドキュメントには「ピッチや速度、イントネーションなどの情報が含まれるよ」といった曖昧な説明しか書かれていません。

SSMLの仕様

ここからは実機で動作検証をして得られた暗黙の仕様についてまとめます。ドキュメントには記載されていないため、仕様が突然変更される恐れがあることを承知の上で読み進めてください。

ssmlRepresentationに格納される文字列について、基本は以下の構造でマークアップされています。読みやすさのためにインデントと改行を加えています。

<speak>
  <prosody rate="400.0%" volume="-1.0dB">
    読み上げ対象の文字列
  </prosody>
</speak>

現時点で判明している仕様をまとめます。

  1. XMLのスキームを表す文字列(<?xml version=...>)は含まれない。
  2. ルート要素は常に<speak>要素である。
  3. <speak>要素について、<speak version="1.1">のような属性が設定されることはない。
  4. <speak>要素の子要素として常に<prosody>要素が含まれる。
  5. <prosody>の子要素として読み上げ対象の文字列が格納される。
  6. <prosody>要素には常にratevolume属性が含まれる。
  7. 読み上げの速度を示すrate属性の値とVoiceOverローターの読み上げ速度は一致するとは限らない。例えば読み上げの言語が日本語の場合、読み上げ速度を100%に設定すると<prosody>rate属性には値として400.0%が設定される。
  8. 読み上げの音量を示すvolume属性の値とVoiceOverローターの読み上げ速度は一致するとは限らない。例えば読み上げの言語が日本語の場合、読み上げ音量を100%に設定すると<prosody>volume属性には値として-1.0dBが設定される。

SSMLのサンプル

実機で取得したSSMLのサンプルをいくつか紹介します。

基本形

<speak>
  <prosody rate="400.0%" volume="-1.0dB">
    カスタムのアクションを選択するには、上または下にスワイプします。その後ダブルタップしてアクティベートします。
  </prosody>
</speak>

VoiceOverでアイコンにカーソルが当たった際の読み上げリクエストです。特にひねりのないマークアップです。<speak>要素の中に<prosody>要素があり、その中に読み上げ対象の文字列が格納されています。

読み上げの待機

「設定...開くにはダブルタップします」のように、読み上げ項目の間に少しだけ間を開けることがあります。その際は以下のようなリクエストが届きます。

1つ目のリクエスト

<speak>
  <prosody rate="400.0%" volume="-1.0dB">
    設定
  </prosody>
</speak>

2つ目のリクエスト

<speak>
  <prosody rate="400.0%" volume="-1.0dB">
    <break time="400ms" />
  </prosody>
</speak>

3つ目のリクエスト

<speak>
  <prosody rate="400.0%" volume="-1.0dB">
    開くにはダブルタップします
  </prosody>
</speak>

<break />要素が登場しました。ここで注目したいのは、「設定<break time="400ms" />開くにはダブルタップします」のように1リクエストに読み上げ項目をすべて含めるのではなく、「設定400 msの待機開くにはダブルタップします」という3つのリクエストに分割されているところです。待機時間の指定についてはミリ秒単位で指定されるようです。

インライン要素

設定→アクセシビリティ→VoiceOver→読み上げ→声→Otoyaと進みます。その後、Otoyaを選択するためのボタンにフォーカスが当たると以下のリクエストが届きます。

<speak>
  <prosody rate="400.0%" volume="-1.0dB">
    Otoya 拡張 ,<break time="178ms" /> 101 ドット 5 MB使用
  </prosody>
</speak>

読み上げ文字列の中に<break />要素が埋め込まれています。待機時間は178msが指定されており、1ms単位で待機時間を指定するパターンがあることがわかります。

あくまで推測ですが、400msのように長めの待機時間が設定される場合は前後の読み上げ対象の文字列でリクエストが分割されるのかもしれません。今のところ、読み上げ対象の文字列の中に<break />要素が埋め込まれる場合、その待機時間は200ms未満であるパターンしか見つけられていません。

話者の指定

設定→アクセシビリティ→VoiceOver→読み上げと進みます。声を選択するボタンにフォーカスが移動した際に以下のリクエストが届きます。

<speak>
  <prosody rate="400.0%" volume="-1.0dB">
    <say-as interpret-as="characters"></say-as>
  </prosody>
</speak>

<say-as>要素が登場するのは設定アプリの中、それもVoiceOverの声を変更するためのボタンだけです。今のところ、他のUIで<say-as>要素が登場する場面は見つかっていません。そもそも、どのようにUIを実装すればSSMLで<say-as>と認識されるのか不明です。

参考資料

  1. Creating a custom speech synthesizer - Apple Developer
  2. Extend Speech Synthesis with personal and custom voices - WWDC23 - Videos - Apple Developer
  3. Logging - Apple Developer
  4. Generating Log Messages from Your Code
  5. Viewing Log Messages
  6. Customizing Logging Behavior While Debugging
  7. Logger - Apple Developer
  8. AVSpeechSynthesisProviderAudioUnit - Apple Developer

Discussion