From d9fd6f0ce176b596fd2fadfe5e26366c1b0a15eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20=E8=80=81=E7=81=AF?= Date: Sun, 2 Aug 2026 00:51:30 +0800 Subject: [PATCH 01/12] Add OpenRouter audio speech support --- 01_PRD_v0.1.md | 4 +- 02_TECHNICAL_DESIGN.md | 41 ++- 03_ACCEPTANCE_TESTS.md | 14 +- README.md | 4 +- README.zh-CN.md | 4 +- TextFlow.xcodeproj/project.pbxproj | 8 + TextFlow/Core/Models/AppError.swift | 6 + TextFlow/Core/Models/SpeechProfile.swift | 129 ++++++++ .../Settings/SpeechProfilesView.swift | 120 ++++++- .../OpenAICompatibleSpeechProvider.swift | 104 ++++-- .../OpenRouterAudioSpeechProvider.swift | 308 ++++++++++++++++++ TextFlow/Features/Speech/SpeechService.swift | 17 +- .../OpenRouterAudioSpeechProviderTests.swift | 299 +++++++++++++++++ .../SpeechProfileSecurityTests.swift | 20 ++ 14 files changed, 1035 insertions(+), 43 deletions(-) create mode 100644 TextFlow/Features/Speech/OpenRouterAudioSpeechProvider.swift create mode 100644 TextFlowTests/OpenRouterAudioSpeechProviderTests.swift diff --git a/01_PRD_v0.1.md b/01_PRD_v0.1.md index 9d702a3..d8f45db 100644 --- a/01_PRD_v0.1.md +++ b/01_PRD_v0.1.md @@ -204,8 +204,10 @@ v0.1 仅服务单一用户,运行在一台 M2 MacBook 上,不需要账号、 - 中文和英文分别保存一个默认 Voice。 - 使用独立的 TTS 配置,不与翻译模型绑定。 -- 首选 OpenAI-compatible `/v1/audio/speech`。 +- 支持 OpenAI-compatible `/v1/audio/speech`。 +- 支持 OpenRouter Audio Chat:通过 `/api/v1/chat/completions` 请求音频输出,并流式接收音频分片。 - 支持自定义 URL、API Key、模型、Voice、请求头、输出格式。 +- 语音协议必须由用户明确选择;不得把 Audio Chat 模型误发到 Speech endpoint。 - 支持生成、播放、暂停、继续、停止、重新播放。 - 点击另一个朗读按钮时停止当前音频并播放新任务。 - API 不可用时可选择 macOS 本地系统语音兜底。 diff --git a/02_TECHNICAL_DESIGN.md b/02_TECHNICAL_DESIGN.md index 28516ca..5f2868e 100644 --- a/02_TECHNICAL_DESIGN.md +++ b/02_TECHNICAL_DESIGN.md @@ -36,6 +36,7 @@ TextFlowApp │ └── ChatCompletionsTranslationProvider ├── SpeechService │ ├── OpenAICompatibleSpeechProvider + │ ├── OpenRouterAudioSpeechProvider │ └── SystemSpeechProvider ├── TranslationPanelController ├── SettingsStore @@ -96,6 +97,7 @@ TextFlow/ │ ├── Speech/ │ │ ├── SpeechService.swift │ │ ├── OpenAICompatibleSpeechProvider.swift +│ │ ├── OpenRouterAudioSpeechProvider.swift │ │ ├── SystemSpeechProvider.swift │ │ └── AudioPlaybackController.swift │ ├── Panel/ @@ -400,6 +402,7 @@ API Key 与 Header secret 不进入此结构的明文持久化。结构只保存 ```swift protocol SpeechProviding: Sendable { func synthesize(_ request: SpeechRequest) async throws -> SpeechAudio + func synthesize(_ request: SpeechRequest, maximumBytes: Int) async throws -> SpeechAudio func testConnection() async throws -> ProviderTestResult } ``` @@ -433,7 +436,39 @@ protocol SpeechProviding: Sendable { 第三方服务如果遵循 OpenAI Speech schema 可直接使用。若协议不同,新增独立 Provider,不在 v0.1 里加入任意 JSON 模板编辑器。 -### 9.3 文本长度 +### 9.3 OpenRouter Audio Chat + +`OpenRouterAudioSpeechProvider` 适配支持音频输出的 Chat Completions 模型,例如 `openai/gpt-audio-mini`。 + +默认 Endpoint:`https://openrouter.ai/api/v1/chat/completions` + +请求概念: + +```json +{ + "model": "openai/gpt-audio-mini", + "messages": [ + {"role": "system", "content": "只朗读用户文本,并按配置控制语速和语气"}, + {"role": "user", "content": "需要朗读的文本"} + ], + "modalities": ["text", "audio"], + "audio": {"voice": "alloy", "format": "wav"}, + "stream": true +} +``` + +实现要求: + +- 只接受 `text/event-stream` 成功响应 +- 从 `choices[].delta.audio.data` 提取 Base64 分片 +- Base64 分片可跨任意 SSE 事件和网络分片,必须增量解码 +- 音频、SSE 单事件和整体传输分别设安全上限 +- `[DONE]` 前后不要求出现文本 transcript +- `pcm` 配置映射为 OpenRouter 的 `pcm16` +- 不记录请求正文、音频 Base64、API Key 或错误响应全文 +- 旧语音配置缺少协议字段时迁移为 OpenAI-compatible Speech + +### 9.4 文本长度 实现 `TextChunker`: @@ -443,7 +478,7 @@ protocol SpeechProviding: Sendable { - 所有块生成完成后依次播放 - 任一块失败时停止并显示具体块序号 -### 9.4 播放 +### 9.5 播放 `AudioPlaybackController` 使用 AVFoundation: @@ -453,7 +488,7 @@ protocol SpeechProviding: Sendable { - 任务取消时释放 Data - 同一时刻只允许一个播放会话 -### 9.5 本地兜底 +### 9.6 本地兜底 `SystemSpeechProvider` 使用 macOS 系统语音: diff --git a/03_ACCEPTANCE_TESTS.md b/03_ACCEPTANCE_TESTS.md index 9eee582..b9c4fb0 100644 --- a/03_ACCEPTANCE_TESTS.md +++ b/03_ACCEPTANCE_TESTS.md @@ -139,10 +139,20 @@ ### P0-14 TTS 自定义 URL -步骤:配置 OpenAI-compatible 第三方 Speech endpoint。 +场景 A:配置 OpenAI-compatible 第三方 Speech endpoint。 期望:自定义 URL、模型、Voice、Key、Header 生效,音频成功播放。 +场景 B:协议选择 `OpenRouter Audio Chat`,使用 `/api/v1/chat/completions` 和支持音频输出的模型。 + +期望: + +- 请求包含 `modalities: ["text", "audio"]`、`audio` 和 `stream: true` +- 任意网络边界切开的 SSE/Base64 音频均可正确还原并播放 +- 使用 `/audio/speech`、非 SSE 响应或无音频响应时显示明确的兼容性错误 +- 401、余额不足、模型不存在、限流和上游拒绝可区分 +- 现有语音配置升级后仍按 OpenAI-compatible Speech 工作 + ### P0-15 TTS 本地兜底 步骤:断开网络或故意配置错误,点击朗读。 @@ -210,7 +220,7 @@ mock server 将 JSON 切成随机字节片段。 -期望:解析正确,无乱码、丢 token 或崩溃。 +期望:翻译文本与 OpenRouter 音频均解析正确,无乱码、丢 token、音频损坏或崩溃。 ### P1-05 剪贴板复杂类型 diff --git a/README.md b/README.md index 451524e..601f42b 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ Both paths open one floating panel where the source stays editable and the sourc - **Two shortcuts, one flow.** No main window to find and no context switching. - **Local OCR.** Screenshots stay in memory and text recognition runs through Apple Vision. - **Bring your own model.** Configure custom URLs, models, headers, timeouts, streaming, OpenAI Responses, or Chat Completions. -- **Flexible speech.** Use an OpenAI-compatible speech endpoint or explicitly fall back to macOS system voices. +- **Flexible speech.** Use an OpenAI-compatible speech endpoint, OpenRouter Audio Chat, or explicitly fall back to macOS system voices. - **Privacy by design.** Keys live in Keychain; logs are redacted; there is no history, account, telemetry, or cloud sync. - **Native and lean.** Swift 6, SwiftUI + AppKit, Apple frameworks, and zero third-party runtime dependencies. - **Cancellation-safe.** A new capture replaces the previous session and cancels in-flight OCR, network, and audio work. @@ -117,7 +117,7 @@ Translation profiles support: - Automatic, enabled, or disabled streaming - Streaming SSE and non-streaming JSON responses -Speech profiles support OpenAI-compatible `/v1/audio/speech` endpoints with custom URL, model, voice, headers, and output format. “OpenAI-compatible” implementations vary; please open a compatibility report when a provider needs a dedicated adapter. +Speech profiles support OpenAI-compatible `/v1/audio/speech` endpoints and OpenRouter Audio Chat through `/api/v1/chat/completions`, with custom URL, model, voice, headers, instructions, and output format. Choose the matching protocol explicitly because Audio Chat models are not compatible with Speech endpoints. Never place a real API key in source files, Markdown, test fixtures, `.env` files committed to Git, or issue screenshots. diff --git a/README.zh-CN.md b/README.zh-CN.md index 956a488..11ec586 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -38,7 +38,7 @@ - **两个快捷键,一条工作流。** 不需要先找到主窗口,也不打断当前工作。 - **OCR 完全本地。** 截图只驻留内存,识别使用 Apple Vision。 - **自带模型。** 支持自定义 URL、模型、Header、超时、流式模式、Responses 和 Chat Completions。 -- **灵活朗读。** 可使用 OpenAI-compatible Speech 接口,也可由用户明确切换到 macOS 系统语音。 +- **灵活朗读。** 可使用 OpenAI-compatible Speech、OpenRouter Audio Chat,也可由用户明确切换到 macOS 系统语音。 - **隐私优先。** 密钥进入 Keychain;日志脱敏;不保存历史;没有账号、遥测或云同步。 - **原生且克制。** Swift 6、SwiftUI + AppKit、Apple 原生框架、零第三方运行时依赖。 - **完整取消链路。** 新任务会替换旧会话并取消进行中的 OCR、网络和音频任务。 @@ -117,7 +117,7 @@ brew install xcodegen - 自动、开启或关闭流式模式 - SSE 流式响应和非流式 JSON 响应 -语音配置支持 OpenAI-compatible `/v1/audio/speech`,可自定义 URL、模型、Voice、Header 和输出格式。不同厂商对“OpenAI 兼容”的实现并不完全一致;如需专门适配器,请提交兼容性报告。 +语音配置支持 OpenAI-compatible `/v1/audio/speech` 和基于 `/api/v1/chat/completions` 的 OpenRouter Audio Chat,可自定义 URL、模型、Voice、Header、Instructions 和输出格式。Audio Chat 模型不兼容 Speech endpoint,必须明确选择匹配的协议。 不要把真实 API Key 放入源码、Markdown、测试 fixture、Git 中的 `.env` 文件或 Issue 截图。 diff --git a/TextFlow.xcodeproj/project.pbxproj b/TextFlow.xcodeproj/project.pbxproj index 1488a69..1371c7d 100644 --- a/TextFlow.xcodeproj/project.pbxproj +++ b/TextFlow.xcodeproj/project.pbxproj @@ -9,6 +9,7 @@ /* Begin PBXBuildFile section */ 0120CF75E228C2D9CBC28839 /* TranslationProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECC84CD09D11EA4DA9B2D418 /* TranslationProfile.swift */; }; 088176417C94EC09178C97F9 /* SpeechProfileSecurityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AFC3645542FBB7987E245F2 /* SpeechProfileSecurityTests.swift */; }; + 0C375ED868552155AE5E3265 /* OpenRouterAudioSpeechProviderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94044696E52806E9E0FFCFCE /* OpenRouterAudioSpeechProviderTests.swift */; }; 0DBD1CD0DD5CBC6A465653EB /* SpeechResourceLimits.swift in Sources */ = {isa = PBXBuildFile; fileRef = 636E9C29E272E74B7A503BFC /* SpeechResourceLimits.swift */; }; 0EA6A6D593A7BCD1ADD66A48 /* TranslationPanelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 795750DFD27EF41C442BCF6F /* TranslationPanelView.swift */; }; 10C11E16CA4EB8093A889174 /* SpeechAudioCacheTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23EC17B8FFA0D22C9C1CBB47 /* SpeechAudioCacheTests.swift */; }; @@ -25,6 +26,7 @@ 1F388C3950C30831E5E56F33 /* LanguageDetectionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5F32523534D603665DF54AB /* LanguageDetectionService.swift */; }; 2099FFBEAB41C87793EA8C38 /* TranslationRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D9DCD870DF9FEDFDE3F9814 /* TranslationRequest.swift */; }; 2905E26DEFB9FEBB9CD036F0 /* SpeechService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CE842B9AF66AD6513D3360A /* SpeechService.swift */; }; + 2EDB4F536AF8F47B1EB09018 /* OpenRouterAudioSpeechProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = B65715EAFF1488595551B060 /* OpenRouterAudioSpeechProvider.swift */; }; 2FA954D135D9DA76FC19AB95 /* LanguageDetectionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B5CE66FDC6829555EA09394 /* LanguageDetectionTests.swift */; }; 30FE6F6317DFA8E8E7D49B2E /* AppError.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9A2DDAEDDAC9527F387D0CC /* AppError.swift */; }; 3B9D0B3EC18F55527804028D /* TranslationProfileSecurityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8A96D3F1B3DDEB31B660AAB /* TranslationProfileSecurityTests.swift */; }; @@ -158,6 +160,7 @@ 87711E592323AD92B556C91D /* URLBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLBuilder.swift; sourceTree = ""; }; 90B72CEE4A9478AF1C512827 /* PanelPositioner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PanelPositioner.swift; sourceTree = ""; }; 92D0E094A543862D2C1D7174 /* OpenAICompatibleSpeechProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenAICompatibleSpeechProvider.swift; sourceTree = ""; }; + 94044696E52806E9E0FFCFCE /* OpenRouterAudioSpeechProviderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenRouterAudioSpeechProviderTests.swift; sourceTree = ""; }; 97395BA291782A91C0631C3A /* TranslationProviders.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranslationProviders.swift; sourceTree = ""; }; 98190C5CAD6F34622AFC4CC7 /* SSEParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SSEParser.swift; sourceTree = ""; }; 98ABD06BDE8DA356EBFF196E /* AudioPlaybackController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPlaybackController.swift; sourceTree = ""; }; @@ -169,6 +172,7 @@ AF1638DF26C44F95AB66C8D4 /* TranslationSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranslationSession.swift; sourceTree = ""; }; B1148CF8A9AE580D79CF24BC /* VisionOCRService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VisionOCRService.swift; sourceTree = ""; }; B5E62521645442FE9B549428 /* TextFlowApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextFlowApp.swift; sourceTree = ""; }; + B65715EAFF1488595551B060 /* OpenRouterAudioSpeechProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenRouterAudioSpeechProvider.swift; sourceTree = ""; }; B920898D0F4A6E839D55E562 /* KeychainStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainStore.swift; sourceTree = ""; }; B9E5F309D497A68743CB5B92 /* ClipboardFallbackReaderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClipboardFallbackReaderTests.swift; sourceTree = ""; }; BFFB7348A3F83C6102E28EE1 /* RedactedLoggerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RedactedLoggerTests.swift; sourceTree = ""; }; @@ -204,6 +208,7 @@ 98ABD06BDE8DA356EBFF196E /* AudioPlaybackController.swift */, 6298768B299979CF0F646982 /* LimitedURLSessionDataLoader.swift */, 92D0E094A543862D2C1D7174 /* OpenAICompatibleSpeechProvider.swift */, + B65715EAFF1488595551B060 /* OpenRouterAudioSpeechProvider.swift */, 56292085A3560797C57EDCD7 /* SpeechAudioCache.swift */, 636E9C29E272E74B7A503BFC /* SpeechResourceLimits.swift */, 4CE842B9AF66AD6513D3360A /* SpeechService.swift */, @@ -354,6 +359,7 @@ 3B5CE66FDC6829555EA09394 /* LanguageDetectionTests.swift */, 5ED1D4D6A9B3DB138738A08A /* LimitedURLSessionDataLoaderTests.swift */, 2B6B8BB11F60DD022BD26820 /* OCRReadingOrderTests.swift */, + 94044696E52806E9E0FFCFCE /* OpenRouterAudioSpeechProviderTests.swift */, 445F8C7502CD861217E285DC /* PanelPositionerTests.swift */, 475D51C2A9EC2A9FA4EC591F /* PermissionSeparationTests.swift */, 01EF3A899ABC719619822EBA /* ProfileSecretSessionStateTests.swift */, @@ -526,6 +532,7 @@ 2FA954D135D9DA76FC19AB95 /* LanguageDetectionTests.swift in Sources */, 3DBF8B3AFA47CF2A2C62A167 /* LimitedURLSessionDataLoaderTests.swift in Sources */, 4FCA25903060F20C7970C9DB /* OCRReadingOrderTests.swift in Sources */, + 0C375ED868552155AE5E3265 /* OpenRouterAudioSpeechProviderTests.swift in Sources */, AEFDE86A81708DC6D5E678AB /* PanelPositionerTests.swift in Sources */, 127AA10273B69DE928546392 /* PermissionSeparationTests.swift in Sources */, BE53C6EFC10DAABED65B9494 /* ProfileSecretSessionStateTests.swift in Sources */, @@ -569,6 +576,7 @@ E5B551B878A066C3FBA728F2 /* LimitedURLSessionDataLoader.swift in Sources */, 71ACB87E5408AD35D9831B04 /* OCRProviding.swift in Sources */, 57BA31F0B76DEE3956E14C45 /* OpenAICompatibleSpeechProvider.swift in Sources */, + 2EDB4F536AF8F47B1EB09018 /* OpenRouterAudioSpeechProvider.swift in Sources */, 3F75409BB2EDAD0337C67A5D /* PanelPositioner.swift in Sources */, 4F5DDF73FAF7DA51C2708F05 /* PermissionService.swift in Sources */, 82D1F16FD42978739A5AB70A /* PermissionsView.swift in Sources */, diff --git a/TextFlow/Core/Models/AppError.swift b/TextFlow/Core/Models/AppError.swift index 54c27e0..75a9278 100644 --- a/TextFlow/Core/Models/AppError.swift +++ b/TextFlow/Core/Models/AppError.swift @@ -12,6 +12,8 @@ enum AppError: LocalizedError, Sendable, Equatable { case networkFailure case tlsFailure case authenticationFailed + case insufficientCredits + case providerRejected case endpointNotFound case rateLimited case serviceUnavailable @@ -51,6 +53,10 @@ enum AppError: LocalizedError, Sendable, Equatable { return "TLS 安全连接失败。请检查证书、系统时间和 HTTPS 地址。" case .authenticationFailed: return "接口鉴权失败。请检查 API Key 或自定义请求头。" + case .insufficientCredits: + return "账户余额不足,服务拒绝了本次请求。请检查额度或充值后重试。" + case .providerRejected: + return "上游提供商拒绝了请求。请检查提供商条款、账户状态或模型访问权限。" case .endpointNotFound: return "接口路径不存在。请检查 Base URL、Path 或完整 Endpoint。" case .rateLimited: diff --git a/TextFlow/Core/Models/SpeechProfile.swift b/TextFlow/Core/Models/SpeechProfile.swift index 3fe896b..fd1e37c 100644 --- a/TextFlow/Core/Models/SpeechProfile.swift +++ b/TextFlow/Core/Models/SpeechProfile.swift @@ -1,5 +1,39 @@ import Foundation +enum SpeechProtocolKind: String, Codable, CaseIterable, Sendable, Identifiable { + case openAISpeech + case openRouterAudioChat + + var id: Self { self } + + var displayName: String { + switch self { + case .openAISpeech: + "OpenAI-compatible Speech" + case .openRouterAudioChat: + "OpenRouter Audio Chat" + } + } + + var defaultPath: String { + switch self { + case .openAISpeech: + "/v1/audio/speech" + case .openRouterAudioChat: + "/api/v1/chat/completions" + } + } + + var supportedFormats: [SpeechOutputFormat] { + switch self { + case .openAISpeech: + SpeechOutputFormat.allCases + case .openRouterAudioChat: + [.wav, .mp3, .flac, .opus, .pcm] + } + } +} + enum SpeechOutputFormat: String, Codable, CaseIterable, Sendable, Identifiable { case mp3 case wav @@ -14,6 +48,7 @@ enum SpeechOutputFormat: String, Codable, CaseIterable, Sendable, Identifiable { struct SpeechProfile: Codable, Identifiable, Hashable, Sendable { let id: UUID var name: String + var protocolKind: SpeechProtocolKind var endpointMode: EndpointMode var baseURL: String var path: String @@ -31,6 +66,7 @@ struct SpeechProfile: Codable, Identifiable, Hashable, Sendable { SpeechProfile( id: UUID(), name: "新语音配置", + protocolKind: .openAISpeech, endpointMode: .baseURLAndPath, baseURL: "https://api.openai.com", path: "/v1/audio/speech", @@ -46,6 +82,86 @@ struct SpeechProfile: Codable, Identifiable, Hashable, Sendable { ) } + private enum CodingKeys: String, CodingKey { + case id + case name + case protocolKind + case endpointMode + case baseURL + case path + case fullEndpoint + case model + case chineseVoice + case englishVoice + case outputFormat + case instructions + case authMode + case customHeaders + case timeoutSeconds + } + + init( + id: UUID, + name: String, + protocolKind: SpeechProtocolKind, + endpointMode: EndpointMode, + baseURL: String, + path: String, + fullEndpoint: String, + model: String, + chineseVoice: String, + englishVoice: String, + outputFormat: SpeechOutputFormat, + instructions: String, + authMode: AuthMode, + customHeaders: [HeaderReference], + timeoutSeconds: Double + ) { + self.id = id + self.name = name + self.protocolKind = protocolKind + self.endpointMode = endpointMode + self.baseURL = baseURL + self.path = path + self.fullEndpoint = fullEndpoint + self.model = model + self.chineseVoice = chineseVoice + self.englishVoice = englishVoice + self.outputFormat = outputFormat + self.instructions = instructions + self.authMode = authMode + self.customHeaders = customHeaders + self.timeoutSeconds = timeoutSeconds + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(UUID.self, forKey: .id) + name = try container.decode(String.self, forKey: .name) + protocolKind = try container.decodeIfPresent( + SpeechProtocolKind.self, + forKey: .protocolKind + ) ?? .openAISpeech + endpointMode = try container.decode(EndpointMode.self, forKey: .endpointMode) + baseURL = try container.decode(String.self, forKey: .baseURL) + path = try container.decode(String.self, forKey: .path) + fullEndpoint = try container.decode(String.self, forKey: .fullEndpoint) + model = try container.decode(String.self, forKey: .model) + chineseVoice = try container.decode(String.self, forKey: .chineseVoice) + englishVoice = try container.decode(String.self, forKey: .englishVoice) + outputFormat = try container.decode( + SpeechOutputFormat.self, + forKey: .outputFormat + ) + instructions = try container.decode(String.self, forKey: .instructions) + authMode = try container.decode(AuthMode.self, forKey: .authMode) + customHeaders = try container.decode( + [HeaderReference].self, + forKey: .customHeaders + ) + timeoutSeconds = try container.decode(Double.self, forKey: .timeoutSeconds) + } + var apiKeyAccount: String { "tts.\(id.uuidString).apiKey" } @@ -72,9 +188,22 @@ struct SpeechAudio: Sendable { protocol SpeechProviding: Sendable { func synthesize(_ request: SpeechRequest) async throws -> SpeechAudio + func synthesize( + _ request: SpeechRequest, + maximumBytes: Int + ) async throws -> SpeechAudio func testConnection() async throws -> ProviderTestResult } +extension SpeechProviding { + func synthesize(_ request: SpeechRequest) async throws -> SpeechAudio { + try await synthesize( + request, + maximumBytes: SpeechResourceLimits.maximumResponseBytes + ) + } +} + struct ResolvedSpeechConfiguration: Sendable { let profile: SpeechProfile let endpoint: URL diff --git a/TextFlow/Features/Settings/SpeechProfilesView.swift b/TextFlow/Features/Settings/SpeechProfilesView.swift index 11e4d9a..2c0c972 100644 --- a/TextFlow/Features/Settings/SpeechProfilesView.swift +++ b/TextFlow/Features/Settings/SpeechProfilesView.swift @@ -25,7 +25,7 @@ struct SpeechProfilesView: View { ContentUnavailableView( "尚无语音配置", systemImage: "speaker.wave.2", - description: Text("添加 OpenAI-compatible Speech 配置;秘密只存钥匙串。") + description: Text("添加 Speech 或 OpenRouter Audio Chat 配置;秘密只存钥匙串。") ) .frame(maxWidth: .infinity, maxHeight: .infinity) } @@ -158,8 +158,42 @@ private struct SpeechProfileEditor: View { private var basicInformationCard: some View { SettingsCard( "基本信息", - subtitle: "模型名称会原样发送给 OpenAI-compatible Speech 服务。" + subtitle: protocolSubtitle ) { + SettingsField("请求协议") { + Picker("请求协议", selection: $profile.protocolKind) { + ForEach(SpeechProtocolKind.allCases) { + Text($0.displayName).tag($0) + } + } + .labelsHidden() + .pickerStyle(.segmented) + .onChange(of: profile.protocolKind) { _, newValue in + if !newValue.supportedFormats.contains(profile.outputFormat) { + profile.outputFormat = newValue == .openRouterAudioChat + ? .wav + : .mp3 + } + } + } + + if profile.protocolKind == .openRouterAudioChat { + HStack { + Label( + "使用 Chat Completions 流式接收 Base64 音频,不兼容 /audio/speech。", + systemImage: "waveform" + ) + .font(.callout) + .foregroundStyle(.secondary) + + Spacer() + + Button("应用 OpenRouter 推荐配置") { + applyOpenRouterDefaults() + } + } + } + HStack(alignment: .top, spacing: 14) { SettingsField("配置名称") { TextField("例如:DMX", text: $profile.name) @@ -168,7 +202,7 @@ private struct SpeechProfileEditor: View { } SettingsField("模型") { - TextField("例如:gpt-4o-mini-tts", text: $profile.model) + TextField(modelPlaceholder, text: $profile.model) .textFieldStyle(.roundedBorder) .controlSize(.large) } @@ -179,7 +213,7 @@ private struct SpeechProfileEditor: View { private var endpointCard: some View { SettingsCard( "接口地址", - subtitle: "可以分别填写 Base URL 与 Path,也可以直接使用完整 Endpoint。" + subtitle: endpointSubtitle ) { SettingsField("URL 模式") { Picker("URL 模式", selection: $profile.endpointMode) { @@ -193,20 +227,20 @@ private struct SpeechProfileEditor: View { if profile.endpointMode == .baseURLAndPath { SettingsField("Base URL") { - TextField("https://api.openai.com", text: $profile.baseURL) + TextField(baseURLPlaceholder, text: $profile.baseURL) .textFieldStyle(.roundedBorder) .controlSize(.large) } SettingsField("Path") { - TextField("/v1/audio/speech", text: $profile.path) + TextField(profile.protocolKind.defaultPath, text: $profile.path) .textFieldStyle(.roundedBorder) .controlSize(.large) } } else { SettingsField("完整 Endpoint") { TextField( - "https://api.openai.com/v1/audio/speech", + fullEndpointPlaceholder, text: $profile.fullEndpoint ) .textFieldStyle(.roundedBorder) @@ -219,7 +253,7 @@ private struct SpeechProfileEditor: View { private var voiceCard: some View { SettingsCard( "声音与朗读方式", - subtitle: "中文和英文可以使用不同 Voice;Instructions 可用于控制语速与语气。" + subtitle: voiceSubtitle ) { HStack(alignment: .top, spacing: 14) { SettingsField("中文 Voice") { @@ -311,7 +345,7 @@ private struct SpeechProfileEditor: View { HStack(alignment: .top, spacing: 14) { SettingsField("输出格式") { Picker("输出格式", selection: $profile.outputFormat) { - ForEach(SpeechOutputFormat.allCases) { + ForEach(profile.protocolKind.supportedFormats) { Text($0.rawValue.uppercased()).tag($0) } } @@ -366,6 +400,7 @@ private struct SpeechProfileEditor: View { } } .disabled(isLoadingSecrets || isSaving || isTesting) + .help("测试会向当前服务发送一次短文本并生成语音,可能产生少量费用。") Button(isDefault ? "当前默认" : "设为默认", action: makeDefault) .disabled(isDefault) @@ -375,6 +410,73 @@ private struct SpeechProfileEditor: View { private var statusIsSuccess: Bool { statusMessage == "连接成功" || statusMessage == "密钥与请求头已保存" + || statusMessage == "已应用 OpenRouter 推荐配置" + } + + private var protocolSubtitle: String { + switch profile.protocolKind { + case .openAISpeech: + "模型名称会原样发送给 OpenAI-compatible Speech 服务。" + case .openRouterAudioChat: + "适用于 openai/gpt-audio-mini 等支持音频输出的 OpenRouter 模型。" + } + } + + private var endpointSubtitle: String { + switch profile.protocolKind { + case .openAISpeech: + "标准语音生成接口;可以分别填写 Base URL 与 Path,或使用完整 Endpoint。" + case .openRouterAudioChat: + "必须指向 OpenRouter 的 /api/v1/chat/completions,而不是 /audio/speech。" + } + } + + private var voiceSubtitle: String { + switch profile.protocolKind { + case .openAISpeech: + "中文和英文可以使用不同 Voice;Instructions 可用于控制语速与语气。" + case .openRouterAudioChat: + "TextFlow 会要求模型只朗读原文;Instructions 会追加为语速和语气要求。" + } + } + + private var modelPlaceholder: String { + profile.protocolKind == .openRouterAudioChat + ? "例如:openai/gpt-audio-mini" + : "例如:gpt-4o-mini-tts" + } + + private var baseURLPlaceholder: String { + profile.protocolKind == .openRouterAudioChat + ? "https://openrouter.ai" + : "https://api.openai.com" + } + + private var fullEndpointPlaceholder: String { + profile.protocolKind == .openRouterAudioChat + ? "https://openrouter.ai/api/v1/chat/completions" + : "https://api.openai.com/v1/audio/speech" + } + + private func applyOpenRouterDefaults() { + if profile.name == "新语音配置" { + profile.name = "OpenRouter GPT Audio Mini" + } + profile.protocolKind = .openRouterAudioChat + profile.endpointMode = .fullEndpoint + profile.baseURL = "https://openrouter.ai" + profile.path = SpeechProtocolKind.openRouterAudioChat.defaultPath + profile.fullEndpoint = "https://openrouter.ai/api/v1/chat/completions" + profile.model = "openai/gpt-audio-mini" + profile.chineseVoice = "alloy" + profile.englishVoice = "alloy" + profile.outputFormat = .wav + if profile.instructions.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + profile.instructions = "Speak slowly and clearly for a language learner, with natural pauses between sentences." + } + profile.authMode = .bearer + profile.timeoutSeconds = 60 + statusMessage = "已应用 OpenRouter 推荐配置" } private func loadSecrets() async { diff --git a/TextFlow/Features/Speech/OpenAICompatibleSpeechProvider.swift b/TextFlow/Features/Speech/OpenAICompatibleSpeechProvider.swift index d190e24..b987eea 100644 --- a/TextFlow/Features/Speech/OpenAICompatibleSpeechProvider.swift +++ b/TextFlow/Features/Speech/OpenAICompatibleSpeechProvider.swift @@ -100,28 +100,14 @@ struct OpenAICompatibleSpeechProvider: SpeechProviding { data: data, format: configuration.profile.outputFormat ) - case 401, 403: - throw AppError.authenticationFailed - case 404: - throw AppError.endpointNotFound - case 429: - throw AppError.rateLimited - default: - throw AppError.incompatibleRequest - } - } catch let error as URLError { - switch error.code { - case .cancelled: - throw CancellationError() - case .timedOut: - throw AppError.timedOut - case .secureConnectionFailed, .serverCertificateHasBadDate, - .serverCertificateUntrusted, .serverCertificateHasUnknownRoot, - .serverCertificateNotYetValid: - throw AppError.tlsFailure default: - throw AppError.networkFailure + throw SpeechProviderErrorMapper.http( + status: response.statusCode, + body: data + ) } + } catch { + throw SpeechProviderErrorMapper.network(error) } } @@ -132,3 +118,81 @@ struct OpenAICompatibleSpeechProvider: SpeechProviding { return .success } } + +enum SpeechProviderErrorMapper { + static func http(status: Int, body: Data) -> AppError { + let message = responseMessage(from: body).lowercased() + if identifiesMissingModel(message) { + return .modelNotFound + } + switch status { + case 401: + return .authenticationFailed + case 402: + return .insufficientCredits + case 403: + if message.contains("terms") + || message.contains("provider") + || message.contains("permission") + || message.contains("access") { + return .providerRejected + } + return .authenticationFailed + case 404: + return .endpointNotFound + case 429: + return .rateLimited + case 500..<600: + return .serviceUnavailable + default: + return .incompatibleRequest + } + } + + static func network(_ error: Error) -> Error { + if error is CancellationError { + return CancellationError() + } + guard let urlError = error as? URLError else { return error } + switch urlError.code { + case .cancelled: + return CancellationError() + case .timedOut: + return AppError.timedOut + case .secureConnectionFailed, .serverCertificateHasBadDate, + .serverCertificateUntrusted, .serverCertificateHasUnknownRoot, + .serverCertificateNotYetValid, .clientCertificateRejected, + .clientCertificateRequired: + return AppError.tlsFailure + default: + return AppError.networkFailure + } + } + + private static func responseMessage(from body: Data) -> String { + guard !body.isEmpty, + let object = try? JSONSerialization.jsonObject(with: body) else { + return "" + } + if let dictionary = object as? [String: Any] { + if let error = dictionary["error"] as? [String: Any] { + return [error["message"], error["code"], error["type"]] + .compactMap { $0 as? String } + .joined(separator: " ") + } + return [dictionary["message"], dictionary["code"]] + .compactMap { $0 as? String } + .joined(separator: " ") + } + return "" + } + + private static func identifiesMissingModel(_ message: String) -> Bool { + guard message.contains("model") else { return false } + return message.contains("not found") + || message.contains("does not exist") + || message.contains("unknown") + || message.contains("invalid") + || message.contains("unavailable") + } +} diff --git a/TextFlow/Features/Speech/OpenRouterAudioSpeechProvider.swift b/TextFlow/Features/Speech/OpenRouterAudioSpeechProvider.swift new file mode 100644 index 0000000..148b20c --- /dev/null +++ b/TextFlow/Features/Speech/OpenRouterAudioSpeechProvider.swift @@ -0,0 +1,308 @@ +import Foundation + +struct OpenRouterAudioSpeechProvider: SpeechProviding { + let configuration: ResolvedSpeechConfiguration + private let session: URLSession + private let responseLimit: Int + + init( + configuration: ResolvedSpeechConfiguration, + session: URLSession? = nil, + responseLimit: Int = SpeechResourceLimits.maximumResponseBytes + ) { + self.configuration = configuration + self.responseLimit = responseLimit + self.session = session ?? Self.makeSession( + timeout: configuration.profile.timeoutSeconds + ) + } + + func synthesize( + _ request: SpeechRequest, + maximumBytes: Int + ) async throws -> SpeechAudio { + let effectiveLimit = min(responseLimit, maximumBytes) + guard effectiveLimit > 0 else { + throw AppError.responseTooLarge + } + + do { + let urlRequest = try makeURLRequest(request) + let (bytes, response) = try await session.bytes(for: urlRequest) + try Task.checkCancellation() + guard let response = response as? HTTPURLResponse else { + throw AppError.invalidResponse + } + guard (200..<300).contains(response.statusCode) else { + let body = try await collect(bytes, limit: 64 * 1024) + throw SpeechProviderErrorMapper.http( + status: response.statusCode, + body: body + ) + } + + let contentType = response.value( + forHTTPHeaderField: "Content-Type" + )?.lowercased() ?? "" + guard contentType.contains("text/event-stream") else { + throw AppError.incompatibleRequest + } + + let audioData = try await consumeSSE( + bytes, + maximumBytes: effectiveLimit + ) + return SpeechAudio( + data: audioData, + format: configuration.profile.outputFormat + ) + } catch { + throw SpeechProviderErrorMapper.network(error) + } + } + + func testConnection() async throws -> ProviderTestResult { + _ = try await synthesize( + SpeechRequest(text: "Test", language: .english) + ) + return .success + } + + private static func makeSession(timeout: TimeInterval) -> URLSession { + let configuration = URLSessionConfiguration.ephemeral + configuration.timeoutIntervalForRequest = timeout + configuration.timeoutIntervalForResource = timeout + configuration.requestCachePolicy = .reloadIgnoringLocalAndRemoteCacheData + configuration.urlCache = nil + configuration.httpCookieStorage = nil + configuration.httpShouldSetCookies = false + return URLSession(configuration: configuration) + } + + private func makeURLRequest(_ request: SpeechRequest) throws -> URLRequest { + let model = configuration.profile.model.trimmingCharacters( + in: .whitespacesAndNewlines + ) + guard !model.isEmpty else { + throw AppError.modelNotFound + } + let voice: String + switch request.language { + case .chinese: + voice = configuration.profile.chineseVoice + case .english: + voice = configuration.profile.englishVoice + } + guard !voice.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw AppError.incompatibleRequest + } + + let baseInstruction = """ + You are a text-to-speech engine. Read the user's text aloud exactly as written. Do not add, omit, translate, answer, or explain anything. + """ + let customInstruction = configuration.profile.instructions.trimmingCharacters( + in: .whitespacesAndNewlines + ) + let systemInstruction = customInstruction.isEmpty + ? baseInstruction + : "\(baseInstruction)\n\(customInstruction)" + let body: [String: Any] = [ + "model": model, + "messages": [ + ["role": "system", "content": systemInstruction], + ["role": "user", "content": request.text] + ], + "modalities": ["text", "audio"], + "audio": [ + "voice": voice, + "format": openRouterFormat + ], + "stream": true + ] + + var urlRequest = URLRequest(url: configuration.endpoint) + urlRequest.httpMethod = "POST" + urlRequest.timeoutInterval = configuration.profile.timeoutSeconds + urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type") + urlRequest.setValue("text/event-stream", forHTTPHeaderField: "Accept") + for (name, value) in configuration.customHeaders { + urlRequest.setValue(value, forHTTPHeaderField: name) + } + switch configuration.profile.authMode { + case .bearer: + guard let apiKey = configuration.apiKey, !apiKey.isEmpty else { + throw AppError.authenticationFailed + } + urlRequest.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + case .customHeadersOnly, .none: + break + } + urlRequest.httpBody = try JSONSerialization.data(withJSONObject: body) + return urlRequest + } + + private var openRouterFormat: String { + switch configuration.profile.outputFormat { + case .pcm: + "pcm16" + default: + configuration.profile.outputFormat.rawValue + } + } + + private func consumeSSE( + _ bytes: URLSession.AsyncBytes, + maximumBytes: Int + ) async throws -> Data { + let maximumEncodedBytes = ((maximumBytes + 2) / 3) * 4 + let maximumWireOverhead = max( + 2 * 1024 * 1024, + maximumEncodedBytes / 4 + ) + let maximumWireBytes = maximumEncodedBytes + maximumWireOverhead + var parser = SSEParser(maximumSize: 2 * 1024 * 1024) + var decoder = IncrementalBase64AudioDecoder( + maximumBytes: maximumBytes + ) + var chunk = Data() + var wireBytes = 0 + + for try await byte in bytes { + try Task.checkCancellation() + wireBytes += 1 + guard wireBytes <= maximumWireBytes else { + throw AppError.responseTooLarge + } + chunk.append(byte) + if chunk.count >= 1024 { + try decode(parser.feed(chunk), into: &decoder) + chunk.removeAll(keepingCapacity: true) + } + } + if !chunk.isEmpty { + try decode(parser.feed(chunk), into: &decoder) + } + try decode(parser.finish(), into: &decoder) + return try decoder.finish() + } + + private func decode( + _ messages: [SSEMessage], + into decoder: inout IncrementalBase64AudioDecoder + ) throws { + for message in messages where !message.isDone { + let data = Data(message.data.utf8) + guard let object = try? JSONSerialization.jsonObject(with: data), + let dictionary = object as? [String: Any] else { + throw AppError.invalidResponse + } + if let error = dictionary["error"] as? [String: Any] { + let status = error["code"] as? Int ?? 400 + throw SpeechProviderErrorMapper.http(status: status, body: data) + } + guard let choices = dictionary["choices"] as? [[String: Any]] else { + continue + } + for choice in choices { + guard let delta = choice["delta"] as? [String: Any], + let audio = delta["audio"] as? [String: Any], + let fragment = audio["data"] as? String, + !fragment.isEmpty else { + continue + } + try decoder.append(fragment) + } + } + } + + private func collect( + _ bytes: URLSession.AsyncBytes, + limit: Int + ) async throws -> Data { + var data = Data() + for try await byte in bytes { + try Task.checkCancellation() + guard data.count < limit else { + throw AppError.responseTooLarge + } + data.append(byte) + } + return data + } +} + +private struct IncrementalBase64AudioDecoder { + private var pending = Data() + private var output = Data() + private let maximumBytes: Int + + init(maximumBytes: Int) { + self.maximumBytes = maximumBytes + output.reserveCapacity(min(maximumBytes, 1024 * 1024)) + } + + mutating func append(_ fragment: String) throws { + let encoded = Data(fragment.utf8) + guard encoded.allSatisfy(Self.isBase64Byte) else { + throw AppError.invalidResponse + } + pending.append(encoded) + + if pending.contains(61) { + guard pending.count.isMultiple(of: 4), + let decoded = Data(base64Encoded: pending) else { + throw AppError.invalidResponse + } + try appendDecoded(decoded) + pending.removeAll(keepingCapacity: true) + return + } + + let decodeCount: Int + decodeCount = (pending.count / 4) * 4 + guard decodeCount > 0 else { return } + try decodePrefix(count: decodeCount) + } + + mutating func finish() throws -> Data { + guard !pending.isEmpty || !output.isEmpty else { + throw AppError.invalidResponse + } + if !pending.isEmpty { + guard pending.count.isMultiple(of: 4), + let decoded = Data(base64Encoded: pending) else { + throw AppError.invalidResponse + } + try appendDecoded(decoded) + pending.removeAll(keepingCapacity: false) + } + guard !output.isEmpty else { + throw AppError.invalidResponse + } + return output + } + + private mutating func decodePrefix(count: Int) throws { + guard let decoded = Data(base64Encoded: pending.prefix(count)) else { + throw AppError.invalidResponse + } + try appendDecoded(decoded) + pending.removeFirst(count) + } + + private mutating func appendDecoded(_ data: Data) throws { + guard data.count <= maximumBytes - output.count else { + throw AppError.responseTooLarge + } + output.append(data) + } + + private static func isBase64Byte(_ byte: UInt8) -> Bool { + (byte >= 65 && byte <= 90) + || (byte >= 97 && byte <= 122) + || (byte >= 48 && byte <= 57) + || byte == 43 + || byte == 47 + || byte == 61 + } +} diff --git a/TextFlow/Features/Speech/SpeechService.swift b/TextFlow/Features/Speech/SpeechService.swift index 91034a5..a09846c 100644 --- a/TextFlow/Features/Speech/SpeechService.swift +++ b/TextFlow/Features/Speech/SpeechService.swift @@ -18,7 +18,7 @@ final class SpeechService { throw AppError.speechConfigurationMissing } let resolved = try await resolve(profile) - let provider = OpenAICompatibleSpeechProvider(configuration: resolved) + let provider = makeProvider(configuration: resolved) let chunks = try await CancellableDetachedTask.run(priority: .userInitiated) { try Task.checkCancellation() let chunks = TextChunker.chunks(text) @@ -59,9 +59,18 @@ final class SpeechService { func testConnection(profile: SpeechProfile) async throws -> ProviderTestResult { let resolved = try await resolve(profile) - return try await OpenAICompatibleSpeechProvider( - configuration: resolved - ).testConnection() + return try await makeProvider(configuration: resolved).testConnection() + } + + private func makeProvider( + configuration: ResolvedSpeechConfiguration + ) -> any SpeechProviding { + switch configuration.profile.protocolKind { + case .openAISpeech: + OpenAICompatibleSpeechProvider(configuration: configuration) + case .openRouterAudioChat: + OpenRouterAudioSpeechProvider(configuration: configuration) + } } private func resolve( diff --git a/TextFlowTests/OpenRouterAudioSpeechProviderTests.swift b/TextFlowTests/OpenRouterAudioSpeechProviderTests.swift new file mode 100644 index 0000000..7a05422 --- /dev/null +++ b/TextFlowTests/OpenRouterAudioSpeechProviderTests.swift @@ -0,0 +1,299 @@ +import Foundation +import XCTest +@testable import TextFlow + +final class OpenRouterAudioSpeechProviderTests: XCTestCase { + override func tearDown() { + MockURLProtocol.handler = nil + super.tearDown() + } + + func testBuildsAudioChatRequestAndDecodesFragmentedSSEAudio() async throws { + MockURLProtocol.handler = { request in + XCTAssertEqual( + request.url?.absoluteString, + "https://openrouter.mock/api/v1/chat/completions" + ) + XCTAssertEqual( + request.value(forHTTPHeaderField: "Authorization"), + "Bearer openrouter-secret" + ) + XCTAssertEqual( + request.value(forHTTPHeaderField: "X-Title"), + "TextFlow Test" + ) + XCTAssertEqual( + request.value(forHTTPHeaderField: "Accept"), + "text/event-stream" + ) + + let data = try Self.bodyData(from: request) + let body = try XCTUnwrap( + JSONSerialization.jsonObject(with: data) as? [String: Any] + ) + XCTAssertEqual(body["model"] as? String, "openai/gpt-audio-mini") + XCTAssertEqual(body["stream"] as? Bool, true) + XCTAssertEqual(body["modalities"] as? [String], ["text", "audio"]) + let audio = try XCTUnwrap(body["audio"] as? [String: Any]) + XCTAssertEqual(audio["voice"] as? String, "alloy") + XCTAssertEqual(audio["format"] as? String, "wav") + let messages = try XCTUnwrap(body["messages"] as? [[String: String]]) + XCTAssertEqual(messages.count, 2) + XCTAssertEqual(messages[0]["role"], "system") + XCTAssertTrue(messages[0]["content"]?.contains("exactly as written") == true) + XCTAssertTrue(messages[0]["content"]?.contains("Speak slowly") == true) + XCTAssertEqual(messages[1], ["role": "user", "content": "Hello!"]) + + let event1 = "data: {\"choices\":[{\"delta\":{\"audio\":{\"data\":\"AQI\",\"transcript\":\"Hel\"}}}]}\n\n" + let event2 = "data: {\"choices\":[{\"delta\":{\"audio\":{\"data\":\"DBAU=\",\"transcript\":\"lo\"}}}]}\n\ndata: [DONE]\n\n" + let response = Data((event1 + event2).utf8) + return .sse([ + response.prefix(17), + response.dropFirst(17).prefix(23), + response.dropFirst(40) + ].map { Data($0) }) + } + let provider = OpenRouterAudioSpeechProvider( + configuration: try configuration(), + session: makeMockSession() + ) + + let result = try await provider.synthesize( + SpeechRequest(text: "Hello!", language: .english) + ) + + XCTAssertEqual(result.data, Data([1, 2, 3, 4, 5])) + XCTAssertEqual(result.format, .wav) + } + + func testMapsPCMToOpenRouterPCM16() async throws { + MockURLProtocol.handler = { request in + let data = try Self.bodyData(from: request) + let body = try XCTUnwrap( + JSONSerialization.jsonObject(with: data) as? [String: Any] + ) + let audio = try XCTUnwrap(body["audio"] as? [String: Any]) + XCTAssertEqual(audio["format"] as? String, "pcm16") + return .sse([ + Data("data: {\"choices\":[{\"delta\":{\"audio\":{\"data\":\"AQID\"}}}]}\n\ndata: [DONE]\n\n".utf8) + ]) + } + let pcmProfile = profile(format: .pcm) + let provider = OpenRouterAudioSpeechProvider( + configuration: ResolvedSpeechConfiguration( + profile: pcmProfile, + endpoint: try URLBuilder.speechEndpoint(for: pcmProfile), + apiKey: "openrouter-secret", + customHeaders: [:] + ), + session: makeMockSession() + ) + + let result = try await provider.synthesize( + SpeechRequest(text: "PCM", language: .english) + ) + + XCTAssertEqual(result.data, Data([1, 2, 3])) + XCTAssertEqual(result.format, .pcm) + } + + func testDecodesIndependentlyPaddedAudioChunks() async throws { + MockURLProtocol.handler = { _ in + .sse([ + Data("data: {\"choices\":[{\"delta\":{\"audio\":{\"data\":\"AQI=\"}}}]}\n\n".utf8), + Data("data: {\"choices\":[{\"delta\":{\"audio\":{\"data\":\"AwQ=\"}}}]}\n\ndata: [DONE]\n\n".utf8) + ]) + } + let provider = OpenRouterAudioSpeechProvider( + configuration: try configuration(), + session: makeMockSession() + ) + + let result = try await provider.synthesize( + SpeechRequest(text: "Chunks", language: .english) + ) + + XCTAssertEqual(result.data, Data([1, 2, 3, 4])) + } + + func testRejectsDecodedAudioOverOperationBudget() async throws { + MockURLProtocol.handler = { _ in + .sse([ + Data("data: {\"choices\":[{\"delta\":{\"audio\":{\"data\":\"AQIDBAU=\"}}}]}\n\ndata: [DONE]\n\n".utf8) + ]) + } + let provider = OpenRouterAudioSpeechProvider( + configuration: try configuration(), + session: makeMockSession() + ) + + do { + _ = try await provider.synthesize( + SpeechRequest(text: "Too large", language: .english), + maximumBytes: 4 + ) + XCTFail("Expected oversized audio to fail") + } catch { + XCTAssertEqual(error as? AppError, .responseTooLarge) + } + } + + func testRejectsMalformedOrMissingAudio() async throws { + let payloads = [ + "data: {\"choices\":[{\"delta\":{\"audio\":{\"data\":\"%%%\"}}}]}\n\ndata: [DONE]\n\n", + "data: {\"choices\":[{\"delta\":{\"content\":\"text only\"}}]}\n\ndata: [DONE]\n\n" + ] + for payload in payloads { + MockURLProtocol.handler = { _ in .sse([Data(payload.utf8)]) } + let provider = OpenRouterAudioSpeechProvider( + configuration: try configuration(), + session: makeMockSession() + ) + do { + _ = try await provider.synthesize( + SpeechRequest(text: "Invalid", language: .english) + ) + XCTFail("Expected invalid stream to fail") + } catch { + XCTAssertEqual(error as? AppError, .invalidResponse) + } + } + } + + func testMapsOpenRouterHTTPFailures() async throws { + let cases: [(Int, String, AppError)] = [ + (400, "Model openai/missing does not exist", .modelNotFound), + (402, "Insufficient credits", .insufficientCredits), + (403, "Provider terms must be accepted", .providerRejected), + (429, "Rate limited", .rateLimited), + (503, "Unavailable", .serviceUnavailable) + ] + for (status, message, expected) in cases { + MockURLProtocol.handler = { _ in + .init( + status: status, + headers: ["Content-Type": "application/json"], + chunks: [Data("{\"error\":{\"message\":\"\(message)\"}}".utf8)], + error: nil + ) + } + let provider = OpenRouterAudioSpeechProvider( + configuration: try configuration(), + session: makeMockSession() + ) + do { + _ = try await provider.synthesize( + SpeechRequest(text: "Failure", language: .english) + ) + XCTFail("Expected HTTP failure") + } catch { + XCTAssertEqual(error as? AppError, expected) + } + } + } + + func testRejectsNonStreamingSuccessResponse() async throws { + MockURLProtocol.handler = { _ in + .json(Data("{\"choices\":[]}".utf8)) + } + let provider = OpenRouterAudioSpeechProvider( + configuration: try configuration(), + session: makeMockSession() + ) + + do { + _ = try await provider.synthesize( + SpeechRequest(text: "No stream", language: .english) + ) + XCTFail("Expected non-SSE response to fail") + } catch { + XCTAssertEqual(error as? AppError, .incompatibleRequest) + } + } + + func testMapsTimeoutAndCancellation() async throws { + let cases: [(URLError.Code, AppError?)] = [ + (.timedOut, .timedOut), + (.cancelled, nil) + ] + for (code, expected) in cases { + MockURLProtocol.handler = { _ in + .init( + status: 200, + headers: ["Content-Type": "text/event-stream"], + chunks: [], + error: URLError(code) + ) + } + let provider = OpenRouterAudioSpeechProvider( + configuration: try configuration(), + session: makeMockSession() + ) + do { + _ = try await provider.synthesize( + SpeechRequest(text: "Network", language: .english) + ) + XCTFail("Expected network failure") + } catch { + if let expected { + XCTAssertEqual(error as? AppError, expected) + } else { + XCTAssertTrue(error is CancellationError) + } + } + } + } + + private func configuration() throws -> ResolvedSpeechConfiguration { + let profile = profile(format: .wav) + return ResolvedSpeechConfiguration( + profile: profile, + endpoint: try URLBuilder.speechEndpoint(for: profile), + apiKey: "openrouter-secret", + customHeaders: ["X-Title": "TextFlow Test"] + ) + } + + private func profile(format: SpeechOutputFormat) -> SpeechProfile { + var profile = SpeechProfile.empty() + profile.protocolKind = .openRouterAudioChat + profile.endpointMode = .fullEndpoint + profile.fullEndpoint = "https://openrouter.mock/api/v1/chat/completions" + profile.model = "openai/gpt-audio-mini" + profile.chineseVoice = "alloy" + profile.englishVoice = "alloy" + profile.outputFormat = format + profile.instructions = "Speak slowly." + return profile + } + + private func makeMockSession() -> URLSession { + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [MockURLProtocol.self] + configuration.urlCache = nil + configuration.httpCookieStorage = nil + return URLSession(configuration: configuration) + } + + private static func bodyData(from request: URLRequest) throws -> Data { + if let body = request.httpBody { + return body + } + let stream = try XCTUnwrap(request.httpBodyStream) + stream.open() + defer { stream.close() } + var result = Data() + var buffer = [UInt8](repeating: 0, count: 1024) + while stream.hasBytesAvailable { + let count = stream.read(&buffer, maxLength: buffer.count) + if count < 0 { + throw try XCTUnwrap(stream.streamError) + } + if count == 0 { + break + } + result.append(buffer, count: count) + } + return result + } +} diff --git a/TextFlowTests/SpeechProfileSecurityTests.swift b/TextFlowTests/SpeechProfileSecurityTests.swift index 54c665a..18a7e3f 100644 --- a/TextFlowTests/SpeechProfileSecurityTests.swift +++ b/TextFlowTests/SpeechProfileSecurityTests.swift @@ -16,4 +16,24 @@ final class SpeechProfileSecurityTests: XCTestCase { XCTAssertFalse(json.lowercased().contains("apikey")) XCTAssertFalse(json.contains("speech-secret")) } + + func testLegacyProfileWithoutProtocolMigratesToOpenAISpeech() throws { + var object = try XCTUnwrap( + JSONSerialization.jsonObject( + with: JSONEncoder().encode(SpeechProfile.empty()) + ) as? [String: Any] + ) + object.removeValue(forKey: "protocolKind") + let legacyData = try JSONSerialization.data(withJSONObject: object) + + let decoded = try JSONDecoder().decode(SpeechProfile.self, from: legacyData) + + XCTAssertEqual(decoded.protocolKind, .openAISpeech) + let migratedObject = try XCTUnwrap( + JSONSerialization.jsonObject( + with: JSONEncoder().encode(decoded) + ) as? [String: Any] + ) + XCTAssertEqual(migratedObject["protocolKind"] as? String, "openAISpeech") + } } From 47d95910586e069a4d4a5c51aaeeee46f67684da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20=E8=80=81=E7=81=AF?= Date: Sun, 2 Aug 2026 01:06:34 +0800 Subject: [PATCH 02/12] Revert "Add OpenRouter audio speech support" This reverts commit d9fd6f0ce176b596fd2fadfe5e26366c1b0a15eb. --- 01_PRD_v0.1.md | 4 +- 02_TECHNICAL_DESIGN.md | 41 +-- 03_ACCEPTANCE_TESTS.md | 14 +- README.md | 4 +- README.zh-CN.md | 4 +- TextFlow.xcodeproj/project.pbxproj | 8 - TextFlow/Core/Models/AppError.swift | 6 - TextFlow/Core/Models/SpeechProfile.swift | 129 -------- .../Settings/SpeechProfilesView.swift | 120 +------ .../OpenAICompatibleSpeechProvider.swift | 104 ++---- .../OpenRouterAudioSpeechProvider.swift | 308 ------------------ TextFlow/Features/Speech/SpeechService.swift | 17 +- .../OpenRouterAudioSpeechProviderTests.swift | 299 ----------------- .../SpeechProfileSecurityTests.swift | 20 -- 14 files changed, 43 insertions(+), 1035 deletions(-) delete mode 100644 TextFlow/Features/Speech/OpenRouterAudioSpeechProvider.swift delete mode 100644 TextFlowTests/OpenRouterAudioSpeechProviderTests.swift diff --git a/01_PRD_v0.1.md b/01_PRD_v0.1.md index d8f45db..9d702a3 100644 --- a/01_PRD_v0.1.md +++ b/01_PRD_v0.1.md @@ -204,10 +204,8 @@ v0.1 仅服务单一用户,运行在一台 M2 MacBook 上,不需要账号、 - 中文和英文分别保存一个默认 Voice。 - 使用独立的 TTS 配置,不与翻译模型绑定。 -- 支持 OpenAI-compatible `/v1/audio/speech`。 -- 支持 OpenRouter Audio Chat:通过 `/api/v1/chat/completions` 请求音频输出,并流式接收音频分片。 +- 首选 OpenAI-compatible `/v1/audio/speech`。 - 支持自定义 URL、API Key、模型、Voice、请求头、输出格式。 -- 语音协议必须由用户明确选择;不得把 Audio Chat 模型误发到 Speech endpoint。 - 支持生成、播放、暂停、继续、停止、重新播放。 - 点击另一个朗读按钮时停止当前音频并播放新任务。 - API 不可用时可选择 macOS 本地系统语音兜底。 diff --git a/02_TECHNICAL_DESIGN.md b/02_TECHNICAL_DESIGN.md index 5f2868e..28516ca 100644 --- a/02_TECHNICAL_DESIGN.md +++ b/02_TECHNICAL_DESIGN.md @@ -36,7 +36,6 @@ TextFlowApp │ └── ChatCompletionsTranslationProvider ├── SpeechService │ ├── OpenAICompatibleSpeechProvider - │ ├── OpenRouterAudioSpeechProvider │ └── SystemSpeechProvider ├── TranslationPanelController ├── SettingsStore @@ -97,7 +96,6 @@ TextFlow/ │ ├── Speech/ │ │ ├── SpeechService.swift │ │ ├── OpenAICompatibleSpeechProvider.swift -│ │ ├── OpenRouterAudioSpeechProvider.swift │ │ ├── SystemSpeechProvider.swift │ │ └── AudioPlaybackController.swift │ ├── Panel/ @@ -402,7 +400,6 @@ API Key 与 Header secret 不进入此结构的明文持久化。结构只保存 ```swift protocol SpeechProviding: Sendable { func synthesize(_ request: SpeechRequest) async throws -> SpeechAudio - func synthesize(_ request: SpeechRequest, maximumBytes: Int) async throws -> SpeechAudio func testConnection() async throws -> ProviderTestResult } ``` @@ -436,39 +433,7 @@ protocol SpeechProviding: Sendable { 第三方服务如果遵循 OpenAI Speech schema 可直接使用。若协议不同,新增独立 Provider,不在 v0.1 里加入任意 JSON 模板编辑器。 -### 9.3 OpenRouter Audio Chat - -`OpenRouterAudioSpeechProvider` 适配支持音频输出的 Chat Completions 模型,例如 `openai/gpt-audio-mini`。 - -默认 Endpoint:`https://openrouter.ai/api/v1/chat/completions` - -请求概念: - -```json -{ - "model": "openai/gpt-audio-mini", - "messages": [ - {"role": "system", "content": "只朗读用户文本,并按配置控制语速和语气"}, - {"role": "user", "content": "需要朗读的文本"} - ], - "modalities": ["text", "audio"], - "audio": {"voice": "alloy", "format": "wav"}, - "stream": true -} -``` - -实现要求: - -- 只接受 `text/event-stream` 成功响应 -- 从 `choices[].delta.audio.data` 提取 Base64 分片 -- Base64 分片可跨任意 SSE 事件和网络分片,必须增量解码 -- 音频、SSE 单事件和整体传输分别设安全上限 -- `[DONE]` 前后不要求出现文本 transcript -- `pcm` 配置映射为 OpenRouter 的 `pcm16` -- 不记录请求正文、音频 Base64、API Key 或错误响应全文 -- 旧语音配置缺少协议字段时迁移为 OpenAI-compatible Speech - -### 9.4 文本长度 +### 9.3 文本长度 实现 `TextChunker`: @@ -478,7 +443,7 @@ protocol SpeechProviding: Sendable { - 所有块生成完成后依次播放 - 任一块失败时停止并显示具体块序号 -### 9.5 播放 +### 9.4 播放 `AudioPlaybackController` 使用 AVFoundation: @@ -488,7 +453,7 @@ protocol SpeechProviding: Sendable { - 任务取消时释放 Data - 同一时刻只允许一个播放会话 -### 9.6 本地兜底 +### 9.5 本地兜底 `SystemSpeechProvider` 使用 macOS 系统语音: diff --git a/03_ACCEPTANCE_TESTS.md b/03_ACCEPTANCE_TESTS.md index b9c4fb0..9eee582 100644 --- a/03_ACCEPTANCE_TESTS.md +++ b/03_ACCEPTANCE_TESTS.md @@ -139,20 +139,10 @@ ### P0-14 TTS 自定义 URL -场景 A:配置 OpenAI-compatible 第三方 Speech endpoint。 +步骤:配置 OpenAI-compatible 第三方 Speech endpoint。 期望:自定义 URL、模型、Voice、Key、Header 生效,音频成功播放。 -场景 B:协议选择 `OpenRouter Audio Chat`,使用 `/api/v1/chat/completions` 和支持音频输出的模型。 - -期望: - -- 请求包含 `modalities: ["text", "audio"]`、`audio` 和 `stream: true` -- 任意网络边界切开的 SSE/Base64 音频均可正确还原并播放 -- 使用 `/audio/speech`、非 SSE 响应或无音频响应时显示明确的兼容性错误 -- 401、余额不足、模型不存在、限流和上游拒绝可区分 -- 现有语音配置升级后仍按 OpenAI-compatible Speech 工作 - ### P0-15 TTS 本地兜底 步骤:断开网络或故意配置错误,点击朗读。 @@ -220,7 +210,7 @@ mock server 将 JSON 切成随机字节片段。 -期望:翻译文本与 OpenRouter 音频均解析正确,无乱码、丢 token、音频损坏或崩溃。 +期望:解析正确,无乱码、丢 token 或崩溃。 ### P1-05 剪贴板复杂类型 diff --git a/README.md b/README.md index 601f42b..451524e 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ Both paths open one floating panel where the source stays editable and the sourc - **Two shortcuts, one flow.** No main window to find and no context switching. - **Local OCR.** Screenshots stay in memory and text recognition runs through Apple Vision. - **Bring your own model.** Configure custom URLs, models, headers, timeouts, streaming, OpenAI Responses, or Chat Completions. -- **Flexible speech.** Use an OpenAI-compatible speech endpoint, OpenRouter Audio Chat, or explicitly fall back to macOS system voices. +- **Flexible speech.** Use an OpenAI-compatible speech endpoint or explicitly fall back to macOS system voices. - **Privacy by design.** Keys live in Keychain; logs are redacted; there is no history, account, telemetry, or cloud sync. - **Native and lean.** Swift 6, SwiftUI + AppKit, Apple frameworks, and zero third-party runtime dependencies. - **Cancellation-safe.** A new capture replaces the previous session and cancels in-flight OCR, network, and audio work. @@ -117,7 +117,7 @@ Translation profiles support: - Automatic, enabled, or disabled streaming - Streaming SSE and non-streaming JSON responses -Speech profiles support OpenAI-compatible `/v1/audio/speech` endpoints and OpenRouter Audio Chat through `/api/v1/chat/completions`, with custom URL, model, voice, headers, instructions, and output format. Choose the matching protocol explicitly because Audio Chat models are not compatible with Speech endpoints. +Speech profiles support OpenAI-compatible `/v1/audio/speech` endpoints with custom URL, model, voice, headers, and output format. “OpenAI-compatible” implementations vary; please open a compatibility report when a provider needs a dedicated adapter. Never place a real API key in source files, Markdown, test fixtures, `.env` files committed to Git, or issue screenshots. diff --git a/README.zh-CN.md b/README.zh-CN.md index 11ec586..956a488 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -38,7 +38,7 @@ - **两个快捷键,一条工作流。** 不需要先找到主窗口,也不打断当前工作。 - **OCR 完全本地。** 截图只驻留内存,识别使用 Apple Vision。 - **自带模型。** 支持自定义 URL、模型、Header、超时、流式模式、Responses 和 Chat Completions。 -- **灵活朗读。** 可使用 OpenAI-compatible Speech、OpenRouter Audio Chat,也可由用户明确切换到 macOS 系统语音。 +- **灵活朗读。** 可使用 OpenAI-compatible Speech 接口,也可由用户明确切换到 macOS 系统语音。 - **隐私优先。** 密钥进入 Keychain;日志脱敏;不保存历史;没有账号、遥测或云同步。 - **原生且克制。** Swift 6、SwiftUI + AppKit、Apple 原生框架、零第三方运行时依赖。 - **完整取消链路。** 新任务会替换旧会话并取消进行中的 OCR、网络和音频任务。 @@ -117,7 +117,7 @@ brew install xcodegen - 自动、开启或关闭流式模式 - SSE 流式响应和非流式 JSON 响应 -语音配置支持 OpenAI-compatible `/v1/audio/speech` 和基于 `/api/v1/chat/completions` 的 OpenRouter Audio Chat,可自定义 URL、模型、Voice、Header、Instructions 和输出格式。Audio Chat 模型不兼容 Speech endpoint,必须明确选择匹配的协议。 +语音配置支持 OpenAI-compatible `/v1/audio/speech`,可自定义 URL、模型、Voice、Header 和输出格式。不同厂商对“OpenAI 兼容”的实现并不完全一致;如需专门适配器,请提交兼容性报告。 不要把真实 API Key 放入源码、Markdown、测试 fixture、Git 中的 `.env` 文件或 Issue 截图。 diff --git a/TextFlow.xcodeproj/project.pbxproj b/TextFlow.xcodeproj/project.pbxproj index 1371c7d..1488a69 100644 --- a/TextFlow.xcodeproj/project.pbxproj +++ b/TextFlow.xcodeproj/project.pbxproj @@ -9,7 +9,6 @@ /* Begin PBXBuildFile section */ 0120CF75E228C2D9CBC28839 /* TranslationProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECC84CD09D11EA4DA9B2D418 /* TranslationProfile.swift */; }; 088176417C94EC09178C97F9 /* SpeechProfileSecurityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AFC3645542FBB7987E245F2 /* SpeechProfileSecurityTests.swift */; }; - 0C375ED868552155AE5E3265 /* OpenRouterAudioSpeechProviderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94044696E52806E9E0FFCFCE /* OpenRouterAudioSpeechProviderTests.swift */; }; 0DBD1CD0DD5CBC6A465653EB /* SpeechResourceLimits.swift in Sources */ = {isa = PBXBuildFile; fileRef = 636E9C29E272E74B7A503BFC /* SpeechResourceLimits.swift */; }; 0EA6A6D593A7BCD1ADD66A48 /* TranslationPanelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 795750DFD27EF41C442BCF6F /* TranslationPanelView.swift */; }; 10C11E16CA4EB8093A889174 /* SpeechAudioCacheTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23EC17B8FFA0D22C9C1CBB47 /* SpeechAudioCacheTests.swift */; }; @@ -26,7 +25,6 @@ 1F388C3950C30831E5E56F33 /* LanguageDetectionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5F32523534D603665DF54AB /* LanguageDetectionService.swift */; }; 2099FFBEAB41C87793EA8C38 /* TranslationRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D9DCD870DF9FEDFDE3F9814 /* TranslationRequest.swift */; }; 2905E26DEFB9FEBB9CD036F0 /* SpeechService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CE842B9AF66AD6513D3360A /* SpeechService.swift */; }; - 2EDB4F536AF8F47B1EB09018 /* OpenRouterAudioSpeechProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = B65715EAFF1488595551B060 /* OpenRouterAudioSpeechProvider.swift */; }; 2FA954D135D9DA76FC19AB95 /* LanguageDetectionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B5CE66FDC6829555EA09394 /* LanguageDetectionTests.swift */; }; 30FE6F6317DFA8E8E7D49B2E /* AppError.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9A2DDAEDDAC9527F387D0CC /* AppError.swift */; }; 3B9D0B3EC18F55527804028D /* TranslationProfileSecurityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8A96D3F1B3DDEB31B660AAB /* TranslationProfileSecurityTests.swift */; }; @@ -160,7 +158,6 @@ 87711E592323AD92B556C91D /* URLBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLBuilder.swift; sourceTree = ""; }; 90B72CEE4A9478AF1C512827 /* PanelPositioner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PanelPositioner.swift; sourceTree = ""; }; 92D0E094A543862D2C1D7174 /* OpenAICompatibleSpeechProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenAICompatibleSpeechProvider.swift; sourceTree = ""; }; - 94044696E52806E9E0FFCFCE /* OpenRouterAudioSpeechProviderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenRouterAudioSpeechProviderTests.swift; sourceTree = ""; }; 97395BA291782A91C0631C3A /* TranslationProviders.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranslationProviders.swift; sourceTree = ""; }; 98190C5CAD6F34622AFC4CC7 /* SSEParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SSEParser.swift; sourceTree = ""; }; 98ABD06BDE8DA356EBFF196E /* AudioPlaybackController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPlaybackController.swift; sourceTree = ""; }; @@ -172,7 +169,6 @@ AF1638DF26C44F95AB66C8D4 /* TranslationSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranslationSession.swift; sourceTree = ""; }; B1148CF8A9AE580D79CF24BC /* VisionOCRService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VisionOCRService.swift; sourceTree = ""; }; B5E62521645442FE9B549428 /* TextFlowApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextFlowApp.swift; sourceTree = ""; }; - B65715EAFF1488595551B060 /* OpenRouterAudioSpeechProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenRouterAudioSpeechProvider.swift; sourceTree = ""; }; B920898D0F4A6E839D55E562 /* KeychainStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainStore.swift; sourceTree = ""; }; B9E5F309D497A68743CB5B92 /* ClipboardFallbackReaderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClipboardFallbackReaderTests.swift; sourceTree = ""; }; BFFB7348A3F83C6102E28EE1 /* RedactedLoggerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RedactedLoggerTests.swift; sourceTree = ""; }; @@ -208,7 +204,6 @@ 98ABD06BDE8DA356EBFF196E /* AudioPlaybackController.swift */, 6298768B299979CF0F646982 /* LimitedURLSessionDataLoader.swift */, 92D0E094A543862D2C1D7174 /* OpenAICompatibleSpeechProvider.swift */, - B65715EAFF1488595551B060 /* OpenRouterAudioSpeechProvider.swift */, 56292085A3560797C57EDCD7 /* SpeechAudioCache.swift */, 636E9C29E272E74B7A503BFC /* SpeechResourceLimits.swift */, 4CE842B9AF66AD6513D3360A /* SpeechService.swift */, @@ -359,7 +354,6 @@ 3B5CE66FDC6829555EA09394 /* LanguageDetectionTests.swift */, 5ED1D4D6A9B3DB138738A08A /* LimitedURLSessionDataLoaderTests.swift */, 2B6B8BB11F60DD022BD26820 /* OCRReadingOrderTests.swift */, - 94044696E52806E9E0FFCFCE /* OpenRouterAudioSpeechProviderTests.swift */, 445F8C7502CD861217E285DC /* PanelPositionerTests.swift */, 475D51C2A9EC2A9FA4EC591F /* PermissionSeparationTests.swift */, 01EF3A899ABC719619822EBA /* ProfileSecretSessionStateTests.swift */, @@ -532,7 +526,6 @@ 2FA954D135D9DA76FC19AB95 /* LanguageDetectionTests.swift in Sources */, 3DBF8B3AFA47CF2A2C62A167 /* LimitedURLSessionDataLoaderTests.swift in Sources */, 4FCA25903060F20C7970C9DB /* OCRReadingOrderTests.swift in Sources */, - 0C375ED868552155AE5E3265 /* OpenRouterAudioSpeechProviderTests.swift in Sources */, AEFDE86A81708DC6D5E678AB /* PanelPositionerTests.swift in Sources */, 127AA10273B69DE928546392 /* PermissionSeparationTests.swift in Sources */, BE53C6EFC10DAABED65B9494 /* ProfileSecretSessionStateTests.swift in Sources */, @@ -576,7 +569,6 @@ E5B551B878A066C3FBA728F2 /* LimitedURLSessionDataLoader.swift in Sources */, 71ACB87E5408AD35D9831B04 /* OCRProviding.swift in Sources */, 57BA31F0B76DEE3956E14C45 /* OpenAICompatibleSpeechProvider.swift in Sources */, - 2EDB4F536AF8F47B1EB09018 /* OpenRouterAudioSpeechProvider.swift in Sources */, 3F75409BB2EDAD0337C67A5D /* PanelPositioner.swift in Sources */, 4F5DDF73FAF7DA51C2708F05 /* PermissionService.swift in Sources */, 82D1F16FD42978739A5AB70A /* PermissionsView.swift in Sources */, diff --git a/TextFlow/Core/Models/AppError.swift b/TextFlow/Core/Models/AppError.swift index 75a9278..54c27e0 100644 --- a/TextFlow/Core/Models/AppError.swift +++ b/TextFlow/Core/Models/AppError.swift @@ -12,8 +12,6 @@ enum AppError: LocalizedError, Sendable, Equatable { case networkFailure case tlsFailure case authenticationFailed - case insufficientCredits - case providerRejected case endpointNotFound case rateLimited case serviceUnavailable @@ -53,10 +51,6 @@ enum AppError: LocalizedError, Sendable, Equatable { return "TLS 安全连接失败。请检查证书、系统时间和 HTTPS 地址。" case .authenticationFailed: return "接口鉴权失败。请检查 API Key 或自定义请求头。" - case .insufficientCredits: - return "账户余额不足,服务拒绝了本次请求。请检查额度或充值后重试。" - case .providerRejected: - return "上游提供商拒绝了请求。请检查提供商条款、账户状态或模型访问权限。" case .endpointNotFound: return "接口路径不存在。请检查 Base URL、Path 或完整 Endpoint。" case .rateLimited: diff --git a/TextFlow/Core/Models/SpeechProfile.swift b/TextFlow/Core/Models/SpeechProfile.swift index fd1e37c..3fe896b 100644 --- a/TextFlow/Core/Models/SpeechProfile.swift +++ b/TextFlow/Core/Models/SpeechProfile.swift @@ -1,39 +1,5 @@ import Foundation -enum SpeechProtocolKind: String, Codable, CaseIterable, Sendable, Identifiable { - case openAISpeech - case openRouterAudioChat - - var id: Self { self } - - var displayName: String { - switch self { - case .openAISpeech: - "OpenAI-compatible Speech" - case .openRouterAudioChat: - "OpenRouter Audio Chat" - } - } - - var defaultPath: String { - switch self { - case .openAISpeech: - "/v1/audio/speech" - case .openRouterAudioChat: - "/api/v1/chat/completions" - } - } - - var supportedFormats: [SpeechOutputFormat] { - switch self { - case .openAISpeech: - SpeechOutputFormat.allCases - case .openRouterAudioChat: - [.wav, .mp3, .flac, .opus, .pcm] - } - } -} - enum SpeechOutputFormat: String, Codable, CaseIterable, Sendable, Identifiable { case mp3 case wav @@ -48,7 +14,6 @@ enum SpeechOutputFormat: String, Codable, CaseIterable, Sendable, Identifiable { struct SpeechProfile: Codable, Identifiable, Hashable, Sendable { let id: UUID var name: String - var protocolKind: SpeechProtocolKind var endpointMode: EndpointMode var baseURL: String var path: String @@ -66,7 +31,6 @@ struct SpeechProfile: Codable, Identifiable, Hashable, Sendable { SpeechProfile( id: UUID(), name: "新语音配置", - protocolKind: .openAISpeech, endpointMode: .baseURLAndPath, baseURL: "https://api.openai.com", path: "/v1/audio/speech", @@ -82,86 +46,6 @@ struct SpeechProfile: Codable, Identifiable, Hashable, Sendable { ) } - private enum CodingKeys: String, CodingKey { - case id - case name - case protocolKind - case endpointMode - case baseURL - case path - case fullEndpoint - case model - case chineseVoice - case englishVoice - case outputFormat - case instructions - case authMode - case customHeaders - case timeoutSeconds - } - - init( - id: UUID, - name: String, - protocolKind: SpeechProtocolKind, - endpointMode: EndpointMode, - baseURL: String, - path: String, - fullEndpoint: String, - model: String, - chineseVoice: String, - englishVoice: String, - outputFormat: SpeechOutputFormat, - instructions: String, - authMode: AuthMode, - customHeaders: [HeaderReference], - timeoutSeconds: Double - ) { - self.id = id - self.name = name - self.protocolKind = protocolKind - self.endpointMode = endpointMode - self.baseURL = baseURL - self.path = path - self.fullEndpoint = fullEndpoint - self.model = model - self.chineseVoice = chineseVoice - self.englishVoice = englishVoice - self.outputFormat = outputFormat - self.instructions = instructions - self.authMode = authMode - self.customHeaders = customHeaders - self.timeoutSeconds = timeoutSeconds - } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - id = try container.decode(UUID.self, forKey: .id) - name = try container.decode(String.self, forKey: .name) - protocolKind = try container.decodeIfPresent( - SpeechProtocolKind.self, - forKey: .protocolKind - ) ?? .openAISpeech - endpointMode = try container.decode(EndpointMode.self, forKey: .endpointMode) - baseURL = try container.decode(String.self, forKey: .baseURL) - path = try container.decode(String.self, forKey: .path) - fullEndpoint = try container.decode(String.self, forKey: .fullEndpoint) - model = try container.decode(String.self, forKey: .model) - chineseVoice = try container.decode(String.self, forKey: .chineseVoice) - englishVoice = try container.decode(String.self, forKey: .englishVoice) - outputFormat = try container.decode( - SpeechOutputFormat.self, - forKey: .outputFormat - ) - instructions = try container.decode(String.self, forKey: .instructions) - authMode = try container.decode(AuthMode.self, forKey: .authMode) - customHeaders = try container.decode( - [HeaderReference].self, - forKey: .customHeaders - ) - timeoutSeconds = try container.decode(Double.self, forKey: .timeoutSeconds) - } - var apiKeyAccount: String { "tts.\(id.uuidString).apiKey" } @@ -188,22 +72,9 @@ struct SpeechAudio: Sendable { protocol SpeechProviding: Sendable { func synthesize(_ request: SpeechRequest) async throws -> SpeechAudio - func synthesize( - _ request: SpeechRequest, - maximumBytes: Int - ) async throws -> SpeechAudio func testConnection() async throws -> ProviderTestResult } -extension SpeechProviding { - func synthesize(_ request: SpeechRequest) async throws -> SpeechAudio { - try await synthesize( - request, - maximumBytes: SpeechResourceLimits.maximumResponseBytes - ) - } -} - struct ResolvedSpeechConfiguration: Sendable { let profile: SpeechProfile let endpoint: URL diff --git a/TextFlow/Features/Settings/SpeechProfilesView.swift b/TextFlow/Features/Settings/SpeechProfilesView.swift index 2c0c972..11e4d9a 100644 --- a/TextFlow/Features/Settings/SpeechProfilesView.swift +++ b/TextFlow/Features/Settings/SpeechProfilesView.swift @@ -25,7 +25,7 @@ struct SpeechProfilesView: View { ContentUnavailableView( "尚无语音配置", systemImage: "speaker.wave.2", - description: Text("添加 Speech 或 OpenRouter Audio Chat 配置;秘密只存钥匙串。") + description: Text("添加 OpenAI-compatible Speech 配置;秘密只存钥匙串。") ) .frame(maxWidth: .infinity, maxHeight: .infinity) } @@ -158,42 +158,8 @@ private struct SpeechProfileEditor: View { private var basicInformationCard: some View { SettingsCard( "基本信息", - subtitle: protocolSubtitle + subtitle: "模型名称会原样发送给 OpenAI-compatible Speech 服务。" ) { - SettingsField("请求协议") { - Picker("请求协议", selection: $profile.protocolKind) { - ForEach(SpeechProtocolKind.allCases) { - Text($0.displayName).tag($0) - } - } - .labelsHidden() - .pickerStyle(.segmented) - .onChange(of: profile.protocolKind) { _, newValue in - if !newValue.supportedFormats.contains(profile.outputFormat) { - profile.outputFormat = newValue == .openRouterAudioChat - ? .wav - : .mp3 - } - } - } - - if profile.protocolKind == .openRouterAudioChat { - HStack { - Label( - "使用 Chat Completions 流式接收 Base64 音频,不兼容 /audio/speech。", - systemImage: "waveform" - ) - .font(.callout) - .foregroundStyle(.secondary) - - Spacer() - - Button("应用 OpenRouter 推荐配置") { - applyOpenRouterDefaults() - } - } - } - HStack(alignment: .top, spacing: 14) { SettingsField("配置名称") { TextField("例如:DMX", text: $profile.name) @@ -202,7 +168,7 @@ private struct SpeechProfileEditor: View { } SettingsField("模型") { - TextField(modelPlaceholder, text: $profile.model) + TextField("例如:gpt-4o-mini-tts", text: $profile.model) .textFieldStyle(.roundedBorder) .controlSize(.large) } @@ -213,7 +179,7 @@ private struct SpeechProfileEditor: View { private var endpointCard: some View { SettingsCard( "接口地址", - subtitle: endpointSubtitle + subtitle: "可以分别填写 Base URL 与 Path,也可以直接使用完整 Endpoint。" ) { SettingsField("URL 模式") { Picker("URL 模式", selection: $profile.endpointMode) { @@ -227,20 +193,20 @@ private struct SpeechProfileEditor: View { if profile.endpointMode == .baseURLAndPath { SettingsField("Base URL") { - TextField(baseURLPlaceholder, text: $profile.baseURL) + TextField("https://api.openai.com", text: $profile.baseURL) .textFieldStyle(.roundedBorder) .controlSize(.large) } SettingsField("Path") { - TextField(profile.protocolKind.defaultPath, text: $profile.path) + TextField("/v1/audio/speech", text: $profile.path) .textFieldStyle(.roundedBorder) .controlSize(.large) } } else { SettingsField("完整 Endpoint") { TextField( - fullEndpointPlaceholder, + "https://api.openai.com/v1/audio/speech", text: $profile.fullEndpoint ) .textFieldStyle(.roundedBorder) @@ -253,7 +219,7 @@ private struct SpeechProfileEditor: View { private var voiceCard: some View { SettingsCard( "声音与朗读方式", - subtitle: voiceSubtitle + subtitle: "中文和英文可以使用不同 Voice;Instructions 可用于控制语速与语气。" ) { HStack(alignment: .top, spacing: 14) { SettingsField("中文 Voice") { @@ -345,7 +311,7 @@ private struct SpeechProfileEditor: View { HStack(alignment: .top, spacing: 14) { SettingsField("输出格式") { Picker("输出格式", selection: $profile.outputFormat) { - ForEach(profile.protocolKind.supportedFormats) { + ForEach(SpeechOutputFormat.allCases) { Text($0.rawValue.uppercased()).tag($0) } } @@ -400,7 +366,6 @@ private struct SpeechProfileEditor: View { } } .disabled(isLoadingSecrets || isSaving || isTesting) - .help("测试会向当前服务发送一次短文本并生成语音,可能产生少量费用。") Button(isDefault ? "当前默认" : "设为默认", action: makeDefault) .disabled(isDefault) @@ -410,73 +375,6 @@ private struct SpeechProfileEditor: View { private var statusIsSuccess: Bool { statusMessage == "连接成功" || statusMessage == "密钥与请求头已保存" - || statusMessage == "已应用 OpenRouter 推荐配置" - } - - private var protocolSubtitle: String { - switch profile.protocolKind { - case .openAISpeech: - "模型名称会原样发送给 OpenAI-compatible Speech 服务。" - case .openRouterAudioChat: - "适用于 openai/gpt-audio-mini 等支持音频输出的 OpenRouter 模型。" - } - } - - private var endpointSubtitle: String { - switch profile.protocolKind { - case .openAISpeech: - "标准语音生成接口;可以分别填写 Base URL 与 Path,或使用完整 Endpoint。" - case .openRouterAudioChat: - "必须指向 OpenRouter 的 /api/v1/chat/completions,而不是 /audio/speech。" - } - } - - private var voiceSubtitle: String { - switch profile.protocolKind { - case .openAISpeech: - "中文和英文可以使用不同 Voice;Instructions 可用于控制语速与语气。" - case .openRouterAudioChat: - "TextFlow 会要求模型只朗读原文;Instructions 会追加为语速和语气要求。" - } - } - - private var modelPlaceholder: String { - profile.protocolKind == .openRouterAudioChat - ? "例如:openai/gpt-audio-mini" - : "例如:gpt-4o-mini-tts" - } - - private var baseURLPlaceholder: String { - profile.protocolKind == .openRouterAudioChat - ? "https://openrouter.ai" - : "https://api.openai.com" - } - - private var fullEndpointPlaceholder: String { - profile.protocolKind == .openRouterAudioChat - ? "https://openrouter.ai/api/v1/chat/completions" - : "https://api.openai.com/v1/audio/speech" - } - - private func applyOpenRouterDefaults() { - if profile.name == "新语音配置" { - profile.name = "OpenRouter GPT Audio Mini" - } - profile.protocolKind = .openRouterAudioChat - profile.endpointMode = .fullEndpoint - profile.baseURL = "https://openrouter.ai" - profile.path = SpeechProtocolKind.openRouterAudioChat.defaultPath - profile.fullEndpoint = "https://openrouter.ai/api/v1/chat/completions" - profile.model = "openai/gpt-audio-mini" - profile.chineseVoice = "alloy" - profile.englishVoice = "alloy" - profile.outputFormat = .wav - if profile.instructions.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - profile.instructions = "Speak slowly and clearly for a language learner, with natural pauses between sentences." - } - profile.authMode = .bearer - profile.timeoutSeconds = 60 - statusMessage = "已应用 OpenRouter 推荐配置" } private func loadSecrets() async { diff --git a/TextFlow/Features/Speech/OpenAICompatibleSpeechProvider.swift b/TextFlow/Features/Speech/OpenAICompatibleSpeechProvider.swift index b987eea..d190e24 100644 --- a/TextFlow/Features/Speech/OpenAICompatibleSpeechProvider.swift +++ b/TextFlow/Features/Speech/OpenAICompatibleSpeechProvider.swift @@ -100,14 +100,28 @@ struct OpenAICompatibleSpeechProvider: SpeechProviding { data: data, format: configuration.profile.outputFormat ) + case 401, 403: + throw AppError.authenticationFailed + case 404: + throw AppError.endpointNotFound + case 429: + throw AppError.rateLimited default: - throw SpeechProviderErrorMapper.http( - status: response.statusCode, - body: data - ) + throw AppError.incompatibleRequest + } + } catch let error as URLError { + switch error.code { + case .cancelled: + throw CancellationError() + case .timedOut: + throw AppError.timedOut + case .secureConnectionFailed, .serverCertificateHasBadDate, + .serverCertificateUntrusted, .serverCertificateHasUnknownRoot, + .serverCertificateNotYetValid: + throw AppError.tlsFailure + default: + throw AppError.networkFailure } - } catch { - throw SpeechProviderErrorMapper.network(error) } } @@ -118,81 +132,3 @@ struct OpenAICompatibleSpeechProvider: SpeechProviding { return .success } } - -enum SpeechProviderErrorMapper { - static func http(status: Int, body: Data) -> AppError { - let message = responseMessage(from: body).lowercased() - if identifiesMissingModel(message) { - return .modelNotFound - } - switch status { - case 401: - return .authenticationFailed - case 402: - return .insufficientCredits - case 403: - if message.contains("terms") - || message.contains("provider") - || message.contains("permission") - || message.contains("access") { - return .providerRejected - } - return .authenticationFailed - case 404: - return .endpointNotFound - case 429: - return .rateLimited - case 500..<600: - return .serviceUnavailable - default: - return .incompatibleRequest - } - } - - static func network(_ error: Error) -> Error { - if error is CancellationError { - return CancellationError() - } - guard let urlError = error as? URLError else { return error } - switch urlError.code { - case .cancelled: - return CancellationError() - case .timedOut: - return AppError.timedOut - case .secureConnectionFailed, .serverCertificateHasBadDate, - .serverCertificateUntrusted, .serverCertificateHasUnknownRoot, - .serverCertificateNotYetValid, .clientCertificateRejected, - .clientCertificateRequired: - return AppError.tlsFailure - default: - return AppError.networkFailure - } - } - - private static func responseMessage(from body: Data) -> String { - guard !body.isEmpty, - let object = try? JSONSerialization.jsonObject(with: body) else { - return "" - } - if let dictionary = object as? [String: Any] { - if let error = dictionary["error"] as? [String: Any] { - return [error["message"], error["code"], error["type"]] - .compactMap { $0 as? String } - .joined(separator: " ") - } - return [dictionary["message"], dictionary["code"]] - .compactMap { $0 as? String } - .joined(separator: " ") - } - return "" - } - - private static func identifiesMissingModel(_ message: String) -> Bool { - guard message.contains("model") else { return false } - return message.contains("not found") - || message.contains("does not exist") - || message.contains("unknown") - || message.contains("invalid") - || message.contains("unavailable") - } -} diff --git a/TextFlow/Features/Speech/OpenRouterAudioSpeechProvider.swift b/TextFlow/Features/Speech/OpenRouterAudioSpeechProvider.swift deleted file mode 100644 index 148b20c..0000000 --- a/TextFlow/Features/Speech/OpenRouterAudioSpeechProvider.swift +++ /dev/null @@ -1,308 +0,0 @@ -import Foundation - -struct OpenRouterAudioSpeechProvider: SpeechProviding { - let configuration: ResolvedSpeechConfiguration - private let session: URLSession - private let responseLimit: Int - - init( - configuration: ResolvedSpeechConfiguration, - session: URLSession? = nil, - responseLimit: Int = SpeechResourceLimits.maximumResponseBytes - ) { - self.configuration = configuration - self.responseLimit = responseLimit - self.session = session ?? Self.makeSession( - timeout: configuration.profile.timeoutSeconds - ) - } - - func synthesize( - _ request: SpeechRequest, - maximumBytes: Int - ) async throws -> SpeechAudio { - let effectiveLimit = min(responseLimit, maximumBytes) - guard effectiveLimit > 0 else { - throw AppError.responseTooLarge - } - - do { - let urlRequest = try makeURLRequest(request) - let (bytes, response) = try await session.bytes(for: urlRequest) - try Task.checkCancellation() - guard let response = response as? HTTPURLResponse else { - throw AppError.invalidResponse - } - guard (200..<300).contains(response.statusCode) else { - let body = try await collect(bytes, limit: 64 * 1024) - throw SpeechProviderErrorMapper.http( - status: response.statusCode, - body: body - ) - } - - let contentType = response.value( - forHTTPHeaderField: "Content-Type" - )?.lowercased() ?? "" - guard contentType.contains("text/event-stream") else { - throw AppError.incompatibleRequest - } - - let audioData = try await consumeSSE( - bytes, - maximumBytes: effectiveLimit - ) - return SpeechAudio( - data: audioData, - format: configuration.profile.outputFormat - ) - } catch { - throw SpeechProviderErrorMapper.network(error) - } - } - - func testConnection() async throws -> ProviderTestResult { - _ = try await synthesize( - SpeechRequest(text: "Test", language: .english) - ) - return .success - } - - private static func makeSession(timeout: TimeInterval) -> URLSession { - let configuration = URLSessionConfiguration.ephemeral - configuration.timeoutIntervalForRequest = timeout - configuration.timeoutIntervalForResource = timeout - configuration.requestCachePolicy = .reloadIgnoringLocalAndRemoteCacheData - configuration.urlCache = nil - configuration.httpCookieStorage = nil - configuration.httpShouldSetCookies = false - return URLSession(configuration: configuration) - } - - private func makeURLRequest(_ request: SpeechRequest) throws -> URLRequest { - let model = configuration.profile.model.trimmingCharacters( - in: .whitespacesAndNewlines - ) - guard !model.isEmpty else { - throw AppError.modelNotFound - } - let voice: String - switch request.language { - case .chinese: - voice = configuration.profile.chineseVoice - case .english: - voice = configuration.profile.englishVoice - } - guard !voice.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { - throw AppError.incompatibleRequest - } - - let baseInstruction = """ - You are a text-to-speech engine. Read the user's text aloud exactly as written. Do not add, omit, translate, answer, or explain anything. - """ - let customInstruction = configuration.profile.instructions.trimmingCharacters( - in: .whitespacesAndNewlines - ) - let systemInstruction = customInstruction.isEmpty - ? baseInstruction - : "\(baseInstruction)\n\(customInstruction)" - let body: [String: Any] = [ - "model": model, - "messages": [ - ["role": "system", "content": systemInstruction], - ["role": "user", "content": request.text] - ], - "modalities": ["text", "audio"], - "audio": [ - "voice": voice, - "format": openRouterFormat - ], - "stream": true - ] - - var urlRequest = URLRequest(url: configuration.endpoint) - urlRequest.httpMethod = "POST" - urlRequest.timeoutInterval = configuration.profile.timeoutSeconds - urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type") - urlRequest.setValue("text/event-stream", forHTTPHeaderField: "Accept") - for (name, value) in configuration.customHeaders { - urlRequest.setValue(value, forHTTPHeaderField: name) - } - switch configuration.profile.authMode { - case .bearer: - guard let apiKey = configuration.apiKey, !apiKey.isEmpty else { - throw AppError.authenticationFailed - } - urlRequest.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") - case .customHeadersOnly, .none: - break - } - urlRequest.httpBody = try JSONSerialization.data(withJSONObject: body) - return urlRequest - } - - private var openRouterFormat: String { - switch configuration.profile.outputFormat { - case .pcm: - "pcm16" - default: - configuration.profile.outputFormat.rawValue - } - } - - private func consumeSSE( - _ bytes: URLSession.AsyncBytes, - maximumBytes: Int - ) async throws -> Data { - let maximumEncodedBytes = ((maximumBytes + 2) / 3) * 4 - let maximumWireOverhead = max( - 2 * 1024 * 1024, - maximumEncodedBytes / 4 - ) - let maximumWireBytes = maximumEncodedBytes + maximumWireOverhead - var parser = SSEParser(maximumSize: 2 * 1024 * 1024) - var decoder = IncrementalBase64AudioDecoder( - maximumBytes: maximumBytes - ) - var chunk = Data() - var wireBytes = 0 - - for try await byte in bytes { - try Task.checkCancellation() - wireBytes += 1 - guard wireBytes <= maximumWireBytes else { - throw AppError.responseTooLarge - } - chunk.append(byte) - if chunk.count >= 1024 { - try decode(parser.feed(chunk), into: &decoder) - chunk.removeAll(keepingCapacity: true) - } - } - if !chunk.isEmpty { - try decode(parser.feed(chunk), into: &decoder) - } - try decode(parser.finish(), into: &decoder) - return try decoder.finish() - } - - private func decode( - _ messages: [SSEMessage], - into decoder: inout IncrementalBase64AudioDecoder - ) throws { - for message in messages where !message.isDone { - let data = Data(message.data.utf8) - guard let object = try? JSONSerialization.jsonObject(with: data), - let dictionary = object as? [String: Any] else { - throw AppError.invalidResponse - } - if let error = dictionary["error"] as? [String: Any] { - let status = error["code"] as? Int ?? 400 - throw SpeechProviderErrorMapper.http(status: status, body: data) - } - guard let choices = dictionary["choices"] as? [[String: Any]] else { - continue - } - for choice in choices { - guard let delta = choice["delta"] as? [String: Any], - let audio = delta["audio"] as? [String: Any], - let fragment = audio["data"] as? String, - !fragment.isEmpty else { - continue - } - try decoder.append(fragment) - } - } - } - - private func collect( - _ bytes: URLSession.AsyncBytes, - limit: Int - ) async throws -> Data { - var data = Data() - for try await byte in bytes { - try Task.checkCancellation() - guard data.count < limit else { - throw AppError.responseTooLarge - } - data.append(byte) - } - return data - } -} - -private struct IncrementalBase64AudioDecoder { - private var pending = Data() - private var output = Data() - private let maximumBytes: Int - - init(maximumBytes: Int) { - self.maximumBytes = maximumBytes - output.reserveCapacity(min(maximumBytes, 1024 * 1024)) - } - - mutating func append(_ fragment: String) throws { - let encoded = Data(fragment.utf8) - guard encoded.allSatisfy(Self.isBase64Byte) else { - throw AppError.invalidResponse - } - pending.append(encoded) - - if pending.contains(61) { - guard pending.count.isMultiple(of: 4), - let decoded = Data(base64Encoded: pending) else { - throw AppError.invalidResponse - } - try appendDecoded(decoded) - pending.removeAll(keepingCapacity: true) - return - } - - let decodeCount: Int - decodeCount = (pending.count / 4) * 4 - guard decodeCount > 0 else { return } - try decodePrefix(count: decodeCount) - } - - mutating func finish() throws -> Data { - guard !pending.isEmpty || !output.isEmpty else { - throw AppError.invalidResponse - } - if !pending.isEmpty { - guard pending.count.isMultiple(of: 4), - let decoded = Data(base64Encoded: pending) else { - throw AppError.invalidResponse - } - try appendDecoded(decoded) - pending.removeAll(keepingCapacity: false) - } - guard !output.isEmpty else { - throw AppError.invalidResponse - } - return output - } - - private mutating func decodePrefix(count: Int) throws { - guard let decoded = Data(base64Encoded: pending.prefix(count)) else { - throw AppError.invalidResponse - } - try appendDecoded(decoded) - pending.removeFirst(count) - } - - private mutating func appendDecoded(_ data: Data) throws { - guard data.count <= maximumBytes - output.count else { - throw AppError.responseTooLarge - } - output.append(data) - } - - private static func isBase64Byte(_ byte: UInt8) -> Bool { - (byte >= 65 && byte <= 90) - || (byte >= 97 && byte <= 122) - || (byte >= 48 && byte <= 57) - || byte == 43 - || byte == 47 - || byte == 61 - } -} diff --git a/TextFlow/Features/Speech/SpeechService.swift b/TextFlow/Features/Speech/SpeechService.swift index a09846c..91034a5 100644 --- a/TextFlow/Features/Speech/SpeechService.swift +++ b/TextFlow/Features/Speech/SpeechService.swift @@ -18,7 +18,7 @@ final class SpeechService { throw AppError.speechConfigurationMissing } let resolved = try await resolve(profile) - let provider = makeProvider(configuration: resolved) + let provider = OpenAICompatibleSpeechProvider(configuration: resolved) let chunks = try await CancellableDetachedTask.run(priority: .userInitiated) { try Task.checkCancellation() let chunks = TextChunker.chunks(text) @@ -59,18 +59,9 @@ final class SpeechService { func testConnection(profile: SpeechProfile) async throws -> ProviderTestResult { let resolved = try await resolve(profile) - return try await makeProvider(configuration: resolved).testConnection() - } - - private func makeProvider( - configuration: ResolvedSpeechConfiguration - ) -> any SpeechProviding { - switch configuration.profile.protocolKind { - case .openAISpeech: - OpenAICompatibleSpeechProvider(configuration: configuration) - case .openRouterAudioChat: - OpenRouterAudioSpeechProvider(configuration: configuration) - } + return try await OpenAICompatibleSpeechProvider( + configuration: resolved + ).testConnection() } private func resolve( diff --git a/TextFlowTests/OpenRouterAudioSpeechProviderTests.swift b/TextFlowTests/OpenRouterAudioSpeechProviderTests.swift deleted file mode 100644 index 7a05422..0000000 --- a/TextFlowTests/OpenRouterAudioSpeechProviderTests.swift +++ /dev/null @@ -1,299 +0,0 @@ -import Foundation -import XCTest -@testable import TextFlow - -final class OpenRouterAudioSpeechProviderTests: XCTestCase { - override func tearDown() { - MockURLProtocol.handler = nil - super.tearDown() - } - - func testBuildsAudioChatRequestAndDecodesFragmentedSSEAudio() async throws { - MockURLProtocol.handler = { request in - XCTAssertEqual( - request.url?.absoluteString, - "https://openrouter.mock/api/v1/chat/completions" - ) - XCTAssertEqual( - request.value(forHTTPHeaderField: "Authorization"), - "Bearer openrouter-secret" - ) - XCTAssertEqual( - request.value(forHTTPHeaderField: "X-Title"), - "TextFlow Test" - ) - XCTAssertEqual( - request.value(forHTTPHeaderField: "Accept"), - "text/event-stream" - ) - - let data = try Self.bodyData(from: request) - let body = try XCTUnwrap( - JSONSerialization.jsonObject(with: data) as? [String: Any] - ) - XCTAssertEqual(body["model"] as? String, "openai/gpt-audio-mini") - XCTAssertEqual(body["stream"] as? Bool, true) - XCTAssertEqual(body["modalities"] as? [String], ["text", "audio"]) - let audio = try XCTUnwrap(body["audio"] as? [String: Any]) - XCTAssertEqual(audio["voice"] as? String, "alloy") - XCTAssertEqual(audio["format"] as? String, "wav") - let messages = try XCTUnwrap(body["messages"] as? [[String: String]]) - XCTAssertEqual(messages.count, 2) - XCTAssertEqual(messages[0]["role"], "system") - XCTAssertTrue(messages[0]["content"]?.contains("exactly as written") == true) - XCTAssertTrue(messages[0]["content"]?.contains("Speak slowly") == true) - XCTAssertEqual(messages[1], ["role": "user", "content": "Hello!"]) - - let event1 = "data: {\"choices\":[{\"delta\":{\"audio\":{\"data\":\"AQI\",\"transcript\":\"Hel\"}}}]}\n\n" - let event2 = "data: {\"choices\":[{\"delta\":{\"audio\":{\"data\":\"DBAU=\",\"transcript\":\"lo\"}}}]}\n\ndata: [DONE]\n\n" - let response = Data((event1 + event2).utf8) - return .sse([ - response.prefix(17), - response.dropFirst(17).prefix(23), - response.dropFirst(40) - ].map { Data($0) }) - } - let provider = OpenRouterAudioSpeechProvider( - configuration: try configuration(), - session: makeMockSession() - ) - - let result = try await provider.synthesize( - SpeechRequest(text: "Hello!", language: .english) - ) - - XCTAssertEqual(result.data, Data([1, 2, 3, 4, 5])) - XCTAssertEqual(result.format, .wav) - } - - func testMapsPCMToOpenRouterPCM16() async throws { - MockURLProtocol.handler = { request in - let data = try Self.bodyData(from: request) - let body = try XCTUnwrap( - JSONSerialization.jsonObject(with: data) as? [String: Any] - ) - let audio = try XCTUnwrap(body["audio"] as? [String: Any]) - XCTAssertEqual(audio["format"] as? String, "pcm16") - return .sse([ - Data("data: {\"choices\":[{\"delta\":{\"audio\":{\"data\":\"AQID\"}}}]}\n\ndata: [DONE]\n\n".utf8) - ]) - } - let pcmProfile = profile(format: .pcm) - let provider = OpenRouterAudioSpeechProvider( - configuration: ResolvedSpeechConfiguration( - profile: pcmProfile, - endpoint: try URLBuilder.speechEndpoint(for: pcmProfile), - apiKey: "openrouter-secret", - customHeaders: [:] - ), - session: makeMockSession() - ) - - let result = try await provider.synthesize( - SpeechRequest(text: "PCM", language: .english) - ) - - XCTAssertEqual(result.data, Data([1, 2, 3])) - XCTAssertEqual(result.format, .pcm) - } - - func testDecodesIndependentlyPaddedAudioChunks() async throws { - MockURLProtocol.handler = { _ in - .sse([ - Data("data: {\"choices\":[{\"delta\":{\"audio\":{\"data\":\"AQI=\"}}}]}\n\n".utf8), - Data("data: {\"choices\":[{\"delta\":{\"audio\":{\"data\":\"AwQ=\"}}}]}\n\ndata: [DONE]\n\n".utf8) - ]) - } - let provider = OpenRouterAudioSpeechProvider( - configuration: try configuration(), - session: makeMockSession() - ) - - let result = try await provider.synthesize( - SpeechRequest(text: "Chunks", language: .english) - ) - - XCTAssertEqual(result.data, Data([1, 2, 3, 4])) - } - - func testRejectsDecodedAudioOverOperationBudget() async throws { - MockURLProtocol.handler = { _ in - .sse([ - Data("data: {\"choices\":[{\"delta\":{\"audio\":{\"data\":\"AQIDBAU=\"}}}]}\n\ndata: [DONE]\n\n".utf8) - ]) - } - let provider = OpenRouterAudioSpeechProvider( - configuration: try configuration(), - session: makeMockSession() - ) - - do { - _ = try await provider.synthesize( - SpeechRequest(text: "Too large", language: .english), - maximumBytes: 4 - ) - XCTFail("Expected oversized audio to fail") - } catch { - XCTAssertEqual(error as? AppError, .responseTooLarge) - } - } - - func testRejectsMalformedOrMissingAudio() async throws { - let payloads = [ - "data: {\"choices\":[{\"delta\":{\"audio\":{\"data\":\"%%%\"}}}]}\n\ndata: [DONE]\n\n", - "data: {\"choices\":[{\"delta\":{\"content\":\"text only\"}}]}\n\ndata: [DONE]\n\n" - ] - for payload in payloads { - MockURLProtocol.handler = { _ in .sse([Data(payload.utf8)]) } - let provider = OpenRouterAudioSpeechProvider( - configuration: try configuration(), - session: makeMockSession() - ) - do { - _ = try await provider.synthesize( - SpeechRequest(text: "Invalid", language: .english) - ) - XCTFail("Expected invalid stream to fail") - } catch { - XCTAssertEqual(error as? AppError, .invalidResponse) - } - } - } - - func testMapsOpenRouterHTTPFailures() async throws { - let cases: [(Int, String, AppError)] = [ - (400, "Model openai/missing does not exist", .modelNotFound), - (402, "Insufficient credits", .insufficientCredits), - (403, "Provider terms must be accepted", .providerRejected), - (429, "Rate limited", .rateLimited), - (503, "Unavailable", .serviceUnavailable) - ] - for (status, message, expected) in cases { - MockURLProtocol.handler = { _ in - .init( - status: status, - headers: ["Content-Type": "application/json"], - chunks: [Data("{\"error\":{\"message\":\"\(message)\"}}".utf8)], - error: nil - ) - } - let provider = OpenRouterAudioSpeechProvider( - configuration: try configuration(), - session: makeMockSession() - ) - do { - _ = try await provider.synthesize( - SpeechRequest(text: "Failure", language: .english) - ) - XCTFail("Expected HTTP failure") - } catch { - XCTAssertEqual(error as? AppError, expected) - } - } - } - - func testRejectsNonStreamingSuccessResponse() async throws { - MockURLProtocol.handler = { _ in - .json(Data("{\"choices\":[]}".utf8)) - } - let provider = OpenRouterAudioSpeechProvider( - configuration: try configuration(), - session: makeMockSession() - ) - - do { - _ = try await provider.synthesize( - SpeechRequest(text: "No stream", language: .english) - ) - XCTFail("Expected non-SSE response to fail") - } catch { - XCTAssertEqual(error as? AppError, .incompatibleRequest) - } - } - - func testMapsTimeoutAndCancellation() async throws { - let cases: [(URLError.Code, AppError?)] = [ - (.timedOut, .timedOut), - (.cancelled, nil) - ] - for (code, expected) in cases { - MockURLProtocol.handler = { _ in - .init( - status: 200, - headers: ["Content-Type": "text/event-stream"], - chunks: [], - error: URLError(code) - ) - } - let provider = OpenRouterAudioSpeechProvider( - configuration: try configuration(), - session: makeMockSession() - ) - do { - _ = try await provider.synthesize( - SpeechRequest(text: "Network", language: .english) - ) - XCTFail("Expected network failure") - } catch { - if let expected { - XCTAssertEqual(error as? AppError, expected) - } else { - XCTAssertTrue(error is CancellationError) - } - } - } - } - - private func configuration() throws -> ResolvedSpeechConfiguration { - let profile = profile(format: .wav) - return ResolvedSpeechConfiguration( - profile: profile, - endpoint: try URLBuilder.speechEndpoint(for: profile), - apiKey: "openrouter-secret", - customHeaders: ["X-Title": "TextFlow Test"] - ) - } - - private func profile(format: SpeechOutputFormat) -> SpeechProfile { - var profile = SpeechProfile.empty() - profile.protocolKind = .openRouterAudioChat - profile.endpointMode = .fullEndpoint - profile.fullEndpoint = "https://openrouter.mock/api/v1/chat/completions" - profile.model = "openai/gpt-audio-mini" - profile.chineseVoice = "alloy" - profile.englishVoice = "alloy" - profile.outputFormat = format - profile.instructions = "Speak slowly." - return profile - } - - private func makeMockSession() -> URLSession { - let configuration = URLSessionConfiguration.ephemeral - configuration.protocolClasses = [MockURLProtocol.self] - configuration.urlCache = nil - configuration.httpCookieStorage = nil - return URLSession(configuration: configuration) - } - - private static func bodyData(from request: URLRequest) throws -> Data { - if let body = request.httpBody { - return body - } - let stream = try XCTUnwrap(request.httpBodyStream) - stream.open() - defer { stream.close() } - var result = Data() - var buffer = [UInt8](repeating: 0, count: 1024) - while stream.hasBytesAvailable { - let count = stream.read(&buffer, maxLength: buffer.count) - if count < 0 { - throw try XCTUnwrap(stream.streamError) - } - if count == 0 { - break - } - result.append(buffer, count: count) - } - return result - } -} diff --git a/TextFlowTests/SpeechProfileSecurityTests.swift b/TextFlowTests/SpeechProfileSecurityTests.swift index 18a7e3f..54c665a 100644 --- a/TextFlowTests/SpeechProfileSecurityTests.swift +++ b/TextFlowTests/SpeechProfileSecurityTests.swift @@ -16,24 +16,4 @@ final class SpeechProfileSecurityTests: XCTestCase { XCTAssertFalse(json.lowercased().contains("apikey")) XCTAssertFalse(json.contains("speech-secret")) } - - func testLegacyProfileWithoutProtocolMigratesToOpenAISpeech() throws { - var object = try XCTUnwrap( - JSONSerialization.jsonObject( - with: JSONEncoder().encode(SpeechProfile.empty()) - ) as? [String: Any] - ) - object.removeValue(forKey: "protocolKind") - let legacyData = try JSONSerialization.data(withJSONObject: object) - - let decoded = try JSONDecoder().decode(SpeechProfile.self, from: legacyData) - - XCTAssertEqual(decoded.protocolKind, .openAISpeech) - let migratedObject = try XCTUnwrap( - JSONSerialization.jsonObject( - with: JSONEncoder().encode(decoded) - ) as? [String: Any] - ) - XCTAssertEqual(migratedObject["protocolKind"] as? String, "openAISpeech") - } } From b258b9b2f857627752df188c4906646e14563313 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20=E8=80=81=E7=81=AF?= Date: Sun, 2 Aug 2026 01:58:15 +0800 Subject: [PATCH 03/12] Add Alibaba Qwen HTTP speech support --- 01_PRD_v0.1.md | 5 +- 02_TECHNICAL_DESIGN.md | 37 ++- 03_ACCEPTANCE_TESTS.md | 15 + README.md | 4 +- README.zh-CN.md | 4 +- TextFlow.xcodeproj/project.pbxproj | 8 + TextFlow/Core/Models/AppError.swift | 3 + TextFlow/Core/Models/SpeechProfile.swift | 108 ++++++ .../Settings/SpeechProfilesView.swift | 166 ++++++++-- .../Speech/DashScopeQwenSpeechProvider.swift | 293 +++++++++++++++++ .../Speech/SpeechResourceLimits.swift | 1 + TextFlow/Features/Speech/SpeechService.swift | 17 +- .../DashScopeQwenSpeechProviderTests.swift | 311 ++++++++++++++++++ .../SpeechProfileSecurityTests.swift | 28 ++ 14 files changed, 967 insertions(+), 33 deletions(-) create mode 100644 TextFlow/Features/Speech/DashScopeQwenSpeechProvider.swift create mode 100644 TextFlowTests/DashScopeQwenSpeechProviderTests.swift diff --git a/01_PRD_v0.1.md b/01_PRD_v0.1.md index 9d702a3..227e034 100644 --- a/01_PRD_v0.1.md +++ b/01_PRD_v0.1.md @@ -204,8 +204,11 @@ v0.1 仅服务单一用户,运行在一台 M2 MacBook 上,不需要账号、 - 中文和英文分别保存一个默认 Voice。 - 使用独立的 TTS 配置,不与翻译模型绑定。 -- 首选 OpenAI-compatible `/v1/audio/speech`。 +- 支持 OpenAI-compatible `/v1/audio/speech`。 +- 支持阿里云百炼 Qwen3-TTS 原生 HTTP 协议,包括 + `qwen3-tts-flash` 与 `qwen3-tts-instruct-flash`。 - 支持自定义 URL、API Key、模型、Voice、请求头、输出格式。 +- 百炼 Qwen3-TTS 的生成响应与临时音频下载分两步完成;下载不转发鉴权信息。 - 支持生成、播放、暂停、继续、停止、重新播放。 - 点击另一个朗读按钮时停止当前音频并播放新任务。 - API 不可用时可选择 macOS 本地系统语音兜底。 diff --git a/02_TECHNICAL_DESIGN.md b/02_TECHNICAL_DESIGN.md index 28516ca..6a03601 100644 --- a/02_TECHNICAL_DESIGN.md +++ b/02_TECHNICAL_DESIGN.md @@ -433,7 +433,38 @@ protocol SpeechProviding: Sendable { 第三方服务如果遵循 OpenAI Speech schema 可直接使用。若协议不同,新增独立 Provider,不在 v0.1 里加入任意 JSON 模板编辑器。 -### 9.3 文本长度 +### 9.3 阿里云百炼 Qwen3-TTS HTTP + +使用独立 `DashScopeQwenSpeechProvider`,不把百炼协议伪装成 +OpenAI `/v1/audio/speech`。 + +生成 Endpoint: + +`/api/v1/services/aigc/multimodal-generation/generation` + +请求概念: + +```json +{ + "model": "qwen3-tts-instruct-flash", + "input": { + "text": "需要朗读的文本", + "voice": "Cherry", + "language_type": "Chinese", + "instructions": "缓慢、清晰地朗读" + } +} +``` + +- `qwen3-tts-flash` 不发送 `instructions` +- `qwen3-tts-instruct-flash` 可发送 `instructions` +- 非流式响应返回临时 OSS 音频 URL,Provider 随即下载到内存 +- 只接受阿里云 OSS `aliyuncs.com` 域名,HTTP URL 升级为 HTTPS +- 音频下载请求不得携带 API Key 或生成请求的自定义 Header +- JSON 元数据与音频分别限制响应大小 +- 生成和下载均支持任务取消 + +### 9.4 文本长度 实现 `TextChunker`: @@ -443,7 +474,7 @@ protocol SpeechProviding: Sendable { - 所有块生成完成后依次播放 - 任一块失败时停止并显示具体块序号 -### 9.4 播放 +### 9.5 播放 `AudioPlaybackController` 使用 AVFoundation: @@ -453,7 +484,7 @@ protocol SpeechProviding: Sendable { - 任务取消时释放 Data - 同一时刻只允许一个播放会话 -### 9.5 本地兜底 +### 9.6 本地兜底 `SystemSpeechProvider` 使用 macOS 系统语音: diff --git a/03_ACCEPTANCE_TESTS.md b/03_ACCEPTANCE_TESTS.md index 9eee582..da7f74f 100644 --- a/03_ACCEPTANCE_TESTS.md +++ b/03_ACCEPTANCE_TESTS.md @@ -143,6 +143,21 @@ 期望:自定义 URL、模型、Voice、Key、Header 生效,音频成功播放。 +### P0-14A 百炼 Qwen3-TTS HTTP + +步骤:选择“阿里云百炼 Qwen HTTP”,配置北京区工作空间域名、API Key、 +`qwen3-tts-flash` 或 `qwen3-tts-instruct-flash`,分别朗读中英文。 + +期望: + +- 使用百炼原生 HTTP 请求结构,而不是 `/v1/audio/speech` +- 中文和英文分别发送正确 Voice 与 `language_type` +- 仅 Instruct 模型发送 Instructions +- 临时 OSS 音频地址升级为 HTTPS 后下载并播放 +- 下载请求不携带 API Key 或自定义鉴权 Header +- 非阿里云 OSS 音频地址被拒绝 +- 相同文本、语言和配置再次朗读时复用内存缓存,不重复计费请求 + ### P0-15 TTS 本地兜底 步骤:断开网络或故意配置错误,点击朗读。 diff --git a/README.md b/README.md index 451524e..d625c02 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ Both paths open one floating panel where the source stays editable and the sourc - **Two shortcuts, one flow.** No main window to find and no context switching. - **Local OCR.** Screenshots stay in memory and text recognition runs through Apple Vision. - **Bring your own model.** Configure custom URLs, models, headers, timeouts, streaming, OpenAI Responses, or Chat Completions. -- **Flexible speech.** Use an OpenAI-compatible speech endpoint or explicitly fall back to macOS system voices. +- **Flexible speech.** Use an OpenAI-compatible speech endpoint, Alibaba Cloud Model Studio Qwen3-TTS over native HTTP, or explicitly fall back to macOS system voices. - **Privacy by design.** Keys live in Keychain; logs are redacted; there is no history, account, telemetry, or cloud sync. - **Native and lean.** Swift 6, SwiftUI + AppKit, Apple frameworks, and zero third-party runtime dependencies. - **Cancellation-safe.** A new capture replaces the previous session and cancels in-flight OCR, network, and audio work. @@ -117,7 +117,7 @@ Translation profiles support: - Automatic, enabled, or disabled streaming - Streaming SSE and non-streaming JSON responses -Speech profiles support OpenAI-compatible `/v1/audio/speech` endpoints with custom URL, model, voice, headers, and output format. “OpenAI-compatible” implementations vary; please open a compatibility report when a provider needs a dedicated adapter. +Speech profiles support OpenAI-compatible `/v1/audio/speech` endpoints with custom URL, model, voice, headers, and output format. A dedicated Alibaba Cloud Model Studio HTTP adapter supports `qwen3-tts-flash` and `qwen3-tts-instruct-flash`; it downloads the short-lived audio result over HTTPS without forwarding API credentials. Other “OpenAI-compatible” implementations still vary, so please open a compatibility report when a provider needs a dedicated adapter. Never place a real API key in source files, Markdown, test fixtures, `.env` files committed to Git, or issue screenshots. diff --git a/README.zh-CN.md b/README.zh-CN.md index 956a488..487038d 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -38,7 +38,7 @@ - **两个快捷键,一条工作流。** 不需要先找到主窗口,也不打断当前工作。 - **OCR 完全本地。** 截图只驻留内存,识别使用 Apple Vision。 - **自带模型。** 支持自定义 URL、模型、Header、超时、流式模式、Responses 和 Chat Completions。 -- **灵活朗读。** 可使用 OpenAI-compatible Speech 接口,也可由用户明确切换到 macOS 系统语音。 +- **灵活朗读。** 可使用 OpenAI-compatible Speech 接口、阿里云百炼 Qwen3-TTS 原生 HTTP,也可由用户明确切换到 macOS 系统语音。 - **隐私优先。** 密钥进入 Keychain;日志脱敏;不保存历史;没有账号、遥测或云同步。 - **原生且克制。** Swift 6、SwiftUI + AppKit、Apple 原生框架、零第三方运行时依赖。 - **完整取消链路。** 新任务会替换旧会话并取消进行中的 OCR、网络和音频任务。 @@ -117,7 +117,7 @@ brew install xcodegen - 自动、开启或关闭流式模式 - SSE 流式响应和非流式 JSON 响应 -语音配置支持 OpenAI-compatible `/v1/audio/speech`,可自定义 URL、模型、Voice、Header 和输出格式。不同厂商对“OpenAI 兼容”的实现并不完全一致;如需专门适配器,请提交兼容性报告。 +语音配置支持 OpenAI-compatible `/v1/audio/speech`,可自定义 URL、模型、Voice、Header 和输出格式。专用的阿里云百炼 HTTP 适配器支持 `qwen3-tts-flash` 和 `qwen3-tts-instruct-flash`,会通过 HTTPS 下载短期音频结果且不转发 API 凭据。其他厂商对“OpenAI 兼容”的实现仍可能不同;如需专门适配器,请提交兼容性报告。 不要把真实 API Key 放入源码、Markdown、测试 fixture、Git 中的 `.env` 文件或 Issue 截图。 diff --git a/TextFlow.xcodeproj/project.pbxproj b/TextFlow.xcodeproj/project.pbxproj index 1488a69..3f2ba59 100644 --- a/TextFlow.xcodeproj/project.pbxproj +++ b/TextFlow.xcodeproj/project.pbxproj @@ -33,6 +33,7 @@ 3DBF8B3AFA47CF2A2C62A167 /* LimitedURLSessionDataLoaderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ED1D4D6A9B3DB138738A08A /* LimitedURLSessionDataLoaderTests.swift */; }; 3F75409BB2EDAD0337C67A5D /* PanelPositioner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 90B72CEE4A9478AF1C512827 /* PanelPositioner.swift */; }; 3FAE646B0A5880E41DC2060B /* PromptBuilderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E9B8F0C3CD29812B3C90E2B /* PromptBuilderTests.swift */; }; + 418447EF716BE81B74A85222 /* DashScopeQwenSpeechProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = D72CCFADB4255CA05FF0B02D /* DashScopeQwenSpeechProvider.swift */; }; 4210B3285656CBEF2EC8A2DE /* TranslationSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF1638DF26C44F95AB66C8D4 /* TranslationSession.swift */; }; 4A062368AFA486E634B54C3F /* TranslationProviderIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 116EB6653009DB064972AC48 /* TranslationProviderIntegrationTests.swift */; }; 4F5DDF73FAF7DA51C2708F05 /* PermissionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1518E896853A11585CD9004 /* PermissionService.swift */; }; @@ -48,6 +49,7 @@ 5DD2E08F7526B16D71E926F6 /* TranslationProfilesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F8B836D1954A3F53C1353CB /* TranslationProfilesView.swift */; }; 62564587D08DA372F1D40E6A /* ScreenCaptureKitService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2978AB10D41EA87019E0AC7E /* ScreenCaptureKitService.swift */; }; 67D9AB61B6D1F9069546A6A5 /* SpeechProfilesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 44AB94AA6BE692A34699339F /* SpeechProfilesView.swift */; }; + 6A91BB8A908A5D6426382F94 /* DashScopeQwenSpeechProviderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53F40339B96CE0A5309178A4 /* DashScopeQwenSpeechProviderTests.swift */; }; 704909DAE43ABF48CF5766E1 /* SelectionOverlayWindowTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71213522042416AADC094862 /* SelectionOverlayWindowTests.swift */; }; 71ACB87E5408AD35D9831B04 /* OCRProviding.swift in Sources */ = {isa = PBXBuildFile; fileRef = F31D4C89797DB7966F40EED7 /* OCRProviding.swift */; }; 7B52C5847ACADB4124618143 /* CoordinateConverter.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB359641EA2C692F1B181B96 /* CoordinateConverter.swift */; }; @@ -135,6 +137,7 @@ 4CE842B9AF66AD6513D3360A /* SpeechService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpeechService.swift; sourceTree = ""; }; 4D46EFC4C3D58904430879AA /* KeychainStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainStoreTests.swift; sourceTree = ""; }; 4DDB218915206C7B8E62FDCA /* ProviderDecoderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProviderDecoderTests.swift; sourceTree = ""; }; + 53F40339B96CE0A5309178A4 /* DashScopeQwenSpeechProviderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashScopeQwenSpeechProviderTests.swift; sourceTree = ""; }; 56292085A3560797C57EDCD7 /* SpeechAudioCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpeechAudioCache.swift; sourceTree = ""; }; 5A09FD015BC2128A8F6898D6 /* TextFlow.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = TextFlow.entitlements; sourceTree = ""; }; 5C5D2984789817E64FCE6762 /* SpeechByteBudgetTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpeechByteBudgetTests.swift; sourceTree = ""; }; @@ -177,6 +180,7 @@ CC830888EB9D0437618BD64D /* AppCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppCoordinator.swift; sourceTree = ""; }; D024FC355DE6A4C7224F13B9 /* SSEParserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SSEParserTests.swift; sourceTree = ""; }; D1722C289DE6D3554A9EE02D /* TranslationPanelController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranslationPanelController.swift; sourceTree = ""; }; + D72CCFADB4255CA05FF0B02D /* DashScopeQwenSpeechProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashScopeQwenSpeechProvider.swift; sourceTree = ""; }; DA523A622ABA147EDEF4A7D0 /* ProfileSecretSessionState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileSecretSessionState.swift; sourceTree = ""; }; DFEDEA3F89FF215DFC8332DA /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; E8325B357D5CC714C05EDDB1 /* ProviderResponseDecoder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProviderResponseDecoder.swift; sourceTree = ""; }; @@ -202,6 +206,7 @@ isa = PBXGroup; children = ( 98ABD06BDE8DA356EBFF196E /* AudioPlaybackController.swift */, + D72CCFADB4255CA05FF0B02D /* DashScopeQwenSpeechProvider.swift */, 6298768B299979CF0F646982 /* LimitedURLSessionDataLoader.swift */, 92D0E094A543862D2C1D7174 /* OpenAICompatibleSpeechProvider.swift */, 56292085A3560797C57EDCD7 /* SpeechAudioCache.swift */, @@ -349,6 +354,7 @@ B9E5F309D497A68743CB5B92 /* ClipboardFallbackReaderTests.swift */, FE252A2394B5C38F0D609D06 /* ClipboardSnapshotTests.swift */, 4328784D054E275DC43C1FE9 /* CoordinateConverterTests.swift */, + 53F40339B96CE0A5309178A4 /* DashScopeQwenSpeechProviderTests.swift */, F23FD27D74B89C872BFDB080 /* HotKeyShortcutTests.swift */, 4D46EFC4C3D58904430879AA /* KeychainStoreTests.swift */, 3B5CE66FDC6829555EA09394 /* LanguageDetectionTests.swift */, @@ -521,6 +527,7 @@ 50E1618B2D24260DD5C67924 /* ClipboardFallbackReaderTests.swift in Sources */, CD22916D7BAD43BB172DB6E8 /* ClipboardSnapshotTests.swift in Sources */, B730F886C2A2B573AC5A5E61 /* CoordinateConverterTests.swift in Sources */, + 6A91BB8A908A5D6426382F94 /* DashScopeQwenSpeechProviderTests.swift in Sources */, 5788177EC3E2C63E9FEC984E /* HotKeyShortcutTests.swift in Sources */, 1A1BD618C6DB603628353251 /* KeychainStoreTests.swift in Sources */, 2FA954D135D9DA76FC19AB95 /* LanguageDetectionTests.swift in Sources */, @@ -562,6 +569,7 @@ AFF59F4FAC5E041CB9ED907A /* ClipboardFallbackReader.swift in Sources */, DBC6A7ECC15029C9AF3EFC1A /* ClipboardSnapshot.swift in Sources */, 7B52C5847ACADB4124618143 /* CoordinateConverter.swift in Sources */, + 418447EF716BE81B74A85222 /* DashScopeQwenSpeechProvider.swift in Sources */, 827A7E6E5FC3A5213740D40A /* GlobalHotKeyService.swift in Sources */, FFD38112182E46989E6EABF1 /* KeychainStore.swift in Sources */, 1F388C3950C30831E5E56F33 /* LanguageDetectionService.swift in Sources */, diff --git a/TextFlow/Core/Models/AppError.swift b/TextFlow/Core/Models/AppError.swift index 54c27e0..609f35b 100644 --- a/TextFlow/Core/Models/AppError.swift +++ b/TextFlow/Core/Models/AppError.swift @@ -18,6 +18,7 @@ enum AppError: LocalizedError, Sendable, Equatable { case modelNotFound case incompatibleRequest case invalidResponse + case untrustedSpeechAudioURL case responseTooLarge case timedOut case cancelled @@ -63,6 +64,8 @@ enum AppError: LocalizedError, Sendable, Equatable { return "服务不兼容当前请求格式。请检查协议和流式设置。" case .invalidResponse: return "服务返回了无法解析的数据。请检查协议兼容性。" + case .untrustedSpeechAudioURL: + return "语音服务返回了不受信任的音频地址,已阻止下载。" case .responseTooLarge: return "服务返回内容超过安全上限。请缩短文本后重试。" case .timedOut: diff --git a/TextFlow/Core/Models/SpeechProfile.swift b/TextFlow/Core/Models/SpeechProfile.swift index 3fe896b..5014bd9 100644 --- a/TextFlow/Core/Models/SpeechProfile.swift +++ b/TextFlow/Core/Models/SpeechProfile.swift @@ -1,5 +1,27 @@ import Foundation +enum SpeechProtocolKind: String, Codable, CaseIterable, Sendable, Identifiable { + case openAISpeech + case dashScopeQwenHTTP + + var id: Self { self } + + var displayName: String { + switch self { + case .openAISpeech: "OpenAI-compatible Speech" + case .dashScopeQwenHTTP: "阿里云百炼 Qwen HTTP" + } + } + + var defaultPath: String { + switch self { + case .openAISpeech: "/v1/audio/speech" + case .dashScopeQwenHTTP: + "/api/v1/services/aigc/multimodal-generation/generation" + } + } +} + enum SpeechOutputFormat: String, Codable, CaseIterable, Sendable, Identifiable { case mp3 case wav @@ -14,6 +36,7 @@ enum SpeechOutputFormat: String, Codable, CaseIterable, Sendable, Identifiable { struct SpeechProfile: Codable, Identifiable, Hashable, Sendable { let id: UUID var name: String + var protocolKind: SpeechProtocolKind var endpointMode: EndpointMode var baseURL: String var path: String @@ -27,10 +50,45 @@ struct SpeechProfile: Codable, Identifiable, Hashable, Sendable { var customHeaders: [HeaderReference] var timeoutSeconds: Double + init( + id: UUID, + name: String, + protocolKind: SpeechProtocolKind, + endpointMode: EndpointMode, + baseURL: String, + path: String, + fullEndpoint: String, + model: String, + chineseVoice: String, + englishVoice: String, + outputFormat: SpeechOutputFormat, + instructions: String, + authMode: AuthMode, + customHeaders: [HeaderReference], + timeoutSeconds: Double + ) { + self.id = id + self.name = name + self.protocolKind = protocolKind + self.endpointMode = endpointMode + self.baseURL = baseURL + self.path = path + self.fullEndpoint = fullEndpoint + self.model = model + self.chineseVoice = chineseVoice + self.englishVoice = englishVoice + self.outputFormat = outputFormat + self.instructions = instructions + self.authMode = authMode + self.customHeaders = customHeaders + self.timeoutSeconds = timeoutSeconds + } + static func empty() -> SpeechProfile { SpeechProfile( id: UUID(), name: "新语音配置", + protocolKind: .openAISpeech, endpointMode: .baseURLAndPath, baseURL: "https://api.openai.com", path: "/v1/audio/speech", @@ -46,6 +104,52 @@ struct SpeechProfile: Codable, Identifiable, Hashable, Sendable { ) } + private enum CodingKeys: String, CodingKey { + case id + case name + case protocolKind + case endpointMode + case baseURL + case path + case fullEndpoint + case model + case chineseVoice + case englishVoice + case outputFormat + case instructions + case authMode + case customHeaders + case timeoutSeconds + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(UUID.self, forKey: .id) + name = try container.decode(String.self, forKey: .name) + protocolKind = try container.decodeIfPresent( + SpeechProtocolKind.self, + forKey: .protocolKind + ) ?? .openAISpeech + endpointMode = try container.decode(EndpointMode.self, forKey: .endpointMode) + baseURL = try container.decode(String.self, forKey: .baseURL) + path = try container.decode(String.self, forKey: .path) + fullEndpoint = try container.decode(String.self, forKey: .fullEndpoint) + model = try container.decode(String.self, forKey: .model) + chineseVoice = try container.decode(String.self, forKey: .chineseVoice) + englishVoice = try container.decode(String.self, forKey: .englishVoice) + outputFormat = try container.decode( + SpeechOutputFormat.self, + forKey: .outputFormat + ) + instructions = try container.decode(String.self, forKey: .instructions) + authMode = try container.decode(AuthMode.self, forKey: .authMode) + customHeaders = try container.decode( + [HeaderReference].self, + forKey: .customHeaders + ) + timeoutSeconds = try container.decode(Double.self, forKey: .timeoutSeconds) + } + var apiKeyAccount: String { "tts.\(id.uuidString).apiKey" } @@ -72,6 +176,10 @@ struct SpeechAudio: Sendable { protocol SpeechProviding: Sendable { func synthesize(_ request: SpeechRequest) async throws -> SpeechAudio + func synthesize( + _ request: SpeechRequest, + maximumBytes: Int + ) async throws -> SpeechAudio func testConnection() async throws -> ProviderTestResult } diff --git a/TextFlow/Features/Settings/SpeechProfilesView.swift b/TextFlow/Features/Settings/SpeechProfilesView.swift index 11e4d9a..7075fcf 100644 --- a/TextFlow/Features/Settings/SpeechProfilesView.swift +++ b/TextFlow/Features/Settings/SpeechProfilesView.swift @@ -25,7 +25,7 @@ struct SpeechProfilesView: View { ContentUnavailableView( "尚无语音配置", systemImage: "speaker.wave.2", - description: Text("添加 OpenAI-compatible Speech 配置;秘密只存钥匙串。") + description: Text("添加 OpenAI-compatible 或阿里云百炼 Qwen 配置;秘密只存钥匙串。") ) .frame(maxWidth: .infinity, maxHeight: .infinity) } @@ -158,17 +158,41 @@ private struct SpeechProfileEditor: View { private var basicInformationCard: some View { SettingsCard( "基本信息", - subtitle: "模型名称会原样发送给 OpenAI-compatible Speech 服务。" + subtitle: basicInformationSubtitle ) { + HStack(alignment: .bottom, spacing: 14) { + SettingsField("请求协议") { + Picker("请求协议", selection: $profile.protocolKind) { + ForEach(SpeechProtocolKind.allCases) { + Text($0.displayName).tag($0) + } + } + .labelsHidden() + .pickerStyle(.menu) + .onChange(of: profile.protocolKind) { _, protocolKind in + if protocolKind == .dashScopeQwenHTTP { + profile.outputFormat = .wav + } + } + } + + if isDashScope { + Button("应用百炼推荐配置") { + applyDashScopeRecommendedConfiguration() + } + .help("保留当前百炼工作空间域名,并填写原生 HTTP 路径、模型、Voice 与慢速朗读指令。") + } + } + HStack(alignment: .top, spacing: 14) { SettingsField("配置名称") { - TextField("例如:DMX", text: $profile.name) + TextField("例如:百炼语音", text: $profile.name) .textFieldStyle(.roundedBorder) .controlSize(.large) } SettingsField("模型") { - TextField("例如:gpt-4o-mini-tts", text: $profile.model) + TextField(modelPlaceholder, text: $profile.model) .textFieldStyle(.roundedBorder) .controlSize(.large) } @@ -179,7 +203,7 @@ private struct SpeechProfileEditor: View { private var endpointCard: some View { SettingsCard( "接口地址", - subtitle: "可以分别填写 Base URL 与 Path,也可以直接使用完整 Endpoint。" + subtitle: endpointSubtitle ) { SettingsField("URL 模式") { Picker("URL 模式", selection: $profile.endpointMode) { @@ -193,20 +217,20 @@ private struct SpeechProfileEditor: View { if profile.endpointMode == .baseURLAndPath { SettingsField("Base URL") { - TextField("https://api.openai.com", text: $profile.baseURL) + TextField(baseURLPlaceholder, text: $profile.baseURL) .textFieldStyle(.roundedBorder) .controlSize(.large) } SettingsField("Path") { - TextField("/v1/audio/speech", text: $profile.path) + TextField(profile.protocolKind.defaultPath, text: $profile.path) .textFieldStyle(.roundedBorder) .controlSize(.large) } } else { SettingsField("完整 Endpoint") { TextField( - "https://api.openai.com/v1/audio/speech", + fullEndpointPlaceholder, text: $profile.fullEndpoint ) .textFieldStyle(.roundedBorder) @@ -219,27 +243,36 @@ private struct SpeechProfileEditor: View { private var voiceCard: some View { SettingsCard( "声音与朗读方式", - subtitle: "中文和英文可以使用不同 Voice;Instructions 可用于控制语速与语气。" + subtitle: voiceSubtitle ) { HStack(alignment: .top, spacing: 14) { SettingsField("中文 Voice") { - TextField("例如:coral", text: $profile.chineseVoice) + TextField(voicePlaceholder, text: $profile.chineseVoice) .textFieldStyle(.roundedBorder) .controlSize(.large) } SettingsField("英文 Voice") { - TextField("例如:coral", text: $profile.englishVoice) + TextField(voicePlaceholder, text: $profile.englishVoice) .textFieldStyle(.roundedBorder) .controlSize(.large) } } - SettingsField( - "Instructions", - help: "例如:Speak slowly and clearly for a language learner." - ) { - SettingsTextEditor(text: $profile.instructions, minHeight: 84) + if supportsInstructions { + SettingsField( + "Instructions", + help: "例如:Speak slowly and clearly for a language learner." + ) { + SettingsTextEditor(text: $profile.instructions, minHeight: 84) + } + } else if isDashScope { + Label( + "qwen3-tts-flash 不支持朗读指令。需要慢速学习朗读时,请改用 qwen3-tts-instruct-flash。", + systemImage: "info.circle" + ) + .font(.callout) + .foregroundStyle(.secondary) } } } @@ -310,13 +343,18 @@ private struct SpeechProfileEditor: View { SettingsCard("请求行为") { HStack(alignment: .top, spacing: 14) { SettingsField("输出格式") { - Picker("输出格式", selection: $profile.outputFormat) { - ForEach(SpeechOutputFormat.allCases) { - Text($0.rawValue.uppercased()).tag($0) + if isDashScope { + Label("WAV(服务返回)", systemImage: "waveform") + .frame(maxWidth: .infinity, alignment: .leading) + } else { + Picker("输出格式", selection: $profile.outputFormat) { + ForEach(SpeechOutputFormat.allCases) { + Text($0.rawValue.uppercased()).tag($0) + } } + .labelsHidden() + .pickerStyle(.menu) } - .labelsHidden() - .pickerStyle(.menu) } SettingsField("请求超时") { @@ -377,6 +415,92 @@ private struct SpeechProfileEditor: View { || statusMessage == "密钥与请求头已保存" } + private var isDashScope: Bool { + profile.protocolKind == .dashScopeQwenHTTP + } + + private var supportsInstructions: Bool { + !isDashScope || profile.model.lowercased().contains("instruct") + } + + private var basicInformationSubtitle: String { + if isDashScope { + return "使用百炼原生 Qwen HTTP 协议;支持 qwen3-tts-flash 与 qwen3-tts-instruct-flash。" + } + return "模型名称会原样发送给 OpenAI-compatible Speech 服务。" + } + + private var endpointSubtitle: String { + if isDashScope { + return "兼容模式的 /audio/speech 不适用于 Qwen3-TTS;请使用工作空间域名加百炼原生 HTTP 路径。" + } + return "可以分别填写 Base URL 与 Path,也可以直接使用完整 Endpoint。" + } + + private var voiceSubtitle: String { + if isDashScope { + return "Voice 区分大小写;Instruct 模型可通过 Instructions 控制语速与语气。" + } + return "中文和英文可以使用不同 Voice;Instructions 可用于控制语速与语气。" + } + + private var modelPlaceholder: String { + isDashScope ? "例如:qwen3-tts-instruct-flash" : "例如:gpt-4o-mini-tts" + } + + private var voicePlaceholder: String { + isDashScope ? "例如:Cherry" : "例如:coral" + } + + private var baseURLPlaceholder: String { + isDashScope + ? "https://你的工作空间.cn-beijing.maas.aliyuncs.com" + : "https://api.openai.com" + } + + private var fullEndpointPlaceholder: String { + let base = isDashScope + ? "https://dashscope.aliyuncs.com" + : "https://api.openai.com" + return base + profile.protocolKind.defaultPath + } + + private func applyDashScopeRecommendedConfiguration() { + let baseURL = inferredDashScopeBaseURL() + profile.protocolKind = .dashScopeQwenHTTP + profile.endpointMode = .baseURLAndPath + profile.baseURL = baseURL + profile.path = SpeechProtocolKind.dashScopeQwenHTTP.defaultPath + profile.fullEndpoint = "" + profile.model = "qwen3-tts-instruct-flash" + profile.chineseVoice = "Cherry" + profile.englishVoice = "Cherry" + profile.outputFormat = .wav + profile.authMode = .bearer + profile.instructions = "以清晰自然的教学语气缓慢朗读,发音准确,短语之间自然停顿,句末停顿稍长。只朗读提供的文本,不添加、删除、翻译或解释任何内容。" + if profile.name == "新语音配置" { + profile.name = "百炼 Qwen TTS" + } + statusMessage = "已应用百炼推荐配置,请保存 API Key 后测试连接" + } + + private func inferredDashScopeBaseURL() -> String { + let candidate = profile.endpointMode == .fullEndpoint + ? profile.fullEndpoint + : profile.baseURL + guard let components = URLComponents(string: candidate), + let scheme = components.scheme, + let host = components.host?.lowercased(), + host.hasSuffix(".maas.aliyuncs.com") else { + return "https://dashscope.aliyuncs.com" + } + var base = URLComponents() + base.scheme = scheme + base.host = host + base.port = components.port + return base.url?.absoluteString ?? "https://dashscope.aliyuncs.com" + } + private func loadSecrets() async { let currentProfile = profile let profileID = currentProfile.id diff --git a/TextFlow/Features/Speech/DashScopeQwenSpeechProvider.swift b/TextFlow/Features/Speech/DashScopeQwenSpeechProvider.swift new file mode 100644 index 0000000..ea22c37 --- /dev/null +++ b/TextFlow/Features/Speech/DashScopeQwenSpeechProvider.swift @@ -0,0 +1,293 @@ +import Foundation + +struct DashScopeQwenSpeechProvider: SpeechProviding { + let configuration: ResolvedSpeechConfiguration + private let dataLoader: any SpeechDataLoading + private let responseLimit: Int + + init( + configuration: ResolvedSpeechConfiguration, + session: URLSession? = nil, + responseLimit: Int = SpeechResourceLimits.maximumResponseBytes + ) { + self.configuration = configuration + self.responseLimit = responseLimit + let sessionConfiguration: URLSessionConfiguration + if let session { + sessionConfiguration = session.configuration + } else { + sessionConfiguration = URLSessionConfiguration.ephemeral + sessionConfiguration.timeoutIntervalForRequest = + configuration.profile.timeoutSeconds + sessionConfiguration.timeoutIntervalForResource = + configuration.profile.timeoutSeconds + sessionConfiguration.requestCachePolicy = + .reloadIgnoringLocalAndRemoteCacheData + sessionConfiguration.urlCache = nil + sessionConfiguration.httpCookieStorage = nil + sessionConfiguration.httpShouldSetCookies = false + } + dataLoader = LimitedURLSessionDataLoader( + configuration: sessionConfiguration + ) + } + + func synthesize(_ request: SpeechRequest) async throws -> SpeechAudio { + try await synthesize(request, maximumBytes: responseLimit) + } + + func synthesize( + _ request: SpeechRequest, + maximumBytes: Int + ) async throws -> SpeechAudio { + let effectiveLimit = min(responseLimit, maximumBytes) + guard effectiveLimit > 0 else { + throw AppError.responseTooLarge + } + + let metadata = try await requestAudioMetadata(for: request) + try Task.checkCancellation() + let audioURL = try Self.trustedAudioURL(from: metadata) + return try await downloadAudio( + from: audioURL, + maximumBytes: effectiveLimit + ) + } + + func testConnection() async throws -> ProviderTestResult { + _ = try await synthesize( + SpeechRequest(text: "Test", language: .english) + ) + return .success + } + + private func requestAudioMetadata( + for request: SpeechRequest + ) async throws -> DashScopeResponse { + var urlRequest = URLRequest(url: configuration.endpoint) + urlRequest.httpMethod = "POST" + urlRequest.timeoutInterval = configuration.profile.timeoutSeconds + urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type") + for (name, value) in configuration.customHeaders { + urlRequest.setValue(value, forHTTPHeaderField: name) + } + switch configuration.profile.authMode { + case .bearer: + guard let apiKey = configuration.apiKey, !apiKey.isEmpty else { + throw AppError.authenticationFailed + } + urlRequest.setValue( + "Bearer \(apiKey)", + forHTTPHeaderField: "Authorization" + ) + case .customHeadersOnly, .none: + break + } + + let voice: String + let languageType: String + switch request.language { + case .chinese: + voice = configuration.profile.chineseVoice + languageType = "Chinese" + case .english: + voice = configuration.profile.englishVoice + languageType = "English" + } + let instructions: String? + if configuration.profile.model.lowercased().contains("instruct"), + !configuration.profile.instructions.isEmpty { + instructions = configuration.profile.instructions + } else { + instructions = nil + } + urlRequest.httpBody = try JSONEncoder().encode( + DashScopeRequest( + model: configuration.profile.model, + input: .init( + text: request.text, + voice: voice, + languageType: languageType, + instructions: instructions + ) + ) + ) + + do { + let (data, response) = try await dataLoader.data( + for: urlRequest, + maximumBytes: SpeechResourceLimits.maximumMetadataResponseBytes + ) + try Task.checkCancellation() + guard let response = response as? HTTPURLResponse else { + throw AppError.invalidResponse + } + try Self.validateHTTPStatus(response.statusCode, body: data) + do { + return try JSONDecoder().decode(DashScopeResponse.self, from: data) + } catch { + throw AppError.invalidResponse + } + } catch let error as URLError { + throw Self.mapNetworkError(error) + } + } + + private func downloadAudio( + from url: URL, + maximumBytes: Int + ) async throws -> SpeechAudio { + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.timeoutInterval = configuration.profile.timeoutSeconds + + do { + let (data, response) = try await dataLoader.data( + for: request, + maximumBytes: maximumBytes + ) + try Task.checkCancellation() + guard let response = response as? HTTPURLResponse else { + throw AppError.invalidResponse + } + try Self.validateHTTPStatus(response.statusCode, body: data) + guard !data.isEmpty, data.count <= maximumBytes else { + throw data.count > maximumBytes + ? AppError.responseTooLarge + : AppError.invalidResponse + } + return SpeechAudio( + data: data, + format: Self.audioFormat(for: url, response: response) + ) + } catch let error as URLError { + throw Self.mapNetworkError(error) + } + } + + private static func validateHTTPStatus( + _ statusCode: Int, + body: Data + ) throws { + switch statusCode { + case 200..<300: + return + case 401, 403: + throw AppError.authenticationFailed + case 404: + throw AppError.endpointNotFound + case 429: + throw AppError.rateLimited + case 500..<600: + throw AppError.serviceUnavailable + default: + if let response = try? JSONDecoder().decode( + DashScopeErrorResponse.self, + from: body + ), (response.code + " " + (response.message ?? "")) + .lowercased() + .contains("model") { + throw AppError.modelNotFound + } + throw AppError.incompatibleRequest + } + } + + private static func trustedAudioURL( + from response: DashScopeResponse + ) throws -> URL { + guard let value = response.output?.audio?.url, !value.isEmpty else { + throw AppError.invalidResponse + } + guard var components = URLComponents(string: value), + let scheme = components.scheme?.lowercased(), + let host = components.host?.lowercased(), + !host.isEmpty, + components.user == nil, + components.password == nil, + components.fragment == nil, + (scheme == "http" || scheme == "https"), + components.port == nil || components.port == 443, + host.hasSuffix(".aliyuncs.com") else { + throw AppError.untrustedSpeechAudioURL + } + let labels = host.split(separator: ".") + guard labels.contains(where: { $0 == "oss" || $0.hasPrefix("oss-") }) else { + throw AppError.untrustedSpeechAudioURL + } + components.scheme = "https" + guard let url = components.url else { + throw AppError.untrustedSpeechAudioURL + } + return url + } + + private static func audioFormat( + for url: URL, + response: HTTPURLResponse + ) -> SpeechOutputFormat { + switch url.pathExtension.lowercased() { + case "mp3": return .mp3 + case "aac", "m4a": return .aac + case "flac": return .flac + case "opus": return .opus + case "pcm": return .pcm + default: + if response.mimeType?.lowercased().contains("mpeg") == true { + return .mp3 + } + return .wav + } + } + + private static func mapNetworkError(_ error: URLError) -> Error { + switch error.code { + case .cancelled: + return CancellationError() + case .timedOut: + return AppError.timedOut + case .secureConnectionFailed, .serverCertificateHasBadDate, + .serverCertificateUntrusted, .serverCertificateHasUnknownRoot, + .serverCertificateNotYetValid: + return AppError.tlsFailure + default: + return AppError.networkFailure + } + } +} + +private struct DashScopeRequest: Encodable { + let model: String + let input: Input + + struct Input: Encodable { + let text: String + let voice: String + let languageType: String + let instructions: String? + + private enum CodingKeys: String, CodingKey { + case text + case voice + case languageType = "language_type" + case instructions + } + } +} + +private struct DashScopeResponse: Decodable { + let output: Output? + + struct Output: Decodable { + let audio: Audio? + } + + struct Audio: Decodable { + let url: String? + } +} + +private struct DashScopeErrorResponse: Decodable { + let code: String + let message: String? +} diff --git a/TextFlow/Features/Speech/SpeechResourceLimits.swift b/TextFlow/Features/Speech/SpeechResourceLimits.swift index ae39e8f..bc7052e 100644 --- a/TextFlow/Features/Speech/SpeechResourceLimits.swift +++ b/TextFlow/Features/Speech/SpeechResourceLimits.swift @@ -1,6 +1,7 @@ import Foundation enum SpeechResourceLimits { + static let maximumMetadataResponseBytes = 1 * 1024 * 1024 static let maximumResponseBytes = 25 * 1024 * 1024 static let maximumOperationBytes = 64 * 1024 * 1024 static let maximumCacheBytes = 64 * 1024 * 1024 diff --git a/TextFlow/Features/Speech/SpeechService.swift b/TextFlow/Features/Speech/SpeechService.swift index 91034a5..5fde900 100644 --- a/TextFlow/Features/Speech/SpeechService.swift +++ b/TextFlow/Features/Speech/SpeechService.swift @@ -18,7 +18,7 @@ final class SpeechService { throw AppError.speechConfigurationMissing } let resolved = try await resolve(profile) - let provider = OpenAICompatibleSpeechProvider(configuration: resolved) + let provider = makeProvider(configuration: resolved) let chunks = try await CancellableDetachedTask.run(priority: .userInitiated) { try Task.checkCancellation() let chunks = TextChunker.chunks(text) @@ -59,9 +59,18 @@ final class SpeechService { func testConnection(profile: SpeechProfile) async throws -> ProviderTestResult { let resolved = try await resolve(profile) - return try await OpenAICompatibleSpeechProvider( - configuration: resolved - ).testConnection() + return try await makeProvider(configuration: resolved).testConnection() + } + + private func makeProvider( + configuration: ResolvedSpeechConfiguration + ) -> any SpeechProviding { + switch configuration.profile.protocolKind { + case .openAISpeech: + OpenAICompatibleSpeechProvider(configuration: configuration) + case .dashScopeQwenHTTP: + DashScopeQwenSpeechProvider(configuration: configuration) + } } private func resolve( diff --git a/TextFlowTests/DashScopeQwenSpeechProviderTests.swift b/TextFlowTests/DashScopeQwenSpeechProviderTests.swift new file mode 100644 index 0000000..57208d4 --- /dev/null +++ b/TextFlowTests/DashScopeQwenSpeechProviderTests.swift @@ -0,0 +1,311 @@ +import Foundation +import XCTest +@testable import TextFlow + +final class DashScopeQwenSpeechProviderTests: XCTestCase { + override func tearDown() { + MockURLProtocol.handler = nil + super.tearDown() + } + + func testUsesNativeRequestShapeAndDownloadsTrustedWAVOverHTTPS() async throws { + var requestCount = 0 + MockURLProtocol.handler = { request in + requestCount += 1 + if requestCount == 1 { + XCTAssertEqual( + request.url?.absoluteString, + "https://speech.mock/api/v1/services/aigc/multimodal-generation/generation" + ) + XCTAssertEqual(request.httpMethod, "POST") + XCTAssertEqual( + request.value(forHTTPHeaderField: "Authorization"), + "Bearer speech-secret" + ) + XCTAssertEqual( + request.value(forHTTPHeaderField: "X-Region"), + "local-test" + ) + + let body = try XCTUnwrap(try self.requestBody(request)) + let json = try XCTUnwrap( + JSONSerialization.jsonObject(with: body) as? [String: Any] + ) + XCTAssertEqual(json["model"] as? String, "qwen3-tts-instruct-flash") + let input = try XCTUnwrap(json["input"] as? [String: Any]) + XCTAssertEqual(input["text"] as? String, "hello") + XCTAssertEqual(input["voice"] as? String, "Cherry") + XCTAssertEqual(input["language_type"] as? String, "English") + XCTAssertEqual( + input["instructions"] as? String, + "Speak slowly and clearly." + ) + XCTAssertNil(json["response_format"]) + + return .json( + try JSONSerialization.data(withJSONObject: [ + "output": [ + "audio": [ + "url": "http://dashscope-result-bj.oss-cn-beijing.aliyuncs.com/result.wav?Expires=123" + ] + ], + "usage": ["characters": 5] + ]) + ) + } + + XCTAssertEqual(request.url?.scheme, "https") + XCTAssertEqual( + request.url?.host, + "dashscope-result-bj.oss-cn-beijing.aliyuncs.com" + ) + XCTAssertNil(request.value(forHTTPHeaderField: "Authorization")) + XCTAssertNil(request.value(forHTTPHeaderField: "X-Region")) + return .init( + status: 200, + headers: ["Content-Type": "audio/x-wav"], + chunks: [Data([0x52, 0x49, 0x46, 0x46])], + error: nil + ) + } + let provider = DashScopeQwenSpeechProvider( + configuration: try configuration(), + session: makeMockSession() + ) + + let audio = try await provider.synthesize( + SpeechRequest(text: "hello", language: .english) + ) + + XCTAssertEqual(requestCount, 2) + XCTAssertEqual(audio.data, Data([0x52, 0x49, 0x46, 0x46])) + XCTAssertEqual(audio.format, .wav) + } + + func testPlainFlashOmitsUnsupportedInstructions() async throws { + let configuration = try configuration(model: "qwen3-tts-flash") + var requestCount = 0 + MockURLProtocol.handler = { request in + requestCount += 1 + if requestCount == 1 { + let body = try XCTUnwrap(try self.requestBody(request)) + let json = try XCTUnwrap( + JSONSerialization.jsonObject(with: body) as? [String: Any] + ) + let input = try XCTUnwrap(json["input"] as? [String: Any]) + XCTAssertNil(input["instructions"]) + XCTAssertEqual(input["language_type"] as? String, "Chinese") + return .json(try self.metadata(url: self.trustedAudioURL)) + } + return self.wavResponse + } + let provider = DashScopeQwenSpeechProvider( + configuration: configuration, + session: makeMockSession() + ) + + _ = try await provider.synthesize( + SpeechRequest(text: "你好", language: .chinese) + ) + + XCTAssertEqual(requestCount, 2) + } + + func testRejectsUntrustedAudioURLWithoutDownloading() async throws { + var requestCount = 0 + MockURLProtocol.handler = { _ in + requestCount += 1 + return .json( + try self.metadata(url: "https://attacker.example/audio.wav") + ) + } + let provider = DashScopeQwenSpeechProvider( + configuration: try configuration(), + session: makeMockSession() + ) + + do { + _ = try await provider.synthesize( + SpeechRequest(text: "hello", language: .english) + ) + XCTFail("Expected untrusted audio URL to fail") + } catch { + XCTAssertEqual(error as? AppError, .untrustedSpeechAudioURL) + } + XCTAssertEqual(requestCount, 1) + } + + func testRejectsMissingAudioURLAsInvalidResponse() async throws { + MockURLProtocol.handler = { _ in + .json(Data(#"{"output":{"audio":{}},"usage":{"characters":5}}"#.utf8)) + } + let provider = DashScopeQwenSpeechProvider( + configuration: try configuration(), + session: makeMockSession() + ) + + do { + _ = try await provider.synthesize( + SpeechRequest(text: "hello", language: .english) + ) + XCTFail("Expected missing audio URL to fail") + } catch { + XCTAssertEqual(error as? AppError, .invalidResponse) + } + } + + func testMapsCancelledTransportToCancellationError() async throws { + MockURLProtocol.handler = { _ in + .init( + status: 200, + headers: [:], + chunks: [], + error: URLError(.cancelled) + ) + } + let provider = DashScopeQwenSpeechProvider( + configuration: try configuration(), + session: makeMockSession() + ) + + do { + _ = try await provider.synthesize( + SpeechRequest(text: "hello", language: .english) + ) + XCTFail("Expected cancellation") + } catch is CancellationError { + // Expected. + } catch { + XCTFail("Expected CancellationError, got \(error)") + } + } + + func testRejectsOversizedAudioBeforeBuffering() async throws { + var requestCount = 0 + MockURLProtocol.handler = { _ in + requestCount += 1 + if requestCount == 1 { + return .json(try self.metadata(url: self.trustedAudioURL)) + } + return .init( + status: 200, + headers: [ + "Content-Type": "audio/x-wav", + "Content-Length": "4" + ], + chunks: [Data([0x52, 0x49, 0x46, 0x46])], + error: nil + ) + } + let provider = DashScopeQwenSpeechProvider( + configuration: try configuration(), + session: makeMockSession() + ) + + do { + _ = try await provider.synthesize( + SpeechRequest(text: "hello", language: .english), + maximumBytes: 3 + ) + XCTFail("Expected oversized audio to fail") + } catch { + XCTAssertEqual(error as? AppError, .responseTooLarge) + } + } + + func testMapsGenerationHTTPAndNetworkErrors() async throws { + let cases: [(MockURLProtocol.Response, AppError)] = [ + (.init(status: 401, headers: [:], chunks: [Data()], error: nil), .authenticationFailed), + (.init(status: 404, headers: [:], chunks: [Data()], error: nil), .endpointNotFound), + (.init(status: 429, headers: [:], chunks: [Data()], error: nil), .rateLimited), + (.init(status: 500, headers: [:], chunks: [Data()], error: nil), .serviceUnavailable), + (.init(status: 200, headers: [:], chunks: [], error: URLError(.timedOut)), .timedOut) + ] + + for (response, expected) in cases { + MockURLProtocol.handler = { _ in response } + let provider = DashScopeQwenSpeechProvider( + configuration: try configuration(), + session: makeMockSession() + ) + do { + _ = try await provider.synthesize( + SpeechRequest(text: "test", language: .english) + ) + XCTFail("Expected failure") + } catch { + XCTAssertEqual(error as? AppError, expected) + } + } + } + + private var trustedAudioURL: String { + "http://dashscope-result-bj.oss-cn-beijing.aliyuncs.com/result.wav?Expires=123" + } + + private var wavResponse: MockURLProtocol.Response { + .init( + status: 200, + headers: ["Content-Type": "audio/x-wav"], + chunks: [Data([0x52, 0x49, 0x46, 0x46])], + error: nil + ) + } + + private func metadata(url: String) throws -> Data { + try JSONSerialization.data(withJSONObject: [ + "output": ["audio": ["url": url]], + "usage": ["characters": 5] + ]) + } + + private func configuration( + model: String = "qwen3-tts-instruct-flash" + ) throws -> ResolvedSpeechConfiguration { + var profile = SpeechProfile.empty() + profile.protocolKind = .dashScopeQwenHTTP + profile.endpointMode = .fullEndpoint + profile.fullEndpoint = "https://speech.mock/api/v1/services/aigc/multimodal-generation/generation" + profile.model = model + profile.chineseVoice = "Cherry" + profile.englishVoice = "Cherry" + profile.outputFormat = .wav + profile.instructions = "Speak slowly and clearly." + return ResolvedSpeechConfiguration( + profile: profile, + endpoint: try URLBuilder.speechEndpoint(for: profile), + apiKey: "speech-secret", + customHeaders: ["X-Region": "local-test"] + ) + } + + private func makeMockSession() -> URLSession { + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [MockURLProtocol.self] + configuration.urlCache = nil + configuration.httpCookieStorage = nil + return URLSession(configuration: configuration) + } + + private func requestBody(_ request: URLRequest) throws -> Data? { + if let body = request.httpBody { + return body + } + guard let stream = request.httpBodyStream else { + return nil + } + stream.open() + defer { stream.close() } + var data = Data() + var buffer = [UInt8](repeating: 0, count: 1024) + while stream.hasBytesAvailable { + let count = stream.read(&buffer, maxLength: buffer.count) + if count < 0 { + throw stream.streamError ?? URLError(.cannotDecodeContentData) + } + if count == 0 { break } + data.append(buffer, count: count) + } + return data + } +} diff --git a/TextFlowTests/SpeechProfileSecurityTests.swift b/TextFlowTests/SpeechProfileSecurityTests.swift index 54c665a..bcf6340 100644 --- a/TextFlowTests/SpeechProfileSecurityTests.swift +++ b/TextFlowTests/SpeechProfileSecurityTests.swift @@ -16,4 +16,32 @@ final class SpeechProfileSecurityTests: XCTestCase { XCTAssertFalse(json.lowercased().contains("apikey")) XCTAssertFalse(json.contains("speech-secret")) } + + func testLegacyProfileDefaultsToOpenAISpeechProtocol() throws { + let legacyProfile = """ + { + "id": "942D2786-01AA-4D5C-871F-C881A1B8B803", + "name": "Legacy", + "endpointMode": "baseURLAndPath", + "baseURL": "https://api.openai.com", + "path": "/v1/audio/speech", + "fullEndpoint": "", + "model": "gpt-4o-mini-tts", + "chineseVoice": "coral", + "englishVoice": "coral", + "outputFormat": "mp3", + "instructions": "", + "authMode": "bearer", + "customHeaders": [], + "timeoutSeconds": 60 + } + """ + + let profile = try JSONDecoder().decode( + SpeechProfile.self, + from: Data(legacyProfile.utf8) + ) + + XCTAssertEqual(profile.protocolKind, .openAISpeech) + } } From 89078084f570e31a1250c64f1a7746c0b4d7e2c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20=E8=80=81=E7=81=AF?= Date: Tue, 4 Aug 2026 23:24:46 +0800 Subject: [PATCH 04/12] Add configurable OpenAI speech speed --- 01_PRD_v0.1.md | 1 + 02_TECHNICAL_DESIGN.md | 8 +- 03_ACCEPTANCE_TESTS.md | 7 +- README.md | 2 +- README.zh-CN.md | 2 +- TextFlow/Core/Models/SpeechProfile.swift | 18 +++++ .../Settings/SpeechProfilesView.swift | 38 +++++++++- .../OpenAICompatibleSpeechProvider.swift | 4 + .../DashScopeQwenSpeechProviderTests.swift | 1 + TextFlowTests/SpeechAudioCacheTests.swift | 7 ++ .../SpeechProfileSecurityTests.swift | 10 +++ .../SpeechProviderIntegrationTests.swift | 75 ++++++++++++++++++- 12 files changed, 167 insertions(+), 6 deletions(-) diff --git a/01_PRD_v0.1.md b/01_PRD_v0.1.md index 227e034..f7d0e12 100644 --- a/01_PRD_v0.1.md +++ b/01_PRD_v0.1.md @@ -208,6 +208,7 @@ v0.1 仅服务单一用户,运行在一台 M2 MacBook 上,不需要账号、 - 支持阿里云百炼 Qwen3-TTS 原生 HTTP 协议,包括 `qwen3-tts-flash` 与 `qwen3-tts-instruct-flash`。 - 支持自定义 URL、API Key、模型、Voice、请求头、输出格式。 +- OpenAI-compatible Speech 支持 `0.25×–4.00×` 朗读速度;默认 `1.00×`。 - 百炼 Qwen3-TTS 的生成响应与临时音频下载分两步完成;下载不转发鉴权信息。 - 支持生成、播放、暂停、继续、停止、重新播放。 - 点击另一个朗读按钮时停止当前音频并播放新任务。 diff --git a/02_TECHNICAL_DESIGN.md b/02_TECHNICAL_DESIGN.md index 6a03601..6128cc9 100644 --- a/02_TECHNICAL_DESIGN.md +++ b/02_TECHNICAL_DESIGN.md @@ -415,7 +415,8 @@ protocol SpeechProviding: Sendable { "model": "gpt-4o-mini-tts", "input": "需要朗读的文本", "voice": "coral", - "response_format": "mp3" + "response_format": "mp3", + "speed": 0.75 } ``` @@ -429,8 +430,13 @@ protocol SpeechProviding: Sendable { - 英文 Voice - 输出格式 - 可选 instructions +- 可选 speed,范围 `0.25...4.0`,默认 `1.0` - 超时 +为兼容未完整实现 OpenAI Speech schema 的旧第三方服务,`speed == 1.0` 时不发送 +该可选字段;用户选择其他速度时才发送。Profile 解码旧数据时补默认值 `1.0`, +并将异常持久化值限制到官方范围。百炼 Qwen Provider 不发送此字段。 + 第三方服务如果遵循 OpenAI Speech schema 可直接使用。若协议不同,新增独立 Provider,不在 v0.1 里加入任意 JSON 模板编辑器。 ### 9.3 阿里云百炼 Qwen3-TTS HTTP diff --git a/03_ACCEPTANCE_TESTS.md b/03_ACCEPTANCE_TESTS.md index da7f74f..0b67e10 100644 --- a/03_ACCEPTANCE_TESTS.md +++ b/03_ACCEPTANCE_TESTS.md @@ -141,7 +141,12 @@ 步骤:配置 OpenAI-compatible 第三方 Speech endpoint。 -期望:自定义 URL、模型、Voice、Key、Header 生效,音频成功播放。 +期望: + +- 自定义 URL、模型、Voice、Key、Header 生效,音频成功播放 +- 调整朗读速度后,请求发送对应 `speed` +- 默认 `1.0×` 不发送可选 `speed` 字段,保持旧第三方服务兼容 +- 修改速度后缓存键变化,不复用其他速度生成的音频 ### P0-14A 百炼 Qwen3-TTS HTTP diff --git a/README.md b/README.md index d625c02..94d8d0a 100644 --- a/README.md +++ b/README.md @@ -117,7 +117,7 @@ Translation profiles support: - Automatic, enabled, or disabled streaming - Streaming SSE and non-streaming JSON responses -Speech profiles support OpenAI-compatible `/v1/audio/speech` endpoints with custom URL, model, voice, headers, and output format. A dedicated Alibaba Cloud Model Studio HTTP adapter supports `qwen3-tts-flash` and `qwen3-tts-instruct-flash`; it downloads the short-lived audio result over HTTPS without forwarding API credentials. Other “OpenAI-compatible” implementations still vary, so please open a compatibility report when a provider needs a dedicated adapter. +Speech profiles support OpenAI-compatible `/v1/audio/speech` endpoints with custom URL, model, voice, headers, output format, and `0.25×–4.00×` speed control. A dedicated Alibaba Cloud Model Studio HTTP adapter supports `qwen3-tts-flash` and `qwen3-tts-instruct-flash`; it downloads the short-lived audio result over HTTPS without forwarding API credentials. Other “OpenAI-compatible” implementations still vary, so please open a compatibility report when a provider needs a dedicated adapter. Never place a real API key in source files, Markdown, test fixtures, `.env` files committed to Git, or issue screenshots. diff --git a/README.zh-CN.md b/README.zh-CN.md index 487038d..0ffa32f 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -117,7 +117,7 @@ brew install xcodegen - 自动、开启或关闭流式模式 - SSE 流式响应和非流式 JSON 响应 -语音配置支持 OpenAI-compatible `/v1/audio/speech`,可自定义 URL、模型、Voice、Header 和输出格式。专用的阿里云百炼 HTTP 适配器支持 `qwen3-tts-flash` 和 `qwen3-tts-instruct-flash`,会通过 HTTPS 下载短期音频结果且不转发 API 凭据。其他厂商对“OpenAI 兼容”的实现仍可能不同;如需专门适配器,请提交兼容性报告。 +语音配置支持 OpenAI-compatible `/v1/audio/speech`,可自定义 URL、模型、Voice、Header、输出格式和 `0.25×–4.00×` 朗读速度。专用的阿里云百炼 HTTP 适配器支持 `qwen3-tts-flash` 和 `qwen3-tts-instruct-flash`,会通过 HTTPS 下载短期音频结果且不转发 API 凭据。其他厂商对“OpenAI 兼容”的实现仍可能不同;如需专门适配器,请提交兼容性报告。 不要把真实 API Key 放入源码、Markdown、测试 fixture、Git 中的 `.env` 文件或 Issue 截图。 diff --git a/TextFlow/Core/Models/SpeechProfile.swift b/TextFlow/Core/Models/SpeechProfile.swift index 5014bd9..a3de214 100644 --- a/TextFlow/Core/Models/SpeechProfile.swift +++ b/TextFlow/Core/Models/SpeechProfile.swift @@ -34,6 +34,10 @@ enum SpeechOutputFormat: String, Codable, CaseIterable, Sendable, Identifiable { } struct SpeechProfile: Codable, Identifiable, Hashable, Sendable { + static let minimumSpeed = 0.25 + static let maximumSpeed = 4.0 + static let defaultSpeed = 1.0 + let id: UUID var name: String var protocolKind: SpeechProtocolKind @@ -46,6 +50,7 @@ struct SpeechProfile: Codable, Identifiable, Hashable, Sendable { var englishVoice: String var outputFormat: SpeechOutputFormat var instructions: String + var speed: Double var authMode: AuthMode var customHeaders: [HeaderReference] var timeoutSeconds: Double @@ -63,6 +68,7 @@ struct SpeechProfile: Codable, Identifiable, Hashable, Sendable { englishVoice: String, outputFormat: SpeechOutputFormat, instructions: String, + speed: Double, authMode: AuthMode, customHeaders: [HeaderReference], timeoutSeconds: Double @@ -79,6 +85,7 @@ struct SpeechProfile: Codable, Identifiable, Hashable, Sendable { self.englishVoice = englishVoice self.outputFormat = outputFormat self.instructions = instructions + self.speed = Self.normalizedSpeed(speed) self.authMode = authMode self.customHeaders = customHeaders self.timeoutSeconds = timeoutSeconds @@ -98,6 +105,7 @@ struct SpeechProfile: Codable, Identifiable, Hashable, Sendable { englishVoice: "coral", outputFormat: .mp3, instructions: "", + speed: defaultSpeed, authMode: .bearer, customHeaders: [], timeoutSeconds: 60 @@ -117,6 +125,7 @@ struct SpeechProfile: Codable, Identifiable, Hashable, Sendable { case englishVoice case outputFormat case instructions + case speed case authMode case customHeaders case timeoutSeconds @@ -142,6 +151,10 @@ struct SpeechProfile: Codable, Identifiable, Hashable, Sendable { forKey: .outputFormat ) instructions = try container.decode(String.self, forKey: .instructions) + speed = Self.normalizedSpeed( + try container.decodeIfPresent(Double.self, forKey: .speed) + ?? Self.defaultSpeed + ) authMode = try container.decode(AuthMode.self, forKey: .authMode) customHeaders = try container.decode( [HeaderReference].self, @@ -150,6 +163,11 @@ struct SpeechProfile: Codable, Identifiable, Hashable, Sendable { timeoutSeconds = try container.decode(Double.self, forKey: .timeoutSeconds) } + static func normalizedSpeed(_ value: Double) -> Double { + guard value.isFinite else { return defaultSpeed } + return min(maximumSpeed, max(minimumSpeed, value)) + } + var apiKeyAccount: String { "tts.\(id.uuidString).apiKey" } diff --git a/TextFlow/Features/Settings/SpeechProfilesView.swift b/TextFlow/Features/Settings/SpeechProfilesView.swift index 7075fcf..4265e7a 100644 --- a/TextFlow/Features/Settings/SpeechProfilesView.swift +++ b/TextFlow/Features/Settings/SpeechProfilesView.swift @@ -340,7 +340,12 @@ private struct SpeechProfileEditor: View { } private var requestBehaviorCard: some View { - SettingsCard("请求行为") { + SettingsCard( + "请求行为", + subtitle: isDashScope + ? "百炼 Qwen 使用 Instructions 控制朗读方式。" + : "OpenAI-compatible Speech 可发送精确 speed;1.0× 时省略该可选字段以保持兼容。" + ) { HStack(alignment: .top, spacing: 14) { SettingsField("输出格式") { if isDashScope { @@ -368,6 +373,37 @@ private struct SpeechProfileEditor: View { } } } + + if !isDashScope { + Divider() + + SettingsField( + "朗读速度", + help: "官方范围为 0.25×–4.00×;语言学习建议从 0.75× 开始。" + ) { + HStack(spacing: 12) { + Slider( + value: $profile.speed, + in: SpeechProfile.minimumSpeed...SpeechProfile.maximumSpeed, + step: 0.05 + ) + .frame(minWidth: 260) + + Text("\(profile.speed, specifier: "%.2f")×") + .monospacedDigit() + .frame(width: 54, alignment: .trailing) + + Button("学习 0.75×") { + profile.speed = 0.75 + } + + Button("正常 1.00×") { + profile.speed = SpeechProfile.defaultSpeed + } + .disabled(profile.speed == SpeechProfile.defaultSpeed) + } + } + } } } diff --git a/TextFlow/Features/Speech/OpenAICompatibleSpeechProvider.swift b/TextFlow/Features/Speech/OpenAICompatibleSpeechProvider.swift index d190e24..76dea02 100644 --- a/TextFlow/Features/Speech/OpenAICompatibleSpeechProvider.swift +++ b/TextFlow/Features/Speech/OpenAICompatibleSpeechProvider.swift @@ -78,6 +78,10 @@ struct OpenAICompatibleSpeechProvider: SpeechProviding { if !configuration.profile.instructions.isEmpty { body["instructions"] = configuration.profile.instructions } + let speed = SpeechProfile.normalizedSpeed(configuration.profile.speed) + if speed != SpeechProfile.defaultSpeed { + body["speed"] = speed + } urlRequest.httpBody = try JSONSerialization.data(withJSONObject: body) do { diff --git a/TextFlowTests/DashScopeQwenSpeechProviderTests.swift b/TextFlowTests/DashScopeQwenSpeechProviderTests.swift index 57208d4..e970212 100644 --- a/TextFlowTests/DashScopeQwenSpeechProviderTests.swift +++ b/TextFlowTests/DashScopeQwenSpeechProviderTests.swift @@ -41,6 +41,7 @@ final class DashScopeQwenSpeechProviderTests: XCTestCase { "Speak slowly and clearly." ) XCTAssertNil(json["response_format"]) + XCTAssertNil(json["speed"]) return .json( try JSONSerialization.data(withJSONObject: [ diff --git a/TextFlowTests/SpeechAudioCacheTests.swift b/TextFlowTests/SpeechAudioCacheTests.swift index 0918f66..bde6212 100644 --- a/TextFlowTests/SpeechAudioCacheTests.swift +++ b/TextFlowTests/SpeechAudioCacheTests.swift @@ -34,6 +34,12 @@ final class SpeechAudioCacheTests: XCTestCase { language: .english, profile: profile ) + profile.speed = 0.75 + let changedSpeed = SpeechAudioCacheKey( + text: "hello", + language: .english, + profile: profile + ) let changedText = SpeechAudioCacheKey( text: "hello again", language: .english, @@ -46,6 +52,7 @@ final class SpeechAudioCacheTests: XCTestCase { ) XCTAssertNotEqual(original, changedPrompt) + XCTAssertNotEqual(changedPrompt, changedSpeed) XCTAssertNotEqual(original, changedText) XCTAssertNotEqual(original, changedLanguage) } diff --git a/TextFlowTests/SpeechProfileSecurityTests.swift b/TextFlowTests/SpeechProfileSecurityTests.swift index bcf6340..d9e4d6d 100644 --- a/TextFlowTests/SpeechProfileSecurityTests.swift +++ b/TextFlowTests/SpeechProfileSecurityTests.swift @@ -43,5 +43,15 @@ final class SpeechProfileSecurityTests: XCTestCase { ) XCTAssertEqual(profile.protocolKind, .openAISpeech) + XCTAssertEqual(profile.speed, 1.0) + } + + func testPersistedSpeedIsClampedToOfficialRange() throws { + var profile = SpeechProfile.empty() + profile.speed = 9.0 + let encoded = try JSONEncoder().encode(profile) + let decoded = try JSONDecoder().decode(SpeechProfile.self, from: encoded) + + XCTAssertEqual(decoded.speed, SpeechProfile.maximumSpeed) } } diff --git a/TextFlowTests/SpeechProviderIntegrationTests.swift b/TextFlowTests/SpeechProviderIntegrationTests.swift index d8175ba..cd3b344 100644 --- a/TextFlowTests/SpeechProviderIntegrationTests.swift +++ b/TextFlowTests/SpeechProviderIntegrationTests.swift @@ -36,6 +36,54 @@ final class SpeechProviderIntegrationTests: XCTestCase { XCTAssertEqual(audio.format, .mp3) } + func testSendsConfiguredSpeedToOpenAICompatibleEndpoint() async throws { + MockURLProtocol.handler = { request in + let body = try XCTUnwrap(try self.requestBody(request)) + let json = try XCTUnwrap( + JSONSerialization.jsonObject(with: body) as? [String: Any] + ) + XCTAssertEqual(json["speed"] as? Double, 0.75) + return .init( + status: 200, + headers: ["Content-Type": "audio/mpeg"], + chunks: [Data([0x49, 0x44, 0x33, 0x04])], + error: nil + ) + } + let provider = OpenAICompatibleSpeechProvider( + configuration: try configuration(speed: 0.75), + session: makeMockSession() + ) + + _ = try await provider.synthesize( + SpeechRequest(text: "Learn at this pace.", language: .english) + ) + } + + func testDefaultSpeedIsOmittedForLegacyCompatibleEndpoints() async throws { + MockURLProtocol.handler = { request in + let body = try XCTUnwrap(try self.requestBody(request)) + let json = try XCTUnwrap( + JSONSerialization.jsonObject(with: body) as? [String: Any] + ) + XCTAssertNil(json["speed"]) + return .init( + status: 200, + headers: ["Content-Type": "audio/mpeg"], + chunks: [Data([0x49, 0x44, 0x33, 0x04])], + error: nil + ) + } + let provider = OpenAICompatibleSpeechProvider( + configuration: try configuration(), + session: makeMockSession() + ) + + _ = try await provider.synthesize( + SpeechRequest(text: "Default pace.", language: .english) + ) + } + func testMapsSpeechHTTPAndNetworkErrors() async throws { let cases: [(MockURLProtocol.Response, AppError)] = [ ( @@ -121,10 +169,13 @@ final class SpeechProviderIntegrationTests: XCTestCase { } } - private func configuration() throws -> ResolvedSpeechConfiguration { + private func configuration( + speed: Double = 1.0 + ) throws -> ResolvedSpeechConfiguration { var profile = SpeechProfile.empty() profile.endpointMode = .fullEndpoint profile.fullEndpoint = "https://speech.mock/v1/audio/speech" + profile.speed = speed return ResolvedSpeechConfiguration( profile: profile, endpoint: try URLBuilder.speechEndpoint(for: profile), @@ -140,4 +191,26 @@ final class SpeechProviderIntegrationTests: XCTestCase { configuration.httpCookieStorage = nil return URLSession(configuration: configuration) } + + private func requestBody(_ request: URLRequest) throws -> Data? { + if let body = request.httpBody { + return body + } + guard let stream = request.httpBodyStream else { + return nil + } + stream.open() + defer { stream.close() } + var data = Data() + var buffer = [UInt8](repeating: 0, count: 1024) + while stream.hasBytesAvailable { + let count = stream.read(&buffer, maxLength: buffer.count) + if count < 0 { + throw stream.streamError ?? URLError(.cannotDecodeContentData) + } + if count == 0 { break } + data.append(buffer, count: count) + } + return data + } } From bff1d8873ecc4b14e37b8d0d2ba6506b6b30bddf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20=E8=80=81=E7=81=AF?= Date: Tue, 4 Aug 2026 23:39:22 +0800 Subject: [PATCH 05/12] Fix exact TTS speed serialization --- 02_TECHNICAL_DESIGN.md | 5 +- 03_ACCEPTANCE_TESTS.md | 2 + TextFlow/Core/Models/AppError.swift | 2 +- TextFlow/Core/Models/SpeechProfile.swift | 9 ++- .../OpenAICompatibleSpeechProvider.swift | 46 ++++++++++---- .../SpeechProfileSecurityTests.swift | 8 +++ .../SpeechProviderIntegrationTests.swift | 61 +++++++++++++++++++ 7 files changed, 116 insertions(+), 17 deletions(-) diff --git a/02_TECHNICAL_DESIGN.md b/02_TECHNICAL_DESIGN.md index 6128cc9..effb3c9 100644 --- a/02_TECHNICAL_DESIGN.md +++ b/02_TECHNICAL_DESIGN.md @@ -435,7 +435,10 @@ protocol SpeechProviding: Sendable { 为兼容未完整实现 OpenAI Speech schema 的旧第三方服务,`speed == 1.0` 时不发送 该可选字段;用户选择其他速度时才发送。Profile 解码旧数据时补默认值 `1.0`, -并将异常持久化值限制到官方范围。百炼 Qwen Provider 不发送此字段。 +并将异常持久化值限制到官方范围。速度按界面精度归一到两位小数,请求使用 +强类型 `Encodable` 与 `JSONEncoder`,禁止把 `0.8` 序列化为带二进制长尾的数字。 +HTTP 5xx 归类为服务暂不可用,不显示为请求格式不兼容。百炼 Qwen Provider +不发送此字段。 第三方服务如果遵循 OpenAI Speech schema 可直接使用。若协议不同,新增独立 Provider,不在 v0.1 里加入任意 JSON 模板编辑器。 diff --git a/03_ACCEPTANCE_TESTS.md b/03_ACCEPTANCE_TESTS.md index 0b67e10..c3dc8fb 100644 --- a/03_ACCEPTANCE_TESTS.md +++ b/03_ACCEPTANCE_TESTS.md @@ -145,8 +145,10 @@ - 自定义 URL、模型、Voice、Key、Header 生效,音频成功播放 - 调整朗读速度后,请求发送对应 `speed` +- `0.8×`、`0.85×` 等速度以简洁十进制数字发送,不包含二进制浮点长尾 - 默认 `1.0×` 不发送可选 `speed` 字段,保持旧第三方服务兼容 - 修改速度后缓存键变化,不复用其他速度生成的音频 +- HTTP 5xx 显示服务暂不可用,不误报为请求格式不兼容 ### P0-14A 百炼 Qwen3-TTS HTTP diff --git a/TextFlow/Core/Models/AppError.swift b/TextFlow/Core/Models/AppError.swift index 609f35b..7233267 100644 --- a/TextFlow/Core/Models/AppError.swift +++ b/TextFlow/Core/Models/AppError.swift @@ -61,7 +61,7 @@ enum AppError: LocalizedError, Sendable, Equatable { case .modelNotFound: return "服务找不到所配置的模型。请检查模型名称。" case .incompatibleRequest: - return "服务不兼容当前请求格式。请检查协议和流式设置。" + return "服务不兼容当前请求格式。请检查协议、模型、请求参数或流式设置。" case .invalidResponse: return "服务返回了无法解析的数据。请检查协议兼容性。" case .untrustedSpeechAudioURL: diff --git a/TextFlow/Core/Models/SpeechProfile.swift b/TextFlow/Core/Models/SpeechProfile.swift index a3de214..e04550b 100644 --- a/TextFlow/Core/Models/SpeechProfile.swift +++ b/TextFlow/Core/Models/SpeechProfile.swift @@ -50,7 +50,11 @@ struct SpeechProfile: Codable, Identifiable, Hashable, Sendable { var englishVoice: String var outputFormat: SpeechOutputFormat var instructions: String - var speed: Double + var speed: Double { + didSet { + speed = Self.normalizedSpeed(speed) + } + } var authMode: AuthMode var customHeaders: [HeaderReference] var timeoutSeconds: Double @@ -165,7 +169,8 @@ struct SpeechProfile: Codable, Identifiable, Hashable, Sendable { static func normalizedSpeed(_ value: Double) -> Double { guard value.isFinite else { return defaultSpeed } - return min(maximumSpeed, max(minimumSpeed, value)) + let clamped = min(maximumSpeed, max(minimumSpeed, value)) + return (clamped * 100).rounded() / 100 } var apiKeyAccount: String { diff --git a/TextFlow/Features/Speech/OpenAICompatibleSpeechProvider.swift b/TextFlow/Features/Speech/OpenAICompatibleSpeechProvider.swift index 76dea02..6983b18 100644 --- a/TextFlow/Features/Speech/OpenAICompatibleSpeechProvider.swift +++ b/TextFlow/Features/Speech/OpenAICompatibleSpeechProvider.swift @@ -1,5 +1,23 @@ import Foundation +private struct OpenAISpeechRequestBody: Encodable { + let model: String + let input: String + let voice: String + let responseFormat: String + let instructions: String? + let speed: Double? + + private enum CodingKeys: String, CodingKey { + case model + case input + case voice + case responseFormat = "response_format" + case instructions + case speed + } +} + struct OpenAICompatibleSpeechProvider: SpeechProviding { let configuration: ResolvedSpeechConfiguration private let dataLoader: any SpeechDataLoading @@ -69,20 +87,18 @@ struct OpenAICompatibleSpeechProvider: SpeechProviding { case .english: voice = configuration.profile.englishVoice } - var body: [String: Any] = [ - "model": configuration.profile.model, - "input": request.text, - "voice": voice, - "response_format": configuration.profile.outputFormat.rawValue - ] - if !configuration.profile.instructions.isEmpty { - body["instructions"] = configuration.profile.instructions - } let speed = SpeechProfile.normalizedSpeed(configuration.profile.speed) - if speed != SpeechProfile.defaultSpeed { - body["speed"] = speed - } - urlRequest.httpBody = try JSONSerialization.data(withJSONObject: body) + let body = OpenAISpeechRequestBody( + model: configuration.profile.model, + input: request.text, + voice: voice, + responseFormat: configuration.profile.outputFormat.rawValue, + instructions: configuration.profile.instructions.isEmpty + ? nil + : configuration.profile.instructions, + speed: speed == SpeechProfile.defaultSpeed ? nil : speed + ) + urlRequest.httpBody = try JSONEncoder().encode(body) do { let (data, response) = try await dataLoader.data( @@ -108,8 +124,12 @@ struct OpenAICompatibleSpeechProvider: SpeechProviding { throw AppError.authenticationFailed case 404: throw AppError.endpointNotFound + case 408: + throw AppError.timedOut case 429: throw AppError.rateLimited + case 500..<600: + throw AppError.serviceUnavailable default: throw AppError.incompatibleRequest } diff --git a/TextFlowTests/SpeechProfileSecurityTests.swift b/TextFlowTests/SpeechProfileSecurityTests.swift index d9e4d6d..3488f47 100644 --- a/TextFlowTests/SpeechProfileSecurityTests.swift +++ b/TextFlowTests/SpeechProfileSecurityTests.swift @@ -54,4 +54,12 @@ final class SpeechProfileSecurityTests: XCTestCase { XCTAssertEqual(decoded.speed, SpeechProfile.maximumSpeed) } + + func testAssignedSpeedIsNormalizedToTwoDecimalPlaces() { + var profile = SpeechProfile.empty() + + profile.speed = 0.8500000000000001 + + XCTAssertEqual(profile.speed, 0.85) + } } diff --git a/TextFlowTests/SpeechProviderIntegrationTests.swift b/TextFlowTests/SpeechProviderIntegrationTests.swift index cd3b344..125fb64 100644 --- a/TextFlowTests/SpeechProviderIntegrationTests.swift +++ b/TextFlowTests/SpeechProviderIntegrationTests.swift @@ -60,6 +60,37 @@ final class SpeechProviderIntegrationTests: XCTestCase { ) } + func testSerializesSliderSpeedsWithoutBinaryFloatingPointTails() async throws { + let cases: [(speed: Double, expectedJSONNumber: String)] = [ + (0.8, "0.8"), + (0.8500000000000001, "0.85") + ] + + for testCase in cases { + MockURLProtocol.handler = { request in + let body = try XCTUnwrap(try self.requestBody(request)) + XCTAssertEqual( + try self.jsonNumber(forKey: "speed", in: body), + testCase.expectedJSONNumber + ) + return .init( + status: 200, + headers: ["Content-Type": "audio/mpeg"], + chunks: [Data([0x49, 0x44, 0x33, 0x04])], + error: nil + ) + } + let provider = OpenAICompatibleSpeechProvider( + configuration: try configuration(speed: testCase.speed), + session: makeMockSession() + ) + + _ = try await provider.synthesize( + SpeechRequest(text: "Exact speed.", language: .english) + ) + } + } + func testDefaultSpeedIsOmittedForLegacyCompatibleEndpoints() async throws { MockURLProtocol.handler = { request in let body = try XCTUnwrap(try self.requestBody(request)) @@ -113,6 +144,24 @@ final class SpeechProviderIntegrationTests: XCTestCase { ), .rateLimited ), + ( + .init( + status: 500, + headers: [:], + chunks: [Data()], + error: nil + ), + .serviceUnavailable + ), + ( + .init( + status: 503, + headers: [:], + chunks: [Data()], + error: nil + ), + .serviceUnavailable + ), ( .init( status: 200, @@ -213,4 +262,16 @@ final class SpeechProviderIntegrationTests: XCTestCase { } return data } + + private func jsonNumber(forKey key: String, in data: Data) throws -> String { + let json = try XCTUnwrap(String(data: data, encoding: .utf8)) + let pattern = "\\\"\(NSRegularExpression.escapedPattern(for: key))\\\"\\s*:\\s*[-+0-9.eE]+" + let fieldRange = try XCTUnwrap( + json.range(of: pattern, options: .regularExpression) + ) + let field = json[fieldRange] + let separator = try XCTUnwrap(field.firstIndex(of: ":")) + return field[field.index(after: separator)...] + .trimmingCharacters(in: .whitespacesAndNewlines) + } } From ad932597d4924a491f80c2393436c7deb2537131 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20=E8=80=81=E7=81=AF?= Date: Wed, 5 Aug 2026 02:14:26 +0800 Subject: [PATCH 06/12] Split TTS settings by language --- 01_PRD_v0.1.md | 2 +- 02_TECHNICAL_DESIGN.md | 13 +- 03_ACCEPTANCE_TESTS.md | 2 + README.md | 2 +- README.zh-CN.md | 2 +- TextFlow/Core/Models/SpeechProfile.swift | 79 ++++++++-- .../Settings/SpeechProfilesView.swift | 146 +++++++++++------- .../Speech/DashScopeQwenSpeechProvider.swift | 7 +- .../OpenAICompatibleSpeechProvider.swift | 15 +- .../DashScopeQwenSpeechProviderTests.swift | 38 ++++- TextFlowTests/SpeechAudioCacheTests.swift | 4 +- .../SpeechProfileSecurityTests.swift | 29 +++- .../SpeechProviderIntegrationTests.swift | 58 ++++++- 13 files changed, 309 insertions(+), 88 deletions(-) diff --git a/01_PRD_v0.1.md b/01_PRD_v0.1.md index f7d0e12..73989b2 100644 --- a/01_PRD_v0.1.md +++ b/01_PRD_v0.1.md @@ -202,7 +202,7 @@ v0.1 仅服务单一用户,运行在一台 M2 MacBook 上,不需要账号、 原文和译文分别提供朗读按钮。 -- 中文和英文分别保存一个默认 Voice。 +- 中文和英文分别保存 Voice、Instructions 和朗读速度。 - 使用独立的 TTS 配置,不与翻译模型绑定。 - 支持 OpenAI-compatible `/v1/audio/speech`。 - 支持阿里云百炼 Qwen3-TTS 原生 HTTP 协议,包括 diff --git a/02_TECHNICAL_DESIGN.md b/02_TECHNICAL_DESIGN.md index effb3c9..f711e4e 100644 --- a/02_TECHNICAL_DESIGN.md +++ b/02_TECHNICAL_DESIGN.md @@ -426,13 +426,16 @@ protocol SpeechProviding: Sendable { - API Key - Bearer 或自定义 Header - 模型 -- 中文 Voice -- 英文 Voice +- 中文 Voice、Instructions、speed +- 英文 Voice、Instructions、speed - 输出格式 -- 可选 instructions -- 可选 speed,范围 `0.25...4.0`,默认 `1.0` - 超时 +Provider 根据 `SpeechRequest.language` 选择对应语言的 Voice、Instructions 与 speed。 +设置页将两种语言并列放在“声音与朗读方式”中;输出格式和超时仍属于“请求行为”。 +旧 Profile 的单一 `instructions` 与 `speed` 在解码时分别复制到中文和英文配置; +新数据只写入独立字段。 + 为兼容未完整实现 OpenAI Speech schema 的旧第三方服务,`speed == 1.0` 时不发送 该可选字段;用户选择其他速度时才发送。Profile 解码旧数据时补默认值 `1.0`, 并将异常持久化值限制到官方范围。速度按界面精度归一到两位小数,请求使用 @@ -466,7 +469,7 @@ OpenAI `/v1/audio/speech`。 ``` - `qwen3-tts-flash` 不发送 `instructions` -- `qwen3-tts-instruct-flash` 可发送 `instructions` +- `qwen3-tts-instruct-flash` 按文本语言发送中文或英文 `instructions` - 非流式响应返回临时 OSS 音频 URL,Provider 随即下载到内存 - 只接受阿里云 OSS `aliyuncs.com` 域名,HTTP URL 升级为 HTTPS - 音频下载请求不得携带 API Key 或生成请求的自定义 Header diff --git a/03_ACCEPTANCE_TESTS.md b/03_ACCEPTANCE_TESTS.md index c3dc8fb..a757f3a 100644 --- a/03_ACCEPTANCE_TESTS.md +++ b/03_ACCEPTANCE_TESTS.md @@ -144,6 +144,8 @@ 期望: - 自定义 URL、模型、Voice、Key、Header 生效,音频成功播放 +- 中文和英文分别使用各自的 Voice、Instructions 与朗读速度 +- 旧版单一 Instructions/Speed 配置升级后同时迁移到中文和英文,不丢失设置 - 调整朗读速度后,请求发送对应 `speed` - `0.8×`、`0.85×` 等速度以简洁十进制数字发送,不包含二进制浮点长尾 - 默认 `1.0×` 不发送可选 `speed` 字段,保持旧第三方服务兼容 diff --git a/README.md b/README.md index 94d8d0a..f9a11a5 100644 --- a/README.md +++ b/README.md @@ -117,7 +117,7 @@ Translation profiles support: - Automatic, enabled, or disabled streaming - Streaming SSE and non-streaming JSON responses -Speech profiles support OpenAI-compatible `/v1/audio/speech` endpoints with custom URL, model, voice, headers, output format, and `0.25×–4.00×` speed control. A dedicated Alibaba Cloud Model Studio HTTP adapter supports `qwen3-tts-flash` and `qwen3-tts-instruct-flash`; it downloads the short-lived audio result over HTTPS without forwarding API credentials. Other “OpenAI-compatible” implementations still vary, so please open a compatibility report when a provider needs a dedicated adapter. +Speech profiles support OpenAI-compatible `/v1/audio/speech` endpoints with custom URL, model, headers, output format, and independent Chinese/English voice, instructions, and `0.25×–4.00×` speed control. A dedicated Alibaba Cloud Model Studio HTTP adapter supports `qwen3-tts-flash` and `qwen3-tts-instruct-flash`; it downloads the short-lived audio result over HTTPS without forwarding API credentials. Other “OpenAI-compatible” implementations still vary, so please open a compatibility report when a provider needs a dedicated adapter. Never place a real API key in source files, Markdown, test fixtures, `.env` files committed to Git, or issue screenshots. diff --git a/README.zh-CN.md b/README.zh-CN.md index 0ffa32f..75a18b0 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -117,7 +117,7 @@ brew install xcodegen - 自动、开启或关闭流式模式 - SSE 流式响应和非流式 JSON 响应 -语音配置支持 OpenAI-compatible `/v1/audio/speech`,可自定义 URL、模型、Voice、Header、输出格式和 `0.25×–4.00×` 朗读速度。专用的阿里云百炼 HTTP 适配器支持 `qwen3-tts-flash` 和 `qwen3-tts-instruct-flash`,会通过 HTTPS 下载短期音频结果且不转发 API 凭据。其他厂商对“OpenAI 兼容”的实现仍可能不同;如需专门适配器,请提交兼容性报告。 +语音配置支持 OpenAI-compatible `/v1/audio/speech`,可自定义 URL、模型、Header 和输出格式,并可独立配置中文/英文 Voice、Instructions 及 `0.25×–4.00×` 朗读速度。专用的阿里云百炼 HTTP 适配器支持 `qwen3-tts-flash` 和 `qwen3-tts-instruct-flash`,会通过 HTTPS 下载短期音频结果且不转发 API 凭据。其他厂商对“OpenAI 兼容”的实现仍可能不同;如需专门适配器,请提交兼容性报告。 不要把真实 API Key 放入源码、Markdown、测试 fixture、Git 中的 `.env` 文件或 Issue 截图。 diff --git a/TextFlow/Core/Models/SpeechProfile.swift b/TextFlow/Core/Models/SpeechProfile.swift index e04550b..f665415 100644 --- a/TextFlow/Core/Models/SpeechProfile.swift +++ b/TextFlow/Core/Models/SpeechProfile.swift @@ -49,10 +49,16 @@ struct SpeechProfile: Codable, Identifiable, Hashable, Sendable { var chineseVoice: String var englishVoice: String var outputFormat: SpeechOutputFormat - var instructions: String - var speed: Double { + var chineseInstructions: String + var englishInstructions: String + var chineseSpeed: Double { didSet { - speed = Self.normalizedSpeed(speed) + chineseSpeed = Self.normalizedSpeed(chineseSpeed) + } + } + var englishSpeed: Double { + didSet { + englishSpeed = Self.normalizedSpeed(englishSpeed) } } var authMode: AuthMode @@ -71,8 +77,10 @@ struct SpeechProfile: Codable, Identifiable, Hashable, Sendable { chineseVoice: String, englishVoice: String, outputFormat: SpeechOutputFormat, - instructions: String, - speed: Double, + chineseInstructions: String, + englishInstructions: String, + chineseSpeed: Double, + englishSpeed: Double, authMode: AuthMode, customHeaders: [HeaderReference], timeoutSeconds: Double @@ -88,8 +96,10 @@ struct SpeechProfile: Codable, Identifiable, Hashable, Sendable { self.chineseVoice = chineseVoice self.englishVoice = englishVoice self.outputFormat = outputFormat - self.instructions = instructions - self.speed = Self.normalizedSpeed(speed) + self.chineseInstructions = chineseInstructions + self.englishInstructions = englishInstructions + self.chineseSpeed = Self.normalizedSpeed(chineseSpeed) + self.englishSpeed = Self.normalizedSpeed(englishSpeed) self.authMode = authMode self.customHeaders = customHeaders self.timeoutSeconds = timeoutSeconds @@ -108,8 +118,10 @@ struct SpeechProfile: Codable, Identifiable, Hashable, Sendable { chineseVoice: "coral", englishVoice: "coral", outputFormat: .mp3, - instructions: "", - speed: defaultSpeed, + chineseInstructions: "", + englishInstructions: "", + chineseSpeed: defaultSpeed, + englishSpeed: defaultSpeed, authMode: .bearer, customHeaders: [], timeoutSeconds: 60 @@ -128,6 +140,10 @@ struct SpeechProfile: Codable, Identifiable, Hashable, Sendable { case chineseVoice case englishVoice case outputFormat + case chineseInstructions + case englishInstructions + case chineseSpeed + case englishSpeed case instructions case speed case authMode @@ -154,11 +170,30 @@ struct SpeechProfile: Codable, Identifiable, Hashable, Sendable { SpeechOutputFormat.self, forKey: .outputFormat ) - instructions = try container.decode(String.self, forKey: .instructions) - speed = Self.normalizedSpeed( + let legacyInstructions = try container.decodeIfPresent( + String.self, + forKey: .instructions + ) ?? "" + chineseInstructions = try container.decodeIfPresent( + String.self, + forKey: .chineseInstructions + ) ?? legacyInstructions + englishInstructions = try container.decodeIfPresent( + String.self, + forKey: .englishInstructions + ) ?? legacyInstructions + let legacySpeed = Self.normalizedSpeed( try container.decodeIfPresent(Double.self, forKey: .speed) ?? Self.defaultSpeed ) + chineseSpeed = Self.normalizedSpeed( + try container.decodeIfPresent(Double.self, forKey: .chineseSpeed) + ?? legacySpeed + ) + englishSpeed = Self.normalizedSpeed( + try container.decodeIfPresent(Double.self, forKey: .englishSpeed) + ?? legacySpeed + ) authMode = try container.decode(AuthMode.self, forKey: .authMode) customHeaders = try container.decode( [HeaderReference].self, @@ -167,6 +202,28 @@ struct SpeechProfile: Codable, Identifiable, Hashable, Sendable { timeoutSeconds = try container.decode(Double.self, forKey: .timeoutSeconds) } + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(id, forKey: .id) + try container.encode(name, forKey: .name) + try container.encode(protocolKind, forKey: .protocolKind) + try container.encode(endpointMode, forKey: .endpointMode) + try container.encode(baseURL, forKey: .baseURL) + try container.encode(path, forKey: .path) + try container.encode(fullEndpoint, forKey: .fullEndpoint) + try container.encode(model, forKey: .model) + try container.encode(chineseVoice, forKey: .chineseVoice) + try container.encode(englishVoice, forKey: .englishVoice) + try container.encode(outputFormat, forKey: .outputFormat) + try container.encode(chineseInstructions, forKey: .chineseInstructions) + try container.encode(englishInstructions, forKey: .englishInstructions) + try container.encode(chineseSpeed, forKey: .chineseSpeed) + try container.encode(englishSpeed, forKey: .englishSpeed) + try container.encode(authMode, forKey: .authMode) + try container.encode(customHeaders, forKey: .customHeaders) + try container.encode(timeoutSeconds, forKey: .timeoutSeconds) + } + static func normalizedSpeed(_ value: Double) -> Double { guard value.isFinite else { return defaultSpeed } let clamped = min(maximumSpeed, max(minimumSpeed, value)) diff --git a/TextFlow/Features/Settings/SpeechProfilesView.swift b/TextFlow/Features/Settings/SpeechProfilesView.swift index 4265e7a..0d8ccbb 100644 --- a/TextFlow/Features/Settings/SpeechProfilesView.swift +++ b/TextFlow/Features/Settings/SpeechProfilesView.swift @@ -246,27 +246,24 @@ private struct SpeechProfileEditor: View { subtitle: voiceSubtitle ) { HStack(alignment: .top, spacing: 14) { - SettingsField("中文 Voice") { - TextField(voicePlaceholder, text: $profile.chineseVoice) - .textFieldStyle(.roundedBorder) - .controlSize(.large) - } + languageVoiceColumn( + title: "中文", + systemImage: "character.book.closed", + voice: $profile.chineseVoice, + instructions: $profile.chineseInstructions, + speed: $profile.chineseSpeed + ) - SettingsField("英文 Voice") { - TextField(voicePlaceholder, text: $profile.englishVoice) - .textFieldStyle(.roundedBorder) - .controlSize(.large) - } + languageVoiceColumn( + title: "English", + systemImage: "textformat.abc", + voice: $profile.englishVoice, + instructions: $profile.englishInstructions, + speed: $profile.englishSpeed + ) } - if supportsInstructions { - SettingsField( - "Instructions", - help: "例如:Speak slowly and clearly for a language learner." - ) { - SettingsTextEditor(text: $profile.instructions, minHeight: 84) - } - } else if isDashScope { + if !supportsInstructions, isDashScope { Label( "qwen3-tts-flash 不支持朗读指令。需要慢速学习朗读时,请改用 qwen3-tts-instruct-flash。", systemImage: "info.circle" @@ -277,6 +274,78 @@ private struct SpeechProfileEditor: View { } } + private func languageVoiceColumn( + title: String, + systemImage: String, + voice: Binding, + instructions: Binding, + speed: Binding + ) -> some View { + VStack(alignment: .leading, spacing: 14) { + Label(title, systemImage: systemImage) + .font(.headline) + + SettingsField("Voice") { + TextField(voicePlaceholder, text: voice) + .textFieldStyle(.roundedBorder) + .controlSize(.large) + } + + if supportsInstructions { + SettingsField( + "Instructions", + help: title == "中文" + ? "仅在朗读中文时发送。" + : "Only sent when reading English." + ) { + SettingsTextEditor(text: instructions, minHeight: 92) + } + } + + if !isDashScope { + SettingsField( + "朗读速度", + help: "范围 0.25×–4.00×;学习建议从 0.75× 开始。" + ) { + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 10) { + Slider( + value: speed, + in: SpeechProfile.minimumSpeed...SpeechProfile.maximumSpeed, + step: 0.05 + ) + + Text("\(speed.wrappedValue, specifier: "%.2f")×") + .monospacedDigit() + .frame(width: 54, alignment: .trailing) + } + + HStack(spacing: 8) { + Button("学习 0.75×") { + speed.wrappedValue = 0.75 + } + Button("正常 1.00×") { + speed.wrappedValue = SpeechProfile.defaultSpeed + } + .disabled( + speed.wrappedValue == SpeechProfile.defaultSpeed + ) + } + .controlSize(.small) + } + } + } + } + .padding(14) + .frame(maxWidth: .infinity, alignment: .topLeading) + .background(Color(nsColor: .controlBackgroundColor).opacity(0.7)) + .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 10, style: .continuous) + .stroke(Color(nsColor: .separatorColor).opacity(0.55)) + } + } + private var authorizationCard: some View { SettingsCard( "鉴权与请求头", @@ -343,8 +412,8 @@ private struct SpeechProfileEditor: View { SettingsCard( "请求行为", subtitle: isDashScope - ? "百炼 Qwen 使用 Instructions 控制朗读方式。" - : "OpenAI-compatible Speech 可发送精确 speed;1.0× 时省略该可选字段以保持兼容。" + ? "百炼 Qwen 的输出格式由服务决定。" + : "输出格式与超时属于请求传输设置。" ) { HStack(alignment: .top, spacing: 14) { SettingsField("输出格式") { @@ -374,36 +443,6 @@ private struct SpeechProfileEditor: View { } } - if !isDashScope { - Divider() - - SettingsField( - "朗读速度", - help: "官方范围为 0.25×–4.00×;语言学习建议从 0.75× 开始。" - ) { - HStack(spacing: 12) { - Slider( - value: $profile.speed, - in: SpeechProfile.minimumSpeed...SpeechProfile.maximumSpeed, - step: 0.05 - ) - .frame(minWidth: 260) - - Text("\(profile.speed, specifier: "%.2f")×") - .monospacedDigit() - .frame(width: 54, alignment: .trailing) - - Button("学习 0.75×") { - profile.speed = 0.75 - } - - Button("正常 1.00×") { - profile.speed = SpeechProfile.defaultSpeed - } - .disabled(profile.speed == SpeechProfile.defaultSpeed) - } - } - } } } @@ -475,9 +514,9 @@ private struct SpeechProfileEditor: View { private var voiceSubtitle: String { if isDashScope { - return "Voice 区分大小写;Instruct 模型可通过 Instructions 控制语速与语气。" + return "中文和英文可独立配置 Voice;Instruct 模型还可分别配置 Instructions。" } - return "中文和英文可以使用不同 Voice;Instructions 可用于控制语速与语气。" + return "中文和英文可独立配置 Voice、Instructions 与朗读速度。" } private var modelPlaceholder: String { @@ -513,7 +552,8 @@ private struct SpeechProfileEditor: View { profile.englishVoice = "Cherry" profile.outputFormat = .wav profile.authMode = .bearer - profile.instructions = "以清晰自然的教学语气缓慢朗读,发音准确,短语之间自然停顿,句末停顿稍长。只朗读提供的文本,不添加、删除、翻译或解释任何内容。" + profile.chineseInstructions = "以清晰自然的教学语气缓慢朗读,发音准确,短语之间自然停顿,句末停顿稍长。只朗读提供的文本,不添加、删除、翻译或解释任何内容。" + profile.englishInstructions = "Read slowly in a clear, natural teaching tone with accurate pronunciation and natural pauses. Read only the provided text without adding, removing, translating, or explaining it." if profile.name == "新语音配置" { profile.name = "百炼 Qwen TTS" } diff --git a/TextFlow/Features/Speech/DashScopeQwenSpeechProvider.swift b/TextFlow/Features/Speech/DashScopeQwenSpeechProvider.swift index ea22c37..da46c3a 100644 --- a/TextFlow/Features/Speech/DashScopeQwenSpeechProvider.swift +++ b/TextFlow/Features/Speech/DashScopeQwenSpeechProvider.swift @@ -86,18 +86,21 @@ struct DashScopeQwenSpeechProvider: SpeechProviding { let voice: String let languageType: String + let languageInstructions: String switch request.language { case .chinese: voice = configuration.profile.chineseVoice languageType = "Chinese" + languageInstructions = configuration.profile.chineseInstructions case .english: voice = configuration.profile.englishVoice languageType = "English" + languageInstructions = configuration.profile.englishInstructions } let instructions: String? if configuration.profile.model.lowercased().contains("instruct"), - !configuration.profile.instructions.isEmpty { - instructions = configuration.profile.instructions + !languageInstructions.isEmpty { + instructions = languageInstructions } else { instructions = nil } diff --git a/TextFlow/Features/Speech/OpenAICompatibleSpeechProvider.swift b/TextFlow/Features/Speech/OpenAICompatibleSpeechProvider.swift index 6983b18..9fb35eb 100644 --- a/TextFlow/Features/Speech/OpenAICompatibleSpeechProvider.swift +++ b/TextFlow/Features/Speech/OpenAICompatibleSpeechProvider.swift @@ -81,21 +81,28 @@ struct OpenAICompatibleSpeechProvider: SpeechProviding { } let voice: String + let instructions: String + let speed: Double switch request.language { case .chinese: voice = configuration.profile.chineseVoice + instructions = configuration.profile.chineseInstructions + speed = SpeechProfile.normalizedSpeed( + configuration.profile.chineseSpeed + ) case .english: voice = configuration.profile.englishVoice + instructions = configuration.profile.englishInstructions + speed = SpeechProfile.normalizedSpeed( + configuration.profile.englishSpeed + ) } - let speed = SpeechProfile.normalizedSpeed(configuration.profile.speed) let body = OpenAISpeechRequestBody( model: configuration.profile.model, input: request.text, voice: voice, responseFormat: configuration.profile.outputFormat.rawValue, - instructions: configuration.profile.instructions.isEmpty - ? nil - : configuration.profile.instructions, + instructions: instructions.isEmpty ? nil : instructions, speed: speed == SpeechProfile.defaultSpeed ? nil : speed ) urlRequest.httpBody = try JSONEncoder().encode(body) diff --git a/TextFlowTests/DashScopeQwenSpeechProviderTests.swift b/TextFlowTests/DashScopeQwenSpeechProviderTests.swift index e970212..016e937 100644 --- a/TextFlowTests/DashScopeQwenSpeechProviderTests.swift +++ b/TextFlowTests/DashScopeQwenSpeechProviderTests.swift @@ -112,6 +112,41 @@ final class DashScopeQwenSpeechProviderTests: XCTestCase { XCTAssertEqual(requestCount, 2) } + func testInstructModelUsesChineseInstructionsForChineseText() async throws { + var requestCount = 0 + MockURLProtocol.handler = { request in + requestCount += 1 + if requestCount == 1 { + let body = try XCTUnwrap(try self.requestBody(request)) + let json = try XCTUnwrap( + JSONSerialization.jsonObject(with: body) as? [String: Any] + ) + let input = try XCTUnwrap(json["input"] as? [String: Any]) + XCTAssertEqual(input["language_type"] as? String, "Chinese") + XCTAssertEqual(input["instructions"] as? String, "请清晰缓慢地朗读。") + return .json(try self.metadata( + url: "https://dashscope-result-bj.oss-cn-beijing.aliyuncs.com/chinese.wav" + )) + } + return .init( + status: 200, + headers: ["Content-Type": "audio/x-wav"], + chunks: [Data([0x52, 0x49, 0x46, 0x46])], + error: nil + ) + } + let provider = DashScopeQwenSpeechProvider( + configuration: try configuration(), + session: makeMockSession() + ) + + _ = try await provider.synthesize( + SpeechRequest(text: "你好", language: .chinese) + ) + + XCTAssertEqual(requestCount, 2) + } + func testRejectsUntrustedAudioURLWithoutDownloading() async throws { var requestCount = 0 MockURLProtocol.handler = { _ in @@ -271,7 +306,8 @@ final class DashScopeQwenSpeechProviderTests: XCTestCase { profile.chineseVoice = "Cherry" profile.englishVoice = "Cherry" profile.outputFormat = .wav - profile.instructions = "Speak slowly and clearly." + profile.chineseInstructions = "请清晰缓慢地朗读。" + profile.englishInstructions = "Speak slowly and clearly." return ResolvedSpeechConfiguration( profile: profile, endpoint: try URLBuilder.speechEndpoint(for: profile), diff --git a/TextFlowTests/SpeechAudioCacheTests.swift b/TextFlowTests/SpeechAudioCacheTests.swift index bde6212..3e56ede 100644 --- a/TextFlowTests/SpeechAudioCacheTests.swift +++ b/TextFlowTests/SpeechAudioCacheTests.swift @@ -28,13 +28,13 @@ final class SpeechAudioCacheTests: XCTestCase { profile: profile ) - profile.instructions = "Speak slowly." + profile.englishInstructions = "Speak slowly." let changedPrompt = SpeechAudioCacheKey( text: "hello", language: .english, profile: profile ) - profile.speed = 0.75 + profile.englishSpeed = 0.75 let changedSpeed = SpeechAudioCacheKey( text: "hello", language: .english, diff --git a/TextFlowTests/SpeechProfileSecurityTests.swift b/TextFlowTests/SpeechProfileSecurityTests.swift index 3488f47..7fadbee 100644 --- a/TextFlowTests/SpeechProfileSecurityTests.swift +++ b/TextFlowTests/SpeechProfileSecurityTests.swift @@ -15,6 +15,15 @@ final class SpeechProfileSecurityTests: XCTestCase { XCTAssertTrue(json.contains("X-Speech-Key")) XCTAssertFalse(json.lowercased().contains("apikey")) XCTAssertFalse(json.contains("speech-secret")) + let object = try XCTUnwrap( + JSONSerialization.jsonObject(with: Data(json.utf8)) as? [String: Any] + ) + XCTAssertNil(object["instructions"]) + XCTAssertNil(object["speed"]) + XCTAssertNotNil(object["chineseInstructions"]) + XCTAssertNotNil(object["englishInstructions"]) + XCTAssertNotNil(object["chineseSpeed"]) + XCTAssertNotNil(object["englishSpeed"]) } func testLegacyProfileDefaultsToOpenAISpeechProtocol() throws { @@ -30,7 +39,8 @@ final class SpeechProfileSecurityTests: XCTestCase { "chineseVoice": "coral", "englishVoice": "coral", "outputFormat": "mp3", - "instructions": "", + "instructions": "Speak slowly.", + "speed": 0.85, "authMode": "bearer", "customHeaders": [], "timeoutSeconds": 60 @@ -43,23 +53,30 @@ final class SpeechProfileSecurityTests: XCTestCase { ) XCTAssertEqual(profile.protocolKind, .openAISpeech) - XCTAssertEqual(profile.speed, 1.0) + XCTAssertEqual(profile.chineseInstructions, "Speak slowly.") + XCTAssertEqual(profile.englishInstructions, "Speak slowly.") + XCTAssertEqual(profile.chineseSpeed, 0.85) + XCTAssertEqual(profile.englishSpeed, 0.85) } func testPersistedSpeedIsClampedToOfficialRange() throws { var profile = SpeechProfile.empty() - profile.speed = 9.0 + profile.chineseSpeed = 9.0 + profile.englishSpeed = -1.0 let encoded = try JSONEncoder().encode(profile) let decoded = try JSONDecoder().decode(SpeechProfile.self, from: encoded) - XCTAssertEqual(decoded.speed, SpeechProfile.maximumSpeed) + XCTAssertEqual(decoded.chineseSpeed, SpeechProfile.maximumSpeed) + XCTAssertEqual(decoded.englishSpeed, SpeechProfile.minimumSpeed) } func testAssignedSpeedIsNormalizedToTwoDecimalPlaces() { var profile = SpeechProfile.empty() - profile.speed = 0.8500000000000001 + profile.chineseSpeed = 0.8500000000000001 + profile.englishSpeed = 0.8000000000000002 - XCTAssertEqual(profile.speed, 0.85) + XCTAssertEqual(profile.chineseSpeed, 0.85) + XCTAssertEqual(profile.englishSpeed, 0.8) } } diff --git a/TextFlowTests/SpeechProviderIntegrationTests.swift b/TextFlowTests/SpeechProviderIntegrationTests.swift index 125fb64..f26cb59 100644 --- a/TextFlowTests/SpeechProviderIntegrationTests.swift +++ b/TextFlowTests/SpeechProviderIntegrationTests.swift @@ -60,6 +60,45 @@ final class SpeechProviderIntegrationTests: XCTestCase { ) } + func testUsesLanguageSpecificInstructionsAndSpeeds() async throws { + let cases: [( + language: SpeechLanguage, + expectedInstructions: String, + expectedSpeed: Double + )] = [ + (.chinese, "清晰缓慢地朗读。", 0.8), + (.english, "Read clearly for a learner.", 0.9) + ] + + for testCase in cases { + MockURLProtocol.handler = { request in + let body = try XCTUnwrap(try self.requestBody(request)) + let json = try XCTUnwrap( + JSONSerialization.jsonObject(with: body) as? [String: Any] + ) + XCTAssertEqual( + json["instructions"] as? String, + testCase.expectedInstructions + ) + XCTAssertEqual(json["speed"] as? Double, testCase.expectedSpeed) + return .init( + status: 200, + headers: ["Content-Type": "audio/mpeg"], + chunks: [Data([0x49, 0x44, 0x33, 0x04])], + error: nil + ) + } + let provider = OpenAICompatibleSpeechProvider( + configuration: try languageSpecificConfiguration(), + session: makeMockSession() + ) + + _ = try await provider.synthesize( + SpeechRequest(text: "Language specific.", language: testCase.language) + ) + } + } + func testSerializesSliderSpeedsWithoutBinaryFloatingPointTails() async throws { let cases: [(speed: Double, expectedJSONNumber: String)] = [ (0.8, "0.8"), @@ -224,7 +263,24 @@ final class SpeechProviderIntegrationTests: XCTestCase { var profile = SpeechProfile.empty() profile.endpointMode = .fullEndpoint profile.fullEndpoint = "https://speech.mock/v1/audio/speech" - profile.speed = speed + profile.chineseSpeed = speed + profile.englishSpeed = speed + return ResolvedSpeechConfiguration( + profile: profile, + endpoint: try URLBuilder.speechEndpoint(for: profile), + apiKey: "speech-secret", + customHeaders: ["X-Region": "local-test"] + ) + } + + private func languageSpecificConfiguration() throws -> ResolvedSpeechConfiguration { + var profile = SpeechProfile.empty() + profile.endpointMode = .fullEndpoint + profile.fullEndpoint = "https://speech.mock/v1/audio/speech" + profile.chineseInstructions = "清晰缓慢地朗读。" + profile.englishInstructions = "Read clearly for a learner." + profile.chineseSpeed = 0.8 + profile.englishSpeed = 0.9 return ResolvedSpeechConfiguration( profile: profile, endpoint: try URLBuilder.speechEndpoint(for: profile), From 21ad0b1c46154e31766f3b2acbd4607ed23e52bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20=E8=80=81=E7=81=AF?= Date: Thu, 13 Aug 2026 13:51:54 +0800 Subject: [PATCH 07/12] Make panel translation re-runnable from the panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace the SwiftUI TextEditor with an AppKit-backed original text editor so Enter re-translates the current text, while marked text (IME composition) and Shift/Option+Enter keep default behavior. - Add an explicit 翻译 button as the mouse equivalent of Enter. - Offer 重试 on failed translations and 打开设置 when no translation profile is configured. Tests: xcodebuild -project TextFlow.xcodeproj -scheme TextFlow \ -configuration Debug -derivedDataPath DerivedData \ CODE_SIGNING_ALLOWED=NO test -> Executed 90 tests, 0 failures --- TextFlow.xcodeproj/project.pbxproj | 8 ++ TextFlow/App/AppCoordinator.swift | 1 + .../Features/Panel/OriginalTextEditor.swift | 121 ++++++++++++++++++ .../Panel/TranslationPanelController.swift | 10 ++ .../Features/Panel/TranslationPanelView.swift | 91 +++++++++---- .../Panel/TranslationPanelViewModel.swift | 25 +++- .../OriginalTextSubmitPolicyTests.swift | 73 +++++++++++ .../TranslationPanelViewModelTests.swift | 113 ++++++++++++++++ 8 files changed, 412 insertions(+), 30 deletions(-) create mode 100644 TextFlow/Features/Panel/OriginalTextEditor.swift create mode 100644 TextFlowTests/OriginalTextSubmitPolicyTests.swift diff --git a/TextFlow.xcodeproj/project.pbxproj b/TextFlow.xcodeproj/project.pbxproj index 3f2ba59..6ff7805 100644 --- a/TextFlow.xcodeproj/project.pbxproj +++ b/TextFlow.xcodeproj/project.pbxproj @@ -43,6 +43,7 @@ 5311C7D45A47BB8186AF6EC5 /* TextChunker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0281D05E8C6A2FF90DE7D0F7 /* TextChunker.swift */; }; 5389A9DBECDDFF0577E184A2 /* AccessibilityTextReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CCA8BE829BDF9E9C8C207AF /* AccessibilityTextReader.swift */; }; 54FC381DA6A5F48A79177027 /* TranslationProviders.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97395BA291782A91C0631C3A /* TranslationProviders.swift */; }; + 558DDF06DA8C17DDD139EFB5 /* OriginalTextEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32C9ACA0B84DCDD429C45459 /* OriginalTextEditor.swift */; }; 5788177EC3E2C63E9FEC984E /* HotKeyShortcutTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F23FD27D74B89C872BFDB080 /* HotKeyShortcutTests.swift */; }; 57BA31F0B76DEE3956E14C45 /* OpenAICompatibleSpeechProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 92D0E094A543862D2C1D7174 /* OpenAICompatibleSpeechProvider.swift */; }; 5AD68F336A79F0E83CB25133 /* TranslationPanelController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1722C289DE6D3554A9EE02D /* TranslationPanelController.swift */; }; @@ -75,6 +76,7 @@ CCF2A7202026701F9C1154ED /* SettingsEditorComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A002F42B4FA84C8549C2219 /* SettingsEditorComponents.swift */; }; CD08B7F43DA5D31DCB3A698F /* TextFlowApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5E62521645442FE9B549428 /* TextFlowApp.swift */; }; CD22916D7BAD43BB172DB6E8 /* ClipboardSnapshotTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE252A2394B5C38F0D609D06 /* ClipboardSnapshotTests.swift */; }; + D6225DCEAC2079FBA2781B49 /* OriginalTextSubmitPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E406937970C0AB2D5F9BA08 /* OriginalTextSubmitPolicyTests.swift */; }; DA294284495299E78702C6A3 /* AudioPlaybackController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98ABD06BDE8DA356EBFF196E /* AudioPlaybackController.swift */; }; DAC1AF3BDDD8B8555B40558D /* TextChunkerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F66C02904E41992055A2325A /* TextChunkerTests.swift */; }; DBC6A7ECC15029C9AF3EFC1A /* ClipboardSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0079B3E892B4A8197352059A /* ClipboardSnapshot.swift */; }; @@ -122,10 +124,12 @@ 2B6B8BB11F60DD022BD26820 /* OCRReadingOrderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OCRReadingOrderTests.swift; sourceTree = ""; }; 2D7FEFD85A4501C85C88B649 /* SettingsWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsWindow.swift; sourceTree = ""; }; 2E9B8F0C3CD29812B3C90E2B /* PromptBuilderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PromptBuilderTests.swift; sourceTree = ""; }; + 32C9ACA0B84DCDD429C45459 /* OriginalTextEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OriginalTextEditor.swift; sourceTree = ""; }; 33CE7E21188DC5CE073B39DD /* PermissionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionsView.swift; sourceTree = ""; }; 38F1BC6EC60600F0FE795CD4 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 3B5CE66FDC6829555EA09394 /* LanguageDetectionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LanguageDetectionTests.swift; sourceTree = ""; }; 3D9DCD870DF9FEDFDE3F9814 /* TranslationRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranslationRequest.swift; sourceTree = ""; }; + 3E406937970C0AB2D5F9BA08 /* OriginalTextSubmitPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OriginalTextSubmitPolicyTests.swift; sourceTree = ""; }; 4328784D054E275DC43C1FE9 /* CoordinateConverterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoordinateConverterTests.swift; sourceTree = ""; }; 445F8C7502CD861217E285DC /* PanelPositionerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PanelPositionerTests.swift; sourceTree = ""; }; 44AB94AA6BE692A34699339F /* SpeechProfilesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpeechProfilesView.swift; sourceTree = ""; }; @@ -339,6 +343,7 @@ 9AB9465DF92D3AF03807D287 /* Panel */ = { isa = PBXGroup; children = ( + 32C9ACA0B84DCDD429C45459 /* OriginalTextEditor.swift */, 8068A26A57898F6F6A6A97B9 /* TranslationPanel.swift */, D1722C289DE6D3554A9EE02D /* TranslationPanelController.swift */, 795750DFD27EF41C442BCF6F /* TranslationPanelView.swift */, @@ -360,6 +365,7 @@ 3B5CE66FDC6829555EA09394 /* LanguageDetectionTests.swift */, 5ED1D4D6A9B3DB138738A08A /* LimitedURLSessionDataLoaderTests.swift */, 2B6B8BB11F60DD022BD26820 /* OCRReadingOrderTests.swift */, + 3E406937970C0AB2D5F9BA08 /* OriginalTextSubmitPolicyTests.swift */, 445F8C7502CD861217E285DC /* PanelPositionerTests.swift */, 475D51C2A9EC2A9FA4EC591F /* PermissionSeparationTests.swift */, 01EF3A899ABC719619822EBA /* ProfileSecretSessionStateTests.swift */, @@ -533,6 +539,7 @@ 2FA954D135D9DA76FC19AB95 /* LanguageDetectionTests.swift in Sources */, 3DBF8B3AFA47CF2A2C62A167 /* LimitedURLSessionDataLoaderTests.swift in Sources */, 4FCA25903060F20C7970C9DB /* OCRReadingOrderTests.swift in Sources */, + D6225DCEAC2079FBA2781B49 /* OriginalTextSubmitPolicyTests.swift in Sources */, AEFDE86A81708DC6D5E678AB /* PanelPositionerTests.swift in Sources */, 127AA10273B69DE928546392 /* PermissionSeparationTests.swift in Sources */, BE53C6EFC10DAABED65B9494 /* ProfileSecretSessionStateTests.swift in Sources */, @@ -577,6 +584,7 @@ E5B551B878A066C3FBA728F2 /* LimitedURLSessionDataLoader.swift in Sources */, 71ACB87E5408AD35D9831B04 /* OCRProviding.swift in Sources */, 57BA31F0B76DEE3956E14C45 /* OpenAICompatibleSpeechProvider.swift in Sources */, + 558DDF06DA8C17DDD139EFB5 /* OriginalTextEditor.swift in Sources */, 3F75409BB2EDAD0337C67A5D /* PanelPositioner.swift in Sources */, 4F5DDF73FAF7DA51C2708F05 /* PermissionService.swift in Sources */, 82D1F16FD42978739A5AB70A /* PermissionsView.swift in Sources */, diff --git a/TextFlow/App/AppCoordinator.swift b/TextFlow/App/AppCoordinator.swift index 27f7eb1..d244575 100644 --- a/TextFlow/App/AppCoordinator.swift +++ b/TextFlow/App/AppCoordinator.swift @@ -76,6 +76,7 @@ final class AppCoordinator { viewModel.onReleaseSpeech = { [weak self] in self?.stopSpeech(release: true) } viewModel.onReplaySpeech = { [weak self] in self?.replaySpeech() } viewModel.onUseLocalSpeech = { [weak self] in self?.useLocalSpeech() } + viewModel.onOpenSettings = { [weak self] in self?.showSettings() } viewModel.onCancelExternalOperations = { [weak self] in self?.cancelActiveAcquisition() } diff --git a/TextFlow/Features/Panel/OriginalTextEditor.swift b/TextFlow/Features/Panel/OriginalTextEditor.swift new file mode 100644 index 0000000..596b153 --- /dev/null +++ b/TextFlow/Features/Panel/OriginalTextEditor.swift @@ -0,0 +1,121 @@ +import AppKit +import SwiftUI + +enum OriginalTextSubmitPolicy { + static let returnKeyCode: UInt16 = 36 + static let keypadEnterKeyCode: UInt16 = 76 + + /// 回车即翻译,但输入法组字期间(marked text)必须让输入法优先处理, + /// 否则中文候选词无法上屏。Shift/Option + 回车保留换行。 + static func shouldSubmit( + keyCode: UInt16, + modifiers: NSEvent.ModifierFlags, + hasMarkedText: Bool + ) -> Bool { + guard !hasMarkedText else { return false } + guard keyCode == returnKeyCode || keyCode == keypadEnterKeyCode else { + return false + } + let flags = modifiers.intersection(.deviceIndependentFlagsMask) + return flags.intersection([.shift, .option, .command, .control]).isEmpty + } +} + +@MainActor +final class SubmittableTextView: NSTextView { + var onSubmit: (() -> Void)? + + override func keyDown(with event: NSEvent) { + guard OriginalTextSubmitPolicy.shouldSubmit( + keyCode: event.keyCode, + modifiers: event.modifierFlags, + hasMarkedText: hasMarkedText() + ) else { + super.keyDown(with: event) + return + } + onSubmit?() + } + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + guard let window, isEditable else { return } + window.initialFirstResponder = self + } +} + +struct OriginalTextEditor: NSViewRepresentable { + @Binding var text: String + var isEditable: Bool + var onSubmit: () -> Void + + func makeNSView(context: Context) -> NSScrollView { + let textView = SubmittableTextView() + textView.delegate = context.coordinator + textView.isRichText = false + textView.allowsUndo = true + textView.font = .preferredFont(forTextStyle: .body) + textView.drawsBackground = false + textView.textContainerInset = NSSize(width: 2, height: 6) + textView.isVerticallyResizable = true + textView.isHorizontallyResizable = false + textView.autoresizingMask = [.width] + textView.textContainer?.widthTracksTextView = true + textView.textContainer?.containerSize = NSSize( + width: 0, + height: CGFloat.greatestFiniteMagnitude + ) + textView.isAutomaticQuoteSubstitutionEnabled = false + textView.isAutomaticDashSubstitutionEnabled = false + textView.isAutomaticTextReplacementEnabled = false + textView.isAutomaticSpellingCorrectionEnabled = false + textView.string = text + textView.isEditable = isEditable + textView.onSubmit = onSubmit + + let scrollView = NSScrollView() + scrollView.borderType = .noBorder + scrollView.drawsBackground = false + scrollView.hasVerticalScroller = true + scrollView.hasHorizontalScroller = false + scrollView.autohidesScrollers = true + scrollView.documentView = textView + return scrollView + } + + func updateNSView(_ scrollView: NSScrollView, context: Context) { + guard let textView = scrollView.documentView as? SubmittableTextView else { + return + } + textView.onSubmit = onSubmit + if textView.string != text { + textView.string = text + } + if textView.isEditable != isEditable { + textView.isEditable = isEditable + if isEditable, textView.window?.initialFirstResponder == nil { + textView.window?.initialFirstResponder = textView + } + } + } + + func makeCoordinator() -> Coordinator { + Coordinator(text: $text) + } + + @MainActor + final class Coordinator: NSObject, NSTextViewDelegate { + private let text: Binding + + init(text: Binding) { + self.text = text + } + + func textDidChange(_ notification: Notification) { + guard let textView = notification.object as? NSTextView else { return } + let value = textView.string + guard text.wrappedValue != value else { return } + text.wrappedValue = value + } + } +} diff --git a/TextFlow/Features/Panel/TranslationPanelController.swift b/TextFlow/Features/Panel/TranslationPanelController.swift index 53525c5..f81671a 100644 --- a/TextFlow/Features/Panel/TranslationPanelController.swift +++ b/TextFlow/Features/Panel/TranslationPanelController.swift @@ -120,9 +120,19 @@ final class TranslationPanelController: NSWindowController, NSWindowDelegate { NSApp.activate(ignoringOtherApps: true) panel.makeKeyAndOrderFront(nil) panel.orderFrontRegardless() + focusOriginalTextIfNeeded(panel) recordPresentation(of: panel, activated: true) } + /// 让原文框在浮窗获得焦点后即可直接输入与回车翻译; + /// 若用户已经把焦点放在其他控件上则不抢焦点。 + private func focusOriginalTextIfNeeded(_ panel: NSWindow) { + guard let initialResponder = panel.initialFirstResponder else { return } + let current = panel.firstResponder + guard current == nil || current === panel else { return } + panel.makeFirstResponder(initialResponder) + } + private func position(_ panel: NSWindow, around anchor: CGPoint) { let screen = NSScreen.screens.first(where: { $0.frame.contains(anchor) }) ?? NSScreen.main if let visibleFrame = screen?.visibleFrame { diff --git a/TextFlow/Features/Panel/TranslationPanelView.swift b/TextFlow/Features/Panel/TranslationPanelView.swift index 6e16b56..ce5f40f 100644 --- a/TextFlow/Features/Panel/TranslationPanelView.swift +++ b/TextFlow/Features/Panel/TranslationPanelView.swift @@ -49,19 +49,26 @@ struct TranslationPanelView: View { targetLanguageControls pinControl } - TextEditor(text: $viewModel.originalText) - .font(.body) - .scrollContentBackground(.hidden) - .frame(minHeight: 72) - .overlay { - if viewModel.phase == .acquiringSelectedText { - ProgressView("正在读取选中文字…") - } else if viewModel.phase == .recognizingText { - ProgressView("正在本地识别文字…") - } + OriginalTextEditor( + text: $viewModel.originalText, + isEditable: true, + onSubmit: { viewModel.translate() } + ) + .frame(minHeight: 72) + .overlay { + if viewModel.phase == .acquiringSelectedText { + ProgressView("正在读取选中文字…") + } else if viewModel.phase == .recognizingText { + ProgressView("正在本地识别文字…") } + } speechError(for: .original) HStack { + Button("翻译") { + viewModel.translate() + } + .disabled(!viewModel.canTranslate) + .help("使用当前原文翻译(回车)") Button(copyTitle(for: .original)) { viewModel.copyOriginal() } @@ -100,22 +107,7 @@ struct TranslationPanelView: View { } .frame(minHeight: 68) - if let errorMessage = viewModel.errorMessage { - HStack(alignment: .top, spacing: 8) { - Image(systemName: "exclamationmark.triangle") - .foregroundStyle(.orange) - Text(errorMessage) - .font(.callout) - .fixedSize(horizontal: false, vertical: true) - .frame(maxWidth: .infinity, alignment: .leading) - if viewModel.canOfferOCR { - Button("开始 OCR") { - viewModel.requestOCR() - } - .fixedSize() - } - } - } + messageSection speechError(for: .translation) HStack { Button(copyTitle(for: .translation)) { @@ -133,6 +125,53 @@ struct TranslationPanelView: View { .padding(14) } + @ViewBuilder + private var messageSection: some View { + if let errorMessage = viewModel.errorMessage { + messageRow( + icon: "exclamationmark.triangle", + tint: .orange, + text: errorMessage + ) { + if viewModel.canRetryTranslation { + Button("重试") { + viewModel.translate() + } + .fixedSize() + } + if viewModel.canOpenSettings { + Button("打开设置") { + viewModel.requestOpenSettings() + } + .fixedSize() + } + if viewModel.canOfferOCR { + Button("开始 OCR") { + viewModel.requestOCR() + } + .fixedSize() + } + } + } + } + + private func messageRow( + icon: String, + tint: Color, + text: String, + @ViewBuilder actions: () -> Actions + ) -> some View { + HStack(alignment: .top, spacing: 8) { + Image(systemName: icon) + .foregroundStyle(tint) + Text(text) + .font(.callout) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + actions() + } + } + private var targetLanguageControls: some View { HStack(spacing: 6) { targetButton(.simplifiedChinese) diff --git a/TextFlow/Features/Panel/TranslationPanelViewModel.swift b/TextFlow/Features/Panel/TranslationPanelViewModel.swift index 12ea8ca..c823943 100644 --- a/TextFlow/Features/Panel/TranslationPanelViewModel.swift +++ b/TextFlow/Features/Panel/TranslationPanelViewModel.swift @@ -18,6 +18,8 @@ final class TranslationPanelViewModel: ObservableObject { @Published var isPinned = false @Published private(set) var copiedField: CopiedField? @Published private(set) var canOfferOCR = false + @Published private(set) var canRetryTranslation = false + @Published private(set) var canOpenSettings = false @Published private(set) var speechState: SpeechPlaybackState = .idle @Published private(set) var activeSpeechTarget: SpeechTarget? @Published private(set) var speechErrorMessage: String? @@ -32,6 +34,7 @@ final class TranslationPanelViewModel: ObservableObject { var onResumeSpeech: (() -> Void)? var onReplaySpeech: (() -> Void)? var onUseLocalSpeech: (() -> Void)? + var onOpenSettings: (() -> Void)? var translationRunner: TranslationRunner? var translationPresetProvider: (() -> TranslationPreset)? var onRequestClose: (() -> Void)? @@ -94,11 +97,18 @@ final class TranslationPanelViewModel: ObservableObject { canOfferOCR = true } - func show(error: Error, offerOCR: Bool = false) { + func show(error: Error, offerOCR: Bool = false, allowRetry: Bool = false) { cancelTranslation() phase = .error errorMessage = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription canOfferOCR = offerOCR + canRetryTranslation = allowRetry && canTranslate + canOpenSettings = (error as? AppError) == .configurationMissing + } + + /// 原文非空时才可以翻译;用于「翻译」按钮与回车的可用性判断。 + var canTranslate: Bool { + !originalText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } func translate() { @@ -109,8 +119,7 @@ final class TranslationPanelViewModel: ObservableObject { return } guard let translationRunner else { - phase = .error - errorMessage = "尚未配置翻译模型。请在设置中添加并测试一个翻译配置。" + show(error: AppError.configurationMissing) return } @@ -127,6 +136,8 @@ final class TranslationPanelViewModel: ObservableObject { translationUIFlushCount = 0 errorMessage = nil canOfferOCR = false + canRetryTranslation = false + canOpenSettings = false phase = .translating translationTask = Task { [weak self] in @@ -159,7 +170,7 @@ final class TranslationPanelViewModel: ObservableObject { } catch { guard let self, self.sessionID == activeSessionID else { return } self.flushTranslationDelta(sessionID: activeSessionID) - self.show(error: error) + self.show(error: error, allowRetry: true) } } } @@ -189,6 +200,10 @@ final class TranslationPanelViewModel: ObservableObject { onStartOCR?() } + func requestOpenSettings() { + onOpenSettings?() + } + func selectTargetLanguage(_ language: TargetLanguage) { targetLanguage = language if !originalText.isEmpty { @@ -270,6 +285,8 @@ final class TranslationPanelViewModel: ObservableObject { screenshot = nil errorMessage = nil canOfferOCR = false + canRetryTranslation = false + canOpenSettings = false isPreviewExpanded = false self.phase = phase } diff --git a/TextFlowTests/OriginalTextSubmitPolicyTests.swift b/TextFlowTests/OriginalTextSubmitPolicyTests.swift new file mode 100644 index 0000000..7834a79 --- /dev/null +++ b/TextFlowTests/OriginalTextSubmitPolicyTests.swift @@ -0,0 +1,73 @@ +import AppKit +import XCTest +@testable import TextFlow + +final class OriginalTextSubmitPolicyTests: XCTestCase { + func testPlainReturnSubmitsTranslation() { + XCTAssertTrue( + OriginalTextSubmitPolicy.shouldSubmit( + keyCode: OriginalTextSubmitPolicy.returnKeyCode, + modifiers: [], + hasMarkedText: false + ) + ) + XCTAssertTrue( + OriginalTextSubmitPolicy.shouldSubmit( + keyCode: OriginalTextSubmitPolicy.keypadEnterKeyCode, + modifiers: [.numericPad, .function], + hasMarkedText: false + ) + ) + } + + func testInputMethodCompositionKeepsDefaultReturnBehavior() { + XCTAssertFalse( + OriginalTextSubmitPolicy.shouldSubmit( + keyCode: OriginalTextSubmitPolicy.returnKeyCode, + modifiers: [], + hasMarkedText: true + ) + ) + } + + func testShiftAndOptionReturnInsertNewline() { + XCTAssertFalse( + OriginalTextSubmitPolicy.shouldSubmit( + keyCode: OriginalTextSubmitPolicy.returnKeyCode, + modifiers: [.shift], + hasMarkedText: false + ) + ) + XCTAssertFalse( + OriginalTextSubmitPolicy.shouldSubmit( + keyCode: OriginalTextSubmitPolicy.returnKeyCode, + modifiers: [.option], + hasMarkedText: false + ) + ) + } + + func testOtherKeysAndCommandCombinationsAreNotSubmits() { + XCTAssertFalse( + OriginalTextSubmitPolicy.shouldSubmit( + keyCode: 0, + modifiers: [], + hasMarkedText: false + ) + ) + XCTAssertFalse( + OriginalTextSubmitPolicy.shouldSubmit( + keyCode: OriginalTextSubmitPolicy.returnKeyCode, + modifiers: [.command], + hasMarkedText: false + ) + ) + XCTAssertFalse( + OriginalTextSubmitPolicy.shouldSubmit( + keyCode: OriginalTextSubmitPolicy.returnKeyCode, + modifiers: [.control], + hasMarkedText: false + ) + ) + } +} diff --git a/TextFlowTests/TranslationPanelViewModelTests.swift b/TextFlowTests/TranslationPanelViewModelTests.swift index dd55f84..0b63807 100644 --- a/TextFlowTests/TranslationPanelViewModelTests.swift +++ b/TextFlowTests/TranslationPanelViewModelTests.swift @@ -232,6 +232,119 @@ final class TranslationPanelViewModelTests: XCTestCase { XCTAssertEqual(viewModel.phase, .ready) } + func testTranslateWhileTranslatingCancelsAndRerunsWithCurrentText() async throws { + let viewModel = TranslationPanelViewModel() + var requestedTexts: [String] = [] + viewModel.translationRunner = { request in + requestedTexts.append(request.text) + return AsyncThrowingStream { continuation in + let task = Task { + if request.text == "hello" { + try? await Task.sleep(for: .seconds(1)) + } + guard !Task.isCancelled else { return } + continuation.yield(.delta(request.text.uppercased())) + continuation.yield(.completed(usage: nil)) + continuation.finish() + } + continuation.onTermination = { _ in task.cancel() } + } + } + + viewModel.showContent( + originalText: "hello", + source: .selectedText, + targetLanguage: .simplifiedChinese, + autoTranslate: true + ) + try await Task.sleep(for: .milliseconds(20)) + XCTAssertEqual(viewModel.phase, .translating) + + viewModel.originalText = "hello world" + viewModel.translate() + for _ in 0..<100 where viewModel.phase == .translating { + try await Task.sleep(for: .milliseconds(10)) + } + + XCTAssertEqual(requestedTexts, ["hello", "hello world"]) + XCTAssertEqual(viewModel.translatedText, "HELLO WORLD") + XCTAssertEqual(viewModel.phase, .ready) + } + + func testEmptyOriginalTextDisablesTranslateAction() { + let viewModel = TranslationPanelViewModel() + + XCTAssertFalse(viewModel.canTranslate) + viewModel.originalText = " \n " + XCTAssertFalse(viewModel.canTranslate) + viewModel.originalText = "hello" + XCTAssertTrue(viewModel.canTranslate) + } + + func testFailedTranslationOffersRetryThatReusesCurrentText() async throws { + let viewModel = TranslationPanelViewModel() + var attempts = 0 + var requestedTexts: [String] = [] + viewModel.translationRunner = { request in + attempts += 1 + requestedTexts.append(request.text) + let shouldFail = attempts == 1 + return AsyncThrowingStream { continuation in + if shouldFail { + continuation.finish(throwing: AppError.networkFailure) + } else { + continuation.yield(.delta("你好")) + continuation.yield(.completed(usage: nil)) + continuation.finish() + } + } + } + + viewModel.showContent( + originalText: "hello", + source: .selectedText, + targetLanguage: .simplifiedChinese, + autoTranslate: true + ) + for _ in 0..<100 where viewModel.phase != .error { + try await Task.sleep(for: .milliseconds(10)) + } + + XCTAssertEqual(viewModel.phase, .error) + XCTAssertTrue(viewModel.canRetryTranslation) + XCTAssertFalse(viewModel.canOpenSettings) + + viewModel.translate() + for _ in 0..<100 where viewModel.phase != .ready { + try await Task.sleep(for: .milliseconds(10)) + } + + XCTAssertEqual(requestedTexts, ["hello", "hello"]) + XCTAssertEqual(viewModel.translatedText, "你好") + XCTAssertFalse(viewModel.canRetryTranslation) + } + + func testMissingTranslationConfigurationOffersSettingsWithoutRetry() { + let viewModel = TranslationPanelViewModel() + var settingsRequests = 0 + viewModel.onOpenSettings = { settingsRequests += 1 } + viewModel.originalText = "hello" + + viewModel.translate() + + XCTAssertEqual(viewModel.phase, .error) + XCTAssertEqual( + viewModel.errorMessage, + AppError.configurationMissing.errorDescription + ) + XCTAssertTrue(viewModel.canOpenSettings) + XCTAssertFalse(viewModel.canRetryTranslation) + XCTAssertEqual(settingsRequests, 0) + + viewModel.requestOpenSettings() + XCTAssertEqual(settingsRequests, 1) + } + func testCancellingTranslationFlushesPendingPartialText() async throws { let viewModel = TranslationPanelViewModel() viewModel.translationRunner = { _ in From d195188ee409f02a9da6043a7f5dd82d75a683be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20=E8=80=81=E7=81=AF?= Date: Thu, 13 Aug 2026 13:56:08 +0800 Subject: [PATCH 08/12] Recover from empty selection through the clipboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - When accessibility and the ⌘C fallback both fail, offer the text that is already on the clipboard: it is filled into the original field with a source note and a 翻译剪贴板内容 action, next to 开始 OCR. Nothing is translated automatically unless the new setting says so. - New general settings: 无选中文字时自动翻译剪贴板 (off), OCR 完成后自动复制识别文字 (on), 翻译完成后自动复制译文 (off). - The panel view model now takes an injectable pasteboard so copy tests no longer touch the developer's clipboard. Tests: xcodebuild ... test -> Executed 97 tests, 0 failures --- TextFlow/App/AppCoordinator.swift | 42 +++++- .../Features/Panel/TranslationPanelView.swift | 20 +++ .../Panel/TranslationPanelViewModel.swift | 59 +++++++- .../Features/Settings/SettingsStore.swift | 25 ++++ .../Features/Settings/SettingsWindow.swift | 6 + TextFlowTests/PermissionSeparationTests.swift | 12 ++ TextFlowTests/SettingsStoreTests.swift | 22 +++ .../TranslationPanelViewModelTests.swift | 135 ++++++++++++++++++ 8 files changed, 312 insertions(+), 9 deletions(-) diff --git a/TextFlow/App/AppCoordinator.swift b/TextFlow/App/AppCoordinator.swift index d244575..6be4586 100644 --- a/TextFlow/App/AppCoordinator.swift +++ b/TextFlow/App/AppCoordinator.swift @@ -30,6 +30,15 @@ enum SelectedTextAcquisitionPolicy { guard let appError = error as? AppError else { return true } return appError != .permissionDenied(.accessibility) } + + /// 取词全部失败后可以提供给用户的剪贴板文本;空白内容视为没有可用文本。 + static func offerableClipboardText(_ value: String?) -> String? { + guard let value, + !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + return nil + } + return value + } } @MainActor @@ -56,6 +65,9 @@ final class AppCoordinator { self?.settingsStore.defaultTranslationPreset ?? TranslationPreset.defaults[1] } + viewModel.shouldAutoCopyTranslation = { [weak self] in + self?.settingsStore.autoCopyTranslation == true + } viewModel.translationRunner = { [weak self] request in guard let self else { return AsyncThrowingStream { continuation in @@ -270,12 +282,35 @@ final class AppCoordinator { } catch is CancellationError { return } catch { + guard acquisitionGeneration.contains(acquisitionID) else { return } + guard let clipboardText = SelectedTextAcquisitionPolicy + .offerableClipboardText( + NSPasteboard.general.string(forType: .string) + ) else { + guard finishAcquisition(acquisitionID) else { return } + panelViewModel.showNoSelectedText() + panelController.activate() + diagnostics.record( + .userAction, + message: "No selected text found; clipboard restored and empty" + ) + return + } + let autoTranslate = settingsStore.autoTranslateClipboardFallback + let targetLanguage = await languageDetection.targetLanguage( + for: clipboardText + ) + guard !Task.isCancelled else { return } guard finishAcquisition(acquisitionID) else { return } - panelViewModel.showNoSelectedText() + panelViewModel.showClipboardFallbackContent( + text: clipboardText, + targetLanguage: targetLanguage, + autoTranslate: autoTranslate + ) panelController.activate() diagnostics.record( .userAction, - message: "No selected text found; clipboard restored" + message: "No selected text found; offered clipboard text length=\(clipboardText.count) auto=\(autoTranslate)" ) } } @@ -315,7 +350,8 @@ final class AppCoordinator { source: .ocr, targetLanguage: targetLanguage, screenshot: settingsStore.showOCRPreview ? image : nil, - autoTranslate: settingsStore.autoTranslateOCR + autoTranslate: settingsStore.autoTranslateOCR, + autoCopyOriginal: settingsStore.autoCopyOCRText ) panelController.show(anchor: selection.globalRect.center) diagnostics.record( diff --git a/TextFlow/Features/Panel/TranslationPanelView.swift b/TextFlow/Features/Panel/TranslationPanelView.swift index ce5f40f..b3230e8 100644 --- a/TextFlow/Features/Panel/TranslationPanelView.swift +++ b/TextFlow/Features/Panel/TranslationPanelView.swift @@ -153,6 +153,26 @@ struct TranslationPanelView: View { } } } + if let noticeMessage = viewModel.noticeMessage { + messageRow( + icon: "info.circle", + tint: .secondary, + text: noticeMessage + ) { + if viewModel.canTranslateClipboardText { + Button("翻译剪贴板内容") { + viewModel.translate() + } + .fixedSize() + } + if viewModel.canOfferOCR { + Button("开始 OCR") { + viewModel.requestOCR() + } + .fixedSize() + } + } + } } private func messageRow( diff --git a/TextFlow/Features/Panel/TranslationPanelViewModel.swift b/TextFlow/Features/Panel/TranslationPanelViewModel.swift index c823943..6c3a59d 100644 --- a/TextFlow/Features/Panel/TranslationPanelViewModel.swift +++ b/TextFlow/Features/Panel/TranslationPanelViewModel.swift @@ -20,6 +20,8 @@ final class TranslationPanelViewModel: ObservableObject { @Published private(set) var canOfferOCR = false @Published private(set) var canRetryTranslation = false @Published private(set) var canOpenSettings = false + @Published private(set) var noticeMessage: String? + @Published private(set) var canTranslateClipboardText = false @Published private(set) var speechState: SpeechPlaybackState = .idle @Published private(set) var activeSpeechTarget: SpeechTarget? @Published private(set) var speechErrorMessage: String? @@ -37,8 +39,10 @@ final class TranslationPanelViewModel: ObservableObject { var onOpenSettings: (() -> Void)? var translationRunner: TranslationRunner? var translationPresetProvider: (() -> TranslationPreset)? + var shouldAutoCopyTranslation: (() -> Bool)? var onRequestClose: (() -> Void)? + private let pasteboard: NSPasteboard private var sessionID = UUID() private var translationTask: Task? private var translationFlushTask: Task? @@ -51,6 +55,10 @@ final class TranslationPanelViewModel: ObservableObject { private let translationFlushThreshold = 128 private let translationFlushDelay = Duration.milliseconds(40) + init(pasteboard: NSPasteboard = .general) { + self.pasteboard = pasteboard + } + enum CopiedField { case original case translation @@ -79,18 +87,44 @@ final class TranslationPanelViewModel: ObservableObject { source: TextSource, targetLanguage: TargetLanguage, screenshot: CGImage? = nil, - autoTranslate: Bool + autoTranslate: Bool, + autoCopyOriginal: Bool = false ) { replaceSession(phase: .ready) self.originalText = originalText self.targetLanguage = targetLanguage self.screenshot = screenshot isPreviewExpanded = false + if autoCopyOriginal { + copy(originalText, field: .original) + } if autoTranslate { translate() } } + /// 取词与 ⌘C 兜底都失败、但剪贴板里有文本时的降级入口。 + /// 默认只把内容填进原文框并说明来源,由用户决定是否翻译。 + func showClipboardFallbackContent( + text: String, + targetLanguage: TargetLanguage, + autoTranslate: Bool + ) { + showContent( + originalText: text, + source: .selectedText, + targetLanguage: targetLanguage, + autoTranslate: autoTranslate + ) + if autoTranslate { + noticeMessage = "未检测到选中文字,已改用剪贴板中的文本翻译。" + } else { + noticeMessage = "未检测到选中文字。以下内容来自剪贴板,尚未翻译。" + canTranslateClipboardText = true + canOfferOCR = true + } + } + func showNoSelectedText() { replaceSession(phase: .error) errorMessage = AppError.noSelectedText.errorDescription @@ -135,6 +169,8 @@ final class TranslationPanelViewModel: ObservableObject { pendingTranslationDelta = "" translationUIFlushCount = 0 errorMessage = nil + noticeMessage = nil + canTranslateClipboardText = false canOfferOCR = false canRetryTranslation = false canOpenSettings = false @@ -156,14 +192,16 @@ final class TranslationPanelViewModel: ObservableObject { case .completed: self.flushTranslationDelta(sessionID: activeSessionID) self.phase = .ready + self.runTranslationCompletionActions() case .warning(let message): self.errorMessage = message } } - if self?.sessionID == activeSessionID, - self?.phase == .translating { - self?.flushTranslationDelta(sessionID: activeSessionID) - self?.phase = .ready + if let self, self.sessionID == activeSessionID, + self.phase == .translating { + self.flushTranslationDelta(sessionID: activeSessionID) + self.phase = .ready + self.runTranslationCompletionActions() } } catch is CancellationError { return @@ -284,6 +322,8 @@ final class TranslationPanelViewModel: ObservableObject { translatedText = "" screenshot = nil errorMessage = nil + noticeMessage = nil + canTranslateClipboardText = false canOfferOCR = false canRetryTranslation = false canOpenSettings = false @@ -293,7 +333,6 @@ final class TranslationPanelViewModel: ObservableObject { private func copy(_ value: String, field: CopiedField) { guard !value.isEmpty else { return } - let pasteboard = NSPasteboard.general pasteboard.clearContents() pasteboard.setString(value, forType: .string) copiedField = field @@ -305,6 +344,14 @@ final class TranslationPanelViewModel: ObservableObject { } } + /// 翻译正常结束后的可选动作,全部由通用设置控制,默认不做任何事。 + private func runTranslationCompletionActions() { + guard !translatedText.isEmpty else { return } + if shouldAutoCopyTranslation?() == true { + copy(translatedText, field: .translation) + } + } + private func speak(_ text: String, target: SpeechTarget) { guard !text.isEmpty else { return } activeSpeechTarget = target diff --git a/TextFlow/Features/Settings/SettingsStore.swift b/TextFlow/Features/Settings/SettingsStore.swift index 9a779a1..12765f8 100644 --- a/TextFlow/Features/Settings/SettingsStore.swift +++ b/TextFlow/Features/Settings/SettingsStore.swift @@ -13,6 +13,9 @@ final class SettingsStore: ObservableObject { static let defaultPinned = "general.defaultPinned" static let autoTranslateOCR = "general.autoTranslateOCR" static let showOCRPreview = "general.showOCRPreview" + static let autoTranslateClipboardFallback = "general.autoTranslateClipboardFallback" + static let autoCopyOCRText = "general.autoCopyOCRText" + static let autoCopyTranslation = "general.autoCopyTranslation" static let localSpeechFallback = "general.localSpeechFallback" static let panelContentWidth = "panel.contentWidth" static let panelContentHeight = "panel.contentHeight" @@ -49,6 +52,20 @@ final class SettingsStore: ObservableObject { @Published var showOCRPreview: Bool { didSet { defaults.set(showOCRPreview, forKey: Key.showOCRPreview) } } + @Published var autoTranslateClipboardFallback: Bool { + didSet { + defaults.set( + autoTranslateClipboardFallback, + forKey: Key.autoTranslateClipboardFallback + ) + } + } + @Published var autoCopyOCRText: Bool { + didSet { defaults.set(autoCopyOCRText, forKey: Key.autoCopyOCRText) } + } + @Published var autoCopyTranslation: Bool { + didSet { defaults.set(autoCopyTranslation, forKey: Key.autoCopyTranslation) } + } @Published var localSpeechFallback: Bool { didSet { defaults.set(localSpeechFallback, forKey: Key.localSpeechFallback) } } @@ -109,6 +126,9 @@ final class SettingsStore: ObservableObject { Key.defaultPinned: false, Key.autoTranslateOCR: true, Key.showOCRPreview: true, + Key.autoTranslateClipboardFallback: false, + Key.autoCopyOCRText: true, + Key.autoCopyTranslation: false, Key.localSpeechFallback: true, Key.launchAtLogin: false, Key.panelContentWidth: Self.defaultPanelContentSize.width, @@ -125,6 +145,11 @@ final class SettingsStore: ObservableObject { defaultPinned = defaults.bool(forKey: Key.defaultPinned) autoTranslateOCR = defaults.bool(forKey: Key.autoTranslateOCR) showOCRPreview = defaults.bool(forKey: Key.showOCRPreview) + autoTranslateClipboardFallback = defaults.bool( + forKey: Key.autoTranslateClipboardFallback + ) + autoCopyOCRText = defaults.bool(forKey: Key.autoCopyOCRText) + autoCopyTranslation = defaults.bool(forKey: Key.autoCopyTranslation) localSpeechFallback = defaults.bool(forKey: Key.localSpeechFallback) selectedTextShortcut = Self.load( HotKeyShortcut.self, diff --git a/TextFlow/Features/Settings/SettingsWindow.swift b/TextFlow/Features/Settings/SettingsWindow.swift index 2a383be..7534d36 100644 --- a/TextFlow/Features/Settings/SettingsWindow.swift +++ b/TextFlow/Features/Settings/SettingsWindow.swift @@ -136,7 +136,13 @@ private struct GeneralSettingsView: View { Toggle("点击外部关闭浮窗", isOn: $settings.closeOnOutsideClick) Toggle("默认固定浮窗", isOn: $settings.defaultPinned) Toggle("OCR 完成后自动翻译", isOn: $settings.autoTranslateOCR) + Toggle("OCR 完成后自动复制识别文字", isOn: $settings.autoCopyOCRText) Toggle("显示 OCR 截图预览", isOn: $settings.showOCRPreview) + Toggle( + "无选中文字时自动翻译剪贴板", + isOn: $settings.autoTranslateClipboardFallback + ) + Toggle("翻译完成后自动复制译文", isOn: $settings.autoCopyTranslation) Toggle("允许本地语音兜底", isOn: $settings.localSpeechFallback) if let error = launchAtLogin.errorMessage { Text(error) diff --git a/TextFlowTests/PermissionSeparationTests.swift b/TextFlowTests/PermissionSeparationTests.swift index 681ecab..11c1dca 100644 --- a/TextFlowTests/PermissionSeparationTests.swift +++ b/TextFlowTests/PermissionSeparationTests.swift @@ -32,4 +32,16 @@ final class PermissionSeparationTests: XCTestCase { ) ) } + + func testOnlyNonBlankClipboardTextIsOfferedAfterFailedAcquisition() { + XCTAssertNil(SelectedTextAcquisitionPolicy.offerableClipboardText(nil)) + XCTAssertNil(SelectedTextAcquisitionPolicy.offerableClipboardText("")) + XCTAssertNil( + SelectedTextAcquisitionPolicy.offerableClipboardText(" \n\t ") + ) + XCTAssertEqual( + SelectedTextAcquisitionPolicy.offerableClipboardText(" hello "), + " hello " + ) + } } diff --git a/TextFlowTests/SettingsStoreTests.swift b/TextFlowTests/SettingsStoreTests.swift index 5d52cda..c1c6c6d 100644 --- a/TextFlowTests/SettingsStoreTests.swift +++ b/TextFlowTests/SettingsStoreTests.swift @@ -62,6 +62,28 @@ final class SettingsStoreTests: XCTestCase { ) } + func testInteractionDefaultsAreRegisteredAndPersist() { + let suiteName = "SettingsStoreInteractionTests.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defer { + defaults.removePersistentDomain(forName: suiteName) + } + + let settings = SettingsStore(defaults: defaults) + XCTAssertFalse(settings.autoTranslateClipboardFallback) + XCTAssertTrue(settings.autoCopyOCRText) + XCTAssertFalse(settings.autoCopyTranslation) + + settings.autoTranslateClipboardFallback = true + settings.autoCopyOCRText = false + settings.autoCopyTranslation = true + + let reloaded = SettingsStore(defaults: defaults) + XCTAssertTrue(reloaded.autoTranslateClipboardFallback) + XCTAssertFalse(reloaded.autoCopyOCRText) + XCTAssertTrue(reloaded.autoCopyTranslation) + } + func testSilentShortcutRollbackPersistsWithoutRecursiveNotification() { let suiteName = "SettingsStoreShortcutTests.\(UUID().uuidString)" let defaults = UserDefaults(suiteName: suiteName)! diff --git a/TextFlowTests/TranslationPanelViewModelTests.swift b/TextFlowTests/TranslationPanelViewModelTests.swift index 0b63807..1d7881a 100644 --- a/TextFlowTests/TranslationPanelViewModelTests.swift +++ b/TextFlowTests/TranslationPanelViewModelTests.swift @@ -1,8 +1,26 @@ +import AppKit import XCTest @testable import TextFlow @MainActor final class TranslationPanelViewModelTests: XCTestCase { + private var testPasteboards: [NSPasteboard] = [] + + override func tearDown() { + testPasteboards.forEach { $0.releaseGlobally() } + testPasteboards.removeAll() + super.tearDown() + } + + /// 独立剪贴板,避免单元测试破坏开发机上的系统剪贴板。 + private func makeTestPasteboard() -> NSPasteboard { + let pasteboard = NSPasteboard( + name: NSPasteboard.Name("TextFlowTests-\(UUID().uuidString)") + ) + testPasteboards.append(pasteboard) + return pasteboard + } + func testNewSessionPreventsOldTranslationFromOverwriting() async throws { let viewModel = TranslationPanelViewModel() viewModel.translationRunner = { request in @@ -345,6 +363,123 @@ final class TranslationPanelViewModelTests: XCTestCase { XCTAssertEqual(settingsRequests, 1) } + func testClipboardFallbackFillsOriginalTextWithoutTranslating() async throws { + let viewModel = TranslationPanelViewModel() + var translationRequests = 0 + viewModel.translationRunner = { _ in + translationRequests += 1 + return AsyncThrowingStream { $0.finish() } + } + + viewModel.showClipboardFallbackContent( + text: "clipboard text", + targetLanguage: .simplifiedChinese, + autoTranslate: false + ) + try await Task.sleep(for: .milliseconds(20)) + + XCTAssertEqual(viewModel.originalText, "clipboard text") + XCTAssertEqual(translationRequests, 0) + XCTAssertTrue(viewModel.canTranslateClipboardText) + XCTAssertTrue(viewModel.canOfferOCR) + XCTAssertNotNil(viewModel.noticeMessage) + XCTAssertNil(viewModel.errorMessage) + + viewModel.translate() + try await Task.sleep(for: .milliseconds(20)) + + XCTAssertEqual(translationRequests, 1) + XCTAssertNil(viewModel.noticeMessage) + XCTAssertFalse(viewModel.canTranslateClipboardText) + XCTAssertFalse(viewModel.canOfferOCR) + } + + func testClipboardFallbackTranslatesImmediatelyWhenEnabled() async throws { + let viewModel = TranslationPanelViewModel() + var requestedTexts: [String] = [] + viewModel.translationRunner = { request in + requestedTexts.append(request.text) + return AsyncThrowingStream { $0.finish() } + } + + viewModel.showClipboardFallbackContent( + text: "clipboard text", + targetLanguage: .english, + autoTranslate: true + ) + try await Task.sleep(for: .milliseconds(20)) + + XCTAssertEqual(requestedTexts, ["clipboard text"]) + XCTAssertFalse(viewModel.canTranslateClipboardText) + XCTAssertNotNil(viewModel.noticeMessage) + } + + func testOCRContentCanAutomaticallyCopyRecognizedText() { + let pasteboard = makeTestPasteboard() + let viewModel = TranslationPanelViewModel(pasteboard: pasteboard) + + viewModel.showContent( + originalText: "recognized", + source: .ocr, + targetLanguage: .simplifiedChinese, + autoTranslate: false, + autoCopyOriginal: true + ) + + XCTAssertEqual(pasteboard.string(forType: .string), "recognized") + XCTAssertEqual(viewModel.copiedField, .original) + } + + func testOCRContentDoesNotCopyWhenSettingIsOff() { + let pasteboard = makeTestPasteboard() + let viewModel = TranslationPanelViewModel(pasteboard: pasteboard) + + viewModel.showContent( + originalText: "recognized", + source: .ocr, + targetLanguage: .simplifiedChinese, + autoTranslate: false + ) + + XCTAssertNil(pasteboard.string(forType: .string)) + XCTAssertNil(viewModel.copiedField) + } + + func testCompletedTranslationCopiesTranslationOnlyWhenEnabled() async throws { + let pasteboard = makeTestPasteboard() + let viewModel = TranslationPanelViewModel(pasteboard: pasteboard) + var autoCopy = false + viewModel.shouldAutoCopyTranslation = { autoCopy } + viewModel.translationRunner = { _ in + AsyncThrowingStream { continuation in + continuation.yield(.delta("你好")) + continuation.yield(.completed(usage: nil)) + continuation.finish() + } + } + + viewModel.showContent( + originalText: "hello", + source: .selectedText, + targetLanguage: .simplifiedChinese, + autoTranslate: true + ) + for _ in 0..<100 where viewModel.phase != .ready { + try await Task.sleep(for: .milliseconds(10)) + } + XCTAssertNil(pasteboard.string(forType: .string)) + XCTAssertNil(viewModel.copiedField) + + autoCopy = true + viewModel.translate() + for _ in 0..<100 where viewModel.phase != .ready { + try await Task.sleep(for: .milliseconds(10)) + } + + XCTAssertEqual(pasteboard.string(forType: .string), "你好") + XCTAssertEqual(viewModel.copiedField, .translation) + } + func testCancellingTranslationFlushesPendingPartialText() async throws { let viewModel = TranslationPanelViewModel() viewModel.translationRunner = { _ in From bf3595bf3176b6321e0dca54bad08e4f83a3aff1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20=E8=80=81=E7=81=AF?= Date: Thu, 13 Aug 2026 13:59:52 +0800 Subject: [PATCH 09/12] Keep the panel session under the user's control MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The 更多 menu now shows the active language and marks it as selected instead of always reading 更多. - A pinned, already visible panel no longer jumps back to the mouse when a new task shows content; it only refreshes in place. - Streaming translations scroll to the bottom, and stop following once the user scrolls up during that run. - A target language picked inside the panel sticks for later ⌥D/⌥Q while the panel stays open, and returns to auto detection after it closes. - Added an in-panel preset menu that re-translates immediately and only affects the current panel session. Tests: xcodebuild ... test -> Executed 110 tests, 0 failures --- TextFlow/App/AppCoordinator.swift | 3 + TextFlow/Core/Models/TranslationSession.swift | 20 ++ .../Panel/TranslationPanelController.swift | 22 ++- .../Features/Panel/TranslationPanelView.swift | 130 ++++++++++-- .../Panel/TranslationPanelViewModel.swift | 40 +++- .../TranslationPanelControllerTests.swift | 55 ++++++ .../TranslationPanelViewModelTests.swift | 187 ++++++++++++++++++ 7 files changed, 437 insertions(+), 20 deletions(-) diff --git a/TextFlow/App/AppCoordinator.swift b/TextFlow/App/AppCoordinator.swift index 6be4586..00fb535 100644 --- a/TextFlow/App/AppCoordinator.swift +++ b/TextFlow/App/AppCoordinator.swift @@ -65,6 +65,9 @@ final class AppCoordinator { self?.settingsStore.defaultTranslationPreset ?? TranslationPreset.defaults[1] } + viewModel.availablePresetsProvider = { [weak self] in + self?.settingsStore.translationPresets ?? TranslationPreset.defaults + } viewModel.shouldAutoCopyTranslation = { [weak self] in self?.settingsStore.autoCopyTranslation == true } diff --git a/TextFlow/Core/Models/TranslationSession.swift b/TextFlow/Core/Models/TranslationSession.swift index 47813dd..295248d 100644 --- a/TextFlow/Core/Models/TranslationSession.swift +++ b/TextFlow/Core/Models/TranslationSession.swift @@ -49,6 +49,26 @@ enum TargetLanguage: String, Codable, CaseIterable, Sendable, Identifiable { } } +/// 浮窗目标语言控件的分组:两个常驻按钮 + 「更多」菜单。 +enum TargetLanguageMenu { + static let primary: [TargetLanguage] = [.simplifiedChinese, .english] + static let secondary: [TargetLanguage] = [ + .japanese, + .korean, + .traditionalChinese + ] + static let fallbackTitle = "更多" + + static func isSecondary(_ language: TargetLanguage) -> Bool { + secondary.contains(language) + } + + /// 选中「更多」里的语言后,菜单标题直接显示当前语言名。 + static func title(for language: TargetLanguage) -> String { + isSecondary(language) ? language.displayName : fallbackTitle + } +} + struct TranslationSession: Identifiable { let id: UUID let source: TextSource diff --git a/TextFlow/Features/Panel/TranslationPanelController.swift b/TextFlow/Features/Panel/TranslationPanelController.swift index f81671a..e51d4f8 100644 --- a/TextFlow/Features/Panel/TranslationPanelController.swift +++ b/TextFlow/Features/Panel/TranslationPanelController.swift @@ -2,6 +2,13 @@ import AppKit import Combine import SwiftUI +enum PanelPositionPolicy { + /// 固定中的浮窗如果已经显示,就留在用户放置的位置,不再跟随鼠标。 + static func shouldReposition(isPinned: Bool, windowIsVisible: Bool) -> Bool { + !(isPinned && windowIsVisible) + } +} + enum OutsideMonitorPolicy { static func shouldInstall( windowIsVisible: Bool, @@ -99,22 +106,33 @@ final class TranslationPanelController: NSWindowController, NSWindowDelegate { func show(anchor: CGPoint = NSEvent.mouseLocation) { guard let panel = window else { return } - position(panel, around: anchor) + repositionIfNeeded(panel, around: anchor) NSApp.activate(ignoringOtherApps: true) panel.makeKeyAndOrderFront(nil) panel.orderFrontRegardless() + focusOriginalTextIfNeeded(panel) recordPresentation(of: panel, activated: true) syncOutsideMonitor() } func showWithoutActivation(anchor: CGPoint = NSEvent.mouseLocation) { guard let panel = window else { return } - position(panel, around: anchor) + repositionIfNeeded(panel, around: anchor) panel.orderFrontRegardless() recordPresentation(of: panel, activated: false) syncOutsideMonitor() } + private func repositionIfNeeded(_ panel: NSWindow, around anchor: CGPoint) { + guard PanelPositionPolicy.shouldReposition( + isPinned: viewModel.isPinned, + windowIsVisible: panel.isVisible + ) else { + return + } + position(panel, around: anchor) + } + func activate() { guard let panel = window else { return } NSApp.activate(ignoringOtherApps: true) diff --git a/TextFlow/Features/Panel/TranslationPanelView.swift b/TextFlow/Features/Panel/TranslationPanelView.swift index b3230e8..20e08eb 100644 --- a/TextFlow/Features/Panel/TranslationPanelView.swift +++ b/TextFlow/Features/Panel/TranslationPanelView.swift @@ -1,7 +1,45 @@ import SwiftUI +/// 流式译文的滚动跟随状态:默认跟到底部,用户手动往上滚动后本次翻译不再跟随。 +struct TranslationStreamFollowState { + private(set) var isFollowing = true + private(set) var isNearBottom = true + + static let nearBottomThreshold: CGFloat = 12 + + static func isNearBottom( + contentOffset: CGFloat, + containerHeight: CGFloat, + contentHeight: CGFloat + ) -> Bool { + contentOffset + containerHeight >= contentHeight - nearBottomThreshold + } + + mutating func reset() { + isFollowing = true + isNearBottom = true + } + + mutating func updateNearBottom(_ nearBottom: Bool) { + isNearBottom = nearBottom + } + + mutating func handleScrollPhase(_ phase: ScrollPhase) { + // 只有用户自己操作滚动条时才考虑退出跟随,程序滚动(.animating)不算。 + guard phase == .tracking || phase == .interacting || phase == .decelerating else { + return + } + if !isNearBottom { + isFollowing = false + } + } +} + struct TranslationPanelView: View { @ObservedObject var viewModel: TranslationPanelViewModel + @State private var followState = TranslationStreamFollowState() + + fileprivate static let translationBottomAnchor = "translation-bottom" var body: some View { VStack(spacing: 0) { @@ -42,10 +80,11 @@ struct TranslationPanelView: View { private var sourceSection: some View { VStack(alignment: .leading, spacing: 8) { - HStack { + HStack(spacing: 8) { Text("原文") .font(.headline) - Spacer() + presetControl + Spacer(minLength: 8) targetLanguageControls pinControl } @@ -99,13 +138,7 @@ struct TranslationPanelView: View { .controlSize(.small) } } - ScrollView { - Text(viewModel.translatedText.isEmpty ? translationPlaceholder : viewModel.translatedText) - .foregroundStyle(viewModel.translatedText.isEmpty ? .secondary : .primary) - .textSelection(.enabled) - .frame(maxWidth: .infinity, alignment: .topLeading) - } - .frame(minHeight: 68) + translationScrollView messageSection speechError(for: .translation) @@ -125,6 +158,41 @@ struct TranslationPanelView: View { .padding(14) } + private var translationScrollView: some View { + ScrollViewReader { proxy in + ScrollView { + Text(viewModel.translatedText.isEmpty ? translationPlaceholder : viewModel.translatedText) + .foregroundStyle(viewModel.translatedText.isEmpty ? .secondary : .primary) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .topLeading) + Color.clear + .frame(height: 1) + .id(Self.translationBottomAnchor) + } + .onScrollGeometryChange(for: Bool.self) { geometry in + TranslationStreamFollowState.isNearBottom( + contentOffset: geometry.contentOffset.y, + containerHeight: geometry.containerSize.height, + contentHeight: geometry.contentSize.height + ) + } action: { _, nearBottom in + followState.updateNearBottom(nearBottom) + } + .onScrollPhaseChange { _, phase in + followState.handleScrollPhase(phase) + } + .onChange(of: viewModel.translatedText) { _, _ in + guard followState.isFollowing else { return } + proxy.scrollTo(Self.translationBottomAnchor, anchor: .bottom) + } + .onChange(of: viewModel.translationRunID) { _, _ in + followState.reset() + proxy.scrollTo(Self.translationBottomAnchor, anchor: .bottom) + } + } + .frame(minHeight: 68) + } + @ViewBuilder private var messageSection: some View { if let errorMessage = viewModel.errorMessage { @@ -194,16 +262,48 @@ struct TranslationPanelView: View { private var targetLanguageControls: some View { HStack(spacing: 6) { - targetButton(.simplifiedChinese) - targetButton(.english) - Menu("更多") { - targetMenuButton(.japanese) - targetMenuButton(.korean) - targetMenuButton(.traditionalChinese) + ForEach(TargetLanguageMenu.primary) { language in + targetButton(language) + } + Menu { + ForEach(TargetLanguageMenu.secondary) { language in + targetMenuButton(language) + } + } label: { + Text(TargetLanguageMenu.title(for: viewModel.targetLanguage)) + .foregroundStyle( + TargetLanguageMenu.isSecondary(viewModel.targetLanguage) + ? AnyShapeStyle(Color.accentColor) + : AnyShapeStyle(.primary) + ) } .menuStyle(.borderlessButton) .fixedSize() + .help("更多目标语言") + } + } + + private var presetControl: some View { + Menu { + ForEach(viewModel.availablePresets) { preset in + Button { + viewModel.selectPreset(preset) + } label: { + if preset.id == viewModel.activePreset.id { + Label(preset.name, systemImage: "checkmark") + } else { + Text(preset.name) + } + } + } + } label: { + Text(viewModel.activePreset.name) + .font(.callout) + .foregroundStyle(.secondary) } + .menuStyle(.borderlessButton) + .fixedSize() + .help("切换本次翻译使用的预设(不修改设置中的默认预设)") } private func targetButton(_ language: TargetLanguage) -> some View { diff --git a/TextFlow/Features/Panel/TranslationPanelViewModel.swift b/TextFlow/Features/Panel/TranslationPanelViewModel.swift index 6c3a59d..750bf3b 100644 --- a/TextFlow/Features/Panel/TranslationPanelViewModel.swift +++ b/TextFlow/Features/Panel/TranslationPanelViewModel.swift @@ -22,6 +22,10 @@ final class TranslationPanelViewModel: ObservableObject { @Published private(set) var canOpenSettings = false @Published private(set) var noticeMessage: String? @Published private(set) var canTranslateClipboardText = false + /// 每次开始翻译都会变化,供译文区重置流式跟随状态。 + @Published private(set) var translationRunID = UUID() + /// 浮窗内临时选择的翻译预设,只在浮窗保持可见期间生效。 + @Published private(set) var sessionPresetID: UUID? @Published private(set) var speechState: SpeechPlaybackState = .idle @Published private(set) var activeSpeechTarget: SpeechTarget? @Published private(set) var speechErrorMessage: String? @@ -39,11 +43,14 @@ final class TranslationPanelViewModel: ObservableObject { var onOpenSettings: (() -> Void)? var translationRunner: TranslationRunner? var translationPresetProvider: (() -> TranslationPreset)? + var availablePresetsProvider: (() -> [TranslationPreset])? var shouldAutoCopyTranslation: (() -> Bool)? var onRequestClose: (() -> Void)? private let pasteboard: NSPasteboard private var sessionID = UUID() + /// 浮窗内手动选择的目标语言,只在浮窗保持可见期间生效。 + private var manualTargetLanguage: TargetLanguage? private var translationTask: Task? private var translationFlushTask: Task? private var pendingTranslationDelta = "" @@ -92,7 +99,7 @@ final class TranslationPanelViewModel: ObservableObject { ) { replaceSession(phase: .ready) self.originalText = originalText - self.targetLanguage = targetLanguage + self.targetLanguage = manualTargetLanguage ?? targetLanguage self.screenshot = screenshot isPreviewExpanded = false if autoCopyOriginal { @@ -162,9 +169,9 @@ final class TranslationPanelViewModel: ObservableObject { sessionID: activeSessionID, text: text, targetLanguage: targetLanguage, - preset: translationPresetProvider?() - ?? TranslationPreset.defaults[1] + preset: activePreset ) + translationRunID = UUID() translatedText = "" pendingTranslationDelta = "" translationUIFlushCount = 0 @@ -244,11 +251,35 @@ final class TranslationPanelViewModel: ObservableObject { func selectTargetLanguage(_ language: TargetLanguage) { targetLanguage = language + manualTargetLanguage = language if !originalText.isEmpty { translate() } } + /// 浮窗内可用的翻译预设,以及当前会话正在使用的那个。 + var availablePresets: [TranslationPreset] { + let presets = availablePresetsProvider?() ?? [] + return presets.isEmpty ? TranslationPreset.defaults : presets + } + + var activePreset: TranslationPreset { + if let sessionPresetID, + let preset = availablePresets.first(where: { $0.id == sessionPresetID }) { + return preset + } + return translationPresetProvider?() ?? TranslationPreset.defaults[1] + } + + /// 只切换当前浮窗会话使用的预设,不改设置里的默认预设。 + func selectPreset(_ preset: TranslationPreset) { + guard sessionPresetID != preset.id else { return } + sessionPresetID = preset.id + if canTranslate { + translate() + } + } + func speakOriginal() { speak(originalText, target: .original) } @@ -301,6 +332,9 @@ final class TranslationPanelViewModel: ObservableObject { screenshot = nil speechErrorMessage = nil canOfferLocalSpeech = false + // 浮窗关闭即结束会话:目标语言恢复自动检测,预设回到设置里的默认值。 + manualTargetLanguage = nil + sessionPresetID = nil onRequestClose?() } diff --git a/TextFlowTests/TranslationPanelControllerTests.swift b/TextFlowTests/TranslationPanelControllerTests.swift index 2b37e42..d39801b 100644 --- a/TextFlowTests/TranslationPanelControllerTests.swift +++ b/TextFlowTests/TranslationPanelControllerTests.swift @@ -102,6 +102,61 @@ final class TranslationPanelControllerTests: XCTestCase { Self.retainedControllers.append(controller) } + func testPinnedVisiblePanelKeepsItsPositionOnNextShow() throws { + let suiteName = "TranslationPanelPinPositionTests-\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + + let viewModel = TranslationPanelViewModel() + let controller = TranslationPanelController( + viewModel: viewModel, + settings: SettingsStore(defaults: defaults), + diagnostics: DiagnosticsService() + ) + let panel = try XCTUnwrap(controller.window as? TranslationPanel) + let screenFrame = try XCTUnwrap(NSScreen.main?.visibleFrame) + + viewModel.isPinned = true + controller.show(anchor: CGPoint(x: screenFrame.midX, y: screenFrame.midY)) + let pinnedFrame = panel.frame + + controller.show(anchor: CGPoint(x: screenFrame.minX + 10, y: screenFrame.minY + 10)) + XCTAssertEqual(panel.frame, pinnedFrame) + + controller.showWithoutActivation( + anchor: CGPoint(x: screenFrame.maxX - 10, y: screenFrame.maxY - 10) + ) + XCTAssertEqual(panel.frame, pinnedFrame) + + viewModel.isPinned = false + controller.show(anchor: CGPoint(x: screenFrame.minX + 10, y: screenFrame.minY + 10)) + XCTAssertNotEqual(panel.frame, pinnedFrame) + + panel.orderOut(nil) + Self.retainedControllers.append(controller) + } + + func testPanelPositionPolicySkipsOnlyPinnedVisiblePanels() { + XCTAssertFalse( + PanelPositionPolicy.shouldReposition( + isPinned: true, + windowIsVisible: true + ) + ) + XCTAssertTrue( + PanelPositionPolicy.shouldReposition( + isPinned: true, + windowIsVisible: false + ) + ) + XCTAssertTrue( + PanelPositionPolicy.shouldReposition( + isPinned: false, + windowIsVisible: true + ) + ) + } + func testOutsideMonitorPolicyOnlyInstallsForVisibleUnpinnedPanel() { XCTAssertTrue( OutsideMonitorPolicy.shouldInstall( diff --git a/TextFlowTests/TranslationPanelViewModelTests.swift b/TextFlowTests/TranslationPanelViewModelTests.swift index 1d7881a..0579373 100644 --- a/TextFlowTests/TranslationPanelViewModelTests.swift +++ b/TextFlowTests/TranslationPanelViewModelTests.swift @@ -1,4 +1,5 @@ import AppKit +import SwiftUI import XCTest @testable import TextFlow @@ -480,6 +481,109 @@ final class TranslationPanelViewModelTests: XCTestCase { XCTAssertEqual(viewModel.copiedField, .translation) } + func testManualTargetLanguageSticksUntilPanelCloses() async throws { + let viewModel = TranslationPanelViewModel() + viewModel.translationRunner = { _ in + AsyncThrowingStream { $0.finish() } + } + + viewModel.showContent( + originalText: "hello", + source: .selectedText, + targetLanguage: .simplifiedChinese, + autoTranslate: false + ) + viewModel.selectTargetLanguage(.japanese) + try await Task.sleep(for: .milliseconds(20)) + + // 面板仍然可见时,后续取词的自动语言检测不再覆盖用户的选择。 + viewModel.showContent( + originalText: "second", + source: .selectedText, + targetLanguage: .english, + autoTranslate: false + ) + XCTAssertEqual(viewModel.targetLanguage, .japanese) + + viewModel.close() + viewModel.showContent( + originalText: "third", + source: .selectedText, + targetLanguage: .english, + autoTranslate: false + ) + XCTAssertEqual(viewModel.targetLanguage, .english) + } + + func testSessionPresetOverridesDefaultUntilPanelCloses() async throws { + let viewModel = TranslationPanelViewModel() + var requestedPresetIDs: [UUID] = [] + viewModel.availablePresetsProvider = { TranslationPreset.defaults } + viewModel.translationPresetProvider = { TranslationPreset.defaults[0] } + viewModel.translationRunner = { request in + requestedPresetIDs.append(request.preset.id) + return AsyncThrowingStream { $0.finish() } + } + + viewModel.showContent( + originalText: "hello", + source: .selectedText, + targetLanguage: .simplifiedChinese, + autoTranslate: true + ) + try await Task.sleep(for: .milliseconds(20)) + XCTAssertEqual(viewModel.activePreset.id, TranslationPreset.generalID) + + viewModel.selectPreset(TranslationPreset.defaults[2]) + try await Task.sleep(for: .milliseconds(20)) + + XCTAssertEqual(viewModel.activePreset.id, TranslationPreset.naturalID) + XCTAssertEqual( + requestedPresetIDs, + [TranslationPreset.generalID, TranslationPreset.naturalID] + ) + + viewModel.close() + XCTAssertNil(viewModel.sessionPresetID) + XCTAssertEqual(viewModel.activePreset.id, TranslationPreset.generalID) + } + + func testSessionPresetSwitchDoesNotTranslateEmptyOriginalText() async throws { + let viewModel = TranslationPanelViewModel() + var translationRequests = 0 + viewModel.availablePresetsProvider = { TranslationPreset.defaults } + viewModel.translationRunner = { _ in + translationRequests += 1 + return AsyncThrowingStream { $0.finish() } + } + + viewModel.selectPreset(TranslationPreset.defaults[2]) + try await Task.sleep(for: .milliseconds(20)) + + XCTAssertEqual(translationRequests, 0) + XCTAssertEqual(viewModel.activePreset.id, TranslationPreset.naturalID) + } + + func testEveryTranslationStartsANewStreamRun() async throws { + let viewModel = TranslationPanelViewModel() + viewModel.translationRunner = { _ in + AsyncThrowingStream { $0.finish() } + } + let initialRunID = viewModel.translationRunID + + viewModel.showContent( + originalText: "hello", + source: .selectedText, + targetLanguage: .simplifiedChinese, + autoTranslate: true + ) + let firstRunID = viewModel.translationRunID + XCTAssertNotEqual(firstRunID, initialRunID) + + viewModel.translate() + XCTAssertNotEqual(viewModel.translationRunID, firstRunID) + } + func testCancellingTranslationFlushesPendingPartialText() async throws { let viewModel = TranslationPanelViewModel() viewModel.translationRunner = { _ in @@ -511,6 +615,89 @@ final class TranslationPanelViewModelTests: XCTestCase { } } +final class TargetLanguageMenuTests: XCTestCase { + func testMenuTitleShowsSelectedSecondaryLanguage() { + XCTAssertEqual( + TargetLanguageMenu.title(for: .japanese), + "日本語" + ) + XCTAssertEqual( + TargetLanguageMenu.title(for: .traditionalChinese), + "繁體中文" + ) + } + + func testMenuTitleStaysGenericForPrimaryLanguages() { + XCTAssertEqual( + TargetLanguageMenu.title(for: .simplifiedChinese), + TargetLanguageMenu.fallbackTitle + ) + XCTAssertEqual( + TargetLanguageMenu.title(for: .english), + TargetLanguageMenu.fallbackTitle + ) + XCTAssertFalse(TargetLanguageMenu.isSecondary(.english)) + XCTAssertTrue(TargetLanguageMenu.isSecondary(.korean)) + } + + func testPrimaryAndSecondaryGroupsCoverEveryTargetLanguage() { + XCTAssertEqual( + Set(TargetLanguageMenu.primary + TargetLanguageMenu.secondary), + Set(TargetLanguage.allCases) + ) + } +} + +final class TranslationStreamFollowStateTests: XCTestCase { + func testFollowsBottomUntilUserScrollsUp() { + var state = TranslationStreamFollowState() + XCTAssertTrue(state.isFollowing) + + state.updateNearBottom(false) + state.handleScrollPhase(.interacting) + XCTAssertFalse(state.isFollowing) + } + + func testProgrammaticScrollAndBottomInteractionKeepFollowing() { + var state = TranslationStreamFollowState() + + state.updateNearBottom(false) + state.handleScrollPhase(.animating) + XCTAssertTrue(state.isFollowing) + + state.updateNearBottom(true) + state.handleScrollPhase(.tracking) + XCTAssertTrue(state.isFollowing) + } + + func testNewTranslationRunRestoresFollowing() { + var state = TranslationStreamFollowState() + state.updateNearBottom(false) + state.handleScrollPhase(.decelerating) + XCTAssertFalse(state.isFollowing) + + state.reset() + XCTAssertTrue(state.isFollowing) + } + + func testNearBottomUsesContainerAndContentGeometry() { + XCTAssertTrue( + TranslationStreamFollowState.isNearBottom( + contentOffset: 400, + containerHeight: 100, + contentHeight: 500 + ) + ) + XCTAssertFalse( + TranslationStreamFollowState.isNearBottom( + contentOffset: 100, + containerHeight: 100, + contentHeight: 500 + ) + ) + } +} + final class OperationGenerationTests: XCTestCase { func testStaleOperationCannotFinishNewGeneration() { var generation = OperationGeneration() From db5cb29c5a734c0fb3a05dbecd1b6e8b3ad343e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20=E8=80=81=E7=81=AF?= Date: Thu, 13 Aug 2026 14:08:30 +0800 Subject: [PATCH 10/12] Finish the OCR and keyboard details around the panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - OCR results offer 重新框选, which re-runs the full ⌥Q flow. - The selection overlay shows 拖拽框选文字 · Esc 取消 until the drag starts. - The original field is read-only while text is being captured or recognized, so results cannot overwrite what the user is typing. - The borderless panel now dispatches ⌘A/⌘C/⌘V/⌘X/⌘Z to its first responder (an accessory app has no main menu to do it) and handles ⌘⇧C as copy translation, which the copy button's help text mentions. - New general setting 翻译完成后自动朗读译文 (off). Tests: xcodebuild ... test -> Executed 117 tests, 0 failures Build: ./scripts/build-debug.sh -> BUILD SUCCEEDED --- TextFlow/App/AppCoordinator.swift | 3 + .../OCR/SelectionOverlayController.swift | 32 ++++ .../Features/Panel/TranslationPanel.swift | 74 +++++++- .../Panel/TranslationPanelController.swift | 3 + .../Features/Panel/TranslationPanelView.swift | 9 +- .../Panel/TranslationPanelViewModel.swift | 16 ++ .../Features/Settings/SettingsStore.swift | 6 + .../Features/Settings/SettingsWindow.swift | 1 + .../SelectionOverlayWindowTests.swift | 25 +++ TextFlowTests/SettingsStoreTests.swift | 3 + .../TranslationPanelControllerTests.swift | 162 ++++++++++++++++++ .../TranslationPanelViewModelTests.swift | 84 +++++++++ 12 files changed, 416 insertions(+), 2 deletions(-) diff --git a/TextFlow/App/AppCoordinator.swift b/TextFlow/App/AppCoordinator.swift index 00fb535..1271368 100644 --- a/TextFlow/App/AppCoordinator.swift +++ b/TextFlow/App/AppCoordinator.swift @@ -71,6 +71,9 @@ final class AppCoordinator { viewModel.shouldAutoCopyTranslation = { [weak self] in self?.settingsStore.autoCopyTranslation == true } + viewModel.shouldAutoSpeakTranslation = { [weak self] in + self?.settingsStore.autoSpeakTranslation == true + } viewModel.translationRunner = { [weak self] request in guard let self else { return AsyncThrowingStream { continuation in diff --git a/TextFlow/Features/OCR/SelectionOverlayController.swift b/TextFlow/Features/OCR/SelectionOverlayController.swift index fe3a7e0..98fb240 100644 --- a/TextFlow/Features/OCR/SelectionOverlayController.swift +++ b/TextFlow/Features/OCR/SelectionOverlayController.swift @@ -162,6 +162,11 @@ final class SelectionOverlayView: NSView { var onComplete: ((CGRect) -> Void)? var onCancel: (() -> Void)? + static let hintText = "拖拽框选文字 · Esc 取消" + + /// 未开始拖拽时显示操作提示,开始拖拽后隐藏。 + private(set) var showsHint = true + private var dragStart: CGPoint? private var selectionRect: CGRect = .zero @@ -171,6 +176,9 @@ final class SelectionOverlayView: NSView { override func draw(_ dirtyRect: NSRect) { NSColor.black.withAlphaComponent(0.42).setFill() bounds.fill() + if showsHint { + drawHint() + } guard !selectionRect.isEmpty else { return } NSColor.white.withAlphaComponent(0.16).setFill() selectionRect.fill() @@ -184,6 +192,7 @@ final class SelectionOverlayView: NSView { override func mouseDown(with event: NSEvent) { dragStart = constrained(convert(event.locationInWindow, from: nil)) selectionRect = .zero + showsHint = false needsDisplay = true } @@ -234,6 +243,29 @@ final class SelectionOverlayView: NSView { ) } + private func drawHint() { + let attributes: [NSAttributedString.Key: Any] = [ + .foregroundColor: NSColor.white.withAlphaComponent(0.75), + .font: NSFont.systemFont(ofSize: 15, weight: .medium) + ] + let text = Self.hintText as NSString + let size = text.size(withAttributes: attributes) + let origin = CGPoint( + x: bounds.midX - size.width / 2, + y: bounds.midY - size.height / 2 + ) + let padding = CGSize(width: 14, height: 8) + let background = NSRect( + x: origin.x - padding.width, + y: origin.y - padding.height, + width: size.width + padding.width * 2, + height: size.height + padding.height * 2 + ) + NSColor.black.withAlphaComponent(0.35).setFill() + NSBezierPath(roundedRect: background, xRadius: 8, yRadius: 8).fill() + text.draw(at: origin, withAttributes: attributes) + } + private func drawSize() { let text = "\(Int(selectionRect.width)) × \(Int(selectionRect.height))" let attributes: [NSAttributedString.Key: Any] = [ diff --git a/TextFlow/Features/Panel/TranslationPanel.swift b/TextFlow/Features/Panel/TranslationPanel.swift index 0c50861..3ab3bf4 100644 --- a/TextFlow/Features/Panel/TranslationPanel.swift +++ b/TextFlow/Features/Panel/TranslationPanel.swift @@ -1,8 +1,57 @@ import AppKit +/// accessory 应用没有主菜单,编辑类快捷键不会自动送达 borderless 面板, +/// 因此浮窗自己把它们分发给当前响应者。 +enum PanelKeyCommand: Equatable { + case selectAll + case copy + case paste + case cut + case undo + case redo + case copyTranslation + + static func command( + charactersIgnoringModifiers characters: String?, + modifiers: NSEvent.ModifierFlags + ) -> PanelKeyCommand? { + let flags = modifiers.intersection(.deviceIndependentFlagsMask) + guard flags.contains(.command), + !flags.contains(.control), + !flags.contains(.option), + let key = characters?.lowercased(), + key.count == 1 else { + return nil + } + switch (key, flags.contains(.shift)) { + case ("c", true): return .copyTranslation + case ("c", false): return .copy + case ("a", false): return .selectAll + case ("v", false): return .paste + case ("x", false): return .cut + case ("z", false): return .undo + case ("z", true): return .redo + default: return nil + } + } + + var responderSelector: Selector? { + switch self { + case .selectAll: #selector(NSText.selectAll(_:)) + case .copy: #selector(NSText.copy(_:)) + case .paste: #selector(NSText.paste(_:)) + case .cut: #selector(NSText.cut(_:)) + case .undo: Selector(("undo:")) + case .redo: Selector(("redo:")) + case .copyTranslation: nil + } + } +} + @MainActor final class TranslationPanel: NSPanel { var onEscape: (() -> Void)? + var onCopyTranslation: (() -> Void)? override var canBecomeKey: Bool { true } override var canBecomeMain: Bool { false } @@ -10,5 +59,28 @@ final class TranslationPanel: NSPanel { override func cancelOperation(_ sender: Any?) { onEscape?() } -} + override func performKeyEquivalent(with event: NSEvent) -> Bool { + guard let command = PanelKeyCommand.command( + charactersIgnoringModifiers: event.charactersIgnoringModifiers, + modifiers: event.modifierFlags + ) else { + return super.performKeyEquivalent(with: event) + } + if command == .copyTranslation { + onCopyTranslation?() + return true + } + if let selector = command.responderSelector { + if let responder = firstResponder, + responder !== self, + responder.tryToPerform(selector, with: self) { + return true + } + if NSApp.sendAction(selector, to: nil, from: self) { + return true + } + } + return super.performKeyEquivalent(with: event) + } +} diff --git a/TextFlow/Features/Panel/TranslationPanelController.swift b/TextFlow/Features/Panel/TranslationPanelController.swift index e51d4f8..d37899d 100644 --- a/TextFlow/Features/Panel/TranslationPanelController.swift +++ b/TextFlow/Features/Panel/TranslationPanelController.swift @@ -82,6 +82,9 @@ final class TranslationPanelController: NSWindowController, NSWindowDelegate { panel.onEscape = { [weak self] in self?.closePanel() } + panel.onCopyTranslation = { [weak self] in + self?.viewModel.copyTranslation() + } viewModel.isPinned = settings.defaultPinned viewModel.onRequestClose = { [weak self] in self?.closeWindowOnly() diff --git a/TextFlow/Features/Panel/TranslationPanelView.swift b/TextFlow/Features/Panel/TranslationPanelView.swift index 20e08eb..a0efe79 100644 --- a/TextFlow/Features/Panel/TranslationPanelView.swift +++ b/TextFlow/Features/Panel/TranslationPanelView.swift @@ -90,7 +90,7 @@ struct TranslationPanelView: View { } OriginalTextEditor( text: $viewModel.originalText, - isEditable: true, + isEditable: viewModel.isOriginalEditable, onSubmit: { viewModel.translate() } ) .frame(minHeight: 72) @@ -116,6 +116,12 @@ struct TranslationPanelView: View { viewModel.speakOriginal() } .disabled(viewModel.originalText.isEmpty) + if viewModel.canRecaptureOCRRegion { + Button("重新框选") { + viewModel.requestOCR() + } + .help("重新进入框选模式并识别新的区域") + } Spacer() speechControls(for: .original) } @@ -147,6 +153,7 @@ struct TranslationPanelView: View { viewModel.copyTranslation() } .disabled(viewModel.translatedText.isEmpty) + .help("复制译文(⌘⇧C)") Button("朗读") { viewModel.speakTranslation() } diff --git a/TextFlow/Features/Panel/TranslationPanelViewModel.swift b/TextFlow/Features/Panel/TranslationPanelViewModel.swift index 750bf3b..a826cc0 100644 --- a/TextFlow/Features/Panel/TranslationPanelViewModel.swift +++ b/TextFlow/Features/Panel/TranslationPanelViewModel.swift @@ -26,6 +26,7 @@ final class TranslationPanelViewModel: ObservableObject { @Published private(set) var translationRunID = UUID() /// 浮窗内临时选择的翻译预设,只在浮窗保持可见期间生效。 @Published private(set) var sessionPresetID: UUID? + @Published private(set) var textSource: TextSource? @Published private(set) var speechState: SpeechPlaybackState = .idle @Published private(set) var activeSpeechTarget: SpeechTarget? @Published private(set) var speechErrorMessage: String? @@ -45,6 +46,7 @@ final class TranslationPanelViewModel: ObservableObject { var translationPresetProvider: (() -> TranslationPreset)? var availablePresetsProvider: (() -> [TranslationPreset])? var shouldAutoCopyTranslation: (() -> Bool)? + var shouldAutoSpeakTranslation: (() -> Bool)? var onRequestClose: (() -> Void)? private let pasteboard: NSPasteboard @@ -101,6 +103,7 @@ final class TranslationPanelViewModel: ObservableObject { self.originalText = originalText self.targetLanguage = manualTargetLanguage ?? targetLanguage self.screenshot = screenshot + textSource = source isPreviewExpanded = false if autoCopyOriginal { copy(originalText, field: .original) @@ -152,6 +155,15 @@ final class TranslationPanelViewModel: ObservableObject { !originalText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } + /// 取词中与本地识别中不允许编辑原文,避免用户的输入被结果覆盖。 + var isOriginalEditable: Bool { + phase != .acquiringSelectedText && phase != .recognizingText + } + + var canRecaptureOCRRegion: Bool { + textSource == .ocr + } + func translate() { cancelTranslation(flushPending: false) let text = originalText.trimmingCharacters(in: .whitespacesAndNewlines) @@ -362,6 +374,7 @@ final class TranslationPanelViewModel: ObservableObject { canRetryTranslation = false canOpenSettings = false isPreviewExpanded = false + textSource = nil self.phase = phase } @@ -384,6 +397,9 @@ final class TranslationPanelViewModel: ObservableObject { if shouldAutoCopyTranslation?() == true { copy(translatedText, field: .translation) } + if shouldAutoSpeakTranslation?() == true { + speak(translatedText, target: .translation) + } } private func speak(_ text: String, target: SpeechTarget) { diff --git a/TextFlow/Features/Settings/SettingsStore.swift b/TextFlow/Features/Settings/SettingsStore.swift index 12765f8..7df5b2d 100644 --- a/TextFlow/Features/Settings/SettingsStore.swift +++ b/TextFlow/Features/Settings/SettingsStore.swift @@ -16,6 +16,7 @@ final class SettingsStore: ObservableObject { static let autoTranslateClipboardFallback = "general.autoTranslateClipboardFallback" static let autoCopyOCRText = "general.autoCopyOCRText" static let autoCopyTranslation = "general.autoCopyTranslation" + static let autoSpeakTranslation = "general.autoSpeakTranslation" static let localSpeechFallback = "general.localSpeechFallback" static let panelContentWidth = "panel.contentWidth" static let panelContentHeight = "panel.contentHeight" @@ -66,6 +67,9 @@ final class SettingsStore: ObservableObject { @Published var autoCopyTranslation: Bool { didSet { defaults.set(autoCopyTranslation, forKey: Key.autoCopyTranslation) } } + @Published var autoSpeakTranslation: Bool { + didSet { defaults.set(autoSpeakTranslation, forKey: Key.autoSpeakTranslation) } + } @Published var localSpeechFallback: Bool { didSet { defaults.set(localSpeechFallback, forKey: Key.localSpeechFallback) } } @@ -129,6 +133,7 @@ final class SettingsStore: ObservableObject { Key.autoTranslateClipboardFallback: false, Key.autoCopyOCRText: true, Key.autoCopyTranslation: false, + Key.autoSpeakTranslation: false, Key.localSpeechFallback: true, Key.launchAtLogin: false, Key.panelContentWidth: Self.defaultPanelContentSize.width, @@ -150,6 +155,7 @@ final class SettingsStore: ObservableObject { ) autoCopyOCRText = defaults.bool(forKey: Key.autoCopyOCRText) autoCopyTranslation = defaults.bool(forKey: Key.autoCopyTranslation) + autoSpeakTranslation = defaults.bool(forKey: Key.autoSpeakTranslation) localSpeechFallback = defaults.bool(forKey: Key.localSpeechFallback) selectedTextShortcut = Self.load( HotKeyShortcut.self, diff --git a/TextFlow/Features/Settings/SettingsWindow.swift b/TextFlow/Features/Settings/SettingsWindow.swift index 7534d36..dcf73de 100644 --- a/TextFlow/Features/Settings/SettingsWindow.swift +++ b/TextFlow/Features/Settings/SettingsWindow.swift @@ -143,6 +143,7 @@ private struct GeneralSettingsView: View { isOn: $settings.autoTranslateClipboardFallback ) Toggle("翻译完成后自动复制译文", isOn: $settings.autoCopyTranslation) + Toggle("翻译完成后自动朗读译文", isOn: $settings.autoSpeakTranslation) Toggle("允许本地语音兜底", isOn: $settings.localSpeechFallback) if let error = launchAtLogin.errorMessage { Text(error) diff --git a/TextFlowTests/SelectionOverlayWindowTests.swift b/TextFlowTests/SelectionOverlayWindowTests.swift index 27aaab5..3f8d560 100644 --- a/TextFlowTests/SelectionOverlayWindowTests.swift +++ b/TextFlowTests/SelectionOverlayWindowTests.swift @@ -19,4 +19,29 @@ final class SelectionOverlayWindowTests: XCTestCase { Self.retainedWindows.append(window) } } + + func testOverlayHintDisappearsOnceDraggingStarts() throws { + let view = SelectionOverlayView( + frame: NSRect(x: 0, y: 0, width: 400, height: 300) + ) + XCTAssertTrue(view.showsHint) + XCTAssertEqual(SelectionOverlayView.hintText, "拖拽框选文字 · Esc 取消") + + let mouseDown = try XCTUnwrap( + NSEvent.mouseEvent( + with: .leftMouseDown, + location: NSPoint(x: 20, y: 20), + modifierFlags: [], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: 0, + context: nil, + eventNumber: 0, + clickCount: 1, + pressure: 1 + ) + ) + view.mouseDown(with: mouseDown) + + XCTAssertFalse(view.showsHint) + } } diff --git a/TextFlowTests/SettingsStoreTests.swift b/TextFlowTests/SettingsStoreTests.swift index c1c6c6d..1cebecf 100644 --- a/TextFlowTests/SettingsStoreTests.swift +++ b/TextFlowTests/SettingsStoreTests.swift @@ -73,15 +73,18 @@ final class SettingsStoreTests: XCTestCase { XCTAssertFalse(settings.autoTranslateClipboardFallback) XCTAssertTrue(settings.autoCopyOCRText) XCTAssertFalse(settings.autoCopyTranslation) + XCTAssertFalse(settings.autoSpeakTranslation) settings.autoTranslateClipboardFallback = true settings.autoCopyOCRText = false settings.autoCopyTranslation = true + settings.autoSpeakTranslation = true let reloaded = SettingsStore(defaults: defaults) XCTAssertTrue(reloaded.autoTranslateClipboardFallback) XCTAssertFalse(reloaded.autoCopyOCRText) XCTAssertTrue(reloaded.autoCopyTranslation) + XCTAssertTrue(reloaded.autoSpeakTranslation) } func testSilentShortcutRollbackPersistsWithoutRecursiveNotification() { diff --git a/TextFlowTests/TranslationPanelControllerTests.swift b/TextFlowTests/TranslationPanelControllerTests.swift index d39801b..eb451ec 100644 --- a/TextFlowTests/TranslationPanelControllerTests.swift +++ b/TextFlowTests/TranslationPanelControllerTests.swift @@ -2,6 +2,30 @@ import AppKit import XCTest @testable import TextFlow +/// 只记录收到的编辑命令,避免测试写入系统剪贴板。 +@MainActor +private final class EditingCommandRecorder: NSView { + private(set) var received: [String] = [] + + override var acceptsFirstResponder: Bool { true } + + override func selectAll(_ sender: Any?) { + received.append("selectAll:") + } + + @objc func copy(_ sender: Any?) { + received.append("copy:") + } + + @objc func paste(_ sender: Any?) { + received.append("paste:") + } + + @objc func cut(_ sender: Any?) { + received.append("cut:") + } +} + @MainActor final class TranslationPanelControllerTests: XCTestCase { private static var retainedControllers: [TranslationPanelController] = [] @@ -188,6 +212,144 @@ final class TranslationPanelControllerTests: XCTestCase { ) } + func testPanelKeyCommandMappingCoversStandardEditingAndTranslationCopy() { + XCTAssertEqual( + PanelKeyCommand.command( + charactersIgnoringModifiers: "a", + modifiers: [.command] + ), + .selectAll + ) + XCTAssertEqual( + PanelKeyCommand.command( + charactersIgnoringModifiers: "c", + modifiers: [.command] + ), + .copy + ) + XCTAssertEqual( + PanelKeyCommand.command( + charactersIgnoringModifiers: "v", + modifiers: [.command] + ), + .paste + ) + XCTAssertEqual( + PanelKeyCommand.command( + charactersIgnoringModifiers: "x", + modifiers: [.command] + ), + .cut + ) + XCTAssertEqual( + PanelKeyCommand.command( + charactersIgnoringModifiers: "C", + modifiers: [.command, .shift] + ), + .copyTranslation + ) + XCTAssertNil( + PanelKeyCommand.command( + charactersIgnoringModifiers: "c", + modifiers: [] + ) + ) + XCTAssertNil( + PanelKeyCommand.command( + charactersIgnoringModifiers: "c", + modifiers: [.command, .option] + ) + ) + } + + func testCommandShiftCCopiesTranslationFromAnywhereInThePanel() throws { + let panel = TranslationPanel( + contentRect: NSRect(x: 0, y: 0, width: 480, height: 330), + styleMask: [.borderless, .resizable], + backing: .buffered, + defer: false + ) + var copyRequests = 0 + panel.onCopyTranslation = { copyRequests += 1 } + + let event = try XCTUnwrap( + Self.keyEvent( + characters: "C", + modifiers: [.command, .shift], + keyCode: 8, + window: panel + ) + ) + + XCTAssertTrue(panel.performKeyEquivalent(with: event)) + XCTAssertEqual(copyRequests, 1) + } + + func testBorderlessPanelForwardsEditingShortcutsToFirstResponder() throws { + let suiteName = "TranslationPanelEditingTests-\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + + let controller = TranslationPanelController( + viewModel: TranslationPanelViewModel(), + settings: SettingsStore(defaults: defaults), + diagnostics: DiagnosticsService() + ) + let panel = try XCTUnwrap(controller.window as? TranslationPanel) + let responder = EditingCommandRecorder( + frame: NSRect(x: 0, y: 0, width: 10, height: 10) + ) + controller.show() + panel.contentView?.addSubview(responder) + defer { + responder.removeFromSuperview() + panel.orderOut(nil) + Self.retainedControllers.append(controller) + } + XCTAssertTrue(panel.makeFirstResponder(responder)) + + for (characters, keyCode, expected) in [ + ("a", CGKeyCode(0), "selectAll:"), + ("c", CGKeyCode(8), "copy:"), + ("v", CGKeyCode(9), "paste:"), + ("x", CGKeyCode(7), "cut:") + ] { + let event = try XCTUnwrap( + Self.keyEvent( + characters: characters, + modifiers: [.command], + keyCode: UInt16(keyCode), + window: panel + ) + ) + XCTAssertTrue( + panel.performKeyEquivalent(with: event), + "⌘\(characters) 未被浮窗分发" + ) + XCTAssertEqual(responder.received.last, expected) + } + } + + private static func keyEvent( + characters: String, + modifiers: NSEvent.ModifierFlags, + keyCode: UInt16, + window: NSWindow + ) -> NSEvent? { + NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: modifiers, + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: window.windowNumber, + context: nil, + characters: characters, + charactersIgnoringModifiers: characters, + isARepeat: false, + keyCode: keyCode + ) + } + func testPanelVisualSnapshot() throws { let suiteName = "TranslationPanelVisualTests-\(UUID().uuidString)" let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) diff --git a/TextFlowTests/TranslationPanelViewModelTests.swift b/TextFlowTests/TranslationPanelViewModelTests.swift index 0579373..bcf0ab3 100644 --- a/TextFlowTests/TranslationPanelViewModelTests.swift +++ b/TextFlowTests/TranslationPanelViewModelTests.swift @@ -584,6 +584,90 @@ final class TranslationPanelViewModelTests: XCTestCase { XCTAssertNotEqual(viewModel.translationRunID, firstRunID) } + func testOnlyOCRResultsOfferRegionRecapture() { + let viewModel = TranslationPanelViewModel() + var ocrRequests = 0 + viewModel.onStartOCR = { ocrRequests += 1 } + + viewModel.showContent( + originalText: "hello", + source: .selectedText, + targetLanguage: .simplifiedChinese, + autoTranslate: false + ) + XCTAssertFalse(viewModel.canRecaptureOCRRegion) + + viewModel.showContent( + originalText: "recognized", + source: .ocr, + targetLanguage: .simplifiedChinese, + autoTranslate: false + ) + XCTAssertTrue(viewModel.canRecaptureOCRRegion) + + viewModel.requestOCR() + XCTAssertEqual(ocrRequests, 1) + + viewModel.showAcquiringSelectedText() + XCTAssertFalse(viewModel.canRecaptureOCRRegion) + } + + func testOriginalTextIsLockedWhileCapturingOrRecognizing() { + let viewModel = TranslationPanelViewModel() + + viewModel.showAcquiringSelectedText() + XCTAssertFalse(viewModel.isOriginalEditable) + + viewModel.showProgress(.recognizingText) + XCTAssertFalse(viewModel.isOriginalEditable) + + viewModel.showProgress(.capturingScreen) + XCTAssertTrue(viewModel.isOriginalEditable) + + viewModel.showContent( + originalText: "recognized", + source: .ocr, + targetLanguage: .simplifiedChinese, + autoTranslate: false + ) + XCTAssertTrue(viewModel.isOriginalEditable) + } + + func testCompletedTranslationSpeaksOnlyWhenEnabled() async throws { + let viewModel = TranslationPanelViewModel() + var autoSpeak = false + var spokenText: [String] = [] + viewModel.shouldAutoSpeakTranslation = { autoSpeak } + viewModel.onSpeakText = { spokenText.append($0) } + viewModel.translationRunner = { _ in + AsyncThrowingStream { continuation in + continuation.yield(.delta("你好")) + continuation.yield(.completed(usage: nil)) + continuation.finish() + } + } + + viewModel.showContent( + originalText: "hello", + source: .selectedText, + targetLanguage: .simplifiedChinese, + autoTranslate: true + ) + for _ in 0..<100 where viewModel.phase != .ready { + try await Task.sleep(for: .milliseconds(10)) + } + XCTAssertTrue(spokenText.isEmpty) + + autoSpeak = true + viewModel.translate() + for _ in 0..<100 where viewModel.phase != .ready { + try await Task.sleep(for: .milliseconds(10)) + } + + XCTAssertEqual(spokenText, ["你好"]) + XCTAssertEqual(viewModel.activeSpeechTarget, .translation) + } + func testCancellingTranslationFlushesPendingPartialText() async throws { let viewModel = TranslationPanelViewModel() viewModel.translationRunner = { _ in From 0292805aaef1cc858c2fe9782f603c2443e6be4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20=E8=80=81=E7=81=AF?= Date: Thu, 13 Aug 2026 14:22:04 +0800 Subject: [PATCH 11/12] Update the PRD for the panel preset switch and auto actions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - §3.1G: the panel now offers a session-scoped preset switch that re-translates immediately and never changes the default preset. - §3.1I: auto copy and auto speak are settings-driven; only OCR 完成后自动复制识别文字 defaults to on. - §3.1K: list the four new general settings with their defaults. Docs only; no behavior change. --- 01_PRD_v0.1.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/01_PRD_v0.1.md b/01_PRD_v0.1.md index 73989b2..4e0af99 100644 --- a/01_PRD_v0.1.md +++ b/01_PRD_v0.1.md @@ -134,7 +134,7 @@ v0.1 仅服务单一用户,运行在一台 M2 MacBook 上,不需要账号、 优先保持含义、语气和阅读体验,避免机械逐字对应;只输出译文。 -三个预设均只在设置页选择和编辑,并支持恢复默认。“翻译预设”页为当前默认翻译配置选择默认预设;浮窗不提供预设切换。 +三个预设均只在设置页编辑,并支持恢复默认。“翻译预设”页为当前默认翻译配置选择默认预设。浮窗提供会话级预设切换:切换后立即用当前原文重新翻译,只影响当前浮窗会话,不修改设置中的默认预设,浮窗关闭后回到默认预设。 #### H. 翻译模型配置 @@ -196,7 +196,7 @@ v0.1 仅服务单一用户,运行在一台 M2 MacBook 上,不需要账号、 - `Esc` 关闭,若正在翻译则先取消请求。 - 新快捷键任务会取消当前未完成请求并替换单一面板内容。 - 复制操作显示短暂成功状态。 -- 默认不自动复制,不自动朗读。 +- 自动复制和自动朗读全部由通用设置控制,自动执行时同样给出复制成功状态。除“OCR 完成后自动复制识别文字”默认开启外,其余自动项默认关闭;默认不自动朗读。 #### J. 语音朗读 @@ -234,7 +234,11 @@ v0.1 仅服务单一用户,运行在一台 M2 MacBook 上,不需要账号、 - 点击外部关闭浮窗 - 默认固定状态,默认关闭 - OCR 完成后自动翻译,默认开启 +- OCR 完成后自动复制识别文字,默认开启 - OCR 截图预览,默认开启 +- 无选中文字时自动翻译剪贴板,默认关闭 +- 翻译完成后自动复制译文,默认关闭 +- 翻译完成后自动朗读译文,默认关闭 - 本地 TTS 兜底,默认开启 #### L. 权限引导 From e66f9a169d68d2be7bc1b3dfca08bdffbc58cf3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20=E8=80=81=E7=81=AF?= Date: Thu, 13 Aug 2026 14:23:41 +0800 Subject: [PATCH 12/12] Document the remaining panel and fallback behavior in the PRD MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - §3.1C: describe the clipboard fallback branch and its setting, and state that no clipboard history is kept. - §3.1F: list every re-translate entry (Enter with its IME and newline rules, 翻译 button, current language, 重试) and the 更多 menu title plus the session-scoped target language. - §3.1I: refresh the panel sketch and add pinned positioning, the keyboard shortcuts, the read-only capture states, and stream auto-scroll. Docs only; no behavior change. --- 01_PRD_v0.1.md | 43 +++++++++++++++++++++++++++++-------------- 1 file changed, 29 insertions(+), 14 deletions(-) diff --git a/01_PRD_v0.1.md b/01_PRD_v0.1.md index 4e0af99..14f9246 100644 --- a/01_PRD_v0.1.md +++ b/01_PRD_v0.1.md @@ -75,9 +75,11 @@ v0.1 仅服务单一用户,运行在一台 M2 MacBook 上,不需要账号、 3. 模拟 `Command + C`。 4. 在限定时间内读取新文本。 5. 恢复原剪贴板。 -6. 仍失败时显示轻提示:“未检测到选中文字”,并提供“开始 OCR”按钮。 +6. 仍失败时分两种情况: + - 剪贴板已有文本:把该文本填入原文框,提示区注明内容来自剪贴板,并提供“翻译剪贴板内容”和“开始 OCR”按钮。默认不自动翻译;通用设置“无选中文字时自动翻译剪贴板”开启后跳过确认直接翻译。 + - 剪贴板没有文本:显示轻提示“未检测到选中文字”,并提供“开始 OCR”按钮。 -禁止在读取失败后自动触发 OCR,避免用户误框选。 +禁止在读取失败后自动触发 OCR,避免用户误框选。剪贴板兜底只读取当前剪贴板内容,不保存剪贴板历史。 #### D. OCR 框选 @@ -115,8 +117,15 @@ v0.1 仅服务单一用户,运行在一台 M2 MacBook 上,不需要账号、 浮窗目标语言控制: - 一级:中文、English -- 更多:日本語、한국어、繁體中文 -- 点击当前目标语言会使用已修改的原文重新翻译。 +- 更多:日本語、한국어、繁體中文;选中“更多”里的语言后,菜单标题显示该语言名并呈现选中态。 +- 用户在浮窗内手动切换过目标语言后,浮窗保持可见期间的后续取词与 OCR 不再被自动检测覆盖;浮窗关闭后恢复自动检测。 + +重新翻译的入口: + +- 在原文框内按回车。正在翻译时按回车会取消当前请求并用当前原文重新翻译;`Shift + 回车`、`Option + 回车` 插入换行;输入法组字期间回车交给输入法处理。 +- 点击原文区的“翻译”按钮,等价于回车。 +- 点击当前目标语言。 +- 翻译失败时点击错误提示行的“重试”。 翻译应流式显示。若第三方接口不支持流式,应兼容一次性 JSON 返回。 @@ -175,25 +184,31 @@ v0.1 仅服务单一用户,运行在一台 M2 MacBook 上,不需要账号、 建议结构: ```text -┌──────────────────────────────────────┐ -│ 原文 中文 English 更多 │ -│ 可编辑原文区域 │ -│ [复制] [朗读] │ -├──────────────────────────────────────┤ -│ 译文 [翻译中/取消] │ -│ 流式译文区域 │ -│ [复制] [朗读] │ -│ OCR 小图预览(可折叠) [固定] │ -└──────────────────────────────────────┘ +┌────────────────────────────────────────────────┐ +│ 原文 预设 中文 English 更多 [固定] │ +│ 可编辑原文区域 │ +│ [翻译] [复制] [朗读] (OCR 来源追加 [重新框选]) │ +├────────────────────────────────────────────────┤ +│ 译文 [翻译中/取消] │ +│ 流式译文区域 │ +│ 错误行:重试 / 打开设置 / 开始 OCR │ +│ 提示行:翻译剪贴板内容 / 开始 OCR │ +│ [复制] [朗读] │ +│ OCR 小图预览(可折叠) │ +└────────────────────────────────────────────────┘ ``` 行为: - 出现在鼠标或 OCR 区域附近。 - 自动避让菜单栏、Dock 和屏幕边缘。 +- 已固定且已显示的浮窗不再被重新定位,只刷新内容并保持前置。 - 点击外部自动关闭。 - 固定后点击外部不关闭。 - `Esc` 关闭,若正在翻译则先取消请求。 +- `⌘⇧C` 复制译文;原文框支持 `⌘A`、`⌘C`、`⌘V`、`⌘X`。 +- 取词中与本地识别中原文框不可编辑,结束后恢复。 +- 流式输出时译文区自动滚动到底部;用户手动向上滚动后,本次翻译不再自动跟随。 - 新快捷键任务会取消当前未完成请求并替换单一面板内容。 - 复制操作显示短暂成功状态。 - 自动复制和自动朗读全部由通用设置控制,自动执行时同样给出复制成功状态。除“OCR 完成后自动复制识别文字”默认开启外,其余自动项默认关闭;默认不自动朗读。