Silver/Gold Challenges: Ch. 26 - another way


#1

Another Way.

I wanted to post my solution to the silver and gold challenges because I went about them a slightly different way and thought it might help others who are brand new (like me) to macOS, Swift and xCode. There is a solution posted by @macintacos that works, but it addresses view components from the Document class and that seems to be a little bit in violation of the Model-View-Controller paradigm. So I offer this solution for comment/critique by the community.

First, I didn’t know how to refer to the buttons in code. So like others I figured it out by searching the web and realized that you need to add Interface Builder Outlets to the View Controller and connect them like the text area was connected in the chapter:

@IBOutlet weak var stopButton: NSButton!
@IBOutlet weak var speakButton: NSButton!
@IBOutlet weak var progressBar: NSProgressIndicator!

Then once the buttons and the progress bar were addressable in code I added these functions to the View Controller.

func configureButtonsWhileSpeaking() {
    speakButton.isEnabled = false
    stopButton.isEnabled = true
    progressBar.isHidden = false
}

func configureButtonsWhileStopped() {
    speakButton.isEnabled = true
    stopButton.isEnabled = false
    progressBar.isHidden = true
}

Just like @macintacos original post I configured the buttons using the viewDidLoad() method in the View Controller. This method gets called right after the view is loaded so this sets the buttons to the right visibility before the user clicks anything. I also use this method to configure the delegate for the NSSpeechSynthesizer so that the View Controller is ‘listening’ when the delegate sends back signals.

override func viewDidLoad() {
    super.viewDidLoad()
    configureButtonsWhileStopped()
    speechSynthesizer.delegate = self
}

A significant difference between my approach and others on this blog is how I control the progress bar.

func configureProgressBar(_ input: String) {
    let contentsArray = input.components(separatedBy: " ")
    let wordCount = contentsArray.count
    progressBar.maxValue = Double(wordCount)
}

My approach from the code above is to count the words in the string that is about to be spoken and set the progress bar to that value. The NSSpeechSynthesizer provides a notification before speaking each word so word count makes an easy way to initialize the progress bar and then to fill it.

Next I added the protocol defined functions for the NSSpeechSynthesizerDelegate

func speechSynthesizer(_ sender: NSSpeechSynthesizer, willSpeakWord characterRange: NSRange, of string: String) {
    configureButtonsWhileSpeaking()
    progressBar.increment(by: 1.0)
}

func speechSynthesizer(_ sender: NSSpeechSynthesizer, didFinishSpeaking finishedSpeaking: Bool) {
    if finishedSpeaking {
        configureButtonsWhileStopped()
        progressBar.doubleValue = 0.0
    }
}

When the willSpeakWord signal arrives the buttons are configured and the progress bar is incremented by one word. Similarly, when the didFinishSpeaking signal arrives the buttons are returned to normal and the progress bar is reset to 0.0.

Now when the speakButton is clicked the following code executes:

@IBAction func speakButtonClicked(_ sender: NSButton) {
        if let contents = textView.string, !contents.isEmpty {
            configureProgressBar(contents)
            speechSynthesizer.startSpeaking(contents)
        } else {
            let defaultString = "The document is empty."
            configureProgressBar(defaultString)
            speechSynthesizer.startSpeaking(defaultString)
        }
}

Here we set the progress bar correctly for the default text or the contents of the textView object. I did not have to do anything with the buttons here because the delegate is keeping track of all that.

Interestingly, when the stopButton is clicked it interrupts the NSSpeechSynthesizer and it does not actually finish. So it never sends the didFinishSpeakingSignal. Thus, I call that signal manually.

@IBAction func stopButtonClicked(_ sender: NSButton) {
    speechSynthesizer.stopSpeaking()
    self.speechSynthesizer(speechSynthesizer, didFinishSpeaking: true)
}

I’ve worked through every challenge in the book up to this point and appreciate the way Big Nerd Ranch has taught Swift. Writing this helped me solidify some of the concepts around delegates in my mind…hope it helped you too.

The full code listing for my View Controller class is:

class ViewController: NSViewController, NSSpeechSynthesizerDelegate {
    
    let speechSynthesizer = NSSpeechSynthesizer()
    
    @IBOutlet var textView: NSTextView!
    
    @IBOutlet weak var stopButton: NSButton!
    @IBOutlet weak var speakButton: NSButton!
    @IBOutlet weak var progressBar: NSProgressIndicator!
    
    func configureButtonsWhileSpeaking() {
        speakButton.isEnabled = false
        stopButton.isEnabled = true
        progressBar.isHidden = false
    }
    
    func configureButtonsWhileStopped() {
        speakButton.isEnabled = true
        stopButton.isEnabled = false
        progressBar.isHidden = true
    }
    
    var contents: String? {
        get {
            return textView.string
        }
        set {
            textView.string = newValue
        }
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        configureButtonsWhileStopped()
        speechSynthesizer.delegate = self
    }
    
    func configureProgressBar(_ input: String) {
        let contentsArray = input.components(separatedBy: " ")
        let wordCount = contentsArray.count
        progressBar.maxValue = Double(wordCount)

    }
    
    @IBAction func speakButtonClicked(_ sender: NSButton) {
        if let contents = textView.string, !contents.isEmpty {
            configureProgressBar(contents)
            speechSynthesizer.startSpeaking(contents)
        } else {
            let defaultString = "The document is empty."
            configureProgressBar(defaultString)
            speechSynthesizer.startSpeaking(defaultString)
        }
    }
    
    @IBAction func stopButtonClicked(_ sender: NSButton) {
        speechSynthesizer.stopSpeaking()
        self.speechSynthesizer(speechSynthesizer, didFinishSpeaking: true)
    }
    
    func speechSynthesizer(_ sender: NSSpeechSynthesizer, willSpeakWord characterRange: NSRange, of string: String) {
        configureButtonsWhileSpeaking()
        progressBar.increment(by: 1.0)
    }
    
    func speechSynthesizer(_ sender: NSSpeechSynthesizer, didFinishSpeaking finishedSpeaking: Bool) {
        if finishedSpeaking {
            configureButtonsWhileStopped()
            progressBar.doubleValue = 0.0
        }
    }
}

#2

There is no need for that. Just remove the finishedSpeaking check in the delegate method, it is blocking the drain. :slight_smile:

func speechSynthesizer(_ sender: NSSpeechSynthesizer, didFinishSpeaking finishedSpeaking: Bool) {
      configureButtonsWhileStopped()
      progressBar.doubleValue = 0.0
 }

Also the names of the following functions sound a bit confusing.

func configureButtonsWhileSpeaking () {...}
func configureButtonsWhileStopped ()  {...}

// suggestion
// func configureButtonsForSpeakingState () {...}
// func configureButtonsForStoppedState ()  {...}

Apart from that, it is all good work.

If you like adventure:

  • add a pause/resume function; and

  • make it possible so that speaker’s voice can be changed anywhere in the text.


#3

@ibex10 thanks for the feedback and the code review. I took your suggestions on the function names, and I tried your idea for removing the manual call to the didFinishSpeaking function, but it made the stop button kind of buggy. When I manually call the didFinishSpeaking method after the stop button is pressed the app correctly returns to the correct state. For some reason when I let the signal get called automatically after pressing the stop button it sometimes hangs and doesn’t shift states. Any ideas what could cause this?


#4

What do you mean by “kind of buggy”?

After stopSpeaking() is called, according to the class reference:

If the receiver is currently generating speech, synthesis is halted, and the message speechSynthesizer(_:didFinishSpeaking:) is sent to the delegate.

The delegate can then configure the controls.

func speechSynthesizer (_ sender: NSSpeechSynthesizer, didFinishSpeaking finishedSpeaking: Bool) {
     configureButtonsForStoppedState ()
     progressBar.doubleValue = 0.0
}

#5

@ibex10 I guess I could have been more desciptive about “kind of buggy”. Here is what happens…

  1. There are two “states” for the UI controls: SpeakingState and StoppedState.
  2. I have a delegate set up to “catch” the signal described in the class reference:

If the receiver is currently generating speech, synthesis is halted, and the message speechSynthesizer(_:didFinishSpeaking:) is sent to the delegate.

  1. Then as you suggest I am shifting from the SpeakingState to the StoppedState after receiving that signal. The configuration is buggy in the sense that it does not make the state transition on over half the times I press the stop button when I rely on the speechSynthesizer automatically sending the didFinishSpeaking signal. In contrast it transitions state perfectly when I let the speechSynthesizer actually finish the sentence in the textView. It only gets buggy when I push the stop button. I think there is a bug in the speechSynthesizer class that does not send the didFinishSpeaking in some cases when the synthesizer is halted before it finishes speaking the String that was passed to it.

  2. On the other hand, when I manually send the didFinishSpeaking signal from @IBAction for the stop button it transitions state correctly.

I’d be interested to see if you get the same results if you load and run my code in both configurations.


#6

That is much better :slight_smile:

I am already using your code, with minor modifications. I don’t see the problem you are experiencing.

...
    @IBAction func stopButtonClicked (_ sender: NSButton) {
        speechSynthesizer.stopSpeaking()
    }
...
    func speechSynthesizer (_ sender: NSSpeechSynthesizer, didFinishSpeaking finishedSpeaking: Bool) {
        configureButtonsForStoppedState ()
        progressBar.doubleValue = 0.0
    }

I wonder if there is a problem with the environment in which you run the program.

One of my apps on the AppStore, SpeakTextMultiVoice, uses the same technique. I have not received any bug reports.


#7

The code I came up with was similar to what zacsketches has, but I found with longer text that the word count did not match up with the number of times that the willSpeakWord callback occurred, and the progress bar was either hitting 100% before the speech was finished, or never getting to 100% at all (can’t remember now which it was).

So instead I set the progress bar maxValue to be the character count of the original text

progressBar.maxValue = Double(input.count)

Then in the callback function updated the progress bar with the characterRange location (which is the location within the text of the first character of the word about to be spoken):

progressBar.doubleValue = Double(characterRange.location)

That produced better results for me.


#8

I’d rather calculate the percentage spoken by the synthesizer using the range of the string every time the delegate method is called. That’s cause the NSSpeechSynthesizer actually divides the string to speak into phonems, and it may not correspond to just dividing the string into substrings by using the space as separator.
For example, punctuation is taken into account: “This is it, my friend!” will be divided into these phonems: [“this”, “is”, “it”, “,”, “my”, “friend”, “!”] which results into having the delegate method called 7 times rather than 5 as the proposed implementation will expect. Another example is the word “NSSpeechSynthesizer” which will get divided into these phonems: [“N”, “S”, “Speech”, Synthesizer]

Here’s my solution:

import Cocoa

class ViewController: NSViewController, NSSpeechSynthesizerDelegate {

@IBOutlet weak var textView: NSTextView!
@IBOutlet weak var startSpeakButton: NSButton!
@IBOutlet weak var stopSpeakButton: NSButton!
@IBOutlet weak var speakingProgressIndicator: NSProgressIndicator!

var contents: String {
    get {
        
        return textView.string
    }
    set {
        textView.string = newValue
    }
}

let synth = NSSpeechSynthesizer()


@IBAction func speakButtonClicked(_ sender: NSButton) {
    if !contents.isEmpty {
        stopSpeakButton.isEnabled = true
        speakingProgressIndicator.doubleValue = 0.0
        speakingProgressIndicator.isHidden = false
        synth.startSpeaking(contents)
    }
}
@IBAction func stopButtonClicked(_ sender: NSButton) {
    synth.stopSpeaking()
}

override func awakeFromNib() {
    stopSpeakButton.isEnabled = false
    speakingProgressIndicator.isHidden = true
}

override func viewDidLoad() {
    synth.delegate = self
}

override func viewDidDisappear() {
    synth.delegate = nil
}

// NSSpeechSyntesizerDelegate
func speechSynthesizer(_ sender: NSSpeechSynthesizer, didFinishSpeaking finishedSpeaking: Bool) {
    stopSpeakButton.isEnabled = false
    speakingProgressIndicator.isHidden = true
}

func speechSynthesizer(_ sender: NSSpeechSynthesizer, willSpeakWord characterRange: NSRange, of string: String) {
    
    if let range = Range(characterRange, in: string) {
        let spokenWord = String(string[range])
        let distance = string.distance(from: range.upperBound, to: string.endIndex)
        let percentageDone = Double((string.count - distance)) / Double(string.count) * 100.0
        
        print("Spoken word: \(spokenWord)\nDistance to end of string: \(distance)\nPercentage done: \(percentageDone)%")
        NSAnimationContext.runAnimationGroup({_ in 
            speakingProgressIndicator.doubleValue = percentageDone
        }, completionHandler: {})
    }
    
}

}