Higlight Speech Utterance in Swiftui

Higlight speech utterance in SwiftUI

I suggest you wrap UILabel in UIViewRepresentable so that you can keep using an attributed string like before:


struct ContentView : View {
@ObservedObject var speaker = Speaker()

var body: some View {
VStack {
LabelRepresented(text: speaker.label)
}.onAppear {
speaker.speak("Hi. This is a test.")
}
}
}

struct LabelRepresented: UIViewRepresentable {
var text : NSAttributedString?

func makeUIView(context: Context) -> UILabel {
return UILabel()
}

func updateUIView(_ uiView: UILabel, context: Context) {
uiView.attributedText = text
}
}

class Speaker: NSObject, ObservableObject, AVSpeechSynthesizerDelegate {
let synth = AVSpeechSynthesizer()
@Published var label: NSAttributedString? // <- change to AttributedString


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

func speak(_ string: String) {
let utterance = AVSpeechUtterance(string: string)
utterance.voice = AVSpeechSynthesisVoice(language: "en-GB")
utterance.rate = 0.4
synth.speak(utterance)
}

// Functions to highlight text
func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, willSpeakRangeOfSpeechString characterRange: NSRange, utterance: AVSpeechUtterance) {
let mutableAttributedString = NSMutableAttributedString(string: utterance.speechString)
mutableAttributedString.addAttribute(.foregroundColor, value: UIColor.red, range: characterRange)
label = mutableAttributedString
}

func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didFinish utterance: AVSpeechUtterance) {
label = NSAttributedString(string: utterance.speechString)
}
}

I changed label into an NSAttributedString? and made it a @Published property on an ObservableObject -- that way, when it changes, the view gets notified right away.

The UIViewRepresentable creates a label and updates it with the attributed string any time it changes.

In the event that you did want to try a more pure SwiftUI approach, this blog posts has some good resources for using NSAttributedString in SwiftUI (including the approach I took): https://swiftui-lab.com/attributed-strings-with-swiftui/

Controlling the loop execution

You could always use Combine for this

import Combine

let speaker = Speaker()
let capitals = ["Canberra is the capital of Australia", "Seoul is the capital of South Korea", "Tokyo is the capital of Japan", "Berlin is the capital of Germany"]
var playerCancellable: AnyCancellable? = nil

Button("Play Sound") {
playSound()
}

func playSound() {
// Fairly standard timer publisher. The call to .autoconnect() tells the timer to start publishing when subscribed to.
let timer = Timer.publish(every: 20, on: .main, in: .default)
.autoconnect()

// Publishers.Zip takes two publishers.
// It will only publish when there is a "symmetrical" output. It behaves in a similar manner as `zip` on sequences.
// So, in this scenario, you will not get the next element of your array until the timer emits another event.
// In the call to sink, we ignore the first element of the tuple relating to the timer
playerCancellable = Publishers.Zip(timer, capitals.publisher)
.sink { _, item in
speaker.speak(item)
}
}

Edit

You mentioned in the comments that you want to be able to variably control the delay between utterances. That's not really something a Timer can be used for. I hacked around a bit because I found it to be an interesting problem and was able to make this work as you describe that you want in the comments:

class Speaker: NSObject {
let synth = AVSpeechSynthesizer()

private var timedPhrases: [(phrase: String, delay: TimeInterval)]
// This is so you don't potentially block the main queue
private let queue = DispatchQueue(label: "Phrase Queue")

override init() {
timed = []
super.init()
synth.delegate = self
}

init(_ timedPhrases: [(phrase: String, delay: TimeInterval)]) {
self.timedPhrases = timedPhrases
super.init()
synth.delegate = self
}

private func speak(_ string: String) {
let utterance = AVSpeechUtterance(string: string)
utterance.voice = AVSpeechSynthesisVoice(language: "en-GB")
utterance.rate = 0.5
synth.speak(utterance)
}

func speak() {
guard let first = timed.first else { return }
speak(first.value)
timed = Array(timed.dropFirst())
}
}

extension Speaker: AVSpeechSynthesizerDelegate {
func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didFinish utterance: AVSpeechUtterance) {
if !timed.isEmpty {
queue.sync {
Thread.sleep(forTimeInterval: TimeInterval(timed.first!.delay))
self.speak()
}
} else {
print("all done")
}
}
}

let speaker = let speaker = Speaker([
(phrase: "1", delay: 0),
(phrase: "2", delay: 3),
(phrase: "3", delay: 1),
(phrase: "4", delay: 5),
(phrase: "5", delay: 10)
])

speaker.speak()

Take this with a huge grain of salt. I don't really consider using Thread.sleep to be a very good practice, but maybe this will give you some ideas on how to approach it. If you want variable timing, a Timer instance is not going to give you that.

How can I get text to wrap in a UILabel (via UIViewRepresentable) without having a fixed width?

Possible solution is to declare the width as a variable on MyTextView:

struct MyTextView: UIViewRepresentable {

var width: CGFloat

func makeUIView(context: Context) -> UILabel {
let label = UILabel()
label.lineBreakMode = .byWordWrapping
label.numberOfLines = 0
label.preferredMaxLayoutWidth = width
label.text = "Here's a lot of text for you to display. It won't fit on the screen."
return label
}

func updateUIView(_ view: UILabel, context: Context) {
}
}

and then use GeometryReader to findout how much space there is avaible and pass it into the intializer:

struct ExampleView: View {

var body: some View {
GeometryReader { geometry in
MyTextView(width: geometry.size.width)
}
}
}


Related Topics



Leave a reply



Submit