feat: recording capsule (#38)
This commit is contained in:
@@ -293,7 +293,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/../Frameworks",
|
"@executable_path/../Frameworks",
|
||||||
);
|
);
|
||||||
MACOSX_DEPLOYMENT_TARGET = 14.4;
|
MACOSX_DEPLOYMENT_TARGET = 15.0;
|
||||||
MARKETING_VERSION = 1.0.6;
|
MARKETING_VERSION = 1.0.6;
|
||||||
ONLY_ACTIVE_ARCH = NO;
|
ONLY_ACTIVE_ARCH = NO;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = owen.meetingnotes;
|
PRODUCT_BUNDLE_IDENTIFIER = owen.meetingnotes;
|
||||||
@@ -328,7 +328,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/../Frameworks",
|
"@executable_path/../Frameworks",
|
||||||
);
|
);
|
||||||
MACOSX_DEPLOYMENT_TARGET = 14.4;
|
MACOSX_DEPLOYMENT_TARGET = 15.0;
|
||||||
MARKETING_VERSION = 1.0.6;
|
MARKETING_VERSION = 1.0.6;
|
||||||
ONLY_ACTIVE_ARCH = YES;
|
ONLY_ACTIVE_ARCH = YES;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = owen.meetingnotes;
|
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 |
@@ -12,6 +12,8 @@ class AudioManager: NSObject, ObservableObject {
|
|||||||
@Published var transcriptChunks: [TranscriptChunk] = []
|
@Published var transcriptChunks: [TranscriptChunk] = []
|
||||||
@Published var isRecording = false
|
@Published var isRecording = false
|
||||||
@Published var errorMessage: String?
|
@Published var errorMessage: String?
|
||||||
|
@Published var micAudioLevel: Float = 0.0
|
||||||
|
@Published var systemAudioLevel: Float = 0.0
|
||||||
|
|
||||||
private var audioEngine = AVAudioEngine()
|
private var audioEngine = AVAudioEngine()
|
||||||
private var micSocketTask: URLSessionWebSocketTask?
|
private var micSocketTask: URLSessionWebSocketTask?
|
||||||
@@ -155,15 +157,17 @@ class AudioManager: NSObject, ObservableObject {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Debug mic RMS
|
// Calculate audio level for visual indicator
|
||||||
if let ch = buffer.floatChannelData?[0] {
|
if let ch = buffer.floatChannelData?[0] {
|
||||||
let frameCount = Int(buffer.frameLength)
|
let frameCount = Int(buffer.frameLength)
|
||||||
let samples = UnsafeBufferPointer(start: ch, count: frameCount)
|
let samples = UnsafeBufferPointer(start: ch, count: frameCount)
|
||||||
let rms = sqrt(samples.map { $0 * $0 }.reduce(0, +) / Float(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)
|
// Update the published audio level on main thread
|
||||||
// But for now, just process
|
DispatchQueue.main.async {
|
||||||
|
self.micAudioLevel = rms
|
||||||
|
AudioLevelManager.shared.updateMicLevel(rms)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
self.processAudioBuffer(buffer, converter: converter, targetFormat: targetFormat, source: .mic)
|
self.processAudioBuffer(buffer, converter: converter, targetFormat: targetFormat, source: .mic)
|
||||||
@@ -249,6 +253,7 @@ class AudioManager: NSObject, ObservableObject {
|
|||||||
|
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
self.isRecording = true
|
self.isRecording = true
|
||||||
|
AudioLevelManager.shared.updateRecordingState(true)
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
let errorMsg = "Failed to start system audio tap IO: \(error.localizedDescription)"
|
let errorMsg = "Failed to start system audio tap IO: \(error.localizedDescription)"
|
||||||
@@ -304,6 +309,19 @@ class AudioManager: NSObject, ObservableObject {
|
|||||||
return
|
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)
|
self.processAudioBuffer(buffer, converter: converter, targetFormat: targetFormat, source: .system)
|
||||||
|
|
||||||
} invalidationHandler: { [weak self] _ in
|
} invalidationHandler: { [weak self] _ in
|
||||||
@@ -317,8 +335,15 @@ class AudioManager: NSObject, ObservableObject {
|
|||||||
func stopRecording() {
|
func stopRecording() {
|
||||||
// Immediately mark as not recording to prevent stale callbacks
|
// Immediately mark as not recording to prevent stale callbacks
|
||||||
self.isRecording = false
|
self.isRecording = false
|
||||||
|
AudioLevelManager.shared.updateRecordingState(false)
|
||||||
print("Stopping recording...")
|
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
|
// Stop system audio capture
|
||||||
if isTapActive {
|
if isTapActive {
|
||||||
self.processTap?.invalidate()
|
self.processTap?.invalidate()
|
||||||
|
|||||||
@@ -43,7 +43,19 @@ struct MeetingnotesApp: App {
|
|||||||
CommandGroup(after: .appInfo) {
|
CommandGroup(after: .appInfo) {
|
||||||
CheckForUpdatesView(updater: updaterController.updater)
|
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)
|
.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)
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Auto-save when meeting properties change
|
// Auto-save when meeting properties change
|
||||||
$meeting
|
$meeting
|
||||||
.debounce(for: .milliseconds(500), scheduler: RunLoop.main)
|
.debounce(for: .milliseconds(500), scheduler: RunLoop.main)
|
||||||
|
|||||||
@@ -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
|
import SwiftUI
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
struct CollapsedTranscriptChunkView: View {
|
struct CollapsedTranscriptChunkView: View {
|
||||||
let chunk: CollapsedTranscriptChunk
|
let chunk: CollapsedTranscriptChunk
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user