Search code examples
swiftavspeechsynthesizer

How can I make fix this quite complex bug in my app that uses AVSpeechSynthesizer to play a playlist?


(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?


Solution

  • 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()