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,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]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user