diff --git a/.github/workflows/swift-ci.yml b/.github/workflows/swift-ci.yml
index e26d7ba..1484da9 100644
--- a/.github/workflows/swift-ci.yml
+++ b/.github/workflows/swift-ci.yml
@@ -15,7 +15,7 @@ on:
jobs:
test:
name: Build and Test Swift App
- runs-on: macos-15
+ runs-on: macos-26
defaults:
run:
working-directory: ZeroGSwift
diff --git a/README.md b/README.md
index 7e45757..b9b4962 100644
--- a/README.md
+++ b/README.md
@@ -1,64 +1,142 @@
-# ZeroG
+
+
+
-Privacy-focused voice typing for macOS.
+# π§βπ ZeroG
-Hold **Left Control** to record, release to transcribe and paste into the active app. Hold **Control + Q** while recording to optionally polish the transcription with Gemini.
+**Open Source Voice Typing for macOS**
-## Requirements
+> **"The voice typing tool so good, you'll forget how to type."**
-- macOS 14+
-- Xcode with Swift 5.9+ toolchain
-- Apple Silicon recommended for WhisperKit performance
+---
-## Development
+## π°οΈ The Manifesto
-```bash
-cd ZeroGSwift
-swift test
-swift run
-```
+**We don't type. We transmit.**
+
+ZeroG was born from the realization that typing is a bottleneck. It is a terrestrial limitation. We spent decades training our fingers to hit 100 Words Per Minute (WPM), only to realize that the speed of thought is infinite.
+
+ZeroG is not just a dictation tool. It is an evolutionary step. Just as an astronaut in orbit unlearns the physics of gravity and expects a pen to float rather than fall, ZeroG users unlearn the friction of the keyboard.
+
+We are building the "Air Gap" for your thoughts: **Private. Local. Weightless.**
+
+---
+
+## π Flight Systems (Features)
-## Build
+- **Zero Friction**: Native Swift running NVIDIA Parakeet on the **Apple Neural Engine** for near-instant transcription. 0 WPM. 100% Output.
+- **Vacuum Sealed**: In space, sound doesn't travel. In ZeroG, your voice doesn't travel either. Audio and text are processed entirely on your Mac. No data leaves the ship.
+- **Universal Comms**: Hold `Left Control` to transmit thought into *any* application. Release to paste.
+- **Gravity Assist** (Optional): Press `ββ₯P` to polish your last transmission with **Apple Foundation Models**, fully on-device (macOS 26 with Apple Intelligence).
+- **Debris Filter**: Strips the "um"s and "uh"s from the feed before payload delivery.
+- **Auto-Cut**: A silence sensor cuts the feed if the mic is left open on dead air.
+- **Flight Recorder**: A native "Glass" HUD that floats above your dock.
+
+
+---
+
+## π οΈ Pre-Flight Check (Installation)
+
+### Prerequisites
+- macOS 14+ (Apple Silicon recommended for optimal thrust)
+- Xcode with a Swift 5.9+ toolchain
+- [Git](https://git-scm.com/)
+
+### 1. Board the Ship
```bash
-cd ZeroGSwift
-swift build -c release
+git clone https://github.com/antonynjoro/ZeroG.git
+cd ZeroG/ZeroGSwift
./build_app.sh
```
-The packaged app is written to:
+The packaged app lands at `ZeroGSwift/build/ZeroG.app`. Move it to `/Applications` and launch.
-```text
-ZeroGSwift/build/ZeroG.app
-```
+First takeoff downloads the transcription model (roughly 460 MB, one time). After that, every flight is fully offline.
+
+### 2. Clearance Codes (Permissions)
+First launch runs a guided pre-flight sequence that requests exactly two clearances:
+
+- **Microphone**: Audio input feed.
+- **Accessibility**: Detects the trigger key and injects the payload (text) into target fields.
+
+That is the full list. No Input Monitoring. No network clearance. Nothing else.
-## Configuration
+### 3. Takeoff
+Look for the microphone icon in your status bar.
-ZeroG reads configuration from environment variables and `UserDefaults`.
+- **Transmit**: Hold `Left Control`. The HUD appears. Speak. Release to paste.
+- **Polished Transmission**: Press `ββ₯P` after a transmission to clean it up and paste the refined version.
-- `DEBUG=true` enables debug logging.
-- `GOOGLE_API_KEY=...` enables optional Gemini polishing.
+---
-In packaged app use, the Gemini key can be set from the menu bar item.
+## πΉοΈ Flight Controls (Configuration)
-## Permissions
+Everything lives in the status bar menu:
-Grant these in **System Settings -> Privacy & Security**:
+- **Trigger Key**: Default is `Left Control`. Swappable.
+- **Polish Shortcut**: Default is `ββ₯P`. Swappable.
-- Microphone
-- Accessibility
-- Input Monitoring
+### Flight Recorder (Logging)
+The Black Box is **OFF** by default for maximum privacy. For development builds, create a `.env` with `DEBUG=true` and reboot systems.
-## Testing and CI
+---
-The active implementation is Swift-only. CI runs on macOS and executes:
+## β οΈ Turbulence (Troubleshooting)
+### Payload Failure (Not Pasting)
+- Check **System Settings > Privacy & Security > Accessibility**.
+- If ZeroG is listed but pasting fails, remove it (-) and re-add it. Old clearance codes expire, especially after rebuilding the app.
+
+### Dead Air (No Audio)
+- Check **System Settings > Privacy & Security > Microphone**.
+- Ensure we have a lock on your comms.
+
+---
+
+## π§ͺ R&D (Development)
+
+### Run From Source
+```bash
+cd ZeroGSwift
+swift run
+```
+
+### Run Diagnostics
```bash
cd ZeroGSwift
-swift package resolve
swift test
-swift build -c release
-./build_app.sh
```
-The legacy Python implementation and Python tests have been removed from the active tree.
+### Blueprint
+- `ZeroGSwift/ZeroG/Core`: Core physics engine (state machine, key monitor, recorder, engines, polish).
+- `ZeroGSwift/ZeroG/GUI`: Visual interface (HUD, status bar, onboarding).
+- `ZeroGSwift/ZeroGTests`: Simulation scenarios.
+
+---
+
+## π
Captain's Log
+
+ZeroG's first prototype flew on Python. In 2026 the whole ship was rebuilt in native Swift.
+
+### [The Swift Era] - 2026
+- **Full Rewrite**: Python systems decommissioned. ZeroG is now a native Swift bird: lighter, faster, silent on the pad.
+- **New Engine**: NVIDIA Parakeet TDT running on the Apple Neural Engine for near-instant transcription.
+- **Gravity Assist 2.0**: Cloud thrusters removed. Polish now runs on-device via Apple Foundation Models. Nothing leaves the ship, ever.
+- **Guided Pre-Flight**: New onboarding sequence walks both permission grants on first launch.
+
+### [v0.10.0] - 2026-02-20
+- **Parallel Chunk Transcription**: Audio chunked and transcribed in the background while you speak, cutting wait-time for long dictations.
+
+### [v0.9.0] - 2026-01-03
+- **Rebrand**: Initiated "ZeroG" protocol.
+- **Brand Guide**: Published `BRANDING.md` for all contributors.
+
+### [v0.8.1] - 2026-01-02
+- **Hands-Free**: Auto-stop on silence.
+
+### [v0.8.0] - 2026-01-02
+- **Universal Injection**: Compatibility across all sectors.
+
+---
+*ZeroG: Don't let gravity hold back your thoughts.*
diff --git a/ZeroGSwift/ZeroG/Config.swift b/ZeroGSwift/ZeroG/Config.swift
index db3209f..a0e5a5c 100644
--- a/ZeroGSwift/ZeroG/Config.swift
+++ b/ZeroGSwift/ZeroG/Config.swift
@@ -39,6 +39,12 @@ enum Config {
/// Interacts with the trailing-audio knobs β see `TranscriptionQuality`.
static let silenceDuration: TimeInterval = 12.0
+ /// Last-resort cap for a recording whose trigger key appears to stay held.
+ /// The release watchdog and silence detector normally stop abandoned sessions
+ /// much sooner; this only bounds audio memory if both safeguards miss one.
+ /// Ten minutes of 16 kHz mono Float audio is roughly 38 MB.
+ static let recordingSafetyTimeout: TimeInterval = 10 * 60
+
/// Seconds to keep recording after key release to capture trailing speech.
/// Sourced from `TranscriptionQuality.recordingTailSeconds`.
static let recordingTailDuration: TimeInterval = TranscriptionQuality.recordingTailSeconds
diff --git a/ZeroGSwift/ZeroG/Core/AVAudioCaptureSession.swift b/ZeroGSwift/ZeroG/Core/AVAudioCaptureSession.swift
new file mode 100644
index 0000000..aa2effc
--- /dev/null
+++ b/ZeroGSwift/ZeroG/Core/AVAudioCaptureSession.swift
@@ -0,0 +1,72 @@
+import AudioToolbox
+import AVFoundation
+
+/// A fresh AVAudioEngine configured for one recording and one concrete input device.
+/// Recreating this object avoids retaining a dead audio route after calls or device changes.
+final class AVAudioCaptureSession: AudioCaptureSession {
+ private enum CaptureError: LocalizedError {
+ case audioUnitUnavailable
+ case deviceSelectionFailed(OSStatus)
+
+ var errorDescription: String? {
+ switch self {
+ case .audioUnitUnavailable:
+ "The microphone audio unit is unavailable."
+ case .deviceSelectionFailed(let status):
+ "The microphone could not be selected (Core Audio error \(status))."
+ }
+ }
+ }
+
+ private let audioEngine: AVAudioEngine
+ private let inputNode: AVAudioInputNode
+
+ let recordingFormat: AVAudioFormat
+
+ init(device: AudioInputDevice) throws {
+ let audioEngine = AVAudioEngine()
+ let inputNode = audioEngine.inputNode
+ guard let audioUnit = inputNode.audioUnit else {
+ throw CaptureError.audioUnitUnavailable
+ }
+
+ var deviceID = device.deviceID
+ let status = AudioUnitSetProperty(
+ audioUnit,
+ kAudioOutputUnitProperty_CurrentDevice,
+ kAudioUnitScope_Global,
+ 0,
+ &deviceID,
+ UInt32(MemoryLayout.size)
+ )
+ guard status == noErr else {
+ throw CaptureError.deviceSelectionFailed(status)
+ }
+
+ self.audioEngine = audioEngine
+ self.inputNode = inputNode
+ self.recordingFormat = inputNode.inputFormat(forBus: 0)
+ }
+
+ func installTap(
+ bufferSize: AVAudioFrameCount,
+ handler: @escaping (AVAudioPCMBuffer, AVAudioFormat) -> Void
+ ) {
+ let format = recordingFormat
+ inputNode.installTap(onBus: 0, bufferSize: bufferSize, format: format) { buffer, _ in
+ handler(buffer, format)
+ }
+ }
+
+ func start() throws {
+ try audioEngine.start()
+ }
+
+ func removeTap() {
+ inputNode.removeTap(onBus: 0)
+ }
+
+ func stop() {
+ audioEngine.stop()
+ }
+}
diff --git a/ZeroGSwift/ZeroG/Core/AppStateMachine.swift b/ZeroGSwift/ZeroG/Core/AppStateMachine.swift
index 8c53f25..1e4c3b4 100644
--- a/ZeroGSwift/ZeroG/Core/AppStateMachine.swift
+++ b/ZeroGSwift/ZeroG/Core/AppStateMachine.swift
@@ -70,10 +70,17 @@ final class AppStateMachine: ObservableObject {
Log.debug("StateMachine", "\(previous) β \(newState)")
}
- /// Convenience: transition to `.idle` after a delay.
+ /// Convenience: transition to `.idle` after a delay β but only if the state
+ /// hasn't moved on in the meantime. An unconditional delayed reset stomped
+ /// a recording that started within the delay window (success β recording β
+ /// timer fires β idle), which left the mic running: the release handler's
+ /// "are we recording?" check failed and the session never stopped, merging
+ /// into the next one (field-captured 2026-07-03 15:53).
func resetToIdle(after delay: TimeInterval = Config.Timing.successReset) {
+ let scheduledFrom = currentState
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
- self?.transition(to: .idle)
+ guard let self, self.currentState == scheduledFrom else { return }
+ self.transition(to: .idle)
}
}
}
diff --git a/ZeroGSwift/ZeroG/Core/AudioCaptureSession.swift b/ZeroGSwift/ZeroG/Core/AudioCaptureSession.swift
new file mode 100644
index 0000000..f80f26a
--- /dev/null
+++ b/ZeroGSwift/ZeroG/Core/AudioCaptureSession.swift
@@ -0,0 +1,14 @@
+import AVFoundation
+
+/// Testable boundary around the AVAudioEngine capture operations used per recording.
+protocol AudioCaptureSession: AnyObject {
+ var recordingFormat: AVAudioFormat { get }
+
+ func installTap(
+ bufferSize: AVAudioFrameCount,
+ handler: @escaping (AVAudioPCMBuffer, AVAudioFormat) -> Void
+ )
+ func start() throws
+ func removeTap()
+ func stop()
+}
diff --git a/ZeroGSwift/ZeroG/Core/AudioInputDevice.swift b/ZeroGSwift/ZeroG/Core/AudioInputDevice.swift
new file mode 100644
index 0000000..f0c4d4b
--- /dev/null
+++ b/ZeroGSwift/ZeroG/Core/AudioInputDevice.swift
@@ -0,0 +1,11 @@
+import CoreAudio
+
+/// A physical or virtual macOS audio input that ZeroG can record from.
+struct AudioInputDevice: Equatable, Identifiable {
+ let deviceID: AudioDeviceID
+ let uid: String
+ let name: String
+ let isBuiltIn: Bool
+
+ var id: String { uid }
+}
diff --git a/ZeroGSwift/ZeroG/Core/AudioInputDeviceManager.swift b/ZeroGSwift/ZeroG/Core/AudioInputDeviceManager.swift
new file mode 100644
index 0000000..4147bd5
--- /dev/null
+++ b/ZeroGSwift/ZeroG/Core/AudioInputDeviceManager.swift
@@ -0,0 +1,165 @@
+import CoreAudio
+import Foundation
+
+/// Discovers macOS input devices and persists an explicit external-microphone choice.
+/// With no explicit choice, the Mac's built-in microphone always wins over the
+/// current system default so calls and newly connected devices cannot reroute ZeroG.
+final class AudioInputDeviceManager {
+ private static let selectedUIDDefaultsKey = "AudioInputDeviceUID"
+
+ private let defaults: UserDefaults
+ private let deviceProvider: () -> [AudioInputDevice]
+
+ init(
+ defaults: UserDefaults = .standard,
+ deviceProvider: @escaping () -> [AudioInputDevice] = AudioInputDeviceManager.discoverDevices
+ ) {
+ self.defaults = defaults
+ self.deviceProvider = deviceProvider
+ }
+
+ var availableDevices: [AudioInputDevice] {
+ deviceProvider().sorted { left, right in
+ if left.isBuiltIn != right.isBuiltIn {
+ return left.isBuiltIn
+ }
+ return left.name.localizedStandardCompare(right.name) == .orderedAscending
+ }
+ }
+
+ var selectedDevice: AudioInputDevice? {
+ let devices = availableDevices
+ if let selectedUID = defaults.string(forKey: Self.selectedUIDDefaultsKey),
+ let selected = devices.first(where: { $0.uid == selectedUID }) {
+ return selected
+ }
+ return devices.first(where: \.isBuiltIn) ?? devices.first
+ }
+
+ /// Built-in is the durable default, so selecting it clears any external override.
+ func select(_ device: AudioInputDevice) {
+ if device.isBuiltIn {
+ defaults.removeObject(forKey: Self.selectedUIDDefaultsKey)
+ } else {
+ defaults.set(device.uid, forKey: Self.selectedUIDDefaultsKey)
+ }
+ }
+
+ private static func discoverDevices() -> [AudioInputDevice] {
+ allDeviceIDs().compactMap { deviceID in
+ guard hasInputStreams(deviceID),
+ let uid = stringProperty(
+ kAudioDevicePropertyDeviceUID,
+ deviceID: deviceID
+ ),
+ let name = stringProperty(
+ kAudioObjectPropertyName,
+ deviceID: deviceID
+ ) else {
+ return nil
+ }
+
+ return AudioInputDevice(
+ deviceID: deviceID,
+ uid: uid,
+ name: name,
+ isBuiltIn: transportType(deviceID) == kAudioDeviceTransportTypeBuiltIn
+ )
+ }
+ }
+
+ private static func allDeviceIDs() -> [AudioDeviceID] {
+ var address = AudioObjectPropertyAddress(
+ mSelector: kAudioHardwarePropertyDevices,
+ mScope: kAudioObjectPropertyScopeGlobal,
+ mElement: kAudioObjectPropertyElementMain
+ )
+ var dataSize: UInt32 = 0
+ guard AudioObjectGetPropertyDataSize(
+ AudioObjectID(kAudioObjectSystemObject),
+ &address,
+ 0,
+ nil,
+ &dataSize
+ ) == noErr else {
+ return []
+ }
+
+ let count = Int(dataSize) / MemoryLayout.stride
+ guard count > 0 else { return [] }
+ var deviceIDs = [AudioDeviceID](repeating: 0, count: count)
+ let status = deviceIDs.withUnsafeMutableBytes { buffer in
+ guard let baseAddress = buffer.baseAddress else { return OSStatus(-50) }
+ return AudioObjectGetPropertyData(
+ AudioObjectID(kAudioObjectSystemObject),
+ &address,
+ 0,
+ nil,
+ &dataSize,
+ baseAddress
+ )
+ }
+ return status == noErr ? deviceIDs : []
+ }
+
+ private static func hasInputStreams(_ deviceID: AudioDeviceID) -> Bool {
+ var address = AudioObjectPropertyAddress(
+ mSelector: kAudioDevicePropertyStreams,
+ mScope: kAudioObjectPropertyScopeInput,
+ mElement: kAudioObjectPropertyElementMain
+ )
+ var dataSize: UInt32 = 0
+ return AudioObjectGetPropertyDataSize(
+ deviceID,
+ &address,
+ 0,
+ nil,
+ &dataSize
+ ) == noErr && dataSize >= MemoryLayout.stride
+ }
+
+ private static func stringProperty(
+ _ selector: AudioObjectPropertySelector,
+ deviceID: AudioDeviceID
+ ) -> String? {
+ var address = AudioObjectPropertyAddress(
+ mSelector: selector,
+ mScope: kAudioObjectPropertyScopeGlobal,
+ mElement: kAudioObjectPropertyElementMain
+ )
+ var value: Unmanaged?
+ var dataSize = UInt32(MemoryLayout?>.size)
+ guard AudioObjectGetPropertyData(
+ deviceID,
+ &address,
+ 0,
+ nil,
+ &dataSize,
+ &value
+ ) == noErr, let value else {
+ return nil
+ }
+ return value.takeRetainedValue() as String
+ }
+
+ private static func transportType(_ deviceID: AudioDeviceID) -> UInt32 {
+ var address = AudioObjectPropertyAddress(
+ mSelector: kAudioDevicePropertyTransportType,
+ mScope: kAudioObjectPropertyScopeGlobal,
+ mElement: kAudioObjectPropertyElementMain
+ )
+ var value: UInt32 = 0
+ var dataSize = UInt32(MemoryLayout.size)
+ guard AudioObjectGetPropertyData(
+ deviceID,
+ &address,
+ 0,
+ nil,
+ &dataSize,
+ &value
+ ) == noErr else {
+ return 0
+ }
+ return value
+ }
+}
diff --git a/ZeroGSwift/ZeroG/Core/AudioRecorder.swift b/ZeroGSwift/ZeroG/Core/AudioRecorder.swift
index 40de504..55222d6 100644
--- a/ZeroGSwift/ZeroG/Core/AudioRecorder.swift
+++ b/ZeroGSwift/ZeroG/Core/AudioRecorder.swift
@@ -35,7 +35,11 @@ final class AudioRecorder: @unchecked Sendable {
// MARK: Audio Engine
- private let audioEngine = AVAudioEngine()
+ typealias CaptureSessionFactory = (AudioInputDevice) throws -> AudioCaptureSession
+
+ private let inputDeviceProvider: () -> AudioInputDevice?
+ private let captureSessionFactory: CaptureSessionFactory
+ private var captureSession: AudioCaptureSession?
private var isRecording = false
// MARK: Audio Accumulation
@@ -57,14 +61,21 @@ final class AudioRecorder: @unchecked Sendable {
// MARK: Lifecycle
- init(stateMachine: AppStateMachine, transcriptionEngine: Transcribing) {
+ init(
+ stateMachine: AppStateMachine,
+ transcriptionEngine: Transcribing,
+ inputDeviceProvider: @escaping () -> AudioInputDevice?,
+ captureSessionFactory: @escaping CaptureSessionFactory = AVAudioCaptureSession.init(device:)
+ ) {
self.stateMachine = stateMachine
self.transcriptionEngine = transcriptionEngine
+ self.inputDeviceProvider = inputDeviceProvider
+ self.captureSessionFactory = captureSessionFactory
}
// MARK: - Recording Control
- /// Begin capturing audio from the default input device.
+ /// Begin capturing audio from the preferred input device.
func startRecording() {
guard !isRecording else { return }
@@ -75,34 +86,57 @@ final class AudioRecorder: @unchecked Sendable {
}
transcribedTexts.removeAll()
- let inputNode = audioEngine.inputNode
- let recordingFormat = inputNode.inputFormat(forBus: 0)
-
- // Ensure we have a valid format
- guard recordingFormat.sampleRate > 0 else {
- DispatchQueue.main.async { [weak self] in
- self?.stateMachine.transition(to: .error("No microphone available"))
- self?.stateMachine.resetToIdle(after: Config.Timing.errorReset)
+ var lastError: Error?
+ for attempt in 1...2 {
+ guard let inputDevice = inputDeviceProvider() else { break }
+
+ do {
+ let session = try captureSessionFactory(inputDevice)
+ let recordingFormat = session.recordingFormat
+ guard recordingFormat.sampleRate > 0, recordingFormat.channelCount > 0 else {
+ Log.info(
+ "AudioRecorder",
+ "Input route for \(inputDevice.name) was invalid; rebuilding (attempt \(attempt)/2)."
+ )
+ continue
+ }
+
+ session.installTap(bufferSize: AudioConstants.bufferSize) { [weak self] buffer, format in
+ self?.processAudioBuffer(buffer, format: format)
+ }
+
+ do {
+ try session.start()
+ } catch {
+ session.removeTap()
+ session.stop()
+ throw error
+ }
+
+ captureSession = session
+ isRecording = true
+ playFeedbackSound()
+ Log.debug(
+ "AudioRecorder",
+ "Recording started with \(inputDevice.name). Format: \(recordingFormat)"
+ )
+ return
+ } catch {
+ lastError = error
+ Log.error(
+ "AudioRecorder",
+ "Could not start input route (attempt \(attempt)/2): \(error.localizedDescription)"
+ )
}
- return
}
-
- // Install tap on the input node for raw audio capture
- inputNode.installTap(onBus: 0, bufferSize: AudioConstants.bufferSize, format: recordingFormat) { [weak self] buffer, _ in
- self?.processAudioBuffer(buffer, format: recordingFormat)
- }
-
- do {
- try audioEngine.start()
- isRecording = true
- playFeedbackSound()
- Log.debug("AudioRecorder", "Recording started. Format: \(recordingFormat)")
- } catch {
- DispatchQueue.main.async { [weak self] in
- self?.stateMachine.transition(to: .error("Mic Error: \(error.localizedDescription)"))
- self?.stateMachine.resetToIdle(after: Config.Timing.errorReset)
- }
+ let diagnostic = lastError.map { ": \($0.localizedDescription)" } ?? ""
+ Log.error("AudioRecorder", "No usable microphone was available\(diagnostic)")
+ DispatchQueue.main.async { [weak self] in
+ self?.stateMachine.transition(
+ to: .error("Microphone unavailable. Choose one from the ZeroG menu")
+ )
+ self?.stateMachine.resetToIdle(after: Config.Timing.errorReset)
}
}
@@ -112,7 +146,11 @@ final class AudioRecorder: @unchecked Sendable {
/// stop" sequence lives in exactly one place. Must be called on the main thread
/// (it touches the state machine).
func beginProcessing() {
- guard stateMachine.currentState == .recording else { return }
+ // Gate on the recorder's own truth, not the display state: if a stray
+ // UI transition ever knocks the state machine out of .recording while
+ // the engine is still capturing, the stop must still happen β a missed
+ // stop leaves the mic running and merges this session into the next.
+ guard isRecording else { return }
stateMachine.transition(to: .processing)
stopRecording()
}
@@ -127,8 +165,9 @@ final class AudioRecorder: @unchecked Sendable {
DispatchQueue.main.asyncAfter(deadline: .now() + Config.recordingTailDuration) { [weak self] in
guard let self else { return }
- self.audioEngine.inputNode.removeTap(onBus: 0)
- self.audioEngine.stop()
+ self.captureSession?.removeTap()
+ self.captureSession?.stop()
+ self.captureSession = nil
let rawAudio: [Float] = self.sampleQueue.sync { self.accumulatedSamples }
let audioData = Self.trimTrailingSilence(rawAudio)
@@ -220,7 +259,7 @@ final class AudioRecorder: @unchecked Sendable {
// Safety-only silence detection. Normal recording still ends when Control is released.
if silenceTracker.observe(rms: rms, at: Date()) == .stop {
- Log.debug("AudioRecorder", "Safety silence detected (>\(Config.silenceDuration)s). Auto-stopping.")
+ Log.info("AudioRecorder", "Safety silence detected (>\(Config.silenceDuration)s). Auto-stopping.")
DispatchQueue.main.async { [weak self] in
self?.beginProcessing()
}
@@ -232,24 +271,28 @@ final class AudioRecorder: @unchecked Sendable {
/// Transcribe audio data on-device and inject the result.
private func transcribeAndInject(audioData: [Float]) async {
guard !audioData.isEmpty else {
+ Log.info("AudioRecorder", "No audio captured β nothing to transcribe.")
DispatchQueue.main.async { [weak self] in
self?.stateMachine.transition(to: .idle)
}
return
}
-
+
do {
let startTime = CFAbsoluteTimeGetCurrent()
+ let audioDuration = Double(audioData.count) / AudioConstants.sampleRate
+ Log.info("AudioRecorder", "Transcribing \(String(format: "%.1f", audioDuration))s of audio...")
// Transcribe
let text = try await transcriptionEngine.transcribe(audioData)
-
+
let transcriptionDuration = CFAbsoluteTimeGetCurrent() - startTime
-
- let audioDuration = Double(audioData.count) / AudioConstants.sampleRate
- Log.debug("AudioRecorder", "Transcribed \(String(format: "%.1f", audioDuration))s audio in \(String(format: "%.2f", transcriptionDuration))s: \(text)")
-
+
+ Log.info("AudioRecorder", "Transcribed \(String(format: "%.1f", audioDuration))s audio in \(String(format: "%.2f", transcriptionDuration))s (\(text.count) chars)")
+ Log.debug("AudioRecorder", "Transcript: \(text)")
+
guard !text.isEmpty else {
+ Log.info("AudioRecorder", "Empty transcription β nothing to paste.")
DispatchQueue.main.async { [weak self] in
self?.stateMachine.transition(to: .idle)
}
@@ -266,6 +309,7 @@ final class AudioRecorder: @unchecked Sendable {
// Inject text. False = Accessibility missing (the injector bailed
// before touching the pasteboard) β never show fake success.
let injected = TextInjector.injectText(finalText)
+ Log.info("AudioRecorder", "Paste \(injected ? "OK" : "FAILED (Accessibility missing)")")
DispatchQueue.main.async { [weak self] in
guard let self else { return }
@@ -286,7 +330,7 @@ final class AudioRecorder: @unchecked Sendable {
}
} catch {
- Log.debug("AudioRecorder", "Transcription error: \(error)")
+ Log.error("AudioRecorder", "Transcription error: \(error)")
DispatchQueue.main.async { [weak self] in
self?.stateMachine.transition(to: .error("Processing Failed"))
self?.stateMachine.resetToIdle(after: Config.Timing.errorReset)
diff --git a/ZeroGSwift/ZeroG/Core/KeyMonitor.swift b/ZeroGSwift/ZeroG/Core/KeyMonitor.swift
index 0f70ad0..70e94ed 100644
--- a/ZeroGSwift/ZeroG/Core/KeyMonitor.swift
+++ b/ZeroGSwift/ZeroG/Core/KeyMonitor.swift
@@ -40,10 +40,22 @@ final class KeyMonitor {
private var isTriggerKeyPressed = false
private var recordingStartTime: Date?
- /// Safety timeout to prevent stuck recording state (2 minutes).
- private let maxRecordingDuration: TimeInterval = 120.0
+ /// Last-resort timeout for a stuck recording. The physical-key watchdog and
+ /// silence detector are the primary safeguards, so legitimate long-form
+ /// dictation gets a much wider window before this backstop fires.
+ private let maxRecordingDuration = Config.recordingSafetyTimeout
private var timeoutTimer: Timer?
+ /// Watchdog that polls the physical trigger-key state while recording.
+ /// Some keys β notably Fn/Globe when macOS maps it to a system action like
+ /// "Show Emoji & Symbols" β get their key-up event swallowed by the OS before
+ /// it reaches our listen-only tap. Without this, a swallowed release strands
+ /// the mic on (no `flagsChanged` ever clears `isTriggerKeyPressed`). The
+ /// watchdog detects the key is no longer physically down and forces release.
+ /// Active only between press and release, so idle CPU is unaffected.
+ private var releaseWatchdog: Timer?
+ private let releaseWatchdogInterval: TimeInterval = 0.12
+
// MARK: Lifecycle
init(
@@ -149,6 +161,7 @@ final class KeyMonitor {
func stop() {
timeoutTimer?.invalidate()
timeoutTimer = nil
+ stopReleaseWatchdog()
NotificationCenter.default.removeObserver(self)
@@ -179,6 +192,7 @@ final class KeyMonitor {
isTriggerKeyPressed = false
timeoutTimer?.invalidate()
timeoutTimer = nil
+ stopReleaseWatchdog()
DispatchQueue.main.async { [weak self] in
self?.onStopRecording()
}
@@ -244,16 +258,23 @@ final class KeyMonitor {
let state = self.stateMachine.currentState
guard state.isReady else {
- Log.debug("KeyMonitor", "Ignoring \(self.triggerKey.displayName) press β app not ready (state: \(state))")
+ Log.info("KeyMonitor", "Press ignored β app busy (state: \(state))")
+ // The user's speech after this press is NOT being recorded β
+ // whether we're still loading the model (~45s after launch) or
+ // mid-transcription. Make that audible instead of silently
+ // eating it.
+ NSSound(named: "Basso")?.play()
return
}
switch state {
case .idle, .success, .error, .needsPermission:
self.recordingStartTime = Date()
+ Log.info("KeyMonitor", "Press β recording (\(self.triggerKey.displayName))")
self.stateMachine.transition(to: .recording)
self.onStartRecording()
self.startTimeoutTimer()
+ self.startReleaseWatchdog()
default:
break
}
@@ -264,6 +285,10 @@ final class KeyMonitor {
isTriggerKeyPressed = false
timeoutTimer?.invalidate()
timeoutTimer = nil
+ stopReleaseWatchdog()
+
+ let held = recordingStartTime.map { String(format: "%.1fs", Date().timeIntervalSince($0)) } ?? "?"
+ Log.info("KeyMonitor", "Release β stop recording (held \(held))")
DispatchQueue.main.async { [weak self] in
self?.onStopRecording()
@@ -279,10 +304,40 @@ final class KeyMonitor {
timeoutTimer = Timer.scheduledTimer(withTimeInterval: maxRecordingDuration, repeats: false) { [weak self] _ in
guard let self else { return }
- Log.debug("KeyMonitor", "Recording timeout (\(self.maxRecordingDuration)s) β forcing stop.")
+ Log.info("KeyMonitor", "Recording timeout (\(self.maxRecordingDuration)s) β forcing stop.")
self.isTriggerKeyPressed = false
+ self.stopReleaseWatchdog()
self.onStopRecording()
}
}
+
+ // MARK: - Release Watchdog
+
+ /// Begin polling the physical trigger-key state. Catches a swallowed key-up
+ /// (e.g. OS-intercepted Fn/Globe) that would otherwise leave the mic stuck on.
+ private func startReleaseWatchdog() {
+ releaseWatchdog?.invalidate()
+ releaseWatchdog = Timer.scheduledTimer(withTimeInterval: releaseWatchdogInterval, repeats: true) { [weak self] _ in
+ guard let self else { return }
+ guard self.isTriggerKeyPressed else {
+ self.stopReleaseWatchdog()
+ return
+ }
+ // Live session modifier flags β same domain as the flagsChanged
+ // events we consume. Per-keycode keyState is unreliable for held
+ // modifiers (Right Shift reads "up" mid-hold β false stop).
+ let flags = CGEventSource.flagsState(.combinedSessionState)
+ let stillDown = flags.contains(self.triggerKey.familyFlagMask)
+ if !stillDown {
+ Log.info("KeyMonitor", "Watchdog: \(self.triggerKey.displayName) release was missed β forcing stop.")
+ self.triggerReleased()
+ }
+ }
+ }
+
+ private func stopReleaseWatchdog() {
+ releaseWatchdog?.invalidate()
+ releaseWatchdog = nil
+ }
}
diff --git a/ZeroGSwift/ZeroG/Core/Log.swift b/ZeroGSwift/ZeroG/Core/Log.swift
index 56037f2..5fde25e 100644
--- a/ZeroGSwift/ZeroG/Core/Log.swift
+++ b/ZeroGSwift/ZeroG/Core/Log.swift
@@ -1,4 +1,5 @@
import Foundation
+import os
// MARK: - Logging
@@ -7,10 +8,15 @@ import Foundation
///
/// - `debug` is compiled out of release builds and takes its message as an
/// `@autoclosure`, so interpolation cost is never paid when DEBUG is off.
-/// - `error` always prints β it's for failures the user/operator should see in
-/// any build.
+/// - `info`/`error` always log β via os.Logger, NOT NSLog: the unified log
+/// redacts NSLog's dynamic content to `` regardless of format
+/// specifiers (`%{public}@` is honored by os_log only β verified in the
+/// field 2026-07-03). `privacy: .public` on the interpolation is the only
+/// thing that keeps `log show` output readable.
enum Log {
+ private static let logger = os.Logger(subsystem: "com.zerog.app", category: "app")
+
/// Verbose / developer-only logging. No-op in release builds.
static func debug(_ tag: String, _ message: @autoclosure () -> String) {
#if DEBUG
@@ -18,12 +24,18 @@ enum Log {
#endif
}
+ /// Always-on breadcrumb for the dictation pipeline (press/release/stop
+ /// reason, audio + inference durations, paste outcome). Sparse by design β
+ /// a handful of lines per dictation, enough to reconstruct a flaky field
+ /// failure from `log show` without a debug build. Never log transcript
+ /// content here β lengths and durations only.
+ static func info(_ tag: String, _ message: String) {
+ logger.log("[\(tag, privacy: .public)] \(message, privacy: .public)")
+ }
+
/// Always-on logging for failures and operationally significant events.
- /// Uses NSLog so the message lands in the unified log (visible in Console
- /// filtered by process "ZeroG") β `print` only reaches stdout, which Console
- /// does not capture for a GUI app launched via Finder/`open`.
+ /// Lands in the unified log (Console, filtered by process "ZeroG").
static func error(_ tag: String, _ message: String) {
- // %{public}@ so the message isn't redacted to in the unified log.
- NSLog("%{public}@", "[\(tag)] \(message)")
+ logger.error("[\(tag, privacy: .public)] \(message, privacy: .public)")
}
}
diff --git a/ZeroGSwift/ZeroG/Core/ParakeetTranscriptionEngine.swift b/ZeroGSwift/ZeroG/Core/ParakeetTranscriptionEngine.swift
index e2bca63..b65be57 100644
--- a/ZeroGSwift/ZeroG/Core/ParakeetTranscriptionEngine.swift
+++ b/ZeroGSwift/ZeroG/Core/ParakeetTranscriptionEngine.swift
@@ -91,9 +91,16 @@ final class ParakeetTranscriptionEngine: Transcribing {
throw TranscriptionError.emptyAudio
}
- // Fresh decoder state per push-to-talk utterance (no cross-utterance carryover).
- var state = try TdtDecoderState(decoderLayers: models.version.decoderLayers)
- let result = try await manager.transcribe(audioArray, decoderState: &state)
+ // Hard deadline: a hung inference must surface as an error the state
+ // machine can recover from, never an indefinite .processing (the UI
+ // would otherwise sit "transcribing" forever and silently drop the
+ // user's next press).
+ let decoderLayers = models.version.decoderLayers
+ let result = try await Self.withTimeout(seconds: Self.transcribeTimeout) {
+ // Fresh decoder state per push-to-talk utterance (no cross-utterance carryover).
+ var state = try TdtDecoderState(decoderLayers: decoderLayers)
+ return try await manager.transcribe(audioArray, decoderState: &state)
+ }
// Parakeet is verbatim by training ("um"/"uh" kept); strip fillers in the text
// domain. Whisper needs no equivalent β its caption-trained decoder self-cleans.
@@ -132,6 +139,11 @@ final class ParakeetTranscriptionEngine: Transcribing {
/// give up and surface a retryable error.
private static let downloadTimeout: Double = 180
+ /// Hard deadline for a single utterance's inference. Push-to-talk audio is
+ /// capped at 120s of recording; anything past this is a hung ANE call, not
+ /// a slow one.
+ private static let transcribeTimeout: Double = 30
+
/// Run `op` with a hard deadline. Whichever finishes first wins; the loser is
/// cancelled. A timeout throws a transcription error the caller can retry.
private static func withTimeout(
@@ -142,7 +154,7 @@ final class ParakeetTranscriptionEngine: Transcribing {
group.addTask { try await op() }
group.addTask {
try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))
- throw TranscriptionError.transcriptionFailed("Model download timed out")
+ throw TranscriptionError.transcriptionFailed("Timed out after \(Int(seconds))s")
}
defer { group.cancelAll() }
return try await group.next()!
diff --git a/ZeroGSwift/ZeroG/Core/TriggerKey.swift b/ZeroGSwift/ZeroG/Core/TriggerKey.swift
index b8e403b..9585ce0 100644
--- a/ZeroGSwift/ZeroG/Core/TriggerKey.swift
+++ b/ZeroGSwift/ZeroG/Core/TriggerKey.swift
@@ -10,14 +10,19 @@ struct TriggerKey: Equatable {
/// Device bits distinguish left from right, preventing stuck-recording
/// when both sides of the same modifier family are held simultaneously.
let deviceFlagMask: UInt64
+ /// Generic CGEventFlags mask for the key's modifier family (maskControl,
+ /// maskShift, β¦). Used by the release watchdog to poll the session's live
+ /// flags via `CGEventSource.flagsState` β per-keycode `keyState` misreports
+ /// held modifiers (e.g. Right Shift reads "up" while physically held).
+ let familyFlagMask: CGEventFlags
static let allOptions: [TriggerKey] = [
- TriggerKey(id: "leftControl", displayName: "Left Control", keyCode: 59, deviceFlagMask: 0x00000001),
- TriggerKey(id: "rightControl", displayName: "Right Control", keyCode: 62, deviceFlagMask: 0x00002000),
- TriggerKey(id: "leftOption", displayName: "Left Option", keyCode: 58, deviceFlagMask: 0x00000020),
- TriggerKey(id: "rightOption", displayName: "Right Option", keyCode: 61, deviceFlagMask: 0x00000040),
- TriggerKey(id: "rightShift", displayName: "Right Shift", keyCode: 60, deviceFlagMask: 0x00000004),
- TriggerKey(id: "fn", displayName: "Fn / Globe", keyCode: 63, deviceFlagMask: 0x00800000),
+ TriggerKey(id: "leftControl", displayName: "Left Control", keyCode: 59, deviceFlagMask: 0x00000001, familyFlagMask: .maskControl),
+ TriggerKey(id: "rightControl", displayName: "Right Control", keyCode: 62, deviceFlagMask: 0x00002000, familyFlagMask: .maskControl),
+ TriggerKey(id: "leftOption", displayName: "Left Option", keyCode: 58, deviceFlagMask: 0x00000020, familyFlagMask: .maskAlternate),
+ TriggerKey(id: "rightOption", displayName: "Right Option", keyCode: 61, deviceFlagMask: 0x00000040, familyFlagMask: .maskAlternate),
+ TriggerKey(id: "rightShift", displayName: "Right Shift", keyCode: 60, deviceFlagMask: 0x00000004, familyFlagMask: .maskShift),
+ TriggerKey(id: "fn", displayName: "Fn / Globe", keyCode: 63, deviceFlagMask: 0x00800000, familyFlagMask: .maskSecondaryFn),
]
static let defaultKey = allOptions[0]
diff --git a/ZeroGSwift/ZeroG/GUI/OnboardingWindow.swift b/ZeroGSwift/ZeroG/GUI/OnboardingWindow.swift
index 886105a..6639620 100644
--- a/ZeroGSwift/ZeroG/GUI/OnboardingWindow.swift
+++ b/ZeroGSwift/ZeroG/GUI/OnboardingWindow.swift
@@ -71,6 +71,10 @@ final class OnboardingViewModel: ObservableObject {
/// Set when Accessibility was granted but the key tap could not be created
/// live β the step then asks the user to relaunch.
@Published var relaunchRequired = false
+ /// Whether the one-shot OS Accessibility prompt has been fired this session.
+ /// Drives the button label (first click shows the prompt, later clicks
+ /// deep-link to Settings).
+ @Published private(set) var didPromptAccessibility = false
let permissions: PermissionsManager
@@ -147,15 +151,25 @@ final class OnboardingViewModel: ObservableObject {
}
}
- /// Fire the native request (pre-lists ZeroG in the pane + shows the OS
- /// "β¦would like toβ¦" prompt) and then open the pane β so the app is already
- /// listed with a toggle when the user arrives, no "+ and hunt" friction.
+ /// First click: fire ONLY the native OS prompt ("ZeroG would like toβ¦").
+ /// That dialog is what pre-lists ZeroG in the Accessibility pane, and its
+ /// own "Open System Settings" button lands the user on a pane where the
+ /// toggle already exists. Opening the pane ourselves in the same breath
+ /// covered the prompt and could race the pre-listing β the user arrived at
+ /// a list with no ZeroG entry.
+ ///
+ /// macOS shows that prompt at most once per app record, so later clicks
+ /// fall through to the Settings deep link (request() stays: it's a silent
+ /// no-op then, but still pre-lists after a tccutil reset). Grant detection
+ /// is the 1s poll reading AXIsProcessTrusted β never the tap (tapCreate can
+ /// succeed with events withheld, so it proves nothing about the grant).
func requestAndOpenSettings(for step: OnboardingStep) {
guard let kind = step.permission else { return }
- // request() (AXIsProcessTrustedWithOptions prompt) pre-lists ZeroG in the
- // Accessibility pane so the toggle is already there. Grant detection is the
- // 1s poll reading AXIsProcessTrusted β never the tap (tapCreate can succeed
- // with events withheld, so it proves nothing about the grant).
+ if kind == .accessibility && !didPromptAccessibility {
+ didPromptAccessibility = true
+ permissions.request(kind)
+ return
+ }
permissions.request(kind)
permissions.openSettings(for: kind)
}
@@ -754,9 +768,14 @@ struct OnboardingWizardView: View {
} else if granted {
PrimaryButton(title: "Continue") { model.advance() }.riseIn(0.30)
} else {
+ let promptFirst = model.step == .accessibility && !model.didPromptAccessibility
VStack(spacing: 0) {
- PrimaryButton(title: "Open System Settings") { model.requestAndOpenSettings(for: model.step) }.riseIn(0.30)
- Text("Turn on the ZeroG toggle, then come back. We'll detect it for you.")
+ PrimaryButton(title: promptFirst ? "Allow Accessibility" : "Open System Settings") {
+ model.requestAndOpenSettings(for: model.step)
+ }.riseIn(0.30)
+ Text(promptFirst
+ ? "macOS will ask for permission β use its Open System Settings button."
+ : "Turn on the ZeroG toggle, then come back. We'll detect it for you.")
.font(.system(size: 11)).foregroundColor(OB.textFaint)
.padding(.top, 12).riseIn(0.36)
}
diff --git a/ZeroGSwift/ZeroG/GUI/StatusBarController.swift b/ZeroGSwift/ZeroG/GUI/StatusBarController.swift
index 9a9842e..1082c23 100644
--- a/ZeroGSwift/ZeroG/GUI/StatusBarController.swift
+++ b/ZeroGSwift/ZeroG/GUI/StatusBarController.swift
@@ -22,11 +22,13 @@ final class StatusBarController: NSObject, NSMenuDelegate {
private var polishReasonMenuItem: NSMenuItem!
private var polishShortcutSubmenu: NSMenu!
private var triggerKeySubmenu: NSMenu!
+ private var audioInputSubmenu: NSMenu!
private var cancellables = Set()
// MARK: Dependencies
private let stateMachine: AppStateMachine
+ private let audioInputManager: AudioInputDeviceManager
/// Opens the permissions / setup wizard.
private let onShowPermissions: () -> Void
@@ -40,10 +42,12 @@ final class StatusBarController: NSObject, NSMenuDelegate {
// MARK: Initialization
init(stateMachine: AppStateMachine,
+ audioInputManager: AudioInputDeviceManager,
onShowPermissions: @escaping () -> Void = {},
onCopyPolished: @escaping () -> Void = {},
onRetryModel: @escaping () -> Void = {}) {
self.stateMachine = stateMachine
+ self.audioInputManager = audioInputManager
self.onShowPermissions = onShowPermissions
self.onCopyPolished = onCopyPolished
self.onRetryModel = onRetryModel
@@ -104,6 +108,14 @@ final class StatusBarController: NSObject, NSMenuDelegate {
triggerKeyItem.submenu = triggerKeySubmenu
menu.addItem(triggerKeyItem)
+ // Audio input submenu. Rebuilt whenever the menu opens so newly connected
+ // microphones appear without relaunching the app.
+ let audioInputItem = NSMenuItem(title: "Microphone", action: nil, keyEquivalent: "")
+ audioInputSubmenu = NSMenu()
+ rebuildAudioInputSubmenu()
+ audioInputItem.submenu = audioInputSubmenu
+ menu.addItem(audioInputItem)
+
menu.addItem(NSMenuItem.separator())
// Copy Last Transcription
@@ -164,6 +176,8 @@ final class StatusBarController: NSObject, NSMenuDelegate {
/// Refresh dynamic enablement each time the menu opens β polish availability
/// (Apple Intelligence can be toggled) and whether there's a transcription to act on.
func menuWillOpen(_ menu: NSMenu) {
+ rebuildAudioInputSubmenu()
+
let hasText = stateMachine.lastTranscription != nil
copyTranscriptionMenuItem.isEnabled = hasText
@@ -226,6 +240,42 @@ final class StatusBarController: NSObject, NSMenuDelegate {
}
}
+ // MARK: - Audio Input Selection
+
+ private func rebuildAudioInputSubmenu() {
+ audioInputSubmenu.removeAllItems()
+ let devices = audioInputManager.availableDevices
+ guard !devices.isEmpty else {
+ let item = NSMenuItem(title: "No microphones available", action: nil, keyEquivalent: "")
+ item.isEnabled = false
+ audioInputSubmenu.addItem(item)
+ return
+ }
+
+ let selectedUID = audioInputManager.selectedDevice?.uid
+ for device in devices {
+ let suffix = device.isBuiltIn ? " (Default)" : ""
+ let item = NSMenuItem(
+ title: device.name + suffix,
+ action: #selector(audioInputSelected(_:)),
+ keyEquivalent: ""
+ )
+ item.target = self
+ item.representedObject = device.uid
+ item.state = device.uid == selectedUID ? .on : .off
+ audioInputSubmenu.addItem(item)
+ }
+ }
+
+ @objc private func audioInputSelected(_ sender: NSMenuItem) {
+ guard let uid = sender.representedObject as? String,
+ let device = audioInputManager.availableDevices.first(where: { $0.uid == uid }) else {
+ return
+ }
+ audioInputManager.select(device)
+ rebuildAudioInputSubmenu()
+ }
+
// MARK: - Permissions / Setup
@objc private func showPermissions() {
diff --git a/ZeroGSwift/ZeroG/ZeroGApp.swift b/ZeroGSwift/ZeroG/ZeroGApp.swift
index b5fd15e..f0acabb 100644
--- a/ZeroGSwift/ZeroG/ZeroGApp.swift
+++ b/ZeroGSwift/ZeroG/ZeroGApp.swift
@@ -23,6 +23,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @unchecked Sendable {
private var stateMachine: AppStateMachine!
private var transcriptionEngine: Transcribing!
private var audioRecorder: AudioRecorder!
+ private var audioInputManager: AudioInputDeviceManager!
private var keyMonitor: KeyMonitor!
// MARK: GUI Components
@@ -45,11 +46,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @unchecked Sendable {
// Initialize core
stateMachine = AppStateMachine()
transcriptionEngine = Self.makeTranscriptionEngine(for: Config.sttBackend)
+ audioInputManager = AudioInputDeviceManager()
Log.debug("ZeroGApp", "STT backend: \(Config.sttBackend.rawValue)")
audioRecorder = AudioRecorder(
stateMachine: stateMachine,
- transcriptionEngine: transcriptionEngine
+ transcriptionEngine: transcriptionEngine,
+ inputDeviceProvider: { [weak self] in self?.audioInputManager.selectedDevice }
)
keyMonitor = KeyMonitor(
@@ -114,6 +117,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @unchecked Sendable {
// Initialize GUI
statusBarController = StatusBarController(
stateMachine: stateMachine,
+ audioInputManager: audioInputManager,
onShowPermissions: { [weak self] in self?.onboardingController.show() },
onCopyPolished: { [weak self] in self?.copyPolished() },
onRetryModel: { [weak self] in self?.startModelLoad() }
diff --git a/ZeroGSwift/ZeroGTests/AppStateMachineTests.swift b/ZeroGSwift/ZeroGTests/AppStateMachineTests.swift
index a0de5ac..b8f9f16 100644
--- a/ZeroGSwift/ZeroGTests/AppStateMachineTests.swift
+++ b/ZeroGSwift/ZeroGTests/AppStateMachineTests.swift
@@ -133,4 +133,17 @@ struct AppStateMachineTests {
#expect(machine.currentState == .idle)
}
+
+ @Test("resetToIdle is a no-op when a new recording started inside the delay window")
+ func resetToIdleDoesNotStompNewRecording() async throws {
+ let machine = AppStateMachine()
+ machine.transition(to: .success)
+
+ machine.resetToIdle(after: 0.01)
+ // The user pressed the trigger again before the reset fired.
+ machine.transition(to: .recording)
+ try await Task.sleep(for: .milliseconds(50))
+
+ #expect(machine.currentState == .recording)
+ }
}
diff --git a/ZeroGSwift/ZeroGTests/AudioInputDeviceManagerTests.swift b/ZeroGSwift/ZeroGTests/AudioInputDeviceManagerTests.swift
new file mode 100644
index 0000000..a8dd566
--- /dev/null
+++ b/ZeroGSwift/ZeroGTests/AudioInputDeviceManagerTests.swift
@@ -0,0 +1,82 @@
+import Foundation
+import Testing
+@testable import ZeroG
+
+@Suite(.serialized)
+struct AudioInputDeviceManagerTests {
+ private let builtIn = AudioInputDevice(
+ deviceID: 1,
+ uid: "built-in",
+ name: "MacBook Pro Microphone",
+ isBuiltIn: true
+ )
+
+ private let external = AudioInputDevice(
+ deviceID: 2,
+ uid: "external",
+ name: "Studio Microphone",
+ isBuiltIn: false
+ )
+
+ @Test("Built-in microphone is preferred when no selection is stored")
+ func builtInIsTheDefault() {
+ let defaults = makeDefaults()
+ let manager = AudioInputDeviceManager(
+ defaults: defaults,
+ deviceProvider: { [external, builtIn] }
+ )
+
+ #expect(manager.selectedDevice == builtIn)
+ }
+
+ @Test("An external microphone can be selected and remembered")
+ func externalSelectionIsRemembered() {
+ let defaults = makeDefaults()
+ let manager = AudioInputDeviceManager(
+ defaults: defaults,
+ deviceProvider: { [builtIn, external] }
+ )
+
+ manager.select(external)
+
+ #expect(manager.selectedDevice == external)
+ }
+
+ @Test("Missing selected microphone falls back to the built-in microphone")
+ func missingSelectionFallsBackToBuiltIn() {
+ var devices = [builtIn, external]
+ let defaults = makeDefaults()
+ let manager = AudioInputDeviceManager(
+ defaults: defaults,
+ deviceProvider: { devices }
+ )
+ manager.select(external)
+
+ devices = [builtIn]
+
+ #expect(manager.selectedDevice == builtIn)
+ }
+
+ @Test("Choosing the built-in microphone restores automatic built-in preference")
+ func choosingBuiltInRestoresDefault() {
+ let defaults = makeDefaults()
+ let manager = AudioInputDeviceManager(
+ defaults: defaults,
+ deviceProvider: { [builtIn, external] }
+ )
+ manager.select(external)
+
+ manager.select(builtIn)
+
+ #expect(manager.selectedDevice == builtIn)
+ }
+
+ private func makeDefaults() -> UserDefaults {
+ let suiteName = "AudioInputDeviceManagerTests.\(UUID().uuidString)"
+ guard let defaults = UserDefaults(suiteName: suiteName) else {
+ fatalError("Could not create isolated UserDefaults suite")
+ }
+ defaults.removePersistentDomain(forName: suiteName)
+ return defaults
+ }
+}
diff --git a/ZeroGSwift/ZeroGTests/AudioRecorderRecoveryTests.swift b/ZeroGSwift/ZeroGTests/AudioRecorderRecoveryTests.swift
new file mode 100644
index 0000000..4e691c2
--- /dev/null
+++ b/ZeroGSwift/ZeroGTests/AudioRecorderRecoveryTests.swift
@@ -0,0 +1,64 @@
+import AVFoundation
+import Testing
+@testable import ZeroG
+
+@Suite
+struct AudioRecorderRecoveryTests {
+ @Test("Recorder rebuilds a stale audio route instead of reporting no microphone")
+ func staleRouteIsRebuilt() throws {
+ let staleSession = MockAudioCaptureSession(recordingFormat: AVAudioFormat())
+ let healthyFormat = try #require(
+ AVAudioFormat(standardFormatWithSampleRate: 48_000, channels: 1)
+ )
+ let healthySession = MockAudioCaptureSession(recordingFormat: healthyFormat)
+ var sessions: [MockAudioCaptureSession] = [staleSession, healthySession]
+ var factoryCallCount = 0
+ let device = AudioInputDevice(
+ deviceID: 1,
+ uid: "built-in",
+ name: "MacBook Pro Microphone",
+ isBuiltIn: true
+ )
+
+ let recorder = AudioRecorder(
+ stateMachine: AppStateMachine(),
+ transcriptionEngine: MockTranscriber(),
+ inputDeviceProvider: { device },
+ captureSessionFactory: { _ in
+ factoryCallCount += 1
+ return sessions.removeFirst()
+ }
+ )
+
+ recorder.startRecording()
+
+ #expect(factoryCallCount == 2)
+ #expect(staleSession.startCallCount == 0)
+ #expect(healthySession.installTapCallCount == 1)
+ #expect(healthySession.startCallCount == 1)
+ }
+}
+
+private final class MockAudioCaptureSession: AudioCaptureSession {
+ let recordingFormat: AVAudioFormat
+ private(set) var installTapCallCount = 0
+ private(set) var startCallCount = 0
+
+ init(recordingFormat: AVAudioFormat) {
+ self.recordingFormat = recordingFormat
+ }
+
+ func installTap(
+ bufferSize: AVAudioFrameCount,
+ handler: @escaping (AVAudioPCMBuffer, AVAudioFormat) -> Void
+ ) {
+ installTapCallCount += 1
+ }
+
+ func start() throws {
+ startCallCount += 1
+ }
+
+ func removeTap() {}
+ func stop() {}
+}
diff --git a/ZeroGSwift/ZeroGTests/ConfigTests.swift b/ZeroGSwift/ZeroGTests/ConfigTests.swift
index 55ded19..d9f7d93 100644
--- a/ZeroGSwift/ZeroGTests/ConfigTests.swift
+++ b/ZeroGSwift/ZeroGTests/ConfigTests.swift
@@ -8,6 +8,7 @@ struct ConfigTests {
func defaultConstants() {
#expect(Config.silenceThreshold == 0.003)
#expect(Config.silenceDuration == 12.0)
+ #expect(Config.recordingSafetyTimeout == 10 * 60)
#expect(Config.recordingTailDuration == 0.3)
#expect(Config.whisperModel == "large-v3-v20240930_turbo")
}
diff --git a/ZeroGSwift/ZeroGTests/TranscribingTests.swift b/ZeroGSwift/ZeroGTests/TranscribingTests.swift
index a70ceb6..ccd0c78 100644
--- a/ZeroGSwift/ZeroGTests/TranscribingTests.swift
+++ b/ZeroGSwift/ZeroGTests/TranscribingTests.swift
@@ -31,7 +31,11 @@ struct TranscribingTests {
func audioRecorderTakesProtocol() {
let mock = MockTranscriber()
// Compiles only if AudioRecorder's dependency is the protocol, not a concrete engine.
- _ = AudioRecorder(stateMachine: AppStateMachine(), transcriptionEngine: mock)
+ _ = AudioRecorder(
+ stateMachine: AppStateMachine(),
+ transcriptionEngine: mock,
+ inputDeviceProvider: { nil }
+ )
}
@Test("The concrete WhisperKit engine conforms to Transcribing")