chore: vendor swabble and add speech usage strings

This commit is contained in:
Peter Steinberger
2025-12-06 02:10:20 +01:00
parent 4e7d905783
commit e1c9885566
34 changed files with 1577 additions and 1 deletions

View File

@@ -0,0 +1,77 @@
import Foundation
public struct SwabbleConfig: Codable, Sendable {
public struct Audio: Codable, Sendable {
public var deviceName: String = ""
public var deviceIndex: Int = -1
public var sampleRate: Double = 16000
public var channels: Int = 1
}
public struct Wake: Codable, Sendable {
public var enabled: Bool = true
public var word: String = "clawd"
public var aliases: [String] = ["claude"]
}
public struct Hook: Codable, Sendable {
public var command: String = ""
public var args: [String] = []
public var prefix: String = "Voice swabble from ${hostname}: "
public var cooldownSeconds: Double = 1
public var minCharacters: Int = 24
public var timeoutSeconds: Double = 5
public var env: [String: String] = [:]
}
public struct Logging: Codable, Sendable {
public var level: String = "info"
public var format: String = "text" // text|json placeholder
}
public struct Transcripts: Codable, Sendable {
public var enabled: Bool = true
public var maxEntries: Int = 50
}
public struct Speech: Codable, Sendable {
public var localeIdentifier: String = Locale.current.identifier
public var etiquetteReplacements: Bool = false
}
public var audio = Audio()
public var wake = Wake()
public var hook = Hook()
public var logging = Logging()
public var transcripts = Transcripts()
public var speech = Speech()
public static let defaultPath = FileManager.default
.homeDirectoryForCurrentUser
.appendingPathComponent(".config/swabble/config.json")
public init() {}
}
public enum ConfigError: Error {
case missingConfig
}
public enum ConfigLoader {
public static func load(at path: URL?) throws -> SwabbleConfig {
let url = path ?? SwabbleConfig.defaultPath
if !FileManager.default.fileExists(atPath: url.path) {
throw ConfigError.missingConfig
}
let data = try Data(contentsOf: url)
return try JSONDecoder().decode(SwabbleConfig.self, from: data)
}
public static func save(_ config: SwabbleConfig, at path: URL?) throws {
let url = path ?? SwabbleConfig.defaultPath
let dir = url.deletingLastPathComponent()
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
let data = try JSONEncoder().encode(config)
try data.write(to: url)
}
}

View File

@@ -0,0 +1,75 @@
import Foundation
public struct HookJob: Sendable {
public let text: String
public let timestamp: Date
public init(text: String, timestamp: Date) {
self.text = text
self.timestamp = timestamp
}
}
public actor HookRunner {
private let config: SwabbleConfig
private var lastRun: Date?
private let hostname: String
public init(config: SwabbleConfig) {
self.config = config
self.hostname = Host.current().localizedName ?? "host"
}
public func shouldRun() -> Bool {
guard self.config.hook.cooldownSeconds > 0 else { return true }
if let lastRun, Date().timeIntervalSince(lastRun) < config.hook.cooldownSeconds {
return false
}
return true
}
public func run(job: HookJob) async throws {
guard self.shouldRun() else { return }
guard !self.config.hook.command.isEmpty else { throw NSError(
domain: "Hook",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "hook command not set"]) }
let prefix = self.config.hook.prefix.replacingOccurrences(of: "${hostname}", with: self.hostname)
let payload = prefix + job.text
let process = Process()
process.executableURL = URL(fileURLWithPath: self.config.hook.command)
process.arguments = self.config.hook.args + [payload]
var env = ProcessInfo.processInfo.environment
env["SWABBLE_TEXT"] = job.text
env["SWABBLE_PREFIX"] = prefix
for (k, v) in self.config.hook.env {
env[k] = v
}
process.environment = env
let pipe = Pipe()
process.standardOutput = pipe
process.standardError = pipe
try process.run()
let timeoutNanos = UInt64(max(config.hook.timeoutSeconds, 0.1) * 1_000_000_000)
try await withThrowingTaskGroup(of: Void.self) { group in
group.addTask {
process.waitUntilExit()
}
group.addTask {
try await Task.sleep(nanoseconds: timeoutNanos)
if process.isRunning {
process.terminate()
}
}
try await group.next()
group.cancelAll()
}
self.lastRun = Date()
}
}

View File

@@ -0,0 +1,50 @@
@preconcurrency import AVFoundation
import Foundation
final class BufferConverter {
private final class Box<T>: @unchecked Sendable { var value: T; init(_ value: T) { self.value = value } }
enum ConverterError: Swift.Error {
case failedToCreateConverter
case failedToCreateConversionBuffer
case conversionFailed(NSError?)
}
private var converter: AVAudioConverter?
func convert(_ buffer: AVAudioPCMBuffer, to format: AVAudioFormat) throws -> AVAudioPCMBuffer {
let inputFormat = buffer.format
if inputFormat == format {
return buffer
}
if converter == nil || converter?.outputFormat != format {
converter = AVAudioConverter(from: inputFormat, to: format)
converter?.primeMethod = .none
}
guard let converter else { throw ConverterError.failedToCreateConverter }
let sampleRateRatio = converter.outputFormat.sampleRate / converter.inputFormat.sampleRate
let scaledInputFrameLength = Double(buffer.frameLength) * sampleRateRatio
let frameCapacity = AVAudioFrameCount(scaledInputFrameLength.rounded(.up))
guard let conversionBuffer = AVAudioPCMBuffer(pcmFormat: converter.outputFormat, frameCapacity: frameCapacity)
else {
throw ConverterError.failedToCreateConversionBuffer
}
var nsError: NSError?
let consumed = Box(false)
let inputBuffer = buffer
let status = converter.convert(to: conversionBuffer, error: &nsError) { _, statusPtr in
if consumed.value {
statusPtr.pointee = .noDataNow
return nil
}
consumed.value = true
statusPtr.pointee = .haveData
return inputBuffer
}
if status == .error {
throw ConverterError.conversionFailed(nsError)
}
return conversionBuffer
}
}

View File

@@ -0,0 +1,111 @@
import AVFoundation
import Foundation
import Speech
public struct SpeechSegment: Sendable {
public let text: String
public let isFinal: Bool
}
public enum SpeechPipelineError: Error {
case authorizationDenied
case analyzerFormatUnavailable
case transcriberUnavailable
}
/// Live microphone SpeechAnalyzer SpeechTranscriber pipeline.
public actor SpeechPipeline {
private struct UnsafeBuffer: @unchecked Sendable { let buffer: AVAudioPCMBuffer }
private var engine = AVAudioEngine()
private var transcriber: SpeechTranscriber?
private var analyzer: SpeechAnalyzer?
private var inputContinuation: AsyncStream<AnalyzerInput>.Continuation?
private var resultTask: Task<Void, Never>?
private let converter = BufferConverter()
public init() {}
public func start(localeIdentifier: String, etiquette: Bool) async throws -> AsyncStream<SpeechSegment> {
let auth = await requestAuthorizationIfNeeded()
guard auth == .authorized else { throw SpeechPipelineError.authorizationDenied }
let transcriberModule = SpeechTranscriber(
locale: Locale(identifier: localeIdentifier),
transcriptionOptions: etiquette ? [.etiquetteReplacements] : [],
reportingOptions: [.volatileResults],
attributeOptions: [])
self.transcriber = transcriberModule
guard let analyzerFormat = await SpeechAnalyzer.bestAvailableAudioFormat(compatibleWith: [transcriberModule])
else {
throw SpeechPipelineError.analyzerFormatUnavailable
}
self.analyzer = SpeechAnalyzer(modules: [transcriberModule])
let (stream, continuation) = AsyncStream<AnalyzerInput>.makeStream()
self.inputContinuation = continuation
let inputNode = self.engine.inputNode
let inputFormat = inputNode.outputFormat(forBus: 0)
inputNode.removeTap(onBus: 0)
inputNode.installTap(onBus: 0, bufferSize: 2048, format: inputFormat) { [weak self] buffer, _ in
guard let self else { return }
let boxed = UnsafeBuffer(buffer: buffer)
Task { await self.handleBuffer(boxed.buffer, targetFormat: analyzerFormat) }
}
self.engine.prepare()
try self.engine.start()
try await self.analyzer?.start(inputSequence: stream)
guard let transcriberForStream = self.transcriber else {
throw SpeechPipelineError.transcriberUnavailable
}
return AsyncStream { continuation in
self.resultTask = Task {
do {
for try await result in transcriberForStream.results {
let seg = SpeechSegment(text: String(result.text.characters), isFinal: result.isFinal)
continuation.yield(seg)
}
} catch {
// swallow errors and finish
}
continuation.finish()
}
continuation.onTermination = { _ in
Task { await self.stop() }
}
}
}
public func stop() async {
self.resultTask?.cancel()
self.inputContinuation?.finish()
self.engine.inputNode.removeTap(onBus: 0)
self.engine.stop()
try? await self.analyzer?.finalizeAndFinishThroughEndOfInput()
}
private func handleBuffer(_ buffer: AVAudioPCMBuffer, targetFormat: AVAudioFormat) async {
do {
let converted = try converter.convert(buffer, to: targetFormat)
let input = AnalyzerInput(buffer: converted)
self.inputContinuation?.yield(input)
} catch {
// drop on conversion failure
}
}
private func requestAuthorizationIfNeeded() async -> SFSpeechRecognizerAuthorizationStatus {
let current = SFSpeechRecognizer.authorizationStatus()
guard current == .notDetermined else { return current }
return await withCheckedContinuation { continuation in
SFSpeechRecognizer.requestAuthorization { status in
continuation.resume(returning: status)
}
}
}
}

View File

@@ -0,0 +1,63 @@
import CoreMedia
import Foundation
import NaturalLanguage
extension AttributedString {
public func sentences(maxLength: Int? = nil) -> [AttributedString] {
let tokenizer = NLTokenizer(unit: .sentence)
let string = String(characters)
tokenizer.string = string
let sentenceRanges = tokenizer.tokens(for: string.startIndex..<string.endIndex).map {
(
$0,
AttributedString.Index($0.lowerBound, within: self)!
..<
AttributedString.Index($0.upperBound, within: self)!)
}
let ranges = sentenceRanges.flatMap { sentenceStringRange, sentenceRange in
let sentence = self[sentenceRange]
guard let maxLength, sentence.characters.count > maxLength else {
return [sentenceRange]
}
let wordTokenizer = NLTokenizer(unit: .word)
wordTokenizer.string = string
var wordRanges = wordTokenizer.tokens(for: sentenceStringRange).map {
AttributedString.Index($0.lowerBound, within: self)!
..<
AttributedString.Index($0.upperBound, within: self)!
}
guard !wordRanges.isEmpty else { return [sentenceRange] }
wordRanges[0] = sentenceRange.lowerBound..<wordRanges[0].upperBound
wordRanges[wordRanges.count - 1] = wordRanges[wordRanges.count - 1].lowerBound..<sentenceRange.upperBound
var ranges: [Range<AttributedString.Index>] = []
for wordRange in wordRanges {
if let lastRange = ranges.last,
self[lastRange].characters.count + self[wordRange].characters.count <= maxLength
{
ranges[ranges.count - 1] = lastRange.lowerBound..<wordRange.upperBound
} else {
ranges.append(wordRange)
}
}
return ranges
}
return ranges.compactMap { range in
let audioTimeRanges = self[range].runs.filter {
!String(self[$0.range].characters)
.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}.compactMap(\.audioTimeRange)
guard !audioTimeRanges.isEmpty else { return nil }
let start = audioTimeRanges.first!.start
let end = audioTimeRanges.last!.end
var attributes = AttributeContainer()
attributes[AttributeScopes.SpeechAttributes.TimeRangeAttribute.self] = CMTimeRange(
start: start,
end: end)
return AttributedString(self[range].characters, attributes: attributes)
}
}
}

View File

@@ -0,0 +1,41 @@
import Foundation
public enum LogLevel: String, Comparable, CaseIterable, Sendable {
case trace, debug, info, warn, error
var rank: Int {
switch self {
case .trace: 0
case .debug: 1
case .info: 2
case .warn: 3
case .error: 4
}
}
public static func < (lhs: LogLevel, rhs: LogLevel) -> Bool { lhs.rank < rhs.rank }
}
public struct Logger: Sendable {
public let level: LogLevel
public init(level: LogLevel) { self.level = level }
public func log(_ level: LogLevel, _ message: String) {
guard level >= self.level else { return }
let ts = ISO8601DateFormatter().string(from: Date())
print("[\(level.rawValue.uppercased())] \(ts) | \(message)")
}
public func trace(_ msg: String) { self.log(.trace, msg) }
public func debug(_ msg: String) { self.log(.debug, msg) }
public func info(_ msg: String) { self.log(.info, msg) }
public func warn(_ msg: String) { self.log(.warn, msg) }
public func error(_ msg: String) { self.log(.error, msg) }
}
extension LogLevel {
public init?(configValue: String) {
self.init(rawValue: configValue.lowercased())
}
}

View File

@@ -0,0 +1,45 @@
import CoreMedia
import Foundation
public enum OutputFormat: String {
case txt
case srt
public var needsAudioTimeRange: Bool {
switch self {
case .srt: true
default: false
}
}
public func text(for transcript: AttributedString, maxLength: Int) -> String {
switch self {
case .txt:
return String(transcript.characters)
case .srt:
func format(_ timeInterval: TimeInterval) -> String {
let ms = Int(timeInterval.truncatingRemainder(dividingBy: 1) * 1000)
let s = Int(timeInterval) % 60
let m = (Int(timeInterval) / 60) % 60
let h = Int(timeInterval) / 60 / 60
return String(format: "%0.2d:%0.2d:%0.2d,%0.3d", h, m, s, ms)
}
return transcript.sentences(maxLength: maxLength).compactMap { (sentence: AttributedString) -> (
CMTimeRange,
String)? in
guard let timeRange = sentence.audioTimeRange else { return nil }
return (timeRange, String(sentence.characters))
}.enumerated().map { index, run in
let (timeRange, text) = run
return """
\(index + 1)
\(format(timeRange.start.seconds)) --> \(format(timeRange.end.seconds))
\(text.trimmingCharacters(in: .whitespacesAndNewlines))
"""
}.joined().trimmingCharacters(in: .whitespacesAndNewlines)
}
}
}

View File

@@ -0,0 +1,46 @@
import Foundation
public actor TranscriptsStore {
public static let shared = TranscriptsStore()
private var entries: [String] = []
private let limit = 100
private let fileURL: URL
public init() {
let dir = FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent("Library/Application Support/swabble", isDirectory: true)
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
self.fileURL = dir.appendingPathComponent("transcripts.log")
if let data = try? Data(contentsOf: fileURL),
let text = String(data: data, encoding: .utf8)
{
self.entries = text.split(separator: "\n").map(String.init).suffix(self.limit)
}
}
public func append(text: String) {
self.entries.append(text)
if self.entries.count > self.limit {
self.entries.removeFirst(self.entries.count - self.limit)
}
let body = self.entries.joined(separator: "\n")
try? body.write(to: self.fileURL, atomically: false, encoding: .utf8)
}
public func latest() -> [String] { self.entries }
}
extension String {
private func appendLine(to url: URL) throws {
let data = (self + "\n").data(using: .utf8) ?? Data()
if FileManager.default.fileExists(atPath: url.path) {
let handle = try FileHandle(forWritingTo: url)
try handle.seekToEnd()
try handle.write(contentsOf: data)
try handle.close()
} else {
try data.write(to: url)
}
}
}

View File

@@ -0,0 +1,70 @@
import Commander
import Foundation
@MainActor
enum CLIRegistry {
static var descriptors: [CommandDescriptor] {
let serveDesc = descriptor(for: ServeCommand.self)
let transcribeDesc = descriptor(for: TranscribeCommand.self)
let testHookDesc = descriptor(for: TestHookCommand.self)
let micList = descriptor(for: MicList.self)
let micSet = descriptor(for: MicSet.self)
let micRoot = CommandDescriptor(
name: "mic",
abstract: "Microphone management",
discussion: nil,
signature: CommandSignature(),
subcommands: [micList, micSet])
let serviceRoot = CommandDescriptor(
name: "service",
abstract: "launchd helper",
discussion: nil,
signature: CommandSignature(),
subcommands: [
descriptor(for: ServiceInstall.self),
descriptor(for: ServiceUninstall.self),
descriptor(for: ServiceStatus.self),
])
let doctorDesc = descriptor(for: DoctorCommand.self)
let setupDesc = descriptor(for: SetupCommand.self)
let healthDesc = descriptor(for: HealthCommand.self)
let tailLogDesc = descriptor(for: TailLogCommand.self)
let startDesc = descriptor(for: StartCommand.self)
let stopDesc = descriptor(for: StopCommand.self)
let restartDesc = descriptor(for: RestartCommand.self)
let statusDesc = descriptor(for: StatusCommand.self)
let rootSignature = CommandSignature().withStandardRuntimeFlags()
let root = CommandDescriptor(
name: "swabble",
abstract: "Speech hook daemon",
discussion: "Local wake-word → SpeechTranscriber → hook",
signature: rootSignature,
subcommands: [
serveDesc,
transcribeDesc,
testHookDesc,
micRoot,
serviceRoot,
doctorDesc,
setupDesc,
healthDesc,
tailLogDesc,
startDesc,
stopDesc,
restartDesc,
statusDesc,
])
return [root]
}
private static func descriptor(for type: any ParsableCommand.Type) -> CommandDescriptor {
let sig = CommandSignature.describe(type.init()).withStandardRuntimeFlags()
return CommandDescriptor(
name: type.commandDescription.commandName ?? "",
abstract: type.commandDescription.abstract,
discussion: type.commandDescription.discussion,
signature: sig,
subcommands: [])
}
}

View File

@@ -0,0 +1,37 @@
import Commander
import Foundation
import Speech
import Swabble
@MainActor
struct DoctorCommand: ParsableCommand {
static var commandDescription: CommandDescription {
CommandDescription(commandName: "doctor", abstract: "Check Speech permission and config")
}
@Option(name: .long("config"), help: "Path to config JSON") var configPath: String?
init() {}
init(parsed: ParsedValues) {
self.init()
if let cfg = parsed.options["config"]?.last { self.configPath = cfg }
}
mutating func run() async throws {
let auth = await SFSpeechRecognizer.authorizationStatus()
print("Speech auth: \(auth)")
do {
_ = try ConfigLoader.load(at: self.configURL)
print("Config: OK")
} catch {
print("Config missing or invalid; run setup")
}
let session = AVCaptureDevice.DiscoverySession(
deviceTypes: [.microphone, .external],
mediaType: .audio,
position: .unspecified)
print("Mics found: \(session.devices.count)")
}
private var configURL: URL? { self.configPath.map { URL(fileURLWithPath: $0) } }
}

View File

@@ -0,0 +1,16 @@
import Commander
import Foundation
@MainActor
struct HealthCommand: ParsableCommand {
static var commandDescription: CommandDescription {
CommandDescription(commandName: "health", abstract: "Health probe")
}
init() {}
init(parsed: ParsedValues) {}
mutating func run() async throws {
print("ok")
}
}

View File

@@ -0,0 +1,62 @@
import AVFoundation
import Commander
import Foundation
import Swabble
@MainActor
struct MicCommand: ParsableCommand {
static var commandDescription: CommandDescription {
CommandDescription(
commandName: "mic",
abstract: "Microphone management",
subcommands: [MicList.self, MicSet.self])
}
}
@MainActor
struct MicList: ParsableCommand {
static var commandDescription: CommandDescription {
CommandDescription(commandName: "list", abstract: "List input devices")
}
init() {}
init(parsed: ParsedValues) {}
mutating func run() async throws {
let session = AVCaptureDevice.DiscoverySession(
deviceTypes: [.microphone, .external],
mediaType: .audio,
position: .unspecified)
let devices = session.devices
if devices.isEmpty { print("no audio inputs found"); return }
for (idx, device) in devices.enumerated() {
print("[\(idx)] \(device.localizedName)")
}
}
}
@MainActor
struct MicSet: ParsableCommand {
@Argument(help: "Device index from list") var index: Int = 0
@Option(name: .long("config"), help: "Path to config JSON") var configPath: String?
static var commandDescription: CommandDescription {
CommandDescription(commandName: "set", abstract: "Set default input device index")
}
init() {}
init(parsed: ParsedValues) {
self.init()
if let value = parsed.positional.first, let intVal = Int(value) { self.index = intVal }
if let cfg = parsed.options["config"]?.last { self.configPath = cfg }
}
mutating func run() async throws {
var cfg = try ConfigLoader.load(at: self.configURL)
cfg.audio.deviceIndex = self.index
try ConfigLoader.save(cfg, at: self.configURL)
print("saved device index \(self.index)")
}
private var configURL: URL? { self.configPath.map { URL(fileURLWithPath: $0) } }
}

View File

@@ -0,0 +1,84 @@
import Commander
import Foundation
import Swabble
@MainActor
struct ServeCommand: ParsableCommand {
@Option(name: .long("config"), help: "Path to config JSON") var configPath: String?
@Flag(name: .long("no-wake"), help: "Disable wake word") var noWake: Bool = false
static var commandDescription: CommandDescription {
CommandDescription(
commandName: "serve",
abstract: "Run swabble in the foreground")
}
init() {}
init(parsed: ParsedValues) {
self.init()
if parsed.flags.contains("noWake") { self.noWake = true }
if let cfg = parsed.options["config"]?.last { self.configPath = cfg }
}
mutating func run() async throws {
var cfg: SwabbleConfig
do {
cfg = try ConfigLoader.load(at: self.configURL)
} catch {
cfg = SwabbleConfig()
try ConfigLoader.save(cfg, at: self.configURL)
}
if self.noWake {
cfg.wake.enabled = false
}
let logger = Logger(level: LogLevel(configValue: cfg.logging.level) ?? .info)
logger.info("swabble serve starting (wake: \(cfg.wake.enabled ? cfg.wake.word : "disabled"))")
let pipeline = SpeechPipeline()
do {
let stream = try await pipeline.start(
localeIdentifier: cfg.speech.localeIdentifier,
etiquette: cfg.speech.etiquetteReplacements)
for await seg in stream {
if cfg.wake.enabled {
guard Self.matchesWake(text: seg.text, cfg: cfg) else { continue }
}
let stripped = Self.stripWake(text: seg.text, cfg: cfg)
let job = HookJob(text: stripped, timestamp: Date())
let runner = HookRunner(config: cfg)
try await runner.run(job: job)
if cfg.transcripts.enabled {
await TranscriptsStore.shared.append(text: stripped)
}
if seg.isFinal {
logger.info("final: \(stripped)")
} else {
logger.debug("partial: \(stripped)")
}
}
} catch {
logger.error("serve error: \(error)")
throw error
}
}
private var configURL: URL? {
self.configPath.map { URL(fileURLWithPath: $0) }
}
private static func matchesWake(text: String, cfg: SwabbleConfig) -> Bool {
let lowered = text.lowercased()
if lowered.contains(cfg.wake.word.lowercased()) { return true }
return cfg.wake.aliases.contains(where: { lowered.contains($0.lowercased()) })
}
private static func stripWake(text: String, cfg: SwabbleConfig) -> String {
var out = text
out = out.replacingOccurrences(of: cfg.wake.word, with: "", options: [.caseInsensitive])
for alias in cfg.wake.aliases {
out = out.replacingOccurrences(of: alias, with: "", options: [.caseInsensitive])
}
return out.trimmingCharacters(in: .whitespacesAndNewlines)
}
}

View File

@@ -0,0 +1,77 @@
import Commander
import Foundation
@MainActor
struct ServiceRootCommand: ParsableCommand {
static var commandDescription: CommandDescription {
CommandDescription(
commandName: "service",
abstract: "Manage launchd agent",
subcommands: [ServiceInstall.self, ServiceUninstall.self, ServiceStatus.self])
}
}
private enum LaunchdHelper {
static let label = "com.swabble.agent"
static var plistURL: URL {
FileManager.default
.homeDirectoryForCurrentUser
.appendingPathComponent("Library/LaunchAgents/\(label).plist")
}
static func writePlist(executable: String) throws {
let plist: [String: Any] = [
"Label": label,
"ProgramArguments": [executable, "serve"],
"RunAtLoad": true,
"KeepAlive": true,
]
let data = try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0)
try data.write(to: self.plistURL)
}
static func removePlist() throws {
try? FileManager.default.removeItem(at: self.plistURL)
}
}
@MainActor
struct ServiceInstall: ParsableCommand {
static var commandDescription: CommandDescription {
CommandDescription(commandName: "install", abstract: "Install user launch agent")
}
mutating func run() async throws {
let exe = CommandLine.arguments.first ?? "/usr/local/bin/swabble"
try LaunchdHelper.writePlist(executable: exe)
print("launchctl load -w \(LaunchdHelper.plistURL.path)")
}
}
@MainActor
struct ServiceUninstall: ParsableCommand {
static var commandDescription: CommandDescription {
CommandDescription(commandName: "uninstall", abstract: "Remove launch agent")
}
mutating func run() async throws {
try LaunchdHelper.removePlist()
print("launchctl bootout gui/$(id -u)/\(LaunchdHelper.label)")
}
}
@MainActor
struct ServiceStatus: ParsableCommand {
static var commandDescription: CommandDescription {
CommandDescription(commandName: "status", abstract: "Show launch agent status")
}
mutating func run() async throws {
if FileManager.default.fileExists(atPath: LaunchdHelper.plistURL.path) {
print("plist present at \(LaunchdHelper.plistURL.path)")
} else {
print("launchd plist not installed")
}
}
}

View File

@@ -0,0 +1,26 @@
import Commander
import Foundation
import Swabble
@MainActor
struct SetupCommand: ParsableCommand {
static var commandDescription: CommandDescription {
CommandDescription(commandName: "setup", abstract: "Write default config")
}
@Option(name: .long("config"), help: "Path to config JSON") var configPath: String?
init() {}
init(parsed: ParsedValues) {
self.init()
if let cfg = parsed.options["config"]?.last { self.configPath = cfg }
}
mutating func run() async throws {
let cfg = SwabbleConfig()
try ConfigLoader.save(cfg, at: self.configURL)
print("wrote config to \(self.configURL?.path ?? SwabbleConfig.defaultPath.path)")
}
private var configURL: URL? { self.configPath.map { URL(fileURLWithPath: $0) } }
}

View File

@@ -0,0 +1,35 @@
import Commander
import Foundation
@MainActor
struct StartCommand: ParsableCommand {
static var commandDescription: CommandDescription {
CommandDescription(commandName: "start", abstract: "Start swabble (foreground placeholder)")
}
mutating func run() async throws {
print("start: launchd helper not implemented; run 'swabble serve' instead")
}
}
@MainActor
struct StopCommand: ParsableCommand {
static var commandDescription: CommandDescription {
CommandDescription(commandName: "stop", abstract: "Stop swabble (placeholder)")
}
mutating func run() async throws {
print("stop: launchd helper not implemented yet")
}
}
@MainActor
struct RestartCommand: ParsableCommand {
static var commandDescription: CommandDescription {
CommandDescription(commandName: "restart", abstract: "Restart swabble (placeholder)")
}
mutating func run() async throws {
print("restart: launchd helper not implemented yet")
}
}

View File

@@ -0,0 +1,34 @@
import Commander
import Foundation
import Swabble
@MainActor
struct StatusCommand: ParsableCommand {
static var commandDescription: CommandDescription {
CommandDescription(commandName: "status", abstract: "Show daemon state")
}
@Option(name: .long("config"), help: "Path to config JSON") var configPath: String?
init() {}
init(parsed: ParsedValues) {
self.init()
if let cfg = parsed.options["config"]?.last { self.configPath = cfg }
}
mutating func run() async throws {
let cfg = try? ConfigLoader.load(at: self.configURL)
let wake = cfg?.wake.word ?? "clawd"
let wakeEnabled = cfg?.wake.enabled ?? false
let latest = await TranscriptsStore.shared.latest().suffix(3)
print("wake: \(wakeEnabled ? wake : "disabled")")
if latest.isEmpty {
print("transcripts: (none yet)")
} else {
print("last transcripts:")
latest.forEach { print("- \($0)") }
}
}
private var configURL: URL? { self.configPath.map { URL(fileURLWithPath: $0) } }
}

View File

@@ -0,0 +1,20 @@
import Commander
import Foundation
import Swabble
@MainActor
struct TailLogCommand: ParsableCommand {
static var commandDescription: CommandDescription {
CommandDescription(commandName: "tail-log", abstract: "Tail recent transcripts")
}
init() {}
init(parsed: ParsedValues) {}
mutating func run() async throws {
let latest = await TranscriptsStore.shared.latest()
for line in latest.suffix(10) {
print(line)
}
}
}

View File

@@ -0,0 +1,30 @@
import Commander
import Foundation
import Swabble
@MainActor
struct TestHookCommand: ParsableCommand {
@Argument(help: "Text to send to hook") var text: String
@Option(name: .long("config"), help: "Path to config JSON") var configPath: String?
static var commandDescription: CommandDescription {
CommandDescription(commandName: "test-hook", abstract: "Invoke the configured hook with text")
}
init() {}
init(parsed: ParsedValues) {
self.init()
if let positional = parsed.positional.first { self.text = positional }
if let cfg = parsed.options["config"]?.last { self.configPath = cfg }
}
mutating func run() async throws {
let cfg = try ConfigLoader.load(at: self.configURL)
let runner = HookRunner(config: cfg)
try await runner.run(job: HookJob(text: self.text, timestamp: Date()))
print("hook invoked")
}
private var configURL: URL? { self.configPath.map { URL(fileURLWithPath: $0) } }
}

View File

@@ -0,0 +1,61 @@
import AVFoundation
import Commander
import Foundation
import Speech
import Swabble
@MainActor
struct TranscribeCommand: ParsableCommand {
@Argument(help: "Path to audio/video file") var inputFile: String = ""
@Option(name: .long("locale"), help: "Locale identifier", parsing: .singleValue) var locale: String = Locale.current
.identifier
@Flag(help: "Censor etiquette-sensitive content") var censor: Bool = false
@Option(name: .long("output"), help: "Output file path") var outputFile: String?
@Option(name: .long("format"), help: "Output format txt|srt") var format: String = "txt"
@Option(name: .long("max-length"), help: "Max sentence length for srt") var maxLength: Int = 40
static var commandDescription: CommandDescription {
CommandDescription(
commandName: "transcribe",
abstract: "Transcribe a media file locally")
}
init() {}
init(parsed: ParsedValues) {
self.init()
if let positional = parsed.positional.first { self.inputFile = positional }
if let loc = parsed.options["locale"]?.last { self.locale = loc }
if parsed.flags.contains("censor") { self.censor = true }
if let out = parsed.options["output"]?.last { self.outputFile = out }
if let fmt = parsed.options["format"]?.last { self.format = fmt }
if let len = parsed.options["maxLength"]?.last, let intVal = Int(len) { self.maxLength = intVal }
}
mutating func run() async throws {
let fileURL = URL(fileURLWithPath: inputFile)
let audioFile = try AVAudioFile(forReading: fileURL)
let outputFormat = OutputFormat(rawValue: format) ?? .txt
let transcriber = SpeechTranscriber(
locale: Locale(identifier: locale),
transcriptionOptions: censor ? [.etiquetteReplacements] : [],
reportingOptions: [],
attributeOptions: outputFormat.needsAudioTimeRange ? [.audioTimeRange] : [])
let analyzer = SpeechAnalyzer(modules: [transcriber])
try await analyzer.start(inputAudioFile: audioFile, finishAfterFile: true)
var transcript: AttributedString = ""
for try await result in transcriber.results {
transcript += result.text
}
let output = outputFormat.text(for: transcript, maxLength: self.maxLength)
if let path = outputFile {
try output.write(to: URL(fileURLWithPath: path), atomically: false, encoding: .utf8)
} else {
print(output)
}
}
}

View File

@@ -0,0 +1,99 @@
import Commander
import Foundation
@MainActor
private func runCLI() async -> Int32 {
do {
let descriptors = CLIRegistry.descriptors
let program = Program(descriptors: descriptors)
let invocation = try program.resolve(argv: CommandLine.arguments)
try await dispatch(invocation: invocation)
return 0
} catch {
fputs("error: \(error)\n", stderr)
return 1
}
}
@MainActor
private func dispatch(invocation: CommandInvocation) async throws {
let parsed = invocation.parsedValues
let path = invocation.path
guard let first = path.first else { throw CommanderProgramError.missingCommand }
switch first {
case "swabble":
guard path.count >= 2 else { throw CommanderProgramError.missingSubcommand(command: "swabble") }
let sub = path[1]
switch sub {
case "serve":
var cmd = ServeCommand(parsed: parsed)
try await cmd.run()
case "transcribe":
var cmd = TranscribeCommand(parsed: parsed)
try await cmd.run()
case "test-hook":
var cmd = TestHookCommand(parsed: parsed)
try await cmd.run()
case "mic":
guard path.count >= 3 else { throw CommanderProgramError.missingSubcommand(command: "mic") }
let micSub = path[2]
if micSub == "list" {
var cmd = MicList(parsed: parsed)
try await cmd.run()
} else if micSub == "set" {
var cmd = MicSet(parsed: parsed)
try await cmd.run()
} else {
throw CommanderProgramError.unknownSubcommand(command: "mic", name: micSub)
}
case "service":
guard path.count >= 3 else { throw CommanderProgramError.missingSubcommand(command: "service") }
let svcSub = path[2]
switch svcSub {
case "install":
var cmd = ServiceInstall()
try await cmd.run()
case "uninstall":
var cmd = ServiceUninstall()
try await cmd.run()
case "status":
var cmd = ServiceStatus()
try await cmd.run()
default:
throw CommanderProgramError.unknownSubcommand(command: "service", name: svcSub)
}
case "doctor":
var cmd = DoctorCommand(parsed: parsed)
try await cmd.run()
case "setup":
var cmd = SetupCommand(parsed: parsed)
try await cmd.run()
case "health":
var cmd = HealthCommand(parsed: parsed)
try await cmd.run()
case "tail-log":
var cmd = TailLogCommand(parsed: parsed)
try await cmd.run()
case "start":
var cmd = StartCommand()
try await cmd.run()
case "stop":
var cmd = StopCommand()
try await cmd.run()
case "restart":
var cmd = RestartCommand()
try await cmd.run()
case "status":
var cmd = StatusCommand()
try await cmd.run()
default:
throw CommanderProgramError.unknownSubcommand(command: "swabble", name: sub)
}
default:
throw CommanderProgramError.unknownCommand(first)
}
}
let exitCode = await runCLI()
exit(exitCode)