From 7ddec9c1932f36627cfa7bc949e429617cc3ac83 Mon Sep 17 00:00:00 2001 From: Owen Gretzinger Date: Wed, 23 Jul 2025 23:15:57 -0400 Subject: [PATCH] feat: recording capsule (#38) --- Meetingnotes.xcodeproj/project.pbxproj | 4 +- .../Icon32.imageset/Contents.json | 22 +++ .../Icon32.imageset/Icon32.png | Bin 0 -> 1421 bytes .../Icon32.imageset/Icon64.png | Bin 0 -> 3790 bytes meetingnotes/Managers/AudioManager.swift | 33 +++- meetingnotes/MeetingnotesApp.swift | 27 +++ .../ViewModels/MeetingViewModel.swift | 2 + meetingnotes/Views/AudioLevelWindow.swift | 165 ++++++++++++++++++ meetingnotes/Views/MeetingDetailView.swift | 2 + 9 files changed, 249 insertions(+), 6 deletions(-) create mode 100644 meetingnotes/Assets.xcassets/Icon32.imageset/Contents.json create mode 100644 meetingnotes/Assets.xcassets/Icon32.imageset/Icon32.png create mode 100644 meetingnotes/Assets.xcassets/Icon32.imageset/Icon64.png create mode 100644 meetingnotes/Views/AudioLevelWindow.swift diff --git a/Meetingnotes.xcodeproj/project.pbxproj b/Meetingnotes.xcodeproj/project.pbxproj index 02c6f39..6c9f23e 100644 --- a/Meetingnotes.xcodeproj/project.pbxproj +++ b/Meetingnotes.xcodeproj/project.pbxproj @@ -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; diff --git a/meetingnotes/Assets.xcassets/Icon32.imageset/Contents.json b/meetingnotes/Assets.xcassets/Icon32.imageset/Contents.json new file mode 100644 index 0000000..6253178 --- /dev/null +++ b/meetingnotes/Assets.xcassets/Icon32.imageset/Contents.json @@ -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 + } +} diff --git a/meetingnotes/Assets.xcassets/Icon32.imageset/Icon32.png b/meetingnotes/Assets.xcassets/Icon32.imageset/Icon32.png new file mode 100644 index 0000000000000000000000000000000000000000..e4e7c2577b6048555cec796020d1dd1dfb45dc06 GIT binary patch literal 1421 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 Ef<0R%E&u=k literal 0 HcmV?d00001 diff --git a/meetingnotes/Managers/AudioManager.swift b/meetingnotes/Managers/AudioManager.swift index 4a94369..0f13a1c 100644 --- a/meetingnotes/Managers/AudioManager.swift +++ b/meetingnotes/Managers/AudioManager.swift @@ -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() diff --git a/meetingnotes/MeetingnotesApp.swift b/meetingnotes/MeetingnotesApp.swift index 3aed1c5..822b193 100644 --- a/meetingnotes/MeetingnotesApp.swift +++ b/meetingnotes/MeetingnotesApp.swift @@ -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() + } + } + } +} diff --git a/meetingnotes/ViewModels/MeetingViewModel.swift b/meetingnotes/ViewModels/MeetingViewModel.swift index 19bbff4..b16313a 100644 --- a/meetingnotes/ViewModels/MeetingViewModel.swift +++ b/meetingnotes/ViewModels/MeetingViewModel.swift @@ -106,6 +106,8 @@ class MeetingViewModel: ObservableObject { } .store(in: &cancellables) + + // Auto-save when meeting properties change $meeting .debounce(for: .milliseconds(500), scheduler: RunLoop.main) diff --git a/meetingnotes/Views/AudioLevelWindow.swift b/meetingnotes/Views/AudioLevelWindow.swift new file mode 100644 index 0000000..f03772d --- /dev/null +++ b/meetingnotes/Views/AudioLevelWindow.swift @@ -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") +} \ No newline at end of file diff --git a/meetingnotes/Views/MeetingDetailView.swift b/meetingnotes/Views/MeetingDetailView.swift index edd3340..4c2257b 100644 --- a/meetingnotes/Views/MeetingDetailView.swift +++ b/meetingnotes/Views/MeetingDetailView.swift @@ -1,5 +1,7 @@ import SwiftUI + + struct CollapsedTranscriptChunkView: View { let chunk: CollapsedTranscriptChunk