Files
ten31-transcripts/Ten31Transcripts/UI/MenuBarView.swift
T
Grant Gilliam fd7e1a5907 Phase 1: dual-track audio capture → mixed-mono 16 kHz WAV + mic VAD
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.
2026-06-05 21:30:11 -05:00

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)"
}
}
}