diff --git a/Package.swift b/Package.swift index ddf5d78..127d1c3 100644 --- a/Package.swift +++ b/Package.swift @@ -16,6 +16,7 @@ let package = Package( .product(name: "WhisperKit", package: "WhisperKit"), ] ), + .executableTarget(name: "Sst", dependencies: ["SstCore", "SstWhisper"]), .executableTarget(name: "SstProbe", dependencies: ["SstCore", "SstWhisper"]), .testTarget(name: "SstCoreTests", dependencies: ["SstCore"]), ], diff --git a/Sources/Sst/AppModel.swift b/Sources/Sst/AppModel.swift new file mode 100644 index 0000000..4502627 --- /dev/null +++ b/Sources/Sst/AppModel.swift @@ -0,0 +1,87 @@ +import AVFoundation +import ApplicationServices +import SwiftUI +import SstCore +import SstWhisper + +@MainActor +final class AppModel: ObservableObject { + @Published var state: DictationState = .idle + @Published var modelStatus = "Model laden…" + @Published var modelReady = false + @Published var lastError: String? + @Published var micGranted = false + @Published var accessibilityGranted = false + @Published var inputMonitoringGranted = false + + private let transcriber = WhisperKitTranscriber() + private let hotkey = HotkeyMonitor() + private let controller: DictationController + + init() { + controller = DictationController( + recorder: AudioRecorder(), + transcriber: transcriber, + cleaner: PassthroughCleaner(), + inserter: TextInserter(pasteboard: SystemPasteboard(), paster: CGEventPaster()) + ) + controller.onStateChange = { [weak self] in self?.state = $0 } + controller.onError = { [weak self] in self?.lastError = $0 } + hotkey.onEvent = { [weak self] event in + guard let self, self.modelReady else { return } + Task { await self.controller.handle(event) } + } + Task { await start() } + } + + var statusLine: String { + guard modelReady else { return modelStatus } + switch state { + case .idle: return "Klaar — houd Fn ingedrukt om te dicteren" + case .recording: return "Luistert…" + case .transcribing: return "Transcriberen…" + case .inserting: return "Invoegen…" + } + } + + var iconName: String { + switch state { + case .idle: return modelReady ? "mic" : "mic.slash" + case .recording: return "mic.fill" + case .transcribing, .inserting: return "waveform" + } + } + + func refreshPermissions() { + micGranted = AVCaptureDevice.authorizationStatus(for: .audio) == .authorized + accessibilityGranted = AXIsProcessTrusted() + inputMonitoringGranted = CGPreflightListenEventAccess() + } + + func retryHotkey() { + refreshPermissions() + do { + try hotkey.start() + lastError = nil + } catch { + lastError = error.localizedDescription + } + } + + private func start() async { + _ = await AVCaptureDevice.requestAccess(for: .audio) + if !CGPreflightListenEventAccess() { _ = CGRequestListenEventAccess() } + if !AXIsProcessTrusted() { + _ = AXIsProcessTrustedWithOptions(["AXTrustedCheckOptionPrompt": true] as CFDictionary) + } + retryHotkey() + do { + try await transcriber.load() + modelReady = true + modelStatus = "Klaar" + } catch { + modelStatus = "Model laden mislukt" + lastError = error.localizedDescription + } + } +} diff --git a/Sources/Sst/MenuContent.swift b/Sources/Sst/MenuContent.swift new file mode 100644 index 0000000..5d9b15f --- /dev/null +++ b/Sources/Sst/MenuContent.swift @@ -0,0 +1,34 @@ +import AppKit +import SwiftUI + +struct MenuContent: View { + @ObservedObject var model: AppModel + + var body: some View { + Text(model.statusLine) + if let error = model.lastError { + Text("Fout: \(error)") + } + Divider() + permissionRow("Microfoon", granted: model.micGranted, pane: "Privacy_Microphone") + permissionRow("Accessibility", granted: model.accessibilityGranted, pane: "Privacy_Accessibility") + permissionRow("Input Monitoring", granted: model.inputMonitoringGranted, pane: "Privacy_ListenEvent") + Button("Rechten opnieuw controleren") { model.retryHotkey() } + Divider() + Button("Stop") { NSApplication.shared.terminate(nil) } + .keyboardShortcut("q") + } + + @ViewBuilder + private func permissionRow(_ title: String, granted: Bool, pane: String) -> some View { + if granted { + Text("✓ \(title)") + } else { + Button("⚠︎ \(title) — open Instellingen") { + if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?\(pane)") { + NSWorkspace.shared.open(url) + } + } + } + } +} diff --git a/Sources/Sst/SstApp.swift b/Sources/Sst/SstApp.swift new file mode 100644 index 0000000..e4d86be --- /dev/null +++ b/Sources/Sst/SstApp.swift @@ -0,0 +1,14 @@ +import SwiftUI + +@main +struct SstApp: App { + @StateObject private var model = AppModel() + + var body: some Scene { + MenuBarExtra { + MenuContent(model: model) + } label: { + Image(systemName: model.iconName) + } + } +} diff --git a/Sources/SstCore/AudioRecorder.swift b/Sources/SstCore/AudioRecorder.swift index 83373ed..b537465 100644 --- a/Sources/SstCore/AudioRecorder.swift +++ b/Sources/SstCore/AudioRecorder.swift @@ -31,6 +31,7 @@ public final class AudioRecorder: AudioRecording { } let ratio = targetFormat.sampleRate / inputFormat.sampleRate + input.removeTap(onBus: 0) input.installTap(onBus: 0, bufferSize: 4096, format: inputFormat) { [weak self] pcm, _ in guard let self else { return } let capacity = AVAudioFrameCount(Double(pcm.frameLength) * ratio) + 1 @@ -52,7 +53,12 @@ public final class AudioRecorder: AudioRecording { self.lock.withLock { self.buffer.append(contentsOf: samples) } } engine.prepare() - try engine.start() + do { + try engine.start() + } catch { + input.removeTap(onBus: 0) + throw error + } } public func stop() -> [Float] { diff --git a/scripts/make-app.sh b/scripts/make-app.sh new file mode 100755 index 0000000..c7661a2 --- /dev/null +++ b/scripts/make-app.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "$0")/.." + +swift build -c release --product Sst +BIN=".build/release/Sst" +APP="build/Sst.app" + +rm -rf "$APP" +mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources" +cp "$BIN" "$APP/Contents/MacOS/Sst" +# SwiftPM resource bundles (if any dependency ships them) live next to the binary. +for b in .build/release/*.bundle; do + [ -e "$b" ] && cp -R "$b" "$APP/Contents/Resources/" +done + +cat > "$APP/Contents/Info.plist" <<'PLIST' + + + + + CFBundleIdentifierlocal.sst.app + CFBundleNameSst + CFBundleExecutableSst + CFBundlePackageTypeAPPL + CFBundleShortVersionString0.1.0 + CFBundleVersion1 + LSMinimumSystemVersion14.0 + LSUIElement + NSMicrophoneUsageDescription + Sst neemt je stem op zolang je Fn ingedrukt houdt, om die lokaal naar tekst om te zetten. + + +PLIST + +codesign --force --sign - --identifier local.sst.app "$APP" +echo "Gebouwd: $APP"