Phase 3/4/5: 字幕浮窗 + 可操作字幕条 + Obsidian 导出(并改为普通窗口 app) - #1
Merged
Merged
Conversation
…stence Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…mini with saved position Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…an vault path barWidth/deepSeekAPIKey/obsidianVaultPath persisted via UserDefaults (didSet); layoutEditing is transient (never persists, always false on launch). Adds 4 SubtitleSettingsTests covering defaults + persistence + transience. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…subtitle bar PermissionsManager requests mic + screen-recording on launch. Bar becomes interactive + movable when layoutEditing is on (else click-through); bar origin saved to ls.barX/Y like mini; bar width reads store.barWidth. OverlayController observes barWidth/layoutEditing and re-applies. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ObsidianExporter writes <date>-<title>.md (frontmatter + summary + transcript)
to the vault folder. DeepSeekClient calls the OpenAI-compatible chat API for a
JSON {title,summary}. ExportCoordinator orchestrates: gather final lines →
DeepSeek (fallback if no key/failure) → write. SettingsView configures key + vault.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…e, export, launch perms Menu gains layoutEditing toggle + bar-width slider, "整理并导出到 Obsidian" + "设置…". Settings scene hosts SettingsView (openSettings from MenuBarExtra). AppDelegate.applicationDidFinishLaunching requests permissions on launch (not lazily on menu-open). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Mini panel gains .resizable + minSize; content autoresizes to fill. Size saved to ls.miniW/miniH on didResize and restored on create, alongside the existing ls.miniX/Y position persistence. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…e-entrancy) Review of the 5 Phase 4/5 commits surfaced these; verified + fixed the real ones: - [HIGH] OverlayController: hide() left withObservationTracking armed with no visibility guard, so changing overlayMode/pinned/barWidth/layoutEditing after 停止字幕 resurrected a ghost bar with stale lines. Add active flag + generation token; observe()/applyMode() no-op when inactive; hide() invalidates armed callbacks. - [MEDIUM] ObsidianExporter: same-name .md was silently overwritten (data loss when two exports share a title within a minute). Add -2/-3 uniqueness suffix. - [LOW] Export re-entrancy: guard exportToObsidian with isExporting + disable button (avoid duplicate cloud upload + racing writes). - [LOW] frontmatter title: always double-quote + escape \\n/\\r/\\t so a DeepSeek title starting with a YAML indicator (- > | @ etc.) can't break frontmatter. - [LOW] 设置…: NSApp.activate before openSettings so the window fronts from the menu bar. - [LOW] Strengthen transient test to assert ls.layoutEditing is never written (the old assertion was tautological). Not changed (deliberate): API key in UserDefaults (documented tradeoff, Keychain later); barWidth full-rebuild flicker and mini edge-resize grabbability need runtime judgment — left for hands-on manual test. Reverted a speculative "read window from Notification" tweak that broke Swift 6 sending isolation. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…p in original-only Three translation gaps from the review closed: - T1 边说边译: translate the volatile (in-progress) line too, not just finals, so Chinese appears ~mid-sentence instead of a full sentence late. Throttled loop (~450ms) gated by a persisted translateVolatile toggle (menu "边说边译(中间态)", default on) + displayMode; per-speaker dedup + in-flight guard; store-side attachVolatileTranslation only applies when the volatile line still exists, is still non-final, and its text is unchanged (drops stale fragments). - T2 失败回退: translate() nil now marks the line translationFailed; UI shows the original as fallback in translated-only mode (no more blank line) and "(译文不可用)" in bilingual mode, instead of a permanent "翻译中…". - T3 原文模式不翻译: skip translate() when displayMode is original-only (PRD intent). TranslationService.translate is now serialized (task chain) so concurrent final + volatile calls never hit one TranslationSession at once. +7 store tests (26 total). Note: T1 volatile translation quality/flicker on partial fragments needs real-audio judgment — one toggle away from off if it reads worse than "one sentence behind". Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…anslation, silent STT freeze) Full 6-agent review; verified each and fixed the real ones: - [HIGH] SystemAudioSource start/stop race orphaned a live SCStream (screen recording stayed on after 停止, CPU churn, accumulates on quick restart). start() is now stored + a `stopped` flag makes a late-resolving start() tear down its own stream; stop() awaits the start task then stops any built stream. - [MEDIUM] commitFinal carried a volatile half-sentence translation onto the final line; if the final re-translation failed it showed permanently as the full translation. Added translationProvisional: markTranslationFailed now clears a provisional translation and falls back to original; attachTranslation clears it on success. - [MEDIUM] Old per-track consume task kept writing finals into the shared store after stop()/engine=nil, corrupting a fast restart. Added a `stopped` guard that breaks the loop. - [MEDIUM] transcriber.results errors were swallowed (silent caption freeze). Pipeline.start now takes onError and surfaces "识别中断…" to the UI. - [LOW] Stopping during STT startup no longer shows a spurious "启动失败: cancelled" (ignore CancellationError). - [LOW] lastVolatileSource cleared on commitFinal so a repeated short phrase in the next sentence still gets live translation. - [LOW] Restored overlay coords are validated against current screens; off-screen → default. - [LOW] DeepSeek request timeout 30s (was default 60s silent hang); export failure message now names a short reason (missing key / HTTP code / network) instead of one generic line. Deferred (need runtime judgment / design call, noted for manual-test pass): bar panel fixed 200pt height clips at max font+bilingual+3 lines; mini rebuild on layoutEditing toggle; unbounded lines growth + O(n) id scans over long sessions (cap vs keep-for-export). +3 store tests (29 total). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
attachTranslation/markTranslationFailed no longer O(n) firstIndex scans over unbounded history — an indexByID map makes them O(1). volatileIndex is now id-based (not absolute index) so trimming can't invalidate it. Lines are capped at maxLines=2000 (oldest dropped, rebuild index); ample for calls/meetings, export uses retained lines. +2 tests (cap + volatile-survives-trim). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…dates (no rebuild flicker) - Bar panel is now 300pt tall with the subtitle bubble bottom-anchored (Spacer + bottom alignment, transparent above). Fixes clipping of the newest line at fontSize 32 + bilingual + 3 lines; position stays pinned to the screen bottom. - OverlayController only rebuilds the panel on a bar⇄mini mode change. pinned / barWidth / layoutEditing now update the existing panel in place (level, width, ignoresMouseEvents/movable). Kills the per-slider-step bar rebuild flicker and the pointless mini-panel rebuild when toggling layoutEditing in mini mode. Visual layout (bottom-anchor + in-place width resize) still wants a hands-on look; logic verified, build + 31 tests green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…btitle bar Native NSMenu can't render sliders — SwiftUI degraded 透明度/字号/字幕条宽度 into "Decrement/Increment" submenus. Moved them out of the menu bar into a dedicated floating gear panel next to the overlay: - AppearanceControlView: collapsed = gear button; expanded = real sliders for opacity / font size / bar width (bar width only in bar mode). - Its own NSPanel, because the subtitle bar is click-through window-wide and a gear drawn on it could never be clicked. Draggable, position persisted (ls.gearX/Y), default sits to the right of the bar, follows Pin level, off-screen coords fall back. - Expanded state persisted (ls.appearanceExpanded); size syncs in place, top edge anchored so expanding doesn't run off the screen bottom. - Menu keeps only what NSMenu renders correctly (buttons/toggles/pickers) plus a toggle to expand the gear panel. +2 settings assertions (31 tests). Panel placement/feel needs a hands-on look. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…e; drop the gear panel The gear panel beside the overlay looked bad, so revert it and solve the original problem (native NSMenu can't render sliders) the other way: switch MenuBarExtra to .menuBarExtraStyle(.window), which renders the dropdown as a real SwiftUI panel. Sliders work there, so 透明度/字号/字幕条宽度 live in the menu again — each with a live value readout; bar width only shows in bar mode. - Deleted AppearanceControlView; removed gearPanel/gearMoveObserver/showGear/ syncGearSize from OverlayController and appearanceExpanded from the store. - Menu redone as a 280pt panel: prominent start/stop, segmented 显示/形态 pickers, toggles, sliders, export + 设置…/退出. - clampToScreen stays (bar/mini positioning still needs it). Also fixes the off-screen overflow the user hit: the old ensureVisible only checked intersects(), so a mostly-off-screen panel passed, and the expand path passed its own overflowing origin as the fallback. clampToScreen now fits the whole frame inside the best-overlapping screen's visibleFrame, called on every position/size change path. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
scripts/make-icon.swift draws the icon with plain AppKit (no deps, no design tool): indigo gradient squircle + white speech bubble, three interchangeable glyph styles (bars / cjk「文」/ mixed). Each size is drawn independently rather than downscaled, so 16px stays legible. Emits a full 10-size iconset plus preview/variant sheets. build-app.sh generates build/AppIcon.icns when missing (ICON_STYLE, default cjk), copies it into Contents/Resources and declares CFBundleIconFile. Verified: icns unpacks back to all 10 sizes, bundle still ad-hoc signs. Note: LSUIElement app — this icon shows in Finder/Spotlight/Get Info, not the Dock; the menu bar keeps the monochrome SF Symbol. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…4/5 batch User ran the app and confirmed all functionality works (2026-07-30), so the "待真机验证" items in backlog/impl notes are now marked verified — flagged explicitly as a user-reported result, not an automated assertion. Also records what landed after the first batch: two multi-agent review rounds and their fixes (ghost overlay, orphan SCStream, stale half-sentence translation, silent STT freeze), the translation trio (live volatile translation / failure fallback / skip in original-only), store O(1) + line cap, bar clipping + in-place updates, the gear-panel-then-back-to-menu-bar (.window style) decision, and the generated app icon. Plus a known-tradeoffs list and an unscheduled follow-up list. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Symptom: launched from /Applications the app ran fine (event loop alive, no crash, no error) but had no menu bar icon at all. Root cause, bisected with minimal SwiftUI test apps (fresh bundle IDs to reproduce a first-launch TCC state): requesting BOTH microphone and screen-recording access during app launch prevents SwiftUI's MenuBarExtra status item from ever being installed. Requesting either one alone is fine; both together reproduces every time; deferring the call 2s after launch does NOT help. Silent — no exception, no log. Fix: request permissions at point of use (when the user hits 开始字幕) instead of at launch, which is also the idiomatic macOS pattern. Drops the AppDelegate adaptor that existed only for the launch-time call; PermissionsManager.requestAllOnLaunch is renamed to requestAll with the constraint documented at the call site so this isn't reintroduced. This walks back the Phase 4 "开 app 即请求权限" item: it is not implementable at launch without losing the menu bar icon. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The menu bar status item never appeared when the app was launched from /Applications via LaunchServices — AX reported the item as registered, the app ran fine with a live event loop, no crash and no log output, but nothing was drawn. Bisecting with minimal SwiftUI apps did not produce a reliable fix, so drop the menu-bar-only design instead of fighting it. - MenuBarExtra(.window) → WindowGroup with the same control panel (300pt wide, .windowResizability(.contentSize)). - Info.plist no longer sets LSUIElement, so this is a normal foreground app: it has a Dock icon (the generated .icns is finally visible) and a standard app menu. - Dropped the panel's 退出 button (⌘Q / app menu covers it) and the NSApp.activate dance before openSettings, which only existed for the menu bar case. - Subtitle overlays (bar/mini NSPanels) are unchanged and still float independently. Verified on the installed copy: launched via LaunchServices, background-only=false, window "LiveSubtitle" 300x490 present, app shows in the Dock. 0 errors, 31 tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…m mystery Phase 4's "开 app 即请求权限" is marked withdrawn (permissions now requested when captioning starts), and the menu-bar-driven design from the PRD/Phase 3 is recorded as replaced by a regular WindowGroup app. Keeps an honest account of the status-item investigation: what was ruled out, which diagnostic tools gave misleading output (lsappinfo misuse, AX titles on accessory apps), and that the "both permission requests at launch" root cause was later disproved — the real cause is still unknown, the window app works around it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
该 PR 一次性合入 LiveSubtitle 的 Phase 3/4/5:新增字幕浮窗两种形态(字幕条/小窗)与可操作控制面板,完善翻译链路(含中间态翻译与失败回退),并加入“导出到 Obsidian + DeepSeek 总结/标题”的可选上云能力,同时将应用形态从菜单栏 app 调整为普通窗口 app。
Changes:
- 新增可切换的 Overlay 形态(bar/mini)、布局编辑(拖动/缩放/持久化)、以及权限请求时机调整
- 翻译链路增强:串行化 TranslationSession 调用、边说边译(volatile)、翻译失败回退标记、以及 store 的 O(1) 定位 + 2000 行截断
- 新增 Obsidian 导出子系统(Exporter/Coordinator/DeepSeekClient/SettingsView)与图标生成/打包脚本
Reviewed changes
Copilot reviewed 21 out of 21 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| Tests/LiveSubtitleTests/SubtitleStoreTests.swift | 增加翻译失败回退/volatile 翻译守卫/截断后索引正确性的单测 |
| Tests/LiveSubtitleTests/SubtitleSettingsTests.swift | 新增设置默认值与持久化、瞬态 layoutEditing 语义的单测 |
| Sources/LiveSubtitle/Translation/TranslationService.swift | 为 TranslationSession 增加“任务队列”串行化以避免并发访问 |
| Sources/LiveSubtitle/Speech/TranscriptionPipeline.swift | 为识别流中途异常增加 onError 回调,避免静默冻结 |
| Sources/LiveSubtitle/Pipeline/CaptionEngine.swift | 增加 volatile 翻译调度、翻译失败标记、stop 后污染防护与错误上报 |
| Sources/LiveSubtitle/Overlay/SubtitleBarView.swift | 抽出可复用单行渲染,并支持失败回退与布局编辑边框提示 |
| Sources/LiveSubtitle/Overlay/SettingsView.swift | 新增 DeepSeek key 与 Obsidian vault 路径设置页 |
| Sources/LiveSubtitle/Overlay/PermissionsManager.swift | 新增“开始字幕时”统一请求麦克风+屏录权限的封装 |
| Sources/LiveSubtitle/Overlay/OverlayController.swift | Overlay 形态切换、就地重配、位置/尺寸持久化、越屏夹回等增强 |
| Sources/LiveSubtitle/Overlay/MiniWindowView.swift | 新增小窗历史滚动视图并复用单行渲染组件 |
| Sources/LiveSubtitle/Models/SubtitleStore.swift | 设置持久化、volatile 管理改为 id 索引、id→index 映射与 2000 行截断 |
| Sources/LiveSubtitle/Models/SubtitleModels.swift | DisplayMode/OverlayMode 改为 RawRepresentable 并新增显示辅助;SubtitleLine 增加失败/临时译文标记 |
| Sources/LiveSubtitle/LiveSubtitleApp.swift | 形态切换为 WindowGroup 控制面板 + Settings scene,并接入导出入口/权限请求 |
| Sources/LiveSubtitle/Export/ObsidianExporter.swift | 新增 Obsidian Markdown(frontmatter+总结+转录)生成与安全写盘(避免覆盖) |
| Sources/LiveSubtitle/Export/ExportCoordinator.swift | 新增导出编排:可选 DeepSeek 总结,失败回退仍导出 |
| Sources/LiveSubtitle/Export/DeepSeekClient.swift | 新增 DeepSeek(OpenAI 兼容)调用与 JSON 结果解析 |
| Sources/LiveSubtitle/Audio/SystemAudioSource.swift | 修复 stop 与异步 start 竞态导致的孤儿 SCStream 风险 |
| scripts/make-icon.swift | 新增纯 AppKit 绘制并导出 iconset/预览的图标生成脚本 |
| scripts/build-app.sh | 构建 bundle 时自动生成并打包 .icns,更新 Info.plist(去掉 LSUIElement,加入图标) |
| docs/superpowers/plans/2026-07-13-livesubtitle-phase4-5-impl.md | 增加 Phase 4/5 实现记录与排查纪要 |
| docs/superpowers/backlog.md | 更新 backlog:Phase 4/5 已实现、权限请求策略调整、以及形态变更说明 |
Suppressed comments (1)
Sources/LiveSubtitle/Translation/TranslationService.swift:37
- 建议在本次
translate完成后(且确认自己仍是队尾/最后一次入队)清空queue,避免queue长期持有已完成任务并间接持有整条历史链。
/// 单句英→中。返回中文;失败返回 nil(调用方回退显示原文)。调用被串行排队。
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
}
queue = task
return await task.value
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Comment on lines
+96
to
104
| @MainActor private func exportToObsidian() { | ||
| guard !isExporting else { return } // 防重入:进行中不再并发触发(避免重复上云 + 竞争写盘) | ||
| isExporting = true | ||
| exportStatus = "正在整理…" | ||
| Task { | ||
| let result = await ExportCoordinator.exportToObsidian(store: store) | ||
| exportStatus = result | ||
| isExporting = false | ||
| } |
Comment on lines
+7
to
+9
| /// 串行化:final 与 volatile 可能并发调 translate,单个 TranslationSession 不保证并发安全, | ||
| /// 用任务链把调用排队,保证同一 session 同一时刻只有一个 translate 在跑。 | ||
| private var queue: Task<String?, Never>? |
Comment on lines
+51
to
+55
| if let zh = await track.translator.translate(e.text) { | ||
| store.attachTranslation(id: id, zh: zh) | ||
| } else { | ||
| store.markTranslationFailed(id: id) // 失败打标,UI 回退显原文 | ||
| } |
Comment on lines
+18
to
+20
| .onChange(of: store.lines.count) { | ||
| withAnimation(.easeOut(duration: 0.15)) { proxy.scrollTo("bottom", anchor: .bottom) } | ||
| } |
zcl0621
added a commit
that referenced
this pull request
Aug 15, 2026
真机崩溃(用户实测,macOS 27.0 / M1 Max): Thread 7 queue: RealtimeMessenger.mServiceQueue _dispatch_assert_queue_fail _swift_task_checkIsolatedSwift swift_task_isCurrentExecutorWithFlagsImpl closure #1 in VoiceprintRecorder.beginCapture() AVAudioNodeTap::TapMessage::RealtimeMessenger_Perform() 根因:VoiceprintRecorder 是 @mainactor 类,tap 闭包在它的隔离方法里形成 → 被推断成 MainActor 隔离 → 编译器在闭包入口插运行时执行器断言;而 AVAudioEngine 在音频线程上调它,断言当场 SIGTRAP。AVAudioNodeTapBlock 没标 @sendable 拦不住这层推断。 MicSource 同样写法不崩,只因为那个类不是 @mainactor,闭包天然 nonisolated。 修法:把装 tap 挪进 `nonisolated static installTap(on:format:converter:cont:)`, 让闭包回到没有 actor 上下文的位置 —— 也就是 MicSource 天然所处的位置。 顺带改掉本文件顶部那段把因果写反的注释:它当时断言「并发检查压根没看这里」, 而事实恰恰相反 —— 编译器不但看了,还主动插了那条要命的断言。这条错误注释正是 让人以为这样写安全的东西。 验证(不止「编译过」):反汇编 release 包里的 tap 闭包 (0x10031868c–0x100318798,68 条指令),bl 目标只剩 channelZeroMono / convert / yield 和 ARC 辅助,isCurrentExecutor|checkIsolated 命中数 = 0。 swift build && swift test:179 tests,0 failures。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
一次性合入 Phase 3、4、5(共 23 个 commit;
phase3-overlay从未单独合过,故一起带上)。SubtitleBarView/MiniWindowView两种形态,显示三态(原文/双语/译文)、透明度、字号、Pin 置顶、位置持久化。ObsidianExporter直接写<日期>-<title>.md(frontmatter + 总结 + 转录),DeepSeekClient做总结/标题(无 key 或失败均回退并仍导出),SettingsView配 key 与 vault 路径。实时字幕链路仍 100% 本地,只有手动导出这一步上云。TranslationSession串行化。WindowGroup,去掉LSUIElement)。从/Applications经访达启动时状态栏图标始终不绘制,根因未查明(排查留档见 docs),遂绕开;附带拿到 Dock 图标与标准 app 菜单。scripts/make-icon.swift→.icns)、三轮多 agent 复审的修复(幽灵浮窗、孤儿 SCStream、半句译文污染定稿、STT 静默冻结、导出覆盖/重入、YAML 转义、浮窗越屏、store O(1) 索引 + 2000 行上限)。Test Plan
swift build0 error(Swift 6 strict concurrency)swift test— 31 XCTest 全绿(store/模型/格式转换等纯逻辑层)scripts/build-app.sh出带图标的 bundle已知取舍(非缺陷)
TranslationSession固有上限;要上下文得上云,与"实时链路不上云"决策冲突)🤖 Generated with Claude Code