From bfc1a47a3158f22ab998961b2e2202c15e133dc2 Mon Sep 17 00:00:00 2001 From: zhangchaolong Date: Sat, 15 Aug 2026 21:21:25 +1200 Subject: [PATCH] =?UTF-8?q?fix:=20=E7=BF=BB=E8=AF=91=E5=A4=B1=E6=95=88?= =?UTF-8?q?=E5=90=8E=E8=83=BD=E8=87=AA=E6=84=88=20=E2=80=94=E2=80=94=20?= =?UTF-8?q?=E4=B8=8D=E5=86=8D"=E4=B8=80=E6=AC=A1=E5=A4=B1=E6=95=88?= =?UTF-8?q?=E5=B0=B1=E6=95=B4=E5=9C=BA=E7=BF=BB=E4=B8=8D=E5=87=BA=E6=9D=A5?= =?UTF-8?q?"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户反馈:经常突然不能翻译,必须去 系统设置 → 语言与地区 → 翻译语言 重新下载 语言包才恢复。 代码里有个明确的缺陷能解释这个症状:TranslationSession 在 warmUp 里建一次就一直用, 而 translate 是 `try? await s.translate(...)` —— 错误被整个吞掉、失效的 session 原样留着。于是一旦它失效,**这一场剩下的每一句都翻不出来**,只能靠用户去系统设置 折腾一圈再重开字幕。 改法:失败后丢掉旧 session、重建一次再试。真的是语言包没了(重建也失败),才退避 30s 并置 needsLanguagePackReinstall 供 UI 给可操作提示。退避逻辑抽成 TranslationRetryGate 值类型,不接 Apple 框架就能单测(+5 条,守「别每句都重建」这条不变量:边界到点即放行、 成功立刻解封、反复失败不叠加封锁)。 顺带两处: - CaptionEngine 暖机循环里的 `break` 改成「只报一次错但每条轨都试」。老写法下第一条轨 暖机失败会连累第二条轨压根不暖机,即使它本可以正常翻译。 - translate 对空串早退,免得因为空输入翻失败而白白重建一次 session。 ⚠️ **未验证根因**:我无法复现「session 失效」这件事本身,它多半在 Apple 的翻译资源 管理里。本次改的是「失效后能自愈」而不是「不再失效」—— 如果重下语言包的频率没降, 说明根因不是 session 失效,得另查。 swift build && swift test:193 tests,0 failures。 Co-Authored-By: Claude Opus 5 --- .../LiveSubtitle/Pipeline/CaptionEngine.swift | 11 ++- .../Translation/TranslationService.swift | 79 +++++++++++++++++-- .../TranslationRetryGateTests.swift | 47 +++++++++++ 3 files changed, 130 insertions(+), 7 deletions(-) create mode 100644 Tests/LiveSubtitleTests/TranslationRetryGateTests.swift diff --git a/Sources/LiveSubtitle/Pipeline/CaptionEngine.swift b/Sources/LiveSubtitle/Pipeline/CaptionEngine.swift index 8bbdd0a..f595731 100644 --- a/Sources/LiveSubtitle/Pipeline/CaptionEngine.swift +++ b/Sources/LiveSubtitle/Pipeline/CaptionEngine.swift @@ -70,9 +70,18 @@ final class CaptionEngine { // 也不会因用户没装中文语言包而弹一条与本场无关的报错。 if meetingLanguage.needsTranslation { tasks.append(Task { + var reported = false for track in tracks { do { try await track.translator.warmUp() } - catch { onError("请在 系统设置→通用→语言与地区→翻译语言 安装 中文(简体)"); break } + catch { + // 只报一次(不刷屏),但**每条轨都要试** —— 老实现在这里 break, + // 于是第一条轨暖机失败会连累第二条轨压根不暖机,即使它本可以正常翻译。 + // 且暖机失败不再是终局:translate 里会重建 session 自愈(见 TranslationService)。 + if !reported { + onError("请在 系统设置→通用→语言与地区→翻译语言 安装 中文(简体)") + reported = true + } + } } }) } diff --git a/Sources/LiveSubtitle/Translation/TranslationService.swift b/Sources/LiveSubtitle/Translation/TranslationService.swift index 662d254..d83eebd 100644 --- a/Sources/LiveSubtitle/Translation/TranslationService.swift +++ b/Sources/LiveSubtitle/Translation/TranslationService.swift @@ -1,6 +1,25 @@ import Foundation @preconcurrency import Translation +/// 重建 session 的退避闸门。抽成独立值类型只为一件事:**这段逻辑能不接 Apple 的 +/// Translation 框架单测**。它管的是「别每句话都去重建 session」这条不变量 —— +/// 写错了不会报错,只会在语言包真没了的时候把每一句都拖慢一次 prepareTranslation。 +struct TranslationRetryGate: Equatable { + /// 重建失败后的静默期。装语言包是分钟级的人工动作,30s 内重试没有意义; + /// 但也不能一次失败就永久放弃 —— 那正是 2026-08-15 要修的老毛病。 + static let cooldown: TimeInterval = 30 + + private var blockedUntil: Date? + + func canRetry(now: Date) -> Bool { + guard let blockedUntil else { return true } + return now >= blockedUntil + } + + mutating func block(now: Date) { blockedUntil = now.addingTimeInterval(Self.cooldown) } + mutating func clear() { blockedUntil = nil } +} + @MainActor final class TranslationService { private var session: TranslationSession? @@ -8,6 +27,10 @@ final class TranslationService { /// 用任务链把调用排队,保证同一 session 同一时刻只有一个 translate 在跑。 private var queue: Task? + private var gate = TranslationRetryGate() + /// 重建 session 后仍然翻不动 —— 供 UI 提示「去系统设置重下语言包」。 + private(set) var needsLanguagePackReinstall = false + enum TranslateError: Error { case notInstalled case failed @@ -15,25 +38,69 @@ final class TranslationService { /// 暖机:构造 session + prepareTranslation。语言包未装则抛 notInstalled。 func warmUp() async throws { - let s = TranslationSession(installedSource: Locale.Language(identifier: "en"), - target: Locale.Language(identifier: "zh-Hans")) do { - try await s.prepareTranslation() + session = try await Self.makeSession() } catch { throw TranslateError.notInstalled } - session = s } /// 单句英→中。返回中文;失败返回 nil(调用方回退显示原文)。调用被串行排队。 + /// + /// **失败后会重建一次 session 再试。** 这是本类的关键行为,别"顺手简化"掉: + /// 实测(用户 2026-08-15 反馈)翻译会莫名其妙整场失效,必须去 系统设置 → 语言与地区 → + /// 翻译语言 重新下载语言包才恢复。老实现是 `try? await s.translate(...)` —— 错误被吞掉、 + /// session 原样留着,于是**一次失效就永久失效**,这一场剩下的每句话都翻不出来。 + /// 换成"丢掉旧 session、重建一次再试",至少让它能自愈;真的是语言包没了,才退避 30s。 func translate(_ en: String) async -> String? { let previous = queue let task = Task { @MainActor () -> String? in _ = await previous?.value // 等前一个 translate 完成,避免并发访问 session - guard let s = session else { return nil } - return try? await s.translate(en).targetText + return await self.translateSerially(en) } queue = task return await task.value } + + /// 已在串行链上,可以安全读写 session。 + private func translateSerially(_ en: String) async -> String? { + // 空串没有翻译的必要,更不该因为它翻失败就去重建 session + guard !en.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return nil } + + if let s = session, let out = try? await s.translate(en).targetText { + return out + } + // 走到这里有两种情况:从没暖机成功过,或原来的 session 失效了。两种都靠重建解决。 + let now = Date() + guard gate.canRetry(now: now) else { return nil } + session = nil + guard let fresh = try? await Self.makeSession() else { + // 重建都失败 → 多半语言包真没了。退避,并让 UI 有机会给出可操作提示。 + gate.block(now: now) + needsLanguagePackReinstall = true + return nil + } + session = fresh + gate.clear() + guard let out = try? await fresh.translate(en).targetText else { + // 新 session 也翻不动:不是"失效"能解释的,同样退避,别每句都重建。 + gate.block(now: now) + return nil + } + needsLanguagePackReinstall = false + return out + } + + /// UI 取走提示后清零,免得同一条提示反复弹。 + func consumeReinstallHint() -> Bool { + defer { needsLanguagePackReinstall = false } + return needsLanguagePackReinstall + } + + private static func makeSession() async throws -> TranslationSession { + let s = TranslationSession(installedSource: Locale.Language(identifier: "en"), + target: Locale.Language(identifier: "zh-Hans")) + try await s.prepareTranslation() + return s + } } diff --git a/Tests/LiveSubtitleTests/TranslationRetryGateTests.swift b/Tests/LiveSubtitleTests/TranslationRetryGateTests.swift new file mode 100644 index 0000000..72ad5a5 --- /dev/null +++ b/Tests/LiveSubtitleTests/TranslationRetryGateTests.swift @@ -0,0 +1,47 @@ +import XCTest + +@testable import LiveSubtitle + +/// 退避闸门的不变量。它守的是「翻译整场失效时别每句话都去重建 session」—— +/// 写错了不报错、不崩,只会在最糟的时候(语言包真没了)把每一句都拖慢一次 +/// prepareTranslation,而那恰恰是用户已经在抱怨的场景。 +final class TranslationRetryGateTests: XCTestCase { + private let t0 = Date(timeIntervalSince1970: 1_000_000) + + func testFreshGateAllowsRetry() { + XCTAssertTrue(TranslationRetryGate().canRetry(now: t0)) + } + + func testBlockedGateRefusesWithinCooldown() { + var g = TranslationRetryGate() + g.block(now: t0) + XCTAssertFalse(g.canRetry(now: t0), "刚退避就该拦住") + XCTAssertFalse(g.canRetry(now: t0.addingTimeInterval(TranslationRetryGate.cooldown - 0.1))) + } + + func testGateReopensExactlyAtCooldownBoundary() { + var g = TranslationRetryGate() + g.block(now: t0) + XCTAssertTrue(g.canRetry(now: t0.addingTimeInterval(TranslationRetryGate.cooldown)), + "到点即放行,不多等") + } + + /// 一次成功要能立刻解除退避 —— 否则临时故障恢复后还要白等 30s。 + func testClearReopensImmediately() { + var g = TranslationRetryGate() + g.block(now: t0) + g.clear() + XCTAssertTrue(g.canRetry(now: t0)) + } + + /// 反复失败只是把静默期往后推,不会累积成越来越长的封锁。 + func testRepeatedBlocksDoNotCompound() { + var g = TranslationRetryGate() + g.block(now: t0) + let later = t0.addingTimeInterval(TranslationRetryGate.cooldown) + g.block(now: later) + XCTAssertFalse(g.canRetry(now: later)) + XCTAssertTrue(g.canRetry(now: later.addingTimeInterval(TranslationRetryGate.cooldown)), + "第二次退避从它自己那一刻起算,不叠加") + } +}