refactor: split Swabble core module
Some checks failed
CI / build-and-test (push) Has been cancelled

This commit is contained in:
Peter Steinberger 2026-05-04 02:15:43 +01:00
parent 07c5b709a4
commit 31d06e095f
No known key found for this signature in database
25 changed files with 334 additions and 224 deletions

54
.github/workflows/ci.yml vendored Normal file
View File

@ -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

View File

@ -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",

View File

@ -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"),
]),
],

View File

@ -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 <file>` — offline transcription (txt|srt)

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

@ -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()
}
}

View File

@ -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
}

View File

@ -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<Void, Never>?
private let converter = BufferConverter()
func start(localeIdentifier: String, etiquette: Bool) async throws -> AsyncStream<SpeechSegment> {
public init() {}
public func start(localeIdentifier: String, etiquette: Bool) async throws -> AsyncStream<SpeechSegment> {
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<AnalyzerInput>.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
}

View File

@ -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)
}
}

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

@ -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

View File

@ -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)

View File

@ -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) } }
}

View File

@ -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) } }
}

View File

@ -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 {

View File

@ -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)
}
}

View File

@ -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) } }
}

View File

@ -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) } }
}

View File

@ -1,5 +1,6 @@
import Commander
import Foundation
import Swabble
@MainActor
struct TailLogCommand: ParsableCommand {

View File

@ -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) } }
}

View File

@ -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 {

View File

@ -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)
}
}

View File

@ -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())
}
}

View File

@ -1,6 +1,6 @@
import Foundation
import Testing
@testable import swabble
@testable import Swabble
@Test
func configRoundTrip() throws {

View File

@ -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.