(If anyone can think of a better title, please edit!)
I noticed that usually, when I call AVSpeechSynthesizer.stopSpeaking(at: .immediate)
, the didCancel
delegate method gets called. However, if I stop the synthesiser at the last syllable, it will instead call didFinish
.
Steps to reproduce:
Create an app with a single button. Hook up the button's IBAction to this method:
let synthesiser = AVSpeechSynthesizer()
@IBAction func click() {
if synthesiser.isSpeaking {
print("Manually stopping")
synthesiser.stopSpeaking(at: .immediate)
} else {
let utterance = AVSpeechUtterance(string: "One Two Three Four Internationalization")
utterance.rate = AVSpeechUtteranceMinimumSpeechRate
utterance.voice = AVSpeechSynthesisVoice(language: "en-US")
synthesiser.delegate = self
synthesiser.speak(utterance)
}
}
Implement these two delegate methods:
func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didFinish utterance: AVSpeechUtterance) {
print("Stopped!")
}
func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didCancel utterance: AVSpeechUtterance) {
print("Cancelled!")
}
Press the button to start synthesising, press again to stop. If you stop at the middle, it would print:
Manually stopping
Cancelled!
which is expected. If you let it speak the whole line, eventually it will print:
Stopped!
which is also expected. However, if you stop it at the last syllable, then it prints:
Manually stopping
Stopped!
I expected the second line to be "Cancelled!", just like stopping in the middle.
Why this is important to me?
In my app, I have a list of utterances (i.e. a playlist) that the user can play through. The user can press buttons to go to the previous or the next utterance. If an utterance finished playing, then the next utterance is automatically played.
// these are properties of the VC
var playlist: Playlist!
var currentIndex = 0 {
didSet {
updateTextView()
}
}
var currentUtterance: Utterance {
return playlist.items[currentIndex]
}
var isPlaying = false
// plays the previous utterance in the playlist
@objc func previousPressed() {
guard currentIndex > 0 else { return }
previous()
}
// plays the next utterance in the playlist
@objc func nextPressed() {
guard currentIndex < playlist.items.count - 1 else { return }
next()
}
func next() {
currentIndex += 1
playCurrentUtterance()
}
func previous() {
currentIndex -= 1
playCurrentUtterance()
}
func playCurrentUtterance() {
speechSynthesiser.stopSpeaking(at: .immediate)
speechSynthesiser.speak(currentUtterance.avUtterance)
isPlaying = true
}
func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didFinish utterance: AVSpeechUtterance) {
if currentIndex >= playlist.items.count - 1 { // this checks whether this is the last utterance
isPlaying = false
} else {
// when one utterance finishes, play the next one
next()
}
}
The bug occurs when the user goes to the next or previous utterance when the synthesiser is speaking the last syllable. Let's say the user goes to the next utterance. speechSynthesiser.stopSpeaking(at: .immediate)
causes didFinish
to be called, so next
is actually called twice (once by nextPressed
and once by didFinish
)! As a result, currentIndex
is incremented twice! playCurrentUtterance
is also called twice which means speechSynthesiser.speak
is called twice. However, the synthesiser can only speak one utterance at a time, and is still speaking the next utterance (as opposed to the next next utterance).
How can I fix this bug?
Add this property to your viewController
:
var manuallyStopping = false
Set it when you call stopSpeaking()
:
manuallyStopping = synthesizer.stopSpeaking(at: .immediate)
Check it in didFinish()
and treat it like didCancel()
if it is set, and then set it it false
.
Also set manuallyStopping
to false
in didCancel()