Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/pull_request_template.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

- [ ] `./scripts/lint.sh`
- [ ] `swift build --package-path KAMIBotApp`
- [ ] `swift test --package-path KAMIBotApp`
- [ ] `./scripts/test.sh`

## Checklist

Expand Down
6 changes: 1 addition & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,4 @@ jobs:
- uses: actions/checkout@v4
- name: Test
run: |
if [ -d KAMIBotApp ]; then
swift test --package-path KAMIBotApp
else
echo "KAMIBotApp package not scaffolded yet"
fi
./scripts/test.sh
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,5 @@ The format is based on Keep a Changelog and this project uses Semantic Versionin

### Added
- Initial repository governance, licensing, and CI baseline.
- Modular Swift package scaffold for app, agent, audio, model, UI, and vision layers.
- Baseline local test harness via `scripts/test.sh` running package `xcodebuild` tests.
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ Why this change was needed and what behavior changed.
```bash
./scripts/lint.sh
swift build --package-path KAMIBotApp
swift test --package-path KAMIBotApp
./scripts/test.sh
```

## Code of Conduct
Expand Down
33 changes: 33 additions & 0 deletions KAMIBotApp/Package.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// swift-tools-version: 5.10
import PackageDescription

let package = Package(
name: "KAMIBotApp",
platforms: [.macOS(.v14)],
products: [
.executable(name: "KAMIBotApp", targets: ["KAMIBotApp"])
],
dependencies: [
.package(path: "../Packages/CoreAgent"),
.package(path: "../Packages/AudioPipeline"),
.package(path: "../Packages/ModelRuntime"),
.package(path: "../Packages/UIComponents"),
.package(path: "../Packages/VisionPipeline")
],
targets: [
.executableTarget(
name: "KAMIBotApp",
dependencies: [
"CoreAgent",
"AudioPipeline",
"ModelRuntime",
"UIComponents",
"VisionPipeline"
]
),
.testTarget(
name: "KAMIBotAppTests",
dependencies: ["KAMIBotApp"]
)
]
)
31 changes: 31 additions & 0 deletions KAMIBotApp/Sources/KAMIBotApp/AppContainer.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import AudioPipeline
import CoreAgent
import Foundation
import ModelRuntime
import VisionPipeline

@MainActor
struct AppContainer {
let agent: BMOAgent

init(config: AgentConfig = AgentConfig()) {
let wakeWord = PorcupineWakeWordService(keyword: config.wakeWord)
let stt = WhisperSpeechToTextService()
let tts = AVSpeechSynthesizerService()

let modelStore = URL(fileURLWithPath: FileManager.default.currentDirectoryPath)
.appendingPathComponent("models", isDirectory: true)
let llm = MLXLLMService(modelID: config.llmModelID, modelStore: modelStore)

let vision = SnapshotVisionService(enabled: config.visionEnabled)

self.agent = BMOAgent(
config: config,
wakeWordService: wakeWord,
sttService: stt,
ttsService: tts,
llmService: llm,
visionService: vision
)
}
}
45 changes: 45 additions & 0 deletions KAMIBotApp/Sources/KAMIBotApp/BMOViewModel.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import CoreAgent
import Observation

@MainActor
@Observable
final class BMOViewModel {
private let agent: BMOAgent
private var streamTask: Task<Void, Never>?

var state: BMOState = .idle
var expression: FaceExpression = .happy
var transcript: [String] = []

init(agent: BMOAgent) {
self.agent = agent
}

func start() {
streamTask = Task {
await agent.start()
for await event in agent.eventStream() {
switch event {
case .stateChanged(let state):
self.state = state
case .faceChanged(let expression):
self.expression = expression
case .heardUtterance(let utterance):
transcript.append("You: \(utterance)")
case .generatedResponse(let response):
transcript.append("BMO: \(response)")
case .error(let message):
transcript.append("Error: \(message)")
}
}
}
}

func stop() {
streamTask?.cancel()
streamTask = nil
Task {
await agent.stop()
}
}
}
35 changes: 35 additions & 0 deletions KAMIBotApp/Sources/KAMIBotApp/ContentView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import CoreAgent
import SwiftUI
import UIComponents

struct ContentView: View {
@Bindable var viewModel: BMOViewModel

var body: some View {
VStack(spacing: 14) {
BMOFaceView(expression: viewModel.expression, state: viewModel.state)

Text("State: \(viewModel.state.rawValue.capitalized)")
.font(.headline)

VStack(alignment: .leading, spacing: 6) {
ForEach(Array(viewModel.transcript.suffix(4).enumerated()), id: \.offset) { _, line in
Text(line)
.font(.caption)
.lineLimit(2)
}
}
.frame(maxWidth: 280, alignment: .leading)

HStack {
Button("Start") {
viewModel.start()
}
Button("Stop") {
viewModel.stop()
}
}
}
.padding(20)
}
}
14 changes: 14 additions & 0 deletions KAMIBotApp/Sources/KAMIBotApp/KAMIBotApp.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import SwiftUI

@main
struct KAMIBotApp: App {
@State private var viewModel = BMOViewModel(agent: AppContainer().agent)

var body: some Scene {
WindowGroup("KAMI BOT") {
ContentView(viewModel: viewModel)
.frame(minWidth: 320, minHeight: 420)
}
.defaultSize(width: 360, height: 460)
}
}
10 changes: 10 additions & 0 deletions KAMIBotApp/Tests/KAMIBotAppTests/KAMIBotAppTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import XCTest
@testable import KAMIBotApp

@MainActor
final class KAMIBotAppTests: XCTestCase {
func testContainerBuildsAgent() {
let container = AppContainer()
_ = container.agent
}
}
20 changes: 20 additions & 0 deletions Packages/AudioPipeline/Package.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
// swift-tools-version: 5.10
import PackageDescription

let package = Package(
name: "AudioPipeline",
platforms: [.macOS(.v14)],
products: [
.library(name: "AudioPipeline", targets: ["AudioPipeline"])
],
dependencies: [
.package(path: "../CoreAgent")
],
targets: [
.target(name: "AudioPipeline", dependencies: ["CoreAgent"]),
.testTarget(
name: "AudioPipelineTests",
dependencies: ["AudioPipeline"]
)
]
)
131 changes: 131 additions & 0 deletions Packages/AudioPipeline/Sources/AudioPipeline/AudioServices.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import AVFoundation
import CoreAgent
import Foundation

public enum AudioPipelineError: Error, Equatable {
case timeout
case maxRetriesExceeded
case microphoneDenied
}

public actor PorcupineWakeWordService: WakeWordService {
private let keyword: String
private let debounceSeconds: TimeInterval
private var isRunning = false
private var lastDetection: Date?
private let stream: AsyncStream<WakeWordEvent>
private let continuation: AsyncStream<WakeWordEvent>.Continuation

public init(keyword: String, debounceSeconds: TimeInterval = 0.8) {
self.keyword = keyword
self.debounceSeconds = debounceSeconds

var localContinuation: AsyncStream<WakeWordEvent>.Continuation?
self.stream = AsyncStream<WakeWordEvent> { continuation in
localContinuation = continuation
}
self.continuation = localContinuation!
}

public func start() async throws {
isRunning = true
}

public func stop() async {
isRunning = false
}

public func events() async -> AsyncStream<WakeWordEvent> {
stream
}

public func emitDetection(now: Date = Date()) {
guard isRunning else {
return
}

if let lastDetection, now.timeIntervalSince(lastDetection) < debounceSeconds {
return
}

lastDetection = now
continuation.yield(WakeWordEvent(keyword: keyword, detectedAt: now))
}
}

public final class MicrophonePermissionManager: @unchecked Sendable {
public init() {}

public func requestPermission() async -> Bool {
await withCheckedContinuation { continuation in
AVCaptureDevice.requestAccess(for: .audio) { granted in
continuation.resume(returning: granted)
}
}
}

public func hasPermission() -> Bool {
AVCaptureDevice.authorizationStatus(for: .audio) == .authorized
}
}

public actor WhisperSpeechToTextService: SpeechToTextService {
private var mockQueue: [String]

public init(initialMockQueue: [String] = []) {
self.mockQueue = initialMockQueue
}

public func enqueueMockTranscription(_ value: String) {
mockQueue.append(value)
}

public func transcribeNextUtterance(timeout: TimeInterval) async throws -> String {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if !mockQueue.isEmpty {
return mockQueue.removeFirst()
}
try await Task.sleep(nanoseconds: 50_000_000)
}
throw AudioPipelineError.timeout
}

public func transcribeWithRetry(timeout: TimeInterval, retries: Int) async throws -> String {
var attempts = 0
while attempts <= retries {
do {
return try await transcribeNextUtterance(timeout: timeout)
} catch AudioPipelineError.timeout {
attempts += 1
}
}
throw AudioPipelineError.maxRetriesExceeded
}
}

@MainActor
public final class AVSpeechSynthesizerService: @unchecked Sendable, TextToSpeechService {
private let synthesizer: AVSpeechSynthesizer

public init() {
self.synthesizer = AVSpeechSynthesizer()
}

public func speak(_ text: String) async throws {
let utterance = AVSpeechUtterance(string: text)
utterance.rate = 0.42
synthesize(utterance)

// Keep this async call cooperative for testability.
try await Task.sleep(nanoseconds: 120_000_000)
}

public func stop() async {
synthesizer.stopSpeaking(at: .immediate)
}

private func synthesize(_ utterance: AVSpeechUtterance) {
synthesizer.speak(utterance)
}
}
Loading
Loading