Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
77fc0c2
feat: overlay settings (opacity/fontSize/pinned) + UserDefaults persi…
zcl0621 Jul 9, 2026
3adbb43
feat: SubtitleBarView respects displayMode/opacity/fontSize; extract …
zcl0621 Jul 9, 2026
bc7792b
feat: MiniWindowView scrollable history with auto-scroll-to-bottom
zcl0621 Jul 9, 2026
58e309a
feat: OverlayController bar/mini switch, Pin window level, draggable …
zcl0621 Jul 9, 2026
c45ba94
feat: menu controls for display mode / overlay form / Pin / opacity /…
zcl0621 Jul 9, 2026
1e6f1e9
feat: settings model — barWidth/layoutEditing + DeepSeek key + Obsidi…
zcl0621 Jul 13, 2026
b0b84af
feat(phase4): launch-time permission preflight + draggable/resizable …
zcl0621 Jul 13, 2026
e9b7069
feat(phase5): Obsidian .md export + DeepSeek summary + settings page
zcl0621 Jul 13, 2026
7714802
feat: wire Phase 4/5 into app — menu bar/edit controls, Settings scen…
zcl0621 Jul 13, 2026
1468b57
feat(phase4): resizable mini window with persisted size
zcl0621 Jul 13, 2026
e5137dc
docs: record Phase 4/5 implementation + update backlog status
zcl0621 Jul 13, 2026
359b499
fix: address code-review findings (ghost overlay, export overwrite, r…
zcl0621 Jul 13, 2026
cf247ce
feat(translation): live volatile translation + failure fallback + ski…
zcl0621 Jul 30, 2026
0dfd750
fix: address full-codebase review findings (orphan SCStream, stale tr…
zcl0621 Jul 30, 2026
61c3c17
perf(store): O(1) id→index lookups + bounded line history
zcl0621 Jul 30, 2026
5bfdfe8
fix(overlay): bar no longer clips at large font; in-place property up…
zcl0621 Jul 30, 2026
9f1e86c
feat(overlay): appearance controls move to a gear panel beside the su…
zcl0621 Jul 30, 2026
7c52a9a
feat(menu): appearance controls back in the menu bar via .window styl…
zcl0621 Jul 30, 2026
97cc9dd
feat: app icon — generated .icns wired into the bundle
zcl0621 Jul 30, 2026
e4ff1a1
docs: record manual acceptance + all changes since the initial Phase …
zcl0621 Jul 30, 2026
32b9996
fix: no menu bar icon — stop requesting both permissions at launch
zcl0621 Aug 1, 2026
9db803f
feat: replace the menu bar extra with a regular window app
zcl0621 Aug 1, 2026
14ae634
docs: record the menu-bar → window switch and the unsolved status-ite…
zcl0621 Aug 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions Sources/LiveSubtitle/Audio/SystemAudioSource.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,17 +11,20 @@ final class SystemAudioSource: NSObject, AudioSource, SCStreamOutput, SCStreamDe
private let converter = FormatConverter()
private var stream: SCStream?
private var continuation: AsyncStream<AudioFrame>.Continuation?
private var startTask: Task<Void, Never>?
private var stopped = false // stop 已调用;start 在异步就绪后据此自拆,避免孤儿 SCStream

func frames() -> AsyncStream<AudioFrame> {
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
}
Expand All @@ -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")
Expand All @@ -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()
}
Expand Down
115 changes: 115 additions & 0 deletions Sources/LiveSubtitle/Export/DeepSeekClient.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
73 changes: 73 additions & 0 deletions Sources/LiveSubtitle/Export/ExportCoordinator.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
147 changes: 147 additions & 0 deletions Sources/LiveSubtitle/Export/ObsidianExporter.swift
Original file line number Diff line number Diff line change
@@ -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")
}

/// 写入 <vaultPath>/<yyyy-MM-dd>-<sanitized title>.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
}

/// 避免覆盖同名笔记:<base>.md 已存在则退到 <base>-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<Character> = ["/", "\\", ":", "*", "?", "\"", "<", ">", "|"]
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)\""
}
}
Loading