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,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
|
||||
/// 0…1 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user