iOS に 日本語を しゃべらせる（macOS / iPadOSも同じ）

# OSネイティブによる音声合成

iOS / iPadOS / macOS は、数ステップのコードにて、日本語を簡単に**しゃべらせる**ことができます。
その方法を説明します。



# Speech Synthesis

OS標準の[**AVFoundation**](https://developer.apple.com/documentation/avfoundation/)ライブラリで提供されている[**Speech Synthesis**](https://developer.apple.com/documentation/avfoundation/speech_synthesis)機能を使います。


# API 

たった3つのAPIを呼び出すだけです。

- [**AVSpeechSynthesizer**](https://developer.apple.com/documentation/avfaudio/AVSpeechSynthesizer/)
- [**AVSpeechUtterance**](https://developer.apple.com/documentation/avfaudio/AVSpeechUtterance/)
- [**AVSpeechSynthesisVoice**](https://developer.apple.com/documentation/avfaudio/AVSpeechSynthesisVoice/)

# 使用例1

最も簡単なAPI使用例は以下の通り。

```swift
import AVFoundation

let speechText = "この言葉をしゃべります"

let utterance = AVSpeechUtterance(string: speechText)
utterance.voice = AVSpeechSynthesisVoice(language: "ja-JP") //日本語で
//utterance.volume = 1.0
//utterance.rate = 0.5
//utterance.pitchMultiplier = 2.0

let speechSynthesizer = AVSpeechSynthesizer()
speechSynthesizer.speak(utterance) //しゃべります

//RunLoop.current.run(until: Date(timeIntervalSinceNow: 5.0)) //for swift repl
```

SpeechUtteranceのパタメタは以下の通り。必要により、しゃべらせる前に設定します。

|パタメタ|内容|範囲|
|-|-|-|
|<i>volume</i>|音量|無音：0.0 〜 最大：1.0<br>デフォルト：1.0|
|<i>rate</i>|速度|AVSpeechUtteranceMinimumSpeechRate 〜 AVSpeechUtteranceMaximumSpeechRate<br>デフォルト：AVSpeechUtteranceDefaultSpeechRate|
|<i>pitchMultiplier</i>|ピッチ|低い：0.4 〜 高い：2.0<br>デフォルト：1.0|


<i>自分の環境だと次の値でした。
　　AVSpeechUtteranceMinimumSpeechRate：0.0
　　AVSpeechUtteranceMaximumSpeechRate：1.0
　　AVSpeechUtteranceDefaultSpeechRate：0.5</i>


:::note
speechSynthesizer.<b><i>speak()</i></b>は非同期に**しゃべり**ます。
このため、swift repl で実行する場合は、`RunLoop.current.run()`が必要。
また、**しゃべっている**時に続けて<b><i>speak()</i></b>すると（要求はキューイングされ）、前の言葉がしゃべり終わると、続けてしゃべります。
:::

しゃべり終わりやその他のタイミングをつかみたい場合は、<i>AVSpeechSynthesizerDelegate</i> を実装します。

```swift:AVSpeechSynthesizerDelegate
//おしゃべりを開始するタイミング
func speechSynthesizer(AVSpeechSynthesizer, didStart: AVSpeechUtterance)

//おしゃべりテキストの一部をしゃべろうとしているとき
func speechSynthesizer(AVSpeechSynthesizer, willSpeakRangeOfSpeechString: NSRange, utterance: AVSpeechUtterance)

//おしゃべりのマーカーをしゃべろうとしているとき
func speechSynthesizer(AVSpeechSynthesizer, willSpeak: AVSpeechSynthesisMarker, utterance: AVSpeechUtterance)

//おしゃべり中に一時停止するタイミング
func speechSynthesizer(AVSpeechSynthesizer, didPause: AVSpeechUtterance)

//一時停止後におしゃべりを再開するタイミング
func speechSynthesizer(AVSpeechSynthesizer, didContinue: AVSpeechUtterance)

//おしゃべりを終了したとき
func speechSynthesizer(AVSpeechSynthesizer, didFinish: AVSpeechUtterance)

//おしゃべりをキャンセルするタイミング
func speechSynthesizer(AVSpeechSynthesizer, didCancel: AVSpeechUtterance)
```

# 使用例2

<i>AVSpeechSynthesizerDelegate</i> の実装例は以下の通り。

```swift
import AVFoundation

let speechText = "エーヴイスピーチシンセサイザーは、テキストの発話から合成音声を生成し、進行中の音声の監視または制御を可能にするオブジェクトです"

let utterance = AVSpeechUtterance(string: speechText)
utterance.voice = AVSpeechSynthesisVoice(language: "ja-JP")

let delegate = SpeechSynthesizerDelegateImplement()
let speechSynthesizer = AVSpeechSynthesizer()
speechSynthesizer.delegate = delegate

speechSynthesizer.speak(utterance)

// AVSpeechSynthesizerDelegate
class SpeechSynthesizerDelegateImplement: NSObject, AVSpeechSynthesizerDelegate {
    func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didStart utterance: AVSpeechUtterance) {
        print("didStart")
    }
    func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, willSpeakRangeOfSpeechString characterRange: NSRange, utterance: AVSpeechUtterance) {
        let start = speechText.index(speechText.startIndex, offsetBy: characterRange.location)
        let end = speechText.index(start, offsetBy: characterRange.length)
        let textPart = speechText[start ..< end]
        print("willSpeakRangeOfSpeechString", characterRange, textPart)
    }
    func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, willSpeak marker: AVSpeechSynthesisMarker, utterance: AVSpeechUtterance) {
        print("willSpeak")
    }
    func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didPause utterance: AVSpeechUtterance) {
        print("didPause")
    }
    func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didContinue utterance: AVSpeechUtterance) {
        print("didContinue")
    }
    func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didFinish utterance: AVSpeechUtterance) {
        print("didFinish")
    }
    func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didCancel utterance: AVSpeechUtterance) {
        print("didCancel")
    }
}
```

おしゃべりの途中で停止、一時停止 / 再開させる場合は、AVSpeechSynthesizerの次の関数を使います。

```swift
//指定した区切りでしゃべりを停止する
func stopSpeaking(at: AVSpeechBoundary) -> Bool

//指定した区切りでしゃべりを一時停止する
func pauseSpeaking(at: AVSpeechBoundary) -> Bool

//一時停止した時点からしゃべりを再開する
func continueSpeaking() -> Bool

//それぞれ、成功するとtrue
```

停止や一時停止する時の区切り _(AVSpeechBoundary)_ は、次の2通りが指定できます。

- <b><i>immediate</i></b>：しゃべりをただちに一時停止または停止する
- <b><i>word</i></b>：現在の単語の発話を終了した後に一時停止または停止する

また、おしゃべり中と一時停止中を判定するには、AVSpeechSynthesizerの次のプロパティを参照します。

```swift
var isSpeaking: Bool //おしゃべり中はtrue
var isPaused: Bool   //一時停止中はtrue
```

# 使用例3

これまでのすべてのAPIを試すことができるアプリのコードを以下に示します。
Xcodeのプレビューでも動作確認できます。

```swift
import AVFoundation
import SwiftUI

struct ContentView: View {
    let speechSynthesizer = SpeechSynthesizer()
    @State var text = ""
    @State var isPaused = false
    @State var speechString = ""
    @State var speechText: AttributedString = ""
    @State var volume: Float = 1.0
    @State var rate: Float = AVSpeechUtteranceDefaultSpeechRate
    @State var pitchMultiplier: Float = 1.0
    @State var speechBoundary = AVSpeechBoundary.immediate
    @FocusState var focus: Bool
    var body: some View {
        List {
            Stepper(value: $volume, in: 0.0 ... 1.0, step: 0.1) {
                HStack {
                    Text("Volume:")
                    Spacer()
                    Text("\(volume, specifier: "%.1f")")
                }
            }
            Stepper(value: $rate, in: AVSpeechUtteranceMinimumSpeechRate ... AVSpeechUtteranceMaximumSpeechRate, step: 0.1) {
                HStack {
                    Text("Rate:")
                    Spacer()
                    Text("\(rate, specifier: "%.1f")")
                }
            }
            Stepper(value: $pitchMultiplier, in: 0.4 ... 2.0, step: 0.1) {
                HStack {
                    Text("PitchMultiplier:")
                    Spacer()
                    Text("\(pitchMultiplier, specifier: "%.1f")")
                }
            }
            HStack {
                Text("")
                Spacer()
                Text("今日の日付 を しゃべる")
                    .foregroundColor(Color.accentColor)
                    .onTapGesture  {
                        let now = Date.now
                        let calendar = Calendar(identifier: .japanese)
                        
                        let dateFormatter = DateFormatter()
                        dateFormatter.locale = Locale(identifier: "ja_JP")
                        dateFormatter.calendar = calendar
                        dateFormatter.dateFormat = "Gy EEEE"
                        let wareki = dateFormatter.string(from: now).split(separator: " ")
                        
                        let dateComponents = calendar.dateComponents([.year, .month, .day, .weekday], from: now)
                        guard let month = dateComponents.month, let day = dateComponents.day else { return }
                        
                        speechString =  "\(wareki[0])年\(correctionMonth(month))\(correctionDay(day))\(wareki[1])"
                        speechSynthesizer.speak(string: speechString, volume: volume, rate: rate, pitchMultiplier: pitchMultiplier)
                    }
            }
            HStack {
                Text("")
                Spacer()
                Text("現在時刻 を しゃべる")
                    .foregroundColor(Color.accentColor)
                    .onTapGesture  {
                        let calendar = Calendar.current
                        let dateComponents = calendar.dateComponents([.hour, .minute, .second], from: Date.now)
                        guard let hour = dateComponents.hour, let minute = dateComponents.minute, let second = dateComponents.second else { return }
                        
                        speechString = minute == 0 && second == 0 ? "\(hour)時ちょうど" : "\(hour)時\(minute)分\(second)秒"
                        speechSynthesizer.speak(string: speechString, volume: volume, rate: rate, pitchMultiplier: pitchMultiplier)
                    }
            }
            HStack {
                Text("")
                TextField("自由テキスト", text: $text)
                    .textFieldStyle(.roundedBorder)
                    .focused($focus)
                Spacer()
                Text("を しゃべる")
                    .foregroundColor(Color.accentColor)
                    .onTapGesture  {
                        focus = false
                        speechString = text
                        if text.isEmpty { speechString = "空欄です。テキストを入力してください" }
                        speechSynthesizer.speak(string: speechString, volume: volume, rate: rate, pitchMultiplier: pitchMultiplier)
                    }
            }
            Picker("SpeechBoundary", selection: $speechBoundary) {
                Text("immediate").tag(AVSpeechBoundary.immediate)
                Text("word").tag(AVSpeechBoundary.word)
            }
            .pickerStyle(.menu)
            
            HStack {
                Text("isSpeaking:")
                Text("\(speechSynthesizer.speechSynthe.isSpeaking)")
                    .foregroundColor(speechSynthesizer.speechSynthe.isSpeaking ? .red : .textColor)
                Spacer()
                Text("Stop")
                    .foregroundColor(speechSynthesizer.speechSynthe.isSpeaking ? .accentColor : .gray)
                    .disabled(!speechSynthesizer.speechSynthe.isSpeaking)
                    .onTapGesture  {
                        if speechSynthesizer.speechSynthe.isSpeaking {
                            speechSynthesizer.speechSynthe.stopSpeaking(at: speechBoundary)
                            isPaused = false
                        }
                    }
            }
            HStack {
                Text("isPause:")
                Text("\(isPaused)")
                    .foregroundColor(isPaused ? .red : .textColor)
                Spacer()
                Text(isPaused ? "Continue" : "Pause")
                    .foregroundColor(Color.accentColor)
                    .onTapGesture  {
                        isPaused = speechSynthesizer.speechSynthe.isPaused
                        if isPaused {
                            speechSynthesizer.speechSynthe.continueSpeaking()
                        } else {
                            speechSynthesizer.speechSynthe.pauseSpeaking(at: speechBoundary)
                        }
                        isPaused.toggle()
                    }
            }
            Text(speechText)
        }
        .listStyle(.plain)
        .padding()
        .onAppear {
            speechSynthesizer.set(text: $speechText)
        }
    }
    private func correctionMonth(_ month: Int) -> String {
        let tsuki = "0 1 2 3 し 5 6 7 8 9 10 11 12".split(separator: " ")
        return "\(tsuki[month])がつ"
    }
    private func correctionDay(_ day: Int) -> String {
        let hiduke = ",一日,二日,三日,四日,五日,六日,七日,八日,九日,とうか"
            .split(separator: ",", omittingEmptySubsequences: false)
        return switch day {
            case 1 ..< 11: "\(hiduke[day])"
            case 20: "二十日"
            case 24: "二十よっか"
            default: "\(day)にち"
        }
    }
}

class SpeechSynthesizer: NSObject {
    let speechSynthe = AVSpeechSynthesizer()
    var speakText: Binding<AttributedString>? = nil
    override init() {
        super.init()
        speechSynthe.delegate = self
    }
    func set(text: Binding<AttributedString>) {
        speakText = text
    }
    func speak(string: String, volume: Float, rate: Float, pitchMultiplier: Float) {
        let utterance = AVSpeechUtterance(string: string)
        utterance.volume = volume
        utterance.rate = rate
        utterance.pitchMultiplier = pitchMultiplier
        utterance.voice = AVSpeechSynthesisVoice(language: "ja-JP")
        speechSynthe.speak(utterance)
    }
}
extension SpeechSynthesizer: AVSpeechSynthesizerDelegate {
    func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didStart utterance: AVSpeechUtterance) {
        print("didStart")
        guard speakText != nil else { return }
        speakText!.wrappedValue = AttributedString(utterance.speechString)
    }
    func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, willSpeakRangeOfSpeechString characterRange: NSRange, utterance: AVSpeechUtterance) {
        let speechText = utterance.speechString
        let textPart = speechText[Range(characterRange, in: speechText)!]
        print("willSpeakRangeOfSpeechString", characterRange, textPart)

        var attributeString = AttributedString(utterance.speechString)
        guard speakText != nil else { return }

        speakText!.wrappedValue = attributeString
        if let range = Range(characterRange, in: attributeString) {
            attributeString[range].foregroundColor = .red
            speakText!.wrappedValue = attributeString
            print(range, attributeString[range])
        }
    }
    func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, willSpeak marker: AVSpeechSynthesisMarker, utterance: AVSpeechUtterance) {
        print("willSpeak", marker)
    }
    func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didPause utterance: AVSpeechUtterance) {
        print("didPause")
    }
    func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didContinue utterance: AVSpeechUtterance) {
        print("didContinue")
    }
    func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didFinish utterance: AVSpeechUtterance) {
        print("didFinish")
        guard speakText != nil else { return }
        speakText!.wrappedValue = ""
    }
    func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didCancel utterance: AVSpeechUtterance) {
        print("didCancel")
        guard speakText != nil else { return }
        speakText!.wrappedValue = ""
    }
}

extension Color {
#if os(macOS)
    static let textColor = Color(nsColor: .textColor)
#else
    static let textColor = Color(.label)
#endif
}

#Preview {
    ContentView()
#if os(macOS)
        .frame(width: 300)
#endif
}
```

<img width="300" alt="app.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/283293/273773f0-9747-8921-0c9d-9c66a0306e85.png" />

<img width="300" alt="iPhone.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/283293/507040cf-c061-62c1-12c0-059a8da98487.png" />


# しゃべらせてみ

[次の文章](https://support.apple.com/ja-jp/guide/mac-help/mchld6aa7d23/mac)をしゃべらせてみました。

- <i>アプリおよびWebサイトの中には、Macの画面やオーディオにアクセスしたりそれを収録したりできるものがあります。画面収録やオーディオ録音を許可するアプリおよびWebサイトを指定できます。</i>

<iframe width="386" height="208" src="https://www.youtube.com/embed/n67cfd7Da6Q?loop=1&playlist=n67cfd7Da6Q" title="Speech Synthesis" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>

少しぎこちないですね。
しかし、同じ文章でも 『**翻訳後のスピーチ**』（↓）では、もう少し上手にしゃべります。音量も大きい。サーバで処理しているからでしょうか、しゃべり出すまでに少し待たされます。（下記動画は待ち無しに加工済み）

<iframe width="384" height="304" src="https://www.youtube.com/embed/33dR2Vypz74?loop=1&playlist=33dR2Vypz74" title="翻訳後のスピーチ" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>

この違いは、Mac も iPhone もまったく同じでした。

**以上です**