Case Silva
Case Silva

Reputation: 514

Keep AVSpeechSynthesizer playing if another view is presented modally

I am trying to utilize the AVSpeechSynthesizer in swift/xcode to read out some text. I have it working for the most part. I have it set so that if they go back to the previous screen or to the next screen, the audio will stop. But in my instance, I want the speech to continue if another view is presented modally. As an example, I have an exit button that once clicked presents an "Are you sure you want to exit? y/n" type screen, but I want the audio to continue until they click yes and are taken away. I also have another view that can be presented modally, again, wanting the audio to continue if this is the case.

Does anyone have an idea of how I can keep the speech playing when a view is presented modally over top but stop playing when navigating to another view entirely?

Here is my code so far:

//Press Play/Pause Button
@IBAction func playPauseButtonAction(_ sender: Any) {
    if(isPlaying){
        //pause
        synthesizer.pauseSpeaking(at: AVSpeechBoundary.immediate)
        playPauseButton.setTitle("Play", for: .normal)
    } else {
        if(synthesizer.isPaused){
            //resume playing
            synthesizer.continueSpeaking()
        } else {
            //start playing
            theUtterance = AVSpeechUtterance(string: audioTextLabel.text!)
            theUtterance.voice = AVSpeechSynthesisVoice(language: "en-UK")
            synthesizer.speak(theUtterance)
        }
        playPauseButton.setTitle("Pause", for: .normal)
    }
    isPlaying = !isPlaying
}

//Press Stop Button
@IBAction func stopButtonAction(_ sender: Any) {
    if(isPlaying){
        //stop
        synthesizer.stopSpeaking(at: AVSpeechBoundary.immediate)
        playPauseButton.setTitle("Play", for: .normal)
        isPlaying = !isPlaying
    }
}

//Leave Page
override func viewWillDisappear(_ animated: Bool) {
    super.viewWillDisappear(animated)

    synthesizer.stopSpeaking(at: .immediate)
}

Upvotes: 1

Views: 983

Answers (2)

Case Silva
Case Silva

Reputation: 514

After a lot of research I was able to get the desired functionality. As suggested by Glenn, the correct approach was to get the stopSpeaking to be called in deinit instead of viewWillDisappear. The issue was that using AVSpeechSynthesizer/AVSpeechSynthesizerDelegate normally would create a strong reference to the ViewController and, therefore, deinit would not be called. To solve this I had to create a custom class that inherits from AVSpeechSynthesizerDelegate but uses a weak delegate reference to a custom protocol.

The custom class and protocol:

import UIKit
import AVFoundation

protocol AudioTextReaderDelegate: AnyObject {
    func speechDidFinish()
}

class AudioTextReader: NSObject, AVSpeechSynthesizerDelegate {
    let synthesizer = AVSpeechSynthesizer()

    weak var delegate: AudioTextReaderDelegate!
    //^IMPORTANT to use weak so that strong reference isn't created.

    override init(){
        super.init()
        self.synthesizer.delegate = self
    }

    func startSpeaking(_ toRead: String){
        let utterance = AVSpeechUtterance(string: toRead)
        synthesizer.speak(utterance)
    }

    func resumeSpeaking(){
        synthesizer.continueSpeaking()
    }

    func pauseSpeaking(){
        synthesizer.pauseSpeaking(at: .immediate)
    }

    func stopSpeaking() {
        synthesizer.stopSpeaking(at: .immediate)
    }

    func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didFinish utterance: AVSpeechUtterance) {
        self.delegate.speechDidFinish()
    }
}

And then inheriting and using it in my ViewController:

import UIKit
import AVFoundation

class MyClassViewController: UIViewController, AudioTextReaderDelegate{
    var isPlaying = false

    let audioReader = AudioTextReader()

    let toReadText = "This is the text to speak"

    @IBOutlet weak var playPauseButton: UIButton!
    @IBOutlet weak var stopButton: UIButton!

    override func viewDidLoad() {
        super.viewDidLoad()

        self.audioReader.delegate = self
    }

    //Press Play/Pause Button
    @IBAction func playPauseButtonAction(_ sender: Any) {
        if(isPlaying){
            //Pause
            audioReader.synthesizer.pauseSpeaking(at: .immediate)
            playPauseButton.setTitle("Play", for: .normal)
        } else {
            if(audioReader.synthesizer.isPaused){
                //Resume Playing
                audioReader.resumeSpeaking()
            } else {
                audioReader.startSpeaking(toReadText)
            }
            playPauseButton.setTitle("Pause", for: .normal)
        }
        isPlaying = !isPlaying
    }

    //Press Stop Button
    @IBAction func stopButtonAction(_ sender: Any) {
        if(isPlaying){
            //Change Button Text
            playPauseButton.setTitle("Play", for: .normal)
            isPlaying = !isPlaying
        }
        //Stop
        audioReader.stopSpeaking()
    }

    //Finished Reading
    func speechDidFinish() {
        playPauseButton.setTitle("Play", for: .normal)
        isPlaying = !isPlaying
    }

    //Leave Page
    deinit {
        audioReader.stopSpeaking()
        playPauseButton.setTitle("Play", for: .normal)
        isPlaying = !isPlaying
    }
    @IBAction func NextStopButton(_ sender: Any) {
        audioReader.stopSpeaking()
        playPauseButton.setTitle("Play", for: .normal)
        isPlaying = !isPlaying
    }
}

I hope this helps someone in the future, because this was driving me up the wall.

Upvotes: 0

Glenn Posadas
Glenn Posadas

Reputation: 13281

The problem lies in your viewWillDisappear. Any kind of new screen will trigger this, so your code indeed synthesizer.stopSpeaking(at: .immediate) will be invoked, thus stopping your audio. And that includes presentation or pushing a new controller.

Now, how to improve that? You've mentioned this:

I have it set so that if they go back to the previous screen or to the next screen, the audio will stop

First off, if they go back to the previous screen:

You'd want to execute the same stopping of audio code line inside your deinit { } method. That will let you know that your screen or controller is being erased from the memory, meaning the controller is gone in your controller stacks (the user went back to the previous screen). This should work 100% fine as long as you don't have retain cycle count issue.

Next, to the next screen, easily, you could include the same code line of stopping your audio inside your function for pushing a new screen.

Upvotes: 1

Related Questions