From 0858e706c56cf97afdddf747acad732ea15203f5 Mon Sep 17 00:00:00 2001 From: Blizzard Date: Wed, 28 Oct 2026 15:24:18 +0800 Subject: [PATCH] =?UTF-8?q?=E8=AF=AD=E8=AE=B0=E6=89=8B=E8=A1=A8=E7=89=88?= =?UTF-8?q?=20v0.1=EF=BC=9A=E4=B8=80=E9=94=AE=E5=BD=95=E9=9F=B3=E3=80=81Wa?= =?UTF-8?q?tchConnectivity=20=E4=BC=A0=E8=BE=93=E3=80=81=E8=A1=A8=E7=9B=98?= =?UTF-8?q?=E5=A4=8D=E6=9D=82=E5=8A=9F=E8=83=BD=E3=80=81iPhone=20=E6=8E=A5?= =?UTF-8?q?=E6=94=B6=E8=BD=AC=E5=86=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 4 + Phone/WatchSyncService.swift | 81 +++++++++++++++ README.md | 54 ++++++++++ YuJiWatchApp/ContentView.swift | 132 +++++++++++++++++++++++++ YuJiWatchApp/WatchRecorder.swift | 63 ++++++++++++ YuJiWatchApp/WatchSessionManager.swift | 74 ++++++++++++++ YuJiWatchApp/WatchStore.swift | 27 +++++ YuJiWatchApp/YuJiWatchApp.swift | 14 +++ YuJiWatchWidget/Complication.swift | 74 ++++++++++++++ 9 files changed, 523 insertions(+) create mode 100644 .gitignore create mode 100644 Phone/WatchSyncService.swift create mode 100644 README.md create mode 100644 YuJiWatchApp/ContentView.swift create mode 100644 YuJiWatchApp/WatchRecorder.swift create mode 100644 YuJiWatchApp/WatchSessionManager.swift create mode 100644 YuJiWatchApp/WatchStore.swift create mode 100644 YuJiWatchApp/YuJiWatchApp.swift create mode 100644 YuJiWatchWidget/Complication.swift diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fd4c5e3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +xcuserdata/ +DerivedData/ +*.xcworkspace/ +.DS_Store diff --git a/Phone/WatchSyncService.swift b/Phone/WatchSyncService.swift new file mode 100644 index 0000000..57e5c8a --- /dev/null +++ b/Phone/WatchSyncService.swift @@ -0,0 +1,81 @@ +import Foundation +import Speech +import SwiftData +import WatchConnectivity + +/// iPhone 端接收胶水代码:拖进 YuJi iPhone 工程即可。 +/// 在 YuJiApp 启动时调用 WatchSyncService.shared.activate(modelContext:)。 +/// 收到手表传来的录音文件后:拷进 AudioStorage → 系统语音识别转写 → 生成一条 VoiceNote, +/// 之后在 App 里点 AI 整理即可(和正常录音的笔记完全一样)。 +final class WatchSyncService: NSObject, WCSessionDelegate, ObservableObject { + static let shared = WatchSyncService() + + private var modelContext: ModelContext? + private let recognizer = SFSpeechRecognizer(locale: Locale(identifier: "zh-CN")) + + func activate(modelContext: ModelContext) { + self.modelContext = modelContext + guard WCSession.isSupported() else { return } + let session = WCSession.default + session.delegate = self + session.activate() + } + + // MARK: - WCSessionDelegate + + func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) { + // 激活完成即可收文件 + } + + func sessionDidBecomeInactive(_ session: WCSession) {} + func sessionDidDeactivate(_ session: WCSession) {} + + func session(_ session: WCSession, didReceive file: WCSessionFile) { + let fileName = file.fileURL.lastPathComponent + let destURL = AudioStorage.directory.appendingPathComponent(fileName) + do { + if FileManager.default.fileExists(atPath: destURL.path) { + try FileManager.default.removeItem(at: destURL) + } + try FileManager.default.copyItem(at: file.fileURL, to: destURL) + } catch { + print("手表录音保存失败:\(error)") + return + } + transcribeAndSave(audioURL: destURL, fileName: fileName) + } + + // MARK: - 转写并建笔记 + + private func transcribeAndSave(audioURL: URL, fileName: String) { + guard let recognizer, recognizer.isAvailable else { + // 识别暂不可用:先建一条空转写笔记,用户可在 App 里重新整理 + insertNote(title: "手表速记", transcript: "", fileName: fileName) + return + } + let request = SFSpeechURLRecognitionRequest(url: audioURL) + if recognizer.supportsOnDeviceRecognition { + request.requiresOnDeviceRecognition = true + } + recognizer.recognitionTask(with: request) { [weak self] result, error in + guard let self else { return } + guard let result, result.isFinal else { + if error != nil { + self.insertNote(title: "手表速记", transcript: "", fileName: fileName) + } + return + } + let text = result.bestTranscription.formattedString + let title = text.count > 20 ? String(text.prefix(20)) + "…" : (text.isEmpty ? "手表速记" : text) + self.insertNote(title: title, transcript: text, fileName: fileName) + } + } + + @MainActor + private func insertNote(title: String, transcript: String, fileName: String) { + guard let modelContext else { return } + let note = VoiceNote(title: title, transcript: transcript, audioFileName: fileName) + modelContext.insert(note) + try? modelContext.save() + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..4ae3bad --- /dev/null +++ b/README.md @@ -0,0 +1,54 @@ +# 语记手表版 YuJiWatch — Apple Watch 伴生 App + +抬腕点一下就录,录完自动传到 iPhone:手机端系统语音识别转写,再 AI 整理成 Markdown。 +手表只负责录和传,整理、存储、分享都在 iPhone 端复用语记现有能力。 + +## 能做什么 + +- ⌚ 手表主页一键录音(72pt 红色话筒钮),录音中显示计时,停止即发送 +- 📲 WatchConnectivity 传文件到 iPhone,iPhone 端自动转写并生成笔记 +- 🧩 表盘复杂功能:圆形 / 长条形 / 行内形三种,显示今日速记数,点一下打开手表 App 开始录音 +- 📋 手表端最近录音列表(时间、时长、是否已传到 iPhone) + +## 目录结构 + +``` +YuJiWatch/ +├── YuJiWatchApp/ # → Watch App target +│ ├── YuJiWatchApp.swift # 入口 +│ ├── ContentView.swift # 主页:录音 / 今日数 / 最近录音 +│ ├── WatchRecorder.swift # AVAudioRecorder 录 AAC .m4a +│ ├── WatchSessionManager.swift # WCSession 传文件 + 今日数 + 刷新复杂功能 +│ └── WatchStore.swift # 手表本地最近录音记录 +├── YuJiWatchWidget/ # → Widget Extension target +│ └── Complication.swift # 三种复杂功能 +└── Phone/ # → 拖进 YuJi iPhone 工程 + └── WatchSyncService.swift # iPhone 端收文件、转写、建笔记 +``` + +注意:`WatchSessionManager.swift` 要同时放进 Watch App 和 Widget Extension 两个 target, +因为复杂功能读今日数走它里面的 `loadTodayCount()`。两个 target 各有一个 `@main`,不要把 +`Complication.swift` 放进 Watch App target,否则 `@main` 冲突。 + +## 在 Mac 上接进 YuJi 工程 + +1. 用 Xcode 16+ 打开 YuJi 工程 → File → New → Target → Watch App,部署目标选 watchOS 10 + (watchOS 10 对应 Series 4 及以后、SE、Ultra 都能用;不需要 Ultra 专属功能) +2. 把 `YuJiWatchApp/` 里的文件加进 Watch App target;新建 Widget Extension target, + 把 `YuJiWatchWidget/Complication.swift` 和 `WatchSessionManager.swift` 加进去 +3. 把 `Phone/WatchSyncService.swift` 拖进 YuJi iPhone target,在 YuJiApp 启动时调一次: + `WatchSyncService.shared.activate(modelContext:)`(modelContext 就是 SwiftData 那个) +4. 真机联调:手表和 iPhone 配对后,手表录一段 → iPhone 端应该自动多出一条“手表速记”笔记 + +## 可选:App Group + +现在今日数存在手表本地 UserDefaults,只给复杂功能用。如果想让 iPhone 端也读到今日数, +在 Xcode 里给 Watch App、Widget Extension、iPhone App 三个 target 开同一个 App Group, +把 `UserDefaults.standard` 换成 `UserDefaults(suiteName: "group.com.你的域名.yuji")` 即可。 + +## 注意 + +- 这份代码在 Linux 环境下写的,没在 Xcode 里实际编译过。第一次打开如果有个别编译报错, + 把报错发给 Muse,一轮就能修好。 +- 手表录音是短录音场景:抬腕说完就停,不要设计成长时间连续录(续航和后台限制)。 +- 手表自己能录音,但转写和 AI 整理必须连上 iPhone 才回来;蜂窝版也一样,只是传输时机不同。 diff --git a/YuJiWatchApp/ContentView.swift b/YuJiWatchApp/ContentView.swift new file mode 100644 index 0000000..d195ade --- /dev/null +++ b/YuJiWatchApp/ContentView.swift @@ -0,0 +1,132 @@ +import SwiftUI + +/// 手表主页:一键录音、今日数、最近录音;录完传 iPhone,整理在手机端完成。 +struct ContentView: View { + @EnvironmentObject private var sessionManager: WatchSessionManager + @StateObject private var recorder = WatchRecorder() + @State private var phase = Phase.idle + @State private var notes = WatchStore.load() + @State private var lastURL: URL? + + enum Phase { + case idle, recording, sending, sent, error(String) + } + + var body: some View { + ScrollView { + VStack(spacing: 12) { + switch phase { + case .idle: + recordButton + Text("今日速记 \(sessionManager.todayCount) 条") + .font(.caption2) + .foregroundStyle(.secondary) + case .recording: + recordingView + case .sending: + ProgressView("正在传到 iPhone…") + case .sent: + Label("已传到 iPhone", systemImage: "checkmark.circle.fill") + .font(.caption) + .foregroundStyle(.green) + Button("再录一条") { phase = .idle } + .font(.caption) + case .error(let message): + Text(message) + .font(.caption) + .foregroundStyle(.red) + .multilineTextAlignment(.center) + if let url = lastURL { + Button("重试传输") { send(url: url) } + .font(.caption) + } + Button("返回") { phase = .idle } + .font(.caption) + } + + if !notes.isEmpty { + Divider() + ForEach(notes.prefix(5)) { note in + HStack { + Text(note.createdAt, format: .dateTime.hour().minute()) + .font(.caption2) + Spacer() + Text(formatTime(note.duration)) + .font(.caption2) + .foregroundStyle(.secondary) + Image(systemName: note.sent ? "checkmark.icloud.fill" : "paperplane") + .font(.caption2) + .foregroundStyle(note.sent ? .green : .secondary) + } + } + } + } + .padding() + } + } + + private var recordButton: some View { + Button { + Task { + guard await recorder.requestPermission() else { + phase = .error("没有麦克风权限,请在手表设置里开启。") + return + } + do { + try recorder.start() + phase = .recording + } catch { + phase = .error("录音启动失败:\(error.localizedDescription)") + } + } + } label: { + ZStack { + Circle().fill(Color.red) + Image(systemName: "mic.fill") + .font(.title2) + .foregroundStyle(.white) + } + .frame(width: 72, height: 72) + } + .buttonStyle(.plain) + } + + private var recordingView: some View { + VStack(spacing: 10) { + HStack(spacing: 4) { + Circle().fill(Color.red).frame(width: 8, height: 8) + Text("录音中 \(formatTime(recorder.elapsed))") + .font(.caption) + } + Button("停止并发送") { + guard let url = recorder.stop() else { + phase = .error("录音文件没生成,请再试一次。") + return + } + lastURL = url + send(url: url) + } + .font(.caption) + .tint(.red) + } + } + + private func send(url: URL) { + phase = .sending + sessionManager.transfer(url: url) { result in + switch result { + case .success: + notes.insert(WatchNote(createdAt: Date(), duration: recorder.elapsed, sent: true), at: 0) + WatchStore.append(notes[0]) + phase = .sent + case .failure(let error): + phase = .error(error.localizedDescription) + } + } + } + + private func formatTime(_ time: TimeInterval) -> String { + let total = Int(time) + return String(format: "%d:%02d", total / 60, total % 60) + } +} diff --git a/YuJiWatchApp/WatchRecorder.swift b/YuJiWatchApp/WatchRecorder.swift new file mode 100644 index 0000000..a312a07 --- /dev/null +++ b/YuJiWatchApp/WatchRecorder.swift @@ -0,0 +1,63 @@ +import AVFoundation +import Foundation + +/// 手表端录音:AVAudioRecorder 写 AAC .m4a,存在手表 Documents 里,停后交给 WatchSessionManager 传到 iPhone。 +@MainActor +final class WatchRecorder: ObservableObject { + @Published var isRecording = false + @Published var elapsed: TimeInterval = 0 + + private var recorder: AVAudioRecorder? + private var timer: Timer? + private var startDate: Date? + private(set) var currentFileURL: URL? + + func requestPermission() async -> Bool { + await withCheckedContinuation { continuation in + AVAudioSession.sharedInstance().requestRecordPermission { granted in + continuation.resume(returning: granted) + } + } + } + + func start() throws { + let session = AVAudioSession.sharedInstance() + try session.setCategory(.record, mode: .default) + try session.setActive(true) + + let fileName = "watch-note-\(Int(Date().timeIntervalSince1970)).m4a" + let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] + let url = docs.appendingPathComponent(fileName) + currentFileURL = url + + let settings: [String: Any] = [ + AVFormatIDKey: Int(kAudioFormatMPEG4AAC), + AVSampleRateKey: 44_100, + AVNumberOfChannelsKey: 1, + AVEncoderAudioQualityKey: AVAudioQuality.high.rawValue + ] + recorder = try AVAudioRecorder(url: url, settings: settings) + recorder?.record() + + isRecording = true + startDate = Date() + elapsed = 0 + timer = Timer.scheduledTimer(withTimeInterval: 0.2, repeats: true) { [weak self] _ in + Task { @MainActor in + guard let self, let start = self.startDate else { return } + self.elapsed = Date().timeIntervalSince(start) + } + } + } + + /// 停止录音,返回录音文件 URL(nil 表示没录成) + func stop() -> URL? { + timer?.invalidate() + timer = nil + recorder?.stop() + recorder = nil + isRecording = false + try? AVAudioSession.sharedInstance().setActive(false) + return currentFileURL + } +} diff --git a/YuJiWatchApp/WatchSessionManager.swift b/YuJiWatchApp/WatchSessionManager.swift new file mode 100644 index 0000000..451dee4 --- /dev/null +++ b/YuJiWatchApp/WatchSessionManager.swift @@ -0,0 +1,74 @@ +import Foundation +import WatchConnectivity +import WidgetKit + +/// 手表端会话管理:激活 WCSession、传录音文件到 iPhone、维护今日速记数并刷新复杂功能。 +@MainActor +final class WatchSessionManager: NSObject, ObservableObject, WCSessionDelegate { + static let todayCountKey = "yujiTodayCount" + static let todayDateKey = "yujiTodayDate" + + @Published var transferState = "" + @Published var todayCount = 0 + + private var completions: [String: (Result) -> Void] = [:] + + override init() { + super.init() + todayCount = Self.loadTodayCount() + if WCSession.isSupported() { + let session = WCSession.default + session.delegate = self + session.activate() + } + } + + /// 今日速记数:按自然日归零,存在手表本地 UserDefaults + static func loadTodayCount() -> Int { + let defaults = UserDefaults.standard + let today = Calendar.current.startOfDay(for: Date()) + if let saved = defaults.object(forKey: todayDateKey) as? Date, + Calendar.current.isDate(saved, inSameDayAs: today) { + return defaults.integer(forKey: todayCountKey) + } + defaults.set(today, forKey: todayDateKey) + defaults.set(0, forKey: todayCountKey) + return 0 + } + + private func bumpTodayCount() { + todayCount = Self.loadTodayCount() + 1 + UserDefaults.standard.set(todayCount, forKey: todayCountKey) + WidgetCenter.shared.reloadAllTimelines() + } + + /// 传录音文件到 iPhone,完成后回调(成功时今日数 +1 并刷新复杂功能) + func transfer(url: URL, completion: @escaping (Result) -> Void) { + guard WCSession.isSupported(), WCSession.default.activationState == .activated else { + completion(.failure(NSError(domain: "YuJiWatch", code: 1, userInfo: [NSLocalizedDescriptionKey: "手表与 iPhone 未连接,录音先存在手表上,连上后会自动重传。"]))) + return + } + let transfer = WCSession.default.transferFile(url, metadata: ["createdAt": Date().timeIntervalSince1970]) + completions[transfer.file.fileURL.lastPathComponent] = completion + transferState = "正在传到 iPhone…" + } + + // MARK: - WCSessionDelegate + + func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) { + // 激活完成即可传文件,无需额外处理 + } + + func session(_ session: WCSession, didFinish fileTransfer: WCSessionFileTransfer, error: Error?) { + let key = fileTransfer.file.fileURL.lastPathComponent + guard let completion = completions.removeValue(forKey: key) else { return } + if let error { + transferState = "传输失败:\(error.localizedDescription)" + completion(.failure(error)) + } else { + transferState = "已传到 iPhone" + bumpTodayCount() + completion(.success(())) + } + } +} diff --git a/YuJiWatchApp/WatchStore.swift b/YuJiWatchApp/WatchStore.swift new file mode 100644 index 0000000..9df5abe --- /dev/null +++ b/YuJiWatchApp/WatchStore.swift @@ -0,0 +1,27 @@ +import Foundation + +/// 手表本地最近录音记录(仅用于手表端列表展示,真正笔记在 iPhone 端生成) +struct WatchNote: Identifiable, Codable { + var id = UUID() + var createdAt: Date + var duration: TimeInterval + var sent: Bool +} + +enum WatchStore { + private static let key = "yujiWatchNotes" + + static func load() -> [WatchNote] { + guard let data = UserDefaults.standard.data(forKey: key), + let notes = try? JSONDecoder().decode([WatchNote].self, from: data) else { return [] } + return notes.sorted { $0.createdAt > $1.createdAt } + } + + static func append(_ note: WatchNote) { + var notes = load() + notes.append(note) + if let data = try? JSONEncoder().encode(notes) { + UserDefaults.standard.set(data, forKey: key) + } + } +} diff --git a/YuJiWatchApp/YuJiWatchApp.swift b/YuJiWatchApp/YuJiWatchApp.swift new file mode 100644 index 0000000..90e312c --- /dev/null +++ b/YuJiWatchApp/YuJiWatchApp.swift @@ -0,0 +1,14 @@ +import SwiftUI + +/// 语记手表版入口:watchOS 10+,SwiftUI +@main +struct YuJiWatchApp: App { + @StateObject private var sessionManager = WatchSessionManager() + + var body: some Scene { + WindowGroup { + ContentView() + .environmentObject(sessionManager) + } + } +} diff --git a/YuJiWatchWidget/Complication.swift b/YuJiWatchWidget/Complication.swift new file mode 100644 index 0000000..8789d2e --- /dev/null +++ b/YuJiWatchWidget/Complication.swift @@ -0,0 +1,74 @@ +import SwiftUI +import WidgetKit + +/// 语记复杂功能:显示今日速记数,点一下打开手表 App 开始录音。 +/// 放进 Widget Extension target;今日数读的是手表本地 UserDefaults(与 WatchSessionManager 共用 key)。 +struct YuJiEntry: TimelineEntry { + let date: Date + let count: Int +} + +struct YuJiProvider: TimelineProvider { + func placeholder(in context: Context) -> YuJiEntry { + YuJiEntry(date: Date(), count: 3) + } + + func getSnapshot(in context: Context, completion: @escaping (YuJiEntry) -> Void) { + completion(YuJiEntry(date: Date(), count: WatchSessionManager.loadTodayCount())) + } + + func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { + let entry = YuJiEntry(date: Date(), count: WatchSessionManager.loadTodayCount()) + let nextMidnight = Calendar.current.startOfDay(for: Date().addingTimeInterval(86400)) + completion(Timeline(entries: [entry], policy: .after(nextMidnight))) + } +} + +struct YuJiComplicationView: View { + @Environment(\.widgetFamily) private var family + var entry: YuJiEntry + + var body: some View { + switch family { + case .accessoryCircular: + ZStack { + AccessoryWidgetBackground() + VStack(spacing: 0) { + Image(systemName: "mic.fill").font(.caption2) + Text("\(entry.count)").font(.headline) + } + } + case .accessoryRectangular: + HStack { + Image(systemName: "mic.fill") + VStack(alignment: .leading) { + Text("语记").font(.caption2) + Text("今日 \(entry.count) 条").font(.caption) + } + } + default: + HStack(spacing: 2) { + Image(systemName: "mic.fill").font(.caption2) + Text("\(entry.count)").font(.caption) + } + } + } +} + +struct YuJiComplicationWidget: Widget { + var body: some WidgetConfiguration { + StaticConfiguration(kind: "YuJiComplication", provider: YuJiProvider()) { entry in + YuJiComplicationView(entry: entry) + } + .configurationDisplayName("语记") + .description("今日速记数,点一下开始录音") + .supportedFamilies([.accessoryCircular, .accessoryRectangular, .accessoryInline]) + } +} + +@main +struct YuJiWidgets: WidgetBundle { + var body: some Widget { + YuJiComplicationWidget() + } +}