Files
notes/Notes/Views/Audio/AudioRecordingControl.swift
T
rzen 86d74806c9 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
2026-07-15 13:30:43 -04:00

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]
}
}