diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..aff600f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,54 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + build-and-test: + runs-on: macos-latest + defaults: + run: + shell: bash + working-directory: swabble + steps: + - name: Checkout swabble + uses: actions/checkout@v4 + with: + path: swabble + + - name: Select Xcode 26.1 (prefer 26.1.1) + run: | + set -euo pipefail + # pick the newest installed 26.1.x, fallback to newest 26.x + CANDIDATE="$(ls -d /Applications/Xcode_26.1*.app 2>/dev/null | sort -V | tail -1 || true)" + if [[ -z "$CANDIDATE" ]]; then + CANDIDATE="$(ls -d /Applications/Xcode_26*.app 2>/dev/null | sort -V | tail -1 || true)" + fi + if [[ -z "$CANDIDATE" ]]; then + echo "No Xcode 26.x found on runner" >&2 + exit 1 + fi + echo "Selecting $CANDIDATE" + sudo xcode-select -s "$CANDIDATE" + xcodebuild -version + + - name: Show Swift version + run: swift --version + + - name: Install tooling + run: | + brew update + brew install swiftlint swiftformat + + - name: Format check + run: | + ./scripts/format.sh + git diff --exit-code + + - name: Lint + run: ./scripts/lint.sh + + - name: Test + run: swift test --parallel diff --git a/Package.resolved b/Package.resolved index 80f7240..a7d2a70 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,6 +1,15 @@ { - "originHash" : "b658624653b4e0bfcd81b3b9820842c95489a766037392e1cedb2d677c61fb3f", + "originHash" : "3018b2c8c183d55b57ad0c4526b2380ac3b957d13a3a86e1b2845e81323c443a", "pins" : [ + { + "identity" : "commander", + "kind" : "remoteSourceControl", + "location" : "https://github.com/steipete/Commander.git", + "state" : { + "revision" : "8b8cb4f34315ce9e5307b3a2bcd77ff73f586a02", + "version" : "0.2.0" + } + }, { "identity" : "swift-syntax", "kind" : "remoteSourceControl", diff --git a/Package.swift b/Package.swift index d30786e..588bd01 100644 --- a/Package.swift +++ b/Package.swift @@ -6,21 +6,30 @@ let package = Package( platforms: [ .macOS(.v26), ], + products: [ + .library(name: "Swabble", targets: ["Swabble"]), + .executable(name: "swabble", targets: ["SwabbleCLI"]), + ], dependencies: [ - .package(path: "../Peekaboo/Commander"), + .package(url: "https://github.com/steipete/Commander.git", from: "0.2.0"), .package(url: "https://github.com/apple/swift-testing", from: "0.99.0"), ], targets: [ + .target( + name: "Swabble", + path: "Sources/SwabbleCore", + swiftSettings: []), .executableTarget( - name: "swabble", + name: "SwabbleCLI", dependencies: [ + "Swabble", .product(name: "Commander", package: "Commander"), ], - path: "Sources"), + path: "Sources/swabble"), .testTarget( name: "swabbleTests", dependencies: [ - "swabble", + "Swabble", .product(name: "Testing", package: "swift-testing"), ]), ], diff --git a/README.md b/README.md index 29a3668..8c8169c 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,19 @@ swift run swabble test-hook "hello world" swift run swabble transcribe /path/to/audio.m4a --format srt --output out.srt ``` +## Use as a library +Add swabble as a SwiftPM dependency and import the `Swabble` product to reuse the Speech pipeline, config loader, hook runner, and transcript store in your own app: + +```swift +// Package.swift +dependencies: [ + .package(url: "https://github.com/steipete/swabble.git", branch: "main"), +], +targets: [ + .target(name: "MyApp", dependencies: [.product(name: "Swabble", package: "swabble")]), +] +``` + ## CLI - `serve` — foreground loop (mic → wake → hook) - `transcribe ` — offline transcription (txt|srt) diff --git a/Sources/SwabbleCore/Config/Config.swift b/Sources/SwabbleCore/Config/Config.swift new file mode 100644 index 0000000..4dc9d46 --- /dev/null +++ b/Sources/SwabbleCore/Config/Config.swift @@ -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) + } +} diff --git a/Sources/swabble/Hooks/HookRunner.swift b/Sources/SwabbleCore/Hooks/HookRunner.swift similarity index 55% rename from Sources/swabble/Hooks/HookRunner.swift rename to Sources/SwabbleCore/Hooks/HookRunner.swift index b227a19..e94b0b0 100644 --- a/Sources/swabble/Hooks/HookRunner.swift +++ b/Sources/SwabbleCore/Hooks/HookRunner.swift @@ -1,43 +1,53 @@ import Foundation -struct HookJob: Sendable { - let text: String - let timestamp: Date +public struct HookJob: Sendable { + public let text: String + public let timestamp: Date + + public init(text: String, timestamp: Date) { + self.text = text + self.timestamp = timestamp + } } -actor HookRunner { +public actor HookRunner { private let config: SwabbleConfig private var lastRun: Date? private let hostname: String - init(config: SwabbleConfig) { + public init(config: SwabbleConfig) { self.config = config self.hostname = Host.current().localizedName ?? "host" } - func shouldRun() -> Bool { - guard config.hook.cooldownSeconds > 0 else { return true } + 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 } - func run(job: HookJob) async throws { - guard shouldRun() else { return } - guard !config.hook.command.isEmpty else { throw NSError(domain: "Hook", code: 1, userInfo: [NSLocalizedDescriptionKey: "hook command not set"]) } + 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 = config.hook.prefix.replacingOccurrences(of: "${hostname}", with: hostname) + let prefix = self.config.hook.prefix.replacingOccurrences(of: "${hostname}", with: self.hostname) let payload = prefix + job.text let process = Process() - process.executableURL = URL(fileURLWithPath: config.hook.command) - process.arguments = config.hook.args + [payload] + 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 config.hook.env { env[k] = v } + for (k, v) in self.config.hook.env { + env[k] = v + } process.environment = env let pipe = Pipe() @@ -60,6 +70,6 @@ actor HookRunner { try await group.next() group.cancelAll() } - lastRun = Date() + self.lastRun = Date() } } diff --git a/Sources/swabble/Speech/BufferConverter.swift b/Sources/SwabbleCore/Speech/BufferConverter.swift similarity index 96% rename from Sources/swabble/Speech/BufferConverter.swift rename to Sources/SwabbleCore/Speech/BufferConverter.swift index 15adc50..e6d7dc9 100644 --- a/Sources/swabble/Speech/BufferConverter.swift +++ b/Sources/SwabbleCore/Speech/BufferConverter.swift @@ -25,7 +25,8 @@ final class BufferConverter { 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 { + guard let conversionBuffer = AVAudioPCMBuffer(pcmFormat: converter.outputFormat, frameCapacity: frameCapacity) + else { throw ConverterError.failedToCreateConversionBuffer } diff --git a/Sources/swabble/Speech/SpeechPipeline.swift b/Sources/SwabbleCore/Speech/SpeechPipeline.swift similarity index 76% rename from Sources/swabble/Speech/SpeechPipeline.swift rename to Sources/SwabbleCore/Speech/SpeechPipeline.swift index 9bec525..faecc64 100644 --- a/Sources/swabble/Speech/SpeechPipeline.swift +++ b/Sources/SwabbleCore/Speech/SpeechPipeline.swift @@ -2,19 +2,19 @@ import AVFoundation import Foundation import Speech -struct SpeechSegment: Sendable { - let text: String - let isFinal: Bool +public struct SpeechSegment: Sendable { + public let text: String + public let isFinal: Bool } -enum SpeechPipelineError: Error { +public enum SpeechPipelineError: Error { case authorizationDenied case analyzerFormatUnavailable case transcriberUnavailable } /// Live microphone → SpeechAnalyzer → SpeechTranscriber pipeline. -actor SpeechPipeline { +public actor SpeechPipeline { private struct UnsafeBuffer: @unchecked Sendable { let buffer: AVAudioPCMBuffer } private var engine = AVAudioEngine() @@ -24,7 +24,9 @@ actor SpeechPipeline { private var resultTask: Task? private let converter = BufferConverter() - func start(localeIdentifier: String, etiquette: Bool) async throws -> AsyncStream { + public init() {} + + public func start(localeIdentifier: String, etiquette: Bool) async throws -> AsyncStream { let auth = await requestAuthorizationIfNeeded() guard auth == .authorized else { throw SpeechPipelineError.authorizationDenied } @@ -35,15 +37,16 @@ actor SpeechPipeline { attributeOptions: []) self.transcriber = transcriberModule - guard let analyzerFormat = await SpeechAnalyzer.bestAvailableAudioFormat(compatibleWith: [transcriberModule]) else { + guard let analyzerFormat = await SpeechAnalyzer.bestAvailableAudioFormat(compatibleWith: [transcriberModule]) + else { throw SpeechPipelineError.analyzerFormatUnavailable } - analyzer = SpeechAnalyzer(modules: [transcriberModule]) + self.analyzer = SpeechAnalyzer(modules: [transcriberModule]) let (stream, continuation) = AsyncStream.makeStream() - inputContinuation = continuation + self.inputContinuation = continuation - let inputNode = engine.inputNode + 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 @@ -52,16 +55,16 @@ actor SpeechPipeline { Task { await self.handleBuffer(boxed.buffer, targetFormat: analyzerFormat) } } - engine.prepare() - try engine.start() - try await analyzer?.start(inputSequence: stream) + 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 - resultTask = Task { + self.resultTask = Task { do { for try await result in transcriberForStream.results { let seg = SpeechSegment(text: String(result.text.characters), isFinal: result.isFinal) @@ -78,19 +81,19 @@ actor SpeechPipeline { } } - func stop() async { - resultTask?.cancel() - inputContinuation?.finish() - engine.inputNode.removeTap(onBus: 0) - engine.stop() - try? await analyzer?.finalizeAndFinishThroughEndOfInput() + 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) - inputContinuation?.yield(input) + self.inputContinuation?.yield(input) } catch { // drop on conversion failure } diff --git a/Sources/swabble/Support/AttributedString+Sentences.swift b/Sources/SwabbleCore/Support/AttributedString+Sentences.swift similarity index 95% rename from Sources/swabble/Support/AttributedString+Sentences.swift rename to Sources/SwabbleCore/Support/AttributedString+Sentences.swift index e5e434b..8f5d6a1 100644 --- a/Sources/swabble/Support/AttributedString+Sentences.swift +++ b/Sources/SwabbleCore/Support/AttributedString+Sentences.swift @@ -3,7 +3,7 @@ import Foundation import NaturalLanguage extension AttributedString { - func sentences(maxLength: Int? = nil) -> [AttributedString] { + public func sentences(maxLength: Int? = nil) -> [AttributedString] { let tokenizer = NLTokenizer(unit: .sentence) let string = String(characters) tokenizer.string = string @@ -12,8 +12,7 @@ extension AttributedString { $0, AttributedString.Index($0.lowerBound, within: self)! ..< - AttributedString.Index($0.upperBound, within: self)! - ) + AttributedString.Index($0.upperBound, within: self)!) } let ranges = sentenceRanges.flatMap { sentenceStringRange, sentenceRange in let sentence = self[sentenceRange] @@ -57,8 +56,7 @@ extension AttributedString { var attributes = AttributeContainer() attributes[AttributeScopes.SpeechAttributes.TimeRangeAttribute.self] = CMTimeRange( start: start, - end: end - ) + end: end) return AttributedString(self[range].characters, attributes: attributes) } } diff --git a/Sources/SwabbleCore/Support/Logging.swift b/Sources/SwabbleCore/Support/Logging.swift new file mode 100644 index 0000000..695567a --- /dev/null +++ b/Sources/SwabbleCore/Support/Logging.swift @@ -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()) + } +} diff --git a/Sources/swabble/Support/OutputFormat.swift b/Sources/SwabbleCore/Support/OutputFormat.swift similarity index 82% rename from Sources/swabble/Support/OutputFormat.swift rename to Sources/SwabbleCore/Support/OutputFormat.swift index 44ef691..84047c7 100644 --- a/Sources/swabble/Support/OutputFormat.swift +++ b/Sources/SwabbleCore/Support/OutputFormat.swift @@ -1,18 +1,18 @@ import CoreMedia import Foundation -enum OutputFormat: String { +public enum OutputFormat: String { case txt case srt - var needsAudioTimeRange: Bool { + public var needsAudioTimeRange: Bool { switch self { case .srt: true default: false } } - func text(for transcript: AttributedString, maxLength: Int) -> String { + public func text(for transcript: AttributedString, maxLength: Int) -> String { switch self { case .txt: return String(transcript.characters) @@ -25,7 +25,9 @@ enum OutputFormat: String { 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 + 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 diff --git a/Sources/swabble/Support/TranscriptsStore.swift b/Sources/SwabbleCore/Support/TranscriptsStore.swift similarity index 55% rename from Sources/swabble/Support/TranscriptsStore.swift rename to Sources/SwabbleCore/Support/TranscriptsStore.swift index 2c0fe93..a62eb5b 100644 --- a/Sources/swabble/Support/TranscriptsStore.swift +++ b/Sources/SwabbleCore/Support/TranscriptsStore.swift @@ -1,40 +1,38 @@ import Foundation -actor TranscriptsStore { - static let shared = TranscriptsStore() +public actor TranscriptsStore { + public static let shared = TranscriptsStore() private var entries: [String] = [] private let limit = 100 private let fileURL: URL - init() { + public init() { let dir = FileManager.default.homeDirectoryForCurrentUser .appendingPathComponent("Library/Application Support/swabble", isDirectory: true) try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) - fileURL = dir.appendingPathComponent("transcripts.log") + self.fileURL = dir.appendingPathComponent("transcripts.log") if let data = try? Data(contentsOf: fileURL), let text = String(data: data, encoding: .utf8) { - entries = text.split(separator: "\n").map(String.init).suffix(limit) + self.entries = text.split(separator: "\n").map(String.init).suffix(self.limit) } } - func append(text: String) { - entries.append(text) - if entries.count > limit { - entries.removeFirst(entries.count - 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 = entries.joined(separator: "\n") - try? body.write(to: fileURL, atomically: false, encoding: .utf8) + let body = self.entries.joined(separator: "\n") + try? body.write(to: self.fileURL, atomically: false, encoding: .utf8) } - func latest() -> [String] { - entries - } + public func latest() -> [String] { self.entries } } -private extension String { - func appendLine(to url: URL) throws { +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) diff --git a/Sources/swabble/Commands/DoctorCommand.swift b/Sources/swabble/Commands/DoctorCommand.swift index fd28034..fc37e36 100644 --- a/Sources/swabble/Commands/DoctorCommand.swift +++ b/Sources/swabble/Commands/DoctorCommand.swift @@ -1,6 +1,7 @@ import Commander import Foundation import Speech +import Swabble @MainActor struct DoctorCommand: ParsableCommand { @@ -13,14 +14,14 @@ struct DoctorCommand: ParsableCommand { init() {} init(parsed: ParsedValues) { self.init() - if let cfg = parsed.options["config"]?.last { configPath = cfg } + 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: configURL) + _ = try ConfigLoader.load(at: self.configURL) print("Config: OK") } catch { print("Config missing or invalid; run setup") @@ -32,5 +33,5 @@ struct DoctorCommand: ParsableCommand { print("Mics found: \(session.devices.count)") } - private var configURL: URL? { configPath.map { URL(fileURLWithPath: $0) } } + private var configURL: URL? { self.configPath.map { URL(fileURLWithPath: $0) } } } diff --git a/Sources/swabble/Commands/MicCommands.swift b/Sources/swabble/Commands/MicCommands.swift index 0d158c8..3c31f74 100644 --- a/Sources/swabble/Commands/MicCommands.swift +++ b/Sources/swabble/Commands/MicCommands.swift @@ -1,6 +1,7 @@ import AVFoundation import Commander import Foundation +import Swabble @MainActor struct MicCommand: ParsableCommand { @@ -46,16 +47,16 @@ struct MicSet: ParsableCommand { init() {} init(parsed: ParsedValues) { self.init() - if let value = parsed.positional.first, let intVal = Int(value) { index = intVal } - if let cfg = parsed.options["config"]?.last { configPath = cfg } + 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: configURL) - cfg.audio.deviceIndex = index - try ConfigLoader.save(cfg, at: configURL) - print("saved device index \(index)") + 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? { configPath.map { URL(fileURLWithPath: $0) } } + private var configURL: URL? { self.configPath.map { URL(fileURLWithPath: $0) } } } diff --git a/Sources/swabble/Commands/ServeCommand.swift b/Sources/swabble/Commands/ServeCommand.swift index 279dbb4..1493402 100644 --- a/Sources/swabble/Commands/ServeCommand.swift +++ b/Sources/swabble/Commands/ServeCommand.swift @@ -1,5 +1,6 @@ import Commander import Foundation +import Swabble @MainActor struct ServeCommand: ParsableCommand { @@ -16,19 +17,19 @@ struct ServeCommand: ParsableCommand { init(parsed: ParsedValues) { self.init() - if parsed.flags.contains("noWake") { noWake = true } - if let cfg = parsed.options["config"]?.last { configPath = cfg } + 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: configURL) + cfg = try ConfigLoader.load(at: self.configURL) } catch { cfg = SwabbleConfig() - try ConfigLoader.save(cfg, at: configURL) + try ConfigLoader.save(cfg, at: self.configURL) } - if noWake { + if self.noWake { cfg.wake.enabled = false } @@ -36,7 +37,9 @@ struct ServeCommand: ParsableCommand { 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) + 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 } @@ -61,7 +64,7 @@ struct ServeCommand: ParsableCommand { } private var configURL: URL? { - configPath.map { URL(fileURLWithPath: $0) } + self.configPath.map { URL(fileURLWithPath: $0) } } private static func matchesWake(text: String, cfg: SwabbleConfig) -> Bool { diff --git a/Sources/swabble/Commands/ServiceCommands.swift b/Sources/swabble/Commands/ServiceCommands.swift index 3a8f31c..70b7213 100644 --- a/Sources/swabble/Commands/ServiceCommands.swift +++ b/Sources/swabble/Commands/ServiceCommands.swift @@ -28,11 +28,11 @@ private enum LaunchdHelper { "KeepAlive": true, ] let data = try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0) - try data.write(to: plistURL) + try data.write(to: self.plistURL) } static func removePlist() throws { - try? FileManager.default.removeItem(at: plistURL) + try? FileManager.default.removeItem(at: self.plistURL) } } diff --git a/Sources/swabble/Commands/SetupCommand.swift b/Sources/swabble/Commands/SetupCommand.swift index 4743a7a..240f4b5 100644 --- a/Sources/swabble/Commands/SetupCommand.swift +++ b/Sources/swabble/Commands/SetupCommand.swift @@ -1,5 +1,6 @@ import Commander import Foundation +import Swabble @MainActor struct SetupCommand: ParsableCommand { @@ -12,14 +13,14 @@ struct SetupCommand: ParsableCommand { init() {} init(parsed: ParsedValues) { self.init() - if let cfg = parsed.options["config"]?.last { configPath = cfg } + if let cfg = parsed.options["config"]?.last { self.configPath = cfg } } mutating func run() async throws { let cfg = SwabbleConfig() - try ConfigLoader.save(cfg, at: configURL) - print("wrote config to \(configURL?.path ?? SwabbleConfig.defaultPath.path)") + try ConfigLoader.save(cfg, at: self.configURL) + print("wrote config to \(self.configURL?.path ?? SwabbleConfig.defaultPath.path)") } - private var configURL: URL? { configPath.map { URL(fileURLWithPath: $0) } } + private var configURL: URL? { self.configPath.map { URL(fileURLWithPath: $0) } } } diff --git a/Sources/swabble/Commands/StatusCommand.swift b/Sources/swabble/Commands/StatusCommand.swift index 5defacb..ed68fbe 100644 --- a/Sources/swabble/Commands/StatusCommand.swift +++ b/Sources/swabble/Commands/StatusCommand.swift @@ -1,5 +1,6 @@ import Commander import Foundation +import Swabble @MainActor struct StatusCommand: ParsableCommand { @@ -12,11 +13,11 @@ struct StatusCommand: ParsableCommand { init() {} init(parsed: ParsedValues) { self.init() - if let cfg = parsed.options["config"]?.last { configPath = cfg } + if let cfg = parsed.options["config"]?.last { self.configPath = cfg } } mutating func run() async throws { - let cfg = try? ConfigLoader.load(at: configURL) + 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) @@ -29,5 +30,5 @@ struct StatusCommand: ParsableCommand { } } - private var configURL: URL? { configPath.map { URL(fileURLWithPath: $0) } } + private var configURL: URL? { self.configPath.map { URL(fileURLWithPath: $0) } } } diff --git a/Sources/swabble/Commands/TailLogCommand.swift b/Sources/swabble/Commands/TailLogCommand.swift index 771a5c8..451ed37 100644 --- a/Sources/swabble/Commands/TailLogCommand.swift +++ b/Sources/swabble/Commands/TailLogCommand.swift @@ -1,5 +1,6 @@ import Commander import Foundation +import Swabble @MainActor struct TailLogCommand: ParsableCommand { diff --git a/Sources/swabble/Commands/TestHookCommand.swift b/Sources/swabble/Commands/TestHookCommand.swift index 92731e0..7ca64bb 100644 --- a/Sources/swabble/Commands/TestHookCommand.swift +++ b/Sources/swabble/Commands/TestHookCommand.swift @@ -1,5 +1,6 @@ import Commander import Foundation +import Swabble @MainActor struct TestHookCommand: ParsableCommand { @@ -14,16 +15,16 @@ struct TestHookCommand: ParsableCommand { init(parsed: ParsedValues) { self.init() - if let positional = parsed.positional.first { text = positional } - if let cfg = parsed.options["config"]?.last { configPath = cfg } + 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: configURL) + let cfg = try ConfigLoader.load(at: self.configURL) let runner = HookRunner(config: cfg) - try await runner.run(job: HookJob(text: text, timestamp: Date())) + try await runner.run(job: HookJob(text: self.text, timestamp: Date())) print("hook invoked") } - private var configURL: URL? { configPath.map { URL(fileURLWithPath: $0) } } + private var configURL: URL? { self.configPath.map { URL(fileURLWithPath: $0) } } } diff --git a/Sources/swabble/Commands/TranscribeCommand.swift b/Sources/swabble/Commands/TranscribeCommand.swift index f348438..81d1578 100644 --- a/Sources/swabble/Commands/TranscribeCommand.swift +++ b/Sources/swabble/Commands/TranscribeCommand.swift @@ -2,11 +2,13 @@ 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 + @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" @@ -22,12 +24,12 @@ struct TranscribeCommand: ParsableCommand { init(parsed: ParsedValues) { self.init() - if let positional = parsed.positional.first { inputFile = positional } - if let loc = parsed.options["locale"]?.last { locale = loc } - if parsed.flags.contains("censor") { censor = true } - if let out = parsed.options["output"]?.last { outputFile = out } - if let fmt = parsed.options["format"]?.last { format = fmt } - if let len = parsed.options["maxLength"]?.last, let intVal = Int(len) { maxLength = intVal } + 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 { @@ -49,7 +51,7 @@ struct TranscribeCommand: ParsableCommand { transcript += result.text } - let output = outputFormat.text(for: transcript, maxLength: maxLength) + 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 { diff --git a/Sources/swabble/Config/Config.swift b/Sources/swabble/Config/Config.swift deleted file mode 100644 index 3a3673e..0000000 --- a/Sources/swabble/Config/Config.swift +++ /dev/null @@ -1,75 +0,0 @@ -import Foundation - -struct SwabbleConfig: Codable, Sendable { - struct Audio: Codable, Sendable { - var deviceName: String = "" - var deviceIndex: Int = -1 - var sampleRate: Double = 16_000 - var channels: Int = 1 - } - - struct Wake: Codable, Sendable { - var enabled: Bool = true - var word: String = "clawd" - var aliases: [String] = ["claude"] - } - - struct Hook: Codable, Sendable { - var command: String = "" - var args: [String] = [] - var prefix: String = "Voice swabble from ${hostname}: " - var cooldownSeconds: Double = 1 - var minCharacters: Int = 24 - var timeoutSeconds: Double = 5 - var env: [String: String] = [:] - } - - struct Logging: Codable, Sendable { - var level: String = "info" - var format: String = "text" // text|json placeholder - } - - struct Transcripts: Codable, Sendable { - var enabled: Bool = true - var maxEntries: Int = 50 - } - - struct Speech: Codable, Sendable { - var localeIdentifier: String = Locale.current.identifier - var etiquetteReplacements: Bool = false - } - - var audio = Audio() - var wake = Wake() - var hook = Hook() - var logging = Logging() - var transcripts = Transcripts() - var speech = Speech() - - static let defaultPath = FileManager.default - .homeDirectoryForCurrentUser - .appendingPathComponent(".config/swabble/config.json") -} - -enum ConfigError: Error { - case missingConfig -} - -enum ConfigLoader { - 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) - } - - 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) - } -} diff --git a/Sources/swabble/Support/Logging.swift b/Sources/swabble/Support/Logging.swift deleted file mode 100644 index 6a7e6bd..0000000 --- a/Sources/swabble/Support/Logging.swift +++ /dev/null @@ -1,41 +0,0 @@ -import Foundation - -enum LogLevel: String, Comparable, CaseIterable { - 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 - } - } - - static func < (lhs: LogLevel, rhs: LogLevel) -> Bool { lhs.rank < rhs.rank } -} - -struct Logger: Sendable { - let level: LogLevel - - init(level: LogLevel) { self.level = level } - - func log(_ level: LogLevel, _ message: String) { - guard level >= self.level else { return } - let ts = ISO8601DateFormatter().string(from: Date()) - print("[\(level.rawValue.uppercased())] \(ts) | \(message)") - } - - func trace(_ msg: String) { log(.trace, msg) } - func debug(_ msg: String) { log(.debug, msg) } - func info(_ msg: String) { log(.info, msg) } - func warn(_ msg: String) { log(.warn, msg) } - func error(_ msg: String) { log(.error, msg) } -} - -extension LogLevel { - init?(configValue: String) { - self.init(rawValue: configValue.lowercased()) - } -} diff --git a/Tests/swabbleTests/ConfigTests.swift b/Tests/swabbleTests/ConfigTests.swift index ce8b47d..e0833db 100644 --- a/Tests/swabbleTests/ConfigTests.swift +++ b/Tests/swabbleTests/ConfigTests.swift @@ -1,6 +1,6 @@ import Foundation import Testing -@testable import swabble +@testable import Swabble @Test func configRoundTrip() throws { diff --git a/docs/spec.md b/docs/spec.md index 8683dcf..aa97c17 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -8,7 +8,7 @@ Goal: brabble-style always-on voice hook for macOS 26 using Apple Speech.framewo - Wake word gating (default "clawd" plus aliases) with bypass flag `--no-wake`. - Hook execution with cooldown, min_chars, timeout, prefix, env vars. - Simple config at `~/.config/swabble/config.json` (JSON, Codable) — no TOML. -- CLI implemented with Commander (local path dependency `../Commander`). +- CLI implemented with Commander (SwiftPM package `steipete/Commander`); core types are available via the SwiftPM library product `Swabble` for embedding. - Foreground `serve`; later launchd helper for start/stop/restart. - File transcription command emitting txt or srt. - Basic status/health surfaces and mic selection stubs.