Skip to content

Commit d85a676

Browse files
authored
Zerm 2.1.2: fix recording drops, stuck state, and terminal text selection (#244)
Three user-reported issues: 1. Long recordings dropped mid-dictation with no warning. The silence auto-stop window was 1.1s — short enough that a normal thinking pause in long-form speech tripped it and cut the recording. Raised the default to 2.5s, which tolerates natural pauses while still stopping promptly when the user is done. 2. After a drop, the app reported the recording was "already running" and required mashing the hotkey before dictation worked again. When the audio unit dies mid-recording (device unplugged, render error), CoreAudio's input callback stops firing while the engine still believes it is recording: the capture is silently lost and the state machine gets stuck. CoreAudioRecorder now exposes the time since the last input callback, and the recording monitor runs a dropped-capture watchdog (always, regardless of the auto-stop setting): if no audio arrives for 3s after the unit was delivering, it notifies the user, transcribes whatever was captured before the drop, and returns cleanly to idle so the next press starts fresh. 3. "No text selected" when reading/handling highlighted text in terminal and TUI apps (e.g. Claude Code, cmux), breaking Read-Aloud-Selected-Text. Terminals render text in custom views that expose neither AXSelectedText (.accessibility) nor a standard Edit > Copy item (.menuAction). Added the .shortcut strategy (simulated Cmd+C + pasteboard read, auto-restored) as a final fallback, which terminals handle reliably. Co-authored-by: thefourCraft <thefourCraft@users.noreply.github.com>
1 parent fce725a commit d85a676

6 files changed

Lines changed: 69 additions & 8 deletions

File tree

Zerm.xcodeproj/project.pbxproj

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -496,7 +496,7 @@
496496
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
497497
CODE_SIGN_STYLE = Automatic;
498498
COMBINE_HIDPI_IMAGES = YES;
499-
CURRENT_PROJECT_VERSION = 211;
499+
CURRENT_PROJECT_VERSION = 212;
500500
DEVELOPMENT_ASSET_PATHS = "\"Zerm/Preview Content\"";
501501
DEVELOPMENT_TEAM = V6J6A3VWY2;
502502
ENABLE_HARDENED_RUNTIME = YES;
@@ -511,7 +511,7 @@
511511
"@executable_path/../Frameworks",
512512
);
513513
MACOSX_DEPLOYMENT_TARGET = 14.4;
514-
MARKETING_VERSION = 2.1.1;
514+
MARKETING_VERSION = 2.1.2;
515515
PRODUCT_BUNDLE_IDENTIFIER = com.arcusis.zerm;
516516
PRODUCT_NAME = "$(TARGET_NAME)";
517517
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG ENABLE_NATIVE_SPEECH_ANALYZER $(inherited)";
@@ -537,7 +537,7 @@
537537
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
538538
CODE_SIGN_STYLE = Automatic;
539539
COMBINE_HIDPI_IMAGES = YES;
540-
CURRENT_PROJECT_VERSION = 211;
540+
CURRENT_PROJECT_VERSION = 212;
541541
DEVELOPMENT_ASSET_PATHS = "\"Zerm/Preview Content\"";
542542
DEVELOPMENT_TEAM = V6J6A3VWY2;
543543
ENABLE_HARDENED_RUNTIME = YES;
@@ -552,7 +552,7 @@
552552
"@executable_path/../Frameworks",
553553
);
554554
MACOSX_DEPLOYMENT_TARGET = 14.4;
555-
MARKETING_VERSION = 2.1.1;
555+
MARKETING_VERSION = 2.1.2;
556556
PRODUCT_BUNDLE_IDENTIFIER = com.arcusis.zerm;
557557
PRODUCT_NAME = "$(TARGET_NAME)";
558558
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "ENABLE_NATIVE_SPEECH_ANALYZER $(inherited)";

Zerm/AppDefaults.swift

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,10 @@ enum AppDefaults {
2424
"IsTextFormattingEnabled": true,
2525
"IsVADEnabled": true,
2626
"AutoStopAfterSilence": true,
27-
"AutoStopSilenceSeconds": 1.1,
27+
// 1.1s was short enough that a normal thinking pause in long-form
28+
// dictation tripped auto-stop and cut the recording off. 2.5s tolerates
29+
// natural pauses while still stopping promptly when the user is done.
30+
"AutoStopSilenceSeconds": 2.5,
2831
"AutoStopMinimumRecordingSeconds": 0.8,
2932
"AutoStopInitialSilenceSeconds": 6.0,
3033
"AutoStopLevelThreshold": 0.12,

Zerm/CoreAudioRecorder.swift

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,10 @@ final class CoreAudioRecorder: @unchecked Sendable {
3131
private let meterLock = NSLock()
3232
private var _averagePower: Float = -160.0
3333
private var _peakPower: Float = -160.0
34+
/// Monotonic timestamp of the last successful input callback. Lets the engine
35+
/// detect a silently-dropped capture: if the audio unit dies (device removed,
36+
/// render error) the callback stops firing while `isRecording` stays true.
37+
private var _lastInputUptimeNanos: UInt64 = 0
3438

3539
var averagePower: Float {
3640
meterLock.lock()
@@ -44,6 +48,18 @@ final class CoreAudioRecorder: @unchecked Sendable {
4448
return _peakPower
4549
}
4650

51+
/// Seconds since the last input callback fired, or `nil` if none has yet.
52+
/// A value that keeps climbing while recording means the capture has stalled.
53+
var secondsSinceLastInput: Double? {
54+
meterLock.lock()
55+
let last = _lastInputUptimeNanos
56+
meterLock.unlock()
57+
guard last != 0 else { return nil }
58+
let now = DispatchTime.now().uptimeNanoseconds
59+
guard now > last else { return 0 }
60+
return Double(now - last) / 1_000_000_000.0
61+
}
62+
4763
// Pre-allocated render buffer (to avoid malloc in real-time callback)
4864
private var renderBuffer: UnsafeMutablePointer<Float32>?
4965
private var renderBufferSize: UInt32 = 0
@@ -147,6 +163,7 @@ final class CoreAudioRecorder: @unchecked Sendable {
147163
meterLock.lock()
148164
_averagePower = -160.0
149165
_peakPower = -160.0
166+
_lastInputUptimeNanos = 0
150167
meterLock.unlock()
151168
}
152169

@@ -625,6 +642,7 @@ final class CoreAudioRecorder: @unchecked Sendable {
625642
meterLock.lock()
626643
_averagePower = avgDb
627644
_peakPower = peakDb
645+
_lastInputUptimeNanos = DispatchTime.now().uptimeNanoseconds
628646
meterLock.unlock()
629647
}
630648

Zerm/Recorder.swift

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,14 @@ class Recorder: NSObject, ObservableObject {
3131
enum RecorderError: Error {
3232
case couldNotStartRecording
3333
}
34+
35+
/// True while the underlying audio unit is live. Goes false once the hardware
36+
/// recorder is stopped/disposed.
37+
var isHardwareRecording: Bool { recorder?.isCurrentlyRecording ?? false }
38+
39+
/// Seconds since the last audio input callback, or `nil` if not recording or
40+
/// none received yet. Used to detect a silently-dropped capture.
41+
var secondsSinceLastAudioInput: Double? { recorder?.secondsSinceLastInput }
3442

3543
override init() {
3644
super.init()

Zerm/Services/SelectedTextService.swift

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,12 @@ import SelectedTextKit
44

55
class SelectedTextService {
66
static func fetchSelectedText() async -> String? {
7-
let strategies: [TextStrategy] = [.accessibility, .menuAction]
7+
// `.shortcut` simulates ⌘C and reads the pasteboard (restoring it afterward).
8+
// It is the only strategy that works in terminal emulators and TUI apps
9+
// (e.g. Claude Code, cmux), which render text in custom views that do not
10+
// expose AXSelectedText (.accessibility) and have no standard Edit ▸ Copy
11+
// menu item (.menuAction). Keep it last so the cheaper strategies win first.
12+
let strategies: [TextStrategy] = [.accessibility, .menuAction, .shortcut]
813
do {
914
let selectedText = try await SelectedTextManager.shared.getSelectedText(strategies: strategies)
1015
return selectedText

Zerm/Transcription/Engine/ZermEngine.swift

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -259,13 +259,20 @@ class ZermEngine: NSObject, ObservableObject {
259259
private func startAutoStopMonitor() {
260260
cancelAutoStopMonitor()
261261

262-
guard UserDefaults.standard.bool(forKey: "AutoStopAfterSilence") else { return }
263-
264262
let defaults = UserDefaults.standard
263+
// The silence auto-stop is opt-out, but the loop always runs so the
264+
// dropped-capture watchdog below works regardless of that setting.
265+
let autoStopEnabled = defaults.bool(forKey: "AutoStopAfterSilence")
265266
let silenceSeconds = max(defaults.double(forKey: "AutoStopSilenceSeconds"), 0.6)
266267
let minimumRecordingSeconds = max(defaults.double(forKey: "AutoStopMinimumRecordingSeconds"), 0.3)
267268
let initialSilenceSeconds = max(defaults.double(forKey: "AutoStopInitialSilenceSeconds"), 2.0)
268269
let levelThreshold = max(defaults.double(forKey: "AutoStopLevelThreshold"), 0.02)
270+
// If the audio unit dies mid-recording (device unplugged, render error)
271+
// the input callback stops firing while the engine still believes it is
272+
// recording — the capture is silently lost and the UI gets stuck. If no
273+
// audio has arrived for this long after the unit was delivering, treat the
274+
// recording as dropped and recover.
275+
let captureStallSeconds = 3.0
269276
let startedAt = Date()
270277

271278
autoStopTask = Task { @MainActor [weak self] in
@@ -281,6 +288,24 @@ class ZermEngine: NSObject, ObservableObject {
281288

282289
let now = Date()
283290
let elapsed = now.timeIntervalSince(startedAt)
291+
292+
// Dropped-capture watchdog. `secondsSinceLastAudioInput` is non-nil
293+
// only once the callback has fired at least once, so this never
294+
// false-fires during the brief hardware start-up window.
295+
if let sinceInput = self.recorder.secondsSinceLastAudioInput,
296+
sinceInput >= captureStallSeconds {
297+
self.logger.error("Recording dropped: no audio input for \(sinceInput, privacy: .public)s — recovering")
298+
await NotificationManager.shared.showNotification(
299+
title: "Recording stopped — microphone dropped",
300+
type: .warning,
301+
duration: 3.0
302+
)
303+
// Stop and transcribe whatever was captured before the drop,
304+
// then return cleanly to idle so the next press starts fresh.
305+
await self.toggleRecord()
306+
return
307+
}
308+
284309
let level = max(self.recorder.audioMeter.averagePower, self.recorder.audioMeter.peakPower * 0.65)
285310

286311
if level >= levelThreshold {
@@ -289,6 +314,8 @@ class ZermEngine: NSObject, ObservableObject {
289314
continue
290315
}
291316

317+
guard autoStopEnabled else { continue }
318+
292319
if heardSpeech,
293320
elapsed >= minimumRecordingSeconds,
294321
now.timeIntervalSince(lastSpeechAt) >= silenceSeconds {

0 commit comments

Comments
 (0)