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 0000000..e4e7c25 Binary files /dev/null and b/meetingnotes/Assets.xcassets/Icon32.imageset/Icon32.png differ diff --git a/meetingnotes/Assets.xcassets/Icon32.imageset/Icon64.png b/meetingnotes/Assets.xcassets/Icon32.imageset/Icon64.png new file mode 100644 index 0000000..df8f84d Binary files /dev/null and b/meetingnotes/Assets.xcassets/Icon32.imageset/Icon64.png differ 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