diff --git a/Sources/LiveSubtitle/Audio/SystemAudioSource.swift b/Sources/LiveSubtitle/Audio/SystemAudioSource.swift index fe95834..b74b93c 100644 --- a/Sources/LiveSubtitle/Audio/SystemAudioSource.swift +++ b/Sources/LiveSubtitle/Audio/SystemAudioSource.swift @@ -11,17 +11,20 @@ final class SystemAudioSource: NSObject, AudioSource, SCStreamOutput, SCStreamDe private let converter = FormatConverter() private var stream: SCStream? private var continuation: AsyncStream.Continuation? + private var startTask: Task? + private var stopped = false // stop 已调用;start 在异步就绪后据此自拆,避免孤儿 SCStream func frames() -> AsyncStream { AsyncStream(bufferingPolicy: .bufferingNewest(32)) { cont in self.continuation = cont - Task { await self.start() } + self.startTask = Task { await self.start() } } } private func start() async { do { let content = try await SCShareableContent.current + if stopped { continuation?.finish(); return } // 解析期间已 stop → 不再建流 guard let display = content.displays.first else { onError?("未找到可采集的显示器"); continuation?.finish(); return } @@ -35,6 +38,10 @@ final class SystemAudioSource: NSObject, AudioSource, SCStreamOutput, SCStreamDe let s = SCStream(filter: filter, configuration: config, delegate: self) try s.addStreamOutput(self, type: .audio, sampleHandlerQueue: DispatchQueue(label: "sysaudio")) try await s.startCapture() + if stopped { // startCapture 期间已 stop → 立即拆掉,别留孤儿流 + try? await s.stopCapture() + continuation?.finish(); return + } stream = s } catch { onError?("系统音频采集失败:\(error.localizedDescription) — 请在 系统设置→隐私与安全性→屏幕录制 授权 LiveSubtitle") @@ -49,7 +56,10 @@ final class SystemAudioSource: NSObject, AudioSource, SCStreamOutput, SCStreamDe } func stop() async { - try? await stream?.stopCapture() + stopped = true + startTask?.cancel() + _ = await startTask?.value // 等在途 start 结束:它要么自拆、要么已把 stream 设好 + try? await stream?.stopCapture() // 兜底停掉已建好的流 stream = nil continuation?.finish() } diff --git a/Sources/LiveSubtitle/Export/DeepSeekClient.swift b/Sources/LiveSubtitle/Export/DeepSeekClient.swift new file mode 100644 index 0000000..8e6c673 --- /dev/null +++ b/Sources/LiveSubtitle/Export/DeepSeekClient.swift @@ -0,0 +1,115 @@ +import Foundation + +/// 调 DeepSeek 云 API(OpenAI 兼容)对转录做总结+优化,并自动生成 title。 +struct DeepSeekClient { + let apiKey: String + + struct Result: Sendable { + let title: String + let summary: String + } + + enum DeepSeekError: Error { + case missingKey + case http(Int, String) + case badResponse(String) + } + + private static let endpoint = URL(string: "https://api.deepseek.com/chat/completions")! + + private static let systemPrompt = """ + 你是中文会议纪要助手。你会阅读一段中英混合的实时字幕转录,输出简洁准确的中文纪要,\ + 并为这段内容起一个不超过20字的标题。\ + 你必须只返回一个 JSON 对象,格式为 {"title":"...","summary":"..."},\ + 其中 summary 使用 markdown 要点(bullet points)形式,不要输出 JSON 以外的任何内容。 + """ + + // MARK: - Request/Response Codable + + private struct RequestBody: Encodable { + struct Message: Encodable { + let role: String + let content: String + } + struct ResponseFormat: Encodable { + let type: String + } + let model: String + let messages: [Message] + let temperature: Double + let response_format: ResponseFormat + } + + private struct ChatCompletionResponse: Decodable { + struct Choice: Decodable { + struct Message: Decodable { + let content: String + } + let message: Message + } + let choices: [Choice] + } + + private struct SummaryPayload: Decodable { + let title: String + let summary: String + } + + /// 传入整段转录(中英混合),返回 { title, summary }。summary 为中文纪要式总结/优化。 + func summarize(transcript: String) async throws -> Result { + guard !apiKey.isEmpty else { throw DeepSeekError.missingKey } + + let body = RequestBody( + model: "deepseek-chat", + messages: [ + RequestBody.Message(role: "system", content: Self.systemPrompt), + RequestBody.Message(role: "user", content: transcript), + ], + temperature: 0.3, + response_format: RequestBody.ResponseFormat(type: "json_object") + ) + + var request = URLRequest(url: Self.endpoint) + request.httpMethod = "POST" + request.timeoutInterval = 30 // 避免半开网络下默认 60s 静默挂起 + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try JSONEncoder().encode(body) + + let (data, response) = try await URLSession.shared.data(for: request) + + guard let http = response as? HTTPURLResponse else { + throw DeepSeekError.badResponse("非 HTTP 响应") + } + guard http.statusCode == 200 else { + let bodyString = String(data: data, encoding: .utf8) ?? "" + throw DeepSeekError.http(http.statusCode, bodyString) + } + + let decoder = JSONDecoder() + let completion: ChatCompletionResponse + do { + completion = try decoder.decode(ChatCompletionResponse.self, from: data) + } catch { + let raw = String(data: data, encoding: .utf8) ?? "" + throw DeepSeekError.badResponse("顶层响应解析失败: \(raw)") + } + + guard let content = completion.choices.first?.message.content else { + throw DeepSeekError.badResponse("choices 为空") + } + + guard let contentData = content.data(using: .utf8) else { + throw DeepSeekError.badResponse("content 无法转为数据: \(content)") + } + + let payload: SummaryPayload + do { + payload = try decoder.decode(SummaryPayload.self, from: contentData) + } catch { + throw DeepSeekError.badResponse("content JSON 解析失败: \(content)") + } + + return Result(title: payload.title, summary: payload.summary) + } +} diff --git a/Sources/LiveSubtitle/Export/ExportCoordinator.swift b/Sources/LiveSubtitle/Export/ExportCoordinator.swift new file mode 100644 index 0000000..42c8680 --- /dev/null +++ b/Sources/LiveSubtitle/Export/ExportCoordinator.swift @@ -0,0 +1,73 @@ +import Foundation + +/// 把当前会话的转录整理(可选 DeepSeek 总结)后写入 Obsidian vault。 +/// 独立于 CaptionEngine/toggle 逻辑,仅在菜单“整理并导出到 Obsidian”触发。 +@MainActor +enum ExportCoordinator { + + /// 执行一次导出,返回给 UI 显示的中文状态文本。 + static func exportToObsidian(store: SubtitleStore) async -> String { + let finalLines = store.lines.filter { $0.isFinal } + guard !finalLines.isEmpty else { + return "没有可导出的转录" + } + + let transcript = ObsidianExporter.transcriptMarkdown(from: finalLines) + let apiKey = store.deepSeekAPIKey.trimmingCharacters(in: .whitespacesAndNewlines) + let vaultPath = store.obsidianVaultPath + let now = Date() + + // ① 总结:有 key 就调 DeepSeek,失败/无 key 回退到默认 title/summary。 + let title: String + let summary: String + if !apiKey.isEmpty { + do { + let result = try await DeepSeekClient(apiKey: apiKey).summarize(transcript: transcript) + title = result.title + summary = result.summary + } catch { + title = fallbackTitle(now) + summary = "(DeepSeek 总结失败:\(shortReason(error)),已跳过)" + } + } else { + title = fallbackTitle(now) + summary = "(未配置 DeepSeek,略过总结)" + } + + // ② 写盘:后台执行,避免阻塞主线程。 + let note = ObsidianExporter.Note( + title: title, + summary: summary, + transcriptMarkdown: transcript, + date: now + ) + do { + let url = try await Task.detached { try ObsidianExporter.write(note, toVaultPath: vaultPath) }.value + return "已导出: \(url.lastPathComponent)" + } catch let error as LocalizedError { + return error.errorDescription ?? "导出失败" + } catch { + return "导出失败: \(error.localizedDescription)" + } + } + + /// 无 DeepSeek 时的回退标题:只用时刻(日期已在文件名前缀里,避免重复)。 + /// 失败原因的简短可读描述(区分坏 key / 网络 / 限流等),不塞 http body 长串。 + private static func shortReason(_ error: Error) -> String { + if let e = error as? DeepSeekClient.DeepSeekError { + switch e { + case .missingKey: return "未配置 key" + case .http(let code, _): return "HTTP \(code)" + case .badResponse: return "响应异常" + } + } + return (error as NSError).localizedDescription // 网络类(超时/断网等) + } + + private static func fallbackTitle(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "HH:mm" + return formatter.string(from: date) + } +} diff --git a/Sources/LiveSubtitle/Export/ObsidianExporter.swift b/Sources/LiveSubtitle/Export/ObsidianExporter.swift new file mode 100644 index 0000000..5346494 --- /dev/null +++ b/Sources/LiveSubtitle/Export/ObsidianExporter.swift @@ -0,0 +1,147 @@ +import Foundation + +/// 把一次会话的转录 + DeepSeek 总结导出为一个 Obsidian vault 里的 .md 文件。 +enum ObsidianExporter { + + struct Note { + let title: String + let summary: String + let transcriptMarkdown: String + let date: Date + } + + enum ExportError: LocalizedError { + case emptyVaultPath + case vaultNotFound(String) + + var errorDescription: String? { + switch self { + case .emptyVaultPath: + return "未设置 Obsidian vault 路径" + case .vaultNotFound(let path): + return "Obsidian vault 目录不存在:\(path)" + } + } + } + + /// 从字幕行生成转录 markdown(只收 isFinal 的行)。 + /// 每行: "- **我/对方**:原文" 若有译文再 " — 译文"。 + static func transcriptMarkdown(from lines: [SubtitleLine]) -> String { + lines + .filter { $0.isFinal } + .map { line in + let speaker = displayName(for: line.speaker) + var row = "- **\(speaker)**:\(line.original)" + if let translated = line.translated, + !translated.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + row += " — \(translated)" + } + return row + } + .joined(separator: "\n") + } + + /// 写入 /-.md,返回写入的 URL。 + @discardableResult + static func write(_ note: Note, toVaultPath vaultPath: String) throws -> URL { + let trimmedVault = vaultPath.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedVault.isEmpty else { + throw ExportError.emptyVaultPath + } + + let vaultURL = URL(fileURLWithPath: trimmedVault, isDirectory: true) + var isDirectory: ObjCBool = false + let exists = FileManager.default.fileExists(atPath: vaultURL.path, isDirectory: &isDirectory) + guard exists, isDirectory.boolValue else { + throw ExportError.vaultNotFound(trimmedVault) + } + + let base = "\(fileDateString(note.date))-\(sanitize(note.title))" + let fileURL = uniqueFileURL(in: vaultURL, base: base) + + let content = fileContent(for: note) + try content.write(to: fileURL, atomically: true, encoding: .utf8) + return fileURL + } + + /// 避免覆盖同名笔记:.md 已存在则退到 -2.md / -3.md … + private static func uniqueFileURL(in dir: URL, base: String) -> URL { + let fm = FileManager.default + var candidate = dir.appendingPathComponent("\(base).md", isDirectory: false) + var n = 2 + while fm.fileExists(atPath: candidate.path) { + candidate = dir.appendingPathComponent("\(base)-\(n).md", isDirectory: false) + n += 1 + } + return candidate + } + + // MARK: - Helpers + + private static func displayName(for speaker: Speaker) -> String { + switch speaker { + case .me: return "我" + case .other: return "对方" + } + } + + private static func sanitize(_ title: String) -> String { + let trimmed = title.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return "未命名" } + + let illegal: Set = ["/", "\\", ":", "*", "?", "\"", "<", ">", "|"] + var cleaned = String(trimmed.map { illegal.contains($0) ? "-" : $0 }) + cleaned = cleaned.trimmingCharacters(in: .whitespacesAndNewlines) + + if cleaned.count > 60 { + cleaned = String(cleaned.prefix(60)) + } + cleaned = cleaned.trimmingCharacters(in: .whitespacesAndNewlines) + return cleaned.isEmpty ? "未命名" : cleaned + } + + private static func fileDateString(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "yyyy-MM-dd" + return formatter.string(from: date) + } + + private static func frontmatterDateString(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "yyyy-MM-dd HH:mm" + return formatter.string(from: date) + } + + private static func fileContent(for note: Note) -> String { + let title = note.title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + ? "未命名" + : note.title.trimmingCharacters(in: .whitespacesAndNewlines) + + var out = "---\n" + out += "title: \(yamlScalar(title))\n" + out += "date: \(frontmatterDateString(note.date))\n" + out += "tags: [livesubtitle]\n" + out += "source: LiveSubtitle\n" + out += "---\n" + out += "\n## 总结\n\n" + out += note.summary + out += "\n\n## 转录\n\n" + out += note.transcriptMarkdown + return out + } + + /// title 一律用双引号包裹并完整转义(反斜杠/引号/换行/回车/制表符)。 + /// 双引号 scalar 不会被 YAML 当成列表(- )、块(| >)、锚点(& *)、指示符(@ ` ! ?)等解析, + /// 也不会因内嵌换行断开 frontmatter,因此对 DeepSeek 返回的任意 title 都安全。 + private static func yamlScalar(_ value: String) -> String { + let escaped = value + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\"", with: "\\\"") + .replacingOccurrences(of: "\n", with: "\\n") + .replacingOccurrences(of: "\r", with: "\\r") + .replacingOccurrences(of: "\t", with: "\\t") + return "\"\(escaped)\"" + } +} diff --git a/Sources/LiveSubtitle/LiveSubtitleApp.swift b/Sources/LiveSubtitle/LiveSubtitleApp.swift index 1bcbc81..fc7eefb 100644 --- a/Sources/LiveSubtitle/LiveSubtitleApp.swift +++ b/Sources/LiveSubtitle/LiveSubtitleApp.swift @@ -8,13 +8,99 @@ struct LiveSubtitleApp: App { @State private var overlay = OverlayController() @State private var running = false @State private var status = "" + @State private var exportStatus = "" + @State private var isExporting = false + + @Environment(\.openSettings) private var openSettings var body: some Scene { - MenuBarExtra("LiveSubtitle", systemImage: "captions.bubble") { + // 普通窗口 app(不再是菜单栏 app):控制面板是一个正常窗口,Dock 里有图标。 + // 字幕本身仍是独立浮窗(OverlayController 的 bar/mini),与本窗口无关。 + WindowGroup("LiveSubtitle") { + controlPanel + } + .windowResizability(.contentSize) + + Settings { + SettingsView(store: store) + } + } + + @ViewBuilder private var controlPanel: some View { + @Bindable var s = store + VStack(alignment: .leading, spacing: 12) { Button(running ? "停止字幕" : "开始字幕") { toggle() } - if !status.isEmpty { Text(status).font(.caption) } + .buttonStyle(.borderedProminent) + .controlSize(.large) + .frame(maxWidth: .infinity) + if !status.isEmpty { + Text(status).font(.caption).foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true) + } + + Divider() + + Picker("显示", selection: $s.displayMode) { + Text("原文").tag(DisplayMode.originalOnly) + Text("双语").tag(DisplayMode.both) + Text("译文").tag(DisplayMode.translatedOnly) + }.pickerStyle(.segmented) + Picker("形态", selection: $s.overlayMode) { + Text("字幕条").tag(OverlayMode.bar) + Text("小窗").tag(OverlayMode.mini) + }.pickerStyle(.segmented) + + Toggle("边说边译(中间态)", isOn: $s.translateVolatile) + Toggle("置顶 Pin", isOn: $s.pinned) + Toggle("布局编辑(拖动字幕条)", isOn: $s.layoutEditing) + Divider() - Button("退出") { NSApplication.shared.terminate(nil) } + + slider("透明度", value: $s.opacity, in: 0.4...1.0, display: "\(Int(store.opacity * 100))%") + slider("字号", value: $s.fontSize, in: 16...32, step: 1, display: "\(Int(store.fontSize))") + if store.overlayMode == .bar { + slider("字幕条宽度", value: $s.barWidth, in: 600...1400, step: 20, display: "\(Int(store.barWidth))") + } + + Divider() + + Button("整理并导出到 Obsidian") { exportToObsidian() } + .disabled(isExporting) + .frame(maxWidth: .infinity) + if !exportStatus.isEmpty { + Text(exportStatus).font(.caption).foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true) + } + Button("设置…") { openSettings() } // 退出走 app 菜单 / ⌘Q + } + .padding(16) + .frame(width: 300) + } + + /// 带右侧数值的紧凑滑块行(.window 样式下 Slider 正常可用)。 + @ViewBuilder private func slider(_ title: String, value: Binding, + in range: ClosedRange, step: Double? = nil, + display: String) -> some View { + VStack(alignment: .leading, spacing: 2) { + HStack { + Text(title).font(.caption) + Spacer() + Text(display).font(.caption).foregroundStyle(.secondary).monospacedDigit() + } + if let step { + Slider(value: value, in: range, step: step) + } else { + Slider(value: value, in: range) + } + } + } + + @MainActor private func exportToObsidian() { + guard !isExporting else { return } // 防重入:进行中不再并发触发(避免重复上云 + 竞争写盘) + isExporting = true + exportStatus = "正在整理…" + Task { + let result = await ExportCoordinator.exportToObsidian(store: store) + exportStatus = result + isExporting = false } } @@ -22,6 +108,10 @@ struct LiveSubtitleApp: App { if running { engine?.stop(); engine = nil; overlay.hide(); running = false; status = "" } else { + // 权限在此刻(真正要用时)请求,不在启动时。 + // 启动阶段同时拉起麦克风 + 屏幕录制两个 TCC 流程会导致 MenuBarExtra 的状态栏项建不出来 + // (实测:单独请求任一项都正常,两项同时请求则菜单栏无图标,延后请求也无效)。 + PermissionsManager.requestAll() let e = CaptionEngine(store: store) engine = e overlay.show(store: store) @@ -30,3 +120,4 @@ struct LiveSubtitleApp: App { } } } + diff --git a/Sources/LiveSubtitle/Models/SubtitleModels.swift b/Sources/LiveSubtitle/Models/SubtitleModels.swift index 858e93a..e7f9848 100644 --- a/Sources/LiveSubtitle/Models/SubtitleModels.swift +++ b/Sources/LiveSubtitle/Models/SubtitleModels.swift @@ -1,8 +1,13 @@ import Foundation enum Speaker: Sendable, Equatable { case me, other } // 麦克风=me,系统声=other -enum DisplayMode: Sendable { case originalOnly, both, translatedOnly } -enum OverlayMode: Sendable { case bar, mini } +enum DisplayMode: String, Sendable, CaseIterable { case originalOnly, both, translatedOnly } +enum OverlayMode: String, Sendable, CaseIterable { case bar, mini } + +extension DisplayMode { + var showsOriginal: Bool { self != .translatedOnly } + var showsTranslated: Bool { self != .originalOnly } +} /// 跨 actor 的 Sendable 音频载体(不直接传 AVAudioPCMBuffer,后者非 Sendable)。 /// pcm 为已转换到 analyzer 目标格式(16k/Int16/单声道)的样本。 @@ -18,10 +23,18 @@ struct SubtitleLine: Identifiable, Sendable { var original: String var translated: String? var isFinal: Bool + /// 翻译已尝试但失败(区别于「尚未翻译/翻译中」)。UI 据此回退显原文而非永久「翻译中…」。 + var translationFailed: Bool + /// 当前 translated 来自中间态的半句译文(临时)。终句成句译文到位前为 true; + /// 若终句重译失败,这份半句译文不可当作定稿译文,应回退显原文。 + var translationProvisional: Bool init(id: UUID = UUID(), speaker: Speaker, original: String, - translated: String? = nil, isFinal: Bool = false) { + translated: String? = nil, isFinal: Bool = false, + translationFailed: Bool = false, translationProvisional: Bool = false) { self.id = id; self.speaker = speaker; self.original = original self.translated = translated; self.isFinal = isFinal + self.translationFailed = translationFailed + self.translationProvisional = translationProvisional } } diff --git a/Sources/LiveSubtitle/Models/SubtitleStore.swift b/Sources/LiveSubtitle/Models/SubtitleStore.swift index fb87220..ddd20ea 100644 --- a/Sources/LiveSubtitle/Models/SubtitleStore.swift +++ b/Sources/LiveSubtitle/Models/SubtitleStore.swift @@ -4,16 +4,51 @@ import Observation @MainActor @Observable final class SubtitleStore { + private let defaults: UserDefaults private(set) var lines: [SubtitleLine] = [] // 全量内存历史(不落盘) - var displayMode: DisplayMode = .both // Phase 1 固定 - var overlayMode: OverlayMode = .bar - /// 每个 speaker 的"当前未定稿灰字行"索引;定稿后清除。 - private var volatileIndex: [Speaker: Int] = [:] + var displayMode: DisplayMode { didSet { defaults.set(displayMode.rawValue, forKey: "ls.displayMode") } } + var overlayMode: OverlayMode { didSet { defaults.set(overlayMode.rawValue, forKey: "ls.overlayMode") } } + var opacity: Double { didSet { defaults.set(opacity, forKey: "ls.opacity") } } + var fontSize: Double { didSet { defaults.set(fontSize, forKey: "ls.fontSize") } } + var pinned: Bool { didSet { defaults.set(pinned, forKey: "ls.pinned") } } + var barWidth: Double { didSet { defaults.set(barWidth, forKey: "ls.barWidth") } } + var deepSeekAPIKey: String { didSet { defaults.set(deepSeekAPIKey, forKey: "ls.deepSeekKey") } } + var obsidianVaultPath: String { didSet { defaults.set(obsidianVaultPath, forKey: "ls.vaultPath") } } + /// 边说边译:对未定稿的中间态也翻译(降延迟,代价是译文会随句子生长而跳变)。 + var translateVolatile: Bool { didSet { defaults.set(translateVolatile, forKey: "ls.translateVolatile") } } + + /// 布局编辑态,瞬态(不持久化),启动永远 false。 + var layoutEditing: Bool = false + + /// 每个 speaker 的"当前未定稿灰字行"id;定稿后清除。 + /// 用 id(而非绝对下标),这样截断旧行后仍能正确定位,不会失效或错位。 + private var volatileIndex: [Speaker: UUID] = [:] + + /// id → lines 下标,O(1) 定位;避免每次终句翻译回填做 O(n) firstIndex 扫描。截断时重建。 + private var indexByID: [UUID: Int] = [:] /// 每个 speaker 的暂存中间态,尚未上屏(由节流器 flush)。 private var pendingVolatile: [Speaker: String] = [:] + /// 保留的最大行数上限;超出则从最旧行开始丢弃,避免长会话内存/渲染无界增长。 + /// 取值足够大,正常通话/会议不会触及;导出用当前保留的行(极长会话会丢最早的回看历史)。 + private let maxLines = 2000 + + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + displayMode = DisplayMode(rawValue: defaults.string(forKey: "ls.displayMode") ?? "") ?? .both + overlayMode = OverlayMode(rawValue: defaults.string(forKey: "ls.overlayMode") ?? "") ?? .bar + opacity = defaults.object(forKey: "ls.opacity") as? Double ?? 0.82 + fontSize = defaults.object(forKey: "ls.fontSize") as? Double ?? 22 + pinned = defaults.bool(forKey: "ls.pinned") + barWidth = defaults.object(forKey: "ls.barWidth") as? Double ?? 900 + deepSeekAPIKey = defaults.string(forKey: "ls.deepSeekKey") ?? "" + obsidianVaultPath = defaults.string(forKey: "ls.vaultPath") ?? "" + translateVolatile = defaults.object(forKey: "ls.translateVolatile") as? Bool ?? true + layoutEditing = false + } + /// 暂存中间态,不立即上屏(由节流器 flush)。 func stageVolatile(speaker: Speaker, text: String) { pendingVolatile[speaker] = text @@ -28,11 +63,12 @@ final class SubtitleStore { } func upsertVolatile(speaker: Speaker, text: String) { - if let i = volatileIndex[speaker] { + if let id = volatileIndex[speaker], let i = index(of: id) { lines[i].original = text } else { - lines.append(SubtitleLine(speaker: speaker, original: text, isFinal: false)) - volatileIndex[speaker] = lines.count - 1 + let line = SubtitleLine(speaker: speaker, original: text, isFinal: false) + append(line) + volatileIndex[speaker] = line.id } } @@ -40,20 +76,67 @@ final class SubtitleStore { @discardableResult func commitFinal(speaker: Speaker, text: String) -> UUID { pendingVolatile[speaker] = nil // 定稿后丢弃陈旧暂存中间态 - if let i = volatileIndex[speaker] { + if let id = volatileIndex[speaker], let i = index(of: id) { lines[i].original = text lines[i].isFinal = true volatileIndex[speaker] = nil - return lines[i].id + return id } else { let line = SubtitleLine(speaker: speaker, original: text, isFinal: true) - lines.append(line) + append(line) return line.id } } func attachTranslation(id: UUID, zh: String) { - guard let i = lines.firstIndex(where: { $0.id == id }) else { return } + guard let i = index(of: id) else { return } + lines[i].translated = zh + lines[i].translationFailed = false // 成功则清除任何旧的失败标记 + lines[i].translationProvisional = false // 定稿译文,不再是临时半句 + } + + /// 翻译尝试失败:打标记,UI 据此回退显原文而非永久「翻译中…」或残留的半句译文。 + /// 未翻译(nil)或仅有中间态临时译文(provisional)时都视为失败并清掉临时译文。 + func markTranslationFailed(id: UUID) { + guard let i = index(of: id) else { return } + if lines[i].translated == nil || lines[i].translationProvisional { + lines[i].translated = nil + lines[i].translationProvisional = false + lines[i].translationFailed = true + } + } + + /// 当前某 speaker 未定稿中间态的原文(供边说边译读取);无则 nil。 + func currentVolatileText(speaker: Speaker) -> String? { + guard let id = volatileIndex[speaker], let i = index(of: id) else { return nil } + return lines[i].original + } + + /// 回填中间态译文:仅当该 speaker 的中间态行仍存在、仍未定稿、且原文未变(== sourceText)时才应用, + /// 避免把过期片段的译文贴到已被新内容替换或已定稿的行上。 + func attachVolatileTranslation(speaker: Speaker, sourceText: String, zh: String) { + guard let id = volatileIndex[speaker], let i = index(of: id), + !lines[i].isFinal, lines[i].original == sourceText else { return } lines[i].translated = zh + lines[i].translationProvisional = true // 半句临时译文;终句译文到位或失败时会被覆盖/清除 + } + + // MARK: - 内部:索引维护 + 截断 + + private func index(of id: UUID) -> Int? { indexByID[id] } + + /// 追加一行,维护 indexByID,并在超限时截断。 + private func append(_ line: SubtitleLine) { + lines.append(line) + indexByID[line.id] = lines.count - 1 + trimIfNeeded() + } + + /// 超过上限则丢弃最旧的行并重建 indexByID。当前未定稿行总在尾部,不会被丢。 + private func trimIfNeeded() { + guard lines.count > maxLines else { return } + lines.removeFirst(lines.count - maxLines) + indexByID.removeAll(keepingCapacity: true) + for (i, line) in lines.enumerated() { indexByID[line.id] = i } } } diff --git a/Sources/LiveSubtitle/Overlay/MiniWindowView.swift b/Sources/LiveSubtitle/Overlay/MiniWindowView.swift new file mode 100644 index 0000000..19662dc --- /dev/null +++ b/Sources/LiveSubtitle/Overlay/MiniWindowView.swift @@ -0,0 +1,26 @@ +import SwiftUI + +/// 小窗:全部历史 + 竖向滚动,新句自动滚到底。复用 SubtitleLineRow。 +struct MiniWindowView: View { + var store: SubtitleStore + var body: some View { + ScrollViewReader { proxy in + ScrollView { + LazyVStack(alignment: .leading, spacing: 10) { + ForEach(store.lines) { line in + SubtitleLineRow(line: line, displayMode: store.displayMode, fontSize: store.fontSize) + .id(line.id) + } + Color.clear.frame(height: 1).id("bottom") + } + .padding(14) + } + .onChange(of: store.lines.count) { + withAnimation(.easeOut(duration: 0.15)) { proxy.scrollTo("bottom", anchor: .bottom) } + } + } + .frame(minWidth: 260, maxWidth: .infinity, minHeight: 180, maxHeight: .infinity) + .background(.black.opacity(store.opacity), in: RoundedRectangle(cornerRadius: 14)) + .overlay(RoundedRectangle(cornerRadius: 14).stroke(.white.opacity(0.12))) + } +} diff --git a/Sources/LiveSubtitle/Overlay/OverlayController.swift b/Sources/LiveSubtitle/Overlay/OverlayController.swift index 373a7b9..bb920f6 100644 --- a/Sources/LiveSubtitle/Overlay/OverlayController.swift +++ b/Sources/LiveSubtitle/Overlay/OverlayController.swift @@ -4,28 +4,206 @@ import SwiftUI @MainActor final class OverlayController { private var panel: NSPanel? + private var store: SubtitleStore? + private var moveObserver: NSObjectProtocol? + private var resizeObserver: NSObjectProtocol? + private let defaults = UserDefaults.standard + /// overlay 是否处于显示态。停止后为 false,阻止观察回调复活幽灵浮窗。 + private var active = false + /// 观察代次:show/hide 时自增,使 hide 前武装的旧 withObservationTracking 回调失效(无法 cancel,只能靠代次作废)。 + private var generation = 0 + /// 当前 panel 的形态;用于判断观察变化是否需要整块重建(仅形态变才重建)。 + private var currentMode: OverlayMode? func show(store: SubtitleStore) { + self.store = store + active = true + generation &+= 1 + applyMode() + observe() + } + + func hide() { + active = false + generation &+= 1 // 作废任何已武装的观察回调 + removeMoveObserver() + panel?.orderOut(nil) + panel = nil + currentMode = nil + } + + /// 观察 overlayMode / pinned / barWidth / layoutEditing,变化即重配并重新武装观察。 + /// active==false(已停止)或代次过期时,回调直接 no-op,不再重建浮窗、不再重新武装。 + private func observe() { + guard active, let store else { return } + let gen = generation + withObservationTracking { + _ = store.overlayMode + _ = store.pinned + _ = store.barWidth + _ = store.layoutEditing + } onChange: { + Task { @MainActor in + guard self.active, gen == self.generation else { return } + self.reconfigure() + self.observe() + } + } + } + + /// 观察回调:仅形态(bar⇄mini)变化才整块重建;pinned/barWidth/layoutEditing 变化就地改属性, + /// 避免每次调 Pin/拖宽度/切编辑态都重建 NSPanel(闪烁 + 丢滚动位置)。 + private func reconfigure() { + guard active, let store else { return } + if currentMode != store.overlayMode || panel == nil { + applyMode() + } else { + updateInPlace(store) + } + } + + private func updateInPlace(_ store: SubtitleStore) { + guard let p = panel else { return } + p.level = store.pinned ? .screenSaver : .floating + if store.overlayMode == .bar { + p.ignoresMouseEvents = !store.layoutEditing + p.isMovableByWindowBackground = store.layoutEditing + if abs(p.frame.size.width - store.barWidth) > 0.5 { + var f = p.frame + f.size.width = store.barWidth // 就地改宽:内容随 autoresize + SwiftUI 重排,不重建 + p.setFrame(f, display: true) + clampToScreen(p) // 加宽可能顶出右边界 + } + } + // mini:layoutEditing/barWidth 与它无关,无需任何重建(消除小窗模式下切编辑态的白重建) + } + + private func applyMode() { + guard active, let store else { return } + removeMoveObserver() + panel?.orderOut(nil) + let p = (store.overlayMode == .bar) ? makeBarPanel(store) : makeMiniPanel(store) + p.level = store.pinned ? .screenSaver : .floating + p.orderFrontRegardless() + panel = p + currentMode = store.overlayMode + } + + private func makeBarPanel(_ store: SubtitleStore) -> NSPanel { + let width = store.barWidth + // 高度取足够容纳最大字号(32)+ 双语 + 3 行的量;内容在 SubtitleBarView 内底对齐, + // 多出的空间透明不可见,字幕始终贴底不被裁。 + let height: CGFloat = 300 let host = NSHostingView(rootView: SubtitleBarView(store: store)) - host.frame = NSRect(x: 0, y: 0, width: 900, height: 200) - let p = NSPanel(contentRect: host.frame, - styleMask: [.nonactivatingPanel, .borderless], + host.frame = NSRect(x: 0, y: 0, width: width, height: height) + host.autoresizingMask = [.width, .height] // 就地改宽时内容跟随 + let p = NSPanel(contentRect: host.frame, styleMask: [.nonactivatingPanel, .borderless], backing: .buffered, defer: false) p.isFloatingPanel = true - p.level = .floating p.backgroundColor = .clear p.isOpaque = false p.hasShadow = true - p.becomesKeyOnlyIfNeeded = true + // 布局编辑态:字幕条可交互 + 可拖;否则点击穿透。 + p.ignoresMouseEvents = !store.layoutEditing + p.isMovableByWindowBackground = store.layoutEditing p.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] p.contentView = host - if let screen = NSScreen.main { - let f = screen.visibleFrame - p.setFrameOrigin(NSPoint(x: f.midX - 450, y: f.minY + 60)) + let barDefault: NSPoint = { + if let screen = NSScreen.main { + let f = screen.visibleFrame + return NSPoint(x: f.midX - width / 2, y: f.minY + 60) + } + return NSPoint(x: 100, y: 100) + }() + if let x = defaults.object(forKey: "ls.barX") as? Double, + let y = defaults.object(forKey: "ls.barY") as? Double { + p.setFrameOrigin(NSPoint(x: x, y: y)) + } else { + p.setFrameOrigin(barDefault) } - p.orderFrontRegardless() - panel = p + clampToScreen(p) + moveObserver = NotificationCenter.default.addObserver( + forName: NSWindow.didMoveNotification, object: p, queue: .main) { [weak self] _ in + MainActor.assumeIsolated { + guard let self, let origin = self.panel?.frame.origin else { return } + self.defaults.set(Double(origin.x), forKey: "ls.barX") + self.defaults.set(Double(origin.y), forKey: "ls.barY") + } + } + return p + } + + private func makeMiniPanel(_ store: SubtitleStore) -> NSPanel { + let w = defaults.object(forKey: "ls.miniW") as? Double ?? 380 + let h = defaults.object(forKey: "ls.miniH") as? Double ?? 480 + let host = NSHostingView(rootView: MiniWindowView(store: store)) + host.frame = NSRect(x: 0, y: 0, width: w, height: h) + host.autoresizingMask = [.width, .height] // 内容随 panel 缩放 + let p = NSPanel(contentRect: host.frame, styleMask: [.nonactivatingPanel, .borderless, .resizable], + backing: .buffered, defer: false) + p.isFloatingPanel = true + p.backgroundColor = .clear + p.isOpaque = false + p.hasShadow = true + p.isMovableByWindowBackground = true // 小窗可拖(边缘仍可缩放) + p.minSize = NSSize(width: 260, height: 180) // 缩放下限 + p.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] + p.contentView = host + let miniDefault: NSPoint = { + if let screen = NSScreen.main { + let f = screen.visibleFrame + return NSPoint(x: f.maxX - 400, y: f.minY + 80) + } + return NSPoint(x: 100, y: 100) + }() + if let x = defaults.object(forKey: "ls.miniX") as? Double, + let y = defaults.object(forKey: "ls.miniY") as? Double { + p.setFrameOrigin(NSPoint(x: x, y: y)) + } else { + p.setFrameOrigin(miniDefault) + } + clampToScreen(p) + moveObserver = NotificationCenter.default.addObserver( + forName: NSWindow.didMoveNotification, object: p, queue: .main) { [weak self] _ in + MainActor.assumeIsolated { + guard let self, let origin = self.panel?.frame.origin else { return } + self.defaults.set(Double(origin.x), forKey: "ls.miniX") + self.defaults.set(Double(origin.y), forKey: "ls.miniY") + } + } + resizeObserver = NotificationCenter.default.addObserver( + forName: NSWindow.didResizeNotification, object: p, queue: .main) { [weak self] _ in + MainActor.assumeIsolated { + guard let self, let size = self.panel?.frame.size else { return } + self.defaults.set(Double(size.width), forKey: "ls.miniW") + self.defaults.set(Double(size.height), forKey: "ls.miniH") + } + } + return p } - func hide() { panel?.orderOut(nil); panel = nil } + private func removeMoveObserver() { + if let o = moveObserver { NotificationCenter.default.removeObserver(o); moveObserver = nil } + if let o = resizeObserver { NotificationCenter.default.removeObserver(o); resizeObserver = nil } + } + + /// 把浮窗【完整】夹进某个屏幕的可见区——只判"有交集"是不够的(一角在屏内、其余溢出也会通过)。 + /// 覆盖溢出来源:恢复的旧坐标(拔显示器/改分辨率)、字幕条加宽、小窗尺寸恢复。 + private func clampToScreen(_ p: NSPanel) { + let frame = p.frame + // 取与窗口重叠最多的屏;完全在屏外时退到主屏 + let screen = NSScreen.screens.max { + overlapArea($0.visibleFrame, frame) < overlapArea($1.visibleFrame, frame) + } ?? NSScreen.main + guard let vf = screen?.visibleFrame else { return } + var origin = frame.origin + origin.x = frame.width <= vf.width ? min(max(origin.x, vf.minX), vf.maxX - frame.width) : vf.minX + origin.y = frame.height <= vf.height ? min(max(origin.y, vf.minY), vf.maxY - frame.height) : vf.minY + if origin != frame.origin { p.setFrameOrigin(origin) } + } + + private func overlapArea(_ a: NSRect, _ b: NSRect) -> CGFloat { + let r = a.intersection(b) + return r.isNull ? 0 : r.width * r.height + } } diff --git a/Sources/LiveSubtitle/Overlay/PermissionsManager.swift b/Sources/LiveSubtitle/Overlay/PermissionsManager.swift new file mode 100644 index 0000000..eb2e0f8 --- /dev/null +++ b/Sources/LiveSubtitle/Overlay/PermissionsManager.swift @@ -0,0 +1,27 @@ +import Foundation +import AVFoundation +import CoreGraphics + +/// 权限申请:麦克风 + 屏幕录制(后者是 ScreenCaptureKit 采系统声音的前提)。 +/// +/// **必须在「开始字幕」时调用,不能在 app 启动阶段调用。** 实测(macOS 27): +/// 启动阶段同时拉起这两个 TCC 授权流程,会导致 SwiftUI `MenuBarExtra` 的状态栏项 +/// 根本建不出来——菜单栏上没有图标、也没有任何报错,app 却在正常运行。 +/// 单独请求任一项都正常;两项同时请求则必现;把请求延后 2 秒也无效。 +@MainActor +enum PermissionsManager { + /// 在真正要用权限时调用(点「开始字幕」)。非阻塞:两项各自异步触发系统授权框,忽略结果。 + static func requestAll() { + // ① 麦克风:异步弹权限框,不关心结果。 + AVCaptureDevice.requestAccess(for: .audio) { _ in } + + // ② 屏幕录制(系统音频采集依赖此授权)。 + // 仅在尚未授权时触发系统授权引导。CGRequestScreenCaptureAccess 是同步阻塞的 + // C API,放到后台队列执行以免阻塞主线程弹窗时机;它只触发 TCC,无需回主线程更新 UI。 + if CGPreflightScreenCaptureAccess() == false { + DispatchQueue.global().async { + _ = CGRequestScreenCaptureAccess() + } + } + } +} diff --git a/Sources/LiveSubtitle/Overlay/SettingsView.swift b/Sources/LiveSubtitle/Overlay/SettingsView.swift new file mode 100644 index 0000000..7165768 --- /dev/null +++ b/Sources/LiveSubtitle/Overlay/SettingsView.swift @@ -0,0 +1,50 @@ +import SwiftUI +import AppKit + +/// 设置页:DeepSeek API Key + Obsidian vault 路径选择。 +@MainActor +struct SettingsView: View { + @Bindable var store: SubtitleStore + + var body: some View { + Form { + Section("DeepSeek") { + SecureField("DeepSeek API Key", text: $store.deepSeekAPIKey) + .textFieldStyle(.roundedBorder) + } + + Section("Obsidian") { + HStack(alignment: .firstTextBaseline) { + Text("Vault 路径") + Spacer() + Text(vaultDisplayPath) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + .help(store.obsidianVaultPath) + } + Button("选择文件夹…") { chooseVaultFolder() } + } + } + .formStyle(.grouped) + .padding() + .frame(width: 420) + } + + private var vaultDisplayPath: String { + let trimmed = store.obsidianVaultPath.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? "未选择" : trimmed + } + + private func chooseVaultFolder() { + let panel = NSOpenPanel() + panel.canChooseDirectories = true + panel.canChooseFiles = false + panel.allowsMultipleSelection = false + panel.prompt = "选择" + panel.message = "选择 Obsidian vault 文件夹" + if panel.runModal() == .OK, let url = panel.url { + store.obsidianVaultPath = url.path + } + } +} diff --git a/Sources/LiveSubtitle/Overlay/SubtitleBarView.swift b/Sources/LiveSubtitle/Overlay/SubtitleBarView.swift index 28fe3f0..7ce54f4 100644 --- a/Sources/LiveSubtitle/Overlay/SubtitleBarView.swift +++ b/Sources/LiveSubtitle/Overlay/SubtitleBarView.swift @@ -1,33 +1,68 @@ import SwiftUI -struct SubtitleBarView: View { - var store: SubtitleStore +/// 单行渲染,bar 与 mini 复用。读 displayMode/fontSize。 +struct SubtitleLineRow: View { + let line: SubtitleLine + let displayMode: DisplayMode + let fontSize: Double var body: some View { - VStack(alignment: .leading, spacing: 12) { - ForEach(store.lines.suffix(3)) { line in - HStack(alignment: .top, spacing: 10) { - Text(line.speaker == .me ? "我" : "对方") - .font(.system(size: 12, weight: .medium)) - .padding(.horizontal, 7).padding(.vertical, 2) - .background(line.speaker == .me ? Color.blue : Color.orange) - .clipShape(RoundedRectangle(cornerRadius: 6)) - VStack(alignment: .leading, spacing: 3) { - Text(line.original) - .font(.system(size: 15)) - .foregroundStyle(.white.opacity(line.isFinal ? 0.6 : 0.4)) - .italic(!line.isFinal) - if let zh = line.translated { - Text(zh).font(.system(size: 22, weight: .medium)).foregroundStyle(.white) - } else if line.isFinal { - Text("翻译中…").font(.system(size: 14)).foregroundStyle(.white.opacity(0.3)) + HStack(alignment: .top, spacing: 10) { + Text(line.speaker == .me ? "我" : "对方") + .font(.system(size: 12, weight: .medium)) + .padding(.horizontal, 7).padding(.vertical, 2) + .background(line.speaker == .me ? Color.blue : Color.orange) + .clipShape(RoundedRectangle(cornerRadius: 6)) + VStack(alignment: .leading, spacing: 3) { + if displayMode.showsOriginal { + Text(line.original) + .font(.system(size: fontSize * 0.68)) + .foregroundStyle(.white.opacity(line.isFinal ? 0.6 : 0.4)) + .italic(!line.isFinal) + } + if displayMode.showsTranslated { + if let zh = line.translated { + Text(zh).font(.system(size: fontSize, weight: .medium)).foregroundStyle(.white) + } else if line.translationFailed { + if displayMode == .translatedOnly { + // 仅译文模式:译文不可用时回退显原文,避免整行空白 + Text(line.original).font(.system(size: fontSize, weight: .medium)) + .foregroundStyle(.white.opacity(0.85)) + } else { + Text("(译文不可用)").font(.system(size: fontSize * 0.6)) + .foregroundStyle(.white.opacity(0.35)) } + } else if line.isFinal { + Text("翻译中…").font(.system(size: fontSize * 0.64)).foregroundStyle(.white.opacity(0.3)) } } } } - .padding(18) - .frame(width: 900, alignment: .leading) - .background(.black.opacity(0.82), in: RoundedRectangle(cornerRadius: 18)) - .overlay(RoundedRectangle(cornerRadius: 18).stroke(.white.opacity(0.1))) + } +} + +struct SubtitleBarView: View { + var store: SubtitleStore + var body: some View { + VStack(spacing: 0) { + Spacer(minLength: 0) // 把字幕气泡顶到 panel 底部,上方多余高度透明 + VStack(alignment: .leading, spacing: 12) { + ForEach(store.lines.suffix(3)) { line in + SubtitleLineRow(line: line, displayMode: store.displayMode, fontSize: store.fontSize) + } + } + .padding(18) + .frame(width: store.barWidth, alignment: .leading) + .background(.black.opacity(store.opacity), in: RoundedRectangle(cornerRadius: 18)) + .overlay( + RoundedRectangle(cornerRadius: 18) + .strokeBorder( + store.layoutEditing ? Color.yellow.opacity(0.9) : Color.white.opacity(0.1), + style: store.layoutEditing + ? StrokeStyle(lineWidth: 2.5, dash: [8, 5]) + : StrokeStyle(lineWidth: 1) + ) + ) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) } } diff --git a/Sources/LiveSubtitle/Pipeline/CaptionEngine.swift b/Sources/LiveSubtitle/Pipeline/CaptionEngine.swift index 14e4bfe..d32f371 100644 --- a/Sources/LiveSubtitle/Pipeline/CaptionEngine.swift +++ b/Sources/LiveSubtitle/Pipeline/CaptionEngine.swift @@ -7,6 +7,10 @@ final class CaptionEngine { private var tracks: [Track] private var tasks: [Task] = [] private var flushTask: Task? + private var volatileTranslateTask: Task? + private var lastVolatileSource: [Speaker: String] = [:] // 每 speaker 上次已送翻译的中间态,去重 + private var volatileInFlight: Set = [] // 每 speaker 是否有中间态翻译在途,防叠 + private var stopped = false // stop 后为 true,阻止旧 consume 继续写共享 store /// 默认双轨:对方(系统音)+ 我(麦克风)。测试可注入自定义轨。 init(store: SubtitleStore, tracks: [(AudioSource, TranscriptionPipeline)]? = nil) { @@ -32,14 +36,23 @@ final class CaptionEngine { tasks.append(Task { do { try await track.pipeline.ensureModel() - let events = try await track.pipeline.start() + // 识别流中途抛错 → 上报,避免字幕静默冻结 + let events = try await track.pipeline.start(onError: { msg in + Task { @MainActor in onError(msg) } + }) let consume = Task { @MainActor in for await e in events { + if stopped { break } // stop 后不再写共享 store(防旧会话污染快速重启的新会话) if e.isFinal { let id = store.commitFinal(speaker: track.source.speaker, text: e.text) - Task { @MainActor in - if let zh = await track.translator.translate(e.text) { - store.attachTranslation(id: id, zh: zh) + lastVolatileSource[track.source.speaker] = nil // 定稿后清去重,下句同短语也能边说边译 + if store.displayMode.showsTranslated { // 原文模式不触发翻译(省资源,对齐 PRD) + Task { @MainActor in + if let zh = await track.translator.translate(e.text) { + store.attachTranslation(id: id, zh: zh) + } else { + store.markTranslationFailed(id: id) // 失败打标,UI 回退显原文 + } } } } else { @@ -51,6 +64,8 @@ final class CaptionEngine { await track.pipeline.feed(frame) } await consume.value + } catch is CancellationError { + // 用户主动停止,不当作错误上报 } catch { onError("启动失败:\(error.localizedDescription)") } @@ -63,10 +78,34 @@ final class CaptionEngine { store.flushVolatile() } } + // 边说边译:每 ~450ms 把当前中间态送翻译(受 translateVolatile + displayMode 门控; + // 每 speaker 内容未变则跳过、在途则不叠;译文经 attachVolatileTranslation 守卫回填)。 + volatileTranslateTask = Task { @MainActor in + while !Task.isCancelled { + try? await Task.sleep(for: .milliseconds(450)) + guard store.translateVolatile, store.displayMode.showsTranslated else { continue } + for track in tracks { + let sp = track.source.speaker + guard !volatileInFlight.contains(sp), + let text = store.currentVolatileText(speaker: sp), + !text.isEmpty, text != lastVolatileSource[sp] else { continue } + lastVolatileSource[sp] = text + volatileInFlight.insert(sp) + let translator = track.translator + Task { @MainActor in + let zh = await translator.translate(text) + volatileInFlight.remove(sp) + if let zh { store.attachVolatileTranslation(speaker: sp, sourceText: text, zh: zh) } + } + } + } + } } func stop() { + stopped = true flushTask?.cancel(); flushTask = nil + volatileTranslateTask?.cancel(); volatileTranslateTask = nil tasks.forEach { $0.cancel() }; tasks = [] let tracks = self.tracks // 先并发停所有采集源(麦克风立即停录),再并发收尾所有 pipeline diff --git a/Sources/LiveSubtitle/Speech/TranscriptionPipeline.swift b/Sources/LiveSubtitle/Speech/TranscriptionPipeline.swift index 913c62f..1b59e8c 100644 --- a/Sources/LiveSubtitle/Speech/TranscriptionPipeline.swift +++ b/Sources/LiveSubtitle/Speech/TranscriptionPipeline.swift @@ -29,7 +29,8 @@ actor TranscriptionPipeline { } /// 启动:返回结果事件流;内部起分析 Task。 - func start() async throws -> AsyncStream { + /// onError:识别流中途抛错时上报(否则字幕会静默冻结,用户无从察觉)。 + func start(onError: (@Sendable (String) -> Void)? = nil) async throws -> AsyncStream { let (inStream, inCont) = AsyncStream.makeStream() inputCont = inCont try await analyzer.start(inputSequence: inStream) @@ -39,7 +40,9 @@ actor TranscriptionPipeline { for try await r in transcriber.results { cont.yield(TranscriptEvent(text: String(r.text.characters), isFinal: r.isFinal)) } - } catch { } + } catch { + onError?("识别中断:\(error.localizedDescription) — 请停止后重开字幕") + } cont.finish() } } diff --git a/Sources/LiveSubtitle/Translation/TranslationService.swift b/Sources/LiveSubtitle/Translation/TranslationService.swift index 2f90bb0..662d254 100644 --- a/Sources/LiveSubtitle/Translation/TranslationService.swift +++ b/Sources/LiveSubtitle/Translation/TranslationService.swift @@ -4,6 +4,9 @@ import Foundation @MainActor final class TranslationService { private var session: TranslationSession? + /// 串行化:final 与 volatile 可能并发调 translate,单个 TranslationSession 不保证并发安全, + /// 用任务链把调用排队,保证同一 session 同一时刻只有一个 translate 在跑。 + private var queue: Task? enum TranslateError: Error { case notInstalled @@ -22,9 +25,15 @@ final class TranslationService { session = s } - /// 单句英→中。返回中文;失败返回 nil(调用方回退显示原文)。 + /// 单句英→中。返回中文;失败返回 nil(调用方回退显示原文)。调用被串行排队。 func translate(_ en: String) async -> String? { - guard let s = session else { return nil } - return try? await s.translate(en).targetText + 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 + } + queue = task + return await task.value } } diff --git a/Tests/LiveSubtitleTests/SubtitleSettingsTests.swift b/Tests/LiveSubtitleTests/SubtitleSettingsTests.swift new file mode 100644 index 0000000..3871dea --- /dev/null +++ b/Tests/LiveSubtitleTests/SubtitleSettingsTests.swift @@ -0,0 +1,69 @@ +import XCTest +@testable import LiveSubtitle + +@MainActor +final class SubtitleSettingsTests: XCTestCase { + private func freshSuite() -> UserDefaults { + UserDefaults(suiteName: "test.ls.\(UUID().uuidString)")! + } + + func testDefaultsWhenEmpty() { + let s = SubtitleStore(defaults: freshSuite()) + XCTAssertEqual(s.displayMode, .both) + XCTAssertEqual(s.overlayMode, .bar) + XCTAssertEqual(s.opacity, 0.82, accuracy: 0.0001) + XCTAssertEqual(s.fontSize, 22, accuracy: 0.0001) + XCTAssertFalse(s.pinned) + XCTAssertEqual(s.barWidth, 900, accuracy: 0.0001) + XCTAssertEqual(s.deepSeekAPIKey, "") + XCTAssertEqual(s.obsidianVaultPath, "") + XCTAssertFalse(s.layoutEditing) + XCTAssertTrue(s.translateVolatile) // 默认开(边说边译) + } + + func testSettingsPersistAcrossInstances() { + let suite = freshSuite() + let s1 = SubtitleStore(defaults: suite) + s1.displayMode = .translatedOnly + s1.overlayMode = .mini + s1.opacity = 0.5 + s1.fontSize = 28 + s1.pinned = true + s1.barWidth = 1200 + s1.deepSeekAPIKey = "sk-test-123" + s1.obsidianVaultPath = "/Users/me/Vault" + s1.translateVolatile = false + let s2 = SubtitleStore(defaults: suite) + XCTAssertEqual(s2.displayMode, .translatedOnly) + XCTAssertEqual(s2.overlayMode, .mini) + XCTAssertEqual(s2.opacity, 0.5, accuracy: 0.0001) + XCTAssertEqual(s2.fontSize, 28, accuracy: 0.0001) + XCTAssertTrue(s2.pinned) + XCTAssertEqual(s2.barWidth, 1200, accuracy: 0.0001) + XCTAssertEqual(s2.deepSeekAPIKey, "sk-test-123") + XCTAssertEqual(s2.obsidianVaultPath, "/Users/me/Vault") + XCTAssertFalse(s2.translateVolatile) + } + + func testLayoutEditingIsTransient() { + let suite = freshSuite() + let s1 = SubtitleStore(defaults: suite) + s1.layoutEditing = true + // 核心断言:瞬态属性【绝不落盘】——直接查底层 key 未被写入。 + // (仅断言 s2.layoutEditing==false 是恒真的:init 无条件置 false, + // 即使误加了持久化 didSet 也照样通过,抓不到回归。) + XCTAssertNil(suite.object(forKey: "ls.layoutEditing"), + "layoutEditing 不应写入 UserDefaults(瞬态语义被破坏)") + let s2 = SubtitleStore(defaults: suite) + XCTAssertFalse(s2.layoutEditing) + } + + func testDisplayModeHelpers() { + XCTAssertTrue(DisplayMode.both.showsOriginal) + XCTAssertTrue(DisplayMode.both.showsTranslated) + XCTAssertFalse(DisplayMode.translatedOnly.showsOriginal) + XCTAssertTrue(DisplayMode.translatedOnly.showsTranslated) + XCTAssertTrue(DisplayMode.originalOnly.showsOriginal) + XCTAssertFalse(DisplayMode.originalOnly.showsTranslated) + } +} diff --git a/Tests/LiveSubtitleTests/SubtitleStoreTests.swift b/Tests/LiveSubtitleTests/SubtitleStoreTests.swift index 9f80efc..fc9145a 100644 --- a/Tests/LiveSubtitleTests/SubtitleStoreTests.swift +++ b/Tests/LiveSubtitleTests/SubtitleStoreTests.swift @@ -75,4 +75,117 @@ final class SubtitleStoreTests: XCTestCase { XCTAssertEqual(s.lines[0].original, "final text") XCTAssertTrue(s.lines[0].isFinal) } + + // MARK: - T2 翻译失败回退 + + func testMarkTranslationFailedSetsFlagWhenUntranslated() { + let s = SubtitleStore() + let id = s.commitFinal(speaker: .other, text: "Hello.") + s.markTranslationFailed(id: id) + XCTAssertTrue(s.lines[0].translationFailed) + XCTAssertNil(s.lines[0].translated) + } + + func testMarkTranslationFailedNoOpWhenAlreadyTranslated() { + let s = SubtitleStore() + let id = s.commitFinal(speaker: .other, text: "Hello.") + s.attachTranslation(id: id, zh: "你好。") + s.markTranslationFailed(id: id) // 已有译文,不应打失败标 + XCTAssertFalse(s.lines[0].translationFailed) + XCTAssertEqual(s.lines[0].translated, "你好。") + } + + func testAttachTranslationClearsFailedFlag() { + let s = SubtitleStore() + let id = s.commitFinal(speaker: .other, text: "Hello.") + s.markTranslationFailed(id: id) + s.attachTranslation(id: id, zh: "你好。") // 成功回填应清除失败标 + XCTAssertFalse(s.lines[0].translationFailed) + XCTAssertEqual(s.lines[0].translated, "你好。") + } + + // MARK: - T1 边说边译(中间态翻译)守卫 + + func testCurrentVolatileTextReflectsLatestVolatile() { + let s = SubtitleStore() + XCTAssertNil(s.currentVolatileText(speaker: .other)) + s.upsertVolatile(speaker: .other, text: "I think we") + XCTAssertEqual(s.currentVolatileText(speaker: .other), "I think we") + } + + func testAttachVolatileTranslationAppliesWhenTextMatches() { + let s = SubtitleStore() + s.upsertVolatile(speaker: .other, text: "I think we") + s.attachVolatileTranslation(speaker: .other, sourceText: "I think we", zh: "我认为我们") + XCTAssertEqual(s.lines[0].translated, "我认为我们") + } + + func testAttachVolatileTranslationDroppedWhenTextChanged() { + let s = SubtitleStore() + s.upsertVolatile(speaker: .other, text: "I think we should") // 原文已生长 + s.attachVolatileTranslation(speaker: .other, sourceText: "I think we", zh: "我认为我们") // 过期片段 + XCTAssertNil(s.lines[0].translated) // 不应贴过期译文 + } + + func testAttachVolatileTranslationDroppedAfterFinal() { + let s = SubtitleStore() + s.upsertVolatile(speaker: .other, text: "I think we") + _ = s.commitFinal(speaker: .other, text: "I think we should go.") // 已定稿,volatileIndex 清空 + s.attachVolatileTranslation(speaker: .other, sourceText: "I think we", zh: "我认为我们") + XCTAssertNil(s.lines[0].translated) // 不应回填到已定稿行 + } + + // MARK: - 半句临时译文(provisional)在定稿后的处理 + + func testCommitFinalCarriesProvisionalVolatileTranslation() { + let s = SubtitleStore() + s.upsertVolatile(speaker: .other, text: "I think we") + s.attachVolatileTranslation(speaker: .other, sourceText: "I think we", zh: "我认为我们") + XCTAssertTrue(s.lines[0].translationProvisional) + _ = s.commitFinal(speaker: .other, text: "I think we should cancel.") + XCTAssertEqual(s.lines[0].translated, "我认为我们") // 半句译文暂留(平滑过渡) + XCTAssertTrue(s.lines[0].translationProvisional) // 仍是临时,未定稿 + } + + func testMarkFailedClearsProvisionalTranslation() { + let s = SubtitleStore() + s.upsertVolatile(speaker: .other, text: "I think we") + s.attachVolatileTranslation(speaker: .other, sourceText: "I think we", zh: "我认为我们") + let id = s.commitFinal(speaker: .other, text: "I think we should cancel.") + s.markTranslationFailed(id: id) // 终句重译失败 + XCTAssertNil(s.lines[0].translated) // 清掉过期半句译文,别当定稿译文 + XCTAssertTrue(s.lines[0].translationFailed) // 回退显原文 + XCTAssertFalse(s.lines[0].translationProvisional) + } + + func testAttachTranslationClearsProvisional() { + let s = SubtitleStore() + s.upsertVolatile(speaker: .other, text: "I think we") + s.attachVolatileTranslation(speaker: .other, sourceText: "I think we", zh: "我认为我们") + let id = s.commitFinal(speaker: .other, text: "I think we should cancel.") + s.attachTranslation(id: id, zh: "我认为我们应该取消。") // 终句成句译文到位 + XCTAssertEqual(s.lines[0].translated, "我认为我们应该取消。") + XCTAssertFalse(s.lines[0].translationProvisional) + } + + // MARK: - 保留上限 + O(1) 索引在截断后仍正确 + + func testLinesCappedAndLatestStillLocatable() { + let s = SubtitleStore() + var lastID = UUID() + for k in 0..<2100 { lastID = s.commitFinal(speaker: .other, text: "line \(k)") } + XCTAssertLessThanOrEqual(s.lines.count, 2000) // 超限被截断 + XCTAssertEqual(s.lines.last?.original, "line 2099") // 最新行保留 + s.attachTranslation(id: lastID, zh: "最后一行") // 截断后 id→index 仍能定位 + XCTAssertEqual(s.lines.last?.translated, "最后一行") + } + + func testVolatileSurvivesTrim() { + let s = SubtitleStore() + for k in 0..<2100 { _ = s.commitFinal(speaker: .other, text: "f\(k)") } + s.upsertVolatile(speaker: .me, text: "hello") // 新中间态在尾部,不应被截断影响 + XCTAssertEqual(s.currentVolatileText(speaker: .me), "hello") + s.attachVolatileTranslation(speaker: .me, sourceText: "hello", zh: "你好") + XCTAssertEqual(s.lines.last(where: { $0.speaker == .me })?.translated, "你好") + } } diff --git a/docs/superpowers/backlog.md b/docs/superpowers/backlog.md index e480fe6..ca92e7a 100644 --- a/docs/superpowers/backlog.md +++ b/docs/superpowers/backlog.md @@ -2,13 +2,24 @@ > 记录已确认、但排在当前 phase 之后的需求。轮到时各自走 brainstorm→spec→plan。 -## Phase 4 — 字幕条可操作 + 权限前置 +## Phase 4 — 字幕条可操作 + 权限前置 ✅ 代码已实现(2026-07-13,branch phase4-5-perms-bar-obsidian) -- **字幕条(bar)可调**:拖动换位置、缩放大小、调透明度(此前 opacity/字号已在 Phase 3 做;Phase 4 补 bar 的**拖拽 + 缩放**,以及小窗缩放)。 -- **开 app 即请求权限**:启动时就请求 屏幕录制 + 系统音频录制授权(不等到点"开始字幕")。 -- (原计划遗留)回声/外放漏音兜底、音频路由变化处理、延迟调优 spike。 +- ✅ **字幕条(bar)可调**:"布局编辑"开关 → bar 变可交互 + 可拖(关闭恢复点击穿透);位置存 `ls.barX/Y`;宽度用滑块(`store.barWidth`,600–1400)。 +- ✅ **小窗缩放**:mini panel 原生 `.resizable`,内容自适应,尺寸存 `ls.miniW/miniH`。 +- ❌ **「开 app 即请求权限」已撤销**(2026-07-30):改为**点「开始字幕」时**才调 `PermissionsManager.requestAll()`。启动阶段拉起 TCC 授权流程与菜单栏图标问题纠缠不清,且点用时请求本就是 macOS 惯例。 +- ✅ **真机验证通过**(2026-07-30 用户手测):bar 拖动/宽度、mini 缩放、控制面板、导出、设置页均正常。 +- (原计划遗留,仍 deferred)回声/外放漏音兜底(**已决策:关 VoiceProcessing + 耳机**,见 probes/RESULTS.md)、音频路由变化处理、延迟调优 spike。 -## Phase 5(新子系统)— Obsidian 导出 + DeepSeek 总结 + 设置页 +## Phase 5(新子系统)— Obsidian 导出 + DeepSeek 总结 + 设置页 ✅ 代码已实现(2026-07-13) + +**实现落点(2026-07-13):** + +- ✅ **Obsidian 导出** `Export/ObsidianExporter.swift`:写 `-.md`(frontmatter title/date/tags/source + `## 总结` + `## 转录`),vault 目录不存在则 throw。转录只收 `isFinal` 行,格式 `- **我/对方**:原文 — 译文`。 +- ✅ **DeepSeek** `Export/DeepSeekClient.swift`:POST `api.deepseek.com/chat/completions`,model `deepseek-chat`,`response_format=json_object`,解析 `{title,summary}`。 +- ✅ **编排** `Export/ExportCoordinator.swift`:取终句 → 有 key 调 DeepSeek(失败/无 key 回退默认标题 + 占位 summary,**仍导出**)→ `Task.detached` 写盘。菜单按钮"整理并导出到 Obsidian"触发,状态显示在菜单。**手动触发**(尊重"主动导出才上云"的隐私边界)。 +- ✅ **设置页** `Overlay/SettingsView.swift` + App `Settings` scene(菜单"设置…"用 `openSettings()` 打开):`SecureField` 配 DeepSeek key、`NSOpenPanel` 选 vault 目录。 +- ⚠️ **API key 存 UserDefaults 明文**(自用可接受;未来上架/沙盒 → Keychain)。 +- ✅ **真机验证通过**(2026-07-30 用户手测):设置页、选 vault 目录、导出流程均正常。 **已定决策(2026-07-09 与用户确认):** @@ -18,6 +29,27 @@ - **设置页**:至少含 DeepSeek API key、Obsidian vault 路径。 - 笔记内容形态待细化:frontmatter(title/date/tags/来源)+ 转录(对方/我)+ DeepSeek 总结段。导出触发时机(停止字幕后?手动按钮?)待 brainstorm。 -## 当前进行 +## ⚠️ 形态变更:菜单栏 app → 普通窗口 app(2026-07-30) + +PRD/Phase 3 原定"菜单栏驱动"(`MenuBarExtra`),**已改为普通窗口 app**。原因: + +- 从 `/Applications` 经访达/LaunchServices 启动时,**菜单栏图标始终不出现**:AX 显示状态项已注册、app 事件循环正常、无崩溃、无任何日志输出,但就是不绘制;从 shell 直接起有时可见,从访达起不可见。 +- 用最小 SwiftUI 测试 app 做过多轮二分(符号有效性、`.menu`/`.window` 样式、`Settings` 场景、`@NSApplicationDelegateAdaptor`、两项权限请求的组合、全新 bundle id 复现首次启动)。一度以为是"启动时同时请求麦克风+屏幕录制"所致,**该结论后被证伪**(去掉权限请求仍不显示)。 +- **根因未查明**,故不再纠缠:改为 `WindowGroup` 控制面板窗口 + 去掉 `LSUIElement`。附带好处是有了 Dock 图标(`.icns` 派上用场)和标准 app 菜单(⌘Q/⌘,)。 +- 字幕浮窗(bar/mini `NSPanel`)不受影响,仍是独立浮层。 + +> 若以后想回菜单栏形态:`MenuBarExtra` 相关代码见 commit `9db803f` 之前的版本;需先解决上述"状态项注册但不绘制"的问题。 + +## 状态(2026-07-30) + +**Phase 3 + 4 + 5 全部完成并经用户真机验收**(功能正常),0 error / 31 XCTest 绿 / bundle 带图标。 +分支 `phase4-5-perms-bar-obsidian`(基于 `phase3-overlay`)共 19 commits,待合入 `main`。 +实现与后续变更明细见 `plans/2026-07-13-livesubtitle-phase4-5-impl.md`。 + +## 后续可做(未排期) -- **Phase 3**:overlay 打磨(菜单栏控制:显示三态 / 字幕条⇄小窗 / Pin / 透明度 / 字号 / 持久化)——spec 已定,进 writing-plans。 +- **翻译上下文**:本地逐句翻译无上下文,代词/专名前后不一致——要破得上云 LLM,与"实时链路全本地"冲突,暂不做。 +- **Keychain 存 API key**(替换 UserDefaults 明文),上架/沙盒化时必做。 +- **显示器热插拔时重定位已显示浮窗**(监听 `didChangeScreenParametersNotification`)。 +- **延迟调优 spike**:量化端到端延迟(spec §1 有 6 个指标),看边说边译开/关的实际差异。 +- 音频路由变化处理(切换耳机/外放中途)。 diff --git a/docs/superpowers/plans/2026-07-13-livesubtitle-phase4-5-impl.md b/docs/superpowers/plans/2026-07-13-livesubtitle-phase4-5-impl.md new file mode 100644 index 0000000..e924535 --- /dev/null +++ b/docs/superpowers/plans/2026-07-13-livesubtitle-phase4-5-impl.md @@ -0,0 +1,70 @@ +# LiveSubtitle Phase 4 + 5 — 实现记录(2026-07-13) + +> 决策已在 `backlog.md` 拍板(2026-07-09),本次直接实现,未再走完整 brainstorm/spec。 +> branch `phase4-5-perms-bar-obsidian`(基于未合并的 `phase3-overlay`)。多 subagent workflow 产出,主会话逐文件复核 + 亲自 build/test 把关。 + +## 交付(6 commits,19 XCTest 绿,`swift build` 0 error,`build-app.sh` 出 bundle) + +| # | commit | 内容 | +|---|--------|------| +| 1 | `1e6f1e9` | 设置模型:`barWidth`/`deepSeekAPIKey`/`obsidianVaultPath`(持久化)+ `layoutEditing`(瞬态)+ 4 测试 | +| 2 | `b0b84af` | Phase 4:`PermissionsManager`(启动请求麦克风+屏录)、bar 拖动/宽度、bar 位置存 `ls.barX/Y` | +| 3 | `e9b7069` | Phase 5:`ObsidianExporter` / `DeepSeekClient` / `ExportCoordinator` / `SettingsView` | +| 4 | `7714802` | App 接线:菜单控件、`Settings` scene、导出按钮、`AppDelegate` 启动权限 | +| 5 | `1468b57` | Phase 4:mini 窗原生可缩放 + 尺寸持久化 `ls.miniW/miniH` | + +## 关键实现决策 + +- **启动请求权限**用 `@NSApplicationDelegateAdaptor` + `applicationDidFinishLaunching`,**不用**菜单 `.task`(默认 `.menu` 样式的 MenuBarExtra 内容要等用户点开菜单才构建,达不到"开 app 即请求")。 +- **bar 点击穿透是默认**,只有 `layoutEditing==true` 时才 `ignoresMouseEvents=false` + 可拖 + 黄色虚线边框提示;关掉恢复穿透。 +- **bar 尺寸用菜单滑条**(确定性、可测),**mini 用原生边缘缩放**(`.resizable` borderless panel + `host.autoresizingMask` + `.frame(maxWidth/Height:.infinity)`)。 +- **导出手动触发**(菜单按钮),尊重"只有主动导出才把转录发 DeepSeek 云"的隐私边界;无 key/失败都回退并仍写本地 .md。 +- **API key 存 UserDefaults 明文**——自用取舍,未来沙盒化换 Keychain。 + +## Swift 6 并发 + +- 所有新 UI/AppKit 代码 `@MainActor`;新回调无 `@unchecked Sendable`/`nonisolated(unsafe)`。 +- NSWindow move/resize 回调照抄既有 mini 的 `MainActor.assumeIsolated + [weak self]`。 +- `DeepSeekClient` 纯值 struct + async,`Result` Sendable。 + +## 后续变更(2026-07-30,同一分支继续) + +| commit | 内容 | +|--------|------| +| `359b499` | 复审修复:幽灵浮窗(hide 后改设置会复活)、导出同名覆盖、导出重入、YAML title 转义、设置窗置前 | +| `cf247ce` | 翻译三项:**边说边译**(中间态也翻,降延迟,菜单可关)、失败回退显原文(不再永久「翻译中…」)、仅原文模式跳翻译 | +| `0dfd750` | 全量复审修复:**孤儿 SCStream**(停止后屏录仍开)、半句译文被当定稿译文、旧 consume 写脏新会话、识别流静默冻结 | +| `61c3c17` | store:id→index O(1) 查找 + 保留上限 2000 行(长会话内存/延迟蠕变) | +| `5bfdfe8` | 字幕条大字号不再被裁(底对齐)+ 属性变化就地更新(消除重建闪烁) | +| `9f1e86c` → `7c52a9a` | 外观控件位置:先试字幕条旁齿轮浮窗 → 用户判定不好看,**改回菜单栏并换 `.menuBarExtraStyle(.window)`**(原生 NSMenu 渲染不了滑块,会退化成 Decrement/Increment) | +| `97cc9dd` | 应用图标:`scripts/make-icon.swift` 纯 AppKit 生成 `.icns`(bars/cjk/mixed 三风格,默认 cjk)接入 bundle | +| `32b9996` | 权限请求从启动时移到「开始字幕」时(见下方"菜单栏图标"一节;该 commit 的根因判断后被证伪,但**改动本身保留**——点用时请求是更合理的 macOS 惯例) | +| `9db803f` | **形态变更:菜单栏 app → 普通窗口 app**(`WindowGroup` + 去掉 `LSUIElement`) | + +## 菜单栏图标失踪(未解之谜 → 绕开) + +装到 `/Applications` 经访达启动后,菜单栏图标始终不出现,且**完全静默**:AX 显示状态项已注册、`NSApplicationMain` 事件循环正常、无崩溃、无 stderr、无系统日志。 + +排查手段(留档,免得以后重走):`sample` 抓主线程栈确认没卡死;`lsappinfo`(**参数用错过一次,输出不可信**);System Events 读菜单栏结构(**accessory app 的 AX 标题是假象,最小对照 app 同样乱**);`screencapture`(无屏录权限,拿不到图);最后靠**最小 SwiftUI 测试 app + 用户肉眼**做二分:符号有效性 ✓、`.menu`/`.window` 样式 ✓、`Settings` 场景 ✓、`@NSApplicationDelegateAdaptor` ✓、全新 bundle id 复现首次启动。 + +一度定位到"启动时同时请求麦克风 + 屏幕录制",但**该结论被后续验证证伪**(去掉权限请求后,访达启动的实例仍无图标)。**根因至今未查明**,遂改用普通窗口形态绕开。 + +**期间做了两轮多 agent 代码复审**(5 个 commit 逐一审 + 6 个子系统全量审),真 bug 都已修并有单测覆盖;被复审排除的疑点见各 commit message。 + +## ✅ 真机手测(2026-07-30,用户验收通过) + +用户实机跑过并确认「功能都正常」,覆盖 Phase 3/4/5 全部交互:显示三态、字幕条⇄小窗、Pin、透明度/字号/宽度、布局编辑拖动、小窗缩放、启动权限、设置页、导出。 + +> 记录口径:此结论来自用户的实机反馈,不是自动化测试断言。单测(31)只覆盖 store/模型/格式转换等纯逻辑层。 + +## 已知取舍(非缺陷) + +- **API key 明文存 UserDefaults** —— 自用取舍,上架/沙盒化再换 Keychain。 +- **保留行数上限 2000** —— 极长会话会丢最早的回看历史(导出用当前保留的行)。 +- **本地翻译逐句、无上下文、句界依赖 STT** —— Apple 本地 `TranslationSession` 的固有上限,要上下文得上云,与"实时链路不上云"的决策冲突,故不做。 +- **拔外接屏时已显示的浮窗不会自动重定位**(无 `didChangeScreenParametersNotification` 监听);下次创建时 `clampToScreen` 会夹回屏内。 +- **菜单栏图标仍是单色 SF Symbol**;`.icns` 只在 Finder/聚焦/简介可见(LSUIElement 无 Dock 图标)。 + +## 未动 + +音频 / 识别管线(`Audio/**` `Speech/**` `Pipeline/**` 的采集与识别部分)保持原状;本轮只在 `CaptionEngine` 编排层和 `TranslationService` 上按需改动(边说边译、串行化、错误上报)。 diff --git a/scripts/build-app.sh b/scripts/build-app.sh index 49a1619..4437e3d 100755 --- a/scripts/build-app.sh +++ b/scripts/build-app.sh @@ -6,8 +6,17 @@ CONF="${1:-debug}" swift build -c "$CONF" BIN=".build/$CONF/LiveSubtitle" APP="build/LiveSubtitle.app" -rm -rf "$APP"; mkdir -p "$APP/Contents/MacOS" +rm -rf "$APP"; mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources" cp "$BIN" "$APP/Contents/MacOS/LiveSubtitle" + +# 应用图标:缺失时用 scripts/make-icon.swift 现画(bars / cjk / mixed) +# 换风格: rm build/AppIcon.icns && ICON_STYLE=bars bash scripts/build-app.sh +ICON_STYLE="${ICON_STYLE:-cjk}" +if [ ! -f build/AppIcon.icns ]; then + swift scripts/make-icon.swift build "$ICON_STYLE" >/dev/null + iconutil -c icns build/AppIcon.iconset -o build/AppIcon.icns +fi +cp build/AppIcon.icns "$APP/Contents/Resources/AppIcon.icns" cat > "$APP/Contents/Info.plist" <<'PLIST' <?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> @@ -16,10 +25,10 @@ cat > "$APP/Contents/Info.plist" <<'PLIST' <key>CFBundleExecutable</key><string>LiveSubtitle</string> <key>CFBundleIdentifier</key><string>com.livesubtitle.app</string> <key>CFBundleName</key><string>LiveSubtitle</string> + <key>CFBundleIconFile</key><string>AppIcon</string> <key>CFBundlePackageType</key><string>APPL</string> <key>CFBundleShortVersionString</key><string>0.1</string> <key>LSMinimumSystemVersion</key><string>26.0</string> - <key>LSUIElement</key><true/> <key>NSScreenCaptureUsageDescription</key><string>LiveSubtitle 采集系统音频用于实时中文字幕(仅音频)。</string> <key>NSMicrophoneUsageDescription</key><string>LiveSubtitle 采集麦克风用于识别你的发言。</string> </dict> diff --git a/scripts/make-icon.swift b/scripts/make-icon.swift new file mode 100644 index 0000000..8fb115a --- /dev/null +++ b/scripts/make-icon.swift @@ -0,0 +1,200 @@ +// 生成 LiveSubtitle 应用图标(纯 AppKit 绘制,无外部依赖)。 +// 用法: swift scripts/make-icon.swift [输出目录=build] +// 产出: <out>/AppIcon.iconset/*.png(全尺寸)+ <out>/icon-preview.png(浅/深底多尺寸预览) +// 随后 scripts/build-app.sh 会调 iconutil 打成 AppIcon.icns 放进 bundle。 +// +// 设计:深蓝渐变圆角方 + 白色对话气泡,气泡内两条字幕条 +// —— 上条短而淡(英文原文)、下条长而实(中文译文),对应 app 的双语字幕。 +import AppKit +import Foundation + +let outDir = CommandLine.arguments.count > 1 ? CommandLine.arguments[1] : "build" + +/// 气泡内的图形:bars=两条字幕条 / cjk=「文」字 / mixed=淡原文条 +「文」 +enum Style: String { case bars, cjk, mixed } +let style = Style(rawValue: CommandLine.arguments.count > 2 ? CommandLine.arguments[2] : "bars") ?? .bars +let wantVariants = CommandLine.arguments.contains("--variants") + +// MARK: - 绘制(尺寸无关,按 s 归一化) + +func drawText(_ str: String, centeredIn rect: NSRect, size: CGFloat, color: NSColor) { + let attrs: [NSAttributedString.Key: Any] = [ + .font: NSFont.systemFont(ofSize: size, weight: .bold), + .foregroundColor: color, + ] + let a = NSAttributedString(string: str, attributes: attrs) + let sz = a.size() + a.draw(at: NSPoint(x: rect.midX - sz.width / 2, y: rect.midY - sz.height / 2)) +} + +func drawIcon(_ s: CGFloat, _ style: Style = style) { + let inset = s * 0.075 // macOS 图标惯例:留出四周透明边距 + let rect = NSRect(x: inset, y: inset, width: s - inset * 2, height: s - inset * 2) + let radius = rect.width * 0.2237 // squircle 近似圆角 + + let bg = NSBezierPath(roundedRect: rect, xRadius: radius, yRadius: radius) + let gradient = NSGradient(colors: [ + NSColor(srgbRed: 0.30, green: 0.37, blue: 1.00, alpha: 1), // 亮靛蓝 + NSColor(srgbRed: 0.05, green: 0.08, blue: 0.26, alpha: 1), // 深夜蓝 + ])! + gradient.draw(in: bg, angle: -62) + + // 对话气泡 + let bw = rect.width * 0.62 + let bh = rect.height * 0.44 + let bx = rect.midX - bw / 2 + let by = rect.midY - bh / 2 + rect.height * 0.05 + NSColor.white.setFill() + NSBezierPath(roundedRect: NSRect(x: bx, y: by, width: bw, height: bh), + xRadius: bh * 0.26, yRadius: bh * 0.26).fill() + let tail = NSBezierPath() // 左下小尾巴 + tail.move(to: NSPoint(x: bx + bw * 0.16, y: by + bh * 0.03)) + tail.line(to: NSPoint(x: bx + bw * 0.36, y: by + bh * 0.03)) + tail.line(to: NSPoint(x: bx + bw * 0.20, y: by - rect.height * 0.11)) + tail.close() + tail.fill() + + // 气泡内容 + let navy = NSColor(srgbRed: 0.08, green: 0.11, blue: 0.30, alpha: 1) + let barH = bh * 0.155 + let padX = bw * 0.14 + let usable = bw - padX * 2 + let bubbleRect = NSRect(x: bx, y: by, width: bw, height: bh) + + func bar(y: CGFloat, widthRatio: CGFloat, alpha: CGFloat) { + navy.withAlphaComponent(alpha).setFill() + NSBezierPath(roundedRect: NSRect(x: bx + padX, y: y, width: usable * widthRatio, height: barH), + xRadius: barH / 2, yRadius: barH / 2).fill() + } + + switch style { + case .bars: + bar(y: by + bh * 0.57, widthRatio: 0.56, alpha: 0.40) // 原文:短、淡 + bar(y: by + bh * 0.25, widthRatio: 0.92, alpha: 1.0) // 译文:长、实 + case .cjk: + drawText("文", centeredIn: bubbleRect, size: bh * 0.66, color: navy) + case .mixed: + bar(y: by + bh * 0.70, widthRatio: 0.62, alpha: 0.38) // 上方淡条 = 英文原文 + drawText("文", centeredIn: NSRect(x: bx, y: by + bh * 0.02, width: bw, height: bh * 0.62), + size: bh * 0.50, color: navy) // 下方「文」= 中文译文 + } +} + +// MARK: - 渲染 + +func makeRep(_ px: Int, _ st: Style = style) -> NSBitmapImageRep { + let rep = NSBitmapImageRep(bitmapDataPlanes: nil, pixelsWide: px, pixelsHigh: px, + bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, + isPlanar: false, colorSpaceName: .deviceRGB, + bytesPerRow: 0, bitsPerPixel: 0)! + rep.size = NSSize(width: px, height: px) + NSGraphicsContext.saveGraphicsState() + NSGraphicsContext.current = NSGraphicsContext(bitmapImageRep: rep) + drawIcon(CGFloat(px), st) // 每个尺寸独立绘制,而非缩放,小图更清晰 + NSGraphicsContext.restoreGraphicsState() + return rep +} + +func pngData(_ rep: NSBitmapImageRep) -> Data { + rep.representation(using: .png, properties: [:])! +} + +func image(_ px: Int) -> NSImage { + let img = NSImage(size: NSSize(width: px, height: px)) + img.addRepresentation(makeRep(px)) + return img +} + +/// 浅底/深底两行、多尺寸并排,用于肉眼检查小尺寸下是否还认得出。 +func previewSheet() -> NSBitmapImageRep { + let sizes = [256, 128, 64, 32, 16] + let pad: CGFloat = 28 + let w = pad + sizes.reduce(CGFloat(0)) { $0 + CGFloat($1) + pad } + let rowH: CGFloat = 256 + pad * 2 + let out = NSBitmapImageRep(bitmapDataPlanes: nil, pixelsWide: Int(w), pixelsHigh: Int(rowH * 2), + bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, + isPlanar: false, colorSpaceName: .deviceRGB, + bytesPerRow: 0, bitsPerPixel: 0)! + out.size = NSSize(width: w, height: rowH * 2) + NSGraphicsContext.saveGraphicsState() + NSGraphicsContext.current = NSGraphicsContext(bitmapImageRep: out) + NSColor(srgbRed: 0.95, green: 0.95, blue: 0.97, alpha: 1).setFill() + NSRect(x: 0, y: rowH, width: w, height: rowH).fill() + NSColor(srgbRed: 0.11, green: 0.11, blue: 0.12, alpha: 1).setFill() + NSRect(x: 0, y: 0, width: w, height: rowH).fill() + var x = pad + for s in sizes { + let img = image(s) + img.draw(in: NSRect(x: x, y: rowH + pad, width: CGFloat(s), height: CGFloat(s))) + img.draw(in: NSRect(x: x, y: pad, width: CGFloat(s), height: CGFloat(s))) + x += CGFloat(s) + pad + } + NSGraphicsContext.restoreGraphicsState() + return out +} + +/// 三种风格 × 大/小尺寸并排,用于挑选。 +func variantsSheet() -> NSBitmapImageRep { + let styles: [Style] = [.bars, .cjk, .mixed] + let big = 256, small = 32 + let pad: CGFloat = 34 + let colW = CGFloat(big) + pad + let w = pad + colW * CGFloat(styles.count) + let h = pad * 3 + CGFloat(big) + CGFloat(small) + 34 + let out = NSBitmapImageRep(bitmapDataPlanes: nil, pixelsWide: Int(w), pixelsHigh: Int(h), + bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, + isPlanar: false, colorSpaceName: .deviceRGB, + bytesPerRow: 0, bitsPerPixel: 0)! + out.size = NSSize(width: w, height: h) + NSGraphicsContext.saveGraphicsState() + NSGraphicsContext.current = NSGraphicsContext(bitmapImageRep: out) + NSColor(srgbRed: 0.95, green: 0.95, blue: 0.97, alpha: 1).setFill() + NSRect(x: 0, y: 0, width: w, height: h).fill() + for (i, st) in styles.enumerated() { + let x = pad + colW * CGFloat(i) + let bigImg = NSImage(size: NSSize(width: big, height: big)) + bigImg.addRepresentation(makeRep(big, st)) + bigImg.draw(in: NSRect(x: x, y: h - pad - CGFloat(big), width: CGFloat(big), height: CGFloat(big))) + let smallImg = NSImage(size: NSSize(width: small, height: small)) + smallImg.addRepresentation(makeRep(small, st)) + smallImg.draw(in: NSRect(x: x + CGFloat(big) / 2 - CGFloat(small) / 2, + y: h - pad * 2 - CGFloat(big) - CGFloat(small), + width: CGFloat(small), height: CGFloat(small))) + drawText(st.rawValue, centeredIn: NSRect(x: x, y: 12, width: CGFloat(big), height: 30), + size: 20, color: NSColor(srgbRed: 0.2, green: 0.2, blue: 0.25, alpha: 1)) + } + NSGraphicsContext.restoreGraphicsState() + return out +} + +// MARK: - 输出 + +let fm = FileManager.default +try? fm.createDirectory(atPath: outDir, withIntermediateDirectories: true) + +if wantVariants { + let p = "\(outDir)/icon-variants.png" + try pngData(variantsSheet()).write(to: URL(fileURLWithPath: p)) + print("variants: \(p)") + exit(0) +} + +let iconset = "\(outDir)/AppIcon.iconset" +try? fm.createDirectory(atPath: iconset, withIntermediateDirectories: true) + +let entries: [(Int, String)] = [ + (16, "icon_16x16"), (32, "icon_16x16@2x"), + (32, "icon_32x32"), (64, "icon_32x32@2x"), + (128, "icon_128x128"), (256, "icon_128x128@2x"), + (256, "icon_256x256"), (512, "icon_256x256@2x"), + (512, "icon_512x512"), (1024, "icon_512x512@2x"), +] +for (px, name) in entries { + let url = URL(fileURLWithPath: "\(iconset)/\(name).png") + try pngData(makeRep(px)).write(to: url) +} +let previewPath = "\(outDir)/icon-preview.png" +try pngData(previewSheet()).write(to: URL(fileURLWithPath: previewPath)) + +print("iconset: \(iconset) (\(entries.count) png)") +print("preview: \(previewPath)")