diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..9bea4330 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ + +.DS_Store diff --git a/Enchanted/Assets.xcassets/AppIcon.appiconset/Contents.json b/Enchanted/Assets.xcassets/AppIcon.appiconset/Contents.json index 8dfc2c05..7913ac82 100644 --- a/Enchanted/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/Enchanted/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -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", diff --git a/Enchanted/Assets.xcassets/AppIcon.appiconset/ios dark.png b/Enchanted/Assets.xcassets/AppIcon.appiconset/ios dark.png new file mode 100644 index 00000000..7bd6c861 Binary files /dev/null and b/Enchanted/Assets.xcassets/AppIcon.appiconset/ios dark.png differ diff --git a/Enchanted/Assets.xcassets/AppIcon.appiconset/ios tint.png b/Enchanted/Assets.xcassets/AppIcon.appiconset/ios tint.png new file mode 100644 index 00000000..7bd6c861 Binary files /dev/null and b/Enchanted/Assets.xcassets/AppIcon.appiconset/ios tint.png differ diff --git a/Enchanted/Extensions/AVSpeechSynthesisVoice+Extension.swift b/Enchanted/Extensions/AVSpeechSynthesisVoice+Extension.swift index 0a031bf0..de241fcc 100644 --- a/Enchanted/Extensions/AVSpeechSynthesisVoice+Extension.swift +++ b/Enchanted/Extensions/AVSpeechSynthesisVoice+Extension.swift @@ -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 + } } } diff --git a/Enchanted/Services/SpeechService.swift b/Enchanted/Services/SpeechService.swift index 9d5aff71..7fe5c332 100644 --- a/Enchanted/Services/SpeechService.swift +++ b/Enchanted/Services/SpeechService.swift @@ -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)") } @@ -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 { @@ -69,7 +91,7 @@ class SpeechSynthesizerDelegate: NSObject, AVSpeechSynthesizerDelegate { print("❓", error.localizedDescription) } #endif - + lastCancelation = onFinished delegate.onSpeechFinished = { withAnimation { @@ -82,18 +104,13 @@ 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 @@ -101,19 +118,48 @@ class SpeechSynthesizerDelegate: NSObject, AVSpeechSynthesizerDelegate { 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 = [] + 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 } diff --git a/Enchanted/UI/macOS/Chat/Components/InputFields_macOS.swift b/Enchanted/UI/macOS/Chat/Components/InputFields_macOS.swift index bca42bf1..b18bb437 100644 --- a/Enchanted/UI/macOS/Chat/Components/InputFields_macOS.swift +++ b/Enchanted/UI/macOS/Chat/Components/InputFields_macOS.swift @@ -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 @@ -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() { @@ -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)) { @@ -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(