diff --git a/Swabble/.github/workflows/ci.yml b/Swabble/.github/workflows/ci.yml new file mode 100644 index 000000000..aff600f6d --- /dev/null +++ b/Swabble/.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/Swabble/.gitignore b/Swabble/.gitignore new file mode 100644 index 000000000..e988a5b23 --- /dev/null +++ b/Swabble/.gitignore @@ -0,0 +1,33 @@ +# macOS +.DS_Store + +# SwiftPM / Build +/.build +/.swiftpm +/DerivedData +xcuserdata/ +*.xcuserstate + +# Editors +/.vscode +.idea/ + +# Xcode artifacts +*.hmap +*.ipa +*.dSYM.zip +*.dSYM + +# Playgrounds +*.xcplayground +playground.xcworkspace +timeline.xctimeline + +# Carthage +Carthage/Build/ + +# fastlane +fastlane/report.xml +fastlane/Preview.html +fastlane/screenshots/**/*.png +fastlane/test_output diff --git a/Swabble/.swiftformat b/Swabble/.swiftformat new file mode 100644 index 000000000..2686269a2 --- /dev/null +++ b/Swabble/.swiftformat @@ -0,0 +1,8 @@ +--swiftversion 6.2 +--indent 4 +--maxwidth 120 +--wraparguments before-first +--wrapcollections before-first +--stripunusedargs closure-only +--self remove +--header "" diff --git a/Swabble/.swiftlint.yml b/Swabble/.swiftlint.yml new file mode 100644 index 000000000..f63ff5dbb --- /dev/null +++ b/Swabble/.swiftlint.yml @@ -0,0 +1,43 @@ +# SwiftLint for swabble +included: + - Sources +excluded: + - .build + - DerivedData + - "**/.swiftpm" + - "**/.build" + - "**/DerivedData" + - "**/.DS_Store" +opt_in_rules: + - array_init + - closure_spacing + - explicit_init + - fatal_error_message + - first_where + - joined_default_parameter + - last_where + - literal_expression_end_indentation + - multiline_arguments + - multiline_parameters + - operator_usage_whitespace + - redundant_nil_coalescing + - sorted_first_last + - switch_case_alignment + - vertical_parameter_alignment_on_call + - vertical_whitespace_opening_braces + - vertical_whitespace_closing_braces + +disabled_rules: + - trailing_whitespace + - trailing_newline + - indentation_width + - identifier_name + - explicit_self + - file_header + - todo + +line_length: + warning: 140 + error: 180 + +reporter: "xcode" diff --git a/Swabble/LICENSE b/Swabble/LICENSE new file mode 100644 index 000000000..f7b526698 --- /dev/null +++ b/Swabble/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Peter Steinberger + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Swabble/Package.resolved b/Swabble/Package.resolved new file mode 100644 index 000000000..a7d2a7064 --- /dev/null +++ b/Swabble/Package.resolved @@ -0,0 +1,33 @@ +{ + "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", + "location" : "https://github.com/swiftlang/swift-syntax.git", + "state" : { + "revision" : "0687f71944021d616d34d922343dcef086855920", + "version" : "600.0.1" + } + }, + { + "identity" : "swift-testing", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-testing", + "state" : { + "revision" : "399f76dcd91e4c688ca2301fa24a8cc6d9927211", + "version" : "0.99.0" + } + } + ], + "version" : 3 +} diff --git a/Swabble/Package.swift b/Swabble/Package.swift new file mode 100644 index 000000000..588bd01d8 --- /dev/null +++ b/Swabble/Package.swift @@ -0,0 +1,37 @@ +// swift-tools-version: 6.2 +import PackageDescription + +let package = Package( + name: "swabble", + platforms: [ + .macOS(.v26), + ], + products: [ + .library(name: "Swabble", targets: ["Swabble"]), + .executable(name: "swabble", targets: ["SwabbleCLI"]), + ], + dependencies: [ + .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: "SwabbleCLI", + dependencies: [ + "Swabble", + .product(name: "Commander", package: "Commander"), + ], + path: "Sources/swabble"), + .testTarget( + name: "swabbleTests", + dependencies: [ + "Swabble", + .product(name: "Testing", package: "swift-testing"), + ]), + ], + swiftLanguageModes: [.v6] +) diff --git a/Swabble/README.md b/Swabble/README.md new file mode 100644 index 000000000..3fe168616 --- /dev/null +++ b/Swabble/README.md @@ -0,0 +1,107 @@ +# 🎙️ swabble — Speech.framework wake-word hook daemon (macOS 26) + +swabble is a Swift 6.2, macOS 26-only rewrite of the brabble voice daemon. It listens on your mic, gates on a wake word, transcribes locally using Apple's new SpeechAnalyzer + SpeechTranscriber, then fires a shell hook with the transcript. No cloud calls, no Whisper binaries. + +- **Local-only**: Speech.framework on-device models; zero network usage. +- **Wake word**: Default `clawd` (aliases `claude`), optional `--no-wake` bypass. +- **Hooks**: Run any command with prefix/env, cooldown, min_chars, timeout. +- **Services**: launchd helper stubs for start/stop/install. +- **File transcribe**: TXT or SRT with time ranges (using AttributedString splits). + +## Quick start +```bash +# Install deps +brew install swiftformat swiftlint + +# Build +swift build + +# Write default config (~/.config/swabble/config.json) +swift run swabble setup + +# Run foreground daemon +swift run swabble serve + +# Test your hook +swift run swabble test-hook "hello world" + +# Transcribe a file to SRT +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) +- `test-hook "text"` — invoke configured hook +- `mic list|set ` — enumerate/select input device +- `setup` — write default config JSON +- `doctor` — check Speech auth & device availability +- `health` — prints `ok` +- `tail-log` — last 10 transcripts +- `status` — show wake state + recent transcripts +- `service install|uninstall|status` — user launchd plist (stub: prints launchctl commands) +- `start|stop|restart` — placeholders until full launchd wiring + +All commands accept Commander runtime flags (`-v/--verbose`, `--json-output`, `--log-level`), plus `--config` where applicable. + +## Config +`~/.config/swabble/config.json` (auto-created by `setup`): +```json +{ + "audio": {"deviceName": "", "deviceIndex": -1, "sampleRate": 16000, "channels": 1}, + "wake": {"enabled": true, "word": "clawd", "aliases": ["claude"]}, + "hook": { + "command": "", + "args": [], + "prefix": "Voice swabble from ${hostname}: ", + "cooldownSeconds": 1, + "minCharacters": 24, + "timeoutSeconds": 5, + "env": {} + }, + "logging": {"level": "info", "format": "text"}, + "transcripts": {"enabled": true, "maxEntries": 50}, + "speech": {"localeIdentifier": "en_US", "etiquetteReplacements": false} +} +``` + +- Config path override: `--config /path/to/config.json` on relevant commands. +- Transcripts persist to `~/Library/Application Support/swabble/transcripts.log`. + +## Hook protocol +When a wake-gated transcript passes min_chars & cooldown, swabble runs: +``` + "" +``` +Environment variables: +- `SWABBLE_TEXT` — stripped transcript (wake word removed) +- `SWABBLE_PREFIX` — rendered prefix (hostname substituted) +- plus any `hook.env` key/values + +## Speech pipeline +- `AVAudioEngine` tap → `BufferConverter` → `AnalyzerInput` → `SpeechAnalyzer` with a `SpeechTranscriber` module. +- Requests volatile + final results; wake gating is string match on partial/final. +- Authorization requested at first start; requires macOS 26 + new Speech.framework APIs. + +## Development +- Format: `./scripts/format.sh` (uses ../peekaboo/.swiftformat if present) +- Lint: `./scripts/lint.sh` (uses ../peekaboo/.swiftlint.yml if present) +- Tests: `swift test` (uses swift-testing package) + +## Roadmap +- launchd control (load/bootout, PID + status socket) +- JSON logging + PII redaction toggle +- Stronger wake-word detection and control socket status/health diff --git a/Swabble/Sources/SwabbleCore/Config/Config.swift b/Swabble/Sources/SwabbleCore/Config/Config.swift new file mode 100644 index 000000000..4dc9d4668 --- /dev/null +++ b/Swabble/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/Swabble/Sources/SwabbleCore/Hooks/HookRunner.swift b/Swabble/Sources/SwabbleCore/Hooks/HookRunner.swift new file mode 100644 index 000000000..e94b0b002 --- /dev/null +++ b/Swabble/Sources/SwabbleCore/Hooks/HookRunner.swift @@ -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() + } +} diff --git a/Swabble/Sources/SwabbleCore/Speech/BufferConverter.swift b/Swabble/Sources/SwabbleCore/Speech/BufferConverter.swift new file mode 100644 index 000000000..e6d7dc993 --- /dev/null +++ b/Swabble/Sources/SwabbleCore/Speech/BufferConverter.swift @@ -0,0 +1,50 @@ +@preconcurrency import AVFoundation +import Foundation + +final class BufferConverter { + private final class Box: @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 + } +} diff --git a/Swabble/Sources/SwabbleCore/Speech/SpeechPipeline.swift b/Swabble/Sources/SwabbleCore/Speech/SpeechPipeline.swift new file mode 100644 index 000000000..faecc64d8 --- /dev/null +++ b/Swabble/Sources/SwabbleCore/Speech/SpeechPipeline.swift @@ -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.Continuation? + private var resultTask: Task? + private let converter = BufferConverter() + + public init() {} + + public func start(localeIdentifier: String, etiquette: Bool) async throws -> AsyncStream { + 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.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) + } + } + } +} diff --git a/Swabble/Sources/SwabbleCore/Support/AttributedString+Sentences.swift b/Swabble/Sources/SwabbleCore/Support/AttributedString+Sentences.swift new file mode 100644 index 000000000..8f5d6a106 --- /dev/null +++ b/Swabble/Sources/SwabbleCore/Support/AttributedString+Sentences.swift @@ -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.. 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..] = [] + for wordRange in wordRanges { + if let lastRange = ranges.last, + self[lastRange].characters.count + self[wordRange].characters.count <= maxLength + { + ranges[ranges.count - 1] = lastRange.lowerBound.. 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/Swabble/Sources/SwabbleCore/Support/OutputFormat.swift b/Swabble/Sources/SwabbleCore/Support/OutputFormat.swift new file mode 100644 index 000000000..84047c728 --- /dev/null +++ b/Swabble/Sources/SwabbleCore/Support/OutputFormat.swift @@ -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) + } + } +} diff --git a/Swabble/Sources/SwabbleCore/Support/TranscriptsStore.swift b/Swabble/Sources/SwabbleCore/Support/TranscriptsStore.swift new file mode 100644 index 000000000..a62eb5b30 --- /dev/null +++ b/Swabble/Sources/SwabbleCore/Support/TranscriptsStore.swift @@ -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) + } + } +} diff --git a/Swabble/Sources/swabble/CLI/CLIRegistry.swift b/Swabble/Sources/swabble/CLI/CLIRegistry.swift new file mode 100644 index 000000000..961c19c9c --- /dev/null +++ b/Swabble/Sources/swabble/CLI/CLIRegistry.swift @@ -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: []) + } +} diff --git a/Swabble/Sources/swabble/Commands/DoctorCommand.swift b/Swabble/Sources/swabble/Commands/DoctorCommand.swift new file mode 100644 index 000000000..fc37e36e0 --- /dev/null +++ b/Swabble/Sources/swabble/Commands/DoctorCommand.swift @@ -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) } } +} diff --git a/Swabble/Sources/swabble/Commands/HealthCommand.swift b/Swabble/Sources/swabble/Commands/HealthCommand.swift new file mode 100644 index 000000000..b3db45286 --- /dev/null +++ b/Swabble/Sources/swabble/Commands/HealthCommand.swift @@ -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") + } +} diff --git a/Swabble/Sources/swabble/Commands/MicCommands.swift b/Swabble/Sources/swabble/Commands/MicCommands.swift new file mode 100644 index 000000000..3c31f74fe --- /dev/null +++ b/Swabble/Sources/swabble/Commands/MicCommands.swift @@ -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) } } +} diff --git a/Swabble/Sources/swabble/Commands/ServeCommand.swift b/Swabble/Sources/swabble/Commands/ServeCommand.swift new file mode 100644 index 000000000..1493402d5 --- /dev/null +++ b/Swabble/Sources/swabble/Commands/ServeCommand.swift @@ -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) + } +} diff --git a/Swabble/Sources/swabble/Commands/ServiceCommands.swift b/Swabble/Sources/swabble/Commands/ServiceCommands.swift new file mode 100644 index 000000000..70b721378 --- /dev/null +++ b/Swabble/Sources/swabble/Commands/ServiceCommands.swift @@ -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") + } + } +} diff --git a/Swabble/Sources/swabble/Commands/SetupCommand.swift b/Swabble/Sources/swabble/Commands/SetupCommand.swift new file mode 100644 index 000000000..240f4b5ce --- /dev/null +++ b/Swabble/Sources/swabble/Commands/SetupCommand.swift @@ -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) } } +} diff --git a/Swabble/Sources/swabble/Commands/StartStopCommands.swift b/Swabble/Sources/swabble/Commands/StartStopCommands.swift new file mode 100644 index 000000000..641cd923a --- /dev/null +++ b/Swabble/Sources/swabble/Commands/StartStopCommands.swift @@ -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") + } +} diff --git a/Swabble/Sources/swabble/Commands/StatusCommand.swift b/Swabble/Sources/swabble/Commands/StatusCommand.swift new file mode 100644 index 000000000..ed68fbe52 --- /dev/null +++ b/Swabble/Sources/swabble/Commands/StatusCommand.swift @@ -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) } } +} diff --git a/Swabble/Sources/swabble/Commands/TailLogCommand.swift b/Swabble/Sources/swabble/Commands/TailLogCommand.swift new file mode 100644 index 000000000..451ed37de --- /dev/null +++ b/Swabble/Sources/swabble/Commands/TailLogCommand.swift @@ -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) + } + } +} diff --git a/Swabble/Sources/swabble/Commands/TestHookCommand.swift b/Swabble/Sources/swabble/Commands/TestHookCommand.swift new file mode 100644 index 000000000..7ca64bbf4 --- /dev/null +++ b/Swabble/Sources/swabble/Commands/TestHookCommand.swift @@ -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) } } +} diff --git a/Swabble/Sources/swabble/Commands/TranscribeCommand.swift b/Swabble/Sources/swabble/Commands/TranscribeCommand.swift new file mode 100644 index 000000000..81d157891 --- /dev/null +++ b/Swabble/Sources/swabble/Commands/TranscribeCommand.swift @@ -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) + } + } +} diff --git a/Swabble/Sources/swabble/main.swift b/Swabble/Sources/swabble/main.swift new file mode 100644 index 000000000..28432a4fb --- /dev/null +++ b/Swabble/Sources/swabble/main.swift @@ -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) diff --git a/Swabble/Tests/swabbleTests/ConfigTests.swift b/Swabble/Tests/swabbleTests/ConfigTests.swift new file mode 100644 index 000000000..e0833db7f --- /dev/null +++ b/Swabble/Tests/swabbleTests/ConfigTests.swift @@ -0,0 +1,23 @@ +import Foundation +import Testing +@testable import Swabble + +@Test +func configRoundTrip() throws { + var cfg = SwabbleConfig() + cfg.wake.word = "robot" + let url = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString + ".json") + defer { try? FileManager.default.removeItem(at: url) } + + try ConfigLoader.save(cfg, at: url) + let loaded = try ConfigLoader.load(at: url) + #expect(loaded.wake.word == "robot") + #expect(loaded.hook.prefix.contains("Voice swabble")) +} + +@Test +func configMissingThrows() { + #expect(throws: ConfigError.missingConfig) { + _ = try ConfigLoader.load(at: FileManager.default.temporaryDirectory.appendingPathComponent("nope.json")) + } +} diff --git a/Swabble/docs/spec.md b/Swabble/docs/spec.md new file mode 100644 index 000000000..aa97c1722 --- /dev/null +++ b/Swabble/docs/spec.md @@ -0,0 +1,32 @@ +# swabble — macOS 26 speech hook daemon (Swift 6.2) + +Goal: brabble-style always-on voice hook for macOS 26 using Apple Speech.framework (SpeechAnalyzer + SpeechTranscriber) instead of whisper.cpp. Local-only, wake word gated, dispatches a shell hook with the transcript. + +## Requirements +- macOS 26+, Swift 6.2, Speech.framework with on-device assets. +- Local only; no network calls during transcription. +- 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 (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. + +## Architecture +- **CLI layer (Commander)**: Root command `swabble` with subcommands `serve`, `transcribe`, `test-hook`, `mic list|set`, `doctor`, `health`, `tail-log`. Runtime flags from Commander (`-v/--verbose`, `--json-output`, `--log-level`). Custom `--config` path applies everywhere. +- **Config**: `SwabbleConfig` Codable. Fields: audio device name/index, wake (enabled/word/aliases/sensitivity placeholder), hook (command/args/prefix/cooldown/min_chars/timeout/env), logging (level, format), transcripts (enabled, max kept), speech (locale, enableEtiquetteReplacements flag). Stored JSON; default written by `setup`. +- **Audio + Speech pipeline**: `SpeechPipeline` wraps `AVAudioEngine` input → `SpeechAnalyzer` with `SpeechTranscriber` module. Emits partial/final transcripts via async stream. Requests `.audioTimeRange` when transcripts enabled. Handles Speech permission and asset download prompts ahead of capture. +- **Wake gate**: text-based keyword match against latest partial/final; strips wake term before hook dispatch. `--no-wake` disables. +- **Hook runner**: async `HookRunner` spawns `Process` with configured args, prefix substitution `${hostname}`. Enforces cooldown + timeout; injects env `SWABBLE_TEXT`, `SWABBLE_PREFIX` plus user env map. +- **Transcripts store**: in-memory ring buffer; optional persisted JSON lines under `~/Library/Application Support/swabble/transcripts.log`. +- **Logging**: simple structured logger to stderr; respects log level. + +## Out of scope (initial cut) +- Model management (Speech handles assets). +- Launchd helper (planned follow-up). +- Advanced wake-word detector (text match only for now). + +## Open decisions +- Whether to expose a UNIX control socket for `status`/`health` (currently planned as stdin/out direct calls). +- Hook redaction (PII) parity with brabble — placeholder boolean, no implementation yet. diff --git a/Swabble/scripts/format.sh b/Swabble/scripts/format.sh new file mode 100755 index 000000000..0ce82a7fb --- /dev/null +++ b/Swabble/scripts/format.sh @@ -0,0 +1,10 @@ +#!/bin/bash +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +PEEKABOO_ROOT="${ROOT}/../peekaboo" +if [ -f "${PEEKABOO_ROOT}/.swiftformat" ]; then + CONFIG="${PEEKABOO_ROOT}/.swiftformat" +else + CONFIG="${ROOT}/.swiftformat" +fi +swiftformat --config "$CONFIG" "$ROOT/Sources" diff --git a/Swabble/scripts/lint.sh b/Swabble/scripts/lint.sh new file mode 100755 index 000000000..650f09176 --- /dev/null +++ b/Swabble/scripts/lint.sh @@ -0,0 +1,14 @@ +#!/bin/bash +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +PEEKABOO_ROOT="${ROOT}/../peekaboo" +if [ -f "${PEEKABOO_ROOT}/.swiftlint.yml" ]; then + CONFIG="${PEEKABOO_ROOT}/.swiftlint.yml" +else + CONFIG="$ROOT/.swiftlint.yml" +fi +if ! command -v swiftlint >/dev/null; then + echo "swiftlint not installed" >&2 + exit 1 +fi +swiftlint --config "$CONFIG" diff --git a/scripts/package-mac-app.sh b/scripts/package-mac-app.sh index 10b8b21f0..2cc4941b6 100755 --- a/scripts/package-mac-app.sh +++ b/scripts/package-mac-app.sh @@ -44,7 +44,9 @@ cat > "$APP_ROOT/Contents/Info.plist" <<'PLIST' NSScreenCaptureDescription Clawdis captures the screen when the agent needs screenshots for context. NSMicrophoneUsageDescription - Clawdis may record screen or audio when requested by the agent. + Clawdis needs the mic for Voice Wake tests and agent audio capture. + NSSpeechRecognitionUsageDescription + Clawdis uses speech recognition to detect your Voice Wake trigger phrase. PLIST