🗣️

【Swift】Swift で読み上げ機能を実装する

2024/08/07に公開

初めに

今回は Swift でテキストを読み上げる機能を実装していきます。
テキストの読み上げを実装することでユーザーは画面を注視せずに情報を得ることができるようになり、音声案内や各言語に翻訳されたテキストの読み上げ、文字に代わるフィードバック方法としての活用など様々な場面で利用することができます。

記事の対象者

  • Swift, SwiftUI 学習者
  • サービスにテキストの読み上げ機能を追加したい方

目的

今回は先述の通り、Swift でテキストの読み上げ機能を実装することを目的とします。
最終的には以下の動画のようにテキストの読み上げと表示ができるような実装を目指します。

https://youtu.be/nghOJ67R1Dw

実装

実装は以下の手順で進めていきたいと思います。

  1. Model の作成
  2. ViewModel の作成
  3. View の作成
  4. 言語と再生速度のカスタム

1. Model の作成

まずは、読み上げに必要な Model の定義を行います。
コードは以下の通りです。

SpeechSpeed では名前の通り読み上げる速さを slow, medium, fase の三つに分けて、それぞれ enum で管理しています。
speakSpeed は名前の通り読み上げる速さです。後述しますが、 Swift では読み上げる速さを調節できるので、ここで定義した速さを割り当てることができます。
letterInterval では、読み上げるテキストを一文字ずつ表示する際に何秒間隔で文字を表示させるかを定義しています。一文字ずつ表示させる場合、読み上げるテキストと文字が表示される速さがある程度一致していた方が良いのでこのように定義しています。

SpeechSpeed.swift
enum SpeechSpeed: String, CaseIterable, Identifiable {
    case slow = "slow"
    case medium = "medium"
    case fast = "fast"
    
    var id: String { self.rawValue }

    var speakSpeed: Float {
        switch self {
        case .slow:
            return 0.3
        case .medium:
            return 0.5
        case .fast:
            return 0.6
        }
    }
    
    var letterInterval: Double {
        switch self {
        case .slow:
            return 0.08
        case .medium:
            return 0.06
        case .fast:
            return 0.04
        }
    }
    
    var displayName: String {
        switch self {
        case .slow:
            return "Slow"
        case .medium:
            return "Medium"
        case .fast:
            return "Fast"
        }
    }
}

SpeechLanguage では読み上げる際の言語を指定しています。
Apple の公式ページ によると、Swift の読み上げでは「BCP 47」というIETF言語タグによって指定することができます。今回は言語を5つのみに絞って選択できるように enum で管理しています。

SpeechLanguage.swift
enum SpeechLanguage: String, CaseIterable, Identifiable {
    case english = "en-US"
    case japanese = "ja-JP"
    case spanish = "es-ES"
    case french = "fr-FR"
    case german = "de-DE"
    
    var id: String { self.rawValue }
    
    var displayName: String {
        switch self {
        case .english: return "English"
        case .japanese: return "日本語"
        case .spanish: return "Español"
        case .french: return "Français"
        case .german: return "Deutsch"
        }
    }
}

これで使用する Model の作成は完了です。

2. ViewModel の作成

次に読み上げの状態を管理する ViewModel を作成していきます。
コードは以下の通りです。

SpeechViewModel.swift
import SwiftUI
import AVFoundation

@MainActor
class SpeechViewModel: NSObject, ObservableObject {
    static let shared = SpeechViewModel()
    private var synthesizer = AVSpeechSynthesizer()

    @Published var isSpeaking: Bool = false
    @Published var currentLanguage: SpeechLanguage = .english
    
    override init() {
        super.init()
        synthesizer.delegate = self
    }
    
    func speak(text: String, speechSpeed: SpeechSpeed) {
        let speechUtterance = AVSpeechUtterance(string: text)
        
        speechUtterance.voice = AVSpeechSynthesisVoice(language: currentLanguage.rawValue)
        speechUtterance.rate = speechSpeed.speakSpeed()
        synthesizer.speak(speechUtterance)
    }
    
    func setLanguage(_ language: SpeechLanguage) {
        currentLanguage = language
    }
}

extension SpeechViewModel: AVSpeechSynthesizerDelegate {
    nonisolated func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didStart utterance: AVSpeechUtterance) {
        Task { @MainActor in
            self.isSpeaking = true
        }
    }
    
    nonisolated func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didFinish utterance: AVSpeechUtterance) {
        Task { @MainActor in
            self.isSpeaking = false
        }
    }
    
    nonisolated func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didCancel utterance: AVSpeechUtterance) {
        Task { @MainActor in
            self.isSpeaking = false
        }
    }
}

それぞれ詳しくみていきます。

以下では状態管理に必要な変数の定義と初期化処理を行なっています。
shared はこの ViewModel を外部から参照する際に使用します。
AVSpeechSynthesizer はテキストから音声を生成し、再生するためのオブジェクトです。
isSpeaking は現在読み上げ中かどうかのフラグを保持するための変数で、これは外部からアクセスできるように @Published にしています。
currentLanguage は現在選択されている言語を保持する変数で、デフォルトでは english になっています。これも外部からアクセスできるように @Published にしています。

static let shared = SpeechViewModel()
private var synthesizer = AVSpeechSynthesizer()

@Published var isSpeaking: Bool = false
@Published var currentLanguage: SpeechLanguage = .english
    
override init() {
    super.init()
    synthesizer.delegate = self
}

以下では現在選択されている言語を変更するための関数を記述しています。
引数として受け取った言語をそのまま currentLanguage に代入する形で言語を変更しています。

func setLanguage(_ language: SpeechLanguage) {
    currentLanguage = language
}

以下では AVSpeechSynthesizerDelegate の extension を作成して、今テキストを読み上げている最中かどうかを変更する処理を記述しています。
didStart の場合は isSpeaking を true に、 didFinish, didCancel の場合は false に設定しています。このように読み上げの状態によって isSpeaking を切り替えることで、外部から読み上げの状態を参照することができるようになります。
なお、nonisolated キーワードをつけることで別のスレッドで実行されても問題ないようにしています。

extension SpeechViewModel: AVSpeechSynthesizerDelegate {
    nonisolated func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didStart utterance: AVSpeechUtterance) {
        Task { @MainActor in
            self.isSpeaking = true
        }
    }
    
    nonisolated func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didFinish utterance: AVSpeechUtterance) {
        Task { @MainActor in
            self.isSpeaking = false
        }
    }
    
    nonisolated func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didCancel utterance: AVSpeechUtterance) {
        Task { @MainActor in
            self.isSpeaking = false
        }
    }
}

3. View の作成

次に View を作成していきます。
コードは以下の通りです。

SpeechSampleContentView.swift
struct SpeechSampleContentView: View {
    @StateObject private var speechViewModel = SpeechViewModel.shared
    private var textToSpeak: String = "Good morning, sir. It's currently 9:15 AM. The weather in New York is sunny with a temperature of 72°F. Your schedule for today includes a Stark Industries board meeting at 11 AM and a meeting with S.H.I.E.L.D. at 2 PM. You have 3 unread messages from Pepper Potts. Also, it appears there's an urgent communication from Nick Fury."
    @State private var startTextAnimation = false
    
    var body: some View {
        VStack {
            Button(speechViewModel.isSpeaking ? "Speaking..." : "Speak") {
                speechViewModel.speak(text: textToSpeak, speechSpeed: SpeechSpeed.medium)
                startTextAnimation = true
            }
            .disabled(speechViewModel.isSpeaking || startTextAnimation)
            .padding()
            
            TextSpellOutView(
                text: textToSpeak,
                textSize: 45,
                maxWordsPerLine: 8,
                isDecorated: true,
                startAnimation: $startTextAnimation,
                speechSpeed: SpeechSpeed.medium
            )
        }
    }
}

それぞれ詳しくみていきます。

以下では speechViewModel として先ほど作成した SpeechViewModelshared を代入しています。これで ViewModel 側での変更を検知したり、関数を実行したりすることができます。
textToSpeak は読み上げてもらう文章を定義しています。
startTextAnimation はテキストのアニメーションをスタートするかどうかのフラグを定義しています。

@StateObject private var speechViewModel = SpeechViewModel.shared
private var textToSpeak: String = "Good morning, sir." // 省略
@State private var startTextAnimation = false

以下では読み上げを行うボタンを実装しています。
読み上げをしている場合としていない場合で表示させるテキストを変更したり、ボタンを非活性化したりしています。
また、ボタンを押した時の処理として ViewModel 側で定義した speak を実行しています。

Button(speechViewModel.isSpeaking ? "Speaking..." : "Speak") {
    speechViewModel.speak(text: textToSpeak, speechSpeed: SpeechSpeed.medium)
    startTextAnimation = true
}
.disabled(speechViewModel.isSpeaking || startTextAnimation)
.padding()

以下では TextSpellOutView として、一文字ずつテキストを表示させるビューを作成しています。
読み上げの機能自体は完成しているため、簡単に解説していきます。

TextSpellOutView.swift
import SwiftUI

struct TextSpellOutView: View {
    var words: [String]
    let textSize: Double
    let maxWordsPerLine: Int
    let isDecorated: Bool
    let speechSpeed: SpeechSpeed
    
    @Binding var startAnimation: Bool
    @State private var visibleWords: Int = 0
    @State private var visibleChars: Int = 0
    @State private var isAnimationComplete: Bool = false
    @State private var timer: Timer?

    init(
        text: String,
        textSize: Double,
        maxWordsPerLine: Int = 5,
        isDecorated: Bool = false,
        startAnimation: Binding<Bool>,
        speechSpeed: SpeechSpeed
    ) {
        words = text.split(separator: " ").map(String.init)
        self.textSize = textSize
        self.maxWordsPerLine = maxWordsPerLine
        self.isDecorated = isDecorated
        self.speechSpeed = speechSpeed
        self._startAnimation = startAnimation
    }

    var body: some View {
        VStack(alignment: .leading, spacing: 5) {
            ForEach(0..<(words.count / maxWordsPerLine + 1), id: \.self) { lineIndex in
                HStack(spacing: 5) {
                    ForEach(0..<min(maxWordsPerLine, words.count - lineIndex * maxWordsPerLine), id: \.self) { wordIndex in
                        let index = lineIndex * maxWordsPerLine + wordIndex
                        let word = words[index]
                        HStack(spacing: 1) {
                            ForEach(0..<word.count, id: \.self) { charIndex in
                                Text(String(word[word.index(word.startIndex, offsetBy: charIndex)]))
                                    .font(.system(size: textSize))
                                    .foregroundColor(isDecorated ? .cyan : .primary)
                                    .shadow(color: isDecorated ? .cyan.opacity(0.7) : .clear, radius: 5, x: 0, y: 0)
                                    .opacity(isAnimationComplete || index < visibleWords || (index == visibleWords && charIndex < visibleChars) ? 1 : 0)
                            }
                        }
                    }
                }
            }
        }
        .blur(radius: isDecorated ? 0.5 : 0)
        .onChange(of: startAnimation) { _, newValue in
            if newValue {
                beginAnimation(speechSpeed: speechSpeed)
            }
        }
        .onDisappear {
            timer?.invalidate()
        }
    }

    private func beginAnimation(speechSpeed: SpeechSpeed) {
        resetAnimation()
        timer = Timer.scheduledTimer(withTimeInterval: speechSpeed.letterInterval, repeats: true) { _ in
            if visibleWords < words.count {
                if visibleChars < words[visibleWords].count {
                    visibleChars += 1
                } else {
                    visibleWords += 1
                    visibleChars = 0
                }
            } else {
                timer?.invalidate()
                isAnimationComplete = true
                startAnimation = false
            }
        }
    }

    private func resetAnimation() {
        timer?.invalidate()
        visibleWords = 0
        visibleChars = 0
        isAnimationComplete = false
    }
}

以下ではそれぞれの変数を定義しており、役割は以下のコメントのとおりです。

var words: [String]    // 英文を単語ごとで切った場合のワードのリスト
let textSize: Double    // テキストの大きさ
let maxWordsPerLine: Int    // 1行に表示させるワードの数
let isDecorated: Bool    // テキストの装飾をするかどうかのフラグ
let speechSpeed: SpeechSpeed    // 表示させるスピード
    
@Binding var startAnimation: Bool    // アニメーションがスタートしているかどうかのフラグ
@State private var visibleWords: Int = 0    // 表示されているワードの数
@State private var visibleChars: Int = 0    // 表示されている文字数
@State private var isAnimationComplete: Bool = false    // アニメーションが終了したかどうかのフラグ
@State private var timer: Timer?    // アニメーションに使用するタイマー

以下ではアニメーションを開始するための関数を実装しています。
timerwithTimeInterval で○秒後にテキストを順次表示していきます。
表示されているワードと文字数が全体のワードと文字数よりも少ない場合に、表示させる文字を増やしていきます。
また、ワードをすべて表示し終わった段階でタイマーをリセットし、 isAnimationComplete を true に、 startAnimation を false に変更しています。

private func beginAnimation(speechSpeed: SpeechSpeed) {
    resetAnimation()
    timer = Timer.scheduledTimer(withTimeInterval: speechSpeed.letterInterval, repeats: true) { _ in
        if visibleWords < words.count {
            if visibleChars < words[visibleWords].count {
                visibleChars += 1
            } else {
                visibleWords += 1
                visibleChars = 0
            }
        } else {
            timer?.invalidate()
            isAnimationComplete = true
            startAnimation = false
        }
    }
}

以下ではアニメーションをリセットする関数を実装しています。
タイマーのリセット、表示されているワードと文字数のリセット、 isAnimationComplete を false にセットしています。

private func resetAnimation() {
    timer?.invalidate()
    visibleWords = 0
    visibleChars = 0
    isAnimationComplete = false
}

これで記事の初めで示した以下の動画のような実装ができるかと思います。

https://youtu.be/nghOJ67R1Dw

4. 言語と再生速度のカスタム

最後にビュー側から読み上げの言語と再生速度を調節できるようにしてみたいと思います。
SpeechSampleContentView を以下のように変更します。
これで言語やスピードを変更できるようになるかと思います。

SpeechSampleContentView.swift
struct SpeechSampleContentView: View {
    @StateObject private var speechViewModel = SpeechViewModel.shared
    private var textToSpeak: String = "Good morning, sir. It's currently 9:15 AM. The weather in New York is sunny with a temperature of 72°F. Your schedule for today includes a Stark Industries board meeting at 11 AM and a meeting with S.H.I.E.L.D. at 2 PM. You have 3 unread messages from Pepper Potts. Also, it appears there's an urgent communication from Nick Fury."
    @State private var startTextAnimation = false
+   @State private var selectedLanguage: SpeechLanguage = .english
+   @State private var selectedSpeed: SpeechSpeed = .medium
    
    var body: some View {
        VStack {
+           Picker("Language", selection: $selectedLanguage) {
+               ForEach(SpeechLanguage.allCases, id: \.self) { language in
+                   Text(language.displayName).tag(language)
+               }
+           }
+           .pickerStyle(SegmentedPickerStyle())
+           .padding()

+           Picker("Speed", selection: $selectedSpeed) {
+               ForEach(SpeechSpeed.allCases, id: \.self) { speed in
+                   Text(speed.displayName).tag(speed)
+               }
+           }
+           .pickerStyle(SegmentedPickerStyle())
+           .padding()

            Button(speechViewModel.isSpeaking ? "Speaking..." : "Speak") {
+               speechViewModel.setLanguage(selectedLanguage)
+               speechViewModel.speak(text: textToSpeak, speechSpeed: selectedSpeed)
                startTextAnimation = true
            }
            .disabled(speechViewModel.isSpeaking || startTextAnimation)
            .padding()

            TextSpellOutView(
                text: textToSpeak,
                textSize: 45,
                maxWordsPerLine: 8,
                isDecorated: true,
                startAnimation: $startTextAnimation,
                speechSpeed: selectedSpeed
            )
        }
    }
}

実際に実行してみると、読み上げる言語やスピードが Picker の選択によって変わることがわかります。
しかし、言語に関しては文章が翻訳されるわけではないので、英語の文章を日本語の設定で読み上げてもらうと、「日本語っぽく」英語を読み上げるといった挙動になります。
翻訳に関しては WWDC2024 Translation API から翻訳したテキストを読み上げてもらうといったことも可能になるかと思います。

以上です。

今回のコードは以下にまとめておくのでよろしければお使いください。

コード
SpeechLanguage.swift
enum SpeechLanguage: String, CaseIterable, Identifiable {
    case english = "en-US"
    case japanese = "ja-JP"
    case spanish = "es-ES"
    case french = "fr-FR"
    case german = "de-DE"
    
    var id: String { self.rawValue }
    
    var displayName: String {
        switch self {
        case .english: return "English"
        case .japanese: return "日本語"
        case .spanish: return "Español"
        case .french: return "Français"
        case .german: return "Deutsch"
        }
    }
}
SpeechSpeed.swift
enum SpeechSpeed: String, CaseIterable, Identifiable {
    case slow = "slow"
    case medium = "medium"
    case fast = "fast"
    
    var id: String { self.rawValue }

    var speakSpeed: Float {
        switch self {
        case .slow:
            return 0.3
        case .medium:
            return 0.5
        case .fast:
            return 0.6
        }
    }
    
    var letterInterval: Double {
        switch self {
        case .slow:
            return 0.08
        case .medium:
            return 0.06
        case .fast:
            return 0.04
        }
    }
    
    var displayName: String {
        switch self {
        case .slow:
            return "Slow"
        case .medium:
            return "Medium"
        case .fast:
            return "Fast"
        }
    }
}
SpeechViewModel.swift
import SwiftUI
import AVFoundation

@MainActor
class SpeechViewModel: NSObject, ObservableObject {
    static let shared = SpeechViewModel()
    private var synthesizer = AVSpeechSynthesizer()

    @Published var isSpeaking: Bool = false
    @Published var currentLanguage: SpeechLanguage = .english
    
    override init() {
        super.init()
        synthesizer.delegate = self
    }
    
    func speak(text: String, speechSpeed: SpeechSpeed) {
        let speechUtterance = AVSpeechUtterance(string: text)
        
        speechUtterance.voice = AVSpeechSynthesisVoice(language: currentLanguage.rawValue)
        speechUtterance.rate = speechSpeed.speakSpeed
        synthesizer.speak(speechUtterance)
    }
    
    func setLanguage(_ language: SpeechLanguage) {
        currentLanguage = language
    }
}

extension SpeechViewModel: AVSpeechSynthesizerDelegate {
    nonisolated func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didStart utterance: AVSpeechUtterance) {
        Task { @MainActor in
            self.isSpeaking = true
        }
    }
    
    nonisolated func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didFinish utterance: AVSpeechUtterance) {
        Task { @MainActor in
            self.isSpeaking = false
        }
    }
    
    nonisolated func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didCancel utterance: AVSpeechUtterance) {
        Task { @MainActor in
            self.isSpeaking = false
        }
    }
}
SpeechSampleContentView.swift
import SwiftUI

struct SpeechSampleContentView: View {
    @StateObject private var speechViewModel = SpeechViewModel.shared
    private var textToSpeak: String = "Good morning, sir. It's currently 9:15 AM. The weather in New York is sunny with a temperature of 72°F. Your schedule for today includes a Stark Industries board meeting at 11 AM and a meeting with S.H.I.E.L.D. at 2 PM. You have 3 unread messages from Pepper Potts. Also, it appears there's an urgent communication from Nick Fury."
    @State private var startTextAnimation = false
    @State private var selectedLanguage: SpeechLanguage = .english
    @State private var selectedSpeed: SpeechSpeed = .medium
    
    var body: some View {
        VStack {
            Picker("Language", selection: $selectedLanguage) {
                ForEach(SpeechLanguage.allCases, id: \.self) { language in
                    Text(language.displayName).tag(language)
                }
            }
            .pickerStyle(SegmentedPickerStyle())
            .padding()

            Picker("Speed", selection: $selectedSpeed) {
                ForEach(SpeechSpeed.allCases, id: \.self) { speed in
                    Text(speed.displayName).tag(speed)
                }
            }
            .pickerStyle(SegmentedPickerStyle())
            .padding()

            Button(speechViewModel.isSpeaking ? "Speaking..." : "Speak") {
                speechViewModel.setLanguage(selectedLanguage)
                speechViewModel.speak(text: textToSpeak, speechSpeed: selectedSpeed)
                startTextAnimation = true
            }
            .disabled(speechViewModel.isSpeaking || startTextAnimation)
            .padding()

            TextSpellOutView(
                text: textToSpeak,
                textSize: 45,
                maxWordsPerLine: 8,
                isDecorated: true,
                startAnimation: $startTextAnimation,
                speechSpeed: selectedSpeed
            )
        }
    }
}
TextSpellOutView.swift
import SwiftUI

struct TextSpellOutView: View {
    var words: [String]
    let textSize: Double
    let maxWordsPerLine: Int
    let isDecorated: Bool
    let speechSpeed: SpeechSpeed
    
    @Binding var startAnimation: Bool
    @State private var visibleWords: Int = 0
    @State private var visibleChars: Int = 0
    @State private var isAnimationComplete: Bool = false
    @State private var timer: Timer?

    init(
        text: String,
        textSize: Double,
        maxWordsPerLine: Int = 5,
        isDecorated: Bool = false,
        startAnimation: Binding<Bool>,
        speechSpeed: SpeechSpeed
    ) {
        words = text.split(separator: " ").map(String.init)
        self.textSize = textSize
        self.maxWordsPerLine = maxWordsPerLine
        self.isDecorated = isDecorated
        self.speechSpeed = speechSpeed
        self._startAnimation = startAnimation
    }

    var body: some View {
        VStack(alignment: .leading, spacing: 5) {
            ForEach(0..<(words.count / maxWordsPerLine + 1), id: \.self) { lineIndex in
                HStack(spacing: 5) {
                    ForEach(0..<min(maxWordsPerLine, words.count - lineIndex * maxWordsPerLine), id: \.self) { wordIndex in
                        let index = lineIndex * maxWordsPerLine + wordIndex
                        let word = words[index]
                        HStack(spacing: 1) {
                            ForEach(0..<word.count, id: \.self) { charIndex in
                                Text(String(word[word.index(word.startIndex, offsetBy: charIndex)]))
                                    .font(.system(size: textSize))
                                    .foregroundColor(isDecorated ? .cyan : .primary)
                                    .shadow(color: isDecorated ? .cyan.opacity(0.7) : .clear, radius: 5, x: 0, y: 0)
                                    .opacity(isAnimationComplete || index < visibleWords || (index == visibleWords && charIndex < visibleChars) ? 1 : 0)
                            }
                        }
                    }
                }
            }
        }
        .blur(radius: isDecorated ? 0.5 : 0)
        .onChange(of: startAnimation) { _, newValue in
            if newValue {
                beginAnimation(speechSpeed: speechSpeed)
            }
        }
        .onDisappear {
            timer?.invalidate()
        }
    }

    private func beginAnimation(speechSpeed: SpeechSpeed) {
        resetAnimation()
        timer = Timer.scheduledTimer(withTimeInterval: speechSpeed.letterInterval, repeats: true) { _ in
            if visibleWords < words.count {
                if visibleChars < words[visibleWords].count {
                    visibleChars += 1
                } else {
                    visibleWords += 1
                    visibleChars = 0
                }
            } else {
                timer?.invalidate()
                isAnimationComplete = true
                startAnimation = false
            }
        }
    }

    private func resetAnimation() {
        timer?.invalidate()
        visibleWords = 0
        visibleChars = 0
        isAnimationComplete = false
    }
}

まとめ

最後まで読んでいただいてありがとうございました。

今回の実装で、テキストの読み上げは予想以上にあっさりと行えることがわかりました。
先述の Translation のAPIと合わせて、それぞれのユーザーに合った情報を音声として伝えることもできるかと思うので、実装の幅もかなり広がるかと思います。

誤っている点やもっと良い書き方があればご指摘いただければ幸いです。

参考

https://developer.apple.com/documentation/avfaudio/avspeechsynthesizer/

https://masasophi.com/swiftui-automatic-voice/

https://zenn.dev/entaku/articles/c3d90b7f310e1b

Discussion