refactor: split Swabble core module
Some checks failed
CI / build-and-test (push) Has been cancelled
Some checks failed
CI / build-and-test (push) Has been cancelled
This commit is contained in:
parent
07c5b709a4
commit
31d06e095f
54
.github/workflows/ci.yml
vendored
Normal file
54
.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
|
||||
@ -1,6 +1,15 @@
|
||||
{
|
||||
"originHash" : "b658624653b4e0bfcd81b3b9820842c95489a766037392e1cedb2d677c61fb3f",
|
||||
"originHash" : "3018b2c8c183d55b57ad0c4526b2380ac3b957d13a3a86e1b2845e81323c443a",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "commander",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/steipete/Commander.git",
|
||||
"state" : {
|
||||
"revision" : "8b8cb4f34315ce9e5307b3a2bcd77ff73f586a02",
|
||||
"version" : "0.2.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-syntax",
|
||||
"kind" : "remoteSourceControl",
|
||||
|
||||
@ -6,21 +6,30 @@ let package = Package(
|
||||
platforms: [
|
||||
.macOS(.v26),
|
||||
],
|
||||
products: [
|
||||
.library(name: "Swabble", targets: ["Swabble"]),
|
||||
.executable(name: "swabble", targets: ["SwabbleCLI"]),
|
||||
],
|
||||
dependencies: [
|
||||
.package(path: "../Peekaboo/Commander"),
|
||||
.package(url: "https://github.com/steipete/Commander.git", from: "0.2.0"),
|
||||
.package(url: "https://github.com/apple/swift-testing", from: "0.99.0"),
|
||||
],
|
||||
targets: [
|
||||
.target(
|
||||
name: "Swabble",
|
||||
path: "Sources/SwabbleCore",
|
||||
swiftSettings: []),
|
||||
.executableTarget(
|
||||
name: "swabble",
|
||||
name: "SwabbleCLI",
|
||||
dependencies: [
|
||||
"Swabble",
|
||||
.product(name: "Commander", package: "Commander"),
|
||||
],
|
||||
path: "Sources"),
|
||||
path: "Sources/swabble"),
|
||||
.testTarget(
|
||||
name: "swabbleTests",
|
||||
dependencies: [
|
||||
"swabble",
|
||||
"Swabble",
|
||||
.product(name: "Testing", package: "swift-testing"),
|
||||
]),
|
||||
],
|
||||
|
||||
13
README.md
13
README.md
@ -29,6 +29,19 @@ swift run swabble test-hook "hello world"
|
||||
swift run swabble transcribe /path/to/audio.m4a --format srt --output out.srt
|
||||
```
|
||||
|
||||
## Use as a library
|
||||
Add swabble as a SwiftPM dependency and import the `Swabble` product to reuse the Speech pipeline, config loader, hook runner, and transcript store in your own app:
|
||||
|
||||
```swift
|
||||
// Package.swift
|
||||
dependencies: [
|
||||
.package(url: "https://github.com/steipete/swabble.git", branch: "main"),
|
||||
],
|
||||
targets: [
|
||||
.target(name: "MyApp", dependencies: [.product(name: "Swabble", package: "swabble")]),
|
||||
]
|
||||
```
|
||||
|
||||
## CLI
|
||||
- `serve` — foreground loop (mic → wake → hook)
|
||||
- `transcribe <file>` — offline transcription (txt|srt)
|
||||
|
||||
77
Sources/SwabbleCore/Config/Config.swift
Normal file
77
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)
|
||||
}
|
||||
}
|
||||
@ -1,43 +1,53 @@
|
||||
import Foundation
|
||||
|
||||
struct HookJob: Sendable {
|
||||
let text: String
|
||||
let timestamp: Date
|
||||
public struct HookJob: Sendable {
|
||||
public let text: String
|
||||
public let timestamp: Date
|
||||
|
||||
public init(text: String, timestamp: Date) {
|
||||
self.text = text
|
||||
self.timestamp = timestamp
|
||||
}
|
||||
}
|
||||
|
||||
actor HookRunner {
|
||||
public actor HookRunner {
|
||||
private let config: SwabbleConfig
|
||||
private var lastRun: Date?
|
||||
private let hostname: String
|
||||
|
||||
init(config: SwabbleConfig) {
|
||||
public init(config: SwabbleConfig) {
|
||||
self.config = config
|
||||
self.hostname = Host.current().localizedName ?? "host"
|
||||
}
|
||||
|
||||
func shouldRun() -> Bool {
|
||||
guard config.hook.cooldownSeconds > 0 else { return true }
|
||||
public func shouldRun() -> Bool {
|
||||
guard self.config.hook.cooldownSeconds > 0 else { return true }
|
||||
if let lastRun, Date().timeIntervalSince(lastRun) < config.hook.cooldownSeconds {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func run(job: HookJob) async throws {
|
||||
guard shouldRun() else { return }
|
||||
guard !config.hook.command.isEmpty else { throw NSError(domain: "Hook", code: 1, userInfo: [NSLocalizedDescriptionKey: "hook command not set"]) }
|
||||
public func run(job: HookJob) async throws {
|
||||
guard self.shouldRun() else { return }
|
||||
guard !self.config.hook.command.isEmpty else { throw NSError(
|
||||
domain: "Hook",
|
||||
code: 1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "hook command not set"]) }
|
||||
|
||||
let prefix = config.hook.prefix.replacingOccurrences(of: "${hostname}", with: hostname)
|
||||
let prefix = self.config.hook.prefix.replacingOccurrences(of: "${hostname}", with: self.hostname)
|
||||
let payload = prefix + job.text
|
||||
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: config.hook.command)
|
||||
process.arguments = config.hook.args + [payload]
|
||||
process.executableURL = URL(fileURLWithPath: self.config.hook.command)
|
||||
process.arguments = self.config.hook.args + [payload]
|
||||
|
||||
var env = ProcessInfo.processInfo.environment
|
||||
env["SWABBLE_TEXT"] = job.text
|
||||
env["SWABBLE_PREFIX"] = prefix
|
||||
for (k, v) in config.hook.env { env[k] = v }
|
||||
for (k, v) in self.config.hook.env {
|
||||
env[k] = v
|
||||
}
|
||||
process.environment = env
|
||||
|
||||
let pipe = Pipe()
|
||||
@ -60,6 +70,6 @@ actor HookRunner {
|
||||
try await group.next()
|
||||
group.cancelAll()
|
||||
}
|
||||
lastRun = Date()
|
||||
self.lastRun = Date()
|
||||
}
|
||||
}
|
||||
@ -25,7 +25,8 @@ final class BufferConverter {
|
||||
let sampleRateRatio = converter.outputFormat.sampleRate / converter.inputFormat.sampleRate
|
||||
let scaledInputFrameLength = Double(buffer.frameLength) * sampleRateRatio
|
||||
let frameCapacity = AVAudioFrameCount(scaledInputFrameLength.rounded(.up))
|
||||
guard let conversionBuffer = AVAudioPCMBuffer(pcmFormat: converter.outputFormat, frameCapacity: frameCapacity) else {
|
||||
guard let conversionBuffer = AVAudioPCMBuffer(pcmFormat: converter.outputFormat, frameCapacity: frameCapacity)
|
||||
else {
|
||||
throw ConverterError.failedToCreateConversionBuffer
|
||||
}
|
||||
|
||||
@ -2,19 +2,19 @@ import AVFoundation
|
||||
import Foundation
|
||||
import Speech
|
||||
|
||||
struct SpeechSegment: Sendable {
|
||||
let text: String
|
||||
let isFinal: Bool
|
||||
public struct SpeechSegment: Sendable {
|
||||
public let text: String
|
||||
public let isFinal: Bool
|
||||
}
|
||||
|
||||
enum SpeechPipelineError: Error {
|
||||
public enum SpeechPipelineError: Error {
|
||||
case authorizationDenied
|
||||
case analyzerFormatUnavailable
|
||||
case transcriberUnavailable
|
||||
}
|
||||
|
||||
/// Live microphone → SpeechAnalyzer → SpeechTranscriber pipeline.
|
||||
actor SpeechPipeline {
|
||||
public actor SpeechPipeline {
|
||||
private struct UnsafeBuffer: @unchecked Sendable { let buffer: AVAudioPCMBuffer }
|
||||
|
||||
private var engine = AVAudioEngine()
|
||||
@ -24,7 +24,9 @@ actor SpeechPipeline {
|
||||
private var resultTask: Task<Void, Never>?
|
||||
private let converter = BufferConverter()
|
||||
|
||||
func start(localeIdentifier: String, etiquette: Bool) async throws -> AsyncStream<SpeechSegment> {
|
||||
public init() {}
|
||||
|
||||
public func start(localeIdentifier: String, etiquette: Bool) async throws -> AsyncStream<SpeechSegment> {
|
||||
let auth = await requestAuthorizationIfNeeded()
|
||||
guard auth == .authorized else { throw SpeechPipelineError.authorizationDenied }
|
||||
|
||||
@ -35,15 +37,16 @@ actor SpeechPipeline {
|
||||
attributeOptions: [])
|
||||
self.transcriber = transcriberModule
|
||||
|
||||
guard let analyzerFormat = await SpeechAnalyzer.bestAvailableAudioFormat(compatibleWith: [transcriberModule]) else {
|
||||
guard let analyzerFormat = await SpeechAnalyzer.bestAvailableAudioFormat(compatibleWith: [transcriberModule])
|
||||
else {
|
||||
throw SpeechPipelineError.analyzerFormatUnavailable
|
||||
}
|
||||
|
||||
analyzer = SpeechAnalyzer(modules: [transcriberModule])
|
||||
self.analyzer = SpeechAnalyzer(modules: [transcriberModule])
|
||||
let (stream, continuation) = AsyncStream<AnalyzerInput>.makeStream()
|
||||
inputContinuation = continuation
|
||||
self.inputContinuation = continuation
|
||||
|
||||
let inputNode = engine.inputNode
|
||||
let inputNode = self.engine.inputNode
|
||||
let inputFormat = inputNode.outputFormat(forBus: 0)
|
||||
inputNode.removeTap(onBus: 0)
|
||||
inputNode.installTap(onBus: 0, bufferSize: 2048, format: inputFormat) { [weak self] buffer, _ in
|
||||
@ -52,16 +55,16 @@ actor SpeechPipeline {
|
||||
Task { await self.handleBuffer(boxed.buffer, targetFormat: analyzerFormat) }
|
||||
}
|
||||
|
||||
engine.prepare()
|
||||
try engine.start()
|
||||
try await analyzer?.start(inputSequence: stream)
|
||||
self.engine.prepare()
|
||||
try self.engine.start()
|
||||
try await self.analyzer?.start(inputSequence: stream)
|
||||
|
||||
guard let transcriberForStream = self.transcriber else {
|
||||
throw SpeechPipelineError.transcriberUnavailable
|
||||
}
|
||||
|
||||
return AsyncStream { continuation in
|
||||
resultTask = Task {
|
||||
self.resultTask = Task {
|
||||
do {
|
||||
for try await result in transcriberForStream.results {
|
||||
let seg = SpeechSegment(text: String(result.text.characters), isFinal: result.isFinal)
|
||||
@ -78,19 +81,19 @@ actor SpeechPipeline {
|
||||
}
|
||||
}
|
||||
|
||||
func stop() async {
|
||||
resultTask?.cancel()
|
||||
inputContinuation?.finish()
|
||||
engine.inputNode.removeTap(onBus: 0)
|
||||
engine.stop()
|
||||
try? await analyzer?.finalizeAndFinishThroughEndOfInput()
|
||||
public func stop() async {
|
||||
self.resultTask?.cancel()
|
||||
self.inputContinuation?.finish()
|
||||
self.engine.inputNode.removeTap(onBus: 0)
|
||||
self.engine.stop()
|
||||
try? await self.analyzer?.finalizeAndFinishThroughEndOfInput()
|
||||
}
|
||||
|
||||
private func handleBuffer(_ buffer: AVAudioPCMBuffer, targetFormat: AVAudioFormat) async {
|
||||
do {
|
||||
let converted = try converter.convert(buffer, to: targetFormat)
|
||||
let input = AnalyzerInput(buffer: converted)
|
||||
inputContinuation?.yield(input)
|
||||
self.inputContinuation?.yield(input)
|
||||
} catch {
|
||||
// drop on conversion failure
|
||||
}
|
||||
@ -3,7 +3,7 @@ import Foundation
|
||||
import NaturalLanguage
|
||||
|
||||
extension AttributedString {
|
||||
func sentences(maxLength: Int? = nil) -> [AttributedString] {
|
||||
public func sentences(maxLength: Int? = nil) -> [AttributedString] {
|
||||
let tokenizer = NLTokenizer(unit: .sentence)
|
||||
let string = String(characters)
|
||||
tokenizer.string = string
|
||||
@ -12,8 +12,7 @@ extension AttributedString {
|
||||
$0,
|
||||
AttributedString.Index($0.lowerBound, within: self)!
|
||||
..<
|
||||
AttributedString.Index($0.upperBound, within: self)!
|
||||
)
|
||||
AttributedString.Index($0.upperBound, within: self)!)
|
||||
}
|
||||
let ranges = sentenceRanges.flatMap { sentenceStringRange, sentenceRange in
|
||||
let sentence = self[sentenceRange]
|
||||
@ -57,8 +56,7 @@ extension AttributedString {
|
||||
var attributes = AttributeContainer()
|
||||
attributes[AttributeScopes.SpeechAttributes.TimeRangeAttribute.self] = CMTimeRange(
|
||||
start: start,
|
||||
end: end
|
||||
)
|
||||
end: end)
|
||||
return AttributedString(self[range].characters, attributes: attributes)
|
||||
}
|
||||
}
|
||||
41
Sources/SwabbleCore/Support/Logging.swift
Normal file
41
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())
|
||||
}
|
||||
}
|
||||
@ -1,18 +1,18 @@
|
||||
import CoreMedia
|
||||
import Foundation
|
||||
|
||||
enum OutputFormat: String {
|
||||
public enum OutputFormat: String {
|
||||
case txt
|
||||
case srt
|
||||
|
||||
var needsAudioTimeRange: Bool {
|
||||
public var needsAudioTimeRange: Bool {
|
||||
switch self {
|
||||
case .srt: true
|
||||
default: false
|
||||
}
|
||||
}
|
||||
|
||||
func text(for transcript: AttributedString, maxLength: Int) -> String {
|
||||
public func text(for transcript: AttributedString, maxLength: Int) -> String {
|
||||
switch self {
|
||||
case .txt:
|
||||
return String(transcript.characters)
|
||||
@ -25,7 +25,9 @@ enum OutputFormat: String {
|
||||
return String(format: "%0.2d:%0.2d:%0.2d,%0.3d", h, m, s, ms)
|
||||
}
|
||||
|
||||
return transcript.sentences(maxLength: maxLength).compactMap { (sentence: AttributedString) -> (CMTimeRange, String)? in
|
||||
return transcript.sentences(maxLength: maxLength).compactMap { (sentence: AttributedString) -> (
|
||||
CMTimeRange,
|
||||
String)? in
|
||||
guard let timeRange = sentence.audioTimeRange else { return nil }
|
||||
return (timeRange, String(sentence.characters))
|
||||
}.enumerated().map { index, run in
|
||||
@ -1,40 +1,38 @@
|
||||
import Foundation
|
||||
|
||||
actor TranscriptsStore {
|
||||
static let shared = TranscriptsStore()
|
||||
public actor TranscriptsStore {
|
||||
public static let shared = TranscriptsStore()
|
||||
|
||||
private var entries: [String] = []
|
||||
private let limit = 100
|
||||
private let fileURL: URL
|
||||
|
||||
init() {
|
||||
public init() {
|
||||
let dir = FileManager.default.homeDirectoryForCurrentUser
|
||||
.appendingPathComponent("Library/Application Support/swabble", isDirectory: true)
|
||||
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||
fileURL = dir.appendingPathComponent("transcripts.log")
|
||||
self.fileURL = dir.appendingPathComponent("transcripts.log")
|
||||
if let data = try? Data(contentsOf: fileURL),
|
||||
let text = String(data: data, encoding: .utf8)
|
||||
{
|
||||
entries = text.split(separator: "\n").map(String.init).suffix(limit)
|
||||
self.entries = text.split(separator: "\n").map(String.init).suffix(self.limit)
|
||||
}
|
||||
}
|
||||
|
||||
func append(text: String) {
|
||||
entries.append(text)
|
||||
if entries.count > limit {
|
||||
entries.removeFirst(entries.count - limit)
|
||||
public func append(text: String) {
|
||||
self.entries.append(text)
|
||||
if self.entries.count > self.limit {
|
||||
self.entries.removeFirst(self.entries.count - self.limit)
|
||||
}
|
||||
let body = entries.joined(separator: "\n")
|
||||
try? body.write(to: fileURL, atomically: false, encoding: .utf8)
|
||||
let body = self.entries.joined(separator: "\n")
|
||||
try? body.write(to: self.fileURL, atomically: false, encoding: .utf8)
|
||||
}
|
||||
|
||||
func latest() -> [String] {
|
||||
entries
|
||||
}
|
||||
public func latest() -> [String] { self.entries }
|
||||
}
|
||||
|
||||
private extension String {
|
||||
func appendLine(to url: URL) throws {
|
||||
extension String {
|
||||
private func appendLine(to url: URL) throws {
|
||||
let data = (self + "\n").data(using: .utf8) ?? Data()
|
||||
if FileManager.default.fileExists(atPath: url.path) {
|
||||
let handle = try FileHandle(forWritingTo: url)
|
||||
@ -1,6 +1,7 @@
|
||||
import Commander
|
||||
import Foundation
|
||||
import Speech
|
||||
import Swabble
|
||||
|
||||
@MainActor
|
||||
struct DoctorCommand: ParsableCommand {
|
||||
@ -13,14 +14,14 @@ struct DoctorCommand: ParsableCommand {
|
||||
init() {}
|
||||
init(parsed: ParsedValues) {
|
||||
self.init()
|
||||
if let cfg = parsed.options["config"]?.last { configPath = cfg }
|
||||
if let cfg = parsed.options["config"]?.last { self.configPath = cfg }
|
||||
}
|
||||
|
||||
mutating func run() async throws {
|
||||
let auth = await SFSpeechRecognizer.authorizationStatus()
|
||||
print("Speech auth: \(auth)")
|
||||
do {
|
||||
_ = try ConfigLoader.load(at: configURL)
|
||||
_ = try ConfigLoader.load(at: self.configURL)
|
||||
print("Config: OK")
|
||||
} catch {
|
||||
print("Config missing or invalid; run setup")
|
||||
@ -32,5 +33,5 @@ struct DoctorCommand: ParsableCommand {
|
||||
print("Mics found: \(session.devices.count)")
|
||||
}
|
||||
|
||||
private var configURL: URL? { configPath.map { URL(fileURLWithPath: $0) } }
|
||||
private var configURL: URL? { self.configPath.map { URL(fileURLWithPath: $0) } }
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import AVFoundation
|
||||
import Commander
|
||||
import Foundation
|
||||
import Swabble
|
||||
|
||||
@MainActor
|
||||
struct MicCommand: ParsableCommand {
|
||||
@ -46,16 +47,16 @@ struct MicSet: ParsableCommand {
|
||||
init() {}
|
||||
init(parsed: ParsedValues) {
|
||||
self.init()
|
||||
if let value = parsed.positional.first, let intVal = Int(value) { index = intVal }
|
||||
if let cfg = parsed.options["config"]?.last { configPath = cfg }
|
||||
if let value = parsed.positional.first, let intVal = Int(value) { self.index = intVal }
|
||||
if let cfg = parsed.options["config"]?.last { self.configPath = cfg }
|
||||
}
|
||||
|
||||
mutating func run() async throws {
|
||||
var cfg = try ConfigLoader.load(at: configURL)
|
||||
cfg.audio.deviceIndex = index
|
||||
try ConfigLoader.save(cfg, at: configURL)
|
||||
print("saved device index \(index)")
|
||||
var cfg = try ConfigLoader.load(at: self.configURL)
|
||||
cfg.audio.deviceIndex = self.index
|
||||
try ConfigLoader.save(cfg, at: self.configURL)
|
||||
print("saved device index \(self.index)")
|
||||
}
|
||||
|
||||
private var configURL: URL? { configPath.map { URL(fileURLWithPath: $0) } }
|
||||
private var configURL: URL? { self.configPath.map { URL(fileURLWithPath: $0) } }
|
||||
}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import Commander
|
||||
import Foundation
|
||||
import Swabble
|
||||
|
||||
@MainActor
|
||||
struct ServeCommand: ParsableCommand {
|
||||
@ -16,19 +17,19 @@ struct ServeCommand: ParsableCommand {
|
||||
|
||||
init(parsed: ParsedValues) {
|
||||
self.init()
|
||||
if parsed.flags.contains("noWake") { noWake = true }
|
||||
if let cfg = parsed.options["config"]?.last { configPath = cfg }
|
||||
if parsed.flags.contains("noWake") { self.noWake = true }
|
||||
if let cfg = parsed.options["config"]?.last { self.configPath = cfg }
|
||||
}
|
||||
|
||||
mutating func run() async throws {
|
||||
var cfg: SwabbleConfig
|
||||
do {
|
||||
cfg = try ConfigLoader.load(at: configURL)
|
||||
cfg = try ConfigLoader.load(at: self.configURL)
|
||||
} catch {
|
||||
cfg = SwabbleConfig()
|
||||
try ConfigLoader.save(cfg, at: configURL)
|
||||
try ConfigLoader.save(cfg, at: self.configURL)
|
||||
}
|
||||
if noWake {
|
||||
if self.noWake {
|
||||
cfg.wake.enabled = false
|
||||
}
|
||||
|
||||
@ -36,7 +37,9 @@ struct ServeCommand: ParsableCommand {
|
||||
logger.info("swabble serve starting (wake: \(cfg.wake.enabled ? cfg.wake.word : "disabled"))")
|
||||
let pipeline = SpeechPipeline()
|
||||
do {
|
||||
let stream = try await pipeline.start(localeIdentifier: cfg.speech.localeIdentifier, etiquette: cfg.speech.etiquetteReplacements)
|
||||
let stream = try await pipeline.start(
|
||||
localeIdentifier: cfg.speech.localeIdentifier,
|
||||
etiquette: cfg.speech.etiquetteReplacements)
|
||||
for await seg in stream {
|
||||
if cfg.wake.enabled {
|
||||
guard Self.matchesWake(text: seg.text, cfg: cfg) else { continue }
|
||||
@ -61,7 +64,7 @@ struct ServeCommand: ParsableCommand {
|
||||
}
|
||||
|
||||
private var configURL: URL? {
|
||||
configPath.map { URL(fileURLWithPath: $0) }
|
||||
self.configPath.map { URL(fileURLWithPath: $0) }
|
||||
}
|
||||
|
||||
private static func matchesWake(text: String, cfg: SwabbleConfig) -> Bool {
|
||||
|
||||
@ -28,11 +28,11 @@ private enum LaunchdHelper {
|
||||
"KeepAlive": true,
|
||||
]
|
||||
let data = try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0)
|
||||
try data.write(to: plistURL)
|
||||
try data.write(to: self.plistURL)
|
||||
}
|
||||
|
||||
static func removePlist() throws {
|
||||
try? FileManager.default.removeItem(at: plistURL)
|
||||
try? FileManager.default.removeItem(at: self.plistURL)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import Commander
|
||||
import Foundation
|
||||
import Swabble
|
||||
|
||||
@MainActor
|
||||
struct SetupCommand: ParsableCommand {
|
||||
@ -12,14 +13,14 @@ struct SetupCommand: ParsableCommand {
|
||||
init() {}
|
||||
init(parsed: ParsedValues) {
|
||||
self.init()
|
||||
if let cfg = parsed.options["config"]?.last { configPath = cfg }
|
||||
if let cfg = parsed.options["config"]?.last { self.configPath = cfg }
|
||||
}
|
||||
|
||||
mutating func run() async throws {
|
||||
let cfg = SwabbleConfig()
|
||||
try ConfigLoader.save(cfg, at: configURL)
|
||||
print("wrote config to \(configURL?.path ?? SwabbleConfig.defaultPath.path)")
|
||||
try ConfigLoader.save(cfg, at: self.configURL)
|
||||
print("wrote config to \(self.configURL?.path ?? SwabbleConfig.defaultPath.path)")
|
||||
}
|
||||
|
||||
private var configURL: URL? { configPath.map { URL(fileURLWithPath: $0) } }
|
||||
private var configURL: URL? { self.configPath.map { URL(fileURLWithPath: $0) } }
|
||||
}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import Commander
|
||||
import Foundation
|
||||
import Swabble
|
||||
|
||||
@MainActor
|
||||
struct StatusCommand: ParsableCommand {
|
||||
@ -12,11 +13,11 @@ struct StatusCommand: ParsableCommand {
|
||||
init() {}
|
||||
init(parsed: ParsedValues) {
|
||||
self.init()
|
||||
if let cfg = parsed.options["config"]?.last { configPath = cfg }
|
||||
if let cfg = parsed.options["config"]?.last { self.configPath = cfg }
|
||||
}
|
||||
|
||||
mutating func run() async throws {
|
||||
let cfg = try? ConfigLoader.load(at: configURL)
|
||||
let cfg = try? ConfigLoader.load(at: self.configURL)
|
||||
let wake = cfg?.wake.word ?? "clawd"
|
||||
let wakeEnabled = cfg?.wake.enabled ?? false
|
||||
let latest = await TranscriptsStore.shared.latest().suffix(3)
|
||||
@ -29,5 +30,5 @@ struct StatusCommand: ParsableCommand {
|
||||
}
|
||||
}
|
||||
|
||||
private var configURL: URL? { configPath.map { URL(fileURLWithPath: $0) } }
|
||||
private var configURL: URL? { self.configPath.map { URL(fileURLWithPath: $0) } }
|
||||
}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import Commander
|
||||
import Foundation
|
||||
import Swabble
|
||||
|
||||
@MainActor
|
||||
struct TailLogCommand: ParsableCommand {
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import Commander
|
||||
import Foundation
|
||||
import Swabble
|
||||
|
||||
@MainActor
|
||||
struct TestHookCommand: ParsableCommand {
|
||||
@ -14,16 +15,16 @@ struct TestHookCommand: ParsableCommand {
|
||||
|
||||
init(parsed: ParsedValues) {
|
||||
self.init()
|
||||
if let positional = parsed.positional.first { text = positional }
|
||||
if let cfg = parsed.options["config"]?.last { configPath = cfg }
|
||||
if let positional = parsed.positional.first { self.text = positional }
|
||||
if let cfg = parsed.options["config"]?.last { self.configPath = cfg }
|
||||
}
|
||||
|
||||
mutating func run() async throws {
|
||||
let cfg = try ConfigLoader.load(at: configURL)
|
||||
let cfg = try ConfigLoader.load(at: self.configURL)
|
||||
let runner = HookRunner(config: cfg)
|
||||
try await runner.run(job: HookJob(text: text, timestamp: Date()))
|
||||
try await runner.run(job: HookJob(text: self.text, timestamp: Date()))
|
||||
print("hook invoked")
|
||||
}
|
||||
|
||||
private var configURL: URL? { configPath.map { URL(fileURLWithPath: $0) } }
|
||||
private var configURL: URL? { self.configPath.map { URL(fileURLWithPath: $0) } }
|
||||
}
|
||||
|
||||
@ -2,11 +2,13 @@ import AVFoundation
|
||||
import Commander
|
||||
import Foundation
|
||||
import Speech
|
||||
import Swabble
|
||||
|
||||
@MainActor
|
||||
struct TranscribeCommand: ParsableCommand {
|
||||
@Argument(help: "Path to audio/video file") var inputFile: String = ""
|
||||
@Option(name: .long("locale"), help: "Locale identifier", parsing: .singleValue) var locale: String = Locale.current.identifier
|
||||
@Option(name: .long("locale"), help: "Locale identifier", parsing: .singleValue) var locale: String = Locale.current
|
||||
.identifier
|
||||
@Flag(help: "Censor etiquette-sensitive content") var censor: Bool = false
|
||||
@Option(name: .long("output"), help: "Output file path") var outputFile: String?
|
||||
@Option(name: .long("format"), help: "Output format txt|srt") var format: String = "txt"
|
||||
@ -22,12 +24,12 @@ struct TranscribeCommand: ParsableCommand {
|
||||
|
||||
init(parsed: ParsedValues) {
|
||||
self.init()
|
||||
if let positional = parsed.positional.first { inputFile = positional }
|
||||
if let loc = parsed.options["locale"]?.last { locale = loc }
|
||||
if parsed.flags.contains("censor") { censor = true }
|
||||
if let out = parsed.options["output"]?.last { outputFile = out }
|
||||
if let fmt = parsed.options["format"]?.last { format = fmt }
|
||||
if let len = parsed.options["maxLength"]?.last, let intVal = Int(len) { maxLength = intVal }
|
||||
if let positional = parsed.positional.first { self.inputFile = positional }
|
||||
if let loc = parsed.options["locale"]?.last { self.locale = loc }
|
||||
if parsed.flags.contains("censor") { self.censor = true }
|
||||
if let out = parsed.options["output"]?.last { self.outputFile = out }
|
||||
if let fmt = parsed.options["format"]?.last { self.format = fmt }
|
||||
if let len = parsed.options["maxLength"]?.last, let intVal = Int(len) { self.maxLength = intVal }
|
||||
}
|
||||
|
||||
mutating func run() async throws {
|
||||
@ -49,7 +51,7 @@ struct TranscribeCommand: ParsableCommand {
|
||||
transcript += result.text
|
||||
}
|
||||
|
||||
let output = outputFormat.text(for: transcript, maxLength: maxLength)
|
||||
let output = outputFormat.text(for: transcript, maxLength: self.maxLength)
|
||||
if let path = outputFile {
|
||||
try output.write(to: URL(fileURLWithPath: path), atomically: false, encoding: .utf8)
|
||||
} else {
|
||||
|
||||
@ -1,75 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
struct SwabbleConfig: Codable, Sendable {
|
||||
struct Audio: Codable, Sendable {
|
||||
var deviceName: String = ""
|
||||
var deviceIndex: Int = -1
|
||||
var sampleRate: Double = 16_000
|
||||
var channels: Int = 1
|
||||
}
|
||||
|
||||
struct Wake: Codable, Sendable {
|
||||
var enabled: Bool = true
|
||||
var word: String = "clawd"
|
||||
var aliases: [String] = ["claude"]
|
||||
}
|
||||
|
||||
struct Hook: Codable, Sendable {
|
||||
var command: String = ""
|
||||
var args: [String] = []
|
||||
var prefix: String = "Voice swabble from ${hostname}: "
|
||||
var cooldownSeconds: Double = 1
|
||||
var minCharacters: Int = 24
|
||||
var timeoutSeconds: Double = 5
|
||||
var env: [String: String] = [:]
|
||||
}
|
||||
|
||||
struct Logging: Codable, Sendable {
|
||||
var level: String = "info"
|
||||
var format: String = "text" // text|json placeholder
|
||||
}
|
||||
|
||||
struct Transcripts: Codable, Sendable {
|
||||
var enabled: Bool = true
|
||||
var maxEntries: Int = 50
|
||||
}
|
||||
|
||||
struct Speech: Codable, Sendable {
|
||||
var localeIdentifier: String = Locale.current.identifier
|
||||
var etiquetteReplacements: Bool = false
|
||||
}
|
||||
|
||||
var audio = Audio()
|
||||
var wake = Wake()
|
||||
var hook = Hook()
|
||||
var logging = Logging()
|
||||
var transcripts = Transcripts()
|
||||
var speech = Speech()
|
||||
|
||||
static let defaultPath = FileManager.default
|
||||
.homeDirectoryForCurrentUser
|
||||
.appendingPathComponent(".config/swabble/config.json")
|
||||
}
|
||||
|
||||
enum ConfigError: Error {
|
||||
case missingConfig
|
||||
}
|
||||
|
||||
enum ConfigLoader {
|
||||
static func load(at path: URL?) throws -> SwabbleConfig {
|
||||
let url = path ?? SwabbleConfig.defaultPath
|
||||
if !FileManager.default.fileExists(atPath: url.path) {
|
||||
throw ConfigError.missingConfig
|
||||
}
|
||||
let data = try Data(contentsOf: url)
|
||||
return try JSONDecoder().decode(SwabbleConfig.self, from: data)
|
||||
}
|
||||
|
||||
static func save(_ config: SwabbleConfig, at path: URL?) throws {
|
||||
let url = path ?? SwabbleConfig.defaultPath
|
||||
let dir = url.deletingLastPathComponent()
|
||||
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||
let data = try JSONEncoder().encode(config)
|
||||
try data.write(to: url)
|
||||
}
|
||||
}
|
||||
@ -1,41 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
enum LogLevel: String, Comparable, CaseIterable {
|
||||
case trace, debug, info, warn, error
|
||||
|
||||
var rank: Int {
|
||||
switch self {
|
||||
case .trace: 0
|
||||
case .debug: 1
|
||||
case .info: 2
|
||||
case .warn: 3
|
||||
case .error: 4
|
||||
}
|
||||
}
|
||||
|
||||
static func < (lhs: LogLevel, rhs: LogLevel) -> Bool { lhs.rank < rhs.rank }
|
||||
}
|
||||
|
||||
struct Logger: Sendable {
|
||||
let level: LogLevel
|
||||
|
||||
init(level: LogLevel) { self.level = level }
|
||||
|
||||
func log(_ level: LogLevel, _ message: String) {
|
||||
guard level >= self.level else { return }
|
||||
let ts = ISO8601DateFormatter().string(from: Date())
|
||||
print("[\(level.rawValue.uppercased())] \(ts) | \(message)")
|
||||
}
|
||||
|
||||
func trace(_ msg: String) { log(.trace, msg) }
|
||||
func debug(_ msg: String) { log(.debug, msg) }
|
||||
func info(_ msg: String) { log(.info, msg) }
|
||||
func warn(_ msg: String) { log(.warn, msg) }
|
||||
func error(_ msg: String) { log(.error, msg) }
|
||||
}
|
||||
|
||||
extension LogLevel {
|
||||
init?(configValue: String) {
|
||||
self.init(rawValue: configValue.lowercased())
|
||||
}
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import swabble
|
||||
@testable import Swabble
|
||||
|
||||
@Test
|
||||
func configRoundTrip() throws {
|
||||
|
||||
@ -8,7 +8,7 @@ Goal: brabble-style always-on voice hook for macOS 26 using Apple Speech.framewo
|
||||
- Wake word gating (default "clawd" plus aliases) with bypass flag `--no-wake`.
|
||||
- Hook execution with cooldown, min_chars, timeout, prefix, env vars.
|
||||
- Simple config at `~/.config/swabble/config.json` (JSON, Codable) — no TOML.
|
||||
- CLI implemented with Commander (local path dependency `../Commander`).
|
||||
- CLI implemented with Commander (SwiftPM package `steipete/Commander`); core types are available via the SwiftPM library product `Swabble` for embedding.
|
||||
- Foreground `serve`; later launchd helper for start/stop/restart.
|
||||
- File transcription command emitting txt or srt.
|
||||
- Basic status/health surfaces and mic selection stubs.
|
||||
|
||||
Loading…
Reference in New Issue
Block a user