【Swift】Swift で読み上げ機能を実装する
初めに
今回は Swift でテキストを読み上げる機能を実装していきます。
テキストの読み上げを実装することでユーザーは画面を注視せずに情報を得ることができるようになり、音声案内や各言語に翻訳されたテキストの読み上げ、文字に代わるフィードバック方法としての活用など様々な場面で利用することができます。
記事の対象者
- Swift, SwiftUI 学習者
- サービスにテキストの読み上げ機能を追加したい方
目的
今回は先述の通り、Swift でテキストの読み上げ機能を実装することを目的とします。
最終的には以下の動画のようにテキストの読み上げと表示ができるような実装を目指します。
実装
実装は以下の手順で進めていきたいと思います。
- Model の作成
- ViewModel の作成
- View の作成
- 言語と再生速度のカスタム
1. Model の作成
まずは、読み上げに必要な Model の定義を行います。
コードは以下の通りです。
SpeechSpeed
では名前の通り読み上げる速さを slow, medium, fase の三つに分けて、それぞれ enum で管理しています。
speakSpeed
は名前の通り読み上げる速さです。後述しますが、 Swift では読み上げる速さを調節できるので、ここで定義した速さを割り当てることができます。
letterInterval
では、読み上げるテキストを一文字ずつ表示する際に何秒間隔で文字を表示させるかを定義しています。一文字ずつ表示させる場合、読み上げるテキストと文字が表示される速さがある程度一致していた方が良いのでこのように定義しています。
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 で管理しています。
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 を作成していきます。
コードは以下の通りです。
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 を作成していきます。
コードは以下の通りです。
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
として先ほど作成した SpeechViewModel
の shared
を代入しています。これで 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
として、一文字ずつテキストを表示させるビューを作成しています。
読み上げの機能自体は完成しているため、簡単に解説していきます。
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? // アニメーションに使用するタイマー
以下ではアニメーションを開始するための関数を実装しています。
timer
の withTimeInterval
で○秒後にテキストを順次表示していきます。
表示されているワードと文字数が全体のワードと文字数よりも少ない場合に、表示させる文字を増やしていきます。
また、ワードをすべて表示し終わった段階でタイマーをリセットし、 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
}
これで記事の初めで示した以下の動画のような実装ができるかと思います。
4. 言語と再生速度のカスタム
最後にビュー側から読み上げの言語と再生速度を調節できるようにしてみたいと思います。
SpeechSampleContentView
を以下のように変更します。
これで言語やスピードを変更できるようになるかと思います。
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 から翻訳したテキストを読み上げてもらうといったことも可能になるかと思います。
以上です。
今回のコードは以下にまとめておくのでよろしければお使いください。
コード
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"
}
}
}
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"
}
}
}
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
}
}
}
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
)
}
}
}
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と合わせて、それぞれのユーザーに合った情報を音声として伝えることもできるかと思うので、実装の幅もかなり広がるかと思います。
誤っている点やもっと良い書き方があればご指摘いただければ幸いです。
参考
Discussion