feat(voice): wire existing mic button to live speech recognition
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 <tqwyy79vzn@privaterelay.appleid.com>
This commit is contained in:
@@ -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 = "<group>"; };
|
||||
768D4E314252E2A90A06CCF2 /* AddExerciseSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddExerciseSheet.swift; sourceTree = "<group>"; };
|
||||
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 = "<group>"; };
|
||||
89550F2CD19B950CCC6AD37F /* AuthManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthManager.swift; sourceTree = "<group>"; };
|
||||
8DC8687EA2FBA9FB2EEE51C6 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
|
||||
8E7FC6446D5C5B2A2D9387DA /* KisaniCalWidgets.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = KisaniCalWidgets.entitlements; sourceTree = "<group>"; };
|
||||
@@ -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;
|
||||
|
||||
140
KisaniCal/Managers/SpeechRecognizer.swift
Normal file
140
KisaniCal/Managers/SpeechRecognizer.swift
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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,10 +1953,16 @@ struct AddTaskSheet: View {
|
||||
.buttonStyle(PressButtonStyle(scale: 0.90))
|
||||
.transition(.scale(scale: 0.5).combined(with: .opacity))
|
||||
} else {
|
||||
Image(systemName: "mic")
|
||||
Button { speech.toggle() } label: {
|
||||
Image(systemName: speech.isRecording ? "mic.fill" : "mic")
|
||||
.font(.system(size: 18))
|
||||
.foregroundColor(AppColors.text3(cs))
|
||||
.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))
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user