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
1 change: 1 addition & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ let package = Package(
.copy("Resources/SettingsIntegrationsIllustration.png"),
.copy("Resources/SettingsAboutIllustration.png"),
.copy("Resources/IndustryLexicons.json"),
.copy("Resources/THUOCL-LICENSE.txt"),
.copy("Resources/Sounds"),
.copy("Resources/AppIcon.icns"),
.copy("Resources/AppIconLight.icns"),
Expand Down
5 changes: 2 additions & 3 deletions Sources/App/AppDelegate+Integrations.swift
Original file line number Diff line number Diff line change
Expand Up @@ -117,10 +117,9 @@ extension AppDelegate {
func makeIntegrationSessionCoordinator(service: OpenTypeService) -> InputSessionCoordinator {
InputSessionCoordinator(
service: service,
engineProvider: speechEngineProvider,
textProcessor: textProcessor,
isUserWorkflowBusy: { [weak self] in
self?.appState.isBusy ?? false
}
ownership: inputOwnership
)
}
}
35 changes: 35 additions & 0 deletions Sources/App/InputSessionOwnership.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import Foundation

/// All input entries share this reservation, including preparation and cancellation drain.
@MainActor
final class InputSessionOwnership {
private var current: UUID?
private var cancelled = false

var isBusy: Bool { current != nil }

func acquire() throws -> UUID {
guard current == nil else { throw IntegrationError.busy }
let id = UUID()
current = id
cancelled = false
return id
}

func check(_ id: UUID) throws {
try Task.checkCancellation()
guard current == id, !cancelled else { throw CancellationError() }
}

func cancel(_ id: UUID) {
guard current == id else { return }
cancelled = true
}

/// Called only after the owner has stopped touching execution resources.
func release(_ id: UUID) {
guard current == id else { return }
current = nil
cancelled = false
}
}
13 changes: 6 additions & 7 deletions Sources/App/OpenTypeApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
private var settingsWindowDelegate: SettingsWindowDelegate?
private var onboardingWindow: NSWindow?
private let popoverOutsideClickMonitor = PopoverOutsideClickMonitor()
let inputOwnership = InputSessionOwnership()
let speechEngineProvider = SpeechEngineProvider()
let textProcessor: TextProcessor
var cancellables = Set<AnyCancellable>()
var iconTimer: Timer?
Expand Down Expand Up @@ -102,7 +104,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
}

private func setupPipeline() {
pipeline = VoicePipeline(appState: appState, textProcessor: textProcessor)
pipeline = VoicePipeline(appState: appState, textProcessor: textProcessor, ownership: inputOwnership, engineProvider: speechEngineProvider)
Task { await pipeline?.warmUp() }
}

Expand Down Expand Up @@ -150,26 +152,23 @@ final class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
action: HotkeyAction,
remoteSessionToken: UInt64? = nil
) -> Task<Void, Never>? {
if integrationSessionCoordinator.isBusy {
pipeline?.showBusyHint()
return nil
}
savePreviousApp()
if popover.isShown { closePopover() }
let mode: VoiceInputMode = action == .translation
? .translation(AppSettings.shared.translationTargetLanguage)
: .dictation
let targetApp = previousApp
return Task {
await pipeline?.start(
mode: mode,
targetApp: previousApp,
targetApp: targetApp,
remoteSessionToken: remoteSessionToken
)
}
}

func stopRecording() {
Task { await pipeline?.stop(targetApp: previousApp) }
Task { await pipeline?.stop() }
}

private func applyPendingReplacement() async {
Expand Down
35 changes: 35 additions & 0 deletions Sources/App/VoiceInputSettings.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import Foundation

/// Immutable choices for one utterance; settings changes apply to the next session.
struct VoiceInputSettings {
let processing: TextProcessingOptions
let speech: SpeechEngineProvider.Selection
let dictionary: PersonalDictionarySnapshot
let outputMode: OutputMode
let enableInstantInsert: Bool
let enableMemory: Bool
let memoryWindowMinutes: Int
let useScreenContext: Bool
let streamingEnabled: Bool
let microphoneID: String?
let audioActivityThresholds: AudioActivityThresholds

var inputLanguage: InputLanguage { processing.inputLanguage }
var llmModel: String { processing.llmModel }
var espressoModelPath: String { processing.espressoModelPath }

@MainActor
init(settings: AppSettings, inputLanguage: InputLanguage? = nil) {
processing = TextProcessingOptions(settings: settings, inputLanguage: inputLanguage)
speech = SpeechEngineProvider.Selection(settings: settings, inputLanguage: inputLanguage)
dictionary = PersonalDictionary.shared.snapshot(settings: settings)
outputMode = settings.outputMode
enableInstantInsert = settings.enableInstantInsert
enableMemory = settings.enableMemory
memoryWindowMinutes = settings.memoryWindowMinutes
useScreenContext = settings.useScreenContext
streamingEnabled = settings.enableStreamingRecognitionBeta
microphoneID = settings.microphoneID
audioActivityThresholds = settings.audioActivityThresholds
}
}
12 changes: 7 additions & 5 deletions Sources/App/VoicePipeline+EditCommandHelpers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import Foundation
@MainActor
extension VoicePipeline {
func replacementInputContext(
settings: AppSettings,
settings: VoiceInputSettings,
targetApp: NSRunningApplication?
) -> InputContext {
InputContext.capture(
Expand All @@ -18,7 +18,7 @@ extension VoicePipeline {

func finalizedReplacementText(
_ text: String,
settings: AppSettings
settings: VoiceInputSettings
) -> String {
textProcessor.cleanCommandGeneratedOutput(
text,
Expand All @@ -29,7 +29,7 @@ extension VoicePipeline {
func rewriteSelectedText(
raw: String,
intent: SelectionRewriteIntent,
settings: AppSettings,
settings: VoiceInputSettings,
targetApp: NSRunningApplication?
) async {
cancelScreenContextCapture()
Expand All @@ -48,11 +48,12 @@ extension VoicePipeline {
inputLanguage: settings.inputLanguage,
source: .menuBar
)
var options = TextProcessingOptions(settings: settings)
var options = settings.processing
options.llmModel = settings.llmModel
let memoryContext = VoicePipelinePolicy.memoryContext(
for: .command,
settings: settings,
enableMemory: settings.enableMemory,
memoryWindowMinutes: settings.memoryWindowMinutes,
currentContext: context
)

Expand All @@ -76,6 +77,7 @@ extension VoicePipeline {
appState.statusMessage = L("pipeline.replacing")

let result = await textInserter.replaceSelectedText(text: rewrittenText, targetApp: targetApp)
guard !Task.isCancelled else { return }
appState.phase = .done
appState.statusMessage = L("status.done")
hideOverlayAfterDelay()
Expand Down
6 changes: 3 additions & 3 deletions Sources/App/VoicePipeline+EditCommandResolution.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import Foundation
extension VoicePipeline {
func resolvedSpokenEditCommand(
raw: String,
settings: AppSettings,
settings: VoiceInputSettings,
targetApp: NSRunningApplication?
) async -> SpokenEditCommand? {
guard VoicePipelinePolicy.shouldResolveEditCommandWithLLMFirst(outputMode: settings.outputMode) else {
Expand Down Expand Up @@ -47,10 +47,10 @@ extension VoicePipeline {

private func resolveSpokenEditCommandWithLLM(
raw: String,
settings: AppSettings,
settings: VoiceInputSettings,
targetApp: NSRunningApplication?
) async -> SpokenEditCommandLLMResolution? {
var options = TextProcessingOptions(settings: settings)
var options = settings.processing
options.llmModel = settings.llmModel
return await textProcessor.resolveSpokenEditCommandResolution(
text: raw,
Expand Down
13 changes: 9 additions & 4 deletions Sources/App/VoicePipeline+EditCommands.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import Foundation
extension VoicePipeline {
func handleSpokenEditCommandIfNeeded(
raw: String,
settings: AppSettings,
settings: VoiceInputSettings,
targetApp: NSRunningApplication?
) async -> Bool {
let expectedEspressoModelPath = settings.espressoModelPath
Expand All @@ -17,6 +17,7 @@ extension VoicePipeline {
return false
}

guard !Task.isCancelled else { return true }
switch command {
case .replaceLast(let replacementRaw):
await replaceLastInsertion(
Expand Down Expand Up @@ -54,7 +55,7 @@ extension VoicePipeline {

guard !Task.isCancelled else { return true }
if let espressoOutcome = await consumeEspressoOutcome(
settings: settings,
settings: appState.settings,
expectedEspressoModelPath: expectedEspressoModelPath
) {
if case .error = appState.phase, espressoOutcome == .fallback {
Expand All @@ -74,7 +75,7 @@ extension VoicePipeline {
private func replaceLastInsertion(
raw: String,
replacementRaw: String,
settings: AppSettings,
settings: VoiceInputSettings,
targetApp: NSRunningApplication?
) async {
cancelScreenContextCapture()
Expand All @@ -101,6 +102,7 @@ extension VoicePipeline {
previouslyInserted: appState.lastInsertedText,
targetApp: targetApp
)
guard !Task.isCancelled else { return }

appState.phase = .done
appState.statusMessage = L("status.done")
Expand Down Expand Up @@ -130,7 +132,7 @@ extension VoicePipeline {
private func replaceSelectedText(
raw: String,
replacementRaw: String,
settings: AppSettings,
settings: VoiceInputSettings,
targetApp: NSRunningApplication?
) async {
cancelScreenContextCapture()
Expand All @@ -148,6 +150,7 @@ extension VoicePipeline {

Log.sensitive("[VoicePipeline] voice edit replace selection \(replacementText.count) chars")
let result = await textInserter.replaceSelectedText(text: replacementText, targetApp: targetApp)
guard !Task.isCancelled else { return }

appState.phase = .done
appState.statusMessage = L("status.done")
Expand Down Expand Up @@ -183,6 +186,7 @@ extension VoicePipeline {

Log.info("[VoicePipeline] voice edit delete selection")
let result = await textInserter.deleteSelectedText(targetApp: targetApp)
guard !Task.isCancelled else { return }

if case .probablyFailed(let reason) = result {
Log.info("[VoicePipeline] voice edit delete selection probably failed: \(reason)")
Expand Down Expand Up @@ -213,6 +217,7 @@ extension VoicePipeline {
previouslyInserted: appState.lastInsertedText,
targetApp: targetApp
)
guard !Task.isCancelled else { return }

if case .probablyFailed(let reason) = result {
Log.info("[VoicePipeline] voice edit undo probably failed: \(reason)")
Expand Down
4 changes: 2 additions & 2 deletions Sources/App/VoicePipeline+ModelLifecycle.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ extension VoicePipeline {
func unloadLLM() {
formattingPreloadGeneration += 1
processingTask?.cancel()
processingTask = nil
replacementTask?.cancel()
replacementTask = nil
appState.clearPendingReplacement()
Expand Down Expand Up @@ -37,7 +36,8 @@ extension VoicePipeline {
let precedingTask = formattingModelLifecycleTask
let task: Task<EspressoGenerationOutcome?, Never> = Task { @MainActor [weak self] in
_ = await precedingTask?.value
guard let self else { return nil }
guard let self, let lease = try? self.ownership.acquire() else { return nil }
defer { self.ownership.release(lease) }
return await self.preloadFormattingModelNow(
showFailureInStatus: showFailureInStatus
)
Expand Down
Loading
Loading