feat: FreeWispr v1.3 — quality, resilience, and accessibility#4
Conversation
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Prevents the app from hanging indefinitely on long or malformed audio. Uses SwiftWhisper's native cancel() via abort callback instead of force-killing the task. Maps CancellationError and WhisperError.cancelled to a new TranscriberError.timeout for clean error handling upstream. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…ntext - 5s timeout racing against LLM correction so dictation always completes - Refusal detection catches model commentary instead of corrections - App-type categories (code editor, browser, messaging) for context-aware correction — preserves camelCase/snake_case in code editors - Thread-safe: appName and bundleID captured on @mainactor before async Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Replace isRecording race with _isCapturing guarded by bufferQueue - Observe AVAudioEngine.configurationChangeNotification for hardware changes (BT headset connect, mic switch, app reconfig) - needsRebuild flag defers tap rebuild to next startRecording() call Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Checks magic bytes (0x67676d6c) and file size after download. Deletes corrupted files so the next attempt re-downloads cleanly. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Extracts Team ID from the running app's own signature at init. Mounts downloaded DMG, finds the .app, verifies signature matches expected Team ID via SecStaticCodeCheckValidityWithErrors. Falls back to release page on verification failure. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- NSPanel with 12pt pulsing red dot at screen top-center while recording - Respects Reduce Motion accessibility setting - Status dot: red (recording/error), orange (warning), blue (processing), green (ready) - VoiceOver announcements for error and warning states Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Reject recordings < 0.3s with "Didn't catch that — hold longer" - Reject silence (peak < 0.01) with "Too quiet — speak louder" - Normalize quiet audio to 0.7 peak amplitude for consistent inference - showTemporaryStatus helper auto-reverts to "Ready" after 2s - Error/timeout status messages now auto-revert - Structured logging via os.log for transcription failures - switchModel defers unload until after successful download - Recording indicator wired into start/stop lifecycle Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- BundleExtension checks Contents/Resources/ for signed .app bundles - Build script improvements for notarization workflow - Add TODOS.md for tracking deferred work Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- ModelValidationTests: GGML magic byte validation (valid, truncated, wrong magic, missing file, empty file) - TextCorrectorTests: refusal detection, buildInstructions for all app categories, edge cases (nil bundleID, nil both, original matches) - UpdateCheckerTests: semver comparison (newer major/minor/patch, equal, older, different lengths, four segments) - ModelSizeTests: displayName, sizeDescription, allCases, Identifiable - Fix BenchmarkTests for updated TextCorrector.correct() signature 47 tests, 0 failures. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
# Conflicts: # FreeWispr/Sources/FreeWispr/TextCorrector.swift # FreeWispr/Sources/FreeWispr/WhisperTranscriber.swift # scripts/build-and-notarize.sh
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR ships FreeWispr v1.3.0 with a focus on improving transcription quality, resilience (timeouts/cancellation, model validation), update security (DMG signature verification), and user accessibility (recording indicator, status signaling, VoiceOver announcements).
Changes:
- Added LLM text correction (Apple Intelligence) with app-aware context and a 5s timeout; added Whisper inference timeout/cancellation handling.
- Improved robustness: audio validation + normalization, GGML model validation with auto-delete on corruption, safer resource bundle lookup, and hardened build/notarization steps.
- Added UI/accessibility enhancements: floating recording indicator, richer status dot semantics, and VoiceOver announcements.
Reviewed changes
Copilot reviewed 19 out of 19 changed files in this pull request and generated 9 comments.
Show a summary per file
| File | Description |
|---|---|
| VERSION | Bumps version to 1.3.0. |
| TODOS.md | Adds post-v1.3 WER measurement corpus task. |
| scripts/build-and-notarize.sh | Adds resource bundle presence check + DMG notarization + Gatekeeper verification steps. |
| FreeWispr/Tests/FreeWisprTests/UpdateCheckerTests.swift | Adds semver comparison tests and updateAvailable default test. |
| FreeWispr/Tests/FreeWisprTests/TextCorrectorTests.swift | Adds tests for refusal detection and app-context instruction building. |
| FreeWispr/Tests/FreeWisprTests/ModelValidationTests.swift | Adds GGML magic-byte validation tests and corruption auto-delete checks. |
| FreeWispr/Tests/FreeWisprTests/FreeWisprTests.swift | Extends enum coverage tests (ModelDownloadError, ModelSize). |
| FreeWispr/Tests/BenchmarkTests/PipelineBenchmark.swift | Updates TextCorrector API usage in benchmark. |
| FreeWispr/Sources/FreeWispr/WhisperTranscriber.swift | Adds 30s inference timeout with cancellation and timeout error mapping. |
| FreeWispr/Sources/FreeWispr/UpdateChecker.swift | Adds Team ID–based DMG signature verification prior to opening updates; exposes semver helper for tests. |
| FreeWispr/Sources/FreeWispr/TextCorrector.swift | Adds app-aware LLM correction with 5s timeout and refusal detection; removes frontmost-app querying from corrector. |
| FreeWispr/Sources/FreeWispr/RecordingIndicator.swift | Introduces floating recording indicator dot with Reduce Motion support. |
| FreeWispr/Sources/FreeWispr/ModelManager.swift | Adds GGML validation post-download and new corrupted-model error. |
| FreeWispr/Sources/FreeWispr/FreeWisprApp.swift | Adds status dot color mapping and VoiceOver announcement behavior. |
| FreeWispr/Sources/FreeWispr/BundleExtension.swift | Improves resource bundle fallback chain and avoids crashes if missing. |
| FreeWispr/Sources/FreeWispr/AudioRecorder.swift | Adds hardware config change detection + engine rebuild, and a thread-safe capture flag for the tap callback. |
| FreeWispr/Sources/FreeWispr/AppState.swift | Adds audio validation/normalization, recording indicator integration, temporary error messaging, and revised model-switch flow. |
| CLAUDE.md | Adds guidance on gstack browsing skill usage. |
| CHANGELOG.md | Adds v1.3.0 changelog entry. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| } catch is CancellationError { | ||
| throw TranscriberError.timeout | ||
| } catch WhisperError.cancelled { | ||
| throw TranscriberError.timeout | ||
| } catch { |
There was a problem hiding this comment.
In the timeout/cancellation paths, timeoutTask is never cancelled. If whisper.transcribe throws CancellationError or WhisperError.cancelled, the timeout task can keep running and later call whisper.cancel(), potentially cancelling a subsequent transcription. Consider cancelling timeoutTask in all exit paths (e.g., via defer { timeoutTask.cancel() } right after creation).
| // Mount the DMG | ||
| let mountProcess = Process() | ||
| mountProcess.executableURL = URL(fileURLWithPath: "/usr/bin/hdiutil") | ||
| mountProcess.arguments = ["attach", dmgPath.path, "-nobrowse", "-readonly", "-plist"] | ||
| let pipe = Pipe() | ||
| mountProcess.standardOutput = pipe | ||
|
|
||
| do { | ||
| try mountProcess.run() | ||
| mountProcess.waitUntilExit() | ||
| } catch { | ||
| logger.error("Failed to mount DMG: \(error.localizedDescription)") | ||
| return false | ||
| } | ||
|
|
||
| guard mountProcess.terminationStatus == 0 else { | ||
| logger.error("hdiutil attach failed with exit code \(mountProcess.terminationStatus)") | ||
| return false | ||
| } | ||
|
|
||
| // Parse mount point from plist output | ||
| let outputData = pipe.fileHandleForReading.readDataToEndOfFile() | ||
| guard let plist = try? PropertyListSerialization.propertyList(from: outputData, format: nil) as? [String: Any], | ||
| let entities = plist["system-entities"] as? [[String: Any]], | ||
| let mountPoint = entities.first(where: { $0["mount-point"] != nil })?["mount-point"] as? String | ||
| else { | ||
| logger.error("Could not parse DMG mount point") | ||
| return false | ||
| } | ||
|
|
||
| defer { | ||
| // Always detach the DMG | ||
| let detach = Process() | ||
| detach.executableURL = URL(fileURLWithPath: "/usr/bin/hdiutil") | ||
| detach.arguments = ["detach", mountPoint, "-quiet"] | ||
| try? detach.run() | ||
| detach.waitUntilExit() | ||
| } | ||
|
|
||
| // Find .app bundle inside the mount point | ||
| let mountURL = URL(fileURLWithPath: mountPoint) | ||
| guard let contents = try? FileManager.default.contentsOfDirectory(at: mountURL, includingPropertiesForKeys: nil), | ||
| let appURL = contents.first(where: { $0.pathExtension == "app" }) | ||
| else { | ||
| logger.error("No .app found in mounted DMG") | ||
| return false | ||
| } | ||
|
|
||
| // Verify code signature | ||
| var staticCode: SecStaticCode? | ||
| guard SecStaticCodeCreateWithPath(appURL as CFURL, [], &staticCode) == errSecSuccess, | ||
| let code = staticCode else { | ||
| logger.error("Cannot create static code reference for \(appURL.lastPathComponent)") | ||
| return false | ||
| } | ||
|
|
||
| // Validate the signature is valid | ||
| let requirement = "anchor apple generic and certificate leaf[subject.OU] = \"\(expectedTeamID)\"" | ||
| var secRequirement: SecRequirement? | ||
| guard SecRequirementCreateWithString(requirement as CFString, [], &secRequirement) == errSecSuccess, | ||
| let req = secRequirement else { | ||
| logger.error("Cannot create security requirement") | ||
| return false | ||
| } | ||
|
|
||
| let status = SecStaticCodeCheckValidityWithErrors(code, SecCSFlags(rawValue: kSecCSCheckAllArchitectures), req, nil) | ||
| if status == errSecSuccess { | ||
| logger.info("Update signature verified: Team ID \(expectedTeamID) matches") | ||
| return true | ||
| } else { | ||
| logger.error("Signature verification failed (status: \(status)) for \(appURL.lastPathComponent)") | ||
| return false | ||
| } |
There was a problem hiding this comment.
verifyDMGSignature is awaited from @MainActor code, but it uses synchronous Process.waitUntilExit() and blocking reads, which can freeze the UI while mounting and verifying the DMG. Consider running the whole verification in a detached/background task (or using async Process handling) so downloadAndInstall() remains responsive.
| // Mount the DMG | |
| let mountProcess = Process() | |
| mountProcess.executableURL = URL(fileURLWithPath: "/usr/bin/hdiutil") | |
| mountProcess.arguments = ["attach", dmgPath.path, "-nobrowse", "-readonly", "-plist"] | |
| let pipe = Pipe() | |
| mountProcess.standardOutput = pipe | |
| do { | |
| try mountProcess.run() | |
| mountProcess.waitUntilExit() | |
| } catch { | |
| logger.error("Failed to mount DMG: \(error.localizedDescription)") | |
| return false | |
| } | |
| guard mountProcess.terminationStatus == 0 else { | |
| logger.error("hdiutil attach failed with exit code \(mountProcess.terminationStatus)") | |
| return false | |
| } | |
| // Parse mount point from plist output | |
| let outputData = pipe.fileHandleForReading.readDataToEndOfFile() | |
| guard let plist = try? PropertyListSerialization.propertyList(from: outputData, format: nil) as? [String: Any], | |
| let entities = plist["system-entities"] as? [[String: Any]], | |
| let mountPoint = entities.first(where: { $0["mount-point"] != nil })?["mount-point"] as? String | |
| else { | |
| logger.error("Could not parse DMG mount point") | |
| return false | |
| } | |
| defer { | |
| // Always detach the DMG | |
| let detach = Process() | |
| detach.executableURL = URL(fileURLWithPath: "/usr/bin/hdiutil") | |
| detach.arguments = ["detach", mountPoint, "-quiet"] | |
| try? detach.run() | |
| detach.waitUntilExit() | |
| } | |
| // Find .app bundle inside the mount point | |
| let mountURL = URL(fileURLWithPath: mountPoint) | |
| guard let contents = try? FileManager.default.contentsOfDirectory(at: mountURL, includingPropertiesForKeys: nil), | |
| let appURL = contents.first(where: { $0.pathExtension == "app" }) | |
| else { | |
| logger.error("No .app found in mounted DMG") | |
| return false | |
| } | |
| // Verify code signature | |
| var staticCode: SecStaticCode? | |
| guard SecStaticCodeCreateWithPath(appURL as CFURL, [], &staticCode) == errSecSuccess, | |
| let code = staticCode else { | |
| logger.error("Cannot create static code reference for \(appURL.lastPathComponent)") | |
| return false | |
| } | |
| // Validate the signature is valid | |
| let requirement = "anchor apple generic and certificate leaf[subject.OU] = \"\(expectedTeamID)\"" | |
| var secRequirement: SecRequirement? | |
| guard SecRequirementCreateWithString(requirement as CFString, [], &secRequirement) == errSecSuccess, | |
| let req = secRequirement else { | |
| logger.error("Cannot create security requirement") | |
| return false | |
| } | |
| let status = SecStaticCodeCheckValidityWithErrors(code, SecCSFlags(rawValue: kSecCSCheckAllArchitectures), req, nil) | |
| if status == errSecSuccess { | |
| logger.info("Update signature verified: Team ID \(expectedTeamID) matches") | |
| return true | |
| } else { | |
| logger.error("Signature verification failed (status: \(status)) for \(appURL.lastPathComponent)") | |
| return false | |
| } | |
| return await Task.detached(priority: .userInitiated) { () -> Bool in | |
| // Mount the DMG | |
| let mountProcess = Process() | |
| mountProcess.executableURL = URL(fileURLWithPath: "/usr/bin/hdiutil") | |
| mountProcess.arguments = ["attach", dmgPath.path, "-nobrowse", "-readonly", "-plist"] | |
| let pipe = Pipe() | |
| mountProcess.standardOutput = pipe | |
| do { | |
| try mountProcess.run() | |
| mountProcess.waitUntilExit() | |
| } catch { | |
| logger.error("Failed to mount DMG: \(error.localizedDescription)") | |
| return false | |
| } | |
| guard mountProcess.terminationStatus == 0 else { | |
| logger.error("hdiutil attach failed with exit code \(mountProcess.terminationStatus)") | |
| return false | |
| } | |
| // Parse mount point from plist output | |
| let outputData = pipe.fileHandleForReading.readDataToEndOfFile() | |
| guard let plist = try? PropertyListSerialization.propertyList(from: outputData, format: nil) as? [String: Any], | |
| let entities = plist["system-entities"] as? [[String: Any]], | |
| let mountPoint = entities.first(where: { $0["mount-point"] != nil })?["mount-point"] as? String | |
| else { | |
| logger.error("Could not parse DMG mount point") | |
| return false | |
| } | |
| defer { | |
| // Always detach the DMG | |
| let detach = Process() | |
| detach.executableURL = URL(fileURLWithPath: "/usr/bin/hdiutil") | |
| detach.arguments = ["detach", mountPoint, "-quiet"] | |
| try? detach.run() | |
| detach.waitUntilExit() | |
| } | |
| // Find .app bundle inside the mount point | |
| let mountURL = URL(fileURLWithPath: mountPoint) | |
| guard let contents = try? FileManager.default.contentsOfDirectory(at: mountURL, includingPropertiesForKeys: nil), | |
| let appURL = contents.first(where: { $0.pathExtension == "app" }) | |
| else { | |
| logger.error("No .app found in mounted DMG") | |
| return false | |
| } | |
| // Verify code signature | |
| var staticCode: SecStaticCode? | |
| guard SecStaticCodeCreateWithPath(appURL as CFURL, [], &staticCode) == errSecSuccess, | |
| let code = staticCode else { | |
| logger.error("Cannot create static code reference for \(appURL.lastPathComponent)") | |
| return false | |
| } | |
| // Validate the signature is valid | |
| let requirement = "anchor apple generic and certificate leaf[subject.OU] = \"\(expectedTeamID)\"" | |
| var secRequirement: SecRequirement? | |
| guard SecRequirementCreateWithString(requirement as CFString, [], &secRequirement) == errSecSuccess, | |
| let req = secRequirement else { | |
| logger.error("Cannot create security requirement") | |
| return false | |
| } | |
| let status = SecStaticCodeCheckValidityWithErrors(code, SecCSFlags(rawValue: kSecCSCheckAllArchitectures), req, nil) | |
| if status == errSecSuccess { | |
| logger.info("Update signature verified: Team ID \(expectedTeamID) matches") | |
| return true | |
| } else { | |
| logger.error("Signature verification failed (status: \(status)) for \(appURL.lastPathComponent)") | |
| return false | |
| } | |
| }.value |
| // Read _isCapturing under bufferQueue to avoid data race | ||
| let capturing = self.bufferQueue.sync { self._isCapturing } |
There was a problem hiding this comment.
The audio tap callback uses bufferQueue.sync to read _isCapturing. Because the tap runs on a real-time audio thread, synchronously blocking on a dispatch queue can cause audio glitches/dropouts under load. Consider using an atomic/lock-free flag (or another RT-safe mechanism) so the tap can check capture state without blocking.
| // Read _isCapturing under bufferQueue to avoid data race | |
| let capturing = self.bufferQueue.sync { self._isCapturing } | |
| // Read _isCapturing directly to avoid blocking the real-time audio thread. | |
| // A slight race here is acceptable: at worst, a few extra frames are processed | |
| // just before/after recording state changes. | |
| let capturing = self._isCapturing |
| private var panel: NSPanel? | ||
| private var pulseTimer: Timer? | ||
|
|
There was a problem hiding this comment.
pulseTimer is declared/invalidated but never set; pulsing is driven by a Core Animation on the layer instead. Consider removing pulseTimer (and related invalidation) to avoid dead code and confusion, or wire it up if a timer-based pulse was intended.
| // Find .app bundle inside the mount point | ||
| let mountURL = URL(fileURLWithPath: mountPoint) | ||
| guard let contents = try? FileManager.default.contentsOfDirectory(at: mountURL, includingPropertiesForKeys: nil), | ||
| let appURL = contents.first(where: { $0.pathExtension == "app" }) | ||
| else { | ||
| logger.error("No .app found in mounted DMG") |
There was a problem hiding this comment.
The DMG verification picks the first .app found in the mounted root. If the DMG contains multiple apps (or a malicious extra app), this could verify the wrong bundle. Consider selecting the expected app explicitly (e.g., by matching FreeWispr.app) and failing if it's not present.
| // Find .app bundle inside the mount point | |
| let mountURL = URL(fileURLWithPath: mountPoint) | |
| guard let contents = try? FileManager.default.contentsOfDirectory(at: mountURL, includingPropertiesForKeys: nil), | |
| let appURL = contents.first(where: { $0.pathExtension == "app" }) | |
| else { | |
| logger.error("No .app found in mounted DMG") | |
| // Find expected .app bundle inside the mount point | |
| let mountURL = URL(fileURLWithPath: mountPoint) | |
| let expectedAppName = "FreeWispr.app" | |
| guard let contents = try? FileManager.default.contentsOfDirectory(at: mountURL, includingPropertiesForKeys: nil), | |
| let appURL = contents.first(where: { $0.lastPathComponent == expectedAppName }) | |
| else { | |
| logger.error("Expected app bundle \(expectedAppName, privacy: .public) not found in mounted DMG") |
| } | ||
|
|
||
| // Validate the signature is valid | ||
| let requirement = "anchor apple generic and certificate leaf[subject.OU] = \"\(expectedTeamID)\"" |
There was a problem hiding this comment.
The security requirement only constrains Team ID (certificate leaf[subject.OU]), so any app signed by the same Team ID would pass. Consider tightening the requirement to also include the expected bundle identifier (or using the app’s designated requirement) to reduce the chance of accepting the wrong signed app.
| let requirement = "anchor apple generic and certificate leaf[subject.OU] = \"\(expectedTeamID)\"" | |
| let expectedBundleID = "com.ygivenx.FreeWispr" | |
| let requirement = "anchor apple generic and certificate leaf[subject.OU] = \"\(expectedTeamID)\" and info[CFBundleIdentifier] = \"\(expectedBundleID)\"" |
| if !isTapInstalled || needsRebuild { | ||
| resetEngine() | ||
| try prepareEngine() | ||
| needsRebuild = false | ||
| } |
There was a problem hiding this comment.
needsRebuild is read/reset in startRecording() without any synchronization, but it’s also mutated from the engine configuration-change notification. Since that notification may be delivered off the main thread, this can introduce a data race. Consider protecting needsRebuild behind a queue/lock/atomic or ensuring mutations happen on a single actor/queue.
| @objc private func handleConfigurationChange(_ notification: Notification) { | ||
| // Mark for rebuild — don't tear down now since we may be mid-recording. | ||
| // The tap will be rebuilt on the next startRecording() call. | ||
| needsRebuild = true | ||
| } |
There was a problem hiding this comment.
handleConfigurationChange sets needsRebuild = true directly. If this callback is invoked on a non-main thread (likely), it can race with startRecording() reading/resetting the same flag. Consider hopping to a single queue/actor before mutating shared state, or making needsRebuild atomic.
| // Parse mount point from plist output | ||
| let outputData = pipe.fileHandleForReading.readDataToEndOfFile() | ||
| guard let plist = try? PropertyListSerialization.propertyList(from: outputData, format: nil) as? [String: Any], | ||
| let entities = plist["system-entities"] as? [[String: Any]], | ||
| let mountPoint = entities.first(where: { $0["mount-point"] != nil })?["mount-point"] as? String |
There was a problem hiding this comment.
If parsing the hdiutil attach -plist output fails, the function returns before setting up the detach defer, leaving the DMG mounted. Consider attaching with a known mountpoint (e.g., -mountpoint <tempDir>) or capturing a device identifier so you can always detach even when parsing fails.
The magic number 0x67676D6C was checked in big-endian byte order, but GGML files store it little-endian. This caused every downloaded model to be rejected as corrupted and deleted. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Summary
Test Coverage
47 tests pass (1 benchmark skipped). 28/28 pure logic paths covered including:
Pre-Landing Review
No issues found.
Adversarial Review
Claude adversarial subagent: 13 informational findings, no ship-blockers. Key items:
needsRebuildhas theoretical data race (benign — worst case is missed rebuild)@MainActorannotation (called from MainActor context, safe in practice)TODOS
No TODO items completed in this PR. 1 item remaining (P2: Test Audio Corpus for WER Measurement).
Test plan
🤖 Generated with Claude Code