diff --git a/Sources/VocaMac/Models/AppState.swift b/Sources/VocaMac/Models/AppState.swift index 1d6e3f5..e6c63a5 100644 --- a/Sources/VocaMac/Models/AppState.swift +++ b/Sources/VocaMac/Models/AppState.swift @@ -550,12 +550,14 @@ final class AppState: ObservableObject { // Setup hotkey callbacks hotKeyManager.onRecordingStart = { [weak self] in + PerformanceTrace.event("HotKeyStart") Task { @MainActor in await self?.startRecording() } } hotKeyManager.onRecordingStop = { [weak self] in + PerformanceTrace.event("HotKeyStop") Task { @MainActor in await self?.stopRecordingAndTranscribe() } @@ -915,6 +917,8 @@ final class AppState: ObservableObject { // MARK: - Recording Flow func startRecording() async { + let interval = PerformanceTrace.begin("RecordingStart") + defer { PerformanceTrace.end(interval) } // If we're already recording, this is a recovery attempt — the user // pressed the hotkey again because a previous key-up was missed. // Stop the current recording and transcribe what we have. @@ -1025,6 +1029,8 @@ final class AppState: ObservableObject { } func stopRecordingAndTranscribe(injectResult: Bool = true) async { + let interval = PerformanceTrace.begin("StopToResultQueued") + defer { PerformanceTrace.end(interval) } // Accept stop if we're recording OR if the audio engine thinks // it's recording (covers stuck-state recovery scenarios where // isRecording and appStatus may be out of sync). diff --git a/Sources/VocaMac/Services/AudioEngine.swift b/Sources/VocaMac/Services/AudioEngine.swift index 6a62342..d8408e2 100644 --- a/Sources/VocaMac/Services/AudioEngine.swift +++ b/Sources/VocaMac/Services/AudioEngine.swift @@ -26,6 +26,25 @@ enum AudioCapturePhase: Equatable { var evaluatesStopConditions: Bool { self == .recording } } +/// Reuses the sample-rate converter while the microphone format is stable. +/// A route change replaces it; each independent tap buffer resets its state. +final class AudioConverterCache { + private var converter: AVAudioConverter? + private(set) var creationCount = 0 + + func converter(from source: AVAudioFormat, to destination: AVAudioFormat) -> AVAudioConverter? { + if let converter, + converter.inputFormat == source, + converter.outputFormat == destination { + converter.reset() + return converter + } + converter = AVAudioConverter(from: source, to: destination) + if converter != nil { creationCount += 1 } + return converter + } +} + /// Tracks continuous silence independently of total recording time. struct SilenceDetector { private(set) var lastSoundTime: Date @@ -74,6 +93,7 @@ final class AudioEngine { private var engine: AVAudioEngine? private var pendingEngineRelease: DispatchWorkItem? private var audioBuffer: [Float] = [] + private let converterCache = AudioConverterCache() private var _isCurrentlyRecording = false /// Realtime-safe capture lifecycle for the input tap. /// @@ -1076,7 +1096,10 @@ final class AudioEngine { guard let convertedBuffer = Self.convertToWhisperFormat( buffer, from: inputFormat, - inputChannel: preferredInputChannel + inputChannel: preferredInputChannel, + converterProvider: { [converterCache] source, destination in + converterCache.converter(from: source, to: destination) + } ) else { return } @@ -1101,10 +1124,12 @@ final class AudioEngine { if let channelData = convertedBuffer.floatChannelData { let frameCount = Int(convertedBuffer.frameLength) bufferQueue.sync { - audioBuffer.reserveCapacity(audioBuffer.count + frameCount) - for i in 0.. AVAudioConverter?)? = nil ) -> AVAudioPCMBuffer? { let sourceBuffer: AVAudioPCMBuffer if inputFormat.channelCount > 1 { @@ -1176,7 +1202,8 @@ final class AudioEngine { } // Create a converter - guard let converter = AVAudioConverter(from: sourceFormat, to: whisperFormat) else { + guard let converter = converterProvider?(sourceFormat, whisperFormat) + ?? AVAudioConverter(from: sourceFormat, to: whisperFormat) else { VocaLogger.error(.audioEngine, "Failed to create audio format converter") return nil } @@ -1218,6 +1245,22 @@ final class AudioEngine { return outputBuffer } + /// Bulk append keeps Array's geometric growth instead of reallocating to + /// the exact size for every realtime callback. + static func appendCapturedSamples( + _ samples: UnsafePointer, + count: Int, + muted: Bool, + to destination: inout [Float] + ) { + guard count > 0 else { return } + if muted { + destination.append(contentsOf: repeatElement(Float.zero, count: count)) + } else { + destination.append(contentsOf: UnsafeBufferPointer(start: samples, count: count)) + } + } + /// Keeps a channel selection only while it still describes the live device layout. private func syncPreferredInputChannel( with deviceID: AudioDeviceID, diff --git a/Sources/VocaMac/Services/Logger.swift b/Sources/VocaMac/Services/Logger.swift index 30788c6..66ef537 100644 --- a/Sources/VocaMac/Services/Logger.swift +++ b/Sources/VocaMac/Services/Logger.swift @@ -6,6 +6,125 @@ import Foundation import os +import Darwin + +/// Bounded, cross-process-safe storage for VocaMac's rolling text logs. +enum LogFileStore { + static let activeName = "vocamac.log" + static let lockName = ".vocamac.lock" + + static func withExclusiveLock(in directory: URL, _ body: () throws -> T) rethrows -> T { + let lockURL = directory.appendingPathComponent(lockName) + let descriptor = open(lockURL.path, O_CREAT | O_RDWR, S_IRUSR | S_IWUSR) + guard descriptor >= 0 else { return try body() } + defer { + flock(descriptor, LOCK_UN) + close(descriptor) + } + flock(descriptor, LOCK_EX) + return try body() + } + + static func rotate( + in directory: URL, + maxRotatedFiles: Int, + maximumFileSize: Int? = nil, + fileManager: FileManager = .default + ) throws { + guard maxRotatedFiles > 0 else { return } + let oldest = directory.appendingPathComponent("vocamac.\(maxRotatedFiles).log") + if fileManager.fileExists(atPath: oldest.path) { + try fileManager.removeItem(at: oldest) + } + if maxRotatedFiles > 1 { + for index in stride(from: maxRotatedFiles - 1, through: 1, by: -1) { + let source = directory.appendingPathComponent("vocamac.\(index).log") + let destination = directory.appendingPathComponent("vocamac.\(index + 1).log") + if fileManager.fileExists(atPath: source.path) { + try fileManager.moveItem(at: source, to: destination) + } + } + } + let active = directory.appendingPathComponent(activeName) + if fileManager.fileExists(atPath: active.path) { + let rotated = directory.appendingPathComponent("vocamac.1.log") + try fileManager.moveItem(at: active, to: rotated) + if let maximumFileSize { + try trimToTail(at: rotated, maximumBytes: maximumFileSize) + } + } + } + + /// Keep a legacy oversized log bounded while preserving its newest entries. + static func trimToTail(at url: URL, maximumBytes: Int) throws { + guard maximumBytes > 0 else { + try Data().write(to: url, options: .atomic) + return + } + let handle = try FileHandle(forReadingFrom: url) + defer { try? handle.close() } + let size = try handle.seekToEnd() + guard size > maximumBytes else { return } + + // Inspect the preceding byte so a cutoff at a complete line does not + // discard that line. Only drop a partial leading entry. + try handle.seek(toOffset: size - UInt64(maximumBytes) - 1) + let precedingByte = try handle.read(upToCount: 1)?.first + var tail = try handle.readToEnd() ?? Data() + if precedingByte != 0x0A, let newline = tail.firstIndex(of: 0x0A) { + tail.removeSubrange(tail.startIndex...newline) + } else if precedingByte != 0x0A { + // No complete entry fits; avoid writing a partial UTF-8 sequence. + tail.removeAll() + } + try tail.write(to: url, options: .atomic) + } + + /// Read only enough of a file's tail to satisfy `count` complete lines. + static func tailLines(at url: URL, count: Int, chunkSize: Int = 64 * 1024) -> [String] { + guard count > 0, let handle = try? FileHandle(forReadingFrom: url) else { return [] } + defer { try? handle.close() } + guard let size = try? handle.seekToEnd() else { return [] } + + var offset = size + var data = Data() + while offset > 0 { + let bytes = min(UInt64(chunkSize), offset) + offset -= bytes + do { + try handle.seek(toOffset: offset) + if let chunk = try handle.read(upToCount: Int(bytes)) { + data.insert(contentsOf: chunk, at: 0) + } + } catch { + return [] + } + if data.reduce(into: 0, { $0 += $1 == 0x0A ? 1 : 0 }) > count { + break + } + } + + // If the read began mid-file, discard the partial leading line. + if offset > 0, let newline = data.firstIndex(of: 0x0A) { + data.removeSubrange(data.startIndex...newline) + } + return String(decoding: data, as: UTF8.self) + .split(separator: "\n", omittingEmptySubsequences: true) + .suffix(count) + .map(String.init) + } + + static func lineCount(at url: URL, chunkSize: Int = 64 * 1024) -> Int { + guard let handle = try? FileHandle(forReadingFrom: url) else { return 0 } + defer { try? handle.close() } + var count = 0 + while true { + guard let data = try? handle.read(upToCount: chunkSize), !data.isEmpty else { break } + count += data.reduce(into: 0) { $0 += $1 == 0x0A ? 1 : 0 } + } + return count + } +} /// Log categories for different services and components enum LogCategory: String { @@ -47,7 +166,6 @@ final class VocaLogger { private let logFileURL: URL private let fileQueue = DispatchQueue(label: "com.vocamac.logger.file", attributes: .initiallyInactive) private let osLogger: os.Logger - private var logFileHandle: FileHandle? private let logMaxSize = 1_000_000 private let maxRotatedFiles = 3 private var currentLogLevel: LogLevel = .info @@ -64,7 +182,7 @@ final class VocaLogger { private init() { let appSupportURL = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] self.logDirectory = appSupportURL.appendingPathComponent("VocaMac/logs", isDirectory: true) - self.logFileURL = logDirectory.appendingPathComponent("vocamac.log") + self.logFileURL = logDirectory.appendingPathComponent(LogFileStore.activeName) self.osLogger = os.Logger(subsystem: "com.vocamac", category: "general") try? FileManager.default.createDirectory(at: logDirectory, withIntermediateDirectories: true, attributes: nil) @@ -116,18 +234,25 @@ final class VocaLogger { /// Get the approximate number of log entries in the current log file static var logEntryCount: Int { - guard let content = try? String(contentsOf: VocaLogger.shared.logFileURL, encoding: .utf8) else { - return 0 + let logger = VocaLogger.shared + return LogFileStore.withExclusiveLock(in: logger.logDirectory) { + LogFileStore.lineCount(at: logger.logFileURL) } - return content.components(separatedBy: "\n").filter { !$0.isEmpty }.count } /// Clear all log entries from the current log file static func clearLogs() { - try? "".write(to: VocaLogger.shared.logFileURL, atomically: true, encoding: .utf8) - VocaLogger.shared.fileQueue.async { - VocaLogger.shared.bytesWrittenSinceLastCheck = 0 - VocaLogger.shared.logFileHandle?.seekToEndOfFile() + let logger = VocaLogger.shared + logger.fileQueue.sync { + LogFileStore.withExclusiveLock(in: logger.logDirectory) { + try? Data().write(to: logger.logFileURL) + for index in 1...logger.maxRotatedFiles { + try? FileManager.default.removeItem( + at: logger.logDirectory.appendingPathComponent("vocamac.\(index).log") + ) + } + } + logger.bytesWrittenSinceLastCheck = 0 } VocaLogger.info(.general, "Logs cleared") } @@ -179,25 +304,33 @@ final class VocaLogger { } private func setupLogFile() { - if !FileManager.default.fileExists(atPath: logFileURL.path) { - FileManager.default.createFile(atPath: logFileURL.path, contents: nil, attributes: nil) + LogFileStore.withExclusiveLock(in: logDirectory) { + if !FileManager.default.fileExists(atPath: logFileURL.path) { + FileManager.default.createFile(atPath: logFileURL.path, contents: nil, attributes: nil) + } + checkAndRotateIfNeeded() } - - logFileHandle = FileHandle(forWritingAtPath: logFileURL.path) - logFileHandle?.seekToEndOfFile() - - checkAndRotateIfNeeded() } private func writeToFile(_ data: Data?) { - guard let data, let handle = logFileHandle else { return } - - handle.write(data) - bytesWrittenSinceLastCheck += data.count - - if bytesWrittenSinceLastCheck >= rotationCheckInterval { - bytesWrittenSinceLastCheck = 0 - checkAndRotateIfNeeded() + guard let data else { return } + LogFileStore.withExclusiveLock(in: logDirectory) { + if !FileManager.default.fileExists(atPath: logFileURL.path) { + FileManager.default.createFile(atPath: logFileURL.path, contents: nil) + } + guard let handle = FileHandle(forWritingAtPath: logFileURL.path) else { return } + defer { try? handle.close() } + do { + try handle.seekToEnd() + try handle.write(contentsOf: data) + bytesWrittenSinceLastCheck += data.count + if bytesWrittenSinceLastCheck >= rotationCheckInterval { + bytesWrittenSinceLastCheck = 0 + checkAndRotateIfNeeded() + } + } catch { + osLogger.error("Log write failed: \(error.localizedDescription)") + } } } @@ -213,66 +346,52 @@ final class VocaLogger { } private func performRotation() { - logFileHandle?.closeFile() - logFileHandle = nil - - for i in stride(from: maxRotatedFiles - 1, through: 1, by: -1) { - let oldURL = logDirectory.appendingPathComponent("vocamac.\(i).log") - let newURL = logDirectory.appendingPathComponent("vocamac.\(i + 1).log") - - if FileManager.default.fileExists(atPath: oldURL.path) { - do { - try FileManager.default.moveItem(at: oldURL, to: newURL) - } catch { - osLogger.error("Log rotation: failed to move \(oldURL.lastPathComponent) to \(newURL.lastPathComponent): \(error.localizedDescription)") - } - } - } - - let rotatedURL = logDirectory.appendingPathComponent("vocamac.1.log") do { - try FileManager.default.moveItem(at: logFileURL, to: rotatedURL) + try LogFileStore.rotate( + in: logDirectory, + maxRotatedFiles: maxRotatedFiles, + maximumFileSize: logMaxSize + ) + FileManager.default.createFile(atPath: logFileURL.path, contents: nil) } catch { - osLogger.error("Log rotation: failed to rotate current log: \(error.localizedDescription)") - logFileHandle = FileHandle(forWritingAtPath: logFileURL.path) - logFileHandle?.seekToEndOfFile() + osLogger.error("Log rotation failed: \(error.localizedDescription)") return } - - let oldestURL = logDirectory.appendingPathComponent("vocamac.\(maxRotatedFiles + 1).log") - try? FileManager.default.removeItem(at: oldestURL) - bytesWrittenSinceLastCheck = 0 - setupLogFile() } private func cleanupOrphanedRotatedFiles() { - let fm = FileManager.default - for i in (maxRotatedFiles + 1)...100 { - let url = logDirectory.appendingPathComponent("vocamac.\(i).log") - if fm.fileExists(atPath: url.path) { - try? fm.removeItem(at: url) - } else { - break + LogFileStore.withExclusiveLock(in: logDirectory) { + let fileManager = FileManager.default + let files = (try? fileManager.contentsOfDirectory( + at: logDirectory, + includingPropertiesForKeys: nil + )) ?? [] + for file in files { + let name = file.deletingPathExtension().lastPathComponent + guard file.pathExtension == "log", name.hasPrefix("vocamac."), + let index = Int(name.dropFirst("vocamac.".count)), + index > maxRotatedFiles else { + continue + } + try? fileManager.removeItem(at: file) } } } private func getLastLines(_ count: Int) -> [String] { - var allLines: [String] = [] - - if let currentContent = try? String(contentsOf: logFileURL, encoding: .utf8) { - allLines.append(contentsOf: currentContent.split(separator: "\n", omittingEmptySubsequences: false).map(String.init)) - } - - for i in 1...maxRotatedFiles { - let rotatedURL = logDirectory.appendingPathComponent("vocamac.\(i).log") - if let content = try? String(contentsOf: rotatedURL, encoding: .utf8) { - allLines.insert(contentsOf: content.split(separator: "\n", omittingEmptySubsequences: false).map(String.init).reversed(), at: 0) + guard count > 0 else { return [] } + return LogFileStore.withExclusiveLock(in: logDirectory) { + var result = LogFileStore.tailLines(at: logFileURL, count: count) + for index in 1...maxRotatedFiles where result.count < count { + let older = LogFileStore.tailLines( + at: logDirectory.appendingPathComponent("vocamac.\(index).log"), + count: count - result.count + ) + result.insert(contentsOf: older, at: 0) } + return Array(result.suffix(count)) } - - return Array(allLines.suffix(count)) } private func formatExportedLogs(lastLines: Int = 500) -> String { diff --git a/Sources/VocaMac/Services/PerformanceTrace.swift b/Sources/VocaMac/Services/PerformanceTrace.swift new file mode 100644 index 0000000..568eda5 --- /dev/null +++ b/Sources/VocaMac/Services/PerformanceTrace.swift @@ -0,0 +1,29 @@ +// PerformanceTrace.swift +// VocaMac + +import Foundation +import os + +/// Instruments-visible intervals for user-perceived dictation latency. +enum PerformanceTrace { + struct Interval: @unchecked Sendable { + fileprivate let name: StaticString + fileprivate let id: OSSignpostID + } + + private static let log = OSLog(subsystem: "com.vocamac", category: "Performance") + + static func begin(_ name: StaticString) -> Interval { + let interval = Interval(name: name, id: OSSignpostID(log: log)) + os_signpost(.begin, log: log, name: name, signpostID: interval.id) + return interval + } + + static func end(_ interval: Interval) { + os_signpost(.end, log: log, name: interval.name, signpostID: interval.id) + } + + static func event(_ name: StaticString) { + os_signpost(.event, log: log, name: name) + } +} diff --git a/Sources/VocaMac/Services/TextInjector.swift b/Sources/VocaMac/Services/TextInjector.swift index 5b02506..e50691e 100644 --- a/Sources/VocaMac/Services/TextInjector.swift +++ b/Sources/VocaMac/Services/TextInjector.swift @@ -266,6 +266,7 @@ final class TextInjector { _ request: ClipboardInjectionRequest, completion: @escaping () -> Void ) { + let interval = PerformanceTrace.begin("TextInjectionToPaste") let pasteboard = self.pasteboard // Deep-copy current clipboard state before we overwrite it. @@ -274,6 +275,7 @@ final class TextInjector { let snapshot = request.preserveClipboard ? captureSnapshot(pasteboard) : nil guard writeTranscribedText(request.text, to: pasteboard) else { + PerformanceTrace.end(interval) completion() return } @@ -298,6 +300,7 @@ final class TextInjector { VocaLogger.debug(.textInjector, "Simulating Cmd+V...") simulatePaste() + PerformanceTrace.end(interval) // Always wait before starting the next queued injection, even // when preservation is disabled. Otherwise the next request could diff --git a/Sources/VocaMac/Services/TranscriptionRouter.swift b/Sources/VocaMac/Services/TranscriptionRouter.swift index 7ad986b..f538ea0 100644 --- a/Sources/VocaMac/Services/TranscriptionRouter.swift +++ b/Sources/VocaMac/Services/TranscriptionRouter.swift @@ -85,6 +85,8 @@ extension TranscriptionRouter: SpeechTranscribing { /// Transcription shares the same queue so a hotkey mid-switch cannot /// decode against an unloaded engine. func _loadModel(name: String?, folder: URL?, onPhaseChange: ((String) -> Void)?) async throws { + let interval = PerformanceTrace.begin("ModelLoad") + defer { PerformanceTrace.end(interval) } try await operationSerializer.run { [self] in try await performLoad(name: name, folder: folder, onPhaseChange: onPhaseChange) } @@ -144,7 +146,9 @@ extension TranscriptionRouter: SpeechTranscribing { translate: Bool, vocabulary: String ) async throws -> VocaTranscription { - try await operationSerializer.run { [self] in + let interval = PerformanceTrace.begin("TranscriptionQueueAndDecode") + defer { PerformanceTrace.end(interval) } + return try await operationSerializer.run { [self] in switch activeEngine { case .whisperKit: return try await whisper.transcribe( diff --git a/Sources/VocaMac/Views/MenuBarView.swift b/Sources/VocaMac/Views/MenuBarView.swift index ee06981..b19e6e8 100644 --- a/Sources/VocaMac/Views/MenuBarView.swift +++ b/Sources/VocaMac/Views/MenuBarView.swift @@ -17,14 +17,27 @@ final class ProcessMonitor: ObservableObject { private var timer: Timer? - init() { + init(useTimer: Bool = true) { + if useTimer { + start() + } + } + + deinit { timer?.invalidate() } + + /// Poll only while a resource-usage view is visible. + func start() { + guard timer == nil else { return } refresh() timer = Timer.scheduledTimer(withTimeInterval: 5, repeats: true) { [weak self] _ in self?.refresh() } } - deinit { timer?.invalidate() } + func stop() { + timer?.invalidate() + timer = nil + } /// One-shot resident memory sample for the current process (MB). static func currentResidentMemoryMB() -> Double { @@ -74,6 +87,9 @@ final class ProcessMonitor: ObservableObject { if infoKr == KERN_SUCCESS && threadInfo.flags != TH_FLAGS_IDLE { totalCPU += Double(threadInfo.cpu_usage) / Double(TH_USAGE_SCALE) * 100 } + // task_threads gives the caller a send right for every thread. + // Releasing only the array leaks one right per sampled thread. + mach_port_deallocate(mach_task_self_, threads[i]) } let count2 = Int(threadCount) @@ -92,7 +108,7 @@ struct MenuBarView: View { @EnvironmentObject var appState: AppState @ObservedObject var settingsManager: SettingsWindowManager @ObservedObject var updateWindowManager: UpdateWindowManager - @StateObject private var processMonitor = ProcessMonitor() + @StateObject private var processMonitor = ProcessMonitor(useTimer: false) @State private var audioDevices: [AudioDevice] = [] var body: some View { @@ -134,6 +150,8 @@ struct MenuBarView: View { } .padding(20) .frame(width: 380) + .onAppear { processMonitor.start() } + .onDisappear { processMonitor.stop() } } // MARK: - Header diff --git a/Sources/VocaMac/Views/SettingsView.swift b/Sources/VocaMac/Views/SettingsView.swift index c207074..a2b295e 100644 --- a/Sources/VocaMac/Views/SettingsView.swift +++ b/Sources/VocaMac/Views/SettingsView.swift @@ -1518,7 +1518,7 @@ struct AudioSettingsTab: View { struct DebugTab: View { @EnvironmentObject var appState: AppState - @StateObject private var processMonitor = ProcessMonitor() + @StateObject private var processMonitor = ProcessMonitor(useTimer: false) @State private var logEntryCount: Int = VocaLogger.logEntryCount var body: some View { @@ -1696,6 +1696,8 @@ struct DebugTab: View { } } .formStyle(.grouped) + .onAppear { processMonitor.start() } + .onDisappear { processMonitor.stop() } } // MARK: - Actions diff --git a/Tests/VocaMacTests/LoggerTests.swift b/Tests/VocaMacTests/LoggerTests.swift index d0574fc..5643b04 100644 --- a/Tests/VocaMacTests/LoggerTests.swift +++ b/Tests/VocaMacTests/LoggerTests.swift @@ -77,6 +77,78 @@ final class LogLevelTests: XCTestCase { final class VocaLoggerTests: XCTestCase { + func testTrimPreservesCompleteLineAtExactCutoff() throws { + let file = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: file) } + try Data("old\nनमस्ते\n".utf8).write(to: file) + try LogFileStore.trimToTail(at: file, maximumBytes: Data("नमस्ते\n".utf8).count) + XCTAssertEqual(try String(contentsOf: file, encoding: .utf8), "नमस्ते\n") + } + + func testTrimDoesNotKeepPartialUnicodeEntry() throws { + let file = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: file) } + try Data("नमस्ते".utf8).write(to: file) + try LogFileStore.trimToTail(at: file, maximumBytes: 2) + XCTAssertEqual(try Data(contentsOf: file), Data()) + } + + func testRotationReplacesFullBackupSetAndPreservesOrder() throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + + for (name, contents) in [ + ("vocamac.log", "current"), + ("vocamac.1.log", "one"), + ("vocamac.2.log", "two"), + ("vocamac.3.log", "three"), + ] { + try Data(contents.utf8).write(to: directory.appendingPathComponent(name)) + } + + try LogFileStore.rotate(in: directory, maxRotatedFiles: 3) + + XCTAssertEqual(try String(contentsOf: directory.appendingPathComponent("vocamac.1.log")), "current") + XCTAssertEqual(try String(contentsOf: directory.appendingPathComponent("vocamac.2.log")), "one") + XCTAssertEqual(try String(contentsOf: directory.appendingPathComponent("vocamac.3.log")), "two") + XCTAssertFalse(FileManager.default.fileExists(atPath: directory.appendingPathComponent("vocamac.log").path)) + } + + func testTailReaderHandlesLargeFileAndUnicodeBoundary() throws { + let file = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: file) } + let lines = (0..<20_000).map { "line-\($0)-नमस्ते" } + try (lines.joined(separator: "\n") + "\n").write(to: file, atomically: true, encoding: .utf8) + + XCTAssertEqual(LogFileStore.tailLines(at: file, count: 3), Array(lines.suffix(3))) + XCTAssertEqual(LogFileStore.lineCount(at: file), lines.count) + XCTAssertEqual(LogFileStore.tailLines(at: file, count: 0), []) + } + + func testRotationBoundsLegacyOversizedLog() throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let lines = (0..<100).map { "entry-\($0)-with-padding" } + try (lines.joined(separator: "\n") + "\n").write( + to: directory.appendingPathComponent(LogFileStore.activeName), + atomically: true, + encoding: .utf8 + ) + + try LogFileStore.rotate(in: directory, maxRotatedFiles: 3, maximumFileSize: 256) + + let rotated = directory.appendingPathComponent("vocamac.1.log") + let size = try XCTUnwrap( + FileManager.default.attributesOfItem(atPath: rotated.path)[.size] as? Int + ) + XCTAssertLessThanOrEqual(size, 256) + XCTAssertEqual(LogFileStore.tailLines(at: rotated, count: 1), [try XCTUnwrap(lines.last)]) + } + func testLogFileURLIsValid() { let url = VocaLogger.logFileURL() XCTAssertFalse(url.path.isEmpty, "Log file URL should not be empty") diff --git a/Tests/VocaMacTests/ProcessMonitorTests.swift b/Tests/VocaMacTests/ProcessMonitorTests.swift new file mode 100644 index 0000000..92f9c85 --- /dev/null +++ b/Tests/VocaMacTests/ProcessMonitorTests.swift @@ -0,0 +1,26 @@ +import Darwin +import XCTest + +@testable import VocaMac + +final class ProcessMonitorTests: XCTestCase { + func testRefreshDoesNotLeakCurrentThreadSendRights() throws { + let thread = pthread_mach_thread_np(pthread_self()) + let monitor = ProcessMonitor(useTimer: false) + let before = try sendRightReferences(for: thread) + for _ in 0..<20 { monitor.refresh() } + let after = try sendRightReferences(for: thread) + XCTAssertEqual(after, before) + } + + private func sendRightReferences(for port: mach_port_t) throws -> mach_port_urefs_t { + var references: mach_port_urefs_t = 0 + let result = mach_port_get_refs( + mach_task_self_, port, mach_port_right_t(MACH_PORT_RIGHT_SEND), &references + ) + guard result == KERN_SUCCESS else { + throw NSError(domain: NSMachErrorDomain, code: Int(result)) + } + return references + } +} diff --git a/Tests/VocaMacTests/ServiceTests.swift b/Tests/VocaMacTests/ServiceTests.swift index 0e8787f..e4dc76a 100644 --- a/Tests/VocaMacTests/ServiceTests.swift +++ b/Tests/VocaMacTests/ServiceTests.swift @@ -446,6 +446,75 @@ extension XCTestCase { final class AudioEngineTests: XCTestCase { + func testCachedConversionMatchesFreshConverterAcrossBuffersAndRouteChanges() throws { + let cache = AudioConverterCache() + for rate in [48_000.0, 48_000.0, 44_100.0, 44_100.0] { + let format = try XCTUnwrap(AVAudioFormat(standardFormatWithSampleRate: rate, channels: 1)) + let input = try XCTUnwrap(AVAudioPCMBuffer(pcmFormat: format, frameCapacity: 4_096)) + input.frameLength = 4_096 + let samples = try XCTUnwrap(input.floatChannelData?[0]) + for frame in 0..<4_096 { + samples[frame] = Float(sin(Double(frame) * 2 * .pi * 440 / rate)) * 0.25 + } + let fresh = try XCTUnwrap(AudioEngine.convertToWhisperFormat(input, from: format)) + let cached = try XCTUnwrap(AudioEngine.convertToWhisperFormat( + input, from: format, converterProvider: { cache.converter(from: $0, to: $1) } + )) + XCTAssertEqual(cached.frameLength, fresh.frameLength) + let expected = try XCTUnwrap(fresh.floatChannelData?[0]) + let actual = try XCTUnwrap(cached.floatChannelData?[0]) + for frame in 0..