Skip to content

feat: Bluetooth-safe capture, audible start tone, paste last dictation (1.7.4) - #130

Merged
trsdn merged 6 commits into
mainfrom
feat/input-only-capture
Oct 13, 2026
Merged

trsdn merged 6 commits into
mainfrom
feat/input-only-capture

Conversation

@trsdn

@trsdn trsdn commented Oct 13, 2026 •

Copy link
Copy Markdown
Owner

Pull Request

Summary

Release 1.7.4: Bluetooth headset capture, a start tone that is actually heard, and a way to paste the last dictation again.

1. Input-only Core Audio capture (Bluetooth headsets)

AVAudioEngine couples input and output in one I/O unit, so with AirPods the capture depended on the output device. Switching the headset to its microphone profile stopped the engine and lost the start of recordings (measured: 2.9 s / 2.8 s / 3.2 s recordings held 0.17 s / 1.2 s / 2.4 s of audio), and selecting the built-in microphone while AirPods were the output could deliver 0 samples.

HALInputCapture (AudioDeviceCreateIOProcIDWithBlock on the chosen device) replaces the engine, so the output device is irrelevant. It reads the device stream format on every callback (Float32, Int16, Int32; rate changes followed), takes the first channel as mono Float32, and the existing 16 kHz conversion is unchanged. AudioEngine keeps its public API. The ObjCExceptionCatcher target is now unused but left in Package.swift.

2. Start tone and Listening overlay wait for the first audio

With a headset the tone played before the output profile switch (about 107-121 ms after capture start) and was not heard. AudioCapturing.waitForFirstAudio(handle:timeout:) (2 s timeout) now gates the tone and the Listening overlay. The capture already records, so nothing is lost, and releasing the key during the wait still transcribes.

3. Keep Last Dictation / Paste Last Dictation (new)

For long dictations whose target window is gone. A menu bar toggle keeps the last text in memory only (never on disk or in logs; discarded when the setting is turned off or the app quits; kept even if pasting failed). Paste Last Dictation pastes it again into the frontmost app with the same routing as a fresh dictation (typing in Windows App when enabled), even with Auto-Paste off, after a short delay so the menu has closed. Off by default.

Supersedes #129, which did not address the cause. Version bumped to 1.7.4.

Related issue

Maintainer reports: dictation fails with AirPods Max/Pro and with the built-in microphone while they are worn; paste fails when the window is gone. Related to #119 and #114. Does not close them.

Prepared with an AI assistant using Copilot SDK in VS Code.

Validation

  • swift build -c release -Xswiftc -warnings-as-errors, swiftlint lint --strict, and swift test succeed locally

  • For audio, hotkey, paste, overlay, or update changes: I ran the built app and checked the affected flow

  • swift build -c release -Xswiftc -warnings-as-errors: passed. swiftlint lint --strict: 0 violations. swift test: 119 tests passed. Python suite: 23 passed. plutil -lint Info.plist: OK. scripts/verify-apple-intelligence-linked.sh: FoundationModels linked.

  • New tests: missing-device handling, sample decoding (Float32/Int16/Int32, interleaved, partial frames, unreadable formats), first-audio gating (including a silent capture failing instead of announcing Listening), default-input changes and liveness handling in the capture, and Keep Last Dictation (off by default, paste again with Auto-Paste on and off, kept after a failed paste, discarded when turned off or on shutdown, clipboard-only recordings).

  • Hardware: the guarded real-capture test passes (OPENWRITR_HARDWARE_TESTS=1). The maintainer ran the build with System Default and explicit selection on AirPods Pro/Max and the built-in microphone with AirPods connected: 22 recordings, none failed, full length; first audio after about 11-65 ms.

  • Not verified by hand: clicking Paste Last Dictation in the real menu into another app (covered by view-model tests with a fake paster only), and that the start tone is audible on the AirPods after the first-audio change.

Impact

  • User or operational risk: the capture path used by every dictation changes. Covered by tests and the hardware checks above. Microphone permission works as before.
  • Security or privacy impact (permissions, network destinations, stored or transmitted data): none new. Kept dictation text is memory-only and never logged; the setting is off by default.
  • Compatibility or migration impact (macOS versions, saved preferences, update path): none. macOS 14 minimum unchanged; one new preference (keepLastDictationEnabled, default off).

Documentation

  • README, docs, and changelog are updated where the change is user-visible (README, docs/index.html, AGENTS.md, changelog 1.7.4)

AVAudioEngine couples input and output in one I/O unit. A Bluetooth headset switching profile stopped the engine and lost the start of the recording, and the built-in microphone selected while a headset was the output delivered no samples. Read the input device directly through an IOProc so the output device is irrelevant.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot AI balanced review requested due to automatic review settings October 13, 2026 09:49
The device summary was redacted as <private> in the unified log.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The new capture path rejects valid selectable microphones that do not expose Float32 PCM.

1 open finding
What changed in this PR

Replaces AVAudioEngine microphone capture with an input-only Core Audio HAL callback to decouple recording from output devices.

Changes:

  • Adds HALInputCapture and integrates it with the existing capture lifecycle.
  • Adds basic lifecycle and opt-in hardware tests.
  • Updates diagnostics, documentation, and release notes.
File Description
Sources/​OpenWritr/​HALInputCapture.swift Implements input-only Core Audio capture.
Sources/​OpenWritr/​AudioEngine.swift Adopts HAL capture while retaining lifecycle and conversion behavior.
Sources/​OpenWritr/​AudioDiagnostics.swift Adds device-format logging.
Tests/​OpenWritrTests/​HALInputCaptureTests.swift Tests failure handling and optional hardware capture.
README.md Updates architecture documentation.
docs/​index.html Updates public technology description.
CHANGELOG.md Adds 1.7.4 release notes.
AGENTS.md Documents the new capture architecture.

🧠 Review effort: Balanced


💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread Sources/OpenWritr/HALInputCapture.swift Resolved
Copilot AI balanced review requested due to automatic review settings October 13, 2026 09:57
The tone played as soon as the capture was requested, before a Bluetooth headset had switched its output profile, so it was not heard. Wait for the first audio before showing Listening and playing the tone; the capture records meanwhile.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

An active System Default capture does not retarget when macOS changes the default microphone.

1 open finding
Previously missed (1)

In code that hasn't changed since last review

Medium severity Re-resolve default input after device changes during recording

Sources/​OpenWritr/​AudioEngine.swift:605

The running-capture shortcut ignores a default-input change. The default-input listener schedules this method, but activeDeviceID is never re-resolved, so when the old device remains available this branch returns and the rest of the recording continues on the old microphone. Resolve the current System Default before this shortcut and recover when its device ID differs from activeDeviceID, while preserving the existing explicit-device loss behavior.

            else {
                failActiveGenerationOnQueue(
                    generation,

🧠 Review effort: Balanced

Copilot AI balanced review requested due to automatic review settings October 13, 2026 10:03
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Device-loss recovery and first-audio timeout handling can leave failed capture appearing active, and the release note overstates validation.

2 open findings
1 resolved since last review
Previously missed (1)

In code that hasn't changed since last review

Low severity Avoid claiming unverified AirPods Max dictation support

CHANGELOG.md:11

This release note states that AirPods Max dictation works, but the PR validation explicitly says AirPods Max and the built-in microphone while worn have not been checked and that the change should not ship before those cases are confirmed. Either complete that validation or qualify the note so the published release notes do not claim an unverified fix.

🧠 Review effort: Balanced

Comment thread Sources/OpenWritr/HALInputCapture.swift Resolved
Comment thread Sources/OpenWritr/OpenWritrApp.swift Resolved
Copilot AI balanced review requested due to automatic review settings October 13, 2026 10:16
Adds Keep Last Dictation and Paste Last Dictation to the menu bar menu. The
text is held in memory only and discarded when the setting is turned off or
the app quits. Also bumps the version to 1.7.4.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@trsdn trsdn changed the title fix(audio): capture through an input-only Core Audio callback feat: Bluetooth-safe capture, audible start tone, paste last dictation (1.7.4) Oct 13, 2026

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

The capture rewrite can discard recordings during input-route changes, and key Bluetooth hardware validation remains incomplete.

4 open findings
Previously missed (2)

In code that hasn't changed since last review

Medium severity Report unsupported audio formats with accurate guidance

Sources/​OpenWritr/​HALInputCapture.swift:112

Using engineStartFailed here gives this format error the recovery suggestion “Check Microphone access…”. Changing permission cannot make an unsupported 24-bit or compressed device format readable, so users receive misleading remediation. Add a distinct unsupported-format error with guidance to choose another input device or support that format.

Low severity Validate AirPods Max support before claiming the fix

CHANGELOG.md:11

This release note states that AirPods Max dictation works, but the PR validation explicitly says AirPods Max has not been checked and requires a manual check before merge. Either complete that hardware validation or avoid claiming this specific device is fixed until it is confirmed.

🧠 Review effort: Balanced

Comment thread Sources/OpenWritr/AudioEngine.swift Resolved
Comment thread Sources/OpenWritr/HALInputCapture.swift Resolved
Copilot AI balanced review requested due to automatic review settings October 13, 2026 10:24

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

System Default changes are not followed during capture, and first-audio timeouts still present a false Listening state.

4 open findings

🧠 Review effort: Balanced

… padded PCM

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot AI balanced review requested due to automatic review settings October 13, 2026 10:33
@trsdn
trsdn merged commit 1317795 into main Oct 13, 2026
14 of 15 checks passed
@trsdn
trsdn deleted the feat/input-only-capture branch October 13, 2026 10:40

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

The HAL callback performs non-real-time-safe work, silently ignores live format failures, and the retention behavior conflicts with its privacy contract.

0 open findings

4 resolved since last review
Previously missed (3)

In code that hasn't changed since last review

Medium severity Avoid blocking Core Audio callback with state queries and allocations

Sources/​OpenWritr/​HALInputCapture.swift:166

handleInput is the synchronously dispatched HAL I/O callback, but this queries Core Audio state for every buffer; AudioObjectGetPropertyData may lock or block, and the subsequent array/AVAudio allocations add more unbounded work. Stalling this callback can drop microphone samples under load. Cache format changes outside the callback and keep the I/O path to a preallocated copy/ring buffer, with conversion on a consumer queue.

Medium severity Report unsupported audio format instead of silently dropping input

Sources/​OpenWritr/​HALInputCapture.swift:168

A live switch to an unsupported or temporarily unreadable format silently drops every callback here. startedFlag remains true and no failure is reported, so after initial audio has arrived the UI can remain in Listening while the recording no longer grows. Report the format failure (or negotiate a supported client format) instead of treating the capture as healthy.

Medium severity Transcript fields remain retained when privacy toggle is disabled

Sources/​OpenWritr/​OpenWritrApp.swift:1757

This guard creates a separate lastDictation, but it does not make the setting control in-memory retention: lastTranscription and, for enhanced output, lastRawTranscription are assigned unconditionally immediately above and remain available to Debug Mode after the toggle is turned off. That contradicts the stated guarantee that the text is not kept unless enabled and is discarded when disabled. Either gate/clear the legacy transcript fields too, or describe this toggle as controlling repaste availability rather than retention.

🧠 Review effort: Balanced

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants