chore: vendor swabble and add speech usage strings
This commit is contained in:
54
Swabble/.github/workflows/ci.yml
vendored
Normal file
54
Swabble/.github/workflows/ci.yml
vendored
Normal 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
|
||||||
33
Swabble/.gitignore
vendored
Normal file
33
Swabble/.gitignore
vendored
Normal file
@@ -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
|
||||||
8
Swabble/.swiftformat
Normal file
8
Swabble/.swiftformat
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
--swiftversion 6.2
|
||||||
|
--indent 4
|
||||||
|
--maxwidth 120
|
||||||
|
--wraparguments before-first
|
||||||
|
--wrapcollections before-first
|
||||||
|
--stripunusedargs closure-only
|
||||||
|
--self remove
|
||||||
|
--header ""
|
||||||
43
Swabble/.swiftlint.yml
Normal file
43
Swabble/.swiftlint.yml
Normal file
@@ -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"
|
||||||
21
Swabble/LICENSE
Normal file
21
Swabble/LICENSE
Normal file
@@ -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.
|
||||||
33
Swabble/Package.resolved
Normal file
33
Swabble/Package.resolved
Normal file
@@ -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
|
||||||
|
}
|
||||||
37
Swabble/Package.swift
Normal file
37
Swabble/Package.swift
Normal file
@@ -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]
|
||||||
|
)
|
||||||
107
Swabble/README.md
Normal file
107
Swabble/README.md
Normal file
@@ -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 <file>` — offline transcription (txt|srt)
|
||||||
|
- `test-hook "text"` — invoke configured hook
|
||||||
|
- `mic list|set <index>` — 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:
|
||||||
|
```
|
||||||
|
<command> <args...> "<prefix><text>"
|
||||||
|
```
|
||||||
|
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
|
||||||
77
Swabble/Sources/SwabbleCore/Config/Config.swift
Normal file
77
Swabble/Sources/SwabbleCore/Config/Config.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
75
Swabble/Sources/SwabbleCore/Hooks/HookRunner.swift
Normal file
75
Swabble/Sources/SwabbleCore/Hooks/HookRunner.swift
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
public struct HookJob: Sendable {
|
||||||
|
public let text: String
|
||||||
|
public let timestamp: Date
|
||||||
|
|
||||||
|
public init(text: String, timestamp: Date) {
|
||||||
|
self.text = text
|
||||||
|
self.timestamp = timestamp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public actor HookRunner {
|
||||||
|
private let config: SwabbleConfig
|
||||||
|
private var lastRun: Date?
|
||||||
|
private let hostname: String
|
||||||
|
|
||||||
|
public init(config: SwabbleConfig) {
|
||||||
|
self.config = config
|
||||||
|
self.hostname = Host.current().localizedName ?? "host"
|
||||||
|
}
|
||||||
|
|
||||||
|
public func shouldRun() -> Bool {
|
||||||
|
guard self.config.hook.cooldownSeconds > 0 else { return true }
|
||||||
|
if let lastRun, Date().timeIntervalSince(lastRun) < config.hook.cooldownSeconds {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
public func run(job: HookJob) async throws {
|
||||||
|
guard self.shouldRun() else { return }
|
||||||
|
guard !self.config.hook.command.isEmpty else { throw NSError(
|
||||||
|
domain: "Hook",
|
||||||
|
code: 1,
|
||||||
|
userInfo: [NSLocalizedDescriptionKey: "hook command not set"]) }
|
||||||
|
|
||||||
|
let prefix = self.config.hook.prefix.replacingOccurrences(of: "${hostname}", with: self.hostname)
|
||||||
|
let payload = prefix + job.text
|
||||||
|
|
||||||
|
let process = Process()
|
||||||
|
process.executableURL = URL(fileURLWithPath: self.config.hook.command)
|
||||||
|
process.arguments = self.config.hook.args + [payload]
|
||||||
|
|
||||||
|
var env = ProcessInfo.processInfo.environment
|
||||||
|
env["SWABBLE_TEXT"] = job.text
|
||||||
|
env["SWABBLE_PREFIX"] = prefix
|
||||||
|
for (k, v) in self.config.hook.env {
|
||||||
|
env[k] = v
|
||||||
|
}
|
||||||
|
process.environment = env
|
||||||
|
|
||||||
|
let pipe = Pipe()
|
||||||
|
process.standardOutput = pipe
|
||||||
|
process.standardError = pipe
|
||||||
|
|
||||||
|
try process.run()
|
||||||
|
|
||||||
|
let timeoutNanos = UInt64(max(config.hook.timeoutSeconds, 0.1) * 1_000_000_000)
|
||||||
|
try await withThrowingTaskGroup(of: Void.self) { group in
|
||||||
|
group.addTask {
|
||||||
|
process.waitUntilExit()
|
||||||
|
}
|
||||||
|
group.addTask {
|
||||||
|
try await Task.sleep(nanoseconds: timeoutNanos)
|
||||||
|
if process.isRunning {
|
||||||
|
process.terminate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try await group.next()
|
||||||
|
group.cancelAll()
|
||||||
|
}
|
||||||
|
self.lastRun = Date()
|
||||||
|
}
|
||||||
|
}
|
||||||
50
Swabble/Sources/SwabbleCore/Speech/BufferConverter.swift
Normal file
50
Swabble/Sources/SwabbleCore/Speech/BufferConverter.swift
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
@preconcurrency import AVFoundation
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
final class BufferConverter {
|
||||||
|
private final class Box<T>: @unchecked Sendable { var value: T; init(_ value: T) { self.value = value } }
|
||||||
|
enum ConverterError: Swift.Error {
|
||||||
|
case failedToCreateConverter
|
||||||
|
case failedToCreateConversionBuffer
|
||||||
|
case conversionFailed(NSError?)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var converter: AVAudioConverter?
|
||||||
|
|
||||||
|
func convert(_ buffer: AVAudioPCMBuffer, to format: AVAudioFormat) throws -> AVAudioPCMBuffer {
|
||||||
|
let inputFormat = buffer.format
|
||||||
|
if inputFormat == format {
|
||||||
|
return buffer
|
||||||
|
}
|
||||||
|
if converter == nil || converter?.outputFormat != format {
|
||||||
|
converter = AVAudioConverter(from: inputFormat, to: format)
|
||||||
|
converter?.primeMethod = .none
|
||||||
|
}
|
||||||
|
guard let converter else { throw ConverterError.failedToCreateConverter }
|
||||||
|
|
||||||
|
let sampleRateRatio = converter.outputFormat.sampleRate / converter.inputFormat.sampleRate
|
||||||
|
let scaledInputFrameLength = Double(buffer.frameLength) * sampleRateRatio
|
||||||
|
let frameCapacity = AVAudioFrameCount(scaledInputFrameLength.rounded(.up))
|
||||||
|
guard let conversionBuffer = AVAudioPCMBuffer(pcmFormat: converter.outputFormat, frameCapacity: frameCapacity)
|
||||||
|
else {
|
||||||
|
throw ConverterError.failedToCreateConversionBuffer
|
||||||
|
}
|
||||||
|
|
||||||
|
var nsError: NSError?
|
||||||
|
let consumed = Box(false)
|
||||||
|
let inputBuffer = buffer
|
||||||
|
let status = converter.convert(to: conversionBuffer, error: &nsError) { _, statusPtr in
|
||||||
|
if consumed.value {
|
||||||
|
statusPtr.pointee = .noDataNow
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
consumed.value = true
|
||||||
|
statusPtr.pointee = .haveData
|
||||||
|
return inputBuffer
|
||||||
|
}
|
||||||
|
if status == .error {
|
||||||
|
throw ConverterError.conversionFailed(nsError)
|
||||||
|
}
|
||||||
|
return conversionBuffer
|
||||||
|
}
|
||||||
|
}
|
||||||
111
Swabble/Sources/SwabbleCore/Speech/SpeechPipeline.swift
Normal file
111
Swabble/Sources/SwabbleCore/Speech/SpeechPipeline.swift
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import AVFoundation
|
||||||
|
import Foundation
|
||||||
|
import Speech
|
||||||
|
|
||||||
|
public struct SpeechSegment: Sendable {
|
||||||
|
public let text: String
|
||||||
|
public let isFinal: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum SpeechPipelineError: Error {
|
||||||
|
case authorizationDenied
|
||||||
|
case analyzerFormatUnavailable
|
||||||
|
case transcriberUnavailable
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Live microphone → SpeechAnalyzer → SpeechTranscriber pipeline.
|
||||||
|
public actor SpeechPipeline {
|
||||||
|
private struct UnsafeBuffer: @unchecked Sendable { let buffer: AVAudioPCMBuffer }
|
||||||
|
|
||||||
|
private var engine = AVAudioEngine()
|
||||||
|
private var transcriber: SpeechTranscriber?
|
||||||
|
private var analyzer: SpeechAnalyzer?
|
||||||
|
private var inputContinuation: AsyncStream<AnalyzerInput>.Continuation?
|
||||||
|
private var resultTask: Task<Void, Never>?
|
||||||
|
private let converter = BufferConverter()
|
||||||
|
|
||||||
|
public init() {}
|
||||||
|
|
||||||
|
public func start(localeIdentifier: String, etiquette: Bool) async throws -> AsyncStream<SpeechSegment> {
|
||||||
|
let auth = await requestAuthorizationIfNeeded()
|
||||||
|
guard auth == .authorized else { throw SpeechPipelineError.authorizationDenied }
|
||||||
|
|
||||||
|
let transcriberModule = SpeechTranscriber(
|
||||||
|
locale: Locale(identifier: localeIdentifier),
|
||||||
|
transcriptionOptions: etiquette ? [.etiquetteReplacements] : [],
|
||||||
|
reportingOptions: [.volatileResults],
|
||||||
|
attributeOptions: [])
|
||||||
|
self.transcriber = transcriberModule
|
||||||
|
|
||||||
|
guard let analyzerFormat = await SpeechAnalyzer.bestAvailableAudioFormat(compatibleWith: [transcriberModule])
|
||||||
|
else {
|
||||||
|
throw SpeechPipelineError.analyzerFormatUnavailable
|
||||||
|
}
|
||||||
|
|
||||||
|
self.analyzer = SpeechAnalyzer(modules: [transcriberModule])
|
||||||
|
let (stream, continuation) = AsyncStream<AnalyzerInput>.makeStream()
|
||||||
|
self.inputContinuation = continuation
|
||||||
|
|
||||||
|
let inputNode = self.engine.inputNode
|
||||||
|
let inputFormat = inputNode.outputFormat(forBus: 0)
|
||||||
|
inputNode.removeTap(onBus: 0)
|
||||||
|
inputNode.installTap(onBus: 0, bufferSize: 2048, format: inputFormat) { [weak self] buffer, _ in
|
||||||
|
guard let self else { return }
|
||||||
|
let boxed = UnsafeBuffer(buffer: buffer)
|
||||||
|
Task { await self.handleBuffer(boxed.buffer, targetFormat: analyzerFormat) }
|
||||||
|
}
|
||||||
|
|
||||||
|
self.engine.prepare()
|
||||||
|
try self.engine.start()
|
||||||
|
try await self.analyzer?.start(inputSequence: stream)
|
||||||
|
|
||||||
|
guard let transcriberForStream = self.transcriber else {
|
||||||
|
throw SpeechPipelineError.transcriberUnavailable
|
||||||
|
}
|
||||||
|
|
||||||
|
return AsyncStream { continuation in
|
||||||
|
self.resultTask = Task {
|
||||||
|
do {
|
||||||
|
for try await result in transcriberForStream.results {
|
||||||
|
let seg = SpeechSegment(text: String(result.text.characters), isFinal: result.isFinal)
|
||||||
|
continuation.yield(seg)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// swallow errors and finish
|
||||||
|
}
|
||||||
|
continuation.finish()
|
||||||
|
}
|
||||||
|
continuation.onTermination = { _ in
|
||||||
|
Task { await self.stop() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func stop() async {
|
||||||
|
self.resultTask?.cancel()
|
||||||
|
self.inputContinuation?.finish()
|
||||||
|
self.engine.inputNode.removeTap(onBus: 0)
|
||||||
|
self.engine.stop()
|
||||||
|
try? await self.analyzer?.finalizeAndFinishThroughEndOfInput()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleBuffer(_ buffer: AVAudioPCMBuffer, targetFormat: AVAudioFormat) async {
|
||||||
|
do {
|
||||||
|
let converted = try converter.convert(buffer, to: targetFormat)
|
||||||
|
let input = AnalyzerInput(buffer: converted)
|
||||||
|
self.inputContinuation?.yield(input)
|
||||||
|
} catch {
|
||||||
|
// drop on conversion failure
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func requestAuthorizationIfNeeded() async -> SFSpeechRecognizerAuthorizationStatus {
|
||||||
|
let current = SFSpeechRecognizer.authorizationStatus()
|
||||||
|
guard current == .notDetermined else { return current }
|
||||||
|
return await withCheckedContinuation { continuation in
|
||||||
|
SFSpeechRecognizer.requestAuthorization { status in
|
||||||
|
continuation.resume(returning: status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import CoreMedia
|
||||||
|
import Foundation
|
||||||
|
import NaturalLanguage
|
||||||
|
|
||||||
|
extension AttributedString {
|
||||||
|
public func sentences(maxLength: Int? = nil) -> [AttributedString] {
|
||||||
|
let tokenizer = NLTokenizer(unit: .sentence)
|
||||||
|
let string = String(characters)
|
||||||
|
tokenizer.string = string
|
||||||
|
let sentenceRanges = tokenizer.tokens(for: string.startIndex..<string.endIndex).map {
|
||||||
|
(
|
||||||
|
$0,
|
||||||
|
AttributedString.Index($0.lowerBound, within: self)!
|
||||||
|
..<
|
||||||
|
AttributedString.Index($0.upperBound, within: self)!)
|
||||||
|
}
|
||||||
|
let ranges = sentenceRanges.flatMap { sentenceStringRange, sentenceRange in
|
||||||
|
let sentence = self[sentenceRange]
|
||||||
|
guard let maxLength, sentence.characters.count > maxLength else {
|
||||||
|
return [sentenceRange]
|
||||||
|
}
|
||||||
|
|
||||||
|
let wordTokenizer = NLTokenizer(unit: .word)
|
||||||
|
wordTokenizer.string = string
|
||||||
|
var wordRanges = wordTokenizer.tokens(for: sentenceStringRange).map {
|
||||||
|
AttributedString.Index($0.lowerBound, within: self)!
|
||||||
|
..<
|
||||||
|
AttributedString.Index($0.upperBound, within: self)!
|
||||||
|
}
|
||||||
|
guard !wordRanges.isEmpty else { return [sentenceRange] }
|
||||||
|
wordRanges[0] = sentenceRange.lowerBound..<wordRanges[0].upperBound
|
||||||
|
wordRanges[wordRanges.count - 1] = wordRanges[wordRanges.count - 1].lowerBound..<sentenceRange.upperBound
|
||||||
|
|
||||||
|
var ranges: [Range<AttributedString.Index>] = []
|
||||||
|
for wordRange in wordRanges {
|
||||||
|
if let lastRange = ranges.last,
|
||||||
|
self[lastRange].characters.count + self[wordRange].characters.count <= maxLength
|
||||||
|
{
|
||||||
|
ranges[ranges.count - 1] = lastRange.lowerBound..<wordRange.upperBound
|
||||||
|
} else {
|
||||||
|
ranges.append(wordRange)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ranges
|
||||||
|
}
|
||||||
|
|
||||||
|
return ranges.compactMap { range in
|
||||||
|
let audioTimeRanges = self[range].runs.filter {
|
||||||
|
!String(self[$0.range].characters)
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||||
|
}.compactMap(\.audioTimeRange)
|
||||||
|
guard !audioTimeRanges.isEmpty else { return nil }
|
||||||
|
let start = audioTimeRanges.first!.start
|
||||||
|
let end = audioTimeRanges.last!.end
|
||||||
|
var attributes = AttributeContainer()
|
||||||
|
attributes[AttributeScopes.SpeechAttributes.TimeRangeAttribute.self] = CMTimeRange(
|
||||||
|
start: start,
|
||||||
|
end: end)
|
||||||
|
return AttributedString(self[range].characters, attributes: attributes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
41
Swabble/Sources/SwabbleCore/Support/Logging.swift
Normal file
41
Swabble/Sources/SwabbleCore/Support/Logging.swift
Normal 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())
|
||||||
|
}
|
||||||
|
}
|
||||||
45
Swabble/Sources/SwabbleCore/Support/OutputFormat.swift
Normal file
45
Swabble/Sources/SwabbleCore/Support/OutputFormat.swift
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import CoreMedia
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public enum OutputFormat: String {
|
||||||
|
case txt
|
||||||
|
case srt
|
||||||
|
|
||||||
|
public var needsAudioTimeRange: Bool {
|
||||||
|
switch self {
|
||||||
|
case .srt: true
|
||||||
|
default: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func text(for transcript: AttributedString, maxLength: Int) -> String {
|
||||||
|
switch self {
|
||||||
|
case .txt:
|
||||||
|
return String(transcript.characters)
|
||||||
|
case .srt:
|
||||||
|
func format(_ timeInterval: TimeInterval) -> String {
|
||||||
|
let ms = Int(timeInterval.truncatingRemainder(dividingBy: 1) * 1000)
|
||||||
|
let s = Int(timeInterval) % 60
|
||||||
|
let m = (Int(timeInterval) / 60) % 60
|
||||||
|
let h = Int(timeInterval) / 60 / 60
|
||||||
|
return String(format: "%0.2d:%0.2d:%0.2d,%0.3d", h, m, s, ms)
|
||||||
|
}
|
||||||
|
|
||||||
|
return transcript.sentences(maxLength: maxLength).compactMap { (sentence: AttributedString) -> (
|
||||||
|
CMTimeRange,
|
||||||
|
String)? in
|
||||||
|
guard let timeRange = sentence.audioTimeRange else { return nil }
|
||||||
|
return (timeRange, String(sentence.characters))
|
||||||
|
}.enumerated().map { index, run in
|
||||||
|
let (timeRange, text) = run
|
||||||
|
return """
|
||||||
|
|
||||||
|
\(index + 1)
|
||||||
|
\(format(timeRange.start.seconds)) --> \(format(timeRange.end.seconds))
|
||||||
|
\(text.trimmingCharacters(in: .whitespacesAndNewlines))
|
||||||
|
|
||||||
|
"""
|
||||||
|
}.joined().trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
46
Swabble/Sources/SwabbleCore/Support/TranscriptsStore.swift
Normal file
46
Swabble/Sources/SwabbleCore/Support/TranscriptsStore.swift
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
public actor TranscriptsStore {
|
||||||
|
public static let shared = TranscriptsStore()
|
||||||
|
|
||||||
|
private var entries: [String] = []
|
||||||
|
private let limit = 100
|
||||||
|
private let fileURL: URL
|
||||||
|
|
||||||
|
public init() {
|
||||||
|
let dir = FileManager.default.homeDirectoryForCurrentUser
|
||||||
|
.appendingPathComponent("Library/Application Support/swabble", isDirectory: true)
|
||||||
|
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||||
|
self.fileURL = dir.appendingPathComponent("transcripts.log")
|
||||||
|
if let data = try? Data(contentsOf: fileURL),
|
||||||
|
let text = String(data: data, encoding: .utf8)
|
||||||
|
{
|
||||||
|
self.entries = text.split(separator: "\n").map(String.init).suffix(self.limit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func append(text: String) {
|
||||||
|
self.entries.append(text)
|
||||||
|
if self.entries.count > self.limit {
|
||||||
|
self.entries.removeFirst(self.entries.count - self.limit)
|
||||||
|
}
|
||||||
|
let body = self.entries.joined(separator: "\n")
|
||||||
|
try? body.write(to: self.fileURL, atomically: false, encoding: .utf8)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func latest() -> [String] { self.entries }
|
||||||
|
}
|
||||||
|
|
||||||
|
extension String {
|
||||||
|
private func appendLine(to url: URL) throws {
|
||||||
|
let data = (self + "\n").data(using: .utf8) ?? Data()
|
||||||
|
if FileManager.default.fileExists(atPath: url.path) {
|
||||||
|
let handle = try FileHandle(forWritingTo: url)
|
||||||
|
try handle.seekToEnd()
|
||||||
|
try handle.write(contentsOf: data)
|
||||||
|
try handle.close()
|
||||||
|
} else {
|
||||||
|
try data.write(to: url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
70
Swabble/Sources/swabble/CLI/CLIRegistry.swift
Normal file
70
Swabble/Sources/swabble/CLI/CLIRegistry.swift
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import Commander
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
enum CLIRegistry {
|
||||||
|
static var descriptors: [CommandDescriptor] {
|
||||||
|
let serveDesc = descriptor(for: ServeCommand.self)
|
||||||
|
let transcribeDesc = descriptor(for: TranscribeCommand.self)
|
||||||
|
let testHookDesc = descriptor(for: TestHookCommand.self)
|
||||||
|
let micList = descriptor(for: MicList.self)
|
||||||
|
let micSet = descriptor(for: MicSet.self)
|
||||||
|
let micRoot = CommandDescriptor(
|
||||||
|
name: "mic",
|
||||||
|
abstract: "Microphone management",
|
||||||
|
discussion: nil,
|
||||||
|
signature: CommandSignature(),
|
||||||
|
subcommands: [micList, micSet])
|
||||||
|
let serviceRoot = CommandDescriptor(
|
||||||
|
name: "service",
|
||||||
|
abstract: "launchd helper",
|
||||||
|
discussion: nil,
|
||||||
|
signature: CommandSignature(),
|
||||||
|
subcommands: [
|
||||||
|
descriptor(for: ServiceInstall.self),
|
||||||
|
descriptor(for: ServiceUninstall.self),
|
||||||
|
descriptor(for: ServiceStatus.self),
|
||||||
|
])
|
||||||
|
let doctorDesc = descriptor(for: DoctorCommand.self)
|
||||||
|
let setupDesc = descriptor(for: SetupCommand.self)
|
||||||
|
let healthDesc = descriptor(for: HealthCommand.self)
|
||||||
|
let tailLogDesc = descriptor(for: TailLogCommand.self)
|
||||||
|
let startDesc = descriptor(for: StartCommand.self)
|
||||||
|
let stopDesc = descriptor(for: StopCommand.self)
|
||||||
|
let restartDesc = descriptor(for: RestartCommand.self)
|
||||||
|
let statusDesc = descriptor(for: StatusCommand.self)
|
||||||
|
|
||||||
|
let rootSignature = CommandSignature().withStandardRuntimeFlags()
|
||||||
|
let root = CommandDescriptor(
|
||||||
|
name: "swabble",
|
||||||
|
abstract: "Speech hook daemon",
|
||||||
|
discussion: "Local wake-word → SpeechTranscriber → hook",
|
||||||
|
signature: rootSignature,
|
||||||
|
subcommands: [
|
||||||
|
serveDesc,
|
||||||
|
transcribeDesc,
|
||||||
|
testHookDesc,
|
||||||
|
micRoot,
|
||||||
|
serviceRoot,
|
||||||
|
doctorDesc,
|
||||||
|
setupDesc,
|
||||||
|
healthDesc,
|
||||||
|
tailLogDesc,
|
||||||
|
startDesc,
|
||||||
|
stopDesc,
|
||||||
|
restartDesc,
|
||||||
|
statusDesc,
|
||||||
|
])
|
||||||
|
return [root]
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func descriptor(for type: any ParsableCommand.Type) -> CommandDescriptor {
|
||||||
|
let sig = CommandSignature.describe(type.init()).withStandardRuntimeFlags()
|
||||||
|
return CommandDescriptor(
|
||||||
|
name: type.commandDescription.commandName ?? "",
|
||||||
|
abstract: type.commandDescription.abstract,
|
||||||
|
discussion: type.commandDescription.discussion,
|
||||||
|
signature: sig,
|
||||||
|
subcommands: [])
|
||||||
|
}
|
||||||
|
}
|
||||||
37
Swabble/Sources/swabble/Commands/DoctorCommand.swift
Normal file
37
Swabble/Sources/swabble/Commands/DoctorCommand.swift
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import Commander
|
||||||
|
import Foundation
|
||||||
|
import Speech
|
||||||
|
import Swabble
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
struct DoctorCommand: ParsableCommand {
|
||||||
|
static var commandDescription: CommandDescription {
|
||||||
|
CommandDescription(commandName: "doctor", abstract: "Check Speech permission and config")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Option(name: .long("config"), help: "Path to config JSON") var configPath: String?
|
||||||
|
|
||||||
|
init() {}
|
||||||
|
init(parsed: ParsedValues) {
|
||||||
|
self.init()
|
||||||
|
if let cfg = parsed.options["config"]?.last { self.configPath = cfg }
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func run() async throws {
|
||||||
|
let auth = await SFSpeechRecognizer.authorizationStatus()
|
||||||
|
print("Speech auth: \(auth)")
|
||||||
|
do {
|
||||||
|
_ = try ConfigLoader.load(at: self.configURL)
|
||||||
|
print("Config: OK")
|
||||||
|
} catch {
|
||||||
|
print("Config missing or invalid; run setup")
|
||||||
|
}
|
||||||
|
let session = AVCaptureDevice.DiscoverySession(
|
||||||
|
deviceTypes: [.microphone, .external],
|
||||||
|
mediaType: .audio,
|
||||||
|
position: .unspecified)
|
||||||
|
print("Mics found: \(session.devices.count)")
|
||||||
|
}
|
||||||
|
|
||||||
|
private var configURL: URL? { self.configPath.map { URL(fileURLWithPath: $0) } }
|
||||||
|
}
|
||||||
16
Swabble/Sources/swabble/Commands/HealthCommand.swift
Normal file
16
Swabble/Sources/swabble/Commands/HealthCommand.swift
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import Commander
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
struct HealthCommand: ParsableCommand {
|
||||||
|
static var commandDescription: CommandDescription {
|
||||||
|
CommandDescription(commandName: "health", abstract: "Health probe")
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {}
|
||||||
|
init(parsed: ParsedValues) {}
|
||||||
|
|
||||||
|
mutating func run() async throws {
|
||||||
|
print("ok")
|
||||||
|
}
|
||||||
|
}
|
||||||
62
Swabble/Sources/swabble/Commands/MicCommands.swift
Normal file
62
Swabble/Sources/swabble/Commands/MicCommands.swift
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import AVFoundation
|
||||||
|
import Commander
|
||||||
|
import Foundation
|
||||||
|
import Swabble
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
struct MicCommand: ParsableCommand {
|
||||||
|
static var commandDescription: CommandDescription {
|
||||||
|
CommandDescription(
|
||||||
|
commandName: "mic",
|
||||||
|
abstract: "Microphone management",
|
||||||
|
subcommands: [MicList.self, MicSet.self])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
struct MicList: ParsableCommand {
|
||||||
|
static var commandDescription: CommandDescription {
|
||||||
|
CommandDescription(commandName: "list", abstract: "List input devices")
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {}
|
||||||
|
init(parsed: ParsedValues) {}
|
||||||
|
|
||||||
|
mutating func run() async throws {
|
||||||
|
let session = AVCaptureDevice.DiscoverySession(
|
||||||
|
deviceTypes: [.microphone, .external],
|
||||||
|
mediaType: .audio,
|
||||||
|
position: .unspecified)
|
||||||
|
let devices = session.devices
|
||||||
|
if devices.isEmpty { print("no audio inputs found"); return }
|
||||||
|
for (idx, device) in devices.enumerated() {
|
||||||
|
print("[\(idx)] \(device.localizedName)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
struct MicSet: ParsableCommand {
|
||||||
|
@Argument(help: "Device index from list") var index: Int = 0
|
||||||
|
@Option(name: .long("config"), help: "Path to config JSON") var configPath: String?
|
||||||
|
|
||||||
|
static var commandDescription: CommandDescription {
|
||||||
|
CommandDescription(commandName: "set", abstract: "Set default input device index")
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {}
|
||||||
|
init(parsed: ParsedValues) {
|
||||||
|
self.init()
|
||||||
|
if let value = parsed.positional.first, let intVal = Int(value) { self.index = intVal }
|
||||||
|
if let cfg = parsed.options["config"]?.last { self.configPath = cfg }
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func run() async throws {
|
||||||
|
var cfg = try ConfigLoader.load(at: self.configURL)
|
||||||
|
cfg.audio.deviceIndex = self.index
|
||||||
|
try ConfigLoader.save(cfg, at: self.configURL)
|
||||||
|
print("saved device index \(self.index)")
|
||||||
|
}
|
||||||
|
|
||||||
|
private var configURL: URL? { self.configPath.map { URL(fileURLWithPath: $0) } }
|
||||||
|
}
|
||||||
84
Swabble/Sources/swabble/Commands/ServeCommand.swift
Normal file
84
Swabble/Sources/swabble/Commands/ServeCommand.swift
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import Commander
|
||||||
|
import Foundation
|
||||||
|
import Swabble
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
struct ServeCommand: ParsableCommand {
|
||||||
|
@Option(name: .long("config"), help: "Path to config JSON") var configPath: String?
|
||||||
|
@Flag(name: .long("no-wake"), help: "Disable wake word") var noWake: Bool = false
|
||||||
|
|
||||||
|
static var commandDescription: CommandDescription {
|
||||||
|
CommandDescription(
|
||||||
|
commandName: "serve",
|
||||||
|
abstract: "Run swabble in the foreground")
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {}
|
||||||
|
|
||||||
|
init(parsed: ParsedValues) {
|
||||||
|
self.init()
|
||||||
|
if parsed.flags.contains("noWake") { self.noWake = true }
|
||||||
|
if let cfg = parsed.options["config"]?.last { self.configPath = cfg }
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func run() async throws {
|
||||||
|
var cfg: SwabbleConfig
|
||||||
|
do {
|
||||||
|
cfg = try ConfigLoader.load(at: self.configURL)
|
||||||
|
} catch {
|
||||||
|
cfg = SwabbleConfig()
|
||||||
|
try ConfigLoader.save(cfg, at: self.configURL)
|
||||||
|
}
|
||||||
|
if self.noWake {
|
||||||
|
cfg.wake.enabled = false
|
||||||
|
}
|
||||||
|
|
||||||
|
let logger = Logger(level: LogLevel(configValue: cfg.logging.level) ?? .info)
|
||||||
|
logger.info("swabble serve starting (wake: \(cfg.wake.enabled ? cfg.wake.word : "disabled"))")
|
||||||
|
let pipeline = SpeechPipeline()
|
||||||
|
do {
|
||||||
|
let stream = try await pipeline.start(
|
||||||
|
localeIdentifier: cfg.speech.localeIdentifier,
|
||||||
|
etiquette: cfg.speech.etiquetteReplacements)
|
||||||
|
for await seg in stream {
|
||||||
|
if cfg.wake.enabled {
|
||||||
|
guard Self.matchesWake(text: seg.text, cfg: cfg) else { continue }
|
||||||
|
}
|
||||||
|
let stripped = Self.stripWake(text: seg.text, cfg: cfg)
|
||||||
|
let job = HookJob(text: stripped, timestamp: Date())
|
||||||
|
let runner = HookRunner(config: cfg)
|
||||||
|
try await runner.run(job: job)
|
||||||
|
if cfg.transcripts.enabled {
|
||||||
|
await TranscriptsStore.shared.append(text: stripped)
|
||||||
|
}
|
||||||
|
if seg.isFinal {
|
||||||
|
logger.info("final: \(stripped)")
|
||||||
|
} else {
|
||||||
|
logger.debug("partial: \(stripped)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
logger.error("serve error: \(error)")
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var configURL: URL? {
|
||||||
|
self.configPath.map { URL(fileURLWithPath: $0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func matchesWake(text: String, cfg: SwabbleConfig) -> Bool {
|
||||||
|
let lowered = text.lowercased()
|
||||||
|
if lowered.contains(cfg.wake.word.lowercased()) { return true }
|
||||||
|
return cfg.wake.aliases.contains(where: { lowered.contains($0.lowercased()) })
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func stripWake(text: String, cfg: SwabbleConfig) -> String {
|
||||||
|
var out = text
|
||||||
|
out = out.replacingOccurrences(of: cfg.wake.word, with: "", options: [.caseInsensitive])
|
||||||
|
for alias in cfg.wake.aliases {
|
||||||
|
out = out.replacingOccurrences(of: alias, with: "", options: [.caseInsensitive])
|
||||||
|
}
|
||||||
|
return out.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
}
|
||||||
|
}
|
||||||
77
Swabble/Sources/swabble/Commands/ServiceCommands.swift
Normal file
77
Swabble/Sources/swabble/Commands/ServiceCommands.swift
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import Commander
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
struct ServiceRootCommand: ParsableCommand {
|
||||||
|
static var commandDescription: CommandDescription {
|
||||||
|
CommandDescription(
|
||||||
|
commandName: "service",
|
||||||
|
abstract: "Manage launchd agent",
|
||||||
|
subcommands: [ServiceInstall.self, ServiceUninstall.self, ServiceStatus.self])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum LaunchdHelper {
|
||||||
|
static let label = "com.swabble.agent"
|
||||||
|
|
||||||
|
static var plistURL: URL {
|
||||||
|
FileManager.default
|
||||||
|
.homeDirectoryForCurrentUser
|
||||||
|
.appendingPathComponent("Library/LaunchAgents/\(label).plist")
|
||||||
|
}
|
||||||
|
|
||||||
|
static func writePlist(executable: String) throws {
|
||||||
|
let plist: [String: Any] = [
|
||||||
|
"Label": label,
|
||||||
|
"ProgramArguments": [executable, "serve"],
|
||||||
|
"RunAtLoad": true,
|
||||||
|
"KeepAlive": true,
|
||||||
|
]
|
||||||
|
let data = try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0)
|
||||||
|
try data.write(to: self.plistURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func removePlist() throws {
|
||||||
|
try? FileManager.default.removeItem(at: self.plistURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
struct ServiceInstall: ParsableCommand {
|
||||||
|
static var commandDescription: CommandDescription {
|
||||||
|
CommandDescription(commandName: "install", abstract: "Install user launch agent")
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func run() async throws {
|
||||||
|
let exe = CommandLine.arguments.first ?? "/usr/local/bin/swabble"
|
||||||
|
try LaunchdHelper.writePlist(executable: exe)
|
||||||
|
print("launchctl load -w \(LaunchdHelper.plistURL.path)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
struct ServiceUninstall: ParsableCommand {
|
||||||
|
static var commandDescription: CommandDescription {
|
||||||
|
CommandDescription(commandName: "uninstall", abstract: "Remove launch agent")
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func run() async throws {
|
||||||
|
try LaunchdHelper.removePlist()
|
||||||
|
print("launchctl bootout gui/$(id -u)/\(LaunchdHelper.label)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
struct ServiceStatus: ParsableCommand {
|
||||||
|
static var commandDescription: CommandDescription {
|
||||||
|
CommandDescription(commandName: "status", abstract: "Show launch agent status")
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func run() async throws {
|
||||||
|
if FileManager.default.fileExists(atPath: LaunchdHelper.plistURL.path) {
|
||||||
|
print("plist present at \(LaunchdHelper.plistURL.path)")
|
||||||
|
} else {
|
||||||
|
print("launchd plist not installed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
26
Swabble/Sources/swabble/Commands/SetupCommand.swift
Normal file
26
Swabble/Sources/swabble/Commands/SetupCommand.swift
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import Commander
|
||||||
|
import Foundation
|
||||||
|
import Swabble
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
struct SetupCommand: ParsableCommand {
|
||||||
|
static var commandDescription: CommandDescription {
|
||||||
|
CommandDescription(commandName: "setup", abstract: "Write default config")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Option(name: .long("config"), help: "Path to config JSON") var configPath: String?
|
||||||
|
|
||||||
|
init() {}
|
||||||
|
init(parsed: ParsedValues) {
|
||||||
|
self.init()
|
||||||
|
if let cfg = parsed.options["config"]?.last { self.configPath = cfg }
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func run() async throws {
|
||||||
|
let cfg = SwabbleConfig()
|
||||||
|
try ConfigLoader.save(cfg, at: self.configURL)
|
||||||
|
print("wrote config to \(self.configURL?.path ?? SwabbleConfig.defaultPath.path)")
|
||||||
|
}
|
||||||
|
|
||||||
|
private var configURL: URL? { self.configPath.map { URL(fileURLWithPath: $0) } }
|
||||||
|
}
|
||||||
35
Swabble/Sources/swabble/Commands/StartStopCommands.swift
Normal file
35
Swabble/Sources/swabble/Commands/StartStopCommands.swift
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import Commander
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
struct StartCommand: ParsableCommand {
|
||||||
|
static var commandDescription: CommandDescription {
|
||||||
|
CommandDescription(commandName: "start", abstract: "Start swabble (foreground placeholder)")
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func run() async throws {
|
||||||
|
print("start: launchd helper not implemented; run 'swabble serve' instead")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
struct StopCommand: ParsableCommand {
|
||||||
|
static var commandDescription: CommandDescription {
|
||||||
|
CommandDescription(commandName: "stop", abstract: "Stop swabble (placeholder)")
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func run() async throws {
|
||||||
|
print("stop: launchd helper not implemented yet")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
struct RestartCommand: ParsableCommand {
|
||||||
|
static var commandDescription: CommandDescription {
|
||||||
|
CommandDescription(commandName: "restart", abstract: "Restart swabble (placeholder)")
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func run() async throws {
|
||||||
|
print("restart: launchd helper not implemented yet")
|
||||||
|
}
|
||||||
|
}
|
||||||
34
Swabble/Sources/swabble/Commands/StatusCommand.swift
Normal file
34
Swabble/Sources/swabble/Commands/StatusCommand.swift
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import Commander
|
||||||
|
import Foundation
|
||||||
|
import Swabble
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
struct StatusCommand: ParsableCommand {
|
||||||
|
static var commandDescription: CommandDescription {
|
||||||
|
CommandDescription(commandName: "status", abstract: "Show daemon state")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Option(name: .long("config"), help: "Path to config JSON") var configPath: String?
|
||||||
|
|
||||||
|
init() {}
|
||||||
|
init(parsed: ParsedValues) {
|
||||||
|
self.init()
|
||||||
|
if let cfg = parsed.options["config"]?.last { self.configPath = cfg }
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func run() async throws {
|
||||||
|
let cfg = try? ConfigLoader.load(at: self.configURL)
|
||||||
|
let wake = cfg?.wake.word ?? "clawd"
|
||||||
|
let wakeEnabled = cfg?.wake.enabled ?? false
|
||||||
|
let latest = await TranscriptsStore.shared.latest().suffix(3)
|
||||||
|
print("wake: \(wakeEnabled ? wake : "disabled")")
|
||||||
|
if latest.isEmpty {
|
||||||
|
print("transcripts: (none yet)")
|
||||||
|
} else {
|
||||||
|
print("last transcripts:")
|
||||||
|
latest.forEach { print("- \($0)") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var configURL: URL? { self.configPath.map { URL(fileURLWithPath: $0) } }
|
||||||
|
}
|
||||||
20
Swabble/Sources/swabble/Commands/TailLogCommand.swift
Normal file
20
Swabble/Sources/swabble/Commands/TailLogCommand.swift
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import Commander
|
||||||
|
import Foundation
|
||||||
|
import Swabble
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
struct TailLogCommand: ParsableCommand {
|
||||||
|
static var commandDescription: CommandDescription {
|
||||||
|
CommandDescription(commandName: "tail-log", abstract: "Tail recent transcripts")
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {}
|
||||||
|
init(parsed: ParsedValues) {}
|
||||||
|
|
||||||
|
mutating func run() async throws {
|
||||||
|
let latest = await TranscriptsStore.shared.latest()
|
||||||
|
for line in latest.suffix(10) {
|
||||||
|
print(line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
30
Swabble/Sources/swabble/Commands/TestHookCommand.swift
Normal file
30
Swabble/Sources/swabble/Commands/TestHookCommand.swift
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import Commander
|
||||||
|
import Foundation
|
||||||
|
import Swabble
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
struct TestHookCommand: ParsableCommand {
|
||||||
|
@Argument(help: "Text to send to hook") var text: String
|
||||||
|
@Option(name: .long("config"), help: "Path to config JSON") var configPath: String?
|
||||||
|
|
||||||
|
static var commandDescription: CommandDescription {
|
||||||
|
CommandDescription(commandName: "test-hook", abstract: "Invoke the configured hook with text")
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {}
|
||||||
|
|
||||||
|
init(parsed: ParsedValues) {
|
||||||
|
self.init()
|
||||||
|
if let positional = parsed.positional.first { self.text = positional }
|
||||||
|
if let cfg = parsed.options["config"]?.last { self.configPath = cfg }
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func run() async throws {
|
||||||
|
let cfg = try ConfigLoader.load(at: self.configURL)
|
||||||
|
let runner = HookRunner(config: cfg)
|
||||||
|
try await runner.run(job: HookJob(text: self.text, timestamp: Date()))
|
||||||
|
print("hook invoked")
|
||||||
|
}
|
||||||
|
|
||||||
|
private var configURL: URL? { self.configPath.map { URL(fileURLWithPath: $0) } }
|
||||||
|
}
|
||||||
61
Swabble/Sources/swabble/Commands/TranscribeCommand.swift
Normal file
61
Swabble/Sources/swabble/Commands/TranscribeCommand.swift
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import AVFoundation
|
||||||
|
import Commander
|
||||||
|
import Foundation
|
||||||
|
import Speech
|
||||||
|
import Swabble
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
struct TranscribeCommand: ParsableCommand {
|
||||||
|
@Argument(help: "Path to audio/video file") var inputFile: String = ""
|
||||||
|
@Option(name: .long("locale"), help: "Locale identifier", parsing: .singleValue) var locale: String = Locale.current
|
||||||
|
.identifier
|
||||||
|
@Flag(help: "Censor etiquette-sensitive content") var censor: Bool = false
|
||||||
|
@Option(name: .long("output"), help: "Output file path") var outputFile: String?
|
||||||
|
@Option(name: .long("format"), help: "Output format txt|srt") var format: String = "txt"
|
||||||
|
@Option(name: .long("max-length"), help: "Max sentence length for srt") var maxLength: Int = 40
|
||||||
|
|
||||||
|
static var commandDescription: CommandDescription {
|
||||||
|
CommandDescription(
|
||||||
|
commandName: "transcribe",
|
||||||
|
abstract: "Transcribe a media file locally")
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {}
|
||||||
|
|
||||||
|
init(parsed: ParsedValues) {
|
||||||
|
self.init()
|
||||||
|
if let positional = parsed.positional.first { self.inputFile = positional }
|
||||||
|
if let loc = parsed.options["locale"]?.last { self.locale = loc }
|
||||||
|
if parsed.flags.contains("censor") { self.censor = true }
|
||||||
|
if let out = parsed.options["output"]?.last { self.outputFile = out }
|
||||||
|
if let fmt = parsed.options["format"]?.last { self.format = fmt }
|
||||||
|
if let len = parsed.options["maxLength"]?.last, let intVal = Int(len) { self.maxLength = intVal }
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func run() async throws {
|
||||||
|
let fileURL = URL(fileURLWithPath: inputFile)
|
||||||
|
let audioFile = try AVAudioFile(forReading: fileURL)
|
||||||
|
|
||||||
|
let outputFormat = OutputFormat(rawValue: format) ?? .txt
|
||||||
|
|
||||||
|
let transcriber = SpeechTranscriber(
|
||||||
|
locale: Locale(identifier: locale),
|
||||||
|
transcriptionOptions: censor ? [.etiquetteReplacements] : [],
|
||||||
|
reportingOptions: [],
|
||||||
|
attributeOptions: outputFormat.needsAudioTimeRange ? [.audioTimeRange] : [])
|
||||||
|
let analyzer = SpeechAnalyzer(modules: [transcriber])
|
||||||
|
try await analyzer.start(inputAudioFile: audioFile, finishAfterFile: true)
|
||||||
|
|
||||||
|
var transcript: AttributedString = ""
|
||||||
|
for try await result in transcriber.results {
|
||||||
|
transcript += result.text
|
||||||
|
}
|
||||||
|
|
||||||
|
let output = outputFormat.text(for: transcript, maxLength: self.maxLength)
|
||||||
|
if let path = outputFile {
|
||||||
|
try output.write(to: URL(fileURLWithPath: path), atomically: false, encoding: .utf8)
|
||||||
|
} else {
|
||||||
|
print(output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
99
Swabble/Sources/swabble/main.swift
Normal file
99
Swabble/Sources/swabble/main.swift
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import Commander
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func runCLI() async -> Int32 {
|
||||||
|
do {
|
||||||
|
let descriptors = CLIRegistry.descriptors
|
||||||
|
let program = Program(descriptors: descriptors)
|
||||||
|
let invocation = try program.resolve(argv: CommandLine.arguments)
|
||||||
|
try await dispatch(invocation: invocation)
|
||||||
|
return 0
|
||||||
|
} catch {
|
||||||
|
fputs("error: \(error)\n", stderr)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func dispatch(invocation: CommandInvocation) async throws {
|
||||||
|
let parsed = invocation.parsedValues
|
||||||
|
let path = invocation.path
|
||||||
|
guard let first = path.first else { throw CommanderProgramError.missingCommand }
|
||||||
|
|
||||||
|
switch first {
|
||||||
|
case "swabble":
|
||||||
|
guard path.count >= 2 else { throw CommanderProgramError.missingSubcommand(command: "swabble") }
|
||||||
|
let sub = path[1]
|
||||||
|
switch sub {
|
||||||
|
case "serve":
|
||||||
|
var cmd = ServeCommand(parsed: parsed)
|
||||||
|
try await cmd.run()
|
||||||
|
case "transcribe":
|
||||||
|
var cmd = TranscribeCommand(parsed: parsed)
|
||||||
|
try await cmd.run()
|
||||||
|
case "test-hook":
|
||||||
|
var cmd = TestHookCommand(parsed: parsed)
|
||||||
|
try await cmd.run()
|
||||||
|
case "mic":
|
||||||
|
guard path.count >= 3 else { throw CommanderProgramError.missingSubcommand(command: "mic") }
|
||||||
|
let micSub = path[2]
|
||||||
|
if micSub == "list" {
|
||||||
|
var cmd = MicList(parsed: parsed)
|
||||||
|
try await cmd.run()
|
||||||
|
} else if micSub == "set" {
|
||||||
|
var cmd = MicSet(parsed: parsed)
|
||||||
|
try await cmd.run()
|
||||||
|
} else {
|
||||||
|
throw CommanderProgramError.unknownSubcommand(command: "mic", name: micSub)
|
||||||
|
}
|
||||||
|
case "service":
|
||||||
|
guard path.count >= 3 else { throw CommanderProgramError.missingSubcommand(command: "service") }
|
||||||
|
let svcSub = path[2]
|
||||||
|
switch svcSub {
|
||||||
|
case "install":
|
||||||
|
var cmd = ServiceInstall()
|
||||||
|
try await cmd.run()
|
||||||
|
case "uninstall":
|
||||||
|
var cmd = ServiceUninstall()
|
||||||
|
try await cmd.run()
|
||||||
|
case "status":
|
||||||
|
var cmd = ServiceStatus()
|
||||||
|
try await cmd.run()
|
||||||
|
default:
|
||||||
|
throw CommanderProgramError.unknownSubcommand(command: "service", name: svcSub)
|
||||||
|
}
|
||||||
|
case "doctor":
|
||||||
|
var cmd = DoctorCommand(parsed: parsed)
|
||||||
|
try await cmd.run()
|
||||||
|
case "setup":
|
||||||
|
var cmd = SetupCommand(parsed: parsed)
|
||||||
|
try await cmd.run()
|
||||||
|
case "health":
|
||||||
|
var cmd = HealthCommand(parsed: parsed)
|
||||||
|
try await cmd.run()
|
||||||
|
case "tail-log":
|
||||||
|
var cmd = TailLogCommand(parsed: parsed)
|
||||||
|
try await cmd.run()
|
||||||
|
case "start":
|
||||||
|
var cmd = StartCommand()
|
||||||
|
try await cmd.run()
|
||||||
|
case "stop":
|
||||||
|
var cmd = StopCommand()
|
||||||
|
try await cmd.run()
|
||||||
|
case "restart":
|
||||||
|
var cmd = RestartCommand()
|
||||||
|
try await cmd.run()
|
||||||
|
case "status":
|
||||||
|
var cmd = StatusCommand()
|
||||||
|
try await cmd.run()
|
||||||
|
default:
|
||||||
|
throw CommanderProgramError.unknownSubcommand(command: "swabble", name: sub)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
throw CommanderProgramError.unknownCommand(first)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let exitCode = await runCLI()
|
||||||
|
exit(exitCode)
|
||||||
23
Swabble/Tests/swabbleTests/ConfigTests.swift
Normal file
23
Swabble/Tests/swabbleTests/ConfigTests.swift
Normal file
@@ -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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
32
Swabble/docs/spec.md
Normal file
32
Swabble/docs/spec.md
Normal file
@@ -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.
|
||||||
10
Swabble/scripts/format.sh
Executable file
10
Swabble/scripts/format.sh
Executable file
@@ -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"
|
||||||
14
Swabble/scripts/lint.sh
Executable file
14
Swabble/scripts/lint.sh
Executable file
@@ -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"
|
||||||
@@ -44,7 +44,9 @@ cat > "$APP_ROOT/Contents/Info.plist" <<'PLIST'
|
|||||||
<key>NSScreenCaptureDescription</key>
|
<key>NSScreenCaptureDescription</key>
|
||||||
<string>Clawdis captures the screen when the agent needs screenshots for context.</string>
|
<string>Clawdis captures the screen when the agent needs screenshots for context.</string>
|
||||||
<key>NSMicrophoneUsageDescription</key>
|
<key>NSMicrophoneUsageDescription</key>
|
||||||
<string>Clawdis may record screen or audio when requested by the agent.</string>
|
<string>Clawdis needs the mic for Voice Wake tests and agent audio capture.</string>
|
||||||
|
<key>NSSpeechRecognitionUsageDescription</key>
|
||||||
|
<string>Clawdis uses speech recognition to detect your Voice Wake trigger phrase.</string>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
PLIST
|
PLIST
|
||||||
|
|||||||
Reference in New Issue
Block a user