fd7e1a5907
AudioRecorder captures system audio (ScreenCaptureKit) + mic (AVAudioEngine) on a single serial ioQueue, one shared monotonic t0, time-driven writers (pad gaps / trim overlaps) so tracks stay aligned, and an energy mic-VAD for 'self' spans. AudioMixer sums the aligned tracks into mixed_mono_16k.wav. SessionController drives a serialized start/stop state machine, writes the session folder + self_vad.json, exposes live level meters, and finalizes on quit. Hardening from review: ioQueue single-domain (no races), stop() never hangs (mic-first teardown + bounded stopCapture), layout-agnostic mic deep-copy, discard-only video output to keep SCStream alive, VAD lockstep on committed frames, stable signing team in project.yml, single-instance enforcement.
205 lines
6.6 KiB
Swift
205 lines
6.6 KiB
Swift
import SwiftUI
|
|
import AppKit
|
|
|
|
/// The menu-bar panel: permission statuses, backend health, and a link to
|
|
/// Settings. Shown when the user clicks the status-bar item.
|
|
struct MenuBarView: View {
|
|
@EnvironmentObject private var settings: AppSettings
|
|
@EnvironmentObject private var permissions: PermissionsManager
|
|
@EnvironmentObject private var health: SparkControlHealth
|
|
@EnvironmentObject private var session: SessionController
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
header
|
|
Divider()
|
|
recordingSection
|
|
Divider()
|
|
permissionsSection
|
|
Divider()
|
|
backendSection
|
|
Divider()
|
|
footer
|
|
}
|
|
.padding(14)
|
|
.frame(width: 320)
|
|
}
|
|
.onAppear { permissions.refresh() }
|
|
.task { await refreshHealth() }
|
|
}
|
|
|
|
// MARK: Recording
|
|
|
|
private var canRecord: Bool {
|
|
permissions.microphone == .granted && permissions.screenRecording == .granted
|
|
}
|
|
|
|
private var recordingSection: some View {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
HStack {
|
|
Text("Recording").font(.subheadline).bold()
|
|
Spacer()
|
|
if session.state == .recording {
|
|
Text(timeString(session.elapsed))
|
|
.font(.system(.caption, design: .monospaced))
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
|
|
Button {
|
|
session.toggle()
|
|
} label: {
|
|
Label(recordButtonTitle, systemImage: recordButtonIcon)
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
.controlSize(.large)
|
|
.tint(session.state == .recording ? .red : .accentColor)
|
|
.disabled(recordButtonDisabled)
|
|
|
|
if session.state == .recording {
|
|
LevelBar(label: "Mic", level: session.micLevel)
|
|
LevelBar(label: "System", level: session.systemLevel)
|
|
}
|
|
|
|
if !canRecord && !session.isBusy {
|
|
Text("Grant Microphone + Screen Recording above to record.")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
|
|
if case .error(let message) = session.state {
|
|
Text(message).font(.caption).foregroundStyle(.red)
|
|
}
|
|
|
|
if let warning = session.warning {
|
|
Text(warning).font(.caption).foregroundStyle(.orange)
|
|
}
|
|
|
|
if let last = session.lastSession {
|
|
Button {
|
|
NSWorkspace.shared.activateFileViewerSelecting([last.mixedURL])
|
|
} label: {
|
|
Text("Last: \(Int(last.duration.rounded()))s · \(last.selfSpanCount) self-spans — reveal in Finder")
|
|
.font(.caption)
|
|
}
|
|
.buttonStyle(.link)
|
|
}
|
|
}
|
|
}
|
|
|
|
private var recordButtonTitle: String {
|
|
switch session.state {
|
|
case .starting: return "Starting…"
|
|
case .recording: return "Stop Recording"
|
|
case .finishing: return "Finishing…"
|
|
case .idle, .error: return "Start Recording"
|
|
}
|
|
}
|
|
|
|
private var recordButtonIcon: String {
|
|
session.state == .recording ? "stop.circle.fill" : "record.circle"
|
|
}
|
|
|
|
private var recordButtonDisabled: Bool {
|
|
switch session.state {
|
|
case .starting, .finishing: return true
|
|
case .recording: return false
|
|
case .idle, .error: return !canRecord
|
|
}
|
|
}
|
|
|
|
private func timeString(_ t: TimeInterval) -> String {
|
|
let total = Int(t)
|
|
return String(format: "%02d:%02d", total / 60, total % 60)
|
|
}
|
|
|
|
private var header: some View {
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text("Ten31 Transcripts").font(.headline)
|
|
Text("Phase 0 · setup & status")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
|
|
private var permissionsSection: some View {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Text("Permissions").font(.subheadline).bold()
|
|
PermissionRow(
|
|
title: "Microphone",
|
|
state: permissions.microphone,
|
|
onGrant: permissions.requestMicrophone,
|
|
onOpenSettings: { permissions.openSettings(.microphone) }
|
|
)
|
|
PermissionRow(
|
|
title: "Screen Recording",
|
|
state: permissions.screenRecording,
|
|
onGrant: permissions.requestScreenRecording,
|
|
onOpenSettings: { permissions.openSettings(.screenRecording) }
|
|
)
|
|
PermissionRow(
|
|
title: "Accessibility",
|
|
state: permissions.accessibility,
|
|
onGrant: permissions.requestAccessibility,
|
|
onOpenSettings: { permissions.openSettings(.accessibility) }
|
|
)
|
|
}
|
|
}
|
|
|
|
private var backendSection: some View {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
HStack {
|
|
Text("Backend").font(.subheadline).bold()
|
|
Spacer()
|
|
Button("Check") { Task { await refreshHealth() } }
|
|
.disabled(health.status == .checking)
|
|
}
|
|
Text(settings.backendBaseURL)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(1)
|
|
.truncationMode(.middle)
|
|
HStack(spacing: 8) {
|
|
StatusDot(color: healthColor)
|
|
Text(healthText).font(.caption)
|
|
}
|
|
}
|
|
}
|
|
|
|
private var footer: some View {
|
|
HStack {
|
|
NavigationLink("Settings…") {
|
|
SettingsView()
|
|
}
|
|
Spacer()
|
|
Button("Quit") { NSApplication.shared.terminate(nil) }
|
|
}
|
|
}
|
|
|
|
private func refreshHealth() async {
|
|
await health.check(
|
|
baseURL: settings.backendBaseURL,
|
|
skipTLS: settings.skipTLSVerification
|
|
)
|
|
}
|
|
|
|
private var healthColor: Color {
|
|
switch health.status {
|
|
case .online: return .green
|
|
case .offline: return .red
|
|
case .checking: return .orange
|
|
case .unknown: return .gray
|
|
}
|
|
}
|
|
|
|
private var healthText: String {
|
|
switch health.status {
|
|
case .unknown: return "Not checked yet"
|
|
case .checking: return "Checking…"
|
|
case .online(let detail): return "Online · \(detail)"
|
|
case .offline(let error): return "Offline · \(error)"
|
|
}
|
|
}
|
|
}
|