diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index db50694..94e1ad2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,12 +21,10 @@ jobs: fail-fast: false matrix: include: - - name: macOS 15, Xcode 26.2 - slug: macos15 - runner: macos-15 - xcode: "26.2" # The OS most users are on: build with its SDK and run the tests on # it. GitHub names this image after its Xcode; it runs macOS 27. + # The app needs macOS 26 on Apple Silicon, so there is no older + # system or Intel Mac left to build for. - name: macOS 27, Xcode 27 slug: macos27 runner: xcode-27 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2aa5f78..6dc5ffc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -40,14 +40,11 @@ jobs: name: Tests must pass before a release is cut uses: ./.github/workflows/ci.yml - # One DMG per architecture rather than a universal app: each download is - # half the size, and the Intel build says plainly that it cannot run the - # local MLX engines. + # Apple Silicon only: the app needs macOS 26 and the local engines need + # Apple Silicon, so an Intel build would be a download nobody can use well. # - # Apple silicon is built with Xcode 27 on macOS 27, so the app is linked - # against the macOS 27 SDK and gets that system's behavior rather than a - # compatibility mode. macOS 27 does not run on Intel Macs, so the Intel - # build stays on the toolchain of the last system they run. + # Built with Xcode 27 on macOS 27, so the app is linked against the macOS 27 + # SDK and gets that system's behavior rather than a compatibility mode. build: name: Build DMG (${{ matrix.arch }}) needs: [version, test] @@ -58,9 +55,6 @@ jobs: - arch: arm64 runner: xcode-27 xcode: "27" - - arch: x86_64 - runner: macos-15 - xcode: "26.2" env: VERSION: ${{ needs.version.outputs.version }} steps: @@ -95,8 +89,8 @@ jobs: -project ClassNote.xcodeproj \ -scheme ClassNote - # The runner is Apple Silicon; x86_64 is cross-compiled. ARCHS pins the - # slice, since a Release build would otherwise make a universal binary. + # ARCHS pins the slice (project.yml does too), so the binary can never + # come out universal. - name: Build Release (${{ matrix.arch }}) run: | xcodebuild -project ClassNote.xcodeproj \ @@ -149,7 +143,8 @@ jobs: name: ClassNote-dmg-${{ matrix.arch }} path: ${{ env.DMG_NAME }} - # Once, after both builds: two matrix jobs creating the same release race. + # Its own job, after the build, so a release is only created once there is + # a DMG to attach. release: name: Publish GitHub Release needs: build diff --git a/App/ClassNoteApp.swift b/App/ClassNoteApp.swift index 3ea6c8c..682c923 100644 --- a/App/ClassNoteApp.swift +++ b/App/ClassNoteApp.swift @@ -78,7 +78,7 @@ struct ClassNoteApp: App { MainWindowView() .environmentObject(appState) .frame(minWidth: 960, minHeight: 600) - .background(translationBridgeView) + .background(AppleTranslationBridgeView()) .captureWindowActions() .id(appState.languageRefreshToken) } @@ -90,7 +90,7 @@ struct ClassNoteApp: App { LiveSessionView(windowId: id) .environmentObject(appState) .frame(minWidth: 640, minHeight: 460) - .background(translationBridgeView) + .background(AppleTranslationBridgeView()) .captureWindowActions() .id(appState.languageRefreshToken) } @@ -128,15 +128,6 @@ struct ClassNoteApp: App { } .menuBarExtraStyle(.window) } - - @ViewBuilder - private var translationBridgeView: some View { - if #available(macOS 15.0, *) { - AppleTranslationBridgeView() - } else { - EmptyView() - } - } } /// Observes `AppState` itself rather than taking a Bool from the scene body, so diff --git a/Core/Engines/AppleSpeechSTT+Legacy.swift b/Core/Engines/AppleSpeechSTT+Legacy.swift deleted file mode 100644 index 5caf6d7..0000000 --- a/Core/Engines/AppleSpeechSTT+Legacy.swift +++ /dev/null @@ -1,328 +0,0 @@ -import Foundation -import AVFoundation -import Speech - -/// SFSpeechRecognizer path for macOS 14-25. Forces on-device recognition -/// whenever the recognizer supports it so audio never leaves the machine; -/// silently falls back to server-assisted recognition on locales/OS versions -/// that don't support on-device mode (matches Apple's own dictation behavior). -extension AppleSpeechSTT { - /// `SFSpeechAudioBufferRecognitionRequest` only reports `isFinal == true` - /// once, when `endAudio()` finishes processing — using a single request - /// for the whole session would delay every subtitle until the user stops - /// recording. So we restart the request on silence boundaries, mirroring - /// the commit strategy `OpenAICompatibleSTT` uses for cloud chunking. - static func transcribeLegacy(audio: AsyncStream, - language: String?) -> AsyncThrowingStream { - AsyncThrowingStream { continuation in - let task = Task { - do { - try await requestAuthorizationIfNeeded() - guard let recognizer = SFSpeechRecognizer(locale: localeFor(language)), - recognizer.isAvailable else { - throw EngineError.unsupported("系统语音识别当前不可用,请检查系统设置 > 隐私与安全性 > 语音识别。") - } - let onDevice = recognizer.supportsOnDeviceRecognition - let format = AVAudioFormat(commonFormat: .pcmFormatInt16, sampleRate: 16000, channels: 1, interleaved: true) - ?? AVAudioFormat() - - let chunker = SilenceChunker() - var activeRequest: SFSpeechAudioBufferRecognitionRequest? - var activeSink: LegacyRecognitionSink? - var activeTask: SFSpeechRecognitionTask? - - func openRequest(baseMs: Int64) { - let request = SFSpeechAudioBufferRecognitionRequest() - request.shouldReportPartialResults = true - if onDevice { request.requiresOnDeviceRecognition = true } - let sink = LegacyRecognitionSink(continuation: continuation, baseMs: baseMs) - let rtask = recognizer.recognitionTask(with: request) { result, error in - sink.handle(result: result, error: error) - } - activeRequest = request - activeSink = sink - activeTask = rtask - } - - func closeRequest() async { - guard let request = activeRequest, let sink = activeSink, let rtask = activeTask else { return } - request.endAudio() - await sink.waitUntilFinished() - rtask.cancel() - activeRequest = nil - activeSink = nil - activeTask = nil - } - - for await chunk in audio { - try Task.checkCancellation() - if activeRequest == nil { openRequest(baseMs: chunk.timestamp) } - guard let buffer = Self.makeBuffer(from: chunk.pcmData, format: format) else { continue } - activeRequest?.append(buffer) - - if chunker.shouldCommit(chunk: chunk) { - await closeRequest() - } - } - await closeRequest() - continuation.finish() - } catch { - continuation.finish(throwing: error) - } - } - continuation.onTermination = { _ in task.cancel() } - } - } - - static func transcribeFileLegacy(url: URL, - language: String?) -> AsyncThrowingStream { - AsyncThrowingStream { continuation in - let task = Task { - do { - try await requestAuthorizationIfNeeded() - guard let recognizer = SFSpeechRecognizer(locale: localeFor(language)), - recognizer.isAvailable else { - throw EngineError.unsupported("系统语音识别当前不可用,请检查系统设置 > 隐私与安全性 > 语音识别。") - } - - continuation.yield(.progress(completed: 0, total: 1)) - let request = SFSpeechURLRecognitionRequest(url: url) - request.shouldReportPartialResults = true - if recognizer.supportsOnDeviceRecognition { - request.requiresOnDeviceRecognition = true - } - - let sink = LegacyFileRecognitionSink(continuation: continuation) - let recognitionTask = recognizer.recognitionTask(with: request) { result, error in - sink.handle(result: result, error: error) - } - - await sink.waitUntilFinished() - recognitionTask.cancel() - continuation.yield(.progress(completed: 1, total: 1)) - continuation.finish() - } catch { - continuation.finish(throwing: error) - } - } - continuation.onTermination = { _ in task.cancel() } - } - } - - private static func requestAuthorizationIfNeeded() async throws { - let status = SFSpeechRecognizer.authorizationStatus() - if status == .authorized { return } - let granted = await withCheckedContinuation { (cont: CheckedContinuation) in - SFSpeechRecognizer.requestAuthorization { newStatus in - cont.resume(returning: newStatus == .authorized) - } - } - guard granted else { - throw EngineError.unsupported("语音识别权限被拒绝,请在系统设置 > 隐私与安全性 > 语音识别 中允许 ClassNote。") - } - } - - private static func makeBuffer(from data: Data, format: AVAudioFormat) -> AVAudioPCMBuffer? { - let frameCount = AVAudioFrameCount(data.count / 2) - guard frameCount > 0, let buffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: frameCount) else { return nil } - buffer.frameLength = frameCount - guard let channelData = buffer.int16ChannelData else { return nil } - data.withUnsafeBytes { raw in - guard let src = raw.bindMemory(to: Int16.self).baseAddress else { return } - channelData[0].update(from: src, count: Int(frameCount)) - } - return buffer - } -} - -/// Tracks voiced/silent run lengths to decide when to cut over to a fresh -/// `SFSpeechAudioBufferRecognitionRequest`, using the same thresholds as the -/// cloud engine's chunking so live-subtitle cadence feels consistent -/// regardless of which STT backend is selected. -private final class SilenceChunker { - private let minVoicedSec: Double = 3.0 - private let silenceHoldSec: Double = 0.5 - private let maxChunkSec: Double = 12.0 - private let rmsThreshold: Double = 0.01 - - private var voicedSec: Double = 0 - private var silentTrailSec: Double = 0 - private var totalSec: Double = 0 - - func shouldCommit(chunk: AudioChunk) -> Bool { - let sampleCount = chunk.pcmData.count / 2 - guard sampleCount > 0 else { return false } - let durationSec = Double(sampleCount) / Double(chunk.sampleRate) - totalSec += durationSec - - let rms = VADGate.rms(pcm16: chunk.pcmData) - if rms >= rmsThreshold { - voicedSec += durationSec - silentTrailSec = 0 - } else { - silentTrailSec += durationSec - } - - let shouldCommit = totalSec >= maxChunkSec || (voicedSec >= minVoicedSec && silentTrailSec >= silenceHoldSec) - if shouldCommit { - voicedSec = 0 - silentTrailSec = 0 - totalSec = 0 - } - return shouldCommit - } -} - -/// SFSpeechRecognizer's completion handler fires repeatedly (partial results, -/// then a final result, then again with `isFinal` for the same utterance) — -/// this tracks the last emitted text per result generation so we only yield -/// once a segment is truly final, then unblocks the feeding loop. -private final class LegacyRecognitionSink: @unchecked Sendable { - private let continuation: AsyncThrowingStream.Continuation - private let baseMs: Int64 - private let lock = NSLock() - private var finished = false - private var waiters: [CheckedContinuation] = [] - private var emittedSegmentCount = 0 - - init(continuation: AsyncThrowingStream.Continuation, baseMs: Int64) { - self.continuation = continuation - self.baseMs = baseMs - } - - func handle(result: SFSpeechRecognitionResult?, error: Error?) { - lock.lock() - defer { lock.unlock() } - guard !finished else { return } - - if let error { - continuation.finish(throwing: error) - markFinished() - return - } - guard let result else { return } - - let segments = result.bestTranscription.segments - if result.isFinal { - for seg in segments.dropFirst(emittedSegmentCount) { - emit(seg) - } - emittedSegmentCount = segments.count - markFinished() - } else { - emitDraft(result.bestTranscription.formattedString) - } - } - - private func emit(_ segment: SFTranscriptionSegment) { - let text = segment.substring.trimmingCharacters(in: .whitespacesAndNewlines) - guard OpenAICompatibleSTT.shouldEmit(text, minChars: 3) else { return } - let startMs = baseMs + Int64(segment.timestamp * 1000) - let endMs = baseMs + Int64((segment.timestamp + segment.duration) * 1000) - continuation.yield(TranscriptEvent(startMs: startMs, endMs: endMs, text: text, isFinal: true)) - } - - private func emitDraft(_ text: String) { - let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return } - continuation.yield(TranscriptEvent(startMs: baseMs, endMs: baseMs, text: trimmed, isFinal: false)) - } - - private func markFinished() { - finished = true - for waiter in waiters { waiter.resume() } - waiters.removeAll() - } - - /// `NSLock.lock()/unlock()` are unavailable from async contexts under - /// Swift 6 mode, so each lock/unlock pair here is fully synchronous — - /// only the continuation's suspension itself crosses an await. - func waitUntilFinished() async { - let alreadyFinished: Bool = { - lock.lock() - defer { lock.unlock() } - return finished - }() - guard !alreadyFinished else { return } - - await withCheckedContinuation { cont in - lock.lock() - defer { lock.unlock() } - if finished { - cont.resume() - } else { - waiters.append(cont) - } - } - } -} - -private final class LegacyFileRecognitionSink: @unchecked Sendable { - private let continuation: AsyncThrowingStream.Continuation - private let lock = NSLock() - private var finished = false - private var waiters: [CheckedContinuation] = [] - private var emittedSegmentCount = 0 - - init(continuation: AsyncThrowingStream.Continuation) { - self.continuation = continuation - } - - func handle(result: SFSpeechRecognitionResult?, error: Error?) { - lock.lock() - defer { lock.unlock() } - guard !finished else { return } - - if let error { - continuation.finish(throwing: error) - markFinished() - return - } - guard let result else { return } - - let segments = result.bestTranscription.segments - if result.isFinal { - for seg in segments.dropFirst(emittedSegmentCount) { - emit(seg) - } - emittedSegmentCount = segments.count - markFinished() - } - } - - private func emit(_ segment: SFTranscriptionSegment) { - let text = segment.substring.trimmingCharacters(in: .whitespacesAndNewlines) - guard OpenAICompatibleSTT.shouldEmit(text, minChars: 3) else { return } - let startMs = Int64(segment.timestamp * 1000) - let endMs = Int64((segment.timestamp + segment.duration) * 1000) - continuation.yield(.segment(TranscriptEvent(startMs: startMs, endMs: endMs, text: text, isFinal: true))) - } - - private func markFinished() { - finished = true - for waiter in waiters { waiter.resume() } - waiters.removeAll() - } - - /// `NSLock.lock()/unlock()` are unavailable from async contexts under - /// Swift 6 mode, so each lock/unlock pair here is fully synchronous — - /// only the continuation's suspension itself crosses an await. - func waitUntilFinished() async { - let alreadyFinished: Bool = { - lock.lock() - defer { lock.unlock() } - return finished - }() - guard !alreadyFinished else { return } - - await withCheckedContinuation { cont in - lock.lock() - defer { lock.unlock() } - if finished { - cont.resume() - } else { - waiters.append(cont) - } - } - } -} diff --git a/Core/Engines/AppleSpeechSTT+Modern.swift b/Core/Engines/AppleSpeechSTT+Modern.swift index 2d0fe20..bcb74c5 100644 --- a/Core/Engines/AppleSpeechSTT+Modern.swift +++ b/Core/Engines/AppleSpeechSTT+Modern.swift @@ -5,9 +5,8 @@ import Foundation import Speech import CoreMedia -/// SpeechAnalyzer/SpeechTranscriber path (macOS 26+). Faster and more -/// accurate than SFSpeechRecognizer, fully on-device. -@available(macOS 26.0, iOS 26.0, *) +/// The SpeechAnalyzer/SpeechTranscriber path. Faster and more accurate than +/// SFSpeechRecognizer, fully on-device. extension AppleSpeechSTT { static func transcribeModern(audio: AsyncStream, language: String?) -> AsyncThrowingStream { diff --git a/Core/Engines/AppleSpeechSTT.swift b/Core/Engines/AppleSpeechSTT.swift index 5890d9b..c6cde29 100644 --- a/Core/Engines/AppleSpeechSTT.swift +++ b/Core/Engines/AppleSpeechSTT.swift @@ -2,34 +2,22 @@ import Foundation import AVFoundation import Speech -/// On-device speech recognition using macOS's built-in engines. Picks between -/// two backends at runtime because Apple's newer, faster engine only ships -/// starting macOS 26: -/// - macOS 26+ : `SpeechAnalyzer` + `SpeechTranscriber` (WWDC25), fully -/// on-device, low latency, the "new dictation" engine. -/// - macOS 14-25: `SFSpeechRecognizer` with `requiresOnDeviceRecognition` -/// set whenever the recognizer supports it. -/// Both paths only emit an event once a segment is finalized, matching the -/// "whole utterance lands at once" semantics the cloud Whisper engine uses. +/// On-device speech recognition with macOS's built-in engine, +/// `SpeechAnalyzer` + `SpeechTranscriber` (WWDC25): fully on-device, low +/// latency, the "new dictation" engine. It only emits an event once a segment +/// is finalized, matching the "whole utterance lands at once" semantics the +/// cloud Whisper engine uses. final class AppleSpeechSTT: STTProvider, Sendable { init() {} func transcribe(audio: AsyncStream, language: String?) -> AsyncThrowingStream { - if #available(macOS 26.0, iOS 26.0, *) { - return Self.transcribeModern(audio: audio, language: language) - } else { - return Self.transcribeLegacy(audio: audio, language: language) - } + Self.transcribeModern(audio: audio, language: language) } func transcribeFile(url: URL, language: String?) -> AsyncThrowingStream { - if #available(macOS 26.0, iOS 26.0, *) { - return Self.transcribeFileModern(url: url, language: language) - } else { - return Self.transcribeFileLegacy(url: url, language: language) - } + Self.transcribeFileModern(url: url, language: language) } static func localeFor(_ languageCode: String?) -> Locale { diff --git a/Core/Engines/AppleTranslationBridge.swift b/Core/Engines/AppleTranslationBridge.swift index 0cc176b..5e677fc 100644 --- a/Core/Engines/AppleTranslationBridge.swift +++ b/Core/Engines/AppleTranslationBridge.swift @@ -1,16 +1,14 @@ import SwiftUI import Translation -/// `TranslationSession` cannot be instantiated directly pre-macOS 26 — it's -/// only obtainable through the SwiftUI `.translationTask` modifier. This -/// bridge hosts that modifier on an invisible view kept alive for the app's -/// lifetime, and hands the resulting session to whichever caller is waiting -/// for the current source/target language pair. +/// Hosts the SwiftUI `.translationTask` modifier on an invisible view kept +/// alive for the app's lifetime, and hands the session it produces to +/// whichever caller is waiting for the current source/target language pair. /// -/// On macOS 26+ `TranslationSession(installedSource:target:)` exists and -/// needs no view at all, so `AppleTranslationEngine` prefers that path and -/// only falls back to this bridge on macOS 15-25. -@available(macOS 15.0, iOS 18.0, *) +/// A pair that is already installed needs no view: +/// `AppleTranslationEngine` makes it a `TranslationSession(installedSource: +/// target:)` directly. A pair still to be downloaded comes through here, +/// because the modifier is what shows the system's download prompt. @MainActor final class AppleTranslationBridge: ObservableObject { static let shared = AppleTranslationBridge() @@ -60,12 +58,10 @@ final class AppleTranslationBridge: ObservableObject { /// `async` calls, so the crossing is unavoidable. The box keeps it in one named /// place instead of at every call site; the session itself is used exactly /// where it was before the box existed — in the task that requested it. -@available(macOS 15.0, iOS 18.0, *) struct TranslationSessionBox: @unchecked Sendable { let session: TranslationSession } -@available(macOS 15.0, iOS 18.0, *) struct AppleTranslationBridgeView: View { @ObservedObject var bridge = AppleTranslationBridge.shared diff --git a/Core/Engines/AppleTranslationEngine.swift b/Core/Engines/AppleTranslationEngine.swift index c694e57..0e2cc8c 100644 --- a/Core/Engines/AppleTranslationEngine.swift +++ b/Core/Engines/AppleTranslationEngine.swift @@ -5,7 +5,6 @@ import Translation /// cloud LLM translator this returns each sentence as a single chunk rather /// than a token stream — `TranslationSession.translate(_:)` has no streaming /// API, so the whole result is yielded at once. -@available(macOS 15.0, iOS 18.0, *) final class AppleTranslationEngine: TranslationProvider, Sendable { /// `glossary` is ignored: `TranslationSession.translate(_:)` takes a string /// and nothing else, so there is no prompt to put it in. @@ -51,10 +50,7 @@ final class AppleTranslationEngine: TranslationProvider, Sendable { case .unsupported: throw EngineError.unsupported("macOS 本地翻译不支持该语言对(\(source.languageCode?.identifier ?? "?") → \(target.languageCode?.identifier ?? "?"))。") case .installed: - if #available(macOS 26.0, iOS 26.0, *) { - return TranslationSessionBox(session: TranslationSession(installedSource: source, target: target)) - } - return await AppleTranslationBridge.shared.session(source: source, target: target) + return TranslationSessionBox(session: TranslationSession(installedSource: source, target: target)) case .supported: let box = await AppleTranslationBridge.shared.session(source: source, target: target) do { diff --git a/Core/Engines/Providers.swift b/Core/Engines/Providers.swift index fd3815b..4ddec85 100644 --- a/Core/Engines/Providers.swift +++ b/Core/Engines/Providers.swift @@ -221,10 +221,7 @@ struct EngineFactory { case .openAICompatible: return OpenAICompatibleTranslator(config: config) case .appleTranslation: - if #available(macOS 15.0, iOS 18.0, *) { - return AppleTranslationEngine() - } - return OpenAICompatibleTranslator(config: config) + return AppleTranslationEngine() case .localMLX: return LocalMLXTranslator() case .t3po: diff --git a/Core/Engines/PythonProvisioner.swift b/Core/Engines/PythonProvisioner.swift index 2e7c460..a82b49b 100644 --- a/Core/Engines/PythonProvisioner.swift +++ b/Core/Engines/PythonProvisioner.swift @@ -1,7 +1,6 @@ import Foundation enum PythonProvisionError: Error, LocalizedError { - case unsupportedArchitecture case downloadFailed(String) case checksumMismatch case extractFailed(String) @@ -9,8 +8,6 @@ enum PythonProvisionError: Error, LocalizedError { var errorDescription: String? { switch self { - case .unsupportedArchitecture: - return L10n.t("localASR.python.intelUnsupported") case .downloadFailed(let msg): return "\(L10n.t("localASR.python.downloadFailed")): \(msg)" case .checksumMismatch: @@ -72,13 +69,6 @@ struct PythonProvisioner: Sendable { func provision(onProgress: @escaping @Sendable (String, Double?) -> Void) async throws -> String { if isProvisioned { return interpreterURL.path } - #if arch(arm64) - #else - // MLX needs Apple Silicon anyway, so there is no useful x86_64 build to - // fall back to — say so plainly rather than downloading 24 MB first. - throw PythonProvisionError.unsupportedArchitecture - #endif - let archive = try await download(onProgress: onProgress) defer { try? FileManager.default.removeItem(at: archive) } diff --git a/Core/Utils/L10n.swift b/Core/Utils/L10n.swift index 3ea6bb6..a8229e9 100644 --- a/Core/Utils/L10n.swift +++ b/Core/Utils/L10n.swift @@ -529,7 +529,6 @@ enum L10n { "localASR.python.checksumMismatch": "The downloaded Python runtime failed its integrity check", "localASR.python.extractFailed": "Could not unpack the Python runtime", "localASR.python.unusableAfterInstall": "The Python runtime installed but will not run", - "localASR.python.intelUnsupported": "The local engine needs an Apple Silicon Mac.", "localASR.python.notFound": "No usable Python runtime is available.", "localASR.installFailed": "Dependency install failed", "settings.engines.installNow": "Install and download models", @@ -1218,7 +1217,6 @@ enum L10n { "localASR.python.checksumMismatch": "下载的 Python 运行时校验失败", "localASR.python.extractFailed": "无法解压 Python 运行时", "localASR.python.unusableAfterInstall": "Python 运行时已安装但无法运行", - "localASR.python.intelUnsupported": "本地引擎需要 Apple 芯片的 Mac。", "localASR.python.notFound": "没有可用的 Python 运行时。", "localASR.installFailed": "依赖安装失败", "settings.engines.installNow": "安装并下载模型", diff --git a/Features/Shared/BottomFollow.swift b/Features/Shared/BottomFollow.swift index 1b9d6e5..46647b7 100644 --- a/Features/Shared/BottomFollow.swift +++ b/Features/Shared/BottomFollow.swift @@ -28,9 +28,7 @@ enum BottomFollow { } extension View { - /// Keeps `following` in step with the reader's scrolling. macOS 14 cannot - /// read where a scroll view is, so there it stays as it is and the view - /// follows the end as it always did. + /// Keeps `following` in step with the reader's scrolling. func tracksBottomFollow(_ following: Binding) -> some View { modifier(BottomFollowTracker(following: following)) } @@ -40,16 +38,12 @@ private struct BottomFollowTracker: ViewModifier { @Binding var following: Bool func body(content: Content) -> some View { - if #available(macOS 15.0, *) { - content.onScrollGeometryChange(for: BottomFollow.Position.self) { geometry in - BottomFollow.Position(offset: geometry.contentOffset.y, - distanceToEnd: geometry.contentSize.height - geometry.visibleRect.maxY) - } action: { old, new in - let next = BottomFollow.following(following, from: old, to: new) - if next != following { following = next } - } - } else { - content + content.onScrollGeometryChange(for: BottomFollow.Position.self) { geometry in + BottomFollow.Position(offset: geometry.contentOffset.y, + distanceToEnd: geometry.contentSize.height - geometry.visibleRect.maxY) + } action: { old, new in + let next = BottomFollow.following(following, from: old, to: new) + if next != following { following = next } } } } diff --git a/README.md b/README.md index d573c2b..b3880d2 100644 --- a/README.md +++ b/README.md @@ -30,11 +30,11 @@ macOS-native lecture recorder for US-bound study-abroad students. Records classr ## Requirements -- macOS 14+ (Apple Silicon recommended) -- Xcode 26+ / Swift 5.10+ +- An Apple Silicon Mac on macOS 26 or later +- Xcode 27+ / Swift 5.10+ - `xcodegen` (`brew install xcodegen`) - An OpenAI-compatible API endpoint (OpenAI official, DeepSeek, Groq, SiliconFlow, Ollama, LM Studio, etc.) — not needed if you use the local engines for transcription, translation and notes -- For the local ASR engine: an Apple Silicon Mac. **No Python setup required** — the app uses a system `python3` if one is 3.11 or newer, and otherwise downloads a self-contained CPython (pinned and SHA-256 verified) into Application Support. macOS's built-in `/usr/bin/python3` is 3.9 and has no `sherpa-onnx` wheel, so it is skipped. +- For the local ASR engine: **No Python setup required** — the app uses a system `python3` if one is 3.11 or newer, and otherwise downloads a self-contained CPython (pinned and SHA-256 verified) into Application Support. macOS's built-in `/usr/bin/python3` is 3.9 and has no `sherpa-onnx` wheel, so it is skipped. Install from **Settings → Engines**, which creates a venv under `~/Library/Application Support/ClassNote/pyenv/` and downloads the weights with progress. **Settings → Models** lists every model with its source, size on disk and a Delete button. The sidecars' direct dependencies are pinned to exact versions in `Scripts/requirements-local.txt`, so two machines installing a week apart get the same program: @@ -77,7 +77,7 @@ python3 -m unittest discover -s Tests/PythonTests 1. On `main`, set `MARKETING_VERSION` to the new version in `project.yml` and bump `CURRENT_PROJECT_VERSION`. 2. Tag that commit and push the tag: `git tag v0.16.0 && git push origin v0.16.0`. The tag must match `MARKETING_VERSION`, or the release stops at its first step. -The Release workflow runs the tests, then builds two DMGs and attaches them to a GitHub Release: `ClassNote--arm64.dmg` for Apple Silicon and `ClassNote--x86_64.dmg` for Intel Macs. The local engines need Apple Silicon; on Intel, use the cloud API or Apple's on-device speech. The app is not notarized, so the first launch has to be allowed in System Settings → Privacy & Security. +The Release workflow runs the tests, then builds `ClassNote--arm64.dmg` and attaches it to a GitHub Release. There is no Intel build: the app needs Apple Silicon and macOS 26. The app is not notarized, so the first launch has to be allowed in System Settings → Privacy & Security. ## Configure diff --git a/project.yml b/project.yml index b418610..f128270 100644 --- a/project.yml +++ b/project.yml @@ -1,8 +1,10 @@ name: ClassNote options: bundleIdPrefix: com.beluga.classnote + # Apple Silicon Macs on macOS 26 or later: the local engines (MLX) need + # Apple Silicon, and every Apple Silicon Mac can run macOS 26. deploymentTarget: - macOS: "14.0" + macOS: "26.0" createIntermediateGroups: true generateEmptyDirectories: true @@ -15,8 +17,11 @@ settings: SWIFT_STRICT_CONCURRENCY: complete SWIFT_TREAT_WARNINGS_AS_ERRORS: "NO" ENABLE_USER_SCRIPT_SANDBOXING: NO - MARKETING_VERSION: "0.16.8" - CURRENT_PROJECT_VERSION: "26" + MARKETING_VERSION: "0.16.9" + CURRENT_PROJECT_VERSION: "27" + # Apple Silicon only, in every configuration: a Release build would + # otherwise also carry an Intel slice nobody can use. + ARCHS: arm64 DEAD_CODE_STRIPPING: YES packages: @@ -61,7 +66,7 @@ targets: - en - zh-Hans CFBundleDevelopmentRegion: en - LSMinimumSystemVersion: "14.0" + LSMinimumSystemVersion: "26.0" NSHumanReadableCopyright: "© 2026 ClassNote" NSMicrophoneUsageDescription: "ClassNote 需要麦克风权限以录制现场课程音频" NSSpeechRecognitionUsageDescription: "ClassNote 需要语音识别权限以使用 macOS 系统自带的本地语音转写"