Files
notes/Notes/Services/AudioRecorderService.swift
T
rzen 86d74806c9 Add voice notes: on-device transcription, multi-language, watch capture
- Schema v2: Note.AudioInfo, .m4a sidecar next to the note JSON in iCloud
- AudioRecorderService (iOS/macOS) + SpeechAnalyzer transcription pipeline
  with enabled-language set and confidence-based auto-pick, re-transcribe menu
- Settings > Transcription > Languages with asset download/reserve handling
- watchOS app + accessory complication: one-button recorder, WCSession
  transferFile to phone, WatchInbox staging, direct-upsert ingestion
- Player UI, transcription status chips, quick-capture mic in notes list
- Version 0.2, changelog + README updated
2026-07-15 13:30:43 -04:00

191 lines
7.0 KiB
Swift

import AVFoundation
import Foundation
import IndieSync
/// Records a single voice note into a temporary `<ULID>.m4a` (AAC mono
/// 44.1 kHz ~48 kbps). Modeled on `ContextService`: `@Observable @MainActor`,
/// so the UI observes `isRecording` / `elapsed` / `level` directly.
///
/// Never records into the iCloud container — the file is written to the temp
/// directory and handed up as a URL; `SyncEngine.createAudioNote` is what copies
/// the finished bytes into the container (audio before JSON). `AVAudioRecorder`
/// is not Sendable, so it stays confined to the main actor for its whole life.
@Observable
@MainActor
final class AudioRecorderService {
/// One finished recording: the temp file and how long it runs. The caller
/// owns the file from here on (moves its bytes into the container, then
/// discards the temp copy).
struct Recording: Equatable {
let url: URL
let duration: TimeInterval
}
private(set) var isRecording = false
/// Seconds recorded so far, updated by the metering loop while recording.
private(set) var elapsed: TimeInterval = 0
/// Normalized input level (0…1) for the level-bar UI; 0 when idle.
private(set) var level: Float = 0
/// Fired when a system interruption (phone call, Siri) forces recording to
/// stop. The partial file is finalized and handed up rather than lost, so the
/// UI can move straight to the "recorded" state. nil unless a recorder wires
/// it up for the duration of a take.
var onInterrupted: ((Recording) -> Void)?
private var recorder: AVAudioRecorder?
private var meterTask: Task<Void, Never>?
private var currentURL: URL?
#if os(iOS)
private var interruptionObserver: (any NSObjectProtocol)?
#endif
/// Ask for microphone access, returning whether it was granted. Safe to call
/// every time recording starts — the system only prompts once.
func requestPermission() async -> Bool {
await AVAudioApplication.requestRecordPermission()
}
/// Whether the mic has been explicitly denied (so the UI can show a jump to
/// Settings instead of a dead record button).
var permissionDenied: Bool {
AVAudioApplication.shared.recordPermission == .denied
}
/// Begin recording into a fresh temp file. Throws if the audio session or
/// recorder can't be configured (surfaced by the UI). A no-op if already
/// recording.
func startRecording() throws {
guard !isRecording else { return }
#if os(iOS)
let session = AVAudioSession.sharedInstance()
try session.setCategory(.playAndRecord, mode: .spokenAudio, options: [.duckOthers])
try session.setActive(true)
observeInterruptions()
#endif
let url = FileManager.default.temporaryDirectory
.appending(path: "\(ULID().stringValue).m4a")
let settings: [String: Any] = [
AVFormatIDKey: kAudioFormatMPEG4AAC,
AVSampleRateKey: 44_100.0,
AVNumberOfChannelsKey: 1,
AVEncoderBitRateKey: 48_000,
AVEncoderAudioQualityKey: AVAudioQuality.medium.rawValue,
]
let recorder = try AVAudioRecorder(url: url, settings: settings)
recorder.isMeteringEnabled = true
guard recorder.record() else {
throw RecorderError.couldNotStart
}
self.recorder = recorder
self.currentURL = url
isRecording = true
elapsed = 0
level = 0
startMetering()
}
/// Stop recording and return the finished file plus its duration, or nil if
/// nothing was recording. Deactivates the audio session on iOS.
@discardableResult
func stopRecording() -> Recording? {
guard let recorder, let url = currentURL else { return nil }
let duration = recorder.currentTime
recorder.stop()
teardown()
return Recording(url: url, duration: duration)
}
/// Abandon the in-progress (or just-finished) recording and delete its temp
/// file. Used by "Discard" and when the composer is dismissed unsaved.
func discard() {
recorder?.stop()
if let url = currentURL {
try? FileManager.default.removeItem(at: url)
}
teardown()
}
// MARK: - Metering
/// ~30 Hz loop that refreshes `elapsed` and `level` while recording. Runs on
/// the main actor (a plain `Task` inherits this actor), so it can touch the
/// non-Sendable recorder safely.
private func startMetering() {
meterTask?.cancel()
meterTask = Task { [weak self] in
while !Task.isCancelled {
guard let self, let recorder = self.recorder, recorder.isRecording else { return }
recorder.updateMeters()
self.elapsed = recorder.currentTime
self.level = Self.normalizedLevel(recorder.averagePower(forChannel: 0))
try? await Task.sleep(for: .milliseconds(33))
}
}
}
/// Map a dBFS reading (roughly -60…0) to a 0…1 bar height.
private static func normalizedLevel(_ power: Float) -> Float {
let floorDB: Float = -50
guard power > floorDB else { return 0 }
return min(1, (power - floorDB) / -floorDB)
}
private func teardown() {
meterTask?.cancel()
meterTask = nil
recorder = nil
currentURL = nil
isRecording = false
elapsed = 0
level = 0
#if os(iOS)
if let interruptionObserver {
NotificationCenter.default.removeObserver(interruptionObserver)
self.interruptionObserver = nil
}
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
#endif
}
// MARK: - Interruptions (iOS)
#if os(iOS)
private func observeInterruptions() {
guard interruptionObserver == nil else { return }
interruptionObserver = NotificationCenter.default.addObserver(
forName: AVAudioSession.interruptionNotification,
object: AVAudioSession.sharedInstance(),
queue: .main
) { [weak self] note in
// Extract the Sendable interruption type before hopping — the
// Notification itself isn't Sendable. Delivered on the main queue, so
// assuming main-actor isolation is sound.
let began = (note.userInfo?[AVAudioSessionInterruptionTypeKey] as? UInt)
.flatMap(AVAudioSession.InterruptionType.init(rawValue:)) == .began
guard began else { return }
MainActor.assumeIsolated {
self?.handleInterruptionBegan()
}
}
}
private func handleInterruptionBegan() {
guard isRecording else { return }
// Finalize rather than drop the take — the partial recording is still a
// valid note.
if let finished = stopRecording() {
onInterrupted?(finished)
}
}
#endif
enum RecorderError: Error {
case couldNotStart
}
}