diff --git a/OpenWhisp/AppMain.swift b/OpenWhisp/AppMain.swift index abef3b1..40c5ba2 100644 --- a/OpenWhisp/AppMain.swift +++ b/OpenWhisp/AppMain.swift @@ -325,13 +325,14 @@ class OpenWhispApp: NSObject, NSApplicationDelegate { // Translate is its own switch now (split from the old "English — // translate to English" language overload). // - // Parakeet and Apple Speech are ASR-only — they have no translate task - // (LanguageResolver.noTranslateEngines). #175 stopped the menu offering a - // dead toggle there, but by hiding the row the menu silently disagreed - // with itself between engines. Keep the row and disable it, naming the - // reason: macOS menus dim unavailable commands rather than hiding them, - // so the capability stays discoverable instead of looking like a bug. - let canTranslate = LanguageResolver.supportsTranslation(transcriptionEngine: appState.transcriptionEngine) + // Offered when the engine translates natively (whisper family) OR the + // on-device text path can cover it (Apple Translation, macOS 15+) — + // `appState.translationOffered`, the SAME predicate the Dictation pane + // reads, so the two surfaces can't disagree (a past bug). Only macOS 14 + // with an ASR-only engine still dims the row: macOS menus dim + // unavailable commands rather than hiding them, so the capability stays + // discoverable instead of looking like a bug (#175 history). + let canTranslate = appState.translationOffered let translateItem = menuItem( canTranslate ? "Translate to English" : "Translate to English (needs WhisperKit)", symbol: "character.bubble", @@ -339,7 +340,7 @@ class OpenWhispApp: NSObject, NSApplicationDelegate { ) translateItem.state = (canTranslate && appState.translateToEnglish) ? .on : .off if !canTranslate { - translateItem.toolTip = "The current engine transcribes only — it has no translation model. Switch to WhisperKit in Settings to translate." + translateItem.toolTip = "The current engine transcribes only, and on-device text translation needs macOS 15. Switch to WhisperKit in Settings to translate." } menu.addItem(translateItem) diff --git a/OpenWhisp/Models/AppState.swift b/OpenWhisp/Models/AppState.swift index fb48919..e4bde2a 100644 --- a/OpenWhisp/Models/AppState.swift +++ b/OpenWhisp/Models/AppState.swift @@ -1657,26 +1657,55 @@ class AppState: ObservableObject { } /// The language of the OUTPUT text, for formatting rules (spoken - /// punctuation etc.): English when translating, else the spoken language. + /// punctuation etc.): English when translating (either the engine-level + /// translate or the text path), else the spoken language. private var outputLanguageForCleaning: String { - LanguageResolver.outputLanguageForCleaning( + TextTranslationPolicy.outputLanguageForCleaning( language: language, translateToEnglish: translateToEnglish, - transcriptionEngine: transcriptionEngine + transcriptionEngine: transcriptionEngine, + textTranslationAvailable: AppleTextTranslation.isSupported ) } - /// The translate intent actually in effect: the stored toggle gated on the - /// engine's translation capability. The refine layer (cleanup prompts, - /// RefineOutputGuard's expected script) must key on THIS, never on the raw - /// `translateToEnglish` — on Parakeet/Apple Speech the transcript stays in the - /// spoken language (and the UI shows translate as off), so a stale stored - /// `true` would otherwise disarm the language guard and, in improveTranslation - /// mode, actively LLM-translate the dictation the engine refused to. + /// The translate intent actually in effect: the stored toggle gated on a + /// path existing that can act on it — the engine's own translate task OR + /// the on-device text path (macOS 15+, `TextTranslationPolicy`). The refine + /// layer (cleanup prompts, RefineOutputGuard's expected script) must key on + /// THIS, never on the raw `translateToEnglish` — on Parakeet/Apple Speech + /// under macOS 14 the transcript stays in the spoken language (and the UI + /// shows translate as dimmed), so a stale stored `true` would otherwise + /// disarm the language guard and, in improveTranslation mode, actively + /// LLM-translate the dictation no path was going to. var effectiveTranslateToEnglish: Bool { - LanguageResolver.effectiveTranslateToEnglish( + TextTranslationPolicy.effectiveTranslateToEnglish( translateToEnglish: translateToEnglish, - transcriptionEngine: transcriptionEngine + language: language, + transcriptionEngine: transcriptionEngine, + textTranslationAvailable: AppleTextTranslation.isSupported + ) + } + + /// Whether "Translate to English" is offerable at all for the current + /// engine — natively (whisper family) or via the on-device text path + /// (macOS 15+). The ONE predicate both offer surfaces (menu bar row, + /// Dictation pane toggle) read, so they can never disagree. + var translationOffered: Bool { + TextTranslationPolicy.translationOffered( + transcriptionEngine: transcriptionEngine, + textTranslationAvailable: AppleTextTranslation.isSupported + ) + } + + /// Whether THIS session's final transcript should be translated as text + /// (Apple Translation, macOS 15+) because the user wants English but the + /// active engine is ASR-only. Pure core decision — see TextTranslationPolicy. + private var shouldTextTranslateFinal: Bool { + TextTranslationPolicy.shouldTranslateFinal( + translateToEnglish: translateToEnglish, + language: language, + transcriptionEngine: transcriptionEngine, + textTranslationAvailable: AppleTextTranslation.isSupported ) } @@ -1846,9 +1875,9 @@ class AppState: ObservableObject { voiceEditingEnabled = settingsStore.object(forKey: "voiceEditingEnabled") as? Bool ?? true scriptPostProcessorEnabled = settingsStore.object(forKey: "scriptPostProcessorEnabled") as? Bool ?? false scriptPostProcessorPath = settingsStore.string(forKey: "scriptPostProcessorPath") ?? "" - outputTargetSettings = Self.loadOutputTargetSettings() + outputTargetSettings = SettingsBlobLoaders.outputTargetSettings() ruleSet = RuleStore.load() - screenContext = Self.loadScreenContext() + screenContext = SettingsBlobLoaders.screenContext() perAppModesEnabled = settingsStore.object(forKey: "perAppModesEnabled") as? Bool ?? false historyEnabled = settingsStore.object(forKey: "historyEnabled") as? Bool ?? true // MAK-40 raw-audio retention: opt-in (default OFF); policy defaults keep the @@ -3033,14 +3062,7 @@ class AppState: ObservableObject { return ModelStorage.sorted(items) } - /// Base directory FluidAudio stages its CoreML model repos under - /// (`~/Library/Application Support/FluidAudio/Models`). Mirrors FluidAudio's - /// own `downloadVariant`/`defaultCacheDirectory` layout. - static func fluidAudioModelsDirectory() -> URL { - FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! - .appendingPathComponent("FluidAudio", isDirectory: true) - .appendingPathComponent("Models", isDirectory: true) - } + static func fluidAudioModelsDirectory() -> URL { FluidAudioModelsLocator.modelsDirectory() } /// Delete a model's files. Refuses the currently-active model (removing it would /// force a re-download on the next dictation) — the UI disables that case, this @@ -3149,16 +3171,7 @@ class AppState: ObservableObject { } /// The set of FluidAudio Models repo folders present on disk right now. - static func installedFluidAudioFolders() -> Set { - let base = fluidAudioModelsDirectory() - let names = (try? FileManager.default.contentsOfDirectory(atPath: base.path)) ?? [] - return Set(names.filter { name in - var isDir: ObjCBool = false - FileManager.default.fileExists( - atPath: base.appendingPathComponent(name).path, isDirectory: &isDir) - return isDir.boolValue - }) - } + static func installedFluidAudioFolders() -> Set { FluidAudioModelsLocator.installedFolders() } func warmWhisperServerIfPossible() { // Warm the CURRENTLY SELECTED file-transcription backend — and only that @@ -3339,6 +3352,14 @@ class AppState: ObservableObject { // whisper chunked sessions. Deciding from the live @Published outputMode // there would paste the whole final text a second time. isLiveChunkSession = outputMode == "liveChunks" + // Text-path translation (Apple Translation, macOS 15+): the live preview + // stays in the spoken language, but the PASTED result is the translated + // FINAL — so the per-partial delta paste must not type the original + // language into the document first. Forcing the finalOnly-style single + // paste keeps "what lands in the document" and "what was translated" + // the same text. Capability-driven (TextTranslationPolicy), no engine + // names here. + if shouldTextTranslateFinal { isLiveChunkSession = false } isAppleSpeechSession = true // Re-bind the active engine's partial/final closures to THIS session's // generation so its callbacks carry `activeSessionID`. A late final from a @@ -4136,7 +4157,40 @@ class AppState: ObservableObject { statusMessage = "Inserted: \(text.prefix(40))..." } + /// Session-final choke point (every dictation path funnels here). When + /// `TextTranslationPolicy` arms the text path, the final transcript is + /// translated ON-DEVICE as text before delivery; on ANY failure or timeout + /// the ORIGINAL transcript is delivered unchanged (untranslated, never lost). + /// + /// Mid-refine finals skip translation on purpose: their tail is a spoken + /// INSTRUCTION and `instructionSuffix` subtracts `refineContentSnapshot` + /// as a literal prefix, which translation would break. private func completeFinalText(_ text: String) { + guard shouldTextTranslateFinal, !text.isEmpty, refineContentSnapshot == nil else { + deliverFinalText(text) + return + } + isTranscribing = true + statusMessage = "Translating…" + // Capture the session so a cancel (Esc) or a new session started while + // the translation is in flight causes this continuation to be ignored — + // same fence as every other async completion in this file. + let sessionID = activeSessionID + // Source hint: the session language ("auto" → provider-side detection). + let sourceHint = language + Task { @MainActor [weak self] in + let translated = await AppleTextTranslation.translate(text, from: sourceHint, to: "en") + guard let self, sessionID == self.activeSessionID else { return } + if translated == nil { + self.translationStatus = AppleTextTranslation.lastError.map { + "Kept original text — \($0)" + } ?? "Kept original text — translation unavailable" + } + self.deliverFinalText(translated ?? text) + } + } + + private func deliverFinalText(_ text: String) { let finalText = postProcess(text, isFinalTranscript: true) // Overlay subtitle. Skipped mid-refine (spoken words there are an @@ -4777,17 +4831,6 @@ class AppState: ObservableObject { settingsStore.set(data, forKey: "outputTargetSettings") } - /// Load the persisted output-target settings, defaulting to focused-app (today's - /// behavior) when absent or unreadable. - private static func loadOutputTargetSettings() -> OutputTargetSettings { - // Static (called during init before `self.settingsStore` is assigned), so - // it reads UserDefaults.standard directly rather than the injected seam. - guard let data = UserDefaults.standard.data(forKey: "outputTargetSettings"), - let decoded = try? JSONDecoder().decode(OutputTargetSettings.self, from: data) - else { return OutputTargetSettings() } - return decoded - } - // MARK: - Rules engine (MAK-43) /// The app-side runner that executes planned rule actions over the existing @@ -4838,16 +4881,6 @@ class AppState: ObservableObject { settingsStore.set(data, forKey: "screenContextSettings") } - /// Load the persisted screen-context config, defaulting to OFF (opt-in) when - /// absent or unreadable. Static (init-time), so it reads UserDefaults.standard - /// directly rather than the injected seam. - private static func loadScreenContext() -> ScreenContextSettings { - guard let data = UserDefaults.standard.data(forKey: "screenContextSettings"), - let decoded = try? JSONDecoder().decode(ScreenContextSettings.self, from: data) - else { return ScreenContextSettings.default } - return decoded - } - /// Capture screen context for the session that is starting, applying the full /// `ScreenContextGate` (opt-in, per-app allowlist, secure-field guard, /// local-provider-only for LLM context). Reads the focused field via AX ONLY @@ -6358,80 +6391,6 @@ class AppState: ObservableObject { } } -final class ModelDownloader: NSObject, URLSessionDownloadDelegate { - private var continuation: CheckedContinuation? - /// Called as bytes arrive with (totalBytesWritten, totalBytesExpectedToWrite). - /// `totalBytesExpectedToWrite` is `NSURLSessionTransferSizeUnknown` (-1) when - /// the server does not advertise a Content-Length. - private var progressHandler: ((Int64, Int64) -> Void)? - - static func download(from url: URL, progress: ((Int64, Int64) -> Void)? = nil) async throws -> URL { - let downloader = ModelDownloader() - return try await downloader.download(from: url, progress: progress) - } - - private func download(from url: URL, progress: ((Int64, Int64) -> Void)?) async throws -> URL { - try await withCheckedThrowingContinuation { continuation in - self.continuation = continuation - self.progressHandler = progress - let configuration = URLSessionConfiguration.default - configuration.timeoutIntervalForRequest = 60 - configuration.timeoutIntervalForResource = 60 * 60 - configuration.waitsForConnectivity = true - let session = URLSession(configuration: configuration, delegate: self, delegateQueue: nil) - session.downloadTask(with: url).resume() - } - } - - func urlSession(_ session: URLSession, - downloadTask: URLSessionDownloadTask, - didWriteData bytesWritten: Int64, - totalBytesWritten: Int64, - totalBytesExpectedToWrite: Int64) { - progressHandler?(totalBytesWritten, totalBytesExpectedToWrite) - } - - func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) { - // URLSession delivers this for ANY completed transfer, including 404/403/5xx - // (the error body is what got written to disk). Installing an error page as - // the model file would break every subsequent transcription with no in-app - // recovery, so fail the download instead. - if let http = downloadTask.response as? HTTPURLResponse, !(200...299).contains(http.statusCode) { - continuation?.resume(throwing: ModelDownloadError(message: "Server returned HTTP \(http.statusCode)")) - continuation = nil - session.invalidateAndCancel() - return - } - do { - let tempURL = FileManager.default.temporaryDirectory - .appendingPathComponent("openwhisp-model-\(UUID().uuidString).download") - try? FileManager.default.removeItem(at: tempURL) - try FileManager.default.moveItem(at: location, to: tempURL) - continuation?.resume(returning: tempURL) - } catch { - continuation?.resume(throwing: error) - } - continuation = nil - session.invalidateAndCancel() - } - - func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { - if let error, continuation != nil { - continuation?.resume(throwing: error) - continuation = nil - session.invalidateAndCancel() - } - } -} - -// MARK: - Helpers - -extension URL { - func createDirectories() throws { - try FileManager.default.createDirectory(at: self, withIntermediateDirectories: true, attributes: nil) - } -} - // MARK: - Agent Bridge host (M8) extension AppState: AgentBridgeHost { diff --git a/OpenWhisp/Models/FluidAudioModelsLocator.swift b/OpenWhisp/Models/FluidAudioModelsLocator.swift new file mode 100644 index 0000000..b72a559 --- /dev/null +++ b/OpenWhisp/Models/FluidAudioModelsLocator.swift @@ -0,0 +1,29 @@ +import Foundation + +/// Where FluidAudio (Parakeet) stages its CoreML model repos on disk, and which +/// variant folders are currently installed. Extracted from AppState (MAK-32 +/// ratchet) — pure filesystem lookups with no AppState state, so they live in +/// their own type and AppState forwards to them. +enum FluidAudioModelsLocator { + + /// Base directory FluidAudio stages its CoreML model repos under + /// (`~/Library/Application Support/FluidAudio/Models`). Mirrors FluidAudio's + /// own `downloadVariant`/`defaultCacheDirectory` layout. + static func modelsDirectory() -> URL { + FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! + .appendingPathComponent("FluidAudio", isDirectory: true) + .appendingPathComponent("Models", isDirectory: true) + } + + /// Names of the variant subfolders currently staged under `modelsDirectory()`. + static func installedFolders() -> Set { + let base = modelsDirectory() + let names = (try? FileManager.default.contentsOfDirectory(atPath: base.path)) ?? [] + return Set(names.filter { name in + var isDir: ObjCBool = false + FileManager.default.fileExists( + atPath: base.appendingPathComponent(name).path, isDirectory: &isDir) + return isDir.boolValue + }) + } +} diff --git a/OpenWhisp/Models/ModelDownloader.swift b/OpenWhisp/Models/ModelDownloader.swift new file mode 100644 index 0000000..8134fe9 --- /dev/null +++ b/OpenWhisp/Models/ModelDownloader.swift @@ -0,0 +1,77 @@ +import Foundation + +// Extracted from AppState (MAK-32 ratchet): a self-contained download helper +// with no AppState state — it lived at the bottom of AppState.swift only for +// historical reasons. + +final class ModelDownloader: NSObject, URLSessionDownloadDelegate { + private var continuation: CheckedContinuation? + /// Called as bytes arrive with (totalBytesWritten, totalBytesExpectedToWrite). + /// `totalBytesExpectedToWrite` is `NSURLSessionTransferSizeUnknown` (-1) when + /// the server does not advertise a Content-Length. + private var progressHandler: ((Int64, Int64) -> Void)? + + static func download(from url: URL, progress: ((Int64, Int64) -> Void)? = nil) async throws -> URL { + let downloader = ModelDownloader() + return try await downloader.download(from: url, progress: progress) + } + + private func download(from url: URL, progress: ((Int64, Int64) -> Void)?) async throws -> URL { + try await withCheckedThrowingContinuation { continuation in + self.continuation = continuation + self.progressHandler = progress + let configuration = URLSessionConfiguration.default + configuration.timeoutIntervalForRequest = 60 + configuration.timeoutIntervalForResource = 60 * 60 + configuration.waitsForConnectivity = true + let session = URLSession(configuration: configuration, delegate: self, delegateQueue: nil) + session.downloadTask(with: url).resume() + } + } + + func urlSession(_ session: URLSession, + downloadTask: URLSessionDownloadTask, + didWriteData bytesWritten: Int64, + totalBytesWritten: Int64, + totalBytesExpectedToWrite: Int64) { + progressHandler?(totalBytesWritten, totalBytesExpectedToWrite) + } + + func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) { + // URLSession delivers this for ANY completed transfer, including 404/403/5xx + // (the error body is what got written to disk). Installing an error page as + // the model file would break every subsequent transcription with no in-app + // recovery, so fail the download instead. + if let http = downloadTask.response as? HTTPURLResponse, !(200...299).contains(http.statusCode) { + continuation?.resume(throwing: ModelDownloadError(message: "Server returned HTTP \(http.statusCode)")) + continuation = nil + session.invalidateAndCancel() + return + } + do { + let tempURL = FileManager.default.temporaryDirectory + .appendingPathComponent("openwhisp-model-\(UUID().uuidString).download") + try? FileManager.default.removeItem(at: tempURL) + try FileManager.default.moveItem(at: location, to: tempURL) + continuation?.resume(returning: tempURL) + } catch { + continuation?.resume(throwing: error) + } + continuation = nil + session.invalidateAndCancel() + } + + func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { + if let error, continuation != nil { + continuation?.resume(throwing: error) + continuation = nil + session.invalidateAndCancel() + } + } +} + +extension URL { + func createDirectories() throws { + try FileManager.default.createDirectory(at: self, withIntermediateDirectories: true, attributes: nil) + } +} diff --git a/OpenWhisp/Models/SettingsBlobLoaders.swift b/OpenWhisp/Models/SettingsBlobLoaders.swift new file mode 100644 index 0000000..07301ea --- /dev/null +++ b/OpenWhisp/Models/SettingsBlobLoaders.swift @@ -0,0 +1,26 @@ +import Foundation + +/// Init-time decoders for the JSON settings blobs AppState reads before its +/// injected `settingsStore` seam exists. Extracted from AppState (MAK-32 ratchet): +/// they read `UserDefaults.standard` directly and hold no AppState state, so they +/// live here and AppState calls them from its initializer. +enum SettingsBlobLoaders { + + /// Load the persisted output-target settings, defaulting to focused-app + /// (today's behavior) when absent or unreadable. + static func outputTargetSettings() -> OutputTargetSettings { + guard let data = UserDefaults.standard.data(forKey: "outputTargetSettings"), + let decoded = try? JSONDecoder().decode(OutputTargetSettings.self, from: data) + else { return OutputTargetSettings() } + return decoded + } + + /// Load the persisted screen-context config, defaulting to OFF (opt-in) when + /// absent or unreadable. + static func screenContext() -> ScreenContextSettings { + guard let data = UserDefaults.standard.data(forKey: "screenContextSettings"), + let decoded = try? JSONDecoder().decode(ScreenContextSettings.self, from: data) + else { return ScreenContextSettings.default } + return decoded + } +} diff --git a/OpenWhisp/Models/StreamOverlayCoordinator.swift b/OpenWhisp/Models/StreamOverlayCoordinator.swift index d407e65..feba405 100644 --- a/OpenWhisp/Models/StreamOverlayCoordinator.swift +++ b/OpenWhisp/Models/StreamOverlayCoordinator.swift @@ -127,7 +127,12 @@ final class StreamOverlayCoordinator: ObservableObject { /// matches the saved look. private func startServer() { stopServer() - let server = StreamOverlayServer(config: config) + // Translated subtitles: the server's injected Translator seam gets the + // on-device Apple Translation text path (macOS 15+; nil on macOS 14 — + // finals then pass through untranslated). The server only invokes it + // when `config.translationEnabled` and a target language are set, and + // a nil result keeps the original caption line (never a lost line). + let server = StreamOverlayServer(config: config, translator: AppleTextTranslation.overlayTranslator()) server.onFailure = { [weak self] message in guard let self, self.server === server else { return } self.server = nil diff --git a/OpenWhisp/Services/AppleTranslationProvider.swift b/OpenWhisp/Services/AppleTranslationProvider.swift new file mode 100644 index 0000000..cb1f459 --- /dev/null +++ b/OpenWhisp/Services/AppleTranslationProvider.swift @@ -0,0 +1,514 @@ +import AppKit +import Foundation +import NaturalLanguage +import SwiftUI +#if canImport(Translation) +import Translation +#endif + +// App-side ONLY (not in OpenWhispCore): Apple's Translation framework + the +// SwiftUI/AppKit anchor below. The pure arm/offer decisions live in core +// (TextTranslationPolicy.swift) and are unit-tested there. + +/// On-device TEXT translation via Apple's Translation framework (macOS 15+), +/// wrapped so the rest of the app can ask one plain async question: "translate +/// this string into that language, or tell me you couldn't". +/// +/// Contract (the never-lose-text rule): `translate` returns the translation or +/// nil. It NEVER throws and NEVER hangs — missing-asset and unsupported pairs +/// fail FAST (the framework itself hangs on them), everything else carries a +/// watchdog timeout — and every caller falls back to the ORIGINAL text on nil. +enum AppleTextTranslation { + + /// Whether the on-device text-translation path exists on this OS. The app's + /// deployment target is macOS 14; there this is false and the feature is + /// simply unavailable (offer gates dim, sessions never arm the text path). + static var isSupported: Bool { + #if canImport(Translation) + if #available(macOS 15.0, *) { return true } + #endif + return false + } + + /// Translate `text` into `targetLanguage` (a BCP-47-ish tag: "en", "es", + /// "pt-BR"). `sourceHint` is the language the text is (probably) in — pass + /// the session's language setting; nil/"auto"/empty falls back to on-device + /// detection over the text itself (NLLanguageRecognizer). The source is + /// ALWAYS resolved to an explicit language before the framework sees it: a + /// nil-source `TranslationSession.Configuration` is broken in practice — + /// `prepareTranslation` fails with `unableToIdentifyLanguage` and + /// `translate` then hangs past any deadline (observed on macOS 26). + /// + /// Nil when the OS can't translate text, the pair is unsupported, language + /// assets still need their one-time download (which this call kicks off), + /// any framework error, or timeout. + @MainActor + static func translate(_ text: String, from sourceHint: String?, to targetLanguage: String) async -> String? { + #if canImport(Translation) + guard #available(macOS 15.0, *) else { return nil } + return await AppleTranslationProvider.shared.translate(text, from: sourceHint, to: targetLanguage) + #else + return nil + #endif + } + + /// Last framework error, for status surfaces ("kept original text"). + @MainActor + static var lastError: String? { + #if canImport(Translation) + guard #available(macOS 15.0, *) else { return nil } + return AppleTranslationProvider.shared.lastError + #else + return nil + #endif + } + + /// The stream-overlay server's injected `Translator` closure, or nil when + /// this OS can't translate text. The server already treats a nil result as + /// "keep the original caption line" — the same never-lose-text fallback. + /// No source hint here: caption lines are detected per-line (the overlay + /// doesn't know the session language). + @MainActor + static func overlayTranslator() -> (@Sendable (String, String) async -> String?)? { + guard isSupported else { return nil } + return { text, target in + await translate(text, from: nil, to: target) + } + } + + /// Asset state of one source→target pair, for the Settings status rows + /// (`TranslationAssetStatusView`). Exists unconditionally (macOS 14 renders + /// the rows too — as "unavailable"). + enum AssetStatus: Equatable { + /// Language assets are on disk — translation works right now. + case installed + /// The pair is supported but its assets need a one-time download. + case needsDownload + /// macOS translation can't do this pair at all. + case unsupported + /// No answer possible: macOS 14, or no concrete source ("auto"). + case unavailable + } + + /// Current asset state of `sourceTag`→`targetTag`. "auto"/empty source → + /// `.unavailable` (no concrete pair to check — assets resolve on first use). + @MainActor + static func assetStatus(from sourceTag: String, to targetTag: String) async -> AssetStatus { + #if canImport(Translation) + guard #available(macOS 15.0, *) else { return .unavailable } + return await AppleTranslationProvider.shared.assetStatus(from: sourceTag, to: targetTag) + #else + return .unavailable + #endif + } + + /// Kick off the one-time language-asset download for `sourceTag`→`targetTag` + /// (presents the system consent sheet). User-initiated only — the Settings + /// Download button; dictations never pop UI on their own. + @MainActor + static func requestAssetDownload(from sourceTag: String, to targetTag: String) { + #if canImport(Translation) + guard #available(macOS 15.0, *) else { return } + AppleTranslationProvider.shared.requestDownload(from: sourceTag, to: targetTag) + #endif + } +} + +#if canImport(Translation) + +/// The hidden-anchor `TranslationSession` broker. +/// +/// Apple gives no direct initializer for `TranslationSession`; the ONLY way to +/// obtain one is SwiftUI's `.translationTask(configuration:)` view modifier. So +/// this provider keeps a persistent, invisible anchor — a 1×1, alpha-0, +/// offscreen borderless `NSWindow` hosting a SwiftUI view that carries +/// `.translationTask` — alive for the app's lifetime. Publishing a new +/// `TranslationSession.Configuration` (re)triggers the modifier; its action +/// hands us a live session, which we hold inside the action (`serve`) and use +/// to drain a MainActor request queue until SwiftUI cancels the action (next +/// configuration change / teardown). The invisible anchor is proven to fire +/// (harness-tested): translation works headless as long as the pair's language +/// assets are INSTALLED. +/// +/// **Missing assets are the sharp edge.** For a merely `supported` (not +/// installed) pair, `prepareTranslation`/`translate` hang indefinitely from an +/// invisible window — the consent sheet has nowhere to present. So `translate` +/// pre-checks `LanguageAvailability` and fails FAST on such pairs, while +/// kicking off a one-time visible consent flow: the anchor window becomes a +/// small centered panel for the duration of `prepareTranslation` (the system +/// download sheet presents from it), then goes invisible again. Once the user +/// approves and the download lands, the pair reads `.installed` and every later +/// request translates headless. +@available(macOS 15.0, *) +@MainActor +final class AppleTranslationProvider: ObservableObject { + + static let shared = AppleTranslationProvider() + + /// Per-request deadline. Generous because the first translation after a + /// configuration change includes model load (~2.5s measured). Callers show + /// "Translating…" while they wait; on timeout the request resolves nil and + /// the caller keeps the original text (never a hang, never a loss). + static let requestTimeout: TimeInterval = 12 + + /// Drives the anchor's `.translationTask`. Nil until the first request; + /// replaced (→ session restart) when the language pair changes. + @Published private(set) var configuration: TranslationSession.Configuration? + + /// True while the anchor window is on screen hosting the one-time language + /// download consent; drives the anchor view's visible body. + @Published private(set) var presentingDownload = false + + /// Last framework error, for status surfaces. + private(set) var lastError: String? + + /// One queued translate call. A class so the serve loop and the watchdog + /// can race to finish it exactly once (MainActor-confined — no lock). + private final class Request { + let text: String + let source: String + let target: String + private var continuation: CheckedContinuation? + + init(text: String, source: String, target: String) { + self.text = text + self.source = source + self.target = target + } + + func adopt(_ continuation: CheckedContinuation) { + self.continuation = continuation + } + + var isFinished: Bool { continuation == nil } + + /// Resume the caller exactly once; later calls are no-ops (e.g. the + /// session's late result after the watchdog already fired). + func finish(returning value: String?) { + continuation?.resume(returning: value) + continuation = nil + } + } + + /// FIFO of unserved requests. MainActor-only. + private var pending: [Request] = [] + /// Serve loops parked waiting for work; resumed by enqueue/cancel. An array + /// (not a single slot) so a cancelled loop's late wake can never strand the + /// replacement loop's continuation. + private var parked: [CheckedContinuation] = [] + /// The language pair of the live/incoming configuration. + private var currentPair: (source: String, target: String)? + /// The invisible SwiftUI anchor hosting `.translationTask`. + private var anchorWindow: NSWindow? + + // MARK: - Public API + + /// Translate `text` into `targetLanguage`. Nil on any failure or timeout — + /// callers keep the original text. See `AppleTextTranslation.translate`. + func translate(_ text: String, from sourceHint: String?, to targetLanguage: String) async -> String? { + let target = Self.normalizedTag(targetLanguage) + guard !target.isEmpty, !text.isEmpty else { return nil } + guard let source = Self.resolveSource(hint: sourceHint, text: text) else { + lastError = "Couldn't identify the dictated language" + return nil + } + // Same language → nothing to translate; the original IS the result. + if Self.baseCode(source) == Self.baseCode(target) { return text } + + // Fail FAST when the pair can't serve, instead of letting the request + // ride the watchdog: the framework hangs (not errors) on not-installed + // pairs, and 12 silent seconds per dictation reads as "broken". + switch await LanguageAvailability().status( + from: Locale.Language(identifier: source), + to: Locale.Language(identifier: target)) { + case .installed: + break + case .supported: + // Assets exist but need their one-time download. Fail fast and + // point at Settings — mid-dictation is the wrong moment to pop a + // consent sheet (a surprise window over whatever the user is + // dictating into). The Settings row owns the download flow. + lastError = "\(Self.pairName(source, target)) isn't downloaded — Settings → Dictation has the download" + return nil + case .unsupported: + lastError = "\(Self.pairName(source, target)) isn't supported by macOS translation" + return nil + @unknown default: + break + } + + ensureAnchor() + retarget(source: source, target: target) + + let request = Request(text: text, source: source, target: target) + return await withCheckedContinuation { (c: CheckedContinuation) in + request.adopt(c) + pending.append(request) + wakeAllParked() + // Watchdog: no session may ever strand a dictation. If nothing + // serves this request in time (framework stall), fail it — the + // caller falls back to the original text. + Task { @MainActor [weak self] in + try? await Task.sleep(nanoseconds: UInt64(Self.requestTimeout * 1_000_000_000)) + guard !request.isFinished else { return } + if let self { + self.pending.removeAll { $0 === request } + if self.lastError == nil { self.lastError = "Translation timed out" } + } + request.finish(returning: nil) + } + } + } + + // MARK: - Session serving (runs inside the anchor's .translationTask action) + + /// Drain the request queue with `session`. Runs as the `.translationTask` + /// action; the session is only valid inside it, so the loop holds it here + /// until SwiftUI cancels the action (configuration change / teardown). + func serve(_ session: TranslationSession) async { + // For installed pairs this is a fast no-op; for a download-consent + // session (anchor presented) it shows the system download sheet and + // returns once the assets land (or the user declines). + do { + try await session.prepareTranslation() + lastError = nil + } catch { + lastError = "Language assets not ready: \(error.localizedDescription)" + } + if presentingDownload { retractAnchor() } + + while !Task.isCancelled { + guard let request = pending.first else { + await parkUntilWork() + continue + } + guard let pair = currentPair, + request.source == pair.source, request.target == pair.target else { + // Queued for a different pair (dictation wants ru→en while the + // overlay wants en→es). Swap the configuration — SwiftUI cancels + // THIS action and starts a fresh session that serves it. The + // request stays queued; its watchdog still bounds the wait. + retarget(source: request.source, target: request.target) + return + } + pending.removeFirst() + do { + let response = try await session.translate(request.text) + // Clear the sticky error on success so a LATER failure's status + // ("Kept original text — …") can't cite a stale cause. + lastError = nil + request.finish(returning: response.targetText) + } catch { + lastError = error.localizedDescription + request.finish(returning: nil) + } + } + } + + /// Park the serve loop until a request arrives or the action is cancelled. + private func parkUntilWork() async { + await withTaskCancellationHandler { + await withCheckedContinuation { (c: CheckedContinuation) in + if Task.isCancelled || !pending.isEmpty { + c.resume() + return + } + parked.append(c) + } + } onCancel: { + Task { @MainActor [weak self] in self?.wakeAllParked() } + } + } + + /// Wake every parked serve loop; each re-checks its own cancellation/queue + /// state (a spurious wake just re-parks). Safe against the cancelled-loop / + /// replacement-loop overlap during a configuration swap. + private func wakeAllParked() { + let waiters = parked + parked = [] + for waiter in waiters { waiter.resume() } + } + + // MARK: - Language-asset status + download (the one visible moment) + + /// Asset state of one pair, for the Settings status rows. See + /// `AppleTextTranslation.assetStatus`. + func assetStatus(from sourceTag: String, to targetTag: String) async -> AppleTextTranslation.AssetStatus { + let source = Self.normalizedTag(sourceTag) + let target = Self.normalizedTag(targetTag) + guard !source.isEmpty, source.lowercased() != "auto", !target.isEmpty else { + return .unavailable + } + // Same language: nothing to download, translation is a no-op pass-through. + if Self.baseCode(source) == Self.baseCode(target) { return .installed } + switch await LanguageAvailability().status( + from: Locale.Language(identifier: source), + to: Locale.Language(identifier: target)) { + case .installed: return .installed + case .supported: return .needsDownload + case .unsupported: return .unsupported + @unknown default: return .unavailable + } + } + + /// USER-INITIATED download flow (the Settings row's Download button): + /// present the anchor and point the configuration at the pair so the + /// replacement session's `prepareTranslation` can show the system download + /// consent from a REAL window. The Settings row polls `assetStatus` and + /// flips to Installed when the download lands. + func requestDownload(from sourceTag: String, to targetTag: String) { + let source = Self.normalizedTag(sourceTag) + let target = Self.normalizedTag(targetTag) + guard !source.isEmpty, source.lowercased() != "auto", !target.isEmpty, + Self.baseCode(source) != Self.baseCode(target), + !presentingDownload else { return } + ensureAnchor() + presentAnchor() + if let pair = currentPair, pair.source == source, pair.target == target, + var live = configuration { + // Same pair as the live session: invalidate to force a fresh action + // (a new-but-equal configuration value may not re-trigger it). + live.invalidate() + configuration = live + } else { + currentPair = (source, target) + configuration = TranslationSession.Configuration( + source: Locale.Language(identifier: source), + target: Locale.Language(identifier: target)) + } + } + + /// Turn the invisible anchor into a small centered panel that can host the + /// system download sheet. + private func presentAnchor() { + guard let window = anchorWindow, !presentingDownload else { return } + presentingDownload = true + window.styleMask = [.titled] + window.title = "OpenWhisp — Translation" + window.alphaValue = 1 + window.level = .floating + window.setContentSize(NSSize(width: 380, height: 96)) + window.center() + window.orderFrontRegardless() + NSApp.activate() + } + + /// Return the anchor to its invisible resting state. + private func retractAnchor() { + presentingDownload = false + guard let window = anchorWindow else { return } + window.styleMask = [.borderless] + window.alphaValue = 0 + window.level = .init(rawValue: NSWindow.Level.normal.rawValue - 1) + window.setFrame(NSRect(x: -4000, y: -4000, width: 1, height: 1), display: false) + window.orderBack(nil) + } + + // MARK: - Configuration / anchor plumbing + + /// Point the anchor's configuration at the EXPLICIT `source`→`target` pair. + /// No-op when already there, so a stream of same-pair requests keeps one + /// long-lived session. Never nil-source (see `AppleTextTranslation.translate`). + private func retarget(source: String, target: String) { + if let pair = currentPair, pair.source == source, pair.target == target, + configuration != nil { return } + currentPair = (source, target) + configuration = TranslationSession.Configuration( + source: Locale.Language(identifier: source), + target: Locale.Language(identifier: target)) + } + + /// Lazily build the persistent invisible anchor: a borderless, alpha-0, + /// click-through, offscreen 1×1 window whose content view carries the + /// `.translationTask` modifier. It must be ordered into the window list so + /// SwiftUI mounts the view (and with it the task); it is never key, never + /// visible (except during a download consent), and lives for the app's + /// lifetime. + private func ensureAnchor() { + guard anchorWindow == nil else { return } + let host = NSHostingView(rootView: TranslationAnchorView(provider: self)) + host.frame = NSRect(x: 0, y: 0, width: 1, height: 1) + let window = NSWindow( + contentRect: NSRect(x: -4000, y: -4000, width: 1, height: 1), + styleMask: [.borderless], + backing: .buffered, + defer: false) + window.isReleasedWhenClosed = false + window.isExcludedFromWindowsMenu = true + window.ignoresMouseEvents = true + window.hasShadow = false + window.alphaValue = 0 + window.level = .init(rawValue: NSWindow.Level.normal.rawValue - 1) + window.collectionBehavior = [.canJoinAllSpaces, .stationary, .ignoresCycle] + window.contentView = host + window.orderBack(nil) + anchorWindow = window + } + + // MARK: - Language tags + + /// The session's language when it names one, else on-device detection over + /// the text itself. Never returns "auto"; nil when even detection can't + /// name a language (caller fails fast with a clear status). + static func resolveSource(hint: String?, text: String) -> String? { + if let hint { + let tag = normalizedTag(hint) + if !tag.isEmpty, tag.lowercased() != "auto" { return tag } + } + let recognizer = NLLanguageRecognizer() + recognizer.processString(text) + return recognizer.dominantLanguage?.rawValue + } + + /// "pt-BR"-style tag the framework accepts: trimmed, underscores dashed. + private static func normalizedTag(_ tag: String) -> String { + tag.trimmingCharacters(in: .whitespacesAndNewlines) + .replacingOccurrences(of: "_", with: "-") + } + + /// "pt" from "pt-BR" — for the same-language no-op check. + static func baseCode(_ tag: String) -> String { + tag.split(separator: "-").first.map { String($0).lowercased() } ?? tag.lowercased() + } + + /// "Russian → English" — status text uses names, not tags. + static func pairName(_ source: String, _ target: String) -> String { + "\(LanguageResolver.displayName(for: source)) → \(LanguageResolver.displayName(for: target))" + } +} + +/// The invisible SwiftUI view that owns the `.translationTask`. Re-runs its +/// action whenever the provider publishes a new configuration; with a nil +/// configuration the framework runs nothing. During a language-asset download +/// consent it shows a small explanatory panel (the window is visible then); +/// the rest of the time it's a 1×1 clear pixel. +@available(macOS 15.0, *) +private struct TranslationAnchorView: View { + @ObservedObject var provider: AppleTranslationProvider + + var body: some View { + content + .translationTask(provider.configuration) { session in + await provider.serve(session) + } + } + + @ViewBuilder + private var content: some View { + if provider.presentingDownload { + VStack(alignment: .leading, spacing: 6) { + Text("Preparing on-device translation") + .font(.headline) + Text("macOS may ask to download a translation language. Approve it once — dictations translate from then on.") + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(16) + .frame(width: 380) + } else { + Color.clear.frame(width: 1, height: 1) + } + } +} + +#endif diff --git a/OpenWhisp/Services/TextTranslationPolicy.swift b/OpenWhisp/Services/TextTranslationPolicy.swift new file mode 100644 index 0000000..42aa2c9 --- /dev/null +++ b/OpenWhisp/Services/TextTranslationPolicy.swift @@ -0,0 +1,114 @@ +import Foundation + +/// Should a dictation session translate its final TEXT — on-device, after the +/// ASR engine produced it? +/// +/// The text path exists for one gap in the capability matrix: a user who wants +/// "Translate to English" while dictating with a fast streaming engine that +/// cannot itself translate (Parakeet, Apple Speech, SpeechAnalyzer — ASR-only, +/// see `EngineCapabilities.translation`). Historically that combination silently +/// gated translation OFF (`LanguageResolver.effectiveTranslateToEnglish` returns +/// false there). The text path recovers it WITHOUT a second ASR runtime (the +/// mistake of the retired dual-runtime approach, PR #219): the engine keeps +/// driving the live preview in the spoken language, and the session's FINAL +/// transcript is translated as text (Apple's Translation framework, macOS 15+) +/// before it is pasted. No audio tap, so it works identically for every +/// streaming engine. +/// +/// This is the single, pure, unit-tested predicate that decides whether to arm +/// that path — a capability question, never an engine-name check at the call +/// site (the rule this repo keeps re-learning): +/// +/// 1. the user asked to translate (`translateToEnglish`), +/// 2. the OS has an on-device text translator (`textTranslationAvailable` — +/// macOS 15+; the app itself still runs on macOS 14, where the feature is +/// simply unavailable), +/// 3. the spoken language is NOT already English (en→en is a no-op; "auto" +/// qualifies — the speaker may be dictating a non-English language), +/// 4. the active engine CANNOT translate natively — otherwise the normal +/// engine-level translate path already owns it. Deliberately the same +/// predicate the engine-level path arms on +/// (`LanguageResolver.supportsTranslation`), so the two paths are exact +/// complements: no session ever translates twice or falls in the gap. +/// +/// **The fallback is always "leave the text untranslated", never "lose text":** +/// when the translator fails or times out, the caller pastes the ORIGINAL +/// transcript unchanged. +public enum TextTranslationPolicy { + + /// Whether the text-translation path should run on this session's final + /// transcript. + public static func shouldTranslateFinal( + translateToEnglish: Bool, + language: String, + transcriptionEngine: String, + textTranslationAvailable: Bool + ) -> Bool { + guard translateToEnglish, textTranslationAvailable else { return false } + // English source → nothing to translate. "auto" is not English. + if isEnglish(language) { return false } + // The engine translates itself → the engine-level path owns it. + if LanguageResolver.supportsTranslation(transcriptionEngine: transcriptionEngine) { + return false + } + return true + } + + /// Whether the UI should OFFER "Translate to English" for this engine at + /// all: either the engine translates natively (whisper family) or the text + /// path can cover it (macOS 15+). The single gate for every offer surface + /// (menu bar, Dictation pane) so they can never disagree — a past bug was + /// exactly those two surfaces drifting apart. + public static func translationOffered( + transcriptionEngine: String, + textTranslationAvailable: Bool + ) -> Bool { + LanguageResolver.supportsTranslation(transcriptionEngine: transcriptionEngine) + || textTranslationAvailable + } + + /// The translate intent actually IN EFFECT for a session, counting BOTH + /// paths: the engine-level translate (whisper family) and the text path. + /// Downstream consumers that describe the session's OUTPUT — refine prompts, + /// `RefineOutputGuard.expectedCleanupScript` — must key on this, not on + /// `LanguageResolver.effectiveTranslateToEnglish` alone: when the text path + /// arms, the final transcript the refine layer sees IS English. + public static func effectiveTranslateToEnglish( + translateToEnglish: Bool, + language: String, + transcriptionEngine: String, + textTranslationAvailable: Bool + ) -> Bool { + LanguageResolver.effectiveTranslateToEnglish( + translateToEnglish: translateToEnglish, + transcriptionEngine: transcriptionEngine) + || shouldTranslateFinal( + translateToEnglish: translateToEnglish, + language: language, + transcriptionEngine: transcriptionEngine, + textTranslationAvailable: textTranslationAvailable) + } + + /// Language of the OUTPUT text for formatting rules — the text-path-aware + /// superset of `LanguageResolver.outputLanguageForCleaning`: English when + /// either translate path is in effect, else the spoken language. + public static func outputLanguageForCleaning( + language: String, + translateToEnglish: Bool, + transcriptionEngine: String, + textTranslationAvailable: Bool + ) -> String { + effectiveTranslateToEnglish( + translateToEnglish: translateToEnglish, + language: language, + transcriptionEngine: transcriptionEngine, + textTranslationAvailable: textTranslationAvailable) ? "en" : language + } + + /// Whether the language code names English (so translating is a no-op). + /// Uses the same base-code stripping as the Parakeet language gate so + /// regional tags ("en-US") count. + private static func isEnglish(_ language: String) -> Bool { + ParakeetLanguageHint.baseCode(from: language) == "en" + } +} diff --git a/OpenWhisp/Views/Settings/DictationPane.swift b/OpenWhisp/Views/Settings/DictationPane.swift index 9fd27d2..61f0af1 100644 --- a/OpenWhisp/Views/Settings/DictationPane.swift +++ b/OpenWhisp/Views/Settings/DictationPane.swift @@ -140,14 +140,26 @@ struct DictationPane: View { .foregroundStyle(.orange) } - // Whisper engines only — Apple Speech, SpeechAnalyzer and Parakeet - // are ASR-only and don't translate (capability-gated). - if LanguageResolver.supportsTranslation(transcriptionEngine: appState.transcriptionEngine) { + // Offered when the engine translates natively (whisper family) + // OR the on-device text path covers it (Apple Translation, + // macOS 15+; the final transcript is translated as text). The + // SAME predicate as the menu-bar row (`translationOffered`), + // so the two surfaces can't disagree. Only macOS 14 with an + // ASR-only engine keeps today's absent toggle. + if appState.translationOffered { SubtitledToggle( "Translate to English", subtitle: "Speech in any language comes out as English text.", isOn: $appState.translateToEnglish ) + // Text-path sessions need the pair's language assets on + // disk; this row shows their state and owns the download + // (dictations never pop the consent sheet themselves). + // Native-translate engines (whisper family) need no assets. + if appState.translateToEnglish, + !LanguageResolver.supportsTranslation(transcriptionEngine: appState.transcriptionEngine) { + TranslationAssetStatusView(sourceTag: appState.language, targetTag: "en") + } } } header: { Text("Language") diff --git a/OpenWhisp/Views/Settings/StreamOverlayPane.swift b/OpenWhisp/Views/Settings/StreamOverlayPane.swift index a54323a..ac9f67d 100644 --- a/OpenWhisp/Views/Settings/StreamOverlayPane.swift +++ b/OpenWhisp/Views/Settings/StreamOverlayPane.swift @@ -11,6 +11,10 @@ import AppKit /// captions capture. Only the port restarts the server. struct StreamOverlayPane: View { @ObservedObject var overlay: StreamOverlayCoordinator + /// The dictation language setting — the best proxy for what a captions + /// capture will hear, so the translation section can show a concrete + /// asset-status row ("auto" shows the download-on-first-use note instead). + var dictationLanguage: String = "auto" var body: some View { Form { @@ -18,6 +22,7 @@ struct StreamOverlayPane: View { if overlay.enabled { captureSection serverSection + translationSection displaySection } } @@ -125,6 +130,63 @@ struct StreamOverlayPane: View { set: { overlay.port = min(max($0, 1_024), 65_535) }) } + // MARK: Translation + + /// Translated subtitles: each finalized caption line runs through Apple's + /// on-device Translation framework (macOS 15+) into the chosen language + /// before it is shown. Live partials stay in the spoken language (they + /// change too fast to be worth a round trip), and a failed translation + /// shows the original line — captions are never dropped. On macOS 14 the + /// controls are replaced by an availability note. + private var translationSection: some View { + Section { + if AppleTextTranslation.isSupported { + Toggle("Translate subtitles", isOn: translationEnabledBinding) + + if overlay.config.translationEnabled { + Picker(selection: configBinding(\.targetLanguage)) { + ForEach(Self.targetLanguages, id: \.self) { code in + Text(LanguageResolver.displayName(for: code)).tag(code) + } + } label: { + Label("Subtitle language", systemImage: "captions.bubble") + } + + TranslationAssetStatusView( + sourceTag: dictationLanguage, + targetTag: overlay.config.targetLanguage, + autoNote: "Auto Detect: the spoken language is detected per caption, and its translation pack downloads on first use — until then those lines show untranslated. Pick a dictation language in the Dictation pane to download ahead of time.") + + SettingsCallout(.info, "Runs on-device with Apple's translation. Until a language pack is downloaded, subtitles show in the spoken language.") + } + } else { + SettingsFootnote("Translated subtitles need macOS 15 or later.") + } + } header: { + Text("Translation") + } + } + + /// Overlay subtitle languages offered (matches the app's language list). + /// The config stores any BCP-47-ish tag; this picker just curates the + /// common ones. + private static let targetLanguages = [ + "en", "es", "fr", "de", "it", "pt", "ja", "zh", "ko", "ru", "ar", + ] + + /// Enabling with no target yet picks English so the toggle visibly does + /// something (the server treats an empty target as pass-through). + private var translationEnabledBinding: Binding { + Binding( + get: { overlay.config.translationEnabled }, + set: { + overlay.config.translationEnabled = $0 + if $0 && overlay.config.targetLanguage.isEmpty { + overlay.config.targetLanguage = "en" + } + }) + } + // MARK: Display private var displaySection: some View { diff --git a/OpenWhisp/Views/Settings/TranslationAssetStatusView.swift b/OpenWhisp/Views/Settings/TranslationAssetStatusView.swift new file mode 100644 index 0000000..55e5be8 --- /dev/null +++ b/OpenWhisp/Views/Settings/TranslationAssetStatusView.swift @@ -0,0 +1,86 @@ +import SwiftUI + +/// Status + management row for the on-device translation assets one +/// source→target pair needs (Apple Translation, macOS 15+). +/// +/// This is the ONLY place a language download starts — dictations themselves +/// never pop UI; when assets are missing they paste the original text with a +/// status pointing here. The row polls while visible, so a download started +/// here (or in the Translate app) flips to "downloaded" within seconds — the +/// visibility that was missing when the consent sheet was the whole story. +struct TranslationAssetStatusView: View { + /// BCP-47-ish source tag, or "auto"/"" when the source is detected per + /// dictation (no concrete pair to pre-check). + let sourceTag: String + let targetTag: String + /// Shown for the "auto" source, where no concrete pair exists to check. + var autoNote: String = "Auto Detect: the translation language for what you dictate downloads on first use. Pick a specific language above to download it ahead of time." + + @State private var status: AppleTextTranslation.AssetStatus? + @State private var downloadRequested = false + + private var isAutoSource: Bool { + let tag = sourceTag.trimmingCharacters(in: .whitespacesAndNewlines) + return tag.isEmpty || tag.lowercased() == "auto" + } + + private var pairName: String { + "\(LanguageResolver.displayName(for: sourceTag)) → \(LanguageResolver.displayName(for: targetTag))" + } + + var body: some View { + content + .task(id: "\(sourceTag)→\(targetTag)") { + // Poll while visible (downloads run in the background and macOS + // gives no completion callback). 3s keeps the row honest without + // meaningfully costing anything — status() is a local lookup. + while !Task.isCancelled { + let latest = await AppleTextTranslation.assetStatus(from: sourceTag, to: targetTag) + status = latest + if latest == .installed { downloadRequested = false } + try? await Task.sleep(nanoseconds: 3_000_000_000) + } + } + } + + @ViewBuilder + private var content: some View { + if isAutoSource { + SettingsFootnote(autoNote) + } else { + switch status { + case .installed: + Label("\(pairName) translation is downloaded", systemImage: "checkmark.circle.fill") + .font(.caption) + .foregroundStyle(.green) + case .needsDownload: + HStack(spacing: 8) { + Label( + downloadRequested + ? "Downloading \(pairName) — takes a few minutes; this row updates when it's ready" + : "\(pairName) translation isn't downloaded — dictations stay untranslated until it is", + systemImage: downloadRequested ? "arrow.down.circle" : "exclamationmark.triangle.fill") + .font(.caption) + .foregroundStyle(downloadRequested ? Color.secondary : Color.orange) + if !downloadRequested { + Button("Download…") { + downloadRequested = true + AppleTextTranslation.requestAssetDownload(from: sourceTag, to: targetTag) + } + .controlSize(.small) + } + } + case .unsupported: + Label("\(pairName) isn't supported by macOS translation", systemImage: "xmark.circle") + .font(.caption) + .foregroundStyle(.orange) + case .unavailable: + SettingsFootnote("On-device translation needs macOS 15 or later.") + case nil: + // First poll hasn't answered yet — render nothing rather + // than a flash of the wrong state. + EmptyView() + } + } + } +} diff --git a/OpenWhisp/Views/SettingsView.swift b/OpenWhisp/Views/SettingsView.swift index 0e05955..fdf397c 100644 --- a/OpenWhisp/Views/SettingsView.swift +++ b/OpenWhisp/Views/SettingsView.swift @@ -184,7 +184,7 @@ struct SettingsView: View { case .profiles: ProfilesPane(appState: appState) case .agentBridge: AgentBridgePane(appState: appState) case .sync: SyncPane(appState: appState) - case .streamOverlay: StreamOverlayPane(overlay: appState.streamOverlay) + case .streamOverlay: StreamOverlayPane(overlay: appState.streamOverlay, dictationLanguage: appState.language) case .privacy: PrivacyPane(appState: appState) case .advanced: AdvancedPane(appState: appState) } diff --git a/Package.swift b/Package.swift index a30a45a..e1b6d02 100644 --- a/Package.swift +++ b/Package.swift @@ -38,6 +38,7 @@ let package = Package( "EditDiff.swift", "LanguageResolver.swift", "EngineCapabilities.swift", + "TextTranslationPolicy.swift", "EnginePurposeRouter.swift", "ParakeetCatalog.swift", "ParakeetLanguageGate.swift", diff --git a/Tests/OpenWhispCoreTests/TextTranslationPolicyTests.swift b/Tests/OpenWhispCoreTests/TextTranslationPolicyTests.swift new file mode 100644 index 0000000..12c458c --- /dev/null +++ b/Tests/OpenWhispCoreTests/TextTranslationPolicyTests.swift @@ -0,0 +1,177 @@ +import XCTest +@testable import OpenWhispCore + +/// The arm/offer decisions for the on-device TEXT translation path (Apple +/// Translation, macOS 15+): translate the session's FINAL transcript as text +/// when the user wants English but the active engine is ASR-only. Replaces the +/// retired dual-runtime approach (PR #219) — one ASR runtime, no audio tap, +/// and on failure the ORIGINAL text is kept, never lost. +/// +/// Everything is asserted engine-by-engine over `allEngineIDs` so a future +/// engine can't silently fall in a gap between the engine-level translate path +/// and the text path. +final class TextTranslationPolicyTests: XCTestCase { + + private let allEngines = EngineCapabilities.allEngineIDs + + private func shouldRun( + translate: Bool = true, language: String = "ru", + engine: String = EngineCapabilities.parakeet, available: Bool = true + ) -> Bool { + TextTranslationPolicy.shouldTranslateFinal( + translateToEnglish: translate, language: language, + transcriptionEngine: engine, textTranslationAvailable: available) + } + + // MARK: - shouldTranslateFinal + + /// The recovered combination: translate on + non-English + ASR-only engine + /// + macOS 15 text translator → the text path runs. All three ASR-only + /// engines, because the text path needs no audio tap. + func testArmsForEveryASROnlyEngine() { + for engine in [EngineCapabilities.parakeet, + EngineCapabilities.appleSpeech, + EngineCapabilities.speechAnalyzer] { + XCTAssertTrue(shouldRun(engine: engine), "text path should arm for \(engine)") + } + } + + /// "auto" is not English — the speaker may be dictating any language, and + /// the framework auto-detects the source (nil source in the configuration). + func testArmsForAutoDetectLanguage() { + XCTAssertTrue(shouldRun(language: "auto")) + } + + func testDoesNotArmWhenTranslateIsOff() { + XCTAssertFalse(shouldRun(translate: false)) + } + + /// English source → en→en is a no-op; regional tags count as English too + /// (same base-code stripping as the Parakeet language gate). + func testDoesNotArmForEnglishIncludingRegionalTags() { + XCTAssertFalse(shouldRun(language: "en")) + XCTAssertFalse(shouldRun(language: "en-US")) + XCTAssertFalse(shouldRun(language: "en_GB")) + } + + /// Engines that translate natively keep the engine-level path — the text + /// path must never double-translate. + func testDoesNotArmForNativeTranslateEngines() { + XCTAssertFalse(shouldRun(engine: EngineCapabilities.whisperCpp)) + XCTAssertFalse(shouldRun(engine: EngineCapabilities.whisperKit)) + } + + /// macOS 14: the framework doesn't exist — the session behaves exactly as + /// before this feature (transcript stays in the spoken language). + func testDoesNotArmWhenTextTranslationUnavailable() { + XCTAssertFalse(shouldRun(available: false)) + } + + /// THE COMPLEMENT CONTRACT: for every engine, exactly one translate path + /// arms when the user wants English with a non-English language on + /// macOS 15+ — the engine-level one (`effectiveTranslateToEnglish` via + /// LanguageResolver) or the text path. Never both (double translation), + /// never neither (the silent-drop bug this feature removes). + func testExactlyOnePathArmsPerEngine() { + for engine in allEngines { + let engineLevel = LanguageResolver.effectiveTranslateToEnglish( + translateToEnglish: true, transcriptionEngine: engine) + let textPath = shouldRun(engine: engine) + XCTAssertTrue(engineLevel != textPath, + "engine \(engine): engine-level=\(engineLevel) text-path=\(textPath) — exactly one must arm") + } + } + + // MARK: - translationOffered (the shared UI gate) + + /// With the text path available (macOS 15+), EVERY engine offers + /// "Translate to English" — natively or via the text path. + func testEveryEngineOffersTranslateWhenTextPathAvailable() { + for engine in allEngines { + XCTAssertTrue( + TextTranslationPolicy.translationOffered( + transcriptionEngine: engine, textTranslationAvailable: true), + "\(engine) should offer translate on macOS 15+") + } + } + + /// Without it (macOS 14), the offer collapses to today's engine-level rule + /// — ASR-only engines keep the dimmed/absent toggle. + func testOfferMatchesEngineCapabilityWhenTextPathUnavailable() { + for engine in allEngines { + XCTAssertEqual( + TextTranslationPolicy.translationOffered( + transcriptionEngine: engine, textTranslationAvailable: false), + LanguageResolver.supportsTranslation(transcriptionEngine: engine), + "\(engine): macOS 14 offer must equal the engine-level rule") + } + } + + // MARK: - effectiveTranslateToEnglish / outputLanguageForCleaning + + /// When the text path arms, translation IS in effect: refine prompts and + /// the RefineOutputGuard expected script must see English output coming. + func testEffectiveTranslateCountsTheTextPath() { + XCTAssertTrue(TextTranslationPolicy.effectiveTranslateToEnglish( + translateToEnglish: true, language: "ru", + transcriptionEngine: EngineCapabilities.parakeet, + textTranslationAvailable: true)) + XCTAssertEqual(TextTranslationPolicy.outputLanguageForCleaning( + language: "ru", translateToEnglish: true, + transcriptionEngine: EngineCapabilities.parakeet, + textTranslationAvailable: true), "en") + } + + /// Without the text path, both derivations are byte-identical to the + /// LanguageResolver originals for every engine — macOS 14 behavior is + /// unchanged by this feature. + func testDerivationsMatchLanguageResolverWhenUnavailable() { + for engine in allEngines { + for translate in [true, false] { + for language in ["ru", "en", "auto"] { + XCTAssertEqual( + TextTranslationPolicy.effectiveTranslateToEnglish( + translateToEnglish: translate, language: language, + transcriptionEngine: engine, textTranslationAvailable: false), + LanguageResolver.effectiveTranslateToEnglish( + translateToEnglish: translate, transcriptionEngine: engine), + "\(engine)/\(language)/translate=\(translate)") + XCTAssertEqual( + TextTranslationPolicy.outputLanguageForCleaning( + language: language, translateToEnglish: translate, + transcriptionEngine: engine, textTranslationAvailable: false), + LanguageResolver.outputLanguageForCleaning( + language: language, translateToEnglish: translate, + transcriptionEngine: engine), + "\(engine)/\(language)/translate=\(translate)") + } + } + } + } + + /// The engine-level translate on whisper engines is unaffected by the text + /// path being available — no double counting, same "en" output language. + func testNativeEnginesUnchangedByAvailability() { + for engine in [EngineCapabilities.whisperCpp, EngineCapabilities.whisperKit] { + XCTAssertTrue(TextTranslationPolicy.effectiveTranslateToEnglish( + translateToEnglish: true, language: "ru", + transcriptionEngine: engine, textTranslationAvailable: true)) + XCTAssertEqual(TextTranslationPolicy.outputLanguageForCleaning( + language: "ru", translateToEnglish: true, + transcriptionEngine: engine, textTranslationAvailable: true), "en") + } + } + + /// Spoken language passes through untouched (including "auto") when no + /// translation is in effect. + func testOutputLanguagePassthroughWhenNotTranslating() { + XCTAssertEqual(TextTranslationPolicy.outputLanguageForCleaning( + language: "ru", translateToEnglish: false, + transcriptionEngine: EngineCapabilities.parakeet, + textTranslationAvailable: true), "ru") + XCTAssertEqual(TextTranslationPolicy.outputLanguageForCleaning( + language: "auto", translateToEnglish: false, + transcriptionEngine: EngineCapabilities.parakeet, + textTranslationAvailable: true), "auto") + } +} diff --git a/Tests/OpenWhispSyncLANTests/StreamOverlayServerTests.swift b/Tests/OpenWhispSyncLANTests/StreamOverlayServerTests.swift index a244c6b..43b945e 100644 --- a/Tests/OpenWhispSyncLANTests/StreamOverlayServerTests.swift +++ b/Tests/OpenWhispSyncLANTests/StreamOverlayServerTests.swift @@ -276,4 +276,23 @@ final class StreamOverlayServerTests: XCTestCase { XCTAssertTrue(client.wait { $0.contains("[es] good morning") }, "translated final missing: \(client.received.suffix(300))") } + + @MainActor + func testFailedTranslationKeepsOriginalCaption() throws { + // The never-lose-text rule at the seam the app now wires live (Apple + // Translation returns nil on missing assets / timeout): a translator + // that fails must degrade to the ORIGINAL caption line, never drop it. + let config = StreamOverlayConfig(translationEnabled: true, targetLanguage: "es") + let (server, port) = try startServer(config: config, translator: { _, _ in nil }) + defer { server.stop() } + + let client = RawClient(port: port) + defer { client.close() } + client.send("GET /events HTTP/1.1\r\n\r\n") + XCTAssertTrue(client.wait { $0.contains("text/event-stream") }) + + server.publishFinal("still here") + XCTAssertTrue(client.wait { $0.contains("still here") }, + "untranslated fallback caption missing: \(client.received.suffix(300))") + } } diff --git a/scripts/appstate-loc-budget.txt b/scripts/appstate-loc-budget.txt index 7ed7e63..23595f8 100644 --- a/scripts/appstate-loc-budget.txt +++ b/scripts/appstate-loc-budget.txt @@ -1 +1 @@ -7092 +7051