feat: recording capsule (#38)

This commit is contained in:
Owen Gretzinger
2025-07-23 23:15:57 -04:00
committed by GitHub
parent 7f37b00e39
commit 7ddec9c193
9 changed files with 249 additions and 6 deletions
+2 -2
View File
@@ -293,7 +293,7 @@
"$(inherited)",
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 14.4;
MACOSX_DEPLOYMENT_TARGET = 15.0;
MARKETING_VERSION = 1.0.6;
ONLY_ACTIVE_ARCH = NO;
PRODUCT_BUNDLE_IDENTIFIER = owen.meetingnotes;
@@ -328,7 +328,7 @@
"$(inherited)",
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 14.4;
MACOSX_DEPLOYMENT_TARGET = 15.0;
MARKETING_VERSION = 1.0.6;
ONLY_ACTIVE_ARCH = YES;
PRODUCT_BUNDLE_IDENTIFIER = owen.meetingnotes;
@@ -0,0 +1,22 @@
{
"images" : [
{
"filename" : "Icon32.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "Icon64.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

+29 -4
View File
@@ -12,6 +12,8 @@ class AudioManager: NSObject, ObservableObject {
@Published var transcriptChunks: [TranscriptChunk] = []
@Published var isRecording = false
@Published var errorMessage: String?
@Published var micAudioLevel: Float = 0.0
@Published var systemAudioLevel: Float = 0.0
private var audioEngine = AVAudioEngine()
private var micSocketTask: URLSessionWebSocketTask?
@@ -155,15 +157,17 @@ class AudioManager: NSObject, ObservableObject {
return
}
// Debug mic RMS
// Calculate audio level for visual indicator
if let ch = buffer.floatChannelData?[0] {
let frameCount = Int(buffer.frameLength)
let samples = UnsafeBufferPointer(start: ch, count: frameCount)
let rms = sqrt(samples.map { $0 * $0 }.reduce(0, +) / Float(frameCount))
print("🎤 Mic RMS: \(rms)")
// Optional: Check for prolonged silence (e.g., RMS < threshold for multiple buffers)
// But for now, just process
// Update the published audio level on main thread
DispatchQueue.main.async {
self.micAudioLevel = rms
AudioLevelManager.shared.updateMicLevel(rms)
}
}
self.processAudioBuffer(buffer, converter: converter, targetFormat: targetFormat, source: .mic)
@@ -249,6 +253,7 @@ class AudioManager: NSObject, ObservableObject {
DispatchQueue.main.async {
self.isRecording = true
AudioLevelManager.shared.updateRecordingState(true)
}
} catch {
let errorMsg = "Failed to start system audio tap IO: \(error.localizedDescription)"
@@ -304,6 +309,19 @@ class AudioManager: NSObject, ObservableObject {
return
}
// Calculate audio level for visual indicator
if let ch = buffer.floatChannelData?[0] {
let frameCount = Int(buffer.frameLength)
let samples = UnsafeBufferPointer(start: ch, count: frameCount)
let rms = sqrt(samples.map { $0 * $0 }.reduce(0, +) / Float(frameCount))
// Update the published audio level on main thread
DispatchQueue.main.async {
self.systemAudioLevel = rms
AudioLevelManager.shared.updateSystemLevel(rms)
}
}
self.processAudioBuffer(buffer, converter: converter, targetFormat: targetFormat, source: .system)
} invalidationHandler: { [weak self] _ in
@@ -317,8 +335,15 @@ class AudioManager: NSObject, ObservableObject {
func stopRecording() {
// Immediately mark as not recording to prevent stale callbacks
self.isRecording = false
AudioLevelManager.shared.updateRecordingState(false)
print("Stopping recording...")
// Reset audio levels
micAudioLevel = 0.0
systemAudioLevel = 0.0
AudioLevelManager.shared.updateMicLevel(0.0)
AudioLevelManager.shared.updateSystemLevel(0.0)
// Stop system audio capture
if isTapActive {
self.processTap?.invalidate()
+27
View File
@@ -43,7 +43,19 @@ struct MeetingnotesApp: App {
CommandGroup(after: .appInfo) {
CheckForUpdatesView(updater: updaterController.updater)
}
// Hidden command group that handles audio level window notifications
CommandGroup(after: .windowArrangement) {
ShowAudioLevelsCommand()
}
}
// Floating Audio Level Window
Window("", id: "audio-levels") {
AudioLevelWindowView()
}
.windowStyle(.plain)
.windowResizability(.contentSize)
.defaultPosition(.trailing)
}
}
@@ -57,3 +69,18 @@ struct CheckForUpdatesView: View {
.keyboardShortcut("u", modifiers: .command)
}
}
struct ShowAudioLevelsCommand: View {
@Environment(\.openWindow) private var openWindow
var body: some View {
// Empty view - we only need this for the notification observer
EmptyView()
.onReceive(NotificationCenter.default.publisher(for: .openAudioLevelWindow)) { _ in
openWindow(id: "audio-levels")
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
AudioLevelWindowManager.shared.showWindow()
}
}
}
}
@@ -106,6 +106,8 @@ class MeetingViewModel: ObservableObject {
}
.store(in: &cancellables)
// Auto-save when meeting properties change
$meeting
.debounce(for: .milliseconds(500), scheduler: RunLoop.main)
+165
View File
@@ -0,0 +1,165 @@
import SwiftUI
// MARK: - Dancing Audio Bars
struct DancingAudioBars: View {
let micLevel: Float
let systemLevel: Float
var body: some View {
VStack(spacing: 0) {
// App logo
Image("Icon32")
.resizable()
.frame(width: 32, height: 32)
// Interleaved bars - alternating blue (mic) and orange (system)
HStack(spacing: -2) {
// Bar 1: Microphone (blue)
RoundedRectangle(cornerRadius: 2)
.fill(Color.blue.opacity(0.6))
.frame(width: 6, height: getDancingBarHeight(index: 0, level: micLevel))
.animation(.easeInOut(duration: 0.1), value: micLevel)
// Bar 2: System (orange)
RoundedRectangle(cornerRadius: 2)
.fill(Color.orange.opacity(0.6))
.frame(width: 6, height: getDancingBarHeight(index: 1, level: systemLevel))
.animation(.easeInOut(duration: 0.1).delay(0.03), value: systemLevel)
// Bar 3: Microphone (blue)
RoundedRectangle(cornerRadius: 2)
.fill(Color.blue.opacity(0.6))
.frame(width: 6, height: getDancingBarHeight(index: 2, level: micLevel))
.animation(.easeInOut(duration: 0.1).delay(0.06), value: micLevel)
// Bar 4: System (orange)
RoundedRectangle(cornerRadius: 2)
.fill(Color.orange.opacity(0.6))
.frame(width: 6, height: getDancingBarHeight(index: 0, level: systemLevel))
.animation(.easeInOut(duration: 0.1).delay(0.09), value: systemLevel)
// Bar 5: Microphone (blue)
RoundedRectangle(cornerRadius: 2)
.fill(Color.blue.opacity(0.6))
.frame(width: 6, height: getDancingBarHeight(index: 1, level: micLevel))
.animation(.easeInOut(duration: 0.1).delay(0.12), value: micLevel)
// Bar 6: System (orange)
RoundedRectangle(cornerRadius: 2)
.fill(Color.orange.opacity(0.6))
.frame(width: 6, height: getDancingBarHeight(index: 2, level: systemLevel))
.animation(.easeInOut(duration: 0.1).delay(0.15), value: systemLevel)
}
.frame(width: 32, height: 32)
}
.padding(0)
}
private func getDancingBarHeight(index: Int, level: Float) -> CGFloat {
let baseHeight: CGFloat = 4 // Flat when no audio
let maxHeight: CGFloat = 32 // Max dancing height
if level > 0.03 { // Only dance when there's actual audio
// Each bar responds to the same audio level but with different scaling
// This creates a natural dancing effect as audio levels fluctuate
let barVariation: [CGFloat] = [0.9, 1.0, 0.8] // Different responsiveness per bar
let audioResponse = CGFloat(level) * maxHeight * barVariation[index % 3]
return max(baseHeight, min(maxHeight, baseHeight + audioResponse))
}
return baseHeight
}
}
// MARK: - Audio Level Window View
struct AudioLevelWindowView: View {
@StateObject private var audioLevelManager = AudioLevelManager.shared
var body: some View {
let micLevel = audioLevelManager.isRecording ? audioLevelManager.micAudioLevel * 40 : 0
let systemLevel = audioLevelManager.isRecording ? audioLevelManager.systemAudioLevel * 5 : 0
return DancingAudioBars(
micLevel: micLevel,
systemLevel: systemLevel
)
.padding(2)
.background(.regularMaterial.opacity(0.9), in: RoundedRectangle(cornerRadius: 10))
.shadow(color: .black.opacity(0.2), radius: 3)
}
}
// MARK: - Audio Level Manager (Singleton to share data)
class AudioLevelManager: ObservableObject {
static let shared = AudioLevelManager()
@Published var micAudioLevel: Float = 0.0
@Published var systemAudioLevel: Float = 0.0
@Published var isRecording: Bool = false
private init() {}
func updateMicLevel(_ level: Float) {
DispatchQueue.main.async {
self.micAudioLevel = level
}
}
func updateSystemLevel(_ level: Float) {
DispatchQueue.main.async {
self.systemAudioLevel = level
}
}
func updateRecordingState(_ isRecording: Bool) {
DispatchQueue.main.async {
self.isRecording = isRecording
if isRecording {
// Auto-show the window when recording starts
AudioLevelWindowManager.shared.showWindow()
} else {
// Auto-hide the window when recording stops
AudioLevelWindowManager.shared.hideWindow()
}
}
}
}
// MARK: - Audio Level Window Manager
@MainActor
class AudioLevelWindowManager: ObservableObject {
static let shared = AudioLevelWindowManager()
private init() {}
func showWindow() {
// Find the audio levels window and bring it to front
for window in NSApplication.shared.windows {
if window.identifier?.rawValue == "audio-levels" {
window.orderFront(nil)
window.level = .floating
window.collectionBehavior = [.canJoinAllSpaces, .stationary, .ignoresCycle]
window.isMovableByWindowBackground = true
window.hasShadow = true
window.styleMask = [.borderless]
return
}
}
// If window not found, post a notification to trigger opening
NotificationCenter.default.post(name: .openAudioLevelWindow, object: nil)
}
func hideWindow() {
// Find the audio levels window and hide it
for window in NSApplication.shared.windows {
if window.identifier?.rawValue == "audio-levels" {
window.orderOut(nil)
return
}
}
}
}
extension Notification.Name {
static let openAudioLevelWindow = Notification.Name("openAudioLevelWindow")
}
@@ -1,5 +1,7 @@
import SwiftUI
struct CollapsedTranscriptChunkView: View {
let chunk: CollapsedTranscriptChunk