Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/conformance.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
# Criteria: https://github.com/trsdn/.github/blob/v1.16.0/docs/repository-quality-standard.md

standard_version: "1.16.0"
assessed_on: "2026-09-21"
assessed_on: "2026-09-22"
state: "Healthy"
evidence: "docs/self-assessment.md"

Expand Down Expand Up @@ -39,7 +39,7 @@ criteria:
P12: pass
P13: pass
S01: pass
S02: partial
S02: pass
S03: pass
S04: pass
S05: pass
Expand Down
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ All notable changes to OpenWritr will be documented in this file.
Each release's notes on GitHub are generated from its section here, and the
release workflow fails when the section for a tag is missing or empty.

## [1.6.6] — 2026-09-22
Comment thread
trsdn marked this conversation as resolved.

### Fixed
- Auto-paste cancellation now shows a recoverable error instead of success when OpenWritr cannot preserve the existing clipboard

## [1.6.5] — 2026-09-20

### Added
Expand Down
25 changes: 24 additions & 1 deletion Sources/OpenWritr/AudioEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,26 @@ struct AudioEngineDebugFaults: Sendable {
}
#endif

final class AudioEngine: @unchecked Sendable {
protocol AudioCapturing: AnyObject, Sendable {
var onDevicesChanged: (@Sendable () -> Void)? { get set }
var onFailure: (@Sendable (AudioEngineError) -> Void)? { get set }
var onAudioLevel: (@Sendable (Float, UInt64) -> Void)? { get set }

func availableInputDevices() -> [AudioInputDevice]
func setInputDevice(_ deviceID: AudioDeviceID?) -> Result<Void, AudioEngineError>
func prepare() -> Result<Void, AudioEngineError>
func startCapture() async -> Result<CaptureHandle, AudioEngineError>
func waitForCaptureToSettle(
handle: CaptureHandle,
idleWindow: Duration,
maxWait: Duration,
pollInterval: Duration
) async
func stopCapture(handle: CaptureHandle) async -> [Float]?
func shutdown() -> Result<Void, AudioEngineError>
}

final class AudioEngine: AudioCapturing, @unchecked Sendable {
private static let maximumRecoveryAttempts = 3
private let targetSampleRate: Double = 16_000
private let captureClock = ContinuousClock()
Expand Down Expand Up @@ -1035,6 +1054,10 @@ final class AudioEngine: @unchecked Sendable {
}
}

func availableInputDevices() -> [AudioInputDevice] {
Self.availableInputDevices()
}

func setInputDevice(_ deviceID: AudioDeviceID?) -> Result<Void, AudioEngineError> {
syncOnLifecycleQueue {
guard !isShuttingDown else { return .failure(.captureCancelled) }
Expand Down
14 changes: 13 additions & 1 deletion Sources/OpenWritr/GrammarEnhancer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,18 @@ struct EnhancementResult: Sendable {
let warning: String?
}

protocol TranscriptEnhancing: Sendable {
func enhance(
text: String,
model: EnhancedModel,
provider: EnhancedProvider,
openAIConfiguration: GrammarEnhancer.OpenAIConfiguration,
prompt: String
) async -> EnhancementResult

func cancelActiveEnhancement()
}

enum EnhancedProvider: String, CaseIterable, Identifiable {
case copilot = "copilot"
case openAICompatible = "openai-compatible"
Expand Down Expand Up @@ -166,7 +178,7 @@ enum GrammarEnhancementError: Error, LocalizedError, Sendable, Equatable {
}
}

struct GrammarEnhancer: Sendable {
struct GrammarEnhancer: TranscriptEnhancing, Sendable {
static let defaultCleanupPrompt = "Clean up this speech transcript: fix grammar, spelling, and punctuation. Remove fillers, hesitations, and stuttering. Every sentence must end with proper punctuation. Preserve meaning, tone, and language. If the input mixes German and English, keep the original language of each word or phrase and do not translate technical terms, product names, commands, or domain-specific wording. If the input contains only filler words or hesitations with no meaningful content, return an empty string. Return only the corrected text."

struct OpenAIConfiguration: Sendable {
Expand Down
2 changes: 1 addition & 1 deletion Sources/OpenWritr/MenuBarView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ struct MenuBarView: View {
}
}
Button("Dismiss Error") { viewModel.dismissRuntimeError() }
case .transcription:
case .transcription, .paste:
Button("Dismiss Error") { viewModel.dismissRuntimeError() }
}
default:
Expand Down
124 changes: 89 additions & 35 deletions Sources/OpenWritr/OpenWritrApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ enum RuntimeErrorKind: Sendable, Equatable {
case audio
case transcription
case enhancement
case paste
}

struct AppErrorPresentation: Sendable {
Expand Down Expand Up @@ -50,6 +51,8 @@ final class AppViewModel {
private static let enhancementAPIKeyAccount = "enhancedOpenAIAPIKey"
private static let customPromptsPreferenceKey = "enhancementCustomPromptsV1"
private static let customPromptsStoreVersion = 1
static let defaultDoneDisplayDuration: Duration = .milliseconds(600)
static let defaultTransientErrorDisplayDuration: Duration = .seconds(2.5)
var state: AppState = .idle
var lastTranscription: String = ""
var lastRawTranscription: String = ""
Expand Down Expand Up @@ -79,18 +82,21 @@ final class AppViewModel {
private(set) var customEnhancementPrompts: [String: String] = [:]
var appleIntelligenceAvailability = AppleIntelligenceEnhancer.currentAvailability()

let transcriptionManager = TranscriptionManager()
let grammarEnhancer: GrammarEnhancer = .init()
@ObservationIgnored lazy var audioEngine = AudioEngine()
let transcriptionManager: any Transcribing
let grammarEnhancer: any TranscriptEnhancing
@ObservationIgnored private let injectedAudioEngine: (any AudioCapturing)?
@ObservationIgnored lazy var audioEngine: any AudioCapturing = injectedAudioEngine ?? AudioEngine()
let hotkeyManager = HotkeyManager()
let pasteManager = PasteManager()
let overlayPanel = OverlayPanel()
let pasteManager: any TextPasting
let overlayPanel: any OverlayPresenting
let soundManager = SoundManager()
let permissionsManager = PermissionsManager()
let updateManager = UpdateManager()

private let captureDrainIdleDuration: Duration = .milliseconds(70)
private let captureDrainTimeout: Duration = .milliseconds(350)
private let doneDisplayDuration: Duration
private let transientErrorDisplayDuration: Duration
private var didConfigure = false
private var didAttemptInitialSetup = false
private var isInitializing = false
Expand All @@ -103,7 +109,6 @@ final class AppViewModel {
@ObservationIgnored private var releaseRequested = false
@ObservationIgnored private var pendingStartTask: Task<Void, Never>?
@ObservationIgnored private var transientErrorDismissTask: Task<Void, Never>?
private static let transientErrorDisplayDuration: Duration = .seconds(2.5)
@ObservationIgnored private var stoppedCaptureGenerations: Set<UInt64> = []
@ObservationIgnored private var activeProcessingTask: Task<Void, Never>?
@ObservationIgnored private var activeProcessingOperationID: UUID?
Expand All @@ -114,6 +119,30 @@ final class AppViewModel {
@ObservationIgnored private var microphoneRecoveryTask: Task<Void, Never>?
@ObservationIgnored private var microphoneRecoveryID: UUID?

init(
audioEngine: (any AudioCapturing)? = nil,
transcriptionManager: any Transcribing = TranscriptionManager(),
grammarEnhancer: any TranscriptEnhancing = GrammarEnhancer(),
pasteManager: any TextPasting = PasteManager(),
overlayPanel: any OverlayPresenting = OverlayPanel(),
startsOperational: Bool = false,
doneDisplayDuration: Duration = AppViewModel.defaultDoneDisplayDuration,
transientErrorDisplayDuration: Duration = AppViewModel.defaultTransientErrorDisplayDuration
) {
injectedAudioEngine = audioEngine
self.transcriptionManager = transcriptionManager
self.grammarEnhancer = grammarEnhancer
self.pasteManager = pasteManager
self.overlayPanel = overlayPanel
self.doneDisplayDuration = doneDisplayDuration
self.transientErrorDisplayDuration = transientErrorDisplayDuration
if startsOperational {
configureAudioCallbacks()
isOperational = true
state = .ready
}
}

var displayedOpenAIModels: [String] {
var models = availableOpenAIModels
let selected = selectedOpenAIModel.trimmingCharacters(in: .whitespacesAndNewlines)
Expand Down Expand Up @@ -306,25 +335,8 @@ final class AppViewModel {
self?.quiesceForUpdateInstall()
}

audioEngine.onDevicesChanged = { [weak self] in
Task { @MainActor [weak self] in
self?.handleInputDevicesChanged()
}
}
audioEngine.onFailure = { [weak self] error in
Task { @MainActor [weak self] in
self?.handleAudioFailure(error)
}
}
audioEngine.onAudioLevel = { [weak self] level, generation in
Task { @MainActor [weak self] in
guard let self,
case .listening = self.state,
self.captureHandle?.generation == generation
else { return }
self.overlayPanel.updateAudioLevel(level)
}
}
configureAudioCallbacks()

hotkeyManager.onRecordingStarted = { [weak self] mode in
Task { @MainActor [weak self] in
self?.startListening(triggerMode: mode)
Expand Down Expand Up @@ -352,6 +364,28 @@ final class AppViewModel {
}
}

private func configureAudioCallbacks() {
audioEngine.onDevicesChanged = { [weak self] in
Task { @MainActor [weak self] in
self?.handleInputDevicesChanged()
}
}
audioEngine.onFailure = { [weak self] error in
Task { @MainActor [weak self] in
self?.handleAudioFailure(error)
}
}
audioEngine.onAudioLevel = { [weak self] level, generation in
Task { @MainActor [weak self] in
guard let self,
case .listening = self.state,
self.captureHandle?.generation == generation
else { return }
self.overlayPanel.updateAudioLevel(level)
}
}
}

private func restorePreferences() {
let defaults = UserDefaults.standard
if defaults.object(forKey: "soundEnabled") != nil {
Expand Down Expand Up @@ -512,7 +546,7 @@ final class AppViewModel {
self.recoverableRawTranscription = recoverableRawTranscription
transitionToErrorState(.runtimeError(error))
overlayPanel.show(state: .error(overlayMessage))
if error.kind == .transcription {
if error.kind == .transcription || error.kind == .paste {
scheduleTransientErrorDismissal()
}
}
Expand All @@ -521,9 +555,10 @@ final class AppViewModel {
/// flash the overlay, then return to `.ready` without requiring a menu action.
private func scheduleTransientErrorDismissal() {
transientErrorDismissTask?.cancel()
let displayDuration = transientErrorDisplayDuration
transientErrorDismissTask = Task { @MainActor [weak self] in
do {
try await Task.sleep(for: Self.transientErrorDisplayDuration)
try await Task.sleep(for: displayDuration)
} catch {
return
}
Expand All @@ -535,7 +570,8 @@ final class AppViewModel {
}

private var isTransientErrorState: Bool {
if case .runtimeError(let error) = state, error.kind == .transcription {
if case .runtimeError(let error) = state,
error.kind == .transcription || error.kind == .paste {
return true
}
return false
Expand Down Expand Up @@ -584,8 +620,9 @@ final class AppViewModel {
lastRawTranscription = ""
lastWasEnhanced = false
recoverableRawTranscription = nil
if autoPasteEnabled {
pasteManager.pasteText(rawText)
if autoPasteEnabled, pasteManager.pasteText(rawText) == .cancelled {
presentPasteCancelledError()
return
}
overlayPanel.dismiss()
state = .ready
Expand Down Expand Up @@ -656,7 +693,7 @@ final class AppViewModel {
}

func refreshInputDevices() {
availableInputDevices = AudioEngine.availableInputDevices()
availableInputDevices = audioEngine.availableInputDevices()
}

func setInputDevice(_ device: AudioInputDevice?) {
Expand Down Expand Up @@ -1359,7 +1396,8 @@ final class AppViewModel {
}

do {
let text = try await transcriptionManager.transcribe(samples: samples)
let transcriptionSamples = TranscriptionInput.paddedIfNeeded(samples)
let text = try await transcriptionManager.transcribe(samples: transcriptionSamples)
guard captureOperationIsCurrent(
operationID: operationID,
handle: handle,
Expand Down Expand Up @@ -1479,13 +1517,17 @@ final class AppViewModel {
lastRawTranscription = rawText ?? ""
lastWasEnhanced = wasEnhanced

if autoPasteEnabled {
pasteManager.pasteText(text)
if autoPasteEnabled, pasteManager.pasteText(text) == .cancelled {
if let (operationID, _) = captureOperation {
clearCaptureOperation(ifCurrent: operationID)
}
presentPasteCancelledError()
return
}

overlayPanel.show(state: .done)
do {
try await Task.sleep(for: .milliseconds(600))
try await Task.sleep(for: doneDisplayDuration)
} catch is CancellationError {
return
} catch {
Expand All @@ -1512,6 +1554,18 @@ final class AppViewModel {
state = .ready
}

private func presentPasteCancelledError() {
presentRuntimeError(
AppErrorPresentation(
kind: .paste,
title: "Paste Cancelled",
message: "OpenWritr could not preserve the clipboard, so the transcript was not pasted.",
recoverySuggestion: "Replace or clear the clipboard contents, then record again."
),
overlayMessage: "Clipboard could not be preserved; paste cancelled"
)
}

private func returnToReady(operationID: UUID? = nil) {
guard isOperational else { return }
if let operationID {
Expand Down
9 changes: 8 additions & 1 deletion Sources/OpenWritr/OverlayPanel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,14 @@ enum OverlayState: Sendable {
}

@MainActor
final class OverlayPanel {
protocol OverlayPresenting: AnyObject {
func show(state: OverlayState)
func updateAudioLevel(_ level: Float)
func dismiss()
}

@MainActor
final class OverlayPanel: OverlayPresenting {
private static let panelSize = NSSize(width: 248, height: 66)

private var panel: NSPanel?
Expand Down
Loading
Loading