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

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