- 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
146 lines
4.6 KiB
Swift
146 lines
4.6 KiB
Swift
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]
|
|
}
|
|
}
|