Reputation: 514
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
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
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