Skip to content
Merged
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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@

.DS_Store
24 changes: 24 additions & 0 deletions Enchanted/Assets.xcassets/AppIcon.appiconset/Contents.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,30 @@
"platform" : "ios",
"size" : "1024x1024"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"filename" : "ios dark.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "tinted"
}
],
"filename" : "ios tint.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"filename" : "latest-16.png",
"idiom" : "mac",
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
31 changes: 20 additions & 11 deletions Enchanted/Extensions/AVSpeechSynthesisVoice+Extension.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,16 +14,25 @@ extension AVSpeechSynthesisVoice {
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"
}
}()

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

// Only append quality for enhanced and premium voices
if let qualityString = self.quality.displayString {
return "\(name) (\(qualityString))"
}

return name
}
}

extension AVSpeechSynthesisVoiceQuality {
var displayString: String? {
switch self {
case .enhanced:
return "Enhanced"
case .premium:
return "Premium"
default:
return nil // Don't show "Default" quality
}
}
}
104 changes: 75 additions & 29 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,57 @@ 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()
}


/// Returns the system's default voice identifier
static func systemDefaultVoiceIdentifier() -> String {
// Get the current locale's language code
let currentLanguage = Locale.current.language.languageCode?.identifier ?? "en"

// Try to find a voice matching the current language
let voices = AVSpeechSynthesisVoice.speechVoices()

// First, try to find a voice for the exact locale
if let localeVoice = voices.first(where: { $0.language.starts(with: currentLanguage) }) {
return localeVoice.identifier
}

// Fall back to the first available voice
return voices.first?.identifier ?? ""
}

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

// If user has set a voice and it's available, use it
if let voiceIdentifier = voiceIdentifier, !voiceIdentifier.isEmpty {
if let voice = voices.first(where: { $0.identifier == voiceIdentifier }) {
return voice.identifier
}
}

return voices.first?.identifier

// Otherwise return the system default voice
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 +91,7 @@ class SpeechSynthesizerDelegate: NSObject, AVSpeechSynthesizerDelegate {
print("❓", error.localizedDescription)
}
#endif

lastCancelation = onFinished
delegate.onSpeechFinished = {
withAnimation {
Expand All @@ -82,38 +104,62 @@ 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 allVoices = AVSpeechSynthesisVoice.speechVoices()

// Get the current system language
let currentLanguage = Locale.current.language.languageCode?.identifier ?? "en"

// Filter voices to current language
let languageFilteredVoices = allVoices.filter { voice in
voice.language.starts(with: currentLanguage)
}

/// prevent state refresh if there are no new elements

// Use language-filtered voices if available, otherwise fall back to all voices
let voicesToProcess = languageFilteredVoices.isEmpty ? allVoices : languageFilteredVoices

// Remove duplicates and sort
var seenVoices: Set<String> = []
let voices = voicesToProcess
.filter { voice in
// Remove duplicates by name + quality combination
let key = "\(voice.name)-\(voice.quality.rawValue)"
if seenVoices.contains(key) {
return false
}
seenVoices.insert(key)
return true
}
.sorted { (firstVoice, secondVoice) -> Bool in
// Sort by quality (higher first), then by name
if firstVoice.quality.rawValue != secondVoice.quality.rawValue {
return firstVoice.quality.rawValue > secondVoice.quality.rawValue
}
return firstVoice.prettyName < secondVoice.prettyName
}

// 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
59 changes: 41 additions & 18 deletions Enchanted/UI/macOS/Chat/Components/InputFields_macOS.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
import SwiftUI

struct InputFieldsView: View {
private let maxTextFieldHeight: CGFloat = 130

@Binding var message: String
var conversationState: ConversationState
var onStopGenerateTap: @MainActor () -> Void
Expand All @@ -20,6 +22,7 @@ struct InputFieldsView: View {
@State private var selectedImage: Image?
@State private var fileDropActive: Bool = false
@State private var fileSelectingActive: Bool = false
@State private var textFieldHeight: CGFloat = 40
@FocusState private var isFocusedInput: Bool

@MainActor private func sendMessage() {
Expand Down Expand Up @@ -69,29 +72,42 @@ struct InputFieldsView: View {
}

ZStack(alignment: .trailing) {
TextField("Message", text: $message.animation(.easeOut(duration: 0.3)), axis: .vertical)
.focused($isFocusedInput)
.font(.system(size: 14))
.frame(maxWidth:.infinity, minHeight: 40)
.clipped()
.textFieldStyle(.plain)
ScrollView {
TextField("Message", text: $message.animation(.easeOut(duration: 0.3)), axis: .vertical)
.focused($isFocusedInput)
.font(.system(size: 14))
.frame(maxWidth:.infinity, minHeight: 40)
.clipped()
.textFieldStyle(.plain)
#if os(macOS)
.onSubmit {
if NSApp.currentEvent?.modifierFlags.contains(.shift) == true {
message += "\n"
} else {
sendMessage()
.onSubmit {
if NSApp.currentEvent?.modifierFlags.contains(.shift) == true {
message += "\n"
} else {
sendMessage()
}
}
}
#endif
/// TextField bypasses drop area
.allowsHitTesting(!fileDropActive)
/// TextField bypasses drop area
.allowsHitTesting(!fileDropActive)
#if os(macOS)
.addCustomHotkeys(hotkeys)
.addCustomHotkeys(hotkeys)
#endif
.padding(.trailing, 80)


.padding(.trailing, 80)
.overlay(
GeometryReader { geometry in
Color.clear
.preference(key: ViewHeightKey.self, value: geometry.size.height)
}
)
.onPreferenceChange(ViewHeightKey.self) { height in
withAnimation {
textFieldHeight = height
}
}
}
.frame(maxHeight: min(textFieldHeight, maxTextFieldHeight))

HStack {
RecordingView(isRecording: $isRecording.animation()) { transcription in
withAnimation(.easeIn(duration: 0.3)) {
Expand Down Expand Up @@ -164,6 +180,13 @@ struct InputFieldsView: View {
}
}

struct ViewHeightKey: PreferenceKey {
static var defaultValue: CGFloat = 40
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = nextValue()
}
}

#Preview {
@State var message = ""
return InputFieldsView(
Expand Down