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
6 changes: 6 additions & 0 deletions TypeWhisper/App/ServiceContainer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,12 @@ final class ServiceContainer: ObservableObject {
},
recoveryRetentionPolicyDidChange: { [audioRecordingService] policy in
_ = audioRecordingService.updateRecoveryRetentionPolicy(policy)
},
cancellationBehaviorDidChange: { [dictationViewModel] behavior in
dictationViewModel.cancellationBehavior = behavior
},
dictationRecoveryPreferencesDidChange: { [recoveryViewModel] in
recoveryViewModel.reloadPreferencesFromDefaults()
}
)
let handlers = APIHandlers(
Expand Down
22 changes: 19 additions & 3 deletions TypeWhisper/Services/SettingsBackupExporter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -636,7 +636,8 @@ enum SettingsBackupExporter {
userDefaults: UserDefaults = .standard,
liveFieldTranscriptEnabledDidChange: ((Bool) -> Void)? = nil,
cancellationBehaviorDidChange: ((CancellationBehavior) -> Void)? = nil,
recoveryRetentionPolicyDidChange: ((DictationRecoveryRetentionPolicy) -> Void)? = nil
recoveryRetentionPolicyDidChange: ((DictationRecoveryRetentionPolicy) -> Void)? = nil,
dictationRecoveryPreferencesDidChange: (() -> Void)? = nil
) async -> ImportResult {
var result = ImportResult()

Expand Down Expand Up @@ -880,6 +881,13 @@ enum SettingsBackupExporter {
forKey: UserDefaultsKeys.dictationRecoveryHedgeThresholdSeconds
)
apply(preferences.dictationRecoveryRetentionDays, forKey: UserDefaultsKeys.dictationRecoveryRetentionDays)
if preferences.dictationRecoveryLanguage != nil
|| preferences.dictationRecoveryAutomaticFallbackEnabled != nil
|| preferences.dictationRecoveryHedgeEnabled != nil
|| preferences.dictationRecoveryHedgeThresholdSeconds != nil
|| preferences.dictationRecoveryRetentionDays != nil {
dictationRecoveryPreferencesDidChange?()
}
if preferences.dictationRecoveryRetentionDays != nil {
recoveryRetentionPolicyDidChange?(DictationRecoveryRetentionPolicy.load(from: userDefaults))
}
Expand Down Expand Up @@ -941,6 +949,8 @@ final class SettingsBackupAutomationService {
private let userDefaults: UserDefaults
private let liveFieldTranscriptEnabledDidChange: ((Bool) -> Void)?
private let recoveryRetentionPolicyDidChange: ((DictationRecoveryRetentionPolicy) -> Void)?
private let cancellationBehaviorDidChange: ((CancellationBehavior) -> Void)?
private let dictationRecoveryPreferencesDidChange: (() -> Void)?

init(
workflowService: WorkflowService,
Expand All @@ -954,7 +964,9 @@ final class SettingsBackupAutomationService {
usageStatisticsService: UsageStatisticsService,
userDefaults: UserDefaults = .standard,
liveFieldTranscriptEnabledDidChange: ((Bool) -> Void)? = nil,
recoveryRetentionPolicyDidChange: ((DictationRecoveryRetentionPolicy) -> Void)? = nil
recoveryRetentionPolicyDidChange: ((DictationRecoveryRetentionPolicy) -> Void)? = nil,
cancellationBehaviorDidChange: ((CancellationBehavior) -> Void)? = nil,
dictationRecoveryPreferencesDidChange: (() -> Void)? = nil
) {
self.workflowService = workflowService
self.dictionaryService = dictionaryService
Expand All @@ -968,6 +980,8 @@ final class SettingsBackupAutomationService {
self.userDefaults = userDefaults
self.liveFieldTranscriptEnabledDidChange = liveFieldTranscriptEnabledDidChange
self.recoveryRetentionPolicyDidChange = recoveryRetentionPolicyDidChange
self.cancellationBehaviorDidChange = cancellationBehaviorDidChange
self.dictationRecoveryPreferencesDidChange = dictationRecoveryPreferencesDidChange
}

func exportData() throws -> Data {
Expand Down Expand Up @@ -999,7 +1013,9 @@ final class SettingsBackupAutomationService {
usageStatisticsService: usageStatisticsService,
userDefaults: userDefaults,
liveFieldTranscriptEnabledDidChange: liveFieldTranscriptEnabledDidChange,
recoveryRetentionPolicyDidChange: recoveryRetentionPolicyDidChange
cancellationBehaviorDidChange: cancellationBehaviorDidChange,
recoveryRetentionPolicyDidChange: recoveryRetentionPolicyDidChange,
dictationRecoveryPreferencesDidChange: dictationRecoveryPreferencesDidChange
)
}
}
22 changes: 22 additions & 0 deletions TypeWhisper/ViewModels/DictationRecoveryViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,28 @@ final class DictationRecoveryViewModel: ObservableObject {
)
}

/// Re-reads the recovery preferences after a settings-backup import wrote
/// them to UserDefaults. The view model is initialized once at launch and is
/// what ServiceContainer consults for the live fallback and hedge values, so
/// without this the imported values stayed invisible until a restart.
func reloadPreferencesFromDefaults() {
isInitialized = false
defer { isInitialized = true }
selectedEngine = defaults.string(forKey: UserDefaultsKeys.dictationRecoveryEngine)
selectedModel = defaults.string(forKey: UserDefaultsKeys.dictationRecoveryModel)
languageSelection = LanguageSelection(
storedValue: defaults.string(forKey: UserDefaultsKeys.dictationRecoveryLanguage),
nilBehavior: .auto
)
automaticFallbackEnabled = defaults.bool(forKey: UserDefaultsKeys.dictationRecoveryAutomaticFallbackEnabled)
hedgeEnabled = defaults.bool(forKey: UserDefaultsKeys.dictationRecoveryHedgeEnabled)
hedgeThresholdSeconds = Self.clampedHedgeThreshold(
defaults.object(forKey: UserDefaultsKeys.dictationRecoveryHedgeThresholdSeconds) as? Double
)
retentionPolicy = DictationRecoveryRetentionPolicy.load(from: defaults)
normalizeLanguageSelectionForResolvedEngine()
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

func observePluginManager() {
guard let pluginManager = PluginManager.shared else { return }
pluginManager.objectWillChange
Expand Down
154 changes: 152 additions & 2 deletions TypeWhisper/ViewModels/DictationViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,10 @@ final class DictationViewModel: ObservableObject {
_ normalizeNumbers: Bool?
) async throws -> TranscriptionResult
typealias RecoveryHedgeThresholdProvider = @MainActor () -> TimeInterval?
/// Upper bound, in seconds, on the whole final-transcription phase (primary,
/// hedge, and sequential fallback together) for a recording of the given
/// duration. `nil` disables the bound.
typealias TranscriptionDeadlineProvider = @MainActor (_ audioDurationSeconds: TimeInterval) -> TimeInterval?
typealias PrimaryTranscriptionRunner = @MainActor (
_ samples: [Float],
_ languageSelection: LanguageSelection,
Expand All @@ -143,7 +147,7 @@ final class DictationViewModel: ObservableObject {
_ normalizeNumbers: Bool?
) async throws -> TranscriptionResult

private struct FinalTranscriptionOutput {
private struct FinalTranscriptionOutput: Sendable {
let result: TranscriptionResult
let modelId: String?
let modelDisplayName: String?
Expand All @@ -162,6 +166,28 @@ final class DictationViewModel: ObservableObject {
}
}

struct TranscriptionDeadlineExceeded: LocalizedError, Equatable {
let seconds: TimeInterval

// Describes the timeout only. Whether a recovery recording exists is
// decided by the failure path (retention policy, file move), which
// appends the recovery confirmation and action itself when one does.
var errorDescription: String? {
let rounded = Int(seconds.rounded())
return localizedAppText(
"Transcription timed out after \(rounded) seconds.",
de: "Die Transkription hat nach \(rounded) Sekunden das Zeitlimit überschritten."
)
}
}

/// Default final-transcription bound: a minute of headroom plus the
/// recording's own length, so long recordings on slow local engines are not
/// cut off while a hung cloud request can never pin the app in "Transcribing".
nonisolated static func defaultTranscriptionDeadline(forAudioDuration duration: TimeInterval) -> TimeInterval {
60 + max(0, duration)
}

nonisolated(unsafe) static var _shared: DictationViewModel?
static var shared: DictationViewModel {
guard let instance = _shared else {
Expand Down Expand Up @@ -348,6 +374,7 @@ final class DictationViewModel: ObservableObject {
private let recoveryFallbackConfigurationProvider: RecoveryFallbackConfigurationProvider
private let recoveryFallbackRunner: RecoveryFallbackRunner
private let recoveryHedgeThresholdProvider: RecoveryHedgeThresholdProvider
private let transcriptionDeadlineProvider: TranscriptionDeadlineProvider
private let primaryTranscriptionRunner: PrimaryTranscriptionRunner
private var matchedWorkflow: Workflow?
private var activeWorkflowMatch: WorkflowMatchResult?
Expand Down Expand Up @@ -459,7 +486,8 @@ final class DictationViewModel: ObservableObject {
recoveryFallbackConfigurationProvider: RecoveryFallbackConfigurationProvider? = nil,
recoveryFallbackRunner: RecoveryFallbackRunner? = nil,
recoveryHedgeThresholdProvider: RecoveryHedgeThresholdProvider? = nil,
primaryTranscriptionRunner: PrimaryTranscriptionRunner? = nil
primaryTranscriptionRunner: PrimaryTranscriptionRunner? = nil,
transcriptionDeadlineProvider: TranscriptionDeadlineProvider? = nil
) {
self.audioRecordingService = audioRecordingService
self.textInsertionService = textInsertionService
Expand Down Expand Up @@ -498,6 +526,9 @@ final class DictationViewModel: ObservableObject {
self.mediaPlaybackService = mediaPlaybackService
self.recoveryFallbackConfigurationProvider = recoveryFallbackConfigurationProvider ?? { _, _ in nil }
self.recoveryHedgeThresholdProvider = recoveryHedgeThresholdProvider ?? { nil }
self.transcriptionDeadlineProvider = transcriptionDeadlineProvider ?? { duration in
Self.defaultTranscriptionDeadline(forAudioDuration: duration)
}
self.primaryTranscriptionRunner = primaryTranscriptionRunner ?? { [modelManager] samples, languageSelection, task, engineOverrideId, cloudModelOverride, prompt, dictionaryTermHints, normalizeNumbers in
try await modelManager.transcribe(
audioSamples: samples,
Expand Down Expand Up @@ -2386,6 +2417,125 @@ final class DictationViewModel: ObservableObject {
prompt: String?,
dictionaryTermHints: [PluginDictionaryTermHint],
normalizeNumbers: Bool?
) async throws -> FinalTranscriptionOutput {
let audioDuration = Double(audioSamples.count) / AudioRecordingService.targetSampleRate
guard let deadline = transcriptionDeadlineProvider(audioDuration), deadline > 0 else {
return try await transcribeFinalAudioWithoutDeadline(
audioSamples: audioSamples,
languageSelection: languageSelection,
task: task,
primaryEngineId: primaryEngineId,
primaryCloudModelOverride: primaryCloudModelOverride,
prompt: prompt,
dictionaryTermHints: dictionaryTermHints,
normalizeNumbers: normalizeNumbers
)
}

// Bound the whole transcription phase (primary, hedge, and the sequential
// fallback) by a deadline that holds regardless of how the runners behave:
// the phase is an unstructured task settled through an arbiter, so when
// the deadline fires the caller gets TranscriptionDeadlineExceeded at the
// bound. The in-flight work is cancelled - which aborts the transport for
// runners that honour cancellation - but it is never awaited, so a runner
// that ignores cancellation (a stalled upload, a server that accepted the
// audio and went silent, a plugin without prompt cancellation) cannot
// hold the app in "Transcribing..." past the bound. Its late result is
// dropped by the arbiter.
let transcriptionOperation: @MainActor () async throws -> FinalTranscriptionOutput = { [self] in
try await self.transcribeFinalAudioWithoutDeadline(
audioSamples: audioSamples,
languageSelection: languageSelection,
task: task,
primaryEngineId: primaryEngineId,
primaryCloudModelOverride: primaryCloudModelOverride,
prompt: prompt,
dictionaryTermHints: dictionaryTermHints,
normalizeNumbers: normalizeNumbers
)
}
let arbiter = DeadlineArbiter<FinalTranscriptionOutput>()
// The continuation only signals completion; the (non-Sendable) outcome
// stays inside the main-actor arbiter and is read back here.
await withTaskCancellationHandler {
await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
arbiter.begin(continuation)
let work = Task { @MainActor in
do {
arbiter.settle(.success(try await transcriptionOperation()))
} catch {
arbiter.settle(.failure(error))
}
}
let timer = Task { @MainActor [logger] in
do {
try await Task.sleep(nanoseconds: UInt64(deadline * 1_000_000_000))
} catch {
return
}
logger.error("Final transcription exceeded its deadline of \(deadline, format: .fixed(precision: 1))s; abandoning in-flight requests")
arbiter.settle(.failure(TranscriptionDeadlineExceeded(seconds: deadline)))
}
arbiter.register(work: work, timer: timer)
}
} onCancel: {
Task { @MainActor in
arbiter.settle(.failure(CancellationError()))
}
}
return try arbiter.takeOutcome().get()
}

/// Settles a deadline-bounded operation on its first outcome: the work's own
/// result, the deadline, or outer cancellation. Everything runs on the main
/// actor; the first call resumes the caller and cancels both tasks, later
/// calls are dropped, and neither task is ever awaited.
@MainActor
private final class DeadlineArbiter<Value> {
private var continuation: CheckedContinuation<Void, Never>?
private var work: Task<Void, Never>?
private var timer: Task<Void, Never>?
private var settled = false
private var outcome: Result<Value, Error>?

func begin(_ continuation: CheckedContinuation<Void, Never>) {
self.continuation = continuation
}

func takeOutcome() -> Result<Value, Error> {
outcome ?? .failure(CancellationError())
}

func register(work: Task<Void, Never>, timer: Task<Void, Never>) {
self.work = work
self.timer = timer
if settled {
work.cancel()
timer.cancel()
}
}

func settle(_ outcome: Result<Value, Error>) {
guard !settled else { return }
settled = true
self.outcome = outcome
work?.cancel()
timer?.cancel()
let continuation = self.continuation
self.continuation = nil
continuation?.resume()
}
}

private func transcribeFinalAudioWithoutDeadline(
audioSamples: [Float],
languageSelection: LanguageSelection,
task: TranscriptionTask,
primaryEngineId: String?,
primaryCloudModelOverride: String?,
prompt: String?,
dictionaryTermHints: [PluginDictionaryTermHint],
normalizeNumbers: Bool?
) async throws -> FinalTranscriptionOutput {
let fallbackConfiguration = recoveryFallbackConfigurationProvider(primaryEngineId, task)
do {
Expand Down
3 changes: 3 additions & 0 deletions TypeWhisper/Views/AdvancedSettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -671,6 +671,9 @@ struct AdvancedSettingsView: View {
},
recoveryRetentionPolicyDidChange: { policy in
_ = container.audioRecordingService.updateRecoveryRetentionPolicy(policy)
},
dictationRecoveryPreferencesDidChange: {
DictationRecoveryViewModel.shared.reloadPreferencesFromDefaults()
}
)

Expand Down
43 changes: 43 additions & 0 deletions TypeWhisperTests/FileTranscriptionViewModelTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -850,6 +850,49 @@ final class FileTranscriptionViewModelTests: XCTestCase {
XCTAssertNil(viewModel.automaticFallbackConfiguration(excluding: "groq", task: .transcribe))
}

func testReloadPreferencesFromDefaultsPicksUpImportedValues() throws {
let defaults = try makeDefaults()
let store = DictationRecoveryAudioStore(directory: makeTemporaryDirectory())
let viewModel = DictationRecoveryViewModel(
audioRecordingService: AudioRecordingService(recoveryAudioStore: store),
modelManager: ModelManagerService(),
historyService: HistoryService(appSupportDirectory: makeTemporaryDirectory()),
audioFileService: AudioFileService(),
defaults: defaults
)
XCTAssertFalse(viewModel.hedgeEnabled)
XCTAssertEqual(viewModel.hedgeThresholdSeconds, 3.0)

// A settings-backup import writes straight to UserDefaults behind the
// already-initialized view model.
defaults.set("imported-engine", forKey: UserDefaultsKeys.dictationRecoveryEngine)
defaults.set("imported-model", forKey: UserDefaultsKeys.dictationRecoveryModel)
defaults.set(true, forKey: UserDefaultsKeys.dictationRecoveryAutomaticFallbackEnabled)
defaults.set(true, forKey: UserDefaultsKeys.dictationRecoveryHedgeEnabled)
defaults.set(7.5, forKey: UserDefaultsKeys.dictationRecoveryHedgeThresholdSeconds)
defaults.set("de", forKey: UserDefaultsKeys.dictationRecoveryLanguage)

viewModel.reloadPreferencesFromDefaults()

XCTAssertEqual(viewModel.selectedEngine, "imported-engine")
XCTAssertEqual(viewModel.selectedModel, "imported-model", "reloading the engine must not reset the imported model")
XCTAssertTrue(viewModel.automaticFallbackEnabled)
XCTAssertTrue(viewModel.hedgeEnabled)
XCTAssertEqual(viewModel.hedgeThresholdSeconds, 7.5)
XCTAssertEqual(viewModel.automaticHedgeThreshold, 7.5)
XCTAssertEqual(viewModel.languageSelection, LanguageSelection(storedValue: "de", nilBehavior: .auto))

defaults.set(1e308, forKey: UserDefaultsKeys.dictationRecoveryHedgeThresholdSeconds)
viewModel.reloadPreferencesFromDefaults()
XCTAssertEqual(viewModel.hedgeThresholdSeconds, 15.0, "a reloaded value is clamped like a stored one")

let retentionBefore = viewModel.retentionPolicy
defaults.set(180, forKey: UserDefaultsKeys.dictationRecoveryRetentionDays)
viewModel.reloadPreferencesFromDefaults()
XCTAssertEqual(viewModel.retentionPolicy, DictationRecoveryRetentionPolicy.load(from: defaults))
XCTAssertNotEqual(viewModel.retentionPolicy, retentionBefore, "a retention-only import must reach the view model")
}

func testHedgeThresholdIsClampedToTheSupportedRange() throws {
let defaults = try makeDefaults()
defaults.set(true, forKey: UserDefaultsKeys.dictationRecoveryAutomaticFallbackEnabled)
Expand Down
Loading