Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion Sources/LiveSubtitle/Pipeline/CaptionEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
}
})
}
Expand Down
79 changes: 73 additions & 6 deletions Sources/LiveSubtitle/Translation/TranslationService.swift
Original file line number Diff line number Diff line change
@@ -1,39 +1,106 @@
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?
/// 串行化:final 与 volatile 可能并发调 translate,单个 TranslationSession 不保证并发安全,
/// 用任务链把调用排队,保证同一 session 同一时刻只有一个 translate 在跑。
private var queue: Task<String?, Never>?

private var gate = TranslationRetryGate()
/// 重建 session 后仍然翻不动 —— 供 UI 提示「去系统设置重下语言包」。
private(set) var needsLanguagePackReinstall = false

enum TranslateError: Error {
case notInstalled
case failed
}

/// 暖机:构造 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
Comment on lines 40 to 44
}
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 }
Comment on lines +67 to +68

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
}
Comment on lines +94 to +98

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
}
}
47 changes: 47 additions & 0 deletions Tests/LiveSubtitleTests/TranslationRetryGateTests.swift
Original file line number Diff line number Diff line change
@@ -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)),
"第二次退避从它自己那一刻起算,不叠加")
}
}