Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 16 additions & 13 deletions Enchanted/Extensions/AVSpeechSynthesisVoice+Extension.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,20 +10,23 @@ import AVFoundation

extension AVSpeechSynthesisVoice {
var prettyName: String {
let name = self.name
if name.lowercased().contains("default") || name.lowercased().contains("premium") || name.lowercased().contains("enhanced") {
return name
}

let qualityString = {
switch self.quality.rawValue {
case 1: return "Default"
case 2: return "Enhanced"
case 3: return "Premium"
default: return "Unknown"
}
}()
guard quality == .enhanced || quality == .premium else { return name }

let qualityString = quality.displayString
guard !name.lowercased().contains(qualityString.lowercased()) else { return name }

return "\(name) (\(qualityString))"
}
}

extension AVSpeechSynthesisVoiceQuality {
var displayString: String {
switch self {
case .default: return "Default"
case .enhanced: return "Enhanced"
case .premium: return "Premium"
@unknown default:
return "Unknown"
}
}
}
66 changes: 43 additions & 23 deletions Enchanted/Services/SpeechService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,15 @@ import SwiftUI
class SpeechSynthesizerDelegate: NSObject, AVSpeechSynthesizerDelegate {
var onSpeechFinished: (() -> Void)?
var onSpeechStart: (() -> Void)?

func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didFinish utterance: AVSpeechUtterance) {
onSpeechFinished?()
}

func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didStart utterance: AVSpeechUtterance) {
onSpeechStart?()
}

func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didReceiveError error: Error, for utterance: AVSpeechUtterance, at characterIndex: UInt) {
print("Speech synthesis error: \(error)")
}
Expand All @@ -31,35 +31,41 @@ class SpeechSynthesizerDelegate: NSObject, AVSpeechSynthesizerDelegate {
static let shared = SpeechSynthesizer()
private let synthesizer = AVSpeechSynthesizer()
private let delegate = SpeechSynthesizerDelegate()

@Published var isSpeaking = false
@Published var voices: [AVSpeechSynthesisVoice] = []

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


static func systemDefaultVoiceIdentifier() -> String {
// Type system says this might be nil, but documentation says we'll receive
// the default voice for the system's language & region
return AVSpeechSynthesisVoice(language: nil)?.identifier ?? ""
}

func getVoiceIdentifier() -> String? {
let voiceIdentifier = UserDefaults.standard.string(forKey: "voiceIdentifier")
if let voice = voices.first(where: {$0.identifier == voiceIdentifier}) {
return voice.identifier
}
return voices.first?.identifier

return SpeechSynthesizer.systemDefaultVoiceIdentifier()
}

var lastCancelation: (()->Void)? = {}

func speak(text: String, onFinished: @escaping () -> Void = {}) async {
guard let voiceIdentifier = getVoiceIdentifier() else {
print("could not find identifier")
return
}

print("selected", voiceIdentifier)

#if os(iOS)
let audioSession = AVAudioSession()
do {
Expand All @@ -69,7 +75,7 @@ class SpeechSynthesizerDelegate: NSObject, AVSpeechSynthesizerDelegate {
print("❓", error.localizedDescription)
}
#endif

lastCancelation = onFinished
delegate.onSpeechFinished = {
withAnimation {
Expand All @@ -82,38 +88,52 @@ class SpeechSynthesizerDelegate: NSObject, AVSpeechSynthesizerDelegate {
self.isSpeaking = true
}
}

let utterance = AVSpeechUtterance(string: text)
utterance.voice = AVSpeechSynthesisVoice(identifier: voiceIdentifier)
utterance.rate = 0.5
synthesizer.speak(utterance)

let voices = AVSpeechSynthesisVoice.speechVoices()
voices.forEach { voice in
print("\(voice.identifier) - \(voice.name)")
}
}

func stopSpeaking() async {
withAnimation {
isSpeaking = false
}
lastCancelation?()
synthesizer.stopSpeaking(at: .immediate)
}


func fetchVoices() {
let voices = AVSpeechSynthesisVoice.speechVoices().sorted { (firstVoice: AVSpeechSynthesisVoice, secondVoice: AVSpeechSynthesisVoice) -> Bool in
return firstVoice.quality.rawValue > secondVoice.quality.rawValue
}

let currentLanguage: String = AVSpeechSynthesisVoice.currentLanguageCode()
let voicesByLanguage = Dictionary(grouping: AVSpeechSynthesisVoice.speechVoices(), by: \.language)
// Filter the list to only include voices for the current language
// example language codes: en-US, en-GB, fr-FR
.filter { $0.key.prefix(2) == currentLanguage.prefix(2) }

let voices = voicesByLanguage.values.reduce(
// Start with all voices that exactly match current language & locale
into: voicesByLanguage[currentLanguage, default: []]) { result, voices in
// add one instance of other voices that match the language, uniquing by name & quality
for voice in voices {
if !result.contains(where: { otherVoice in otherVoice.name == voice.name && otherVoice.quality == voice.quality }) {
result.append(voice)
}
}
}
.sorted { $0.prettyName.localizedStandardCompare($1.prettyName) == .orderedAscending }

/// prevent state refresh if there are no new elements
let diff = self.voices.elementsEqual(voices, by: { $0.identifier == $1.identifier })
if diff {
return
}

DispatchQueue.main.async {
self.voices = voices
}
Expand Down
4 changes: 2 additions & 2 deletions Enchanted/UI/Shared/Settings/Settings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@ struct Settings: View {
@AppStorage("ollamaBearerToken") private var ollamaBearerToken: String = ""
@AppStorage("appUserInitials") private var appUserInitials: String = ""
@AppStorage("pingInterval") private var pingInterval: String = "5"
@AppStorage("voiceIdentifier") private var voiceIdentifier: String = ""
@AppStorage("voiceIdentifier") private var voiceIdentifier: String = SpeechSynthesizer.systemDefaultVoiceIdentifier()

@StateObject private var speechSynthesiser = SpeechSynthesizer.shared

@Environment(\.presentationMode) var presentationMode
Expand Down