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:
kutesir
2026-06-24 19:48:00 +03:00
parent 716e6e8f31
commit eb7d563bbd
4 changed files with 186 additions and 5 deletions

View File

@@ -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;

View 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
}
}

View File

@@ -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()
}
}

View File

@@ -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