Silver/Gold Challenges: Ch. 26

Silver Challenge:

To complete this challenge, first, you have to initiate the state of the ‘Stop’ button when the windowController is created:

/// Document.swift
class Document: NSDocument {
    ...
    override func makeWindowControllers() {
        ...
        viewController.contents = contents
        viewController.stopButton.isEnabled = false
        ...
    }
}

Then, you must go into the the ViewController class and update it’s method so that they update the state of the button when either is clicked (turn ‘Stop’ off when ‘Start’ is still enabled, and vice-versa):

/// ViewController.swift
@IBAction func speakButtonClicked(_ sender: NSButton) {
    if let contents = textView.string {
        speechSynthesizer.startSpeaking(contents)
        stopButton.isEnabled = true
        startButton.isEnabled = false
    } else {
        speechSynthesizer.startSpeaking("The document is empty")
    }
}

@IBAction func stopButtonClicked(_ sender: NSButton) {
    speechSynthesizer.stopSpeaking()
    stopButton.isEnabled = false
    startButton.isEnabled = true
}

Then, conform the ViewController class to the NSSpeechSynthesizerDelegate, and implement one of its (optional) members:

/// ViewController.swift
class ViewController: NSViewController, NSSpeechSynthesizerDelegate {
    ...
    // Need to specify the delegate to handle executing the functions when the conditions are met
    weak var delegate: NSSpeechSynthesizerDelegate?
    ...
    override func viewDidLoad() { // Override this function to ensure delegate is set
        super.viewDidLoad()
        speechSynthesizer.delegate = self
    }
    ...
    // Finally, implement the didFinishSpeaking delegate method
    func speechSynthesizer(_ sender: NSSpeechSynthesizer, didFinishSpeaking finishedSpeaking: Bool) {
        if finishedSpeaking {
            stopButton.isEnabled = false
            startButton.isEnabled = true
        }
    }
}

Gold Challenge

For this one, it took a little bit of work to figure out how to update the progress indicator correctly and ensuring that the values were correct (for both increment and max value of the progress bar).

To do this, I had to:

  • Create a new NSProgressIndicator (of the ‘determinate’ variety) and update its display in the viewDidLoad() method (I put my progress bar at the bottom of the text display).

  • Then, manually set the rate of the speechSynthesizer so that we can determine approximately how fast the speechSynthesizer is speaking (average human-speech is 180-220, according to Apple documentation). I set mine to 200.

@IBOutlet weak var speechProgressBar: NSProgressIndicator!
...
override func viewDidLoad() {
    super.viewDidLoad()
    speechSynthesizer.delegate = self
    speechProgressBar.isHidden = true
    speechSynthesizer.rate = 200.0
}
  • Using the text supplied by the user, determine how many words there are to read aloud, and determine approximately how long it will take to tay. Set that to the max value of the NSProgressIndicator.
@IBAction func speakButtonClicked(_ sender: NSButton) {
    if let contents = textView.string {
        stopButton.isEnabled = true
        startButton.isEnabled = false

        speechProgressBar.maxValue = Double(determineSpeechDuration(text: contents))
        speechProgressBar.isHidden = false
        speechSynthesizer.startSpeaking(contents)
    } else {
        speechSynthesizer.startSpeaking("The document is empty")
    }
}

func determineSpeechDuration(text: String) -> Double {
    let words = text.characters.split(separator: " ").map(String.init)
    let approximateTimeInMinutes = Double(words.count) / Double(speechSynthesizer.rate) * 100
    return approximateTimeInMinutes
}
  • Now you can determine the increment based on the words and the max value of the progress bar and then implement the speechSynthesizer(_:,willSpeakWord:,of:) delegate method that will increment the progress bar whenever it is about to speak a word
func speechSynthesizer(_ sender: NSSpeechSynthesizer, willSpeakWord characterRange: NSRange, of string: String) {
    if let contents = textView.string {
        let words = contents.characters.split(separator: " ").map(String.init)
        let increment = speechProgressBar.maxValue / Double(words.count)
        speechProgressBar.increment(by: increment)
    }
}
  • And finally, set the progress bar value back to 0.0 every time it finishes speaking, or the stop button is clicked.
@IBAction func stopButtonClicked(_ sender: NSButton) {
    ...
    speechProgressBar.isHidden = true
    speechProgressBar.doubleValue = 0.0
}

func speechSynthesizer(_ sender: NSSpeechSynthesizer, didFinishSpeaking finishedSpeaking: Bool) {
    if finishedSpeaking {
        ...
        speechProgressBar.isHidden = true
        speechProgressBar.doubleValue = 0.0
    }
}

This really helped me.
I did managed to get the gold one done using a perform function with delay, but it wasn’t as precise and efficient as your solution.
Thank you.

Glad I could help! Please feel free to share your solution, definitely don’t mind discussing your approach

For the gold challenge, I went about the problem differently from macintacos. The major difference:

func speechSynthesizer(_ sender: NSSpeechSynthesizer, willSpeakWord: NSRange, of: String) {
       progressIndicator.increment(by: Double(willSpeakWord.location) - progressIndicator.doubleValue)
}

This function went in my view controller. Basically, the speech synthesizer will call the above function right before it speaks a word. And it supplies how many characters it’s about to read. So I just increment the progress indicator by that amount, after subtracting how much progress has been made so far. This has the advantage of working at any reading speed, it’s a lot less code, and I think it’s more in the spirit of what the challenge is trying to get us to do: to use delegate methods.

Great solution! I really liked how simple it is.
But it has in flaw: it only works well for strings up to 100 characters. So I tweaked it a little bit and now it works with strings of any length. Here it is:

func speechSynthesizer(_ sender: NSSpeechSynthesizer, willSpeakWord characterRange: NSRange, of string: String)
{
    let incrementValue = progressBar.maxValue * Double(characterRange.location) / Double(string.characters.count) - progressBar.doubleValue
        
    progressBar.increment(by: incrementValue)
}

I really should have thought of that! Thanks for fixing it :slight_smile:

Regarding the silver challenge your solution is exactly what I set out to do.

However, this is the stupid question I have: How do you know the names of the buttons as properties of the view controller? I couldn’t figure out how to address them in my code, so I couldn’t get it to work. I just can’t figure where I find or set the names of those buttons!!! Feeling dumb…

TIA!

My progress indicator is not showing up at all. My code looks similar to the above except that I’m setting doubleValue directly rather than using the .increment(by:) function. I put a print statement into the speechSynthesizer function so I know it’s being called and my doubleValue is being calculated correctly. I even tried switching it over to use the .increment(by:) function but the progress indicator still doesn’t appear. What am i missing?

uncheck the box besides “Indeterminate” hope this will help.

Thanks for the reply. That box is not checked. As I understand it, checking that box would present a slightly different progress indicator. My problem is that I can’t seem to get the progress indicator to display at all. I tried playing with some of the other settings (e.g. - “Can draw concurrently” and “Hidden”) but that didn’t help. I’ve looked at my size settings but there doesn’t seem to be anything there that can help. Any other thoughts or suggestions? Thanks in advance.

How do you know the names of the buttons as properties of the view controller?

Could you tell me, how you could set the enabled property of the two buttons?

Not sure if you figured this out yet, but since you have declared the variables previously in the ViewController, you can then use the names of those buttons to get their .enabled property. This is just standard behavior from the NSButtons that were created.

It helps to go to whatever functions instantiate the view/activate a click event to set the appropriate enabled property (as shown above in the solutions).

Hope this helps.

I have the same problem when I add the constraints to properly place the progress bar.
I place it to the left of the stop button and when I add constraints, the progress indicator
just disappears. Just placeing it where I want it nest to the stop button I have no problem making
it work, showing progress when speaking and disappearing when no speaking.

An alternative method of calculating the value for the progress bar uses the NSRange value passed to the delegate function.

first I set the min & max values

progress.minValue = 0.0
progress.maxValue = contents.characters.count
progress.doubleValue = 0.0

Then in the delegate function - I have to work to break out the starting index of each word to be spoken

func speechSynthesizer(_ sender: NSSpeechSynthesizer, willSpeakWord characterRange: NSRange, of string: String)
{   let stringRange = NSStringFromRange(characterRange)  // convert NSRange to string "{index of first char of word, chars in word}
    var beginRange = stringRange.index(after: stringRange.startIndex) // get leading edge index of number
    var endLocIndex = stringRange.characters.index(of: ",")  // find index of comma
    var endLoc = stringRange.characters.index(before: endLocIndex!) // back up one character
    let newString = stringRange[beginRange...endLoc]  // create new string with only index of first char in word
    var beginRangeInt = Int(stringRange[beginRange...endLoc]) // convert number from string to integer
    progress.doubleValue = Double(beginRangeInt!)  // pass it to the progress bar as a Double
}

Like a couple of others, I was stymied as to how to enable/disable the buttons. I was even more confused upon reading the reply from macintacos, because I could find no current written code that “declared the variables previously.”

So, I had to declare the variables by adding this code to ViewController.swift:

/// ViewController.swift
...
    @IBOutlet var textView: NSTextView!

    // declare variables for the buttons (note that var startButton represents the button that you labelled "Speak" in Main.storyboard)

    @IBOutlet var startButton: NSButton!
    
    @IBOutlet var stopButton: NSButton!
...

Then, you have to connect the @IBOutlet for the buttons. Do this in Main.storyboard like you connected the text view outlet as in the chapter’s section “Connecting the text view outlet.” Remember, here you are doing a control-drag from the view controller icon down to each button in turn. Just select from the drop-down menu the variable name that you gave the button.

Now that you have added code for the button variables and have connected them to your view controller, you can add the code from the Silver Challenge solution offered by macintacos and get no compiler errors!

Cheers,

Jim

Great solution! I was thinking about how to get information from the speechSynthesizer about timing of characters being synchronized and characterRange does that!
However, it has a small bug: Progress indicator doesn’t fill completely.

I added this code and problem fixed:

func speechSynthesizer(_ sender: NSSpeechSynthesizer, willSpeakWord characterRange: NSRange, of string: String) {
    let incrementValue = progressBar.maxValue * Double(characterRange.location) / Double(string.characters.count) - progressBar.doubleValue
    progressBar.increment(by: incrementValue)
    if characterRange.location + 1 == string.characters.count {
        progressBar.increment(by: progressBar.maxValue - progressBar.doubleValue)
    }
}

It checks if synchronization is completed and increments the bar by amount which is left once it is.

characterRange.location + 1 == string.characters.count will work if the last word only contains 1 character.
I think you should change to characterRange.location + characterRange.length == string.characters.count

@macintacos I’m new to Swift programming, and your solution is certainly better than the one I still haven’t figured out yet…but…

Putting code into the Document class that sets the enabled property of a View element doesn’t feel like good MVC. What do you think?

Sorry my error. Disregard