- 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
161 lines
6.3 KiB
Swift
161 lines
6.3 KiB
Swift
import AVFoundation
|
|
import Foundation
|
|
import IndieSync
|
|
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 {
|
|
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 `<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()
|
|
try session.setCategory(.record, mode: .spokenAudio)
|
|
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()
|
|
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
|
|
}
|
|
}
|