Skip to content

Phase 3/4/5: 字幕浮窗 + 可操作字幕条 + Obsidian 导出(并改为普通窗口 app) - #1

Merged
zcl0621 merged 23 commits into
mainfrom
phase4-5-perms-bar-obsidian
Aug 1, 2026
Merged

zcl0621 merged 23 commits into
mainfrom
phase4-5-perms-bar-obsidian

Conversation

@zcl0621

@zcl0621 zcl0621 commented Aug 1, 2026

Copy link
Copy Markdown
Owner

Summary

一次性合入 Phase 3、4、5(共 23 个 commit;phase3-overlay 从未单独合过,故一起带上)。

  • Phase 3 — 浮窗形态:SubtitleBarView / MiniWindowView 两种形态,显示三态(原文/双语/译文)、透明度、字号、Pin 置顶、位置持久化。
  • Phase 4 — 字幕条可操作:「布局编辑」开关下 bar 可拖动(平时点击穿透)、宽度滑块、mini 窗原生边缘缩放 + 尺寸持久化;权限请求从启动时改为点「开始字幕」时(macOS 惯例)。
  • Phase 5 — 导出:ObsidianExporter 直接写 <日期>-<title>.md(frontmatter + 总结 + 转录),DeepSeekClient 做总结/标题(无 key 或失败均回退并仍导出),SettingsView 配 key 与 vault 路径。实时字幕链路仍 100% 本地,只有手动导出这一步上云。
  • 翻译改进:边说边译(中间态也翻,降延迟,可关)、翻译失败回退显原文、仅原文模式跳过翻译、TranslationSession 串行化。
  • ⚠️ 形态变更:菜单栏 app → 普通窗口 app(WindowGroup,去掉 LSUIElement)。从 /Applications 经访达启动时状态栏图标始终不绘制,根因未查明(排查留档见 docs),遂绕开;附带拿到 Dock 图标与标准 app 菜单。
  • 另含:应用图标生成脚本(scripts/make-icon.swift.icns)、三轮多 agent 复审的修复(幽灵浮窗、孤儿 SCStream、半句译文污染定稿、STT 静默冻结、导出覆盖/重入、YAML 转义、浮窗越屏、store O(1) 索引 + 2000 行上限)。

Test Plan

  • swift build 0 error(Swift 6 strict concurrency)
  • swift test — 31 XCTest 全绿(store/模型/格式转换等纯逻辑层)
  • scripts/build-app.sh 出带图标的 bundle
  • 真机手测(2026-07-30 用户验收):显示三态、字幕条⇄小窗、Pin、透明度/字号/宽度、布局编辑拖动、小窗缩放、权限、设置页、Obsidian 导出均正常

已知取舍(非缺陷)

  • DeepSeek API key 明文存 UserDefaults(自用取舍;上架/沙盒化再换 Keychain)
  • 保留行数上限 2000,极长会话会丢最早历史
  • 本地翻译逐句无上下文(Apple TranslationSession 固有上限;要上下文得上云,与"实时链路不上云"决策冲突)

🤖 Generated with Claude Code

zcl0621 and others added 23 commits July 9, 2026 21:56
…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>
Copilot AI review requested due to automatic review settings August 1, 2026 11:02
@zcl0621
zcl0621 merged commit 68948a9 into main Aug 1, 2026
1 check passed

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants