feat: recording capsule (#38)
This commit is contained in:
@@ -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 |
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user