- 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
191 lines
7.0 KiB
Swift
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
|
|
}
|
|
}
|