From eb7d563bbdcda08bb5fe5c9989afc212833c3a6c Mon Sep 17 00:00:00 2001 From: kutesir Date: Wed, 24 Jun 2026 19:48:00 +0300 Subject: [PATCH] feat(voice): wire existing mic button to live speech recognition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mic icon in AddTaskSheet was a static Image with no action and no speech service behind it. Full implementation: SpeechRecognizer.swift (new): - @MainActor ObservableObject wrapping SFSpeechRecognizer + AVAudioEngine - Requests speech recognition then microphone permission in sequence - Streams partial results into @Published transcript; final result stops engine - Suppresses transient error 301 (no speech detected) - reset() cleans up on sheet dismiss - [VOICE] log trail at every step: tapped → granted → started → transcript → saved TodayView.swift — AddTaskSheet: - @StateObject private var speech = SpeechRecognizer() - onChange(speech.transcript) writes transcript to rawText (task field) - Mic Image → Button { speech.toggle() } with mic.fill + pulse animation while recording - .safeAreaInset shows a red dismissible error banner on permission denial - submit() stops recording before saving; logs task title - .onDisappear resets speech state project.yml: - NSMicrophoneUsageDescription - NSSpeechRecognitionUsageDescription Co-Authored-By: Kutesir --- KisaniCal.xcodeproj/project.pbxproj | 8 ++ KisaniCal/Managers/SpeechRecognizer.swift | 140 ++++++++++++++++++++++ KisaniCal/Views/TodayView.swift | 41 ++++++- project.yml | 2 + 4 files changed, 186 insertions(+), 5 deletions(-) create mode 100644 KisaniCal/Managers/SpeechRecognizer.swift diff --git a/KisaniCal.xcodeproj/project.pbxproj b/KisaniCal.xcodeproj/project.pbxproj index f290556..a6c6e5b 100644 --- a/KisaniCal.xcodeproj/project.pbxproj +++ b/KisaniCal.xcodeproj/project.pbxproj @@ -13,6 +13,7 @@ 13E4B9854F595394FC9D5912 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC5E179A9189B0A8C3F856F6 /* ContentView.swift */; }; 1A22EE21460821170E44B1DF /* ExerciseModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5722CC4B59E3939724142710 /* ExerciseModels.swift */; }; 205846F9651EDCE0D8207358 /* TaskLiveActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0513EB8C7F6B6853B9E93E09 /* TaskLiveActivity.swift */; }; + 2885D000426D063F2125804C /* SpeechRecognizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 86906905A4FA7A2384B3E24E /* SpeechRecognizer.swift */; }; 2CD2313EDC4BFCE6E89C6787 /* AppGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF905C574F34B4EE51A8D21E /* AppGroup.swift */; }; 32C63D81925FBFE51CAE1FB7 /* TaskActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEEB07EB89E89383C32ADB34 /* TaskActivityAttributes.swift */; }; 3AD7F62D231DCFFFFCB31649 /* TutorialManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01A27D42E141DC056D32C1A3 /* TutorialManager.swift */; }; @@ -114,6 +115,7 @@ 74FAA2B41FCEBC7E3F156F0F /* CalendarGridTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarGridTests.swift; sourceTree = ""; }; 768D4E314252E2A90A06CCF2 /* AddExerciseSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddExerciseSheet.swift; sourceTree = ""; }; 7A9D47B284FD6A217AEF813B /* WenzaWatch.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = WenzaWatch.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 86906905A4FA7A2384B3E24E /* SpeechRecognizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpeechRecognizer.swift; sourceTree = ""; }; 89550F2CD19B950CCC6AD37F /* AuthManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthManager.swift; sourceTree = ""; }; 8DC8687EA2FBA9FB2EEE51C6 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; 8E7FC6446D5C5B2A2D9387DA /* KisaniCalWidgets.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = KisaniCalWidgets.entitlements; sourceTree = ""; }; @@ -279,6 +281,7 @@ 0506183945D16EC443A69651 /* LiveActivityManager.swift */, 93D045FE3DEB1D22D908A29F /* NotificationManager.swift */, 326B77A7B317A7DB44E13EA5 /* ShortcutHandler.swift */, + 86906905A4FA7A2384B3E24E /* SpeechRecognizer.swift */, FEEB07EB89E89383C32ADB34 /* TaskActivityAttributes.swift */, 01A27D42E141DC056D32C1A3 /* TutorialManager.swift */, ); @@ -464,6 +467,7 @@ A3B2D6622A2EE35C8D5A3C9B /* SettingsView.swift in Sources */, 8BE9D3D650B0F06169EC7048 /* SharedComponents.swift in Sources */, 497732557745AE9BDA44FB2F /* ShortcutHandler.swift in Sources */, + 2885D000426D063F2125804C /* SpeechRecognizer.swift in Sources */, 6921CB73A3257502FF778381 /* SplashView.swift in Sources */, 32C63D81925FBFE51CAE1FB7 /* TaskActivityAttributes.swift in Sources */, EE7BCEB45B1F0B91C9D3C1D2 /* TaskContextMenu.swift in Sources */, @@ -522,6 +526,8 @@ INFOPLIST_KEY_NSCalendarsUsageDescription = "Wenza shows your calendar events alongside tasks."; INFOPLIST_KEY_NSHealthShareUsageDescription = "Wenza reads your steps, energy, heart rate, and workouts to show health stats on your dashboard."; INFOPLIST_KEY_NSHealthUpdateUsageDescription = "Wenza saves workouts you complete to the Health app."; + INFOPLIST_KEY_NSMicrophoneUsageDescription = "Wenza uses your microphone to convert speech into task text."; + INFOPLIST_KEY_NSSpeechRecognitionUsageDescription = "Wenza uses speech recognition so you can add tasks by voice."; INFOPLIST_KEY_NSSupportsLiveActivities = YES; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; @@ -786,6 +792,8 @@ INFOPLIST_KEY_NSCalendarsUsageDescription = "Wenza shows your calendar events alongside tasks."; INFOPLIST_KEY_NSHealthShareUsageDescription = "Wenza reads your steps, energy, heart rate, and workouts to show health stats on your dashboard."; INFOPLIST_KEY_NSHealthUpdateUsageDescription = "Wenza saves workouts you complete to the Health app."; + INFOPLIST_KEY_NSMicrophoneUsageDescription = "Wenza uses your microphone to convert speech into task text."; + INFOPLIST_KEY_NSSpeechRecognitionUsageDescription = "Wenza uses speech recognition so you can add tasks by voice."; INFOPLIST_KEY_NSSupportsLiveActivities = YES; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; diff --git a/KisaniCal/Managers/SpeechRecognizer.swift b/KisaniCal/Managers/SpeechRecognizer.swift new file mode 100644 index 0000000..e1f4d25 --- /dev/null +++ b/KisaniCal/Managers/SpeechRecognizer.swift @@ -0,0 +1,140 @@ +import Foundation +import Speech +import AVFoundation + +@MainActor +final class SpeechRecognizer: ObservableObject { + @Published var transcript = "" + @Published var isRecording = false + @Published var error: String? + + private var recognizer: SFSpeechRecognizer? + private var audioEngine = AVAudioEngine() + private var request: SFSpeechAudioBufferRecognitionRequest? + private var task: SFSpeechRecognitionTask? + + init() { + recognizer = SFSpeechRecognizer(locale: .current) + } + + func toggle() { + isRecording ? stop() : start() + } + + func start() { + guard !isRecording else { return } + error = nil + print("[VOICE] Button tapped") + + SFSpeechRecognizer.requestAuthorization { [weak self] status in + DispatchQueue.main.async { + guard let self else { return } + switch status { + case .authorized: + print("[VOICE] Permission granted — speech recognition") + self.requestMicThenRecord() + case .denied, .restricted: + self.error = "Speech recognition access denied. Enable it in Settings → Wenza." + print("[VOICE] Permission denied — speech recognition") + case .notDetermined: + self.error = "Speech recognition permission not yet determined." + print("[VOICE] Permission not determined") + @unknown default: break + } + } + } + } + + private func requestMicThenRecord() { + AVAudioSession.sharedInstance().requestRecordPermission { [weak self] granted in + DispatchQueue.main.async { + guard let self else { return } + if granted { + print("[VOICE] Permission granted — microphone") + self.startEngine() + } else { + self.error = "Microphone access denied. Enable it in Settings → Wenza." + print("[VOICE] Permission denied — microphone") + } + } + } + } + + private func startEngine() { + do { + let session = AVAudioSession.sharedInstance() + try session.setCategory(.record, mode: .measurement, options: .duckOthers) + try session.setActive(true, options: .notifyOthersOnDeactivation) + + guard let recognizer, recognizer.isAvailable else { + error = "Speech recognition is not available right now." + print("[VOICE] Recognizer unavailable") + return + } + + let req = SFSpeechAudioBufferRecognitionRequest() + req.shouldReportPartialResults = true + self.request = req + + task = recognizer.recognitionTask(with: req) { [weak self] result, err in + DispatchQueue.main.async { + guard let self else { return } + if let result { + let text = result.bestTranscription.formattedString + self.transcript = text + print("[VOICE] Transcript received: \"\(text)\"") + } + if result?.isFinal == true { + print("[VOICE] Recognition finished (final)") + self.stopEngine() + } else if let err { + let code = (err as NSError).code + // Code 301 = "no speech" — not an error worth surfacing + if code != 301 { + print("[VOICE] Recognition error \(code): \(err.localizedDescription)") + } + } + } + } + + let node = audioEngine.inputNode + let fmt = node.outputFormat(forBus: 0) + node.installTap(onBus: 0, bufferSize: 1024, format: fmt) { [weak self] buf, _ in + self?.request?.append(buf) + } + + audioEngine.prepare() + try audioEngine.start() + isRecording = true + print("[VOICE] Recording started") + + } catch { + self.error = "Could not start recording." + print("[VOICE] Engine start failed: \(error)") + stopEngine() + } + } + + func stop() { + print("[VOICE] Recording stopped") + stopEngine() + } + + private func stopEngine() { + guard audioEngine.isRunning || task != nil else { return } + audioEngine.stop() + audioEngine.inputNode.removeTap(onBus: 0) + request?.endAudio() + task?.finish() + request = nil + task = nil + isRecording = false + try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation) + } + + func reset() { + stopEngine() + transcript = "" + error = nil + } +} diff --git a/KisaniCal/Views/TodayView.swift b/KisaniCal/Views/TodayView.swift index c4918da..842245f 100644 --- a/KisaniCal/Views/TodayView.swift +++ b/KisaniCal/Views/TodayView.swift @@ -1814,6 +1814,7 @@ struct AddTaskSheet: View { @State private var reminderDate: Date? = nil @State private var constantReminder = false @State private var showDatePicker = false + @StateObject private var speech = SpeechRecognizer() var prefilledDate: Date? var prefilledQuadrant: Quadrant @@ -1849,6 +1850,11 @@ struct AddTaskSheet: View { if p.isBirthday { selectedCategory = .birthday } } } + .onChange(of: speech.transcript) { text in + guard !text.isEmpty else { return } + rawText = text + print("[VOICE] Transcript bound to task field") + } .padding(.horizontal, 16) .padding(.top, 14) .padding(.bottom, 10) @@ -1947,11 +1953,17 @@ struct AddTaskSheet: View { .buttonStyle(PressButtonStyle(scale: 0.90)) .transition(.scale(scale: 0.5).combined(with: .opacity)) } else { - Image(systemName: "mic") - .font(.system(size: 18)) - .foregroundColor(AppColors.text3(cs)) - .frame(width: 42, height: 44) - .transition(.scale(scale: 0.5).combined(with: .opacity)) + Button { speech.toggle() } label: { + Image(systemName: speech.isRecording ? "mic.fill" : "mic") + .font(.system(size: 18)) + .foregroundColor(speech.isRecording ? AppColors.accent : AppColors.text3(cs)) + .frame(width: 42, height: 44) + .scaleEffect(speech.isRecording ? 1.15 : 1.0) + .animation(.easeInOut(duration: 0.6).repeatForever(autoreverses: true), + value: speech.isRecording) + } + .buttonStyle(.plain) + .transition(.scale(scale: 0.5).combined(with: .opacity)) } } .animation(KisaniSpring.micro, value: canAdd) @@ -1959,6 +1971,23 @@ struct AddTaskSheet: View { .padding(.horizontal, 12) .padding(.vertical, 4) } + .safeAreaInset(edge: .top, spacing: 0) { + if let err = speech.error { + HStack(spacing: 6) { + Image(systemName: "exclamationmark.circle.fill") + .font(.system(size: 13)) + Text(err) + .font(AppFonts.sans(13, weight: .medium)) + } + .foregroundColor(.white) + .padding(.horizontal, 14).padding(.vertical, 8) + .background(Color.red.opacity(0.85)) + .clipShape(Capsule()) + .padding(.top, 8) + .onTapGesture { speech.error = nil } + } + } + .onDisappear { speech.reset() } .background(AppColors.surface(cs).ignoresSafeArea()) .sheet(isPresented: $showDatePicker) { TaskDatePickerSheet( @@ -1977,6 +2006,7 @@ struct AddTaskSheet: View { private func submit() { guard canAdd else { return } + if speech.isRecording { speech.stop() } taskVM.addTask(title: cleanTitle, dueDate: parsed.date, hasTime: parsed.hasTime, quadrant: prefilledQuadrant, category: selectedCategory, isRecurring: parsed.isRecurring, recurrenceLabel: parsed.recurrenceLabel, @@ -1984,6 +2014,7 @@ struct AddTaskSheet: View { priority: selectedPriority, reminderDate: reminderDate, constantReminder: constantReminder, recurrenceEnd: parsed.recurrenceEnd) + print("[VOICE] Task saved: \"\(cleanTitle)\"") dismiss() } } diff --git a/project.yml b/project.yml index e5e4a26..42e11d6 100644 --- a/project.yml +++ b/project.yml @@ -66,6 +66,8 @@ targets: INFOPLIST_KEY_NSCalendarsFullAccessUsageDescription: "Wenza shows your calendar events alongside tasks." INFOPLIST_KEY_NSHealthShareUsageDescription: "Wenza reads your steps, energy, heart rate, and workouts to show health stats on your dashboard." INFOPLIST_KEY_NSHealthUpdateUsageDescription: "Wenza saves workouts you complete to the Health app." + INFOPLIST_KEY_NSMicrophoneUsageDescription: "Wenza uses your microphone to convert speech into task text." + INFOPLIST_KEY_NSSpeechRecognitionUsageDescription: "Wenza uses speech recognition so you can add tasks by voice." KisaniCalTests: type: bundle.unit-test