feat: Enhance audio visualization and recording integration - Improved audio meter visualization, removed debug logs, optimized updates

This commit is contained in:
Beingpax 2025-03-03 21:34:09 +05:45
parent e45112cfd7
commit 57e5d456a6
10 changed files with 226 additions and 375 deletions

View File

@ -483,7 +483,7 @@
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
); );
MACOSX_DEPLOYMENT_TARGET = 14.0; MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = 0.96; MARKETING_VERSION = 0.97;
PRODUCT_BUNDLE_IDENTIFIER = com.prakashjoshipax.VoiceInk; PRODUCT_BUNDLE_IDENTIFIER = com.prakashjoshipax.VoiceInk;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_EMIT_LOC_STRINGS = YES;
@ -516,7 +516,7 @@
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
); );
MACOSX_DEPLOYMENT_TARGET = 14.0; MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = 0.96; MARKETING_VERSION = 0.97;
PRODUCT_BUNDLE_IDENTIFIER = com.prakashjoshipax.VoiceInk; PRODUCT_BUNDLE_IDENTIFIER = com.prakashjoshipax.VoiceInk;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_EMIT_LOC_STRINGS = YES;

View File

@ -1,236 +0,0 @@
import Foundation
import AVFoundation
import CoreAudio
import os
class AudioEngine: ObservableObject {
private let logger = Logger(subsystem: "com.prakashjoshipax.voiceink", category: "AudioEngine")
private lazy var engine = AVAudioEngine()
private lazy var mixer = AVAudioMixerNode()
@Published var isRunning = false
@Published var audioLevel: CGFloat = 0.0
private var lastUpdateTime: TimeInterval = 0
private var inputTap: Any?
private let updateInterval: TimeInterval = 0.05
private let deviceManager = AudioDeviceManager.shared
private var deviceObserver: NSObjectProtocol?
private var isConfiguring = false
init() {
setupDeviceChangeObserver()
}
private func setupDeviceChangeObserver() {
deviceObserver = AudioDeviceConfiguration.createDeviceChangeObserver { [weak self] in
guard let self = self else { return }
if self.isRunning {
self.handleDeviceChange()
}
}
}
private func handleDeviceChange() {
guard !isConfiguring else {
logger.warning("Device change already in progress, skipping")
return
}
isConfiguring = true
logger.info("Handling device change - Current engine state: \(self.isRunning ? "Running" : "Stopped")")
// Stop the engine first
stopAudioEngine()
// Log device change details
let currentDeviceID = deviceManager.getCurrentDevice()
if let deviceName = deviceManager.getDeviceName(deviceID: currentDeviceID) {
logger.info("Switching to device: \(deviceName) (ID: \(currentDeviceID))")
}
// Wait a bit for the system to process the device change
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
guard let self = self else { return }
// Try to start with new device
self.startAudioEngine()
self.isConfiguring = false
logger.info("Device change handling completed")
}
}
private func setupAudioEngine() {
guard inputTap == nil else { return }
let bus = 0
// Get the current device (either selected or fallback)
let currentDeviceID = deviceManager.getCurrentDevice()
if currentDeviceID != 0 {
do {
logger.info("Setting up audio engine with device ID: \(currentDeviceID)")
// Log the device type (helps identify Bluetooth devices)
if let deviceName = deviceManager.getDeviceName(deviceID: currentDeviceID) {
let isBluetoothDevice = deviceName.lowercased().contains("bluetooth")
logger.info("Device type: \(isBluetoothDevice ? "Bluetooth" : "Standard") - \(deviceName)")
}
try configureAudioSession(with: currentDeviceID)
} catch {
logger.error("Audio engine setup failed: \(error.localizedDescription)")
logger.error("Device ID: \(currentDeviceID)")
if let deviceName = deviceManager.getDeviceName(deviceID: currentDeviceID) {
logger.error("Failed device name: \(deviceName)")
}
// Don't return here, let it try with default device
}
} else {
logger.info("No specific device available, using system default")
}
// Wait briefly for device configuration to take effect
Thread.sleep(forTimeInterval: 0.05)
// Log input format details
let inputFormat = engine.inputNode.inputFormat(forBus: bus)
logger.info("""
Input format details:
- Sample Rate: \(inputFormat.sampleRate)
- Channel Count: \(inputFormat.channelCount)
- Common Format: \(inputFormat.commonFormat.rawValue)
- Channel Layout: \(inputFormat.channelLayout?.layoutTag ?? 0)
""")
inputTap = engine.inputNode.installTap(onBus: bus, bufferSize: 1024, format: inputFormat) { [weak self] (buffer, time) in
self?.processAudioBuffer(buffer)
}
}
private func configureAudioSession(with deviceID: AudioDeviceID) throws {
logger.info("Starting audio session configuration for device ID: \(deviceID)")
// Get the audio format from the selected device
let streamFormat = try AudioDeviceConfiguration.configureAudioSession(with: deviceID)
logger.info("Got stream format: \(streamFormat.mSampleRate)Hz, \(streamFormat.mChannelsPerFrame) channels")
// Configure the input node to use the selected device
let inputNode = engine.inputNode
guard let audioUnit = inputNode.audioUnit else {
logger.error("Failed to get audio unit from input node")
throw AudioConfigurationError.failedToGetAudioUnit
}
logger.info("Got audio unit from input node")
// Set the device for the audio unit
try AudioDeviceConfiguration.configureAudioUnit(audioUnit, with: deviceID)
logger.info("Configured audio unit with device")
// Reset the engine to apply the new configuration
engine.stop()
try engine.reset()
logger.info("Reset audio engine")
// Use async dispatch instead of thread sleep
DispatchQueue.global().async {
Thread.sleep(forTimeInterval: 0.05)
self.logger.info("Audio configuration delay completed")
}
}
func startAudioEngine() {
guard !isRunning else { return }
logger.info("Starting audio engine")
do {
setupAudioEngine()
logger.info("Audio engine setup completed")
try engine.prepare()
logger.info("Audio engine prepared")
try engine.start()
isRunning = true
// Log active device and configuration details
let currentDeviceID = deviceManager.getCurrentDevice()
if let deviceName = deviceManager.getDeviceName(deviceID: currentDeviceID) {
let isBluetoothDevice = deviceName.lowercased().contains("bluetooth")
logger.info("""
Audio engine started successfully:
- Device: \(deviceName)
- Device ID: \(currentDeviceID)
- Device Type: \(isBluetoothDevice ? "Bluetooth" : "Standard")
- Engine Status: Running
""")
}
} catch {
logger.error("""
Audio engine start failed:
- Error: \(error.localizedDescription)
- Error Details: \(error)
- Current Device ID: \(self.deviceManager.getCurrentDevice())
- Engine State: \(self.engine.isRunning ? "Running" : "Stopped")
""")
// Clean up on failure
stopAudioEngine()
}
}
func stopAudioEngine() {
guard isRunning else { return }
logger.info("Stopping audio engine")
if let tap = inputTap {
engine.inputNode.removeTap(onBus: 0)
inputTap = nil
}
engine.stop()
// Complete cleanup of the engine
engine = AVAudioEngine() // Create a fresh instance
mixer = AVAudioMixerNode() // Reset mixer
isRunning = false
audioLevel = 0.0
logger.info("Audio engine stopped and reset")
}
private func processAudioBuffer(_ buffer: AVAudioPCMBuffer) {
guard let channelData = buffer.floatChannelData?[0] else { return }
let frameCount = buffer.frameLength
let currentTime = CACurrentMediaTime()
guard currentTime - lastUpdateTime >= updateInterval else { return }
lastUpdateTime = currentTime
// Use vDSP for faster processing
var sum: Float = 0
for frame in 0..<Int(frameCount) {
let sample = abs(channelData[frame])
sum += sample
}
let average = sum / Float(frameCount)
let level = CGFloat(average)
// Apply higher scaling for built-in microphone
let currentDeviceID = deviceManager.getCurrentDevice()
let isBuiltInMic = deviceManager.getDeviceName(deviceID: currentDeviceID)?.lowercased().contains("built-in") ?? false
let scalingFactor: CGFloat = isBuiltInMic ? 11.0 : 5.0 // Higher scaling for built-in mic
DispatchQueue.main.async {
self.audioLevel = min(max(level * scalingFactor, 0), 1)
}
}
deinit {
if let observer = deviceObserver {
NotificationCenter.default.removeObserver(observer)
}
stopAudioEngine()
}
}

View File

@ -3,13 +3,16 @@ import AVFoundation
import CoreAudio import CoreAudio
import os import os
actor Recorder { @MainActor // Change to MainActor since we need to interact with UI
class Recorder: ObservableObject {
private var recorder: AVAudioRecorder? private var recorder: AVAudioRecorder?
private let logger = Logger(subsystem: "com.prakashjoshipax.voiceink", category: "Recorder") private let logger = Logger(subsystem: "com.prakashjoshipax.voiceink", category: "Recorder")
private let deviceManager = AudioDeviceManager.shared private let deviceManager = AudioDeviceManager.shared
private var deviceObserver: NSObjectProtocol? private var deviceObserver: NSObjectProtocol?
private var isReconfiguring = false private var isReconfiguring = false
private let mediaController = MediaController.shared private let mediaController = MediaController.shared
@Published var audioMeter = AudioMeter(averagePower: 0, peakPower: 0)
private var levelMonitorTimer: Timer?
enum RecorderError: Error { enum RecorderError: Error {
case couldNotStartRecording case couldNotStartRecording
@ -139,11 +142,13 @@ actor Recorder {
logger.info("Initializing AVAudioRecorder with URL: \(url.path)") logger.info("Initializing AVAudioRecorder with URL: \(url.path)")
let recorder = try AVAudioRecorder(url: url, settings: recordSettings) let recorder = try AVAudioRecorder(url: url, settings: recordSettings)
recorder.delegate = delegate recorder.delegate = delegate
recorder.isMeteringEnabled = true // Enable metering
logger.info("Attempting to start recording...") logger.info("Attempting to start recording...")
if recorder.record() { if recorder.record() {
logger.info("Recording started successfully") logger.info("Recording started successfully")
self.recorder = recorder self.recorder = recorder
startLevelMonitoring()
} else { } else {
logger.error("Failed to start recording - recorder.record() returned false") logger.error("Failed to start recording - recorder.record() returned false")
logger.error("Current device ID: \(deviceID)") logger.error("Current device ID: \(deviceID)")
@ -170,6 +175,7 @@ actor Recorder {
func stopRecording() { func stopRecording() {
logger.info("Stopping recording") logger.info("Stopping recording")
stopLevelMonitoring()
recorder?.stop() recorder?.stop()
recorder?.delegate = nil // Remove delegate recorder?.delegate = nil // Remove delegate
recorder = nil recorder = nil
@ -186,10 +192,56 @@ actor Recorder {
logger.info("Recording stopped successfully") logger.info("Recording stopped successfully")
} }
private func startLevelMonitoring() {
levelMonitorTimer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { [weak self] _ in
guard let self = self else { return }
self.updateAudioLevel()
}
}
private func stopLevelMonitoring() {
levelMonitorTimer?.invalidate()
levelMonitorTimer = nil
audioMeter = AudioMeter(averagePower: 0, peakPower: 0)
}
private func updateAudioLevel() {
guard let recorder = recorder else { return }
recorder.updateMeters()
// Get the power values in decibels
let averagePowerDb = recorder.averagePower(forChannel: 0)
let peakPowerDb = recorder.peakPower(forChannel: 0)
// Convert from dB to linear scale using proper conversion
let normalizedAverage = pow(10, Double(averagePowerDb) / 30)
let normalizedPeak = pow(10, Double(peakPowerDb) / 30)
// Apply standard scaling factor for all devices
let scalingFactor = 2.5
// Update the audio meter with scaled values
let scaledAverage = min(normalizedAverage * scalingFactor, 1.0)
let scaledPeak = min(normalizedPeak * scalingFactor, 1.0)
audioMeter = AudioMeter(
averagePower: scaledAverage,
peakPower: scaledPeak
)
}
deinit { deinit {
logger.info("Deinitializing Recorder") logger.info("Deinitializing Recorder")
if let observer = deviceObserver { if let observer = deviceObserver {
NotificationCenter.default.removeObserver(observer) NotificationCenter.default.removeObserver(observer)
} }
Task { @MainActor in
stopLevelMonitoring()
}
} }
} }
struct AudioMeter: Equatable {
let averagePower: Double
let peakPower: Double
}

View File

@ -2,7 +2,7 @@ import SwiftUI
struct MiniRecorderView: View { struct MiniRecorderView: View {
@ObservedObject var whisperState: WhisperState @ObservedObject var whisperState: WhisperState
@ObservedObject var audioEngine: AudioEngine @ObservedObject var recorder: Recorder
@EnvironmentObject var windowManager: MiniWindowManager @EnvironmentObject var windowManager: MiniWindowManager
@State private var showPromptPopover = false @State private var showPromptPopover = false
@ -84,7 +84,7 @@ struct MiniRecorderView: View {
NotchStaticVisualizer(color: .white) NotchStaticVisualizer(color: .white)
} else { } else {
NotchAudioVisualizer( NotchAudioVisualizer(
audioLevel: audioEngine.audioLevel, audioMeter: recorder.audioMeter,
color: .white, color: .white,
isActive: whisperState.isRecording isActive: whisperState.isRecording
) )

View File

@ -6,11 +6,11 @@ class MiniWindowManager: ObservableObject {
private var windowController: NSWindowController? private var windowController: NSWindowController?
private var miniPanel: MiniRecorderPanel? private var miniPanel: MiniRecorderPanel?
private let whisperState: WhisperState private let whisperState: WhisperState
private let audioEngine: AudioEngine private let recorder: Recorder
init(whisperState: WhisperState, audioEngine: AudioEngine) { init(whisperState: WhisperState, recorder: Recorder) {
self.whisperState = whisperState self.whisperState = whisperState
self.audioEngine = audioEngine self.recorder = recorder
NotificationCenter.default.addObserver( NotificationCenter.default.addObserver(
self, self,
@ -55,7 +55,7 @@ class MiniWindowManager: ObservableObject {
let metrics = MiniRecorderPanel.calculateWindowMetrics() let metrics = MiniRecorderPanel.calculateWindowMetrics()
let panel = MiniRecorderPanel(contentRect: metrics) let panel = MiniRecorderPanel(contentRect: metrics)
let miniRecorderView = MiniRecorderView(whisperState: whisperState, audioEngine: audioEngine) let miniRecorderView = MiniRecorderView(whisperState: whisperState, recorder: recorder)
.environmentObject(self) .environmentObject(self)
let hostingController = NSHostingController(rootView: miniRecorderView) let hostingController = NSHostingController(rootView: miniRecorderView)

View File

@ -2,7 +2,7 @@ import SwiftUI
struct NotchRecorderView: View { struct NotchRecorderView: View {
@ObservedObject var whisperState: WhisperState @ObservedObject var whisperState: WhisperState
@ObservedObject var audioEngine: AudioEngine @ObservedObject var recorder: Recorder
@EnvironmentObject var windowManager: NotchWindowManager @EnvironmentObject var windowManager: NotchWindowManager
@State private var isHovering = false @State private var isHovering = false
@State private var showPromptPopover = false @State private var showPromptPopover = false
@ -92,7 +92,7 @@ struct NotchRecorderView: View {
NotchStaticVisualizer(color: .white) NotchStaticVisualizer(color: .white)
} else { } else {
NotchAudioVisualizer( NotchAudioVisualizer(
audioLevel: audioEngine.audioLevel, audioMeter: recorder.audioMeter,
color: .white, color: .white,
isActive: whisperState.isRecording isActive: whisperState.isRecording
) )
@ -266,7 +266,7 @@ struct NotchRecordButton: View {
} }
struct NotchAudioVisualizer: View { struct NotchAudioVisualizer: View {
let audioLevel: CGFloat let audioMeter: AudioMeter
let color: Color let color: Color
let isActive: Bool let isActive: Bool
@ -275,23 +275,33 @@ struct NotchAudioVisualizer: View {
private let maxHeight: CGFloat = 18 private let maxHeight: CGFloat = 18
private let audioThreshold: CGFloat = 0.01 private let audioThreshold: CGFloat = 0.01
@State private var barHeights: [CGFloat] @State private var barHeights: [BarLevel] = []
init(audioLevel: CGFloat, color: Color, isActive: Bool) { struct BarLevel {
self.audioLevel = audioLevel var average: CGFloat
var peak: CGFloat
}
init(audioMeter: AudioMeter, color: Color, isActive: Bool) {
self.audioMeter = audioMeter
self.color = color self.color = color
self.isActive = isActive self.isActive = isActive
_barHeights = State(initialValue: Array(repeating: minHeight, count: 5)) _barHeights = State(initialValue: Array(repeating: BarLevel(average: minHeight, peak: minHeight), count: 5))
} }
var body: some View { var body: some View {
HStack(spacing: 2) { HStack(spacing: 2) {
ForEach(0..<barCount, id: \.self) { index in ForEach(0..<barCount, id: \.self) { index in
NotchVisualizerBar(height: barHeights[index], color: color) NotchVisualizerBar(
averageHeight: barHeights[index].average,
peakHeight: barHeights[index].peak,
color: color
)
} }
} }
.onReceive(Timer.publish(every: 0.05, on: .main, in: .common).autoconnect()) { _ in .onChange(of: audioMeter) { newMeter in
if isActive && audioLevel > audioThreshold {
if isActive {
updateBars() updateBars()
} else { } else {
resetBars() resetBars()
@ -303,33 +313,53 @@ struct NotchAudioVisualizer: View {
for i in 0..<barCount { for i in 0..<barCount {
let targetHeight = calculateTargetHeight(for: i) let targetHeight = calculateTargetHeight(for: i)
let speed = CGFloat.random(in: 0.4...0.8) let speed = CGFloat.random(in: 0.4...0.8)
barHeights[i] += (targetHeight - barHeights[i]) * speed
withAnimation(.spring(response: 0.2, dampingFraction: 0.7)) {
barHeights[i].average += (targetHeight.average - barHeights[i].average) * speed
barHeights[i].peak += (targetHeight.peak - barHeights[i].peak) * speed
}
} }
} }
private func resetBars() { private func resetBars() {
withAnimation(.spring(response: 0.2, dampingFraction: 0.7)) {
for i in 0..<barCount { for i in 0..<barCount {
barHeights[i] = minHeight barHeights[i].average = minHeight
barHeights[i].peak = minHeight
}
} }
} }
private func calculateTargetHeight(for index: Int) -> CGFloat { private func calculateTargetHeight(for index: Int) -> BarLevel {
let normalizedLevel = max(0, audioLevel - audioThreshold)
let amplifiedLevel = pow(normalizedLevel, 0.6)
let baseHeight = amplifiedLevel * maxHeight * 1.7
let variation = CGFloat.random(in: -2...2)
let positionFactor = CGFloat(index) / CGFloat(barCount - 1) let positionFactor = CGFloat(index) / CGFloat(barCount - 1)
let curve = sin(positionFactor * .pi) let curve = sin(positionFactor * .pi)
return max(minHeight, min(baseHeight * curve + variation, maxHeight)) let randomFactor = Double.random(in: 0.8...1.2)
let averageBase = audioMeter.averagePower * randomFactor
let peakBase = audioMeter.peakPower * randomFactor
let averageHeight = CGFloat(averageBase) * maxHeight * 1.7 * curve
let peakHeight = CGFloat(peakBase) * maxHeight * 1.7 * curve
let finalAverage = max(minHeight, min(averageHeight, maxHeight))
let finalPeak = max(minHeight, min(peakHeight, maxHeight))
return BarLevel(
average: finalAverage,
peak: finalPeak
)
} }
} }
struct NotchVisualizerBar: View { struct NotchVisualizerBar: View {
let height: CGFloat let averageHeight: CGFloat
let peakHeight: CGFloat
let color: Color let color: Color
var body: some View { var body: some View {
ZStack(alignment: .bottom) {
// Average level bar
RoundedRectangle(cornerRadius: 1.5) RoundedRectangle(cornerRadius: 1.5)
.fill( .fill(
LinearGradient( LinearGradient(
@ -342,8 +372,11 @@ struct NotchVisualizerBar: View {
endPoint: .top endPoint: .top
) )
) )
.frame(width: 2, height: height) .frame(width: 2, height: averageHeight)
.animation(.spring(response: 0.2, dampingFraction: 0.7, blendDuration: 0), value: height)
}
.animation(.spring(response: 0.2, dampingFraction: 0.7, blendDuration: 0), value: averageHeight)
.animation(.spring(response: 0.2, dampingFraction: 0.7, blendDuration: 0), value: peakHeight)
} }
} }
@ -355,7 +388,11 @@ struct NotchStaticVisualizer: View {
var body: some View { var body: some View {
HStack(spacing: 2) { HStack(spacing: 2) {
ForEach(0..<barCount, id: \.self) { index in ForEach(0..<barCount, id: \.self) { index in
NotchVisualizerBar(height: barHeights[index] * 18, color: color) NotchVisualizerBar(
averageHeight: barHeights[index] * 18,
peakHeight: barHeights[index] * 18,
color: color
)
} }
} }
} }

View File

@ -6,11 +6,11 @@ class NotchWindowManager: ObservableObject {
private var windowController: NSWindowController? private var windowController: NSWindowController?
private var notchPanel: NotchRecorderPanel? private var notchPanel: NotchRecorderPanel?
private let whisperState: WhisperState private let whisperState: WhisperState
private let audioEngine: AudioEngine private let recorder: Recorder
init(whisperState: WhisperState, audioEngine: AudioEngine) { init(whisperState: WhisperState, recorder: Recorder) {
self.whisperState = whisperState self.whisperState = whisperState
self.audioEngine = audioEngine self.recorder = recorder
NotificationCenter.default.addObserver( NotificationCenter.default.addObserver(
self, self,
@ -58,8 +58,7 @@ class NotchWindowManager: ObservableObject {
let metrics = NotchRecorderPanel.calculateWindowMetrics() let metrics = NotchRecorderPanel.calculateWindowMetrics()
let panel = NotchRecorderPanel(contentRect: metrics.frame) let panel = NotchRecorderPanel(contentRect: metrics.frame)
// Create the NotchRecorderView and set it as the content let notchRecorderView = NotchRecorderView(whisperState: whisperState, recorder: recorder)
let notchRecorderView = NotchRecorderView(whisperState: whisperState, audioEngine: audioEngine)
.environmentObject(self) .environmentObject(self)
let hostingController = NotchRecorderHostingController(rootView: notchRecorderView) let hostingController = NotchRecorderHostingController(rootView: notchRecorderView)
@ -68,7 +67,6 @@ class NotchWindowManager: ObservableObject {
self.notchPanel = panel self.notchPanel = panel
self.windowController = NSWindowController(window: panel) self.windowController = NSWindowController(window: panel)
// Only use orderFrontRegardless to show without activating
panel.orderFrontRegardless() panel.orderFrontRegardless()
} }

View File

@ -1,54 +1,91 @@
import SwiftUI import SwiftUI
struct VisualizerView: View { struct VisualizerView: View {
@ObservedObject var audioEngine: AudioEngine @ObservedObject var recorder: Recorder
@State private var levels: [CGFloat] = Array(repeating: 0, count: 50) private let barCount = 50
private let smoothingFactor: CGFloat = 0.3 @State private var levels: [BarLevel] = []
private let smoothingFactor: Double = 0.3
struct BarLevel: Equatable {
var average: CGFloat
var peak: CGFloat
}
var body: some View { var body: some View {
GeometryReader { geometry in GeometryReader { geometry in
HStack(alignment: .center, spacing: 4) { HStack(alignment: .center, spacing: 4) {
ForEach(0..<levels.count, id: \.self) { index in ForEach(0..<barCount, id: \.self) { index in
VisualizerBar(level: levels[index]) VisualizerBar(level: levels.isEmpty ? BarLevel(average: 0, peak: 0) : levels[index])
.frame(width: (geometry.size.width - CGFloat(levels.count - 1) * 2) / CGFloat(levels.count)) .frame(width: (geometry.size.width - CGFloat(barCount - 1) * 4) / CGFloat(barCount))
} }
} }
.frame(width: geometry.size.width, height: geometry.size.height) .frame(width: geometry.size.width, height: geometry.size.height)
.background(Color.black.opacity(0.1)) .background(Color.black.opacity(0.1))
.cornerRadius(10) .cornerRadius(10)
.onReceive(audioEngine.$audioLevel) { newLevel in .onAppear {
updateLevels(with: newLevel) levels = Array(repeating: BarLevel(average: 0, peak: 0), count: barCount)
}
.onReceive(recorder.$audioMeter) { newMeter in
updateLevels(with: newMeter)
} }
} }
} }
private func updateLevels(with newLevel: CGFloat) { private func updateLevels(with meter: AudioMeter) {
// Apply smoothing to make transitions more natural // Create new levels with randomization for visual interest
for i in 0..<levels.count { var newLevels: [BarLevel] = []
let randomFactor = CGFloat.random(in: 0.8...1.2) for i in 0..<barCount {
let targetLevel = min(max(newLevel * randomFactor, 0), 1) let randomFactor = Double.random(in: 0.8...1.2)
let smoothedLevel = levels[i] + (targetLevel - levels[i]) * smoothingFactor let targetAverage = min(max(meter.averagePower * randomFactor, 0), 1)
let targetPeak = min(max(meter.peakPower * randomFactor, 0), 1)
let currentLevel = levels[i]
let smoothedAverage = currentLevel.average + (CGFloat(targetAverage) - currentLevel.average) * CGFloat(smoothingFactor)
let smoothedPeak = currentLevel.peak + (CGFloat(targetPeak) - currentLevel.peak) * CGFloat(smoothingFactor)
newLevels.append(BarLevel(
average: smoothedAverage,
peak: smoothedPeak
))
}
withAnimation(.easeInOut(duration: 0.15)) { withAnimation(.easeInOut(duration: 0.15)) {
levels[i] = smoothedLevel levels = newLevels
}
} }
} }
} }
struct VisualizerBar: View { struct VisualizerBar: View {
let level: CGFloat let level: VisualizerView.BarLevel
var body: some View { var body: some View {
GeometryReader { geometry in GeometryReader { geometry in
ZStack(alignment: .bottom) {
// Average level bar
RoundedRectangle(cornerRadius: 2) RoundedRectangle(cornerRadius: 2)
.fill( .fill(
LinearGradient(gradient: Gradient(colors: [.blue, .purple]), LinearGradient(
gradient: Gradient(colors: [.blue.opacity(0.7), .purple.opacity(0.7)]),
startPoint: .bottom, startPoint: .bottom,
endPoint: .top) endPoint: .top
) )
.frame(height: level * geometry.size.height) )
.position(x: geometry.size.width / 2, y: geometry.size.height / 2) .frame(height: level.average * geometry.size.height)
// Peak level indicator
RoundedRectangle(cornerRadius: 2)
.fill(
LinearGradient(
gradient: Gradient(colors: [.blue, .purple]),
startPoint: .bottom,
endPoint: .top
)
)
.frame(height: 2)
.offset(y: -level.peak * geometry.size.height + 1)
.opacity(level.peak > 0.01 ? 1 : 0)
}
.frame(maxHeight: geometry.size.height, alignment: .bottom)
} }
} }
} }

View File

@ -95,7 +95,6 @@ struct VoiceInkApp: App {
WindowManager.shared.configureWindow(window) WindowManager.shared.configureWindow(window)
}) })
.onDisappear { .onDisappear {
whisperState.audioEngine.stopAudioEngine()
whisperState.unloadModel() whisperState.unloadModel()
} }
} else { } else {

View File

@ -58,15 +58,14 @@ class WhisperState: NSObject, ObservableObject, AVAudioRecorderDelegate {
case couldNotLocateModel case couldNotLocateModel
} }
private let modelsDirectory: URL let modelsDirectory: URL
private let recordingsDirectory: URL let recordingsDirectory: URL
private var transcriptionStartTime: Date? private let enhancementService: AIEnhancementService?
private var enhancementService: AIEnhancementService?
private let licenseViewModel: LicenseViewModel private let licenseViewModel: LicenseViewModel
private let logger = Logger(subsystem: "com.prakashjoshipax.voiceink", category: "WhisperState")
private var transcriptionStartTime: Date?
private var notchWindowManager: NotchWindowManager? private var notchWindowManager: NotchWindowManager?
private var miniWindowManager: MiniWindowManager? private var miniWindowManager: MiniWindowManager?
var audioEngine: AudioEngine
private let logger = Logger(subsystem: "com.prakashjoshipax.voiceink", category: "WhisperState")
init(modelContext: ModelContext, enhancementService: AIEnhancementService? = nil) { init(modelContext: ModelContext, enhancementService: AIEnhancementService? = nil) {
self.modelContext = modelContext self.modelContext = modelContext
@ -74,7 +73,6 @@ class WhisperState: NSObject, ObservableObject, AVAudioRecorderDelegate {
self.recordingsDirectory = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] self.recordingsDirectory = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
.appendingPathComponent("com.prakashjoshipax.VoiceInk") .appendingPathComponent("com.prakashjoshipax.VoiceInk")
.appendingPathComponent("Recordings") .appendingPathComponent("Recordings")
self.audioEngine = AudioEngine()
self.enhancementService = enhancementService self.enhancementService = enhancementService
self.licenseViewModel = LicenseViewModel() self.licenseViewModel = LicenseViewModel()
@ -150,7 +148,6 @@ class WhisperState: NSObject, ObservableObject, AVAudioRecorderDelegate {
await recorder.stopRecording() await recorder.stopRecording()
isRecording = false isRecording = false
isVisualizerActive = false isVisualizerActive = false
audioEngine.stopAudioEngine()
if let recordedFile { if let recordedFile {
let duration = Date().timeIntervalSince(transcriptionStartTime ?? Date()) let duration = Date().timeIntervalSince(transcriptionStartTime ?? Date())
await transcribeAudio(recordedFile, duration: duration) await transcribeAudio(recordedFile, duration: duration)
@ -166,10 +163,6 @@ class WhisperState: NSObject, ObservableObject, AVAudioRecorderDelegate {
create: true) create: true)
.appending(path: "output.wav") .appending(path: "output.wav")
if !self.audioEngine.isRunning {
self.audioEngine.startAudioEngine()
}
try await self.recorder.startRecording(toOutputFile: file, delegate: self) try await self.recorder.startRecording(toOutputFile: file, delegate: self)
self.isRecording = true self.isRecording = true
@ -192,7 +185,6 @@ class WhisperState: NSObject, ObservableObject, AVAudioRecorderDelegate {
self.messageLog += "\(error.localizedDescription)\n" self.messageLog += "\(error.localizedDescription)\n"
self.isRecording = false self.isRecording = false
self.isVisualizerActive = false self.isVisualizerActive = false
self.audioEngine.stopAudioEngine()
} }
} }
} else { } else {
@ -479,31 +471,18 @@ class WhisperState: NSObject, ObservableObject, AVAudioRecorderDelegate {
await toggleRecord() await toggleRecord()
} }
} else { } else {
// Serialize audio operations to prevent deadlocks // Start recording first, then show UI
Task { Task {
do { // Start recording immediately
// First start the audio engine await toggleRecord()
await MainActor.run {
audioEngine.startAudioEngine()
}
// Small delay to ensure audio system is ready // Play sound and show UI after recording has started
try await Task.sleep(nanoseconds: 50_000_000) // 50ms
// Now play the sound
SoundManager.shared.playStartSound() SoundManager.shared.playStartSound()
// Show UI
await MainActor.run { await MainActor.run {
showRecorderPanel() showRecorderPanel()
isMiniRecorderVisible = true isMiniRecorderVisible = true
} }
// Finally start recording
await toggleRecord()
} catch {
logger.error("Error during recorder initialization: \(error)")
}
} }
} }
} }
@ -512,25 +491,21 @@ class WhisperState: NSObject, ObservableObject, AVAudioRecorderDelegate {
logger.info("Showing recorder panel, type: \(self.recorderType)") logger.info("Showing recorder panel, type: \(self.recorderType)")
if recorderType == "notch" { if recorderType == "notch" {
if notchWindowManager == nil { if notchWindowManager == nil {
notchWindowManager = NotchWindowManager(whisperState: self, audioEngine: audioEngine) notchWindowManager = NotchWindowManager(whisperState: self, recorder: recorder)
logger.info("Created new notch window manager") logger.info("Created new notch window manager")
} }
notchWindowManager?.show() notchWindowManager?.show()
} else { } else {
if miniWindowManager == nil { if miniWindowManager == nil {
miniWindowManager = MiniWindowManager(whisperState: self, audioEngine: audioEngine) miniWindowManager = MiniWindowManager(whisperState: self, recorder: recorder)
logger.info("Created new mini window manager") logger.info("Created new mini window manager")
} }
miniWindowManager?.show() miniWindowManager?.show()
} }
// Audio engine is now started separately in handleToggleMiniRecorder
// SoundManager.shared.playStartSound() - Moved to handleToggleMiniRecorder
logger.info("Recorder panel shown successfully") logger.info("Recorder panel shown successfully")
} }
private func hideRecorderPanel() { private func hideRecorderPanel() {
audioEngine.stopAudioEngine()
if isRecording { if isRecording {
Task { Task {
await toggleRecord() await toggleRecord()
@ -542,30 +517,20 @@ class WhisperState: NSObject, ObservableObject, AVAudioRecorderDelegate {
if isMiniRecorderVisible { if isMiniRecorderVisible {
await dismissMiniRecorder() await dismissMiniRecorder()
} else { } else {
// Start a parallel task for both UI and recording // Start recording first
Task { await toggleRecord()
// Play start sound first
// Play sound and show UI after recording has started
SoundManager.shared.playStartSound() SoundManager.shared.playStartSound()
// Start audio engine immediately - this can happen in parallel
audioEngine.startAudioEngine()
// Show UI (this is quick now that we removed animations)
await MainActor.run { await MainActor.run {
showRecorderPanel() // Modified version that doesn't start audio engine showRecorderPanel()
isMiniRecorderVisible = true isMiniRecorderVisible = true
} }
// Start recording
await toggleRecord()
}
} }
} }
private func cleanupResources() async { private func cleanupResources() async {
audioEngine.stopAudioEngine()
try? await Task.sleep(nanoseconds: 100_000_000)
if !isRecording && !isProcessing { if !isRecording && !isProcessing {
await whisperContext?.releaseResources() await whisperContext?.releaseResources()
whisperContext = nil whisperContext = nil
@ -616,7 +581,6 @@ class WhisperState: NSObject, ObservableObject, AVAudioRecorderDelegate {
whisperContext = nil whisperContext = nil
isModelLoaded = false isModelLoaded = false
audioEngine.stopAudioEngine()
if let recordedFile = recordedFile { if let recordedFile = recordedFile {
try? FileManager.default.removeItem(at: recordedFile) try? FileManager.default.removeItem(at: recordedFile)
self.recordedFile = nil self.recordedFile = nil