Initial commit of Edison Voice menu-bar dictation app
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
import OSLog
|
||||
|
||||
public enum Log {
|
||||
public static let audio = Logger(subsystem: "ai.pivotstudio.edison-voice", category: "audio")
|
||||
public static let speech = Logger(subsystem: "ai.pivotstudio.edison-voice", category: "speech")
|
||||
public static let hotkey = Logger(subsystem: "ai.pivotstudio.edison-voice", category: "hotkey")
|
||||
public static let inject = Logger(subsystem: "ai.pivotstudio.edison-voice", category: "inject")
|
||||
public static let app = Logger(subsystem: "ai.pivotstudio.edison-voice", category: "app")
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import AVFoundation
|
||||
import AppKit
|
||||
import ApplicationServices
|
||||
import Foundation
|
||||
|
||||
/// Edison Voice needs two grants, and neither can be worked around:
|
||||
/// - **Microphone** — obviously.
|
||||
/// - **Accessibility** — for both the `CGEventTap` (hotkey) and the AX text insert.
|
||||
///
|
||||
/// Accessibility has no programmatic request; the OS only shows the prompt, and the user
|
||||
/// must toggle it in System Settings. TCC also keys on the code signature, so re-signing
|
||||
/// the app resets the grant.
|
||||
@MainActor
|
||||
public enum Permissions {
|
||||
public static var hasAccessibility: Bool {
|
||||
AXIsProcessTrusted()
|
||||
}
|
||||
|
||||
public static var hasMicrophone: Bool {
|
||||
AVCaptureDevice.authorizationStatus(for: .audio) == .authorized
|
||||
}
|
||||
|
||||
/// Shows the system Accessibility prompt if the app isn't yet trusted.
|
||||
@discardableResult
|
||||
public static func promptForAccessibility() -> Bool {
|
||||
// Spelled out rather than using `kAXTrustedCheckOptionPrompt`, which imports as a
|
||||
// mutable global and so isn't usable from concurrency-checked code.
|
||||
let options = ["AXTrustedCheckOptionPrompt": true] as CFDictionary
|
||||
return AXIsProcessTrustedWithOptions(options)
|
||||
}
|
||||
|
||||
public static func requestMicrophone() async -> Bool {
|
||||
switch AVCaptureDevice.authorizationStatus(for: .audio) {
|
||||
case .authorized:
|
||||
return true
|
||||
case .notDetermined:
|
||||
return await AVCaptureDevice.requestAccess(for: .audio)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
public static func openAccessibilitySettings() {
|
||||
let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility")!
|
||||
NSWorkspace.shared.open(url)
|
||||
}
|
||||
|
||||
public static func openMicrophoneSettings() {
|
||||
let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone")!
|
||||
NSWorkspace.shared.open(url)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
/// Which speech engine transcribes an utterance.
|
||||
public enum SpeechEngineChoice: String, CaseIterable, Sendable {
|
||||
case apple
|
||||
case parakeet
|
||||
|
||||
public var displayName: String {
|
||||
switch self {
|
||||
case .apple: "Apple (streaming)"
|
||||
case .parakeet: "Parakeet (batch)"
|
||||
}
|
||||
}
|
||||
|
||||
/// Apple shows text while you talk; Parakeet only resolves on release.
|
||||
public var showsLiveText: Bool { self == .apple }
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
public final class Settings {
|
||||
public static let shared = Settings()
|
||||
|
||||
public var pushToTalkKey: PushToTalkKey {
|
||||
didSet { defaults.set(pushToTalkKey.rawValue, forKey: Keys.pushToTalkKey) }
|
||||
}
|
||||
|
||||
public var engine: SpeechEngineChoice {
|
||||
didSet { defaults.set(engine.rawValue, forKey: Keys.engine) }
|
||||
}
|
||||
|
||||
/// Run the cleanup pass before injecting. Off = raw engine output.
|
||||
public var cleanupEnabled: Bool {
|
||||
didSet { defaults.set(cleanupEnabled, forKey: Keys.cleanupEnabled) }
|
||||
}
|
||||
|
||||
/// Use the on-device LLM for cleanup instead of the deterministic rule pass.
|
||||
public var smartCleanup: Bool {
|
||||
didSet { defaults.set(smartCleanup, forKey: Keys.smartCleanup) }
|
||||
}
|
||||
|
||||
/// Play a short tick when capture starts and stops.
|
||||
public var soundEnabled: Bool {
|
||||
didSet { defaults.set(soundEnabled, forKey: Keys.soundEnabled) }
|
||||
}
|
||||
|
||||
private let defaults = UserDefaults.standard
|
||||
|
||||
private enum Keys {
|
||||
static let pushToTalkKey = "pushToTalkKey"
|
||||
static let cleanupEnabled = "cleanupEnabled"
|
||||
static let soundEnabled = "soundEnabled"
|
||||
static let engine = "engine"
|
||||
static let smartCleanup = "smartCleanup"
|
||||
}
|
||||
|
||||
private init() {
|
||||
let raw = defaults.string(forKey: Keys.pushToTalkKey) ?? PushToTalkKey.rightOption.rawValue
|
||||
pushToTalkKey = PushToTalkKey(rawValue: raw) ?? .rightOption
|
||||
// Apple by default: no download, no dependency, live text while speaking.
|
||||
engine = SpeechEngineChoice(rawValue: defaults.string(forKey: Keys.engine) ?? "") ?? .apple
|
||||
cleanupEnabled = defaults.object(forKey: Keys.cleanupEnabled) as? Bool ?? true
|
||||
smartCleanup = defaults.object(forKey: Keys.smartCleanup) as? Bool ?? false
|
||||
soundEnabled = defaults.object(forKey: Keys.soundEnabled) as? Bool ?? true
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user