Files
notes/Notes Watch App/Services/WatchAudioRecorder.swift
T
rzen 798abf3b90 Watch: show version/build on screen, surface recording errors in UI
A tester on real hardware has no console, so a failed start looked like
a dead button and there was no way to tell which build the watch had
actually installed. Show the stamped version/build under the record
button, display the last start error in place of the hint text, and
fall back to the default audio session mode if .spokenAudio is rejected
with the .record category.

Claude-Session: https://claude.ai/code/session_01GKfAiKiQKLDTmbiGva4FGE
2026-07-15 16:07:32 -04:00

178 lines
7.4 KiB
Swift

import AVFoundation
import Foundation
import IndieSync
import OSLog
import WatchKit
/// Records a single voice note on the watch into a persisted
/// `Documents/Recordings/<ULID>.m4a` (AAC mono 22.05 kHz ~32 kbps). The ULID is
/// minted at record **start**, so the note's identity — and its UTC time bucket
/// once ingested on the phone — reflects when it was recorded, not when it later
/// reaches the phone.
///
/// The file lands in the app's Documents (not temp) so a queued recording
/// survives suspension until `WatchTransferService` confirms the phone received
/// it. `AVAudioRecorder` is not Sendable, so the recorder stays confined to the
/// main actor for its whole life.
@Observable
@MainActor
final class WatchAudioRecorder {
/// One finished recording: the file on disk, its length, its ULID, and the
/// instant it started. The caller (`WatchTransferService`) owns delivery.
struct Recording: Equatable, Sendable {
let id: ULID
let url: URL
let duration: TimeInterval
let recordedAt: Date
}
private(set) var isRecording = false
/// Seconds recorded so far, refreshed by the tick loop while recording.
private(set) var elapsed: TimeInterval = 0
/// Fired when a recording ends without the user tapping Stop — the 10-minute
/// safety cap fires. The finished file is handed up to be queued rather than
/// lost. (Scene-deactivation stop-and-queue is driven by `WatchAppServices`,
/// which calls `stopRecording()` directly.)
var onInvoluntaryFinish: ((Recording) -> Void)?
/// Hard cap on a single take so a recording the user forgot about can't run
/// the battery down. The finished file is queued, never dropped.
private static let maxDuration: TimeInterval = 600
private var recorder: AVAudioRecorder?
private var current: (id: ULID, url: URL, startedAt: Date)?
private var tickTask: Task<Void, Never>?
/// Directory the finished recordings (and their `.meta.plist` sidecars) live
/// in until the phone confirms delivery. Persisted, so a queued file survives
/// app suspension and can be re-enqueued on the next launch.
static var recordingsDirectory: URL {
URL.documentsDirectory.appending(path: "Recordings", directoryHint: .isDirectory)
}
/// 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 {
let granted = await AVAudioApplication.requestRecordPermission()
permissionDenied = !granted
return granted
}
/// Whether the mic has been explicitly denied, so the UI can show a jump to
/// Settings instead of a dead record button. Stored (not computed from
/// `AVAudioApplication.shared.recordPermission`) so Observation tracks it and
/// the UI actually flips to the denied state the moment a request is refused.
private(set) var permissionDenied = AVAudioApplication.shared.recordPermission == .denied
/// Begin recording into a fresh `<ULID>.m4a`. A no-op if already recording.
/// Throws if the audio session or recorder can't be configured.
func startRecording() throws {
guard !isRecording else { return }
let session = AVAudioSession.sharedInstance()
do {
try session.setCategory(.record, mode: .spokenAudio)
} catch {
// .spokenAudio isn't guaranteed compatible with .record on every
// route — fall back rather than refuse to record at all.
try session.setCategory(.record, mode: .default)
}
try session.setActive(true)
let dir = Self.recordingsDirectory
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
let id = ULID()
let url = dir.appending(path: "\(id.stringValue).m4a")
let settings: [String: Any] = [
AVFormatIDKey: kAudioFormatMPEG4AAC,
AVSampleRateKey: 22_050.0,
AVNumberOfChannelsKey: 1,
AVEncoderBitRateKey: 32_000,
AVEncoderAudioQualityKey: AVAudioQuality.medium.rawValue,
]
let recorder = try AVAudioRecorder(url: url, settings: settings)
guard recorder.record() else { throw RecorderError.couldNotStart }
self.recorder = recorder
self.current = (id, url, Date())
isRecording = true
elapsed = 0
// Continuity through a wrist-down rests on the `audio` background mode
// (declared in Info-watchOS.plist) keeping the active audio session
// alive; the old `WKExtension.isFrontmostTimeoutExtended` knob is
// deprecated and unsupported since watchOS 7. The stop-and-queue on
// scene-background (see `WatchAppServices`) is the never-lose-audio net.
WKInterfaceDevice.current().play(.start)
startTicking()
}
/// Stop recording and return the finished file plus its duration, or nil if
/// nothing was recording.
@discardableResult
func stopRecording() -> Recording? {
guard let recorder, let current else { return nil }
let duration = recorder.currentTime
recorder.stop()
WKInterfaceDevice.current().play(.stop)
let recording = Recording(
id: current.id, url: current.url, duration: duration, recordedAt: current.startedAt)
teardown()
// The watch simulator has no audio input device: record() reports
// success and currentTime advances, but no file is ever written. Don't
// hand a phantom recording up to the transfer queue.
guard FileManager.default.fileExists(atPath: recording.url.path) else {
Logger(subsystem: "dev.rzen.indie.Notes.watchkitapp", category: "recording")
.warning("recording produced no file (simulator has no mic input?): \(recording.url.lastPathComponent, privacy: .public)")
return nil
}
return recording
}
/// Abandon the in-progress recording and delete its file. (Not surfaced in the
/// single-button UI, but kept for symmetry / future use.)
func discard() {
recorder?.stop()
if let url = current?.url { try? FileManager.default.removeItem(at: url) }
teardown()
}
// MARK: - Metering / watchdog
/// ~5 Hz loop that refreshes `elapsed` and enforces the safety cap. Runs on
/// the main actor (a plain `Task` inherits it), so it can touch the
/// non-Sendable recorder safely.
private func startTicking() {
tickTask?.cancel()
tickTask = Task { [weak self] in
while !Task.isCancelled {
guard let self, let recorder = self.recorder, recorder.isRecording else { return }
self.elapsed = recorder.currentTime
if self.elapsed >= Self.maxDuration {
if let finished = self.stopRecording() {
self.onInvoluntaryFinish?(finished)
}
return
}
try? await Task.sleep(for: .milliseconds(200))
}
}
}
private func teardown() {
tickTask?.cancel()
tickTask = nil
recorder = nil
current = nil
isRecording = false
elapsed = 0
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
}
enum RecorderError: Error {
case couldNotStart
}
}