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:
2026-07-15 13:30:43 -04:00
parent 6a93cedaa6
commit 86d74806c9
44 changed files with 3329 additions and 123 deletions
+206
View File
@@ -0,0 +1,206 @@
import AVFoundation
import IndieSync
import SwiftUI
/// Plays back a voice note's audio. Two sources: a fresh recording still on disk
/// (`localURL`, used by the composer) or a saved note whose bytes come back
/// through `SyncEngine.loadAudioData` (fail-fast, so an evicted file shows a
/// "downloading" retry state instead of stalling). `AVAudioPlayer` is not
/// Sendable, so the playback model stays main-actor confined.
struct AudioPlayerView: View {
enum Source: Equatable {
case localURL(URL)
case note(id: ULID, duration: TimeInterval)
}
let source: Source
@Environment(SyncEngine.self) private var syncEngine
@State private var model = AudioPlaybackModel()
var body: some View {
Group {
switch model.loadState {
case .idle, .loading:
HStack {
ProgressView().controlSize(.small)
Text("Loading audio…").foregroundStyle(.secondary)
}
.font(.callout)
case .downloading:
VStack(alignment: .leading, spacing: 6) {
Label("Downloading from iCloud…", systemImage: "icloud.and.arrow.down")
.foregroundStyle(.secondary)
Button("Retry") { Task { await load() } }
.buttonStyle(.bordered)
.controlSize(.small)
}
.font(.callout)
case .failed:
Label("Couldn't play audio", systemImage: "exclamationmark.triangle")
.foregroundStyle(.secondary)
.font(.callout)
case .ready:
player
}
}
.task { await load() }
.onDisappear { model.stop() }
}
private var player: some View {
HStack(spacing: 12) {
Button {
model.togglePlay()
} label: {
Image(systemName: model.isPlaying ? "pause.circle.fill" : "play.circle.fill")
.font(.largeTitle)
.foregroundStyle(.tint)
}
.buttonStyle(.plain)
VStack(spacing: 2) {
Slider(
value: Binding(get: { model.progress }, set: { model.seek(toFraction: $0) }),
in: 0...1
)
HStack {
Text(Self.timeString(model.currentTime))
Spacer()
Text(Self.timeString(model.duration))
}
.font(.caption2.monospacedDigit())
.foregroundStyle(.secondary)
}
}
}
private func load() async {
switch source {
case .localURL(let url):
model.loadLocal(url)
case .note(let id, let duration):
await model.loadNote(id: id, fallbackDuration: duration, syncEngine: syncEngine)
}
}
static func timeString(_ interval: TimeInterval) -> String {
let total = Int(interval.rounded())
return String(format: "%d:%02d", total / 60, total % 60)
}
}
/// Owns the `AVAudioPlayer` and a lightweight progress ticker. Main-actor
/// confined because `AVAudioPlayer` isn't Sendable.
@Observable
@MainActor
final class AudioPlaybackModel {
enum LoadState: Equatable { case idle, loading, ready, downloading, failed }
private(set) var loadState: LoadState = .idle
private(set) var isPlaying = false
/// 01 position, bound to the scrubber.
private(set) var progress: Double = 0
private(set) var duration: TimeInterval = 0
var currentTime: TimeInterval { duration * progress }
private var player: AVAudioPlayer?
private var ticker: Task<Void, Never>?
func loadLocal(_ url: URL) {
loadState = .loading
do {
let player = try AVAudioPlayer(contentsOf: url)
player.prepareToPlay()
adopt(player)
} catch {
loadState = .failed
}
}
/// Fetch a saved note's audio (fail-fast). A miss means the file is evicted:
/// show the downloading state and warm it up for the retry.
func loadNote(id: ULID, fallbackDuration: TimeInterval, syncEngine: SyncEngine) async {
loadState = .loading
duration = fallbackDuration
guard let data = await syncEngine.loadAudioData(for: id) else {
loadState = .downloading
await syncEngine.requestAudioDownload(for: id)
return
}
do {
let player = try AVAudioPlayer(data: data)
player.prepareToPlay()
adopt(player)
} catch {
loadState = .failed
}
}
private func adopt(_ player: AVAudioPlayer) {
self.player = player
duration = player.duration
progress = 0
loadState = .ready
}
func togglePlay() {
guard let player else { return }
if player.isPlaying {
player.pause()
isPlaying = false
ticker?.cancel()
} else {
activatePlaybackSession()
player.play()
isPlaying = true
startTicker()
}
}
func seek(toFraction fraction: Double) {
guard let player else { return }
progress = min(max(fraction, 0), 1)
player.currentTime = progress * duration
}
func stop() {
player?.stop()
isPlaying = false
ticker?.cancel()
ticker = nil
#if os(iOS)
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
#endif
}
private func startTicker() {
ticker?.cancel()
ticker = Task { [weak self] in
while !Task.isCancelled {
guard let self, let player = self.player else { return }
if player.isPlaying {
self.progress = self.duration > 0 ? player.currentTime / self.duration : 0
} else {
// Reached the end (or was paused elsewhere): reset to the start.
self.isPlaying = false
self.progress = 0
return
}
try? await Task.sleep(for: .milliseconds(50))
}
}
}
private func activatePlaybackSession() {
#if os(iOS)
let session = AVAudioSession.sharedInstance()
try? session.setCategory(.playback, mode: .spokenAudio)
try? session.setActive(true)
#endif
}
}
@@ -0,0 +1,145 @@
import SwiftUI
/// The record surface in the composer: a mic button that, once tapped (or
/// auto-started), shows the elapsed time and a live level meter with Stop /
/// Discard. When recording ends it hands the finished `(url, duration)` up via
/// `onFinish`; the composer owns everything after that.
struct AudioRecordingControl: View {
@Environment(AudioRecorderService.self) private var recorder
/// Begin recording as soon as the view appears (the toolbar "record a note"
/// entry point), after the permission prompt resolves.
var autostart = false
let onFinish: (URL, TimeInterval) -> Void
@State private var permissionDenied = false
@State private var startError: String?
@State private var didAutostart = false
var body: some View {
VStack(spacing: 12) {
if permissionDenied {
deniedState
} else if recorder.isRecording {
recordingState
} else {
idleState
}
if let startError {
Text(startError)
.font(.footnote)
.foregroundStyle(.red)
}
}
.frame(maxWidth: .infinity)
.task {
recorder.onInterrupted = { recording in onFinish(recording.url, recording.duration) }
if autostart, !didAutostart {
didAutostart = true
await beginRecording()
}
}
.onDisappear {
recorder.onInterrupted = nil
if recorder.isRecording { recorder.discard() }
}
}
private var idleState: some View {
Button {
Task { await beginRecording() }
} label: {
VStack(spacing: 6) {
Image(systemName: "mic.circle.fill")
.font(.system(size: 52))
.foregroundStyle(.tint)
Text("Record")
.font(.callout)
}
}
.buttonStyle(.plain)
}
private var recordingState: some View {
VStack(spacing: 14) {
LevelMeter(level: recorder.level)
Text(AudioPlayerView.timeString(recorder.elapsed))
.font(.title2.monospacedDigit())
.foregroundStyle(.primary)
HStack(spacing: 24) {
Button(role: .destructive) {
recorder.discard()
} label: {
Label("Discard", systemImage: "trash")
}
.buttonStyle(.bordered)
Button {
if let finished = recorder.stopRecording() {
onFinish(finished.url, finished.duration)
}
} label: {
Label("Stop", systemImage: "stop.circle.fill")
}
.buttonStyle(.borderedProminent)
}
}
}
private var deniedState: some View {
VStack(spacing: 8) {
Image(systemName: "mic.slash.circle.fill")
.font(.system(size: 44))
.foregroundStyle(.secondary)
Text("Microphone access is off. Enable it for Contextful in Settings to record voice notes.")
.font(.footnote)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
}
private func beginRecording() async {
startError = nil
guard await recorder.requestPermission() else {
permissionDenied = true
return
}
permissionDenied = false
do {
try recorder.startRecording()
} catch {
startError = "Couldn't start recording: \(error.localizedDescription)"
}
}
}
/// A small row of bars whose height rides the current input level enough to
/// show the mic is live without pretending to be a real waveform.
private struct LevelMeter: View {
let level: Float
private let barCount = 9
/// Fixed per-bar weights so the meter has a stable, mic-like shape.
private let weights: [CGFloat] = [0.35, 0.55, 0.75, 0.9, 1.0, 0.9, 0.75, 0.55, 0.35]
var body: some View {
HStack(alignment: .center, spacing: 4) {
ForEach(0..<barCount, id: \.self) { index in
Capsule()
.fill(.tint)
.frame(width: 4, height: height(index))
}
}
.frame(height: 40)
.animation(.easeOut(duration: 0.08), value: level)
}
private func height(_ index: Int) -> CGFloat {
let base: CGFloat = 4
let peak: CGFloat = 36
return base + peak * CGFloat(level) * weights[index]
}
}