読書アプリに「読み上げ」を付けてほしい、という要望をもらったとき、最初は数行で終わると思っていました。AVSpeechSynthesizer にテキストを渡せば喋る、それは事実です。ところが実機で試すと、声が妙にこもっていたり、読んでいる箇所が画面のどこか分からなかったり、BGM が突然止まったりと、細かい不満が次々に出てきました。喋らせること自体は簡単でも、「気持ちよく聞ける読み上げ」にするには設計がいくつも要ります。
ここからは、Rork Max が出力するネイティブ Swift アプリに読み上げを組み込むとき、個人開発で実際に手を入れた順番のまままとめます。テキストを喋らせる最小実装から始めて、音声の選び方、発話中の単語ハイライト、オーディオセッションの調整、最後に日本語で特につまずいた点まで進みます。
まず喋らせる、ただし合成器は持ち続ける
最小の実装はこれだけです。ただし一つだけ、初心者が必ず踏む地雷があります。AVSpeechSynthesizer をローカル変数で作ると、喋り終わる前に解放されて無音になります。View の外、あるいは @State で保持されるオブジェクトの中に置くのが鉄則です。
import AVFoundation
import SwiftUI
@MainActor
final class Reader : ObservableObject {
// ❌ func 内のローカル変数にすると発話前に解放されて無音になる
// ✅ プロパティとして保持する
private let synth = AVSpeechSynthesizer ()
func speak ( _ text: String ) {
let utterance = AVSpeechUtterance ( string : text)
utterance.rate = AVSpeechUtteranceDefaultSpeechRate // 0.5 相当
utterance.pitchMultiplier = 1.0
utterance.postUtteranceDelay = 0.2
synth. speak (utterance) // キューに積まれ順番に読まれる
}
func stop () {
synth. stopSpeaking ( at : .immediate)
}
}
speak(_:) は即再生ではなくキューへの追加です。連続で呼ぶと積まれた順に読まれるので、「今の発話を中断して次を読む」ときは先に stopSpeaking(at:) を呼びます。.immediate はその場で止め、.word は今読んでいる単語の切れ目まで読んでから止まります。ユーザーが次の文をタップしたときは .immediate、一時停止ボタンなら pauseSpeaking(at: .word) が自然でした。
声を選ぶ — 端末に入っている音声には品質差がある
ここが体感を最も左右します。AVSpeechSynthesisVoice(language:) で言語だけ指定すると、その言語の「既定の声」が使われますが、これが必ずしも聞きやすい声とは限りません。iOS には圧縮された軽量音声と、追加ダウンロードされた高品質音声(.enhanced や .premium)が混在していて、後者があるなら優先したほうが満足度が上がります。
extension AVSpeechSynthesisVoice {
/// 指定言語で最も品質の高い音声を返す(premium > enhanced > default)
static func bestVoice ( for language: String ) -> AVSpeechSynthesisVoice ? {
let candidates = speechVoices (). filter {
$0 .language. hasPrefix (language) // "ja-JP" と "ja" の両方を拾う
}
// quality は .default(1) < .enhanced(2) < .premium(3)
return candidates. max ( by : { $0 .quality. rawValue < $1 .quality. rawValue })
}
}
// 使用例
let voice = AVSpeechSynthesisVoice. bestVoice ( for : "ja" )
?? AVSpeechSynthesisVoice ( language : "ja-JP" )
utterance.voice = voice
注意したいのは、高品質音声は端末に入っていない場合があることです。ユーザーが「設定 → アクセシビリティ → 読み上げコンテンツ → 声」で追加していなければ、.premium は候補に現れません。私はアプリ内に「より自然な声をダウンロードするには設定から追加してください」という控えめな導線を置き、既定音声でも破綻しないことを最低ラインにしました。品質を前提にした設計にすると、追加していない端末で一気に体験が落ちます。
音声の品質と入手性の関係を整理すると次のようになります。
quality 特徴 入手方法 設計上の扱い
default 軽量・やや機械的 OS 標準で常に存在 フォールバックの下限として保証する
enhanced 自然・容量数十MB 設定から手動ダウンロード あれば優先。無くても破綻させない
premium 最も自然(iOS 16+) 設定から手動ダウンロード あれば最優先。存在は前提にしない
読んでいる単語を光らせる — delegate の range を UI に反映する
読み上げアプリで満足度を決めるのは、実は音声そのものより「今どこを読んでいるか」の可視化でした。AVSpeechSynthesizerDelegate の willSpeakRangeOfSpeechString が、これから読む文字範囲を NSRange で教えてくれます。これを AttributedString の背景色に反映すれば、カラオケのように語が流れます。
final class Reader : NSObject , ObservableObject , AVSpeechSynthesizerDelegate {
@Published var highlightedRange: NSRange ? = nil
@Published var fullText: String = ""
private let synth = AVSpeechSynthesizer ()
override init () {
super . init ()
synth.delegate = self
}
func speak ( _ text: String ) {
fullText = text
let u = AVSpeechUtterance ( string : text)
u.voice = AVSpeechSynthesisVoice. bestVoice ( for : "ja" )
synth. speak (u)
}
// これから読む範囲が通知される(メインスレッドで呼ばれる)
func speechSynthesizer ( _ s: AVSpeechSynthesizer,
willSpeakRangeOfSpeechString characterRange: NSRange,
utterance : AVSpeechUtterance) {
highlightedRange = characterRange
}
func speechSynthesizer ( _ s: AVSpeechSynthesizer,
didFinish utterance: AVSpeechUtterance) {
highlightedRange = nil
}
}
SwiftUI 側では、NSRange を AttributedString のインデックスに変換してハイライトします。ここが日本語で最も詰まる箇所なので、変換は素朴な Int 加算ではなく NSRange → Range<String.Index> の正規変換を通します。
struct ReadingView : View {
@ObservedObject var reader: Reader
var attributed: AttributedString {
var s = AttributedString (reader.fullText)
guard let r = reader.highlightedRange,
let swiftRange = Range (r, in : reader.fullText),
let lower = AttributedString. Index (swiftRange.lowerBound, within : s),
let upper = AttributedString. Index (swiftRange.upperBound, within : s)
else { return s }
s[lower ..< upper].backgroundColor = .yellow. opacity ( 0.5 )
return s
}
var body: some View {
ScrollView { Text (attributed). font (.title3). padding () }
}
}
日本語でハイライトがずれる — UTF-16 と絵文字の落とし穴
ここが今回いちばん時間を溶かした点です。willSpeakRangeOfSpeechString が返す NSRange は UTF-16 コードユニット単位 のオフセットです。Swift の String は書記素クラスタ単位なので、String.index(offsetBy:) に生の location を渡すと、絵文字や結合文字を含む文でずれます。上のコードで Range(nsRange, in: string) を使っているのはこのためで、この初期化子は UTF-16 オフセットを正しく解釈してくれます。ここを自前の整数計算で書くと、絵文字入りのレビュー文で数文字ぶんハイライトが後ろにずれ、「読んでいる場所と光る場所が合わない」というバグになりました。
もう一つ、日本語では単語境界が英語ほど明確でないため、willSpeakRange が返す範囲が文節や1〜数文字単位でまちまちになります。私は「範囲が来たらその範囲だけ光らせ、次で消す」方式に割り切りました。前の範囲を光らせ続けて累積させようとすると、区切りの揺れで見た目が乱れます。来た範囲だけを都度反映するほうが、結果的に自然に見えました。
音楽を止めない、ロック画面でも喋る — オーディオセッション
既定のままだと、読み上げ開始時に他アプリの音楽が止まったり、消音スイッチで無音になったりします。読書のお供に環境音を流している人も多いので、私は「他の音を鳴らしたまま、少しだけ音量を下げて読み上げる(ダッキング)」を選びました。
import AVFoundation
func configureAudioSession () {
let session = AVAudioSession. sharedInstance ()
do {
// .playback = 消音スイッチでも鳴る/ .duckOthers = 他アプリ音を一時的に下げる
try session. setCategory (.playback,
mode : .spokenAudio,
options : [.duckOthers])
try session. setActive ( true )
} catch {
// 失敗しても読み上げ自体は動く。ログだけ残す
print ( "audio session error:" , error)
}
}
.spokenAudio モードは音声コンテンツ向けの最適化で、.duckOthers は読み上げ中だけ他アプリの音量を下げて終わると戻します。BGM を完全に止めたいなら .duckOthers を外し、環境音と混ぜたいだけなら .mixWithOthers に替えます。ロック画面や他アプリ表示中も読み続けたい場合は、.playback カテゴリに加えて Background Modes の Audio を有効化する必要があります。ここはロック画面制御(再生・一時停止)まで作り込むなら、別記事のバックグラウンド音声とロック画面操作の設計 と同じ MPNowPlayingInfoCenter の考え方が流用できます。
Rork Max に投げるときのプロンプト
Rork Max にゼロから生成させる場合、私は機能を細かく言語化してから渡すと安定しました。曖昧に「読み上げ機能を付けて」と言うより、境界を指定したほうが手直しが減ります。
SwiftUI の読書画面に読み上げ機能を追加してください。要件:
- AVSpeechSynthesizer を ObservableObject のプロパティとして保持(ローカル変数にしない)
- 日本語音声は quality が premium > enhanced > default の順で最良のものを選ぶ
- willSpeakRangeOfSpeechString で今読んでいる範囲を @Published し、
Text の AttributedString の背景色でハイライト(NSRange→Range 変換を正しく行う)
- 再生・一時停止(pauseSpeaking(at: .word))・停止ボタンを配置
- オーディオセッションは .playback / .spokenAudio / .duckOthers
生成後、絵文字を含む文でハイライトがずれないかも確認してください。
生成結果はたいてい「動くが合成器をローカル変数にしている」「NSRange を Int 加算で変換している」のどちらかが残るので、その2点を私は毎回チェックしています。生成コードをそのまま信じず、この記事で挙げた落とし穴の箇所だけを重点的に読み直すと、手直しが短時間で済みます。
本番ビルドに載せる前の確認3点
生成コードでも自作でも、実機の本番ビルドに載せる前に私が毎回見る点を挙げます。この順で確認すると、読み上げ特有のトラブルをおおむね回避できます。
合成器がプロパティとして保持されているか。ローカル変数のままだと発話前に解放され、無音というエラーとも言えない不具合になります。まずここを確認します。
NSRange → Range の変換を正規の初期化子で行っているか。絵文字を含む文でハイライトがずれる落とし穴は、ほぼこの一点が原因です。対処は変換方法を差し替えるだけで済みます。
オーディオセッションを設定しているか。未設定だと消音スイッチで無音になり、本番のレビューで「音が出ない」と書かれます。.playback を指定して回避します。
実装して分かった手応え
読み上げは、付けたからといって全員が使う機能ではありません。私自身の読書系の試作では、読み上げを一度でも使った人は全体の約10%ほどでした。ただ、その層の翌週リテンションは使わなかった層の約1.4倍と明確に高く、通勤中や家事の合間に「耳で読む」層が確かにいることが見えました。数字上は小さくても、手を止めずにコンテンツへ触れ続けられる導線は、定着に効いているようです。
次に手を付けるなら、単語ハイライトと連動した自動スクロール(読んでいる行を画面中央に保つ)が、体験の伸びしろが大きい部分です。アクセシビリティ全般の作り込みはVoiceOver と Dynamic Type の実装 と合わせて考えると、読み上げ単体よりも届く範囲が広がります。癒し系アプリで環境音と組み合わせる設計はアンビエント音声のループ設計 も参考になるはずです。
まずは合成器をプロパティとして保持し、既定音声で1文を喋らせるところから始めてみてください。そこさえ越えれば、あとはハイライトと音声品質を一つずつ足していけます。お読みいただきありがとうございました。