diff --git a/Enchanted.xcodeproj/project.pbxproj b/Enchanted.xcodeproj/project.pbxproj index 74d5d68..c861b7d 100644 --- a/Enchanted.xcodeproj/project.pbxproj +++ b/Enchanted.xcodeproj/project.pbxproj @@ -74,10 +74,13 @@ FF9C920C2BF0C088004C8275 /* OptionsMenuView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF9C920B2BF0C088004C8275 /* OptionsMenuView.swift */; }; FFB0327D2B312F3D0066A9DB /* RecordingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFB0327C2B312F3D0066A9DB /* RecordingView.swift */; }; FFB21A872B7BD0BA00D148A4 /* KeyboardReadable+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFB21A862B7BD0BA00D148A4 /* KeyboardReadable+Extension.swift */; }; + FFB56F432C0353CF0020AFFD /* ReadingAloudView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFB56F422C0353CF0020AFFD /* ReadingAloudView.swift */; }; + FFB56F462C0383B80020AFFD /* ConversationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFB56F452C0383B80020AFFD /* ConversationView.swift */; }; FFBBF4842B34881B008D611C /* SpeechRecogniser.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFBBF4832B34881B008D611C /* SpeechRecogniser.swift */; }; FFBBF4882B34F9C8008D611C /* View+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFBBF4872B34F9C8008D611C /* View+Extension.swift */; }; FFBBF48A2B350283008D611C /* SelectedImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFBBF4892B350283008D611C /* SelectedImageView.swift */; }; FFBBF48C2B35051D008D611C /* UIImage+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFBBF48B2B35051D008D611C /* UIImage+Extension.swift */; }; + FFCF00F12C03209A00590E79 /* SpeechService.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFCF00F02C03209A00590E79 /* SpeechService.swift */; }; FFD57E302BF29145003FEFF1 /* MarkdownColours.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFD57E2F2BF29145003FEFF1 /* MarkdownColours.swift */; }; FFD57E322BF291B2003FEFF1 /* CodeBlockView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFD57E312BF291B2003FEFF1 /* CodeBlockView.swift */; }; FFD5FAD22B8130490055AB51 /* Vortex in Frameworks */ = {isa = PBXBuildFile; productRef = FF464B122B8026DA008E5130 /* Vortex */; }; @@ -164,11 +167,14 @@ FF9C920B2BF0C088004C8275 /* OptionsMenuView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OptionsMenuView.swift; sourceTree = ""; }; FFB0327C2B312F3D0066A9DB /* RecordingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordingView.swift; sourceTree = ""; }; FFB21A862B7BD0BA00D148A4 /* KeyboardReadable+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "KeyboardReadable+Extension.swift"; sourceTree = ""; }; + FFB56F422C0353CF0020AFFD /* ReadingAloudView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadingAloudView.swift; sourceTree = ""; }; + FFB56F452C0383B80020AFFD /* ConversationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationView.swift; sourceTree = ""; }; FFBBF4822B348345008D611C /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; FFBBF4832B34881B008D611C /* SpeechRecogniser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpeechRecogniser.swift; sourceTree = ""; }; FFBBF4872B34F9C8008D611C /* View+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+Extension.swift"; sourceTree = ""; }; FFBBF4892B350283008D611C /* SelectedImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectedImageView.swift; sourceTree = ""; }; FFBBF48B2B35051D008D611C /* UIImage+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIImage+Extension.swift"; sourceTree = ""; }; + FFCF00F02C03209A00590E79 /* SpeechService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpeechService.swift; sourceTree = ""; }; FFD57E2F2BF29145003FEFF1 /* MarkdownColours.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownColours.swift; sourceTree = ""; }; FFD57E312BF291B2003FEFF1 /* CodeBlockView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodeBlockView.swift; sourceTree = ""; }; FFE21C772B82353A00A69B9C /* SleepTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SleepTest.swift; sourceTree = ""; }; @@ -221,6 +227,7 @@ FFBBF4892B350283008D611C /* SelectedImageView.swift */, FF9300DD2B782A28000859F4 /* UnreachableAPIView.swift */, FFB0327B2B312F310066A9DB /* Recorder */, + FFB56F422C0353CF0020AFFD /* ReadingAloudView.swift */, ); path = Components; sourceTree = ""; @@ -402,6 +409,14 @@ path = Recorder; sourceTree = ""; }; + FFB56F442C0383A60020AFFD /* Conversation */ = { + isa = PBXGroup; + children = ( + FFB56F452C0383B80020AFFD /* ConversationView.swift */, + ); + path = Conversation; + sourceTree = ""; + }; FFD57E2E2BF2901A003FEFF1 /* ChatMessages */ = { isa = PBXGroup; children = ( @@ -502,6 +517,7 @@ FF10023D2B24F4900011A4DC /* OllamaService.swift */, FF10024F2B25C79F0011A4DC /* SwiftDataService.swift */, FF6B7B122B3EE7AC00E8FEA3 /* Throttler.swift */, + FFCF00F02C03209A00590E79 /* SpeechService.swift */, ); path = Services; sourceTree = ""; @@ -520,11 +536,12 @@ FFEC32A72B247896003E5C04 /* Shared */ = { isa = PBXGroup; children = ( - FF15EF682B826BF400D4A541 /* Components */, + FF38F8522B7AA9C400546B56 /* ApplicationEntry.swift */, FFEC32A82B24795A003E5C04 /* Chat */, + FF15EF682B826BF400D4A541 /* Components */, + FFB56F442C0383A60020AFFD /* Conversation */, FF10026B2B2751630011A4DC /* Settings */, FF1002552B2624790011A4DC /* Sidebar */, - FF38F8522B7AA9C400546B56 /* ApplicationEntry.swift */, ); path = Shared; sourceTree = ""; @@ -637,6 +654,7 @@ FF24B30E2B66BE8500AB618F /* RunningBorder.swift in Sources */, FF1002602B26499B0011A4DC /* ConversationStatusView.swift in Sources */, FF10024C2B25BEA00011A4DC /* MessageSD.swift in Sources */, + FFCF00F12C03209A00590E79 /* SpeechService.swift in Sources */, FF10023E2B24F4900011A4DC /* OllamaService.swift in Sources */, FFBBF48C2B35051D008D611C /* UIImage+Extension.swift in Sources */, FF1002302B2482BA0011A4DC /* ChatMessageView.swift in Sources */, @@ -647,6 +665,7 @@ FF66A51D2B76949A00FAAC1E /* Helpers.swift in Sources */, FF10026A2B2731C60011A4DC /* ModelSelectorView.swift in Sources */, FF38F84F2B7A7B6700546B56 /* MenuBarControlView_macOS.swift in Sources */, + FFB56F462C0383B80020AFFD /* ConversationView.swift in Sources */, FFBBF48A2B350283008D611C /* SelectedImageView.swift in Sources */, FFEC9BDF2B8131B900AFBA63 /* HotKeys.swift in Sources */, FF2F03492B796A6500349855 /* HotkeyService.swift in Sources */, @@ -657,6 +676,7 @@ FF6D82202B916CC3001183A8 /* CompletionsEditor.swift in Sources */, FF38F85C2B7ABC2C00546B56 /* PromptPanel.swift in Sources */, FFEC9BE12B81327B00AFBA63 /* DragAndDrop.swift in Sources */, + FFB56F432C0353CF0020AFFD /* ReadingAloudView.swift in Sources */, FF2F03442B79631800349855 /* Button+Extension.swift in Sources */, FF1002682B2668790011A4DC /* Date+Extension.swift in Sources */, FF6D821E2B916053001183A8 /* UpsertCompletionView.swift in Sources */, @@ -841,7 +861,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 30; + CURRENT_PROJECT_VERSION = 31; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = "\"Enchanted/Preview Content\""; DEVELOPMENT_TEAM = JDDZ55DT74; @@ -867,7 +887,7 @@ LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 1.6.6; + MARKETING_VERSION = 1.6.7; PRODUCT_BUNDLE_IDENTIFIER = subj.Enchanted; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -891,7 +911,7 @@ CODE_SIGN_ENTITLEMENTS = Enchanted/Enchanted.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 30; + CURRENT_PROJECT_VERSION = 31; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = "\"Enchanted/Preview Content\""; DEVELOPMENT_TEAM = JDDZ55DT74; @@ -917,7 +937,7 @@ LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 1.6.6; + MARKETING_VERSION = 1.6.7; PRODUCT_BUNDLE_IDENTIFIER = subj.Enchanted; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; diff --git a/Enchanted/Enchanted.entitlements b/Enchanted/Enchanted.entitlements index 625af03..2b6edc3 100644 --- a/Enchanted/Enchanted.entitlements +++ b/Enchanted/Enchanted.entitlements @@ -4,6 +4,8 @@ com.apple.security.app-sandbox + com.apple.security.device.audio-input + com.apple.security.files.user-selected.read-only com.apple.security.network.client diff --git a/Enchanted/EnchantedDebug.entitlements b/Enchanted/EnchantedDebug.entitlements index 625af03..2b6edc3 100644 --- a/Enchanted/EnchantedDebug.entitlements +++ b/Enchanted/EnchantedDebug.entitlements @@ -4,6 +4,8 @@ com.apple.security.app-sandbox + com.apple.security.device.audio-input + com.apple.security.files.user-selected.read-only com.apple.security.network.client diff --git a/Enchanted/Info.plist b/Enchanted/Info.plist index d2f52fa..c67d59a 100644 --- a/Enchanted/Info.plist +++ b/Enchanted/Info.plist @@ -2,6 +2,8 @@ + NSSpeechRecognitionUsageDescription + You can speak with enchanted with your voice. NSAccessibilityUsageDescription Enchanted can perform operatios on selected text such as fixing grammar, extending texts as well as custom commands. NSAppTransportSecurity diff --git a/Enchanted/Services/SpeechService.swift b/Enchanted/Services/SpeechService.swift new file mode 100644 index 0000000..98bfd09 --- /dev/null +++ b/Enchanted/Services/SpeechService.swift @@ -0,0 +1,112 @@ +// +// SpeechService.swift +// Enchanted +// +// Created by Augustinas Malinauskas on 26/05/2024. +// + +import Foundation +import AVFoundation +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)") + } +} + +@MainActor final class SpeechSynthesizer: NSObject, ObservableObject { + 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 + voices = listAllVoices() + } + + 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 + } + + 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 { + try audioSession.setCategory(.playback, mode: .default, options: .duckOthers) + try audioSession.setActive(false) + } catch let error { + print("❓", error.localizedDescription) + } +#endif + + lastCancelation = onFinished + delegate.onSpeechFinished = { + withAnimation { + self.isSpeaking = false + } + onFinished() + } + delegate.onSpeechStart = { + withAnimation { + 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 listAllVoices() -> [AVSpeechSynthesisVoice] { + let voices = AVSpeechSynthesisVoice.speechVoices().sorted { (firstVoice: AVSpeechSynthesisVoice, secondVoice: AVSpeechSynthesisVoice) -> Bool in + return firstVoice.quality.rawValue > secondVoice.quality.rawValue + } + return voices + } +} diff --git a/Enchanted/UI/Shared/Chat/Components/ChatMessages/ChatMessageView.swift b/Enchanted/UI/Shared/Chat/Components/ChatMessages/ChatMessageView.swift index 4db5653..93b3f4b 100644 --- a/Enchanted/UI/Shared/Chat/Components/ChatMessages/ChatMessageView.swift +++ b/Enchanted/UI/Shared/Chat/Components/ChatMessages/ChatMessageView.swift @@ -12,13 +12,15 @@ import ActivityIndicatorView struct ChatMessageView: View { @Environment(\.colorScheme) var colorScheme + @StateObject private var speechSynthesizer = SpeechSynthesizer.shared var message: MessageSD var showLoader: Bool = false var userInitials: String @Binding var editMessage: MessageSD? @State private var mouseHover = false + @State private var isSpeaking = false - var roleName: String { + var roleName: String { let userInitialsNotEmpty = userInitials != "" ? userInitials : "AM" return message.role == "user" ? userInitialsNotEmpty.uppercased() : "AI" } @@ -74,7 +76,9 @@ struct ChatMessageView: View { } Markdown(message.content) +#if os(macOS) .textSelection(.enabled) +#endif .markdownCodeSyntaxHighlighter(.splash(theme: codeHighlightColorScheme)) .markdownTheme(MarkdownColours.enchantedTheme) @@ -102,19 +106,56 @@ struct ChatMessageView: View { #if os(macOS) HStack(spacing: 0) { + /// Copy button Button(action: {Clipboard.shared.setString(message.content)}) { Image(systemName: "doc.on.doc") + .padding(8) + } + .buttonStyle(GrowingButton()) + .clipShape(RoundedRectangle(cornerRadius: 10)) + .showIf(mouseHover) + + /// Play button + Button(action: { + Task { + await speechSynthesizer.stopSpeaking() + await speechSynthesizer.speak(text: message.content, onFinished: { isSpeaking = false }) + DispatchQueue.main.asyncAfter(deadline: .now()+0.1) { + isSpeaking = true + } + } + }) { + Image(systemName: "speaker.wave.2.fill") + .frame(width: 10) + .padding(8) + } + .buttonStyle(GrowingButton()) + .clipShape(RoundedRectangle(cornerRadius: 10)) + .showIf(mouseHover) + .showIf(!isSpeaking) + + /// Stop button + Button(action: { + Task { + isSpeaking = false + await speechSynthesizer.stopSpeaking() + } + }) { + Image(systemName: "speaker.slash.fill") + .frame(width: 10) + .padding(8) } .buttonStyle(GrowingButton()) - .padding(8) .clipShape(RoundedRectangle(cornerRadius: 10)) .showIf(mouseHover) + .showIf(isSpeaking) + /// Edit button Button(action: {editMessage = message}) { Image(systemName: "pencil") + .padding(8) } .buttonStyle(GrowingButton()) - .padding(8) .clipShape(RoundedRectangle(cornerRadius: 10)) .showIf(mouseHover) .showIf(message.role == "user") diff --git a/Enchanted/UI/Shared/Chat/Components/ConversationStatusView.swift b/Enchanted/UI/Shared/Chat/Components/ConversationStatusView.swift index d88cfd6..98a137e 100644 --- a/Enchanted/UI/Shared/Chat/Components/ConversationStatusView.swift +++ b/Enchanted/UI/Shared/Chat/Components/ConversationStatusView.swift @@ -12,14 +12,7 @@ struct ConversationStatusView: View { var state: ConversationState var body: some View { switch state { - case .loading: HStack { - Spacer() - ActivityIndicatorView(isVisible: .constant(true), type: .opacityDots(count: 1, inset: 4)) - .frame(width: 21, height: 21) - .foregroundColor(Color.labelCustom) - - Spacer() - } + case .loading: EmptyView() case .completed: EmptyView() case .error(let message): HStack { Text(message) diff --git a/Enchanted/UI/Shared/Chat/Components/EmptyConversaitonView.swift b/Enchanted/UI/Shared/Chat/Components/EmptyConversaitonView.swift index fa48011..5fc77d3 100644 --- a/Enchanted/UI/Shared/Chat/Components/EmptyConversaitonView.swift +++ b/Enchanted/UI/Shared/Chat/Components/EmptyConversaitonView.swift @@ -44,7 +44,11 @@ struct EmptyConversaitonView: View, KeyboardReadable { LazyVGrid(columns: columns, alignment: .leading, spacing: 15) { ForEach(0.. (MessageSD) -> Void { return { message in @@ -24,69 +25,91 @@ struct MessageListView: View { } } + func onReadAloud(_ message: String) { + Task { + await speechSynthesizer.speak(text: message) + } + } + + func stopReadingAloud() { + Task { + await speechSynthesizer.stopSpeaking() + } + } + var body: some View { - ScrollViewReader { scrollViewProxy in - ScrollView { - ForEach(messages) { message in - - let contextMenu = ContextMenu(menuItems: { - Button(action: {Clipboard.shared.setString(message.content)}) { - Label("Copy", systemImage: "doc.on.doc") - } + VStack(spacing: 0) { + ReadingAloudView(onStopTap: stopReadingAloud) + .showIf(speechSynthesizer.isSpeaking) + + ScrollViewReader { scrollViewProxy in + ScrollView { + ForEach(messages) { message in + let contextMenu = ContextMenu(menuItems: { + Button(action: {Clipboard.shared.setString(message.content)}) { + Label("Copy", systemImage: "doc.on.doc") + } + #if os(iOS) - Button(action: { messageSelected = message }) { - Label("Select Text", systemImage: "selection.pin.in.out") - } -#endif - - if message.role == "user" { - Button(action: { - withAnimation { editMessage = message } - }) { - Label("Edit", systemImage: "pencil") + Button(action: { messageSelected = message }) { + Label("Select Text", systemImage: "selection.pin.in.out") } - } - - if editMessage?.id == message.id { + Button(action: { - withAnimation { editMessage = nil } + onReadAloud(message.content) }) { - Label("Unselect", systemImage: "pencil") + Label("Read Aloud", systemImage: "speaker.wave.3.fill") + } +#endif + + if message.role == "user" { + Button(action: { + withAnimation { editMessage = message } + }) { + Label("Edit", systemImage: "pencil") + } + } + + if editMessage?.id == message.id { + Button(action: { + withAnimation { editMessage = nil } + }) { + Label("Unselect", systemImage: "pencil") + } } - } - }) - - ChatMessageView( - message: message, - showLoader: conversationState == .loading && messages.last == message, - userInitials: userInitials, - editMessage: $editMessage - ) - .listRowInsets(EdgeInsets()) - .listRowSeparator(.hidden) - .padding(.vertical, 10) - .contextMenu(contextMenu) - .padding(.horizontal, 10) - .runningBorder(animated: message.id == editMessage?.id) - .id(message) + }) + + ChatMessageView( + message: message, + showLoader: conversationState == .loading && messages.last == message, + userInitials: userInitials, + editMessage: $editMessage + ) + .listRowInsets(EdgeInsets()) + .listRowSeparator(.hidden) + .padding(.vertical, 10) + .contextMenu(contextMenu) + .padding(.horizontal, 10) + .runningBorder(animated: message.id == editMessage?.id) + .id(message) + } + } + .onAppear { + scrollViewProxy.scrollTo(messages.last, anchor: .bottom) + } + .onChange(of: messages) { oldMessages, newMessages in + scrollViewProxy.scrollTo(messages.last, anchor: .bottom) + } + .onChange(of: messages.last?.content) { + scrollViewProxy.scrollTo(messages.last, anchor: .bottom) } - } - .textSelection(.enabled) - .onAppear { - scrollViewProxy.scrollTo(messages.last, anchor: .bottom) - } - .onChange(of: messages) { oldMessages, newMessages in - scrollViewProxy.scrollTo(messages.last, anchor: .bottom) - } - .onChange(of: messages.last?.content) { - scrollViewProxy.scrollTo(messages.last, anchor: .bottom) - } #if os(iOS) - .sheet(item: $messageSelected) { message in - SelectTextSheet(message: message) - } + .sheet(item: $messageSelected) { message in + SelectTextSheet(message: message) + } #endif + } } } } diff --git a/Enchanted/UI/Shared/Chat/Components/ReadingAloudView.swift b/Enchanted/UI/Shared/Chat/Components/ReadingAloudView.swift new file mode 100644 index 0000000..e776024 --- /dev/null +++ b/Enchanted/UI/Shared/Chat/Components/ReadingAloudView.swift @@ -0,0 +1,49 @@ +// +// ReadingAloudView.swift +// Enchanted +// +// Created by Augustinas Malinauskas on 26/05/2024. +// + +import SwiftUI + +struct ReadingAloudView: View { + var onStopTap: () -> () + @State private var animationsRunning = false + + var body: some View { + HStack { + + Text("Reading Aloud") + .font(.system(size: 14)) + + Image(systemName: "speaker.wave.3") + .symbolEffect(.variableColor.iterative, options: .repeat(100), value: animationsRunning) + .scaledToFit() + .frame(width: 18) + + Spacer() + + Button(action: onStopTap) { + Image(systemName: "stop.fill") + .foregroundColor(.black) + .font(.system(size: 15, weight: .semibold)) + .padding(5) + } + .buttonStyle(GrowingButton()) + + } + .padding() + .background { + RoundedRectangle(cornerRadius: 8).fill(Color.gray5Custom) + } + .padding() + .onAppear { + animationsRunning = true + } + } +} + +#Preview { + ReadingAloudView(onStopTap: {}) +} diff --git a/Enchanted/UI/Shared/Chat/Components/Recorder/RecordingView.swift b/Enchanted/UI/Shared/Chat/Components/Recorder/RecordingView.swift index 4c40264..b3a635f 100644 --- a/Enchanted/UI/Shared/Chat/Components/Recorder/RecordingView.swift +++ b/Enchanted/UI/Shared/Chat/Components/Recorder/RecordingView.swift @@ -5,12 +5,11 @@ // Created by Augustinas Malinauskas on 18/12/2023. // -#if os(iOS) import SwiftUI import AVFoundation struct RecordingView: View { - @StateObject var speechRecognizer: SpeechRecognizer + @StateObject var speechRecognizer: SpeechRecognizer = SpeechRecognizer() @Binding var isRecording: Bool var onComplete: (_ transcription: String) -> () = {_ in} @@ -28,10 +27,11 @@ struct RecordingView: View { if isRecording { speechRecognizer.stopTranscribing() onComplete(speechRecognizer.transcript) + print("recogniser transcript: ", speechRecognizer.transcript) isRecording = false } else { speechRecognizer.resetTranscript() - speechRecognizer.startTranscribing() + speechRecognizer.startTranscribing(onUpdate: onComplete) isRecording = true } } @@ -58,6 +58,7 @@ struct RecordingView: View { .foregroundStyle(Color(.systemGray)) } } + .buttonStyle(PlainButtonStyle()) } } @@ -67,5 +68,3 @@ struct MeetingView_Previews: PreviewProvider { RecordingView(speechRecognizer: SpeechRecognizer(), isRecording: .constant(true)) } } - -#endif diff --git a/Enchanted/UI/Shared/Chat/Components/Recorder/SpeechRecogniser.swift b/Enchanted/UI/Shared/Chat/Components/Recorder/SpeechRecogniser.swift index e407377..cad69a2 100644 --- a/Enchanted/UI/Shared/Chat/Components/Recorder/SpeechRecogniser.swift +++ b/Enchanted/UI/Shared/Chat/Components/Recorder/SpeechRecogniser.swift @@ -5,7 +5,7 @@ // Created by Augustinas Malinauskas on 21/12/2023. // -#if os(iOS) +//#if os(iOS) import Foundation import Speech @@ -32,6 +32,7 @@ actor SpeechRecognizer: ObservableObject { private var request: SFSpeechAudioBufferRecognitionRequest? private var task: SFSpeechRecognitionTask? var recognizer: SFSpeechRecognizer? + private var onUpdate: ((String) -> ())? /** Initializes a new speech recognizer. If this is the first time you've used the class, it @@ -50,6 +51,20 @@ actor SpeechRecognizer: ObservableObject { Task { do { + + + let authStatus = SFSpeechRecognizer.authorizationStatus() + + switch authStatus { + case .authorized: + print("authorised") + case .denied, .restricted, .notDetermined: + print("denicd") + @unknown default: + print("wtf") + break + } + guard await SFSpeechRecognizer.hasAuthorizationToRecognize() else { throw RecognizerError.notAuthorizedToRecognize } @@ -64,8 +79,13 @@ actor SpeechRecognizer: ObservableObject { } } - @MainActor func startTranscribing() { + private func setUpdateHandler(_ handler: @escaping (_ message: String) -> ()) { + onUpdate = handler + } + + @MainActor func startTranscribing(onUpdate: @escaping (_ message: String) -> ()) { Task { + await self.setUpdateHandler(onUpdate) await transcribe() } } @@ -102,6 +122,7 @@ actor SpeechRecognizer: ObservableObject { self?.recognitionHandler(audioEngine: audioEngine, result: result, error: error) }) } catch { + print("error here") self.reset() self.transcribe(error) } @@ -122,9 +143,11 @@ actor SpeechRecognizer: ObservableObject { let request = SFSpeechAudioBufferRecognitionRequest() request.shouldReportPartialResults = true +#if os(iOS) let audioSession = AVAudioSession.sharedInstance() try audioSession.setCategory(.playAndRecord, mode: .measurement, options: .duckOthers) try audioSession.setActive(true, options: .notifyOthersOnDeactivation) +#endif let inputNode = audioEngine.inputNode let recordingFormat = inputNode.outputFormat(forBus: 0) @@ -155,6 +178,9 @@ actor SpeechRecognizer: ObservableObject { nonisolated private func transcribe(_ message: String) { Task { @MainActor in transcript = message + if !message.isEmpty { + await onUpdate?(message) + } } } nonisolated private func transcribe(_ error: Error) { @@ -182,6 +208,7 @@ extension SFSpeechRecognizer { } +#if os(iOS) extension AVAudioSession { func hasPermissionToRecord() async -> Bool { await withCheckedContinuation { continuation in @@ -192,3 +219,4 @@ extension AVAudioSession { } } #endif +//#endif diff --git a/Enchanted/UI/Shared/Conversation/ConversationView.swift b/Enchanted/UI/Shared/Conversation/ConversationView.swift new file mode 100644 index 0000000..9ff869e --- /dev/null +++ b/Enchanted/UI/Shared/Conversation/ConversationView.swift @@ -0,0 +1,107 @@ +// +// ConversationView.swift +// Enchanted +// +// Created by Augustinas Malinauskas on 26/05/2024. +// + +import SwiftUI + + +struct SiriAnimation: View { + @State var isRotating = false + + var body: some View { +// NavigationStack { + Text("SwiftUI Siri Animation") + .font(.largeTitle) + .toolbar { +// ToolbarItem(placement: .bottomOrnament) { + ZStack { + ZStack { + Image("shadow") + Image("icon-bg") + .scaleEffect(0.5) + + Image("blue-right") + .rotationEffect(.degrees(isRotating ? -359 : 420)) + .hueRotation(.degrees(isRotating ? 720 : -50)) + .rotation3DEffect(.degrees(75), axis: (x: 1, y: 0, z: isRotating ? -5 : 15)) + .scaleEffect(0.5) + .blendMode(.colorBurn) + + Image("blue-middle") + .rotationEffect(.degrees(isRotating ? -359 : 420)) + .hueRotation(.degrees(isRotating ? -150 : 0)) + .rotation3DEffect(.degrees(75), axis: (x: isRotating ? 1 : 5, y: 0, z: 0)) + .blur(radius: 25) + .scaleEffect(0.5) + + Image("pink-top") + .rotationEffect(.degrees(isRotating ? 320 : -359)) + .hueRotation(.degrees(isRotating ? -270 : 60)) + + Image("pink-left") + .rotationEffect(.degrees(isRotating ? -359 : 179)) + .hueRotation(.degrees(isRotating ? -220 : 300)) + .scaleEffect(0.5) + + Image("intersect") + .rotationEffect(.degrees(isRotating ? 30 : -420)) + .hueRotation(.degrees(isRotating ? 0 : 720)) + .rotation3DEffect(.degrees(-360), axis: (x: 1, y: 5, z: 1)) + + // Here + Image("green-right") + .rotationEffect(.degrees(isRotating ? -300 : 359)) + .hueRotation(.degrees(isRotating ? 300 : -15)) + .rotation3DEffect(.degrees(-15), axis: (x: 1, y: isRotating ? -1 : 1, z: 0)) + .scaleEffect(0.5) + .blur(radius: 25) + .opacity(0.5) + .blendMode(.colorBurn) + + Image("green-left") + .rotationEffect(.degrees(isRotating ? 359 : -358)) + .hueRotation(.degrees(isRotating ? 180 :50)) + .rotation3DEffect(.degrees(330), axis: (x: 1, y:isRotating ? -5 : 15, z: 0)) + .scaleEffect(0.5) + .blur(radius: 25) + + Image("bottom-pink") + .rotationEffect(.degrees(isRotating ? 400 : -359)) + .hueRotation(.degrees(isRotating ? 0 : 230)) + .opacity(0.25) + .blendMode(.multiply) + .rotation3DEffect(.degrees(75), axis: (x: 5, y:isRotating ? 1 : -45, z: 0)) + } + .blendMode(isRotating ? .hardLight : .difference ) + + Image("highlight") + .rotationEffect(.degrees(isRotating ? 359 : 250)) + .hueRotation(.degrees(isRotating ? 0 : 230)) + .padding() + .onAppear{ + withAnimation(.easeInOut(duration: 12).repeatForever(autoreverses: false)) { + isRotating.toggle() + } + } + } + .padding(.top) + .scaleEffect(0.4) + .frame(width: 60, height: 60) + } +// } +// } + } +} + +struct ConversationView: View { + var body: some View { + SiriAnimation() + } +} + +#Preview { + ConversationView() +} diff --git a/Enchanted/UI/Shared/Settings/Settings.swift b/Enchanted/UI/Shared/Settings/Settings.swift index 85e3c64..386335e 100644 --- a/Enchanted/UI/Shared/Settings/Settings.swift +++ b/Enchanted/UI/Shared/Settings/Settings.swift @@ -19,6 +19,9 @@ 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 = "" + + @StateObject private var speechSynthesiser = SpeechSynthesizer.shared @Environment(\.presentationMode) var presentationMode @@ -32,7 +35,7 @@ struct Settings: View { OllamaService.shared.initEndpoint(url: ollamaUri, bearerToken: ollamaBearerToken) Task { - await Haptics.shared.mediumTap() + Haptics.shared.mediumTap() try? await languageModelStore.loadModels() } presentationMode.wrappedValue.dismiss() @@ -61,10 +64,12 @@ struct Settings: View { ollamaBearerToken: $ollamaBearerToken, appUserInitials: $appUserInitials, pingInterval: $pingInterval, + voiceIdentifier: $voiceIdentifier, save: save, checkServer: checkServer, deleteAllConversations: conversationStore.deleteAllConversations, - ollamaLangugeModels: languageModelStore.models + ollamaLangugeModels: languageModelStore.models, + voices: speechSynthesiser.voices ) .frame(maxWidth: 700) .onChange(of: defaultOllamaModel) { _, modelName in diff --git a/Enchanted/UI/Shared/Settings/SettingsView.swift b/Enchanted/UI/Shared/Settings/SettingsView.swift index 2ecfb7c..a36fd6e 100644 --- a/Enchanted/UI/Shared/Settings/SettingsView.swift +++ b/Enchanted/UI/Shared/Settings/SettingsView.swift @@ -6,6 +6,7 @@ // import SwiftUI +import AVFoundation struct SettingsView: View { @Environment(\.presentationMode) var presentationMode @@ -17,14 +18,26 @@ struct SettingsView: View { @Binding var ollamaBearerToken: String @Binding var appUserInitials: String @Binding var pingInterval: String + @Binding var voiceIdentifier: String @State var ollamaStatus: Bool? var save: () -> () var checkServer: () -> () var deleteAllConversations: () -> () var ollamaLangugeModels: [LanguageModelSD] + var voices: [AVSpeechSynthesisVoice] @State private var deleteConversationsDialog = false + private func voiceQualityPrettify(_ quality: Int) -> String { + switch quality { + case 1: return "Default" + case 2: return "Enhanced" + case 3: return "Premium" + default: return "Unknown" + } + + } + var body: some View { VStack { ZStack { @@ -37,7 +50,7 @@ struct SettingsView: View { .foregroundStyle(Color(.label)) } - + Spacer() Button(action: save) { @@ -99,24 +112,24 @@ struct SettingsView: View { TextField("Bearer Token", text: $ollamaBearerToken) .disableAutocorrection(true) .textFieldStyle(RoundedBorderTextFieldStyle()) - #if os(iOS) +#if os(iOS) .autocapitalization(.none) - #endif +#endif TextField("Ping Interval (seconds)", text: $pingInterval) .disableAutocorrection(true) .textFieldStyle(RoundedBorderTextFieldStyle()) Section(header: Text("APP").font(.headline).padding(.top, 20)) { - #if os(iOS) +#if os(iOS) Toggle(isOn: $vibrations, label: { Label("Vibrations", systemImage: "water.waves") .foregroundStyle(Color.label) }) - #endif - } - - +#endif + } + + Picker(selection: $colorScheme) { ForEach(AppColorScheme.allCases, id:\.self) { scheme in Text(scheme.toString).tag(scheme.id) @@ -125,7 +138,41 @@ struct SettingsView: View { Label("Appearance", systemImage: "sun.max") .foregroundStyle(Color.label) } - + + Picker(selection: $voiceIdentifier) { + ForEach(voices, id:\.self) { voice in + Text("\(voice.name) (\(voiceQualityPrettify(voice.quality.rawValue)))").tag(voice.identifier) + } + } label: { + Label("Voice", systemImage: "waveform") + .foregroundStyle(Color.label) + +#if os(macOS) + Text("Download voices by going to Settings > Accessibility > Spoken Content > System Voice > Manage Voices.") +#else + Text("Download voices by going to Settings > Accessibility > Spoken Content > Voices.") +#endif + + Button(action: { +#if os(macOS) + if let url = URL(string: "x-apple.systempreferences:com.apple.preference.universalaccess?SpeakableItems") { + NSWorkspace.shared.open(url) + } +#else + let url = URL(string: "App-Prefs:root=General&path=ACCESSIBILITY") + if let url = url, UIApplication.shared.canOpenURL(url) { + UIApplication.shared.open(url, options: [:], completionHandler: nil) + } +#endif + + }) { + + Text("Open Settings") + } + .buttonStyle(PlainButtonStyle()) + } + + TextField("Initials", text: $appUserInitials) .disableAutocorrection(true) .textFieldStyle(RoundedBorderTextFieldStyle()) @@ -169,10 +216,12 @@ struct SettingsView: View { ollamaBearerToken: .constant("x"), appUserInitials: .constant("AM"), pingInterval: .constant("5"), + voiceIdentifier: .constant("sample"), save: {}, checkServer: {}, deleteAllConversations: {}, - ollamaLangugeModels: LanguageModelSD.sample + ollamaLangugeModels: LanguageModelSD.sample, + voices: [] ) } diff --git a/Enchanted/UI/macOS/Chat/ChatView_macOS.swift b/Enchanted/UI/macOS/Chat/ChatView_macOS.swift index a1ba402..9513586 100644 --- a/Enchanted/UI/macOS/Chat/ChatView_macOS.swift +++ b/Enchanted/UI/macOS/Chat/ChatView_macOS.swift @@ -33,6 +33,9 @@ struct ChatView: View { @State private var editMessage: MessageSD? @FocusState private var isFocusedInput: Bool + @StateObject var speechRecognizer = SpeechRecognizer() + @State var isRecording = false + var body: some View { NavigationSplitView(columnVisibility: $columnVisibility) { SidebarView( diff --git a/Enchanted/UI/macOS/Chat/Components/InputFields_macOS.swift b/Enchanted/UI/macOS/Chat/Components/InputFields_macOS.swift index fabac5d..6988954 100644 --- a/Enchanted/UI/macOS/Chat/Components/InputFields_macOS.swift +++ b/Enchanted/UI/macOS/Chat/Components/InputFields_macOS.swift @@ -15,6 +15,7 @@ struct InputFieldsView: View { var selectedModel: LanguageModelSD? var onSendMessageTap: @MainActor (_ prompt: String, _ model: LanguageModelSD, _ image: Image?, _ trimmingMessageId: String?) -> () @Binding var editMessage: MessageSD? + @State var isRecording = false @State private var selectedImage: Image? @State private var fileDropActive: Bool = false @@ -31,6 +32,7 @@ struct InputFieldsView: View { editMessage?.id.uuidString ) withAnimation { + isRecording = false isFocusedInput = false editMessage = nil selectedImage = nil @@ -80,6 +82,12 @@ struct InputFieldsView: View { .allowsHitTesting(!fileDropActive) .addCustomHotkeys(hotkeys) + RecordingView(isRecording: $isRecording.animation()) { transcription in + withAnimation(.easeIn(duration: 0.3)) { + self.message = transcription + } + } + SimpleFloatingButton(systemImage: "photo.fill", onClick: { fileSelectingActive.toggle() }) .showIf(selectedModel?.supportsImages ?? false) .fileImporter(isPresented: $fileSelectingActive, @@ -109,9 +117,8 @@ struct InputFieldsView: View { } .transition(.slide) .padding(.horizontal) - .padding(.vertical, 5) .overlay( - RoundedRectangle(cornerRadius: 10) + RoundedRectangle(cornerRadius: 20) .strokeBorder( Color.gray2Custom, style: StrokeStyle(lineWidth: 1)