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
This commit is contained in:
@@ -0,0 +1,190 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user