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:
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user