AVSpeechSynthesizer の delegate メソッド willSpeakRangeOfSpeechString でクラッシュする危険性
AVSpeechSynthesizer の delegate メソッド speechSynthesizer(_:willSpeakRangeOfSpeechString:utterance:) は音声読み上げの際にこれから読み上げる箇所を NSRange で知らせてくれる。
このメソッドを使って読み上げテキスト全体のうち今どこが読まれているのかを知る事ができ、ハイライト表示などにも利用する事ができる。
NSRange の location にはテキストの開始位置、length には長さが入る。
読み上げ中のテキスト全体は AVSpeechUtterance から speechString で取得可能。これから読み上げる部分テキスト取得の具体的な実装は以下
NSRange をそのまま使うパターン、Swift.String を NSString にキャストする
func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, willSpeakRangeOfSpeechString characterRange: NSRange, utterance: AVSpeechUtterance) { let substring = (utterance.speechString as NSString).substring(with: characterRange) print(substring) }
もしくは NSRange を Range<String.Index> に変換して Swift.String をそのまま使うパターン
func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, willSpeakRangeOfSpeechString characterRange: NSRange, utterance: AVSpeechUtterance) { if let range = Range<String.Index>(characterRange, in: utterance.speechString) { let substring = utterance.speechString[range] print(substring) } }
クラッシュの危険性
当該の delegate メソッドは日本語での読み上げではかなり不具合があり、短い文章ならともかく通常の文章では正しい読み上げ位置をレポートしてくれる事はほぼ無く、読み上げ位置が間違っている、スキップする、停止するといった問題がある。*1
また特定の日本語テキストの並び、組み合わせによっては NSRange に不正な値が入ってきてしまいその値を使う事でアプリがクラッシュしてしまうケースがある事が最近分かった。ちなみに問題が確認された日本語テキストは iOS 13.2 では不正な値が入っている現象が解消されており*2、バージョンを遡って確認したところ iOS 12 から iOS 13.1.3 で問題が確認された。
不正な値は NSRange の length が -1 になっている。
本来 NSRange は適切な範囲を取得できない場合は location に NSNotFound、length には 0 が入るはず。(これについてドキュメントソースはみつからず。NSString のAPIの場合は明示的に書かれていたりする https://developer.apple.com/documentation/foundation/nsstring/1416849-range )
NSRange のドキュメントによれば
NSRange - Foundation | Apple Developer Documentation
location は
The start index (0 is the first, as in C arrays).
length は
The number of items in the range (can be 0).
と書かれており、length は 0 を取り得るものの、マイナス値については言及が無く NSNotFound の様な定数も存在していない。
この length に -1 が入った NSRange をそのまま先の substring 取得の実装で使用すると
NSRangeException が発生してアプリはクラッシュしてしまう。
不正な NSRange の原因
NSRangeException のメッセージには Range {7, 18446744073709551615} out of bounds; と書かれていた。
NSRange は location length 共に Int 型であり、最大値は Int.max = NSInteger.max は 9223372036854775807。
NSNotFound もドキュメントによれば同じ値になる。
NSNotFound is now formally defined as NSIntegerMax
NSNotFound - Foundation | Apple Developer Documentation
しかし実際に Exception で表示されている値は 18446744073709551615 これは NSUIntegerMax の値になっている。
つまり NSRange の Int 型に UInt.max が入ってきてしまっている。
Swift なら型の範囲外の値が入ってきた時点で Exception が起きて処理が止まるはず。なぜこういう事態が起きているのか。
NSRange の Swift と Objective-C でのヘッダでの定義を見てみるとヒントがあった。
Swift
public struct _NSRange { public var location: Int public var length: Int public init() public init(location: Int, length: Int) }
Objective-C
typedef struct _NSRange { NSUInteger location; NSUInteger length; } NSRange;
これまでの Objective-C の世界で NSUInteger で動いていたものを Swift の世界では Int で扱っている。型が変わってしまっている!なぜこんな事に。
NSRange のドキュメントを見ると
length
The number of items in the range (can be 0). For type compatibility with the rest of the system, LONG_MAX is the maximum value you should use for length.location
The start index (0 is the first, as in C arrays). For type compatibility with the rest of the system, LONG_MAX is the maximum value you should use for location.
NSRange - Foundation | Apple Developer Documentation
この様に最大値は LONG_MAX == 9223372036854775807
つまり 64bit の IntergerMax である(にすべき)と記述されている。
それ以上の数値を扱う想定がされていない。そこで Swift への移行を契機に(定義のシンプルさも考慮して)Int 型に変更になったのだと思われる。
NSRange の location length の Objective-C Swift それぞれの型と想定される値の取り得る範囲は以下になる。
| min | max | |
|---|---|---|
| Objective-C Type | 0 | 18446744073709551615 |
| Usage | 0 | 9223372036854775807 |
| Swift Type | -9223372036854775808 | 9223372036854775807 |
9223372036854775807 == NSNotFound == Int64.max
18446744073709551615 == UInt64.max
回り道になったけど本題に戻って、問題の原因は NSRange の仕様上の最大値が Int64.max であるにも関わらず型としての最大値 UInt64.max が指定されてしまう事があるという点になる。
おそらく AVFoundation の Objective-C 側の処理で何かしらの例外が発生した際に NSRange の NSUInteger length; に -1 を入れてオーバーフローで NSUIntegerMax になってしまったか、直接 NSUIntegerMax を入れてしまったか、そんなところだろうと推測される。*3
対策
NSRange の取り得る範囲をチェックして有効な時のみ処理を行う様にする。*4
型としての値の範囲チェックのついでに NSNotFound でない事もチェックしておいてもいいかも知れない。
簡便に以下の様に NSNotFound の場合も処理を行わない様に実装する事もできる。
if 0 <= characterRange.length, characterRange.length < NSNotFound { let substring = (utterance.speechString as NSString).substring(with: characterRange) print(substring) }
他の実装方法として NSRange の有効性をチェックする extension を書く事もできる。個人的には純粋な値の有効性のチェックは公式ドキュメントの仕様ベースで実装し、 NSNotFound かどうかのチェックは extension には含めない様にして extension の外でエラー処理を分けた方がいい気がする。
extension の実装
extension NSRange { var isValid: Bool { if 0 <= location, location <= LONG_MAX, 0 <= length, length <= LONG_MAX { return true } return false } }
extension の利用
guard characterRange.isValid else { print("invalid range") return } guard characterRange.location != NSNotFound, characterRange.length != NSNotFound else { print("location or length are not found") return } let substring = (utterance.speechString as NSString).substring(with: characterRange) print(substring)
以上