Initial commit
This commit is contained in:
commit
962dd3fb70
2
.gitattributes
vendored
Normal file
2
.gitattributes
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
# Auto detect text files and perform LF normalization
|
||||
* text=auto
|
||||
34
.gitignore
vendored
Normal file
34
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
# dependencies (bun install)
|
||||
node_modules
|
||||
|
||||
# output
|
||||
out
|
||||
dist
|
||||
*.tgz
|
||||
|
||||
# code coverage
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# logs
|
||||
logs
|
||||
_.log
|
||||
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||
|
||||
# dotenv environment variable files
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
|
||||
# caches
|
||||
.eslintcache
|
||||
.cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# IntelliJ based IDEs
|
||||
.idea
|
||||
|
||||
# Finder (MacOS) folder config
|
||||
.DS_Store
|
||||
3
.vscode/settings.json
vendored
Normal file
3
.vscode/settings.json
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"typescript.tsdk": "node_modules/typescript/lib"
|
||||
}
|
||||
106
CLAUDE.md
Normal file
106
CLAUDE.md
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
|
||||
Default to using Bun instead of Node.js.
|
||||
|
||||
- Use `bun <file>` instead of `node <file>` or `ts-node <file>`
|
||||
- Use `bun test` instead of `jest` or `vitest`
|
||||
- Use `bun build <file.html|file.ts|file.css>` instead of `webpack` or `esbuild`
|
||||
- Use `bun install` instead of `npm install` or `yarn install` or `pnpm install`
|
||||
- Use `bun run <script>` instead of `npm run <script>` or `yarn run <script>` or `pnpm run <script>`
|
||||
- Bun automatically loads .env, so don't use dotenv.
|
||||
|
||||
## APIs
|
||||
|
||||
- `Bun.serve()` supports WebSockets, HTTPS, and routes. Don't use `express`.
|
||||
- `bun:sqlite` for SQLite. Don't use `better-sqlite3`.
|
||||
- `Bun.redis` for Redis. Don't use `ioredis`.
|
||||
- `Bun.sql` for Postgres. Don't use `pg` or `postgres.js`.
|
||||
- `WebSocket` is built-in. Don't use `ws`.
|
||||
- Prefer `Bun.file` over `node:fs`'s readFile/writeFile
|
||||
- Bun.$`ls` instead of execa.
|
||||
|
||||
## Testing
|
||||
|
||||
Use `bun test` to run tests.
|
||||
|
||||
```ts#index.test.ts
|
||||
import { test, expect } from "bun:test";
|
||||
|
||||
test("hello world", () => {
|
||||
expect(1).toBe(1);
|
||||
});
|
||||
```
|
||||
|
||||
## Frontend
|
||||
|
||||
Use HTML imports with `Bun.serve()`. Don't use `vite`. HTML imports fully support React, CSS, Tailwind.
|
||||
|
||||
Server:
|
||||
|
||||
```ts#index.ts
|
||||
import index from "./index.html"
|
||||
|
||||
Bun.serve({
|
||||
routes: {
|
||||
"/": index,
|
||||
"/api/users/:id": {
|
||||
GET: (req) => {
|
||||
return new Response(JSON.stringify({ id: req.params.id }));
|
||||
},
|
||||
},
|
||||
},
|
||||
// optional websocket support
|
||||
websocket: {
|
||||
open: (ws) => {
|
||||
ws.send("Hello, world!");
|
||||
},
|
||||
message: (ws, message) => {
|
||||
ws.send(message);
|
||||
},
|
||||
close: (ws) => {
|
||||
// handle close
|
||||
}
|
||||
},
|
||||
development: {
|
||||
hmr: true,
|
||||
console: true,
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
HTML files can import .tsx, .jsx or .js files directly and Bun's bundler will transpile & bundle automatically. `<link>` tags can point to stylesheets and Bun's CSS bundler will bundle.
|
||||
|
||||
```html#index.html
|
||||
<html>
|
||||
<body>
|
||||
<h1>Hello, world!</h1>
|
||||
<script type="module" src="./frontend.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
With the following `frontend.tsx`:
|
||||
|
||||
```tsx#frontend.tsx
|
||||
import React from "react";
|
||||
|
||||
// import .css files directly and it works
|
||||
import './index.css';
|
||||
|
||||
import { createRoot } from "react-dom/client";
|
||||
|
||||
const root = createRoot(document.body);
|
||||
|
||||
export default function Frontend() {
|
||||
return <h1>Hello, world!</h1>;
|
||||
}
|
||||
|
||||
root.render(<Frontend />);
|
||||
```
|
||||
|
||||
Then, run index.ts
|
||||
|
||||
```sh
|
||||
bun --hot ./index.ts
|
||||
```
|
||||
|
||||
For more information, read the Bun API docs in `node_modules/bun-types/docs/**.md`.
|
||||
53
README.md
Normal file
53
README.md
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
# Deployment Script
|
||||
|
||||
This directory contains a deployment script for the Yellow Phone project to a Raspberry Pi.
|
||||
|
||||
## File: deploy.ts
|
||||
|
||||
A Bun-based deployment script that automates copying files to a Raspberry Pi and managing systemd services.
|
||||
|
||||
### Configuration
|
||||
|
||||
- **Target Host**: `yellow-phone.local`
|
||||
- **Target Directory**: `/home/corey/yellow-phone`
|
||||
|
||||
### What It Does
|
||||
|
||||
1. **Creates directory** on the Pi at the configured path
|
||||
2. **Copies files** from local `pi/` directory to the Pi
|
||||
3. **Sets permissions** to make all TypeScript files executable
|
||||
4. **Bootstrap (optional)**: If `--bootstrap` flag is passed, runs `bootstrap.ts` on the Pi with sudo
|
||||
5. **Service management**:
|
||||
- Checks if `phone-ap.service` and `phone-web.service` exist
|
||||
- If they exist, restarts both services
|
||||
- If they don't exist and bootstrap wasn't run, warns the user
|
||||
|
||||
### Usage
|
||||
|
||||
**Standard deployment** (just copy files and restart services):
|
||||
```bash
|
||||
bun deploy.ts
|
||||
```
|
||||
|
||||
**First-time deployment** (copy files + run bootstrap):
|
||||
```bash
|
||||
bun deploy.ts --bootstrap
|
||||
```
|
||||
|
||||
### Services
|
||||
|
||||
The script manages two systemd services:
|
||||
- `phone-ap.service` - Access point service
|
||||
- `phone-web.service` - Web interface service
|
||||
|
||||
### Access
|
||||
|
||||
After deployment, the Pi is accessible at:
|
||||
- **Web URL**: http://yellow-phone.local
|
||||
- **WiFi Network**: yellow-phone-setup
|
||||
|
||||
### Requirements
|
||||
|
||||
- Bun runtime
|
||||
- SSH access to `yellow-phone.local`
|
||||
- Local `pi/` directory with files to deploy
|
||||
72
basic-test.ts
Executable file
72
basic-test.ts
Executable file
|
|
@ -0,0 +1,72 @@
|
|||
#!/usr/bin/env bun
|
||||
|
||||
/**
|
||||
* Basic functionality test for Buzz library
|
||||
* Tests device listing, player, recorder, and tone generation
|
||||
*/
|
||||
|
||||
import Buzz from "./src/buzz"
|
||||
|
||||
console.log("🎵 Buzz Audio Library - Basic Test\n")
|
||||
|
||||
// Test 1: List devices
|
||||
console.log("📋 Listing devices...")
|
||||
const devices = await Buzz.listDevices()
|
||||
console.log(`Found ${devices.length} device(s):`)
|
||||
devices.forEach((d) => {
|
||||
console.log(` ${d.type.padEnd(8)} ${d.label} (${d.id})`)
|
||||
})
|
||||
console.log("")
|
||||
|
||||
// Test 2: Create player
|
||||
console.log("🔊 Creating default player...")
|
||||
try {
|
||||
const player = await Buzz.defaultPlayer()
|
||||
console.log("✅ Player created\n")
|
||||
|
||||
// Test 3: Play sound file
|
||||
console.log("🔊 Playing greeting sound...")
|
||||
const playback = await player.play("./sounds/greeting/greet1.wav")
|
||||
await playback.finished()
|
||||
console.log("✅ Sound played\n")
|
||||
|
||||
// Test 4: Play tone
|
||||
console.log("🎵 Playing 440Hz tone for 1 second...")
|
||||
const tone = await player.playTone([440], 1000)
|
||||
await tone.finished()
|
||||
console.log("✅ Tone played\n")
|
||||
} catch (error) {
|
||||
console.log(`⚠️ Skipping player tests: ${error instanceof Error ? error.message : error}\n`)
|
||||
}
|
||||
|
||||
// Test 5: Create recorder
|
||||
console.log("🎤 Creating default recorder...")
|
||||
try {
|
||||
const recorder = await Buzz.defaultRecorder()
|
||||
console.log("✅ Recorder created\n")
|
||||
|
||||
// Test 6: Stream recording with RMS
|
||||
console.log("📊 Recording for 2 seconds with RMS monitoring...")
|
||||
const recording = recorder.start()
|
||||
let chunkCount = 0
|
||||
let maxRMS = 0
|
||||
|
||||
setTimeout(async () => {
|
||||
await recording.stop()
|
||||
}, 2000)
|
||||
|
||||
for await (const chunk of recording.stream()) {
|
||||
chunkCount++
|
||||
const rms = Buzz.calculateRMS(chunk)
|
||||
if (rms > maxRMS) maxRMS = rms
|
||||
if (chunkCount % 20 === 0) {
|
||||
console.log(` RMS: ${Math.round(rms)}`)
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`✅ Recorded ${chunkCount} chunks, max RMS: ${Math.round(maxRMS)}\n`)
|
||||
} catch (error) {
|
||||
console.log(`⚠️ Skipping recorder tests: ${error instanceof Error ? error.message : error}\n`)
|
||||
}
|
||||
|
||||
console.log("✅ All tests complete!")
|
||||
37
bun.lock
Normal file
37
bun.lock
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
{
|
||||
"lockfileVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "tmp",
|
||||
"dependencies": {
|
||||
"hono": "^4.10.4",
|
||||
"openai": "^6.9.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@types/bun": ["@types/bun@1.3.2", "", { "dependencies": { "bun-types": "1.3.2" } }, "sha512-t15P7k5UIgHKkxwnMNkJbWlh/617rkDGEdSsDbu+qNHTaz9SKf7aC8fiIlUdD5RPpH6GEkP0cK7WlvmrEBRtWg=="],
|
||||
|
||||
"@types/node": ["@types/node@24.10.1", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ=="],
|
||||
|
||||
"@types/react": ["@types/react@19.2.5", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-keKxkZMqnDicuvFoJbzrhbtdLSPhj/rZThDlKWCDbgXmUg0rEUFtRssDXKYmtXluZlIqiC5VqkCgRwzuyLHKHw=="],
|
||||
|
||||
"bun-types": ["bun-types@1.3.2", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-i/Gln4tbzKNuxP70OWhJRZz1MRfvqExowP7U6JKoI8cntFrtxg7RJK3jvz7wQW54UuvNC8tbKHHri5fy74FVqg=="],
|
||||
|
||||
"csstype": ["csstype@3.2.2", "", {}, "sha512-D80T+tiqkd/8B0xNlbstWDG4x6aqVfO52+OlSUNIdkTvmNw0uQpJLeos2J/2XvpyidAFuTPmpad+tUxLndwj6g=="],
|
||||
|
||||
"hono": ["hono@4.10.6", "", {}, "sha512-BIdolzGpDO9MQ4nu3AUuDwHZZ+KViNm+EZ75Ae55eMXMqLVhDFqEMXxtUe9Qh8hjL+pIna/frs2j6Y2yD5Ua/g=="],
|
||||
|
||||
"openai": ["openai@6.9.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-n2sJRYmM+xfJ0l3OfH8eNnIyv3nQY7L08gZQu3dw6wSdfPtKAk92L83M2NIP5SS8Cl/bsBBG3yKzEOjkx0O+7A=="],
|
||||
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
||||
}
|
||||
}
|
||||
23
package.json
Normal file
23
package.json
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"name": "tmp",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit",
|
||||
"d": "bun scripts/deploy.ts",
|
||||
"start": "bun run src/operator.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5"
|
||||
},
|
||||
"dependencies": {
|
||||
"hono": "^4.10.4",
|
||||
"openai": "^6.9.0"
|
||||
},
|
||||
"prettier": {
|
||||
"semi": false,
|
||||
"printWidth": 100
|
||||
}
|
||||
}
|
||||
61
scripts/bootstrap-bun.sh
Normal file
61
scripts/bootstrap-bun.sh
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
#!/bin/bash
|
||||
# Install Bun and make it available system-wide
|
||||
set -e
|
||||
|
||||
echo "==========================================
|
||||
Bun Installation for Yellow Phone
|
||||
==========================================
|
||||
"
|
||||
|
||||
# Check if already installed
|
||||
if command -v bun >/dev/null 2>&1; then
|
||||
echo "✓ Bun is already installed at: $(which bun)"
|
||||
bun --version
|
||||
echo ""
|
||||
read -p "Reinstall anyway? (y/N): " -n 1 -r
|
||||
echo
|
||||
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||
echo "Skipping installation."
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "Step 1: Installing Bun..."
|
||||
echo "Running official Bun installer..."
|
||||
curl -fsSL https://bun.sh/install | bash
|
||||
|
||||
echo ""
|
||||
echo "Step 2: Finding Bun installation..."
|
||||
BUN_PATH="$HOME/.bun/bin/bun"
|
||||
|
||||
if [ ! -f "$BUN_PATH" ]; then
|
||||
echo "❌ Error: Bun not found at $BUN_PATH"
|
||||
echo "Installation may have failed."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✓ Found Bun at: $BUN_PATH"
|
||||
$BUN_PATH --version
|
||||
|
||||
echo ""
|
||||
echo "Step 3: Creating system-wide symlink..."
|
||||
sudo ln -sf "$BUN_PATH" /usr/local/bin/bun
|
||||
echo "✓ Symlinked to /usr/local/bin/bun"
|
||||
|
||||
echo ""
|
||||
echo "Step 4: Verifying installation..."
|
||||
which bun
|
||||
bun --version
|
||||
|
||||
echo "
|
||||
==========================================
|
||||
✓ Bun installation complete!
|
||||
==========================================
|
||||
|
||||
Bun is now available system-wide:
|
||||
Location: /usr/local/bin/bun -> $BUN_PATH
|
||||
Version: $(bun --version)
|
||||
|
||||
You can now run bootstrap:
|
||||
sudo bun bootstrap.ts
|
||||
"
|
||||
110
scripts/bootstrap.ts
Executable file
110
scripts/bootstrap.ts
Executable file
|
|
@ -0,0 +1,110 @@
|
|||
import { $ } from "bun"
|
||||
import { writeFileSync } from "fs"
|
||||
|
||||
console.log(`
|
||||
==========================================
|
||||
Yellow Phone Setup Bootstrap
|
||||
==========================================
|
||||
`)
|
||||
|
||||
// Check if running as root
|
||||
if (process.getuid && process.getuid() !== 0) {
|
||||
console.error("Please run with sudo: sudo bun bootstrap.ts [install-dir]")
|
||||
console.error("Or use full path: sudo ~/.bun/bin/bun bootstrap.ts [install-dir]")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// Get install directory from argument or use default
|
||||
const INSTALL_DIR = process.argv[2] || "/home/corey/yellow-phone"
|
||||
const AP_SERVICE_FILE = "/etc/systemd/system/phone-ap.service"
|
||||
const WEB_SERVICE_FILE = "/etc/systemd/system/phone-web.service"
|
||||
|
||||
console.log(`Install directory: ${INSTALL_DIR}`)
|
||||
|
||||
console.log("\nStep 1: Ensuring directory exists...")
|
||||
await $`mkdir -p ${INSTALL_DIR}`
|
||||
console.log(`✓ Directory ready: ${INSTALL_DIR}`)
|
||||
|
||||
console.log("\nStep 2: Installing dependencies...")
|
||||
await $`cd ${INSTALL_DIR} && bun install`
|
||||
console.log(`✓ Dependencies installed`)
|
||||
|
||||
console.log("\nStep 3: Installing systemd services...")
|
||||
// Find where bun is installed
|
||||
const bunPath = await $`which bun`
|
||||
.quiet()
|
||||
.nothrow()
|
||||
.text()
|
||||
.then((p) => p.trim())
|
||||
if (!bunPath) {
|
||||
console.error("Error: bun not found in PATH. Please ensure bun is available system-wide.")
|
||||
process.exit(1)
|
||||
}
|
||||
console.log(`Using bun at: ${bunPath}`)
|
||||
|
||||
// Create AP monitor service
|
||||
const apServiceContent = `[Unit]
|
||||
Description=Yellow Phone WiFi AP Monitor
|
||||
After=network.target
|
||||
Before=phone-web.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=${bunPath} ${INSTALL_DIR}/services/ap-monitor.ts
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
`
|
||||
writeFileSync(AP_SERVICE_FILE, apServiceContent, "utf8")
|
||||
console.log("✓ Created phone-ap.service")
|
||||
|
||||
// Create web server service
|
||||
const webServiceContent = `[Unit]
|
||||
Description=Yellow Phone Web Server
|
||||
After=network.target phone-ap.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=${bunPath} ${INSTALL_DIR}/services/server/server.tsx
|
||||
WorkingDirectory=${INSTALL_DIR}
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
`
|
||||
writeFileSync(WEB_SERVICE_FILE, webServiceContent, "utf8")
|
||||
console.log("✓ Created phone-web.service")
|
||||
|
||||
await $`systemctl daemon-reload`
|
||||
await $`systemctl enable phone-ap.service`
|
||||
await $`systemctl enable phone-web.service`
|
||||
console.log("✓ Services enabled")
|
||||
|
||||
console.log("\nStep 4: Starting the services...")
|
||||
await $`systemctl start phone-ap.service`
|
||||
await $`systemctl start phone-web.service`
|
||||
console.log("✓ Services started")
|
||||
|
||||
console.log(`
|
||||
==========================================
|
||||
✓ Bootstrap complete!
|
||||
==========================================
|
||||
|
||||
Both services are now running and will start automatically on boot:
|
||||
- phone-ap.service: Monitors WiFi and manages AP
|
||||
- phone-web.service: Web server for configuration
|
||||
|
||||
How it works:
|
||||
- If connected to WiFi: Access at http://yellow-phone.local
|
||||
- If NOT connected: WiFi AP "yellow-phone-setup" will start automatically
|
||||
Connect to the AP at the same address http://yellow-phone.local
|
||||
|
||||
To check status use ./cli
|
||||
`)
|
||||
178
scripts/cli.sh
Normal file
178
scripts/cli.sh
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
#!/usr/bin/env bun
|
||||
|
||||
import {$} from "bun";
|
||||
|
||||
const SERVICES = {
|
||||
ap: "phone-ap",
|
||||
web: "phone-web",
|
||||
};
|
||||
|
||||
const commands = {
|
||||
status: "Show status of all services",
|
||||
logs: "Show recent logs from all services (last 50 lines)",
|
||||
tail: "Tail logs from all services in real-time",
|
||||
restart: "Restart all services",
|
||||
stop: "Stop all services",
|
||||
start: "Start all services",
|
||||
"ap-status": "Show status of AP service",
|
||||
"ap-logs": "Show recent logs from AP service (last 50 lines)",
|
||||
"ap-tail": "Tail logs from AP service in real-time",
|
||||
"ap-restart": "Restart AP service",
|
||||
"ap-stop": "Stop AP service",
|
||||
"ap-start": "Start AP service",
|
||||
"web-status": "Show status of web service",
|
||||
"web-logs": "Show recent logs from web service (last 50 lines)",
|
||||
"web-tail": "Tail logs from web service in real-time",
|
||||
"web-restart": "Restart web service",
|
||||
"web-stop": "Stop web service",
|
||||
"web-start": "Start web service",
|
||||
help: "Show this help message",
|
||||
};
|
||||
|
||||
const command = process.argv[2];
|
||||
|
||||
if (!command || command === "help") {
|
||||
console.log(`
|
||||
Yellow Phone CLI - Service Management Tool
|
||||
|
||||
Usage: bun cli <command>
|
||||
|
||||
All Services:
|
||||
status Show status of all services
|
||||
logs Show recent logs from all services (last 50 lines)
|
||||
tail Tail logs from all services in real-time
|
||||
restart Restart all services
|
||||
stop Stop all services
|
||||
start Start all services
|
||||
|
||||
AP Service (phone-ap):
|
||||
ap-status Show AP status
|
||||
ap-logs Show AP logs (last 50 lines)
|
||||
ap-tail Tail AP logs in real-time
|
||||
ap-restart Restart AP service
|
||||
ap-stop Stop AP service
|
||||
ap-start Start AP service
|
||||
|
||||
Web Service (phone-web):
|
||||
web-status Show web status
|
||||
web-logs Show web logs (last 50 lines)
|
||||
web-tail Tail web logs in real-time
|
||||
web-restart Restart web service
|
||||
web-stop Stop web service
|
||||
web-start Start web service
|
||||
|
||||
Examples:
|
||||
bun cli status
|
||||
bun cli ap-logs
|
||||
bun cli web-tail
|
||||
sudo bun cli ap-restart
|
||||
`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (!Object.keys(commands).includes(command)) {
|
||||
console.error(`❌ Unknown command: ${command}`);
|
||||
console.log(`Run 'bun cli.ts help' to see available commands`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`\n🔧 Yellow Phone CLI - ${command}\n`);
|
||||
|
||||
// Parse service-specific commands
|
||||
const match = command.match(/^(ap|web)-(.+)$/);
|
||||
if (match) {
|
||||
const [, prefix, action] = match;
|
||||
const service = SERVICES[prefix as keyof typeof SERVICES];
|
||||
|
||||
switch (action) {
|
||||
case "status":
|
||||
console.log(`━━━ ${service}.service ━━━`);
|
||||
await $`systemctl status ${service}.service --no-pager -l`.nothrow();
|
||||
break;
|
||||
|
||||
case "logs":
|
||||
console.log(`📋 Recent logs (last 50 lines):\n`);
|
||||
await $`journalctl -u ${service}.service -n 50 --no-pager`.nothrow();
|
||||
break;
|
||||
|
||||
case "tail":
|
||||
console.log(`📡 Tailing logs (Ctrl+C to stop)...\n`);
|
||||
await $`journalctl -u ${service}.service -f --no-pager`.nothrow();
|
||||
break;
|
||||
|
||||
case "restart":
|
||||
console.log(`🔄 Restarting ${service}.service...\n`);
|
||||
await $`sudo systemctl restart ${service}.service`;
|
||||
console.log(`✓ ${service}.service restarted!`);
|
||||
break;
|
||||
|
||||
case "stop":
|
||||
console.log(`🛑 Stopping ${service}.service...\n`);
|
||||
await $`sudo systemctl stop ${service}.service`;
|
||||
console.log(`✓ ${service}.service stopped!`);
|
||||
break;
|
||||
|
||||
case "start":
|
||||
console.log(`▶️ Starting ${service}.service...\n`);
|
||||
await $`sudo systemctl start ${service}.service`;
|
||||
console.log(`✓ ${service}.service started!`);
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
// All-services commands
|
||||
const allServices = Object.values(SERVICES);
|
||||
|
||||
switch (command) {
|
||||
case "status":
|
||||
for (const service of allServices) {
|
||||
console.log(`━━━ ${service}.service ━━━`);
|
||||
await $`systemctl status ${service}.service --no-pager -l`.nothrow();
|
||||
console.log("");
|
||||
}
|
||||
break;
|
||||
|
||||
case "logs":
|
||||
console.log("📋 Recent logs (last 50 lines):\n");
|
||||
const serviceFlags = allServices.map(s => `-u ${s}.service`).join(" ");
|
||||
await $`journalctl ${serviceFlags} -n 50 --no-pager`.nothrow();
|
||||
break;
|
||||
|
||||
case "tail":
|
||||
console.log("📡 Tailing logs (Ctrl+C to stop)...\n");
|
||||
const tailFlags = allServices.map(s => `-u ${s}.service`).join(" ");
|
||||
await $`journalctl ${tailFlags} -f --no-pager`.nothrow();
|
||||
break;
|
||||
|
||||
case "restart":
|
||||
console.log("🔄 Restarting services...\n");
|
||||
for (const service of allServices) {
|
||||
console.log(`Restarting ${service}.service...`);
|
||||
await $`sudo systemctl restart ${service}.service`;
|
||||
console.log(`✓ ${service}.service restarted`);
|
||||
}
|
||||
console.log("\n✓ All services restarted!");
|
||||
break;
|
||||
|
||||
case "stop":
|
||||
console.log("🛑 Stopping services...\n");
|
||||
for (const service of allServices) {
|
||||
console.log(`Stopping ${service}.service...`);
|
||||
await $`sudo systemctl stop ${service}.service`;
|
||||
console.log(`✓ ${service}.service stopped`);
|
||||
}
|
||||
console.log("\n✓ All services stopped!");
|
||||
break;
|
||||
|
||||
case "start":
|
||||
console.log("▶️ Starting services...\n");
|
||||
for (const service of allServices) {
|
||||
console.log(`Starting ${service}.service...`);
|
||||
await $`sudo systemctl start ${service}.service`;
|
||||
console.log(`✓ ${service}.service started`);
|
||||
}
|
||||
console.log("\n✓ All services started!");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
console.log("");
|
||||
59
scripts/deploy.ts
Executable file
59
scripts/deploy.ts
Executable file
|
|
@ -0,0 +1,59 @@
|
|||
#!/usr/bin/env bun
|
||||
|
||||
import { $ } from "bun"
|
||||
|
||||
const PI_HOST = "yellow-phone.local"
|
||||
const PI_DIR = "/home/corey/yellow-phone"
|
||||
|
||||
// Parse command line arguments
|
||||
const shouldBootstrap = process.argv.includes("--bootstrap")
|
||||
|
||||
console.log(`🧪 Run basic tests...\n`)
|
||||
|
||||
await $`bun run typecheck`
|
||||
|
||||
console.log(`📦 Deploying to ${PI_HOST}...\n`)
|
||||
|
||||
// Create directory on Pi
|
||||
console.log("Creating directory on Pi...")
|
||||
await $`ssh ${PI_HOST} "mkdir -p ${PI_DIR}"`
|
||||
|
||||
// Sync files from . directory to Pi (only transfers changed files)
|
||||
console.log("Syncing files from . directory...")
|
||||
await $`rsync -avz --delete --exclude-from='.gitignore' . ${PI_HOST}:${PI_DIR}/`
|
||||
|
||||
// Make all TypeScript files executable
|
||||
console.log("Making scripts executable...")
|
||||
await $`ssh ${PI_HOST} "chmod +x ${PI_DIR}/scripts/*"`
|
||||
|
||||
console.log("\n✓ Files deployed!\n")
|
||||
|
||||
// Run bootstrap if requested
|
||||
if (shouldBootstrap) {
|
||||
console.log("Running bootstrap on Pi...\n")
|
||||
await $`ssh ${PI_HOST} "cd ${PI_DIR} && sudo bun bootstrap.ts ${PI_DIR}"`
|
||||
}
|
||||
|
||||
// Always check if services exist and restart them (whether we bootstrapped or not)
|
||||
console.log("Checking for existing services...")
|
||||
const apServiceExists = await $`ssh ${PI_HOST} "systemctl is-enabled phone-ap.service"`
|
||||
.nothrow()
|
||||
.quiet()
|
||||
const webServiceExists = await $`ssh ${PI_HOST} "systemctl is-enabled phone-web.service"`
|
||||
.nothrow()
|
||||
.quiet()
|
||||
|
||||
if (apServiceExists.exitCode === 0 && webServiceExists.exitCode === 0) {
|
||||
console.log("Restarting services...")
|
||||
await $`ssh ${PI_HOST} "sudo systemctl restart phone-ap.service phone-web.service"`
|
||||
console.log("✓ Services restarted\n")
|
||||
} else if (!shouldBootstrap) {
|
||||
console.log("Services not installed. Run with --bootstrap to install.\n")
|
||||
}
|
||||
|
||||
console.log(`
|
||||
✓ Deploy complete!
|
||||
|
||||
Access via WiFi at http://yellow-phone.local
|
||||
The Pi is discoverable as "yellow-phone-setup"
|
||||
`)
|
||||
BIN
sounds/apology/excuse-me1.wav
Normal file
BIN
sounds/apology/excuse-me1.wav
Normal file
Binary file not shown.
BIN
sounds/apology/excuse-me2.wav
Normal file
BIN
sounds/apology/excuse-me2.wav
Normal file
Binary file not shown.
BIN
sounds/apology/excuse-me3.wav
Normal file
BIN
sounds/apology/excuse-me3.wav
Normal file
Binary file not shown.
BIN
sounds/background/background1.wav
Normal file
BIN
sounds/background/background1.wav
Normal file
Binary file not shown.
BIN
sounds/background/background2.wav
Normal file
BIN
sounds/background/background2.wav
Normal file
Binary file not shown.
BIN
sounds/body-noises/burp1.wav
Normal file
BIN
sounds/body-noises/burp1.wav
Normal file
Binary file not shown.
BIN
sounds/body-noises/burp2.wav
Normal file
BIN
sounds/body-noises/burp2.wav
Normal file
Binary file not shown.
BIN
sounds/body-noises/fart1.wav
Normal file
BIN
sounds/body-noises/fart1.wav
Normal file
Binary file not shown.
BIN
sounds/body-noises/fart2.wav
Normal file
BIN
sounds/body-noises/fart2.wav
Normal file
Binary file not shown.
BIN
sounds/clicking/mouse1.wav
Normal file
BIN
sounds/clicking/mouse1.wav
Normal file
Binary file not shown.
BIN
sounds/clicking/mouse2.wav
Normal file
BIN
sounds/clicking/mouse2.wav
Normal file
Binary file not shown.
BIN
sounds/clicking/mouse3.wav
Normal file
BIN
sounds/clicking/mouse3.wav
Normal file
Binary file not shown.
BIN
sounds/greeting/greet1.wav
Normal file
BIN
sounds/greeting/greet1.wav
Normal file
Binary file not shown.
BIN
sounds/greeting/greet2.wav
Normal file
BIN
sounds/greeting/greet2.wav
Normal file
Binary file not shown.
BIN
sounds/greeting/greet3.wav
Normal file
BIN
sounds/greeting/greet3.wav
Normal file
Binary file not shown.
BIN
sounds/greeting/greet4.wav
Normal file
BIN
sounds/greeting/greet4.wav
Normal file
Binary file not shown.
BIN
sounds/greeting/greet5.wav
Normal file
BIN
sounds/greeting/greet5.wav
Normal file
Binary file not shown.
BIN
sounds/greeting/greet6.wav
Normal file
BIN
sounds/greeting/greet6.wav
Normal file
Binary file not shown.
BIN
sounds/greeting/greet7.wav
Normal file
BIN
sounds/greeting/greet7.wav
Normal file
Binary file not shown.
BIN
sounds/greeting/greet8.wav
Normal file
BIN
sounds/greeting/greet8.wav
Normal file
Binary file not shown.
BIN
sounds/stalling/cough1.wav
Normal file
BIN
sounds/stalling/cough1.wav
Normal file
Binary file not shown.
BIN
sounds/stalling/cough2.wav
Normal file
BIN
sounds/stalling/cough2.wav
Normal file
Binary file not shown.
BIN
sounds/stalling/hmm1.wav
Normal file
BIN
sounds/stalling/hmm1.wav
Normal file
Binary file not shown.
BIN
sounds/stalling/hmm2.wav
Normal file
BIN
sounds/stalling/hmm2.wav
Normal file
Binary file not shown.
BIN
sounds/stalling/hum1.wav
Normal file
BIN
sounds/stalling/hum1.wav
Normal file
Binary file not shown.
BIN
sounds/stalling/hum2.wav
Normal file
BIN
sounds/stalling/hum2.wav
Normal file
Binary file not shown.
BIN
sounds/stalling/one-sec.wav
Normal file
BIN
sounds/stalling/one-sec.wav
Normal file
Binary file not shown.
BIN
sounds/stalling/sigh1.wav
Normal file
BIN
sounds/stalling/sigh1.wav
Normal file
Binary file not shown.
BIN
sounds/stalling/sigh2.wav
Normal file
BIN
sounds/stalling/sigh2.wav
Normal file
Binary file not shown.
BIN
sounds/stalling/sneeze.wav
Normal file
BIN
sounds/stalling/sneeze.wav
Normal file
Binary file not shown.
BIN
sounds/stalling/uh-huh.wav
Normal file
BIN
sounds/stalling/uh-huh.wav
Normal file
Binary file not shown.
BIN
sounds/stalling/yeah.wav
Normal file
BIN
sounds/stalling/yeah.wav
Normal file
Binary file not shown.
BIN
sounds/typing/typing1.wav
Normal file
BIN
sounds/typing/typing1.wav
Normal file
Binary file not shown.
BIN
sounds/typing/typing2.wav
Normal file
BIN
sounds/typing/typing2.wav
Normal file
Binary file not shown.
157
src/agent/README.md
Normal file
157
src/agent/README.md
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
# Agent
|
||||
|
||||
A clean, reusable wrapper for ElevenLabs conversational AI WebSocket protocol. Uses Signal-based events and provides simple tool registration.
|
||||
|
||||
## Basic Usage
|
||||
|
||||
```typescript
|
||||
import { Agent } from './pi/agent'
|
||||
import Buzz from './pi/buzz'
|
||||
|
||||
const agent = new Agent({
|
||||
agentId: process.env.ELEVEN_AGENT_ID!,
|
||||
apiKey: process.env.ELEVEN_API_KEY!,
|
||||
tools: {
|
||||
search_web: async (args) => {
|
||||
return { results: [`Result for ${args.query}`] }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Set up event handlers
|
||||
const player = await Buzz.defaultPlayer()
|
||||
let playback = player.playStream()
|
||||
|
||||
agent.events.connect((event) => {
|
||||
if (event.type === 'audio') {
|
||||
const audioBuffer = Buffer.from(event.audioBase64, 'base64')
|
||||
if (!playback.isPlaying) playback = player.playStream()
|
||||
playback.write(audioBuffer)
|
||||
}
|
||||
else if (event.type === 'interruption') {
|
||||
playback.stop()
|
||||
}
|
||||
else if (event.type === 'user_transcript') {
|
||||
console.log(`User: ${event.transcript}`)
|
||||
}
|
||||
else if (event.type === 'agent_response') {
|
||||
console.log(`Agent: ${event.response}`)
|
||||
}
|
||||
})
|
||||
|
||||
// Start conversation
|
||||
await agent.start()
|
||||
|
||||
// Continuously stream audio
|
||||
const recorder = await Buzz.defaultRecorder()
|
||||
const recording = recorder.start()
|
||||
for await (const chunk of recording.stream()) {
|
||||
agent.sendAudio(chunk)
|
||||
}
|
||||
```
|
||||
|
||||
## VAD Pattern
|
||||
|
||||
```typescript
|
||||
const recorder = await Buzz.defaultRecorder()
|
||||
const recording = recorder.start()
|
||||
const buffer = new RollingBuffer()
|
||||
|
||||
let agent: Agent | undefined
|
||||
|
||||
for await (const chunk of recording.stream()) {
|
||||
if (!agent) {
|
||||
// Waiting for voice
|
||||
buffer.add(chunk)
|
||||
const rms = Buzz.calculateRMS(chunk)
|
||||
|
||||
if (rms > vadThreshold) {
|
||||
// Speech detected! Start conversation
|
||||
agent = new Agent({ agentId, apiKey, tools })
|
||||
agent.events.connect(eventHandler)
|
||||
await agent.start()
|
||||
|
||||
// Send buffered audio
|
||||
const buffered = buffer.flush()
|
||||
agent.sendAudio(buffered)
|
||||
}
|
||||
} else {
|
||||
// In conversation - stream continuously
|
||||
agent.sendAudio(chunk)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
### Constructor
|
||||
|
||||
```typescript
|
||||
new Agent({
|
||||
agentId: string,
|
||||
apiKey: string,
|
||||
tools?: {
|
||||
[toolName: string]: (args: Record<string, unknown>) => Promise<unknown> | unknown
|
||||
},
|
||||
conversationConfig?: {
|
||||
agentConfig?: object,
|
||||
ttsConfig?: object,
|
||||
customLlmExtraBody?: { temperature?: number, max_tokens?: number },
|
||||
dynamicVariables?: Record<string, string | number | boolean>
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### Methods
|
||||
|
||||
- `await agent.start()` - Connect WebSocket and start conversation
|
||||
- `agent.sendAudio(chunk: Uint8Array)` - Send audio chunk (buffers during connection)
|
||||
- `agent.sendMessage(text: string)` - Send text message to agent
|
||||
- `agent.sendContextUpdate(text: string)` - Update context during conversation
|
||||
- `await agent.stop()` - Close WebSocket and clean up
|
||||
|
||||
### Properties
|
||||
|
||||
- `agent.events: Signal<AgentEvent>` - Connect to receive all events
|
||||
- `agent.isConnected: boolean` - Current connection state
|
||||
- `agent.conversationId?: string` - Available after connected event
|
||||
|
||||
## Events
|
||||
|
||||
All events are emitted through `agent.events`:
|
||||
|
||||
### Connection
|
||||
- `{ type: 'connected', conversationId, audioFormat }`
|
||||
- `{ type: 'disconnected' }`
|
||||
- `{ type: 'error', error }`
|
||||
|
||||
### Conversation
|
||||
- `{ type: 'user_transcript', transcript }`
|
||||
- `{ type: 'agent_response', response }`
|
||||
- `{ type: 'agent_response_correction', original, corrected }`
|
||||
- `{ type: 'tentative_agent_response', response }`
|
||||
- `{ type: 'audio', audioBase64, eventId }`
|
||||
- `{ type: 'interruption', eventId }`
|
||||
|
||||
### Tools
|
||||
- `{ type: 'tool_call', name, args, callId }`
|
||||
- `{ type: 'tool_result', name, result, callId }`
|
||||
- `{ type: 'tool_error', name, error, callId }`
|
||||
|
||||
### Optional
|
||||
- `{ type: 'vad_score', score }`
|
||||
- `{ type: 'ping', eventId, pingMs }`
|
||||
|
||||
## Design Principles
|
||||
|
||||
- **Generic**: Not tied to phone systems, works in any context
|
||||
- **Flexible audio**: You control when to send audio, Agent just handles WebSocket
|
||||
- **Event-driven**: All communication through Signal events, no throws
|
||||
- **Simple tools**: Just pass a function map to constructor
|
||||
- **Automatic buffering**: Sends buffered audio when connection opens
|
||||
- **Automatic chunking**: Handles 8000-byte chunking internally
|
||||
|
||||
## See Also
|
||||
|
||||
- Design doc: `docs/plans/2025-01-16-agent-refactor-design.md`
|
||||
- Original implementation: `pi/agent/old-index.ts`
|
||||
284
src/agent/index.ts
Normal file
284
src/agent/index.ts
Normal file
|
|
@ -0,0 +1,284 @@
|
|||
import { Signal } from "../utils/signal"
|
||||
import type { AgentConfig, AgentEvent } from "./types"
|
||||
|
||||
type AgentState = "disconnected" | "connecting" | "connected"
|
||||
|
||||
export class Agent {
|
||||
#config: AgentConfig
|
||||
#state: AgentState = "disconnected"
|
||||
#ws?: WebSocket
|
||||
#audioBuffer: Uint8Array[] = []
|
||||
#chunkBuffer = new Uint8Array(0)
|
||||
#chunkSize = 8000
|
||||
|
||||
public readonly events = new Signal<AgentEvent>()
|
||||
public conversationId?: string
|
||||
|
||||
constructor(config: AgentConfig) {
|
||||
this.#config = config
|
||||
}
|
||||
|
||||
get isConnected(): boolean {
|
||||
return this.#state === "connected"
|
||||
}
|
||||
|
||||
start = async (): Promise<void> => {
|
||||
if (this.#state !== "disconnected") {
|
||||
return
|
||||
}
|
||||
|
||||
this.#state = "connecting"
|
||||
this.#audioBuffer = []
|
||||
this.conversationId = undefined
|
||||
|
||||
const wsUrl = `wss://api.elevenlabs.io/v1/convai/conversation?agent_id=${this.#config.agentId}`
|
||||
this.#ws = new WebSocket(wsUrl, {
|
||||
headers: { "xi-api-key": this.#config.apiKey },
|
||||
})
|
||||
|
||||
this.#ws.addEventListener("open", this.#handleOpen)
|
||||
this.#ws.addEventListener("message", this.#handleMessage)
|
||||
this.#ws.addEventListener("error", this.#handleError)
|
||||
this.#ws.addEventListener("close", this.#handleClose)
|
||||
}
|
||||
|
||||
stop = async (): Promise<void> => {
|
||||
if (this.#state === "disconnected") {
|
||||
return
|
||||
}
|
||||
|
||||
this.#ws?.close()
|
||||
this.#cleanup()
|
||||
}
|
||||
|
||||
sendAudio = (chunk: Uint8Array): void => {
|
||||
if (this.#state === "disconnected") {
|
||||
return
|
||||
}
|
||||
|
||||
if (this.#state === "connecting") {
|
||||
this.#audioBuffer.push(chunk)
|
||||
return
|
||||
}
|
||||
|
||||
// Chunk audio to appropriate size
|
||||
const newBuffer = new Uint8Array(this.#chunkBuffer.length + chunk.length)
|
||||
newBuffer.set(this.#chunkBuffer)
|
||||
newBuffer.set(chunk, this.#chunkBuffer.length)
|
||||
this.#chunkBuffer = newBuffer
|
||||
|
||||
while (this.#chunkBuffer.length >= this.#chunkSize) {
|
||||
const audioChunk = this.#chunkBuffer.slice(0, this.#chunkSize)
|
||||
this.#chunkBuffer = this.#chunkBuffer.slice(this.#chunkSize)
|
||||
|
||||
const base64Audio = Buffer.from(audioChunk).toString("base64")
|
||||
this.#send({ user_audio_chunk: base64Audio })
|
||||
}
|
||||
}
|
||||
|
||||
sendMessage = (text: string): void => {
|
||||
if (this.#state !== "connected") {
|
||||
return
|
||||
}
|
||||
|
||||
this.#send({ type: "user_message", text })
|
||||
}
|
||||
|
||||
sendContextUpdate = (text: string): void => {
|
||||
if (this.#state !== "connected") {
|
||||
return
|
||||
}
|
||||
|
||||
this.#send({ type: "contextual_update", text })
|
||||
}
|
||||
|
||||
#handleOpen = (): void => {
|
||||
this.#state = "connected"
|
||||
|
||||
// Send conversation config if provided
|
||||
if (this.#config.conversationConfig) {
|
||||
this.#send({
|
||||
type: "conversation_initiation_client_data",
|
||||
conversation_config_override: this.#config.conversationConfig.agentConfig,
|
||||
custom_llm_extra_body: this.#config.conversationConfig.customLlmExtraBody,
|
||||
dynamic_variables: this.#config.conversationConfig.dynamicVariables,
|
||||
})
|
||||
}
|
||||
|
||||
// Flush buffered audio
|
||||
for (const chunk of this.#audioBuffer) {
|
||||
const base64 = Buffer.from(chunk).toString("base64")
|
||||
this.#send({ user_audio_chunk: base64 })
|
||||
}
|
||||
this.#audioBuffer = []
|
||||
}
|
||||
|
||||
#handleMessage = async (event: MessageEvent): Promise<void> => {
|
||||
try {
|
||||
const message = JSON.parse(event.data as string)
|
||||
|
||||
if (message.type === "conversation_initiation_metadata") {
|
||||
const metadata = message.conversation_initiation_metadata_event
|
||||
this.conversationId = metadata.conversation_id
|
||||
this.events.emit({
|
||||
type: "connected",
|
||||
conversationId: metadata.conversation_id,
|
||||
audioFormat: {
|
||||
agent_output_audio_format: metadata.agent_output_audio_format,
|
||||
user_input_audio_format: metadata.user_input_audio_format,
|
||||
},
|
||||
})
|
||||
} else if (message.type === "user_transcript") {
|
||||
this.events.emit({
|
||||
type: "user_transcript",
|
||||
transcript: message.user_transcription_event.user_transcript,
|
||||
})
|
||||
} else if (message.type === "agent_response") {
|
||||
this.events.emit({
|
||||
type: "agent_response",
|
||||
response: message.agent_response_event.agent_response,
|
||||
})
|
||||
} else if (message.type === "agent_response_correction") {
|
||||
this.events.emit({
|
||||
type: "agent_response_correction",
|
||||
original: message.agent_response_correction_event.original_agent_response,
|
||||
corrected: message.agent_response_correction_event.corrected_agent_response,
|
||||
})
|
||||
} else if (message.type === "internal_tentative_agent_response") {
|
||||
this.events.emit({
|
||||
type: "tentative_agent_response",
|
||||
response: message.tentative_agent_response_internal_event.tentative_agent_response,
|
||||
})
|
||||
} else if (message.type === "audio") {
|
||||
this.events.emit({
|
||||
type: "audio",
|
||||
audioBase64: message.audio_event.audio_base_64,
|
||||
eventId: message.audio_event.event_id,
|
||||
})
|
||||
} else if (message.type === "interruption") {
|
||||
this.events.emit({
|
||||
type: "interruption",
|
||||
eventId: message.interruption_event.event_id,
|
||||
})
|
||||
} else if (message.type === "ping") {
|
||||
const eventId = message.ping_event.event_id
|
||||
this.#send({ type: "pong", event_id: eventId })
|
||||
this.events.emit({
|
||||
type: "ping",
|
||||
eventId,
|
||||
pingMs: message.ping_event.ping_ms,
|
||||
})
|
||||
} else if (message.type === "vad_score") {
|
||||
this.events.emit({
|
||||
type: "vad_score",
|
||||
score: message.vad_score_event.vad_score,
|
||||
})
|
||||
} else if (message.type === "client_tool_call") {
|
||||
await this.#handleToolCall(message.client_tool_call)
|
||||
}
|
||||
} catch (error) {
|
||||
this.events.emit({
|
||||
type: "error",
|
||||
error: error instanceof Error ? error : new Error(String(error)),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#handleToolCall = async (toolCall: {
|
||||
tool_name: string
|
||||
tool_call_id: string
|
||||
parameters: Record<string, unknown>
|
||||
}): Promise<void> => {
|
||||
const { tool_name, tool_call_id, parameters } = toolCall
|
||||
|
||||
this.events.emit({
|
||||
type: "tool_call",
|
||||
name: tool_name,
|
||||
args: parameters,
|
||||
callId: tool_call_id,
|
||||
})
|
||||
|
||||
const handler = this.#config.tools?.[tool_name]
|
||||
|
||||
if (!handler) {
|
||||
const error = new Error(`Tool ${tool_name} not found`)
|
||||
this.events.emit({
|
||||
type: "tool_error",
|
||||
name: tool_name,
|
||||
error,
|
||||
callId: tool_call_id,
|
||||
})
|
||||
this.#send({
|
||||
type: "client_tool_result",
|
||||
tool_call_id,
|
||||
result: error.message,
|
||||
is_error: true,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await handler(parameters)
|
||||
this.events.emit({
|
||||
type: "tool_result",
|
||||
name: tool_name,
|
||||
result,
|
||||
callId: tool_call_id,
|
||||
})
|
||||
this.#send({
|
||||
type: "client_tool_result",
|
||||
tool_call_id,
|
||||
result: typeof result === "string" ? result : JSON.stringify(result),
|
||||
is_error: false,
|
||||
})
|
||||
} catch (error) {
|
||||
const err = error instanceof Error ? error : new Error(String(error))
|
||||
this.events.emit({
|
||||
type: "tool_error",
|
||||
name: tool_name,
|
||||
error: err,
|
||||
callId: tool_call_id,
|
||||
})
|
||||
this.#send({
|
||||
type: "client_tool_result",
|
||||
tool_call_id,
|
||||
result: err.message,
|
||||
is_error: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#handleError = (error: Event): void => {
|
||||
this.events.emit({
|
||||
type: "error",
|
||||
error: new Error(`WebSocket error: ${error.type}`),
|
||||
})
|
||||
}
|
||||
|
||||
#handleClose = (): void => {
|
||||
this.#cleanup()
|
||||
this.events.emit({ type: "disconnected" })
|
||||
}
|
||||
|
||||
#cleanup = (): void => {
|
||||
this.#state = "disconnected"
|
||||
this.#audioBuffer = []
|
||||
this.#chunkBuffer = new Uint8Array(0)
|
||||
|
||||
if (this.#ws) {
|
||||
this.#ws.removeEventListener("open", this.#handleOpen)
|
||||
this.#ws.removeEventListener("message", this.#handleMessage)
|
||||
this.#ws.removeEventListener("error", this.#handleError)
|
||||
this.#ws.removeEventListener("close", this.#handleClose)
|
||||
this.#ws = undefined
|
||||
}
|
||||
}
|
||||
|
||||
#send = (data: object): void => {
|
||||
if (!this.#ws || this.#ws.readyState !== WebSocket.OPEN) {
|
||||
return
|
||||
}
|
||||
|
||||
this.#ws.send(JSON.stringify(data))
|
||||
}
|
||||
}
|
||||
30
src/agent/tools.ts
Normal file
30
src/agent/tools.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import OpenAI from "openai"
|
||||
import { ensure } from "../utils"
|
||||
|
||||
const apiKey = process.env.OPENAI_API_KEY
|
||||
ensure(apiKey, "OPENAI_API_KEY environment variable is required")
|
||||
|
||||
const client = new OpenAI({
|
||||
apiKey: process.env.OPENAI_API_KEY,
|
||||
})
|
||||
|
||||
export const searchWeb = async (query: string) => {
|
||||
const response = await client.responses.create({
|
||||
model: "gpt-4o-mini",
|
||||
tools: [
|
||||
{
|
||||
type: "web_search_preview",
|
||||
search_context_size: "low",
|
||||
user_location: {
|
||||
type: "approximate",
|
||||
country: "US",
|
||||
city: "San Francisco",
|
||||
region: "California",
|
||||
},
|
||||
},
|
||||
],
|
||||
input: `Search the web for: ${query}`,
|
||||
})
|
||||
|
||||
return response.output_text
|
||||
}
|
||||
42
src/agent/types.ts
Normal file
42
src/agent/types.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
// Agent configuration types
|
||||
export type AgentConfig = {
|
||||
agentId: string
|
||||
apiKey: string
|
||||
tools?: {
|
||||
[toolName: string]: (args: any) => Promise<unknown> | unknown
|
||||
}
|
||||
conversationConfig?: {
|
||||
agentConfig?: object
|
||||
ttsConfig?: object
|
||||
customLlmExtraBody?: { temperature?: number; max_tokens?: number; [key: string]: unknown }
|
||||
dynamicVariables?: Record<string, string | number | boolean>
|
||||
}
|
||||
}
|
||||
|
||||
// Event types emitted by the Agent
|
||||
export type AgentEvent =
|
||||
// Connection lifecycle
|
||||
| {
|
||||
type: "connected"
|
||||
conversationId: string
|
||||
audioFormat: {
|
||||
agent_output_audio_format?: string
|
||||
user_input_audio_format?: string
|
||||
}
|
||||
}
|
||||
| { type: "disconnected" }
|
||||
| { type: "error"; error: Error }
|
||||
// Conversation events
|
||||
| { type: "user_transcript"; transcript: string }
|
||||
| { type: "agent_response"; response: string }
|
||||
| { type: "agent_response_correction"; original: string; corrected: string }
|
||||
| { type: "tentative_agent_response"; response: string }
|
||||
| { type: "audio"; audioBase64: string; eventId: number }
|
||||
| { type: "interruption"; eventId: number }
|
||||
// Tool events
|
||||
| { type: "tool_call"; name: string; args: Record<string, unknown>; callId: string }
|
||||
| { type: "tool_result"; name: string; result: unknown; callId: string }
|
||||
| { type: "tool_error"; name: string; error: Error; callId: string }
|
||||
// Optional events
|
||||
| { type: "vad_score"; score: number }
|
||||
| { type: "ping"; eventId: number; pingMs: number }
|
||||
95
src/buzz/index.ts
Normal file
95
src/buzz/index.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
import { Player } from "./player.js"
|
||||
import { Recorder } from "./recorder.js"
|
||||
import {
|
||||
listDevices,
|
||||
calculateRMS,
|
||||
findDeviceByLabel,
|
||||
type AudioFormat,
|
||||
type Device,
|
||||
} from "./utils.js"
|
||||
|
||||
const defaultPlayer = (format?: AudioFormat) => Player.create({ format })
|
||||
|
||||
const player = (label: string, format?: AudioFormat) => Player.create({ label, format })
|
||||
|
||||
const defaultRecorder = (format?: AudioFormat) => Recorder.create({ format })
|
||||
|
||||
const recorder = (label: string, format?: AudioFormat) => Recorder.create({ label, format })
|
||||
|
||||
const getVolumeControl = async (cardNumber?: number): Promise<string> => {
|
||||
const output = cardNumber
|
||||
? await Bun.$`amixer -c ${cardNumber} scontrols`.text()
|
||||
: await Bun.$`amixer scontrols`.text()
|
||||
|
||||
const controls = output
|
||||
.split("\n")
|
||||
.map((line) => {
|
||||
const match = line.match(/Simple mixer control '([^']+)'/)
|
||||
return match?.[1]
|
||||
})
|
||||
.filter(Boolean)
|
||||
|
||||
const playbackControl = controls.find((c) => c !== "Capture")
|
||||
if (!playbackControl) {
|
||||
throw new Error("No playback mixer control found")
|
||||
}
|
||||
|
||||
return playbackControl
|
||||
}
|
||||
|
||||
const setVolume = async (volume: number, label?: string): Promise<void> => {
|
||||
const percent = Math.round(volume * 100)
|
||||
|
||||
let cardNumber: number | undefined
|
||||
if (label) {
|
||||
const device = await findDeviceByLabel(label)
|
||||
cardNumber = device.card
|
||||
}
|
||||
|
||||
const control = await getVolumeControl(cardNumber)
|
||||
|
||||
const result = cardNumber
|
||||
? await Bun.$`amixer -c ${cardNumber} sset ${control} ${percent}%`.quiet()
|
||||
: await Bun.$`amixer sset ${control} ${percent}%`.quiet()
|
||||
|
||||
if (result.exitCode !== 0) {
|
||||
throw new Error(`Failed to set volume: ${result.stderr}`)
|
||||
}
|
||||
}
|
||||
|
||||
const getVolume = async (label?: string): Promise<number> => {
|
||||
let cardNumber: number | undefined
|
||||
if (label) {
|
||||
const device = await findDeviceByLabel(label)
|
||||
cardNumber = device.card
|
||||
}
|
||||
|
||||
const control = await getVolumeControl(cardNumber)
|
||||
|
||||
const output = cardNumber
|
||||
? await Bun.$`amixer -c ${cardNumber} sget ${control}`.text()
|
||||
: await Bun.$`amixer sget ${control}`.text()
|
||||
|
||||
const match = output.match(/\[(\d+)%\]/)
|
||||
if (!match?.[1]) {
|
||||
throw new Error("Failed to parse volume from amixer output")
|
||||
}
|
||||
|
||||
return parseInt(match[1]) / 100
|
||||
}
|
||||
|
||||
const Buzz = {
|
||||
listDevices,
|
||||
defaultPlayer,
|
||||
player,
|
||||
defaultRecorder,
|
||||
recorder,
|
||||
setVolume,
|
||||
getVolume,
|
||||
calculateRMS,
|
||||
}
|
||||
|
||||
export default Buzz
|
||||
export type { Device, AudioFormat }
|
||||
export { type Player } from "./player.js"
|
||||
export { type Recorder } from "./recorder.js"
|
||||
178
src/buzz/player.ts
Normal file
178
src/buzz/player.ts
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
import {
|
||||
DEFAULT_AUDIO_FORMAT,
|
||||
type AudioFormat,
|
||||
type Playback,
|
||||
type StreamingPlayback,
|
||||
} from "./utils.js"
|
||||
import { listDevices, findDeviceByLabel, streamTone } from "./utils.js"
|
||||
|
||||
export class Player {
|
||||
#deviceId?: string
|
||||
#format: Required<AudioFormat>
|
||||
|
||||
static async create({ label, format }: { label?: string; format?: AudioFormat } = {}) {
|
||||
const devices = await listDevices()
|
||||
const playbackDevices = devices.filter((d) => d.type === "playback")
|
||||
|
||||
if (playbackDevices.length === 0) {
|
||||
throw new Error("No playback devices found")
|
||||
}
|
||||
|
||||
let deviceId: string | undefined
|
||||
if (label) {
|
||||
const device = await findDeviceByLabel(label, "playback")
|
||||
deviceId = device.id
|
||||
}
|
||||
|
||||
return new Player({ deviceId, format })
|
||||
}
|
||||
|
||||
constructor({ deviceId, format }: { deviceId?: string; format?: AudioFormat }) {
|
||||
this.#deviceId = deviceId
|
||||
this.#format = {
|
||||
format: format?.format ?? DEFAULT_AUDIO_FORMAT.format,
|
||||
sampleRate: format?.sampleRate ?? DEFAULT_AUDIO_FORMAT.sampleRate,
|
||||
channels: format?.channels ?? DEFAULT_AUDIO_FORMAT.channels,
|
||||
}
|
||||
}
|
||||
|
||||
#commonArgs(): string[] {
|
||||
const args = []
|
||||
if (this.#deviceId) {
|
||||
args.push("-D", this.#deviceId)
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
async play(filePath: string, { repeat }: { repeat?: boolean } = {}): Promise<Playback> {
|
||||
const args = [...this.#commonArgs(), filePath]
|
||||
|
||||
let proc = Bun.spawn(["aplay", ...args], { stdout: "pipe", stderr: "pipe" })
|
||||
|
||||
const handle: Playback = {
|
||||
isPlaying: true,
|
||||
stop: async () => {
|
||||
if (!handle.isPlaying) return
|
||||
handle.isPlaying = false
|
||||
proc.kill()
|
||||
await proc.exited
|
||||
},
|
||||
finished: async () => {
|
||||
await proc.exited
|
||||
},
|
||||
}
|
||||
|
||||
const loop = async () => {
|
||||
while (handle.isPlaying) {
|
||||
await proc.exited
|
||||
if (!handle.isPlaying) break
|
||||
|
||||
if (repeat) {
|
||||
proc = Bun.spawn(["aplay", ...args], { stdout: "pipe", stderr: "pipe" })
|
||||
} else {
|
||||
handle.isPlaying = false
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loop()
|
||||
|
||||
return handle
|
||||
}
|
||||
|
||||
async playTone(frequencies: number[], duration: number): Promise<Playback> {
|
||||
if (duration !== Infinity && duration <= 0) {
|
||||
throw new Error("Duration must be greater than 0 or Infinity")
|
||||
}
|
||||
|
||||
const args = [
|
||||
...this.#commonArgs(),
|
||||
"-f",
|
||||
this.#format.format,
|
||||
"-r",
|
||||
this.#format.sampleRate.toString(),
|
||||
"-c",
|
||||
this.#format.channels.toString(),
|
||||
"-t",
|
||||
"raw",
|
||||
"-",
|
||||
]
|
||||
|
||||
const proc = Bun.spawn(["aplay", ...args], {
|
||||
stdin: "pipe",
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
})
|
||||
|
||||
// Start streaming tone in background
|
||||
streamTone(proc.stdin, frequencies, duration, this.#format)
|
||||
|
||||
const handle: Playback = {
|
||||
isPlaying: true,
|
||||
stop: async () => {
|
||||
if (!handle.isPlaying) return
|
||||
handle.isPlaying = false
|
||||
try {
|
||||
proc.stdin.end()
|
||||
} catch (e) {}
|
||||
proc.kill()
|
||||
await proc.exited
|
||||
},
|
||||
finished: async () => {
|
||||
await proc.exited
|
||||
},
|
||||
}
|
||||
|
||||
proc.exited.then(() => {
|
||||
handle.isPlaying = false
|
||||
})
|
||||
|
||||
return handle
|
||||
}
|
||||
|
||||
playStream(): StreamingPlayback {
|
||||
const args = [
|
||||
...this.#commonArgs(),
|
||||
"-f",
|
||||
this.#format.format,
|
||||
"-r",
|
||||
this.#format.sampleRate.toString(),
|
||||
"-c",
|
||||
this.#format.channels.toString(),
|
||||
"-t",
|
||||
"raw",
|
||||
"-",
|
||||
]
|
||||
|
||||
const proc = Bun.spawn(["aplay", ...args], {
|
||||
stdin: "pipe",
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
})
|
||||
|
||||
const handle: StreamingPlayback = {
|
||||
isPlaying: true,
|
||||
write: (chunk: Uint8Array) => {
|
||||
if (handle.isPlaying) {
|
||||
proc.stdin.write(chunk)
|
||||
}
|
||||
},
|
||||
stop: async () => {
|
||||
if (!handle.isPlaying) return
|
||||
handle.isPlaying = false
|
||||
try {
|
||||
proc.stdin.end()
|
||||
} catch (e) {}
|
||||
proc.kill()
|
||||
await proc.exited
|
||||
},
|
||||
}
|
||||
|
||||
proc.exited.then(() => {
|
||||
handle.isPlaying = false
|
||||
})
|
||||
|
||||
return handle
|
||||
}
|
||||
}
|
||||
114
src/buzz/recorder.ts
Normal file
114
src/buzz/recorder.ts
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
import {
|
||||
DEFAULT_AUDIO_FORMAT,
|
||||
type AudioFormat,
|
||||
type StreamingRecording,
|
||||
type FileRecording,
|
||||
} from "./utils.js"
|
||||
import { listDevices, findDeviceByLabel } from "./utils.js"
|
||||
|
||||
export class Recorder {
|
||||
#deviceId?: string
|
||||
#format: Required<AudioFormat>
|
||||
|
||||
static async create({ label, format }: { label?: string; format?: AudioFormat } = {}) {
|
||||
const devices = await listDevices()
|
||||
const captureDevices = devices.filter((d) => d.type === "capture")
|
||||
|
||||
if (captureDevices.length === 0) {
|
||||
throw new Error("No capture devices found")
|
||||
}
|
||||
|
||||
let deviceId: string | undefined
|
||||
if (label) {
|
||||
const device = await findDeviceByLabel(label, "capture")
|
||||
deviceId = device.id
|
||||
}
|
||||
|
||||
return new Recorder({ deviceId, format })
|
||||
}
|
||||
|
||||
constructor({ deviceId, format }: { deviceId?: string; format?: AudioFormat }) {
|
||||
this.#deviceId = deviceId
|
||||
this.#format = {
|
||||
format: format?.format ?? DEFAULT_AUDIO_FORMAT.format,
|
||||
sampleRate: format?.sampleRate ?? DEFAULT_AUDIO_FORMAT.sampleRate,
|
||||
channels: format?.channels ?? DEFAULT_AUDIO_FORMAT.channels,
|
||||
}
|
||||
}
|
||||
|
||||
#commonArgs(): string[] {
|
||||
const args = []
|
||||
if (this.#deviceId) {
|
||||
args.push("-D", this.#deviceId)
|
||||
}
|
||||
args.push(
|
||||
"-f",
|
||||
this.#format.format,
|
||||
"-r",
|
||||
this.#format.sampleRate.toString(),
|
||||
"-c",
|
||||
this.#format.channels.toString()
|
||||
)
|
||||
return args
|
||||
}
|
||||
|
||||
#startStreaming(): StreamingRecording {
|
||||
const args = [...this.#commonArgs(), "-t", "raw", "-"]
|
||||
|
||||
const proc = Bun.spawn(["arecord", ...args], {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
})
|
||||
|
||||
const handle: StreamingRecording = {
|
||||
isRecording: true,
|
||||
stream: () => proc.stdout,
|
||||
stop: async () => {
|
||||
if (!handle.isRecording) return
|
||||
handle.isRecording = false
|
||||
proc.kill()
|
||||
await proc.exited
|
||||
},
|
||||
}
|
||||
|
||||
proc.exited.then(() => {
|
||||
handle.isRecording = false
|
||||
})
|
||||
|
||||
return handle
|
||||
}
|
||||
|
||||
#startFileRecording(outputFile: string): FileRecording {
|
||||
const args = [...this.#commonArgs(), "-t", "wav", outputFile]
|
||||
|
||||
const proc = Bun.spawn(["arecord", ...args], {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
})
|
||||
|
||||
const handle: FileRecording = {
|
||||
isRecording: true,
|
||||
stop: async () => {
|
||||
if (!handle.isRecording) return
|
||||
handle.isRecording = false
|
||||
proc.kill()
|
||||
await proc.exited
|
||||
},
|
||||
}
|
||||
|
||||
proc.exited.then(() => {
|
||||
handle.isRecording = false
|
||||
})
|
||||
|
||||
return handle
|
||||
}
|
||||
|
||||
start(): StreamingRecording
|
||||
start(outputFile: string): FileRecording
|
||||
start(outputFile?: string): StreamingRecording | FileRecording {
|
||||
if (outputFile) {
|
||||
return this.#startFileRecording(outputFile)
|
||||
}
|
||||
return this.#startStreaming()
|
||||
}
|
||||
}
|
||||
166
src/buzz/utils.ts
Normal file
166
src/buzz/utils.ts
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
// Audio format configuration
|
||||
export type AudioFormat = {
|
||||
format?: string;
|
||||
sampleRate?: number;
|
||||
channels?: number;
|
||||
};
|
||||
|
||||
// Default audio format for recordings and tone generation
|
||||
export const DEFAULT_AUDIO_FORMAT = {
|
||||
format: 'S16_LE',
|
||||
sampleRate: 16000,
|
||||
channels: 1,
|
||||
} as const;
|
||||
|
||||
// Device from ALSA listing
|
||||
export type Device = {
|
||||
id: string; // "default" or "plughw:1,0"
|
||||
card: number; // ALSA card number
|
||||
device: number; // ALSA device number
|
||||
label: string; // Human-readable name
|
||||
type: 'playback' | 'capture';
|
||||
};
|
||||
|
||||
// Playback control handle
|
||||
export type Playback = {
|
||||
isPlaying: boolean;
|
||||
stop: () => Promise<void>;
|
||||
finished: () => Promise<void>;
|
||||
};
|
||||
|
||||
// Streaming playback handle
|
||||
export type StreamingPlayback = {
|
||||
isPlaying: boolean;
|
||||
write: (chunk: Uint8Array) => void;
|
||||
stop: () => Promise<void>;
|
||||
};
|
||||
|
||||
// Streaming recording control handle
|
||||
export type StreamingRecording = {
|
||||
isRecording: boolean;
|
||||
stream: () => ReadableStream<Uint8Array>;
|
||||
stop: () => Promise<void>;
|
||||
};
|
||||
|
||||
// File recording control handle
|
||||
export type FileRecording = {
|
||||
isRecording: boolean;
|
||||
stop: () => Promise<void>;
|
||||
};
|
||||
|
||||
const parseDeviceLine = (line: string, type: 'playback' | 'capture'): Device | undefined => {
|
||||
if (!line.startsWith('card ')) return undefined;
|
||||
|
||||
const match = line.match(/^card (\d+):\s+\w+\s+\[(.+?)\],\s+device (\d+):/);
|
||||
if (!match) return undefined;
|
||||
|
||||
const [, cardStr, label, deviceStr] = match;
|
||||
|
||||
if (!cardStr || !label || !deviceStr) return undefined;
|
||||
|
||||
const card = parseInt(cardStr);
|
||||
const device = parseInt(deviceStr);
|
||||
|
||||
return {
|
||||
id: `plughw:${card},${device}`,
|
||||
card,
|
||||
device,
|
||||
label,
|
||||
type,
|
||||
};
|
||||
};
|
||||
|
||||
const parseAlsaDevices = (output: string, type: 'playback' | 'capture'): Device[] => {
|
||||
return output
|
||||
.split('\n')
|
||||
.map(line => parseDeviceLine(line, type))
|
||||
.filter(device => device !== undefined);
|
||||
};
|
||||
|
||||
export const listDevices = async (): Promise<Device[]> => {
|
||||
const playbackOutput = await Bun.$`aplay -l`.text();
|
||||
const captureOutput = await Bun.$`arecord -l`.text();
|
||||
|
||||
const playback = parseAlsaDevices(playbackOutput, 'playback');
|
||||
const capture = parseAlsaDevices(captureOutput, 'capture');
|
||||
|
||||
return [...playback, ...capture];
|
||||
};
|
||||
|
||||
export const findDeviceByLabel = async (
|
||||
label: string,
|
||||
type?: 'playback' | 'capture'
|
||||
): Promise<Device> => {
|
||||
const devices = await listDevices();
|
||||
const device = devices.find(d =>
|
||||
d.label === label && (!type || d.type === type)
|
||||
);
|
||||
|
||||
if (!device) {
|
||||
const typeStr = type ? ` (type: ${type})` : '';
|
||||
throw new Error(`Device not found: ${label}${typeStr}`);
|
||||
}
|
||||
|
||||
return device;
|
||||
};
|
||||
|
||||
export const calculateRMS = (chunk: Uint8Array): number => {
|
||||
const samples = new Int16Array(chunk.buffer, chunk.byteOffset, chunk.byteLength / 2);
|
||||
let sum = 0;
|
||||
|
||||
for (const sample of samples) {
|
||||
sum += sample * sample;
|
||||
}
|
||||
|
||||
return Math.sqrt(sum / samples.length);
|
||||
};
|
||||
|
||||
export const generateToneSamples = (
|
||||
frequencies: number[],
|
||||
sampleRate: number,
|
||||
durationSeconds: number
|
||||
): Uint8Array => {
|
||||
const numSamples = Math.floor(sampleRate * durationSeconds);
|
||||
const buffer = new ArrayBuffer(numSamples * 2); // 2 bytes per S16_LE sample
|
||||
const samples = new Int16Array(buffer);
|
||||
|
||||
for (let i = 0; i < numSamples; i++) {
|
||||
const t = i / sampleRate;
|
||||
let value = 0;
|
||||
|
||||
// Mix all frequencies together
|
||||
for (const freq of frequencies) {
|
||||
value += Math.sin(2 * Math.PI * freq * t);
|
||||
}
|
||||
|
||||
// Average and scale to Int16 range
|
||||
value = (value / frequencies.length) * 32767;
|
||||
samples[i] = Math.round(value);
|
||||
}
|
||||
|
||||
return new Uint8Array(buffer);
|
||||
};
|
||||
|
||||
export const streamTone = async (
|
||||
stream: { write: (chunk: Uint8Array) => void; end: () => void },
|
||||
frequencies: number[],
|
||||
durationMs: number,
|
||||
format: Required<AudioFormat>
|
||||
): Promise<void> => {
|
||||
const infinite = durationMs === Infinity;
|
||||
const durationSeconds = durationMs / 1000;
|
||||
|
||||
// Continuous tone
|
||||
const samples = generateToneSamples(frequencies, format.sampleRate, infinite ? 1 : durationSeconds);
|
||||
|
||||
if (infinite) {
|
||||
// Loop 1-second chunks forever
|
||||
while (true) {
|
||||
stream.write(samples);
|
||||
await Bun.sleep(1000);
|
||||
}
|
||||
} else {
|
||||
stream.write(samples);
|
||||
stream.end();
|
||||
}
|
||||
};
|
||||
166
src/operator.ts
Executable file
166
src/operator.ts
Executable file
|
|
@ -0,0 +1,166 @@
|
|||
import Buzz from "./buzz/index.ts"
|
||||
import type { Playback } from "./buzz/utils.ts"
|
||||
import { Agent } from "./agent/index.ts"
|
||||
import { searchWeb } from "./agent/tools.ts"
|
||||
import { getSound, WaitingSounds } from "./utils/waiting-sounds.ts"
|
||||
|
||||
const runPhoneSystem = async (agentId: string, apiKey: string) => {
|
||||
console.log("📞 Phone System Starting\n")
|
||||
await Buzz.setVolume(0.4)
|
||||
|
||||
const recorder = await Buzz.defaultRecorder()
|
||||
const player = await Buzz.defaultPlayer()
|
||||
|
||||
const agent = new Agent({
|
||||
agentId,
|
||||
apiKey,
|
||||
tools: {
|
||||
search_web: (args: { query: string }) => searchWeb(args.query),
|
||||
},
|
||||
})
|
||||
|
||||
let currentDialtone: Playback | undefined
|
||||
let currentBackgroundNoise: Playback | undefined
|
||||
let currentPlayback = player.playStream()
|
||||
const waitingIndicator = new WaitingSounds(player)
|
||||
|
||||
// Set up agent event listeners
|
||||
agent.events.connect((event) => {
|
||||
switch (event.type) {
|
||||
case "connected":
|
||||
console.log("✅ Connected to AI agent\n")
|
||||
break
|
||||
|
||||
case "user_transcript":
|
||||
console.log(`👤 You: ${event.transcript}`)
|
||||
break
|
||||
|
||||
case "agent_response":
|
||||
console.log(`🤖 Agent: ${event.response}`)
|
||||
break
|
||||
|
||||
case "audio":
|
||||
waitingIndicator.stop()
|
||||
const audioBuffer = Buffer.from(event.audioBase64, "base64")
|
||||
currentPlayback.write(audioBuffer)
|
||||
break
|
||||
|
||||
case "interruption":
|
||||
console.log("🛑 User interrupted")
|
||||
currentPlayback?.stop()
|
||||
currentPlayback = player.playStream() // Reset playback stream
|
||||
break
|
||||
|
||||
case "tool_call":
|
||||
waitingIndicator.start()
|
||||
console.log(`🔧 Tool call: ${event.name}(${JSON.stringify(event.args)})`)
|
||||
break
|
||||
|
||||
case "tool_result":
|
||||
console.log(`✅ Tool result: ${JSON.stringify(event.result)}`)
|
||||
break
|
||||
|
||||
case "tool_error":
|
||||
console.error(`❌ Tool error: ${event.error}`)
|
||||
break
|
||||
|
||||
case "disconnected":
|
||||
console.log("\n👋 Conversation ended, returning to dialtone\n")
|
||||
currentPlayback?.stop()
|
||||
state = "WAITING_FOR_VOICE"
|
||||
startDialtone()
|
||||
break
|
||||
|
||||
case "error":
|
||||
console.error("Agent error:", event.error)
|
||||
}
|
||||
})
|
||||
|
||||
const recording = recorder.start()
|
||||
const audioStream = recording.stream()
|
||||
console.log("🎤 Recording started\n")
|
||||
|
||||
type State = "WAITING_FOR_VOICE" | "IN_CONVERSATION"
|
||||
let state: State = "WAITING_FOR_VOICE"
|
||||
let preConnectionBuffer: Uint8Array[] = []
|
||||
|
||||
const startDialtone = async () => {
|
||||
console.log("🔊 Playing dialtone (waiting for speech)...\n")
|
||||
await currentBackgroundNoise?.stop()
|
||||
currentBackgroundNoise = undefined
|
||||
currentDialtone = await player.playTone([350, 440], Infinity)
|
||||
}
|
||||
|
||||
const stopDialtone = async () => {
|
||||
await currentDialtone?.stop()
|
||||
currentDialtone = undefined
|
||||
currentBackgroundNoise = await player.play(getSound("background"), {
|
||||
repeat: true,
|
||||
})
|
||||
}
|
||||
|
||||
const startConversation = async () => {
|
||||
stopDialtone()
|
||||
|
||||
state = "IN_CONVERSATION"
|
||||
await agent.start()
|
||||
|
||||
// Send pre-buffered audio
|
||||
for (const chunk of preConnectionBuffer) {
|
||||
agent.sendAudio(chunk)
|
||||
}
|
||||
preConnectionBuffer = []
|
||||
}
|
||||
|
||||
await startDialtone()
|
||||
|
||||
const vadThreshold = 5000
|
||||
const maxPreBufferChunks = 4 // Keep ~1 second of audio before speech detection
|
||||
|
||||
for await (const chunk of audioStream) {
|
||||
if (state === "WAITING_FOR_VOICE") {
|
||||
// Keep a rolling buffer of recent audio
|
||||
preConnectionBuffer.push(chunk)
|
||||
if (preConnectionBuffer.length > maxPreBufferChunks) {
|
||||
preConnectionBuffer.shift()
|
||||
}
|
||||
|
||||
const rms = Buzz.calculateRMS(chunk)
|
||||
if (rms > vadThreshold) {
|
||||
console.log(`🗣️ Speech detected! (RMS: ${Math.round(rms)})`)
|
||||
await startConversation()
|
||||
}
|
||||
} else if (state === "IN_CONVERSATION") {
|
||||
agent.sendAudio(chunk)
|
||||
}
|
||||
}
|
||||
|
||||
const cleanup = async () => {
|
||||
console.log("\n\n🛑 Shutting down phone system...")
|
||||
await currentDialtone?.stop()
|
||||
await currentBackgroundNoise?.stop()
|
||||
await currentPlayback?.stop()
|
||||
await agent.stop()
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
process.on("SIGINT", cleanup)
|
||||
}
|
||||
|
||||
const apiKey = process.env.ELEVEN_API_KEY
|
||||
const agentId = process.env.ELEVEN_AGENT_ID
|
||||
|
||||
if (!apiKey) {
|
||||
console.error("❌ Error: ELEVEN_API_KEY environment variable is required")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (!agentId) {
|
||||
console.error(
|
||||
"❌ Error: ELEVEN_AGENT_ID environELEVEN_AGENT_ID=agent_5601k4taw2cvfjzrz6snxpgeh7x8 ELEVEN_API_KEY=sk_0313740f112c5992cb62ed96c974ab19b5916f1ea172471fment variable is required"
|
||||
)
|
||||
console.error(" Create an agent at https://elevenlabs.io/app/conversational-ai")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
await runPhoneSystem(agentId, apiKey)
|
||||
238
src/services/ap-monitor.ts
Normal file
238
src/services/ap-monitor.ts
Normal file
|
|
@ -0,0 +1,238 @@
|
|||
#!/usr/bin/env bun
|
||||
import { $ } from "bun"
|
||||
|
||||
// Configuration
|
||||
const CONFIG = {
|
||||
checkInterval: 10_000, // 10 seconds
|
||||
ap: {
|
||||
ip: "192.168.4.1",
|
||||
dhcpRange: "192.168.4.2,192.168.4.20",
|
||||
channel: 7,
|
||||
},
|
||||
}
|
||||
|
||||
// Get AP SSID from hostname
|
||||
const hostname = (await $`hostname`.text()).trim()
|
||||
const AP_SSID = `${hostname}-setup`
|
||||
const AP_CONNECTION_NAME = `${AP_SSID}-ap`
|
||||
|
||||
console.log("Starting WiFi AP Monitor...")
|
||||
console.log(`AP SSID will be: ${AP_SSID}`)
|
||||
console.log(`AP connection name: ${AP_CONNECTION_NAME}`)
|
||||
console.log(`Checking connectivity every ${CONFIG.checkInterval / 1000} seconds\n`)
|
||||
|
||||
let apRunning = false
|
||||
|
||||
async function isConnectedToWiFi(): Promise<boolean> {
|
||||
try {
|
||||
// Check if wlan0 is connected to a WiFi network AS A CLIENT (not in AP mode)
|
||||
// We need to check if there's an active connection that is NOT our AP
|
||||
const activeConnections = await $`nmcli -t -f NAME,TYPE connection show --active`.quiet().text()
|
||||
const lines = activeConnections.trim().split("\n")
|
||||
|
||||
for (const line of lines) {
|
||||
const [name, type] = line.split(":")
|
||||
// Check if there's a wifi connection that's NOT our AP
|
||||
if (type === "802-11-wireless" && name !== AP_CONNECTION_NAME) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
} catch (error) {
|
||||
console.error("[isConnectedToWiFi] ERROR: Failed to check WiFi status")
|
||||
console.error(error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function startAP() {
|
||||
if (apRunning) {
|
||||
console.log("[startAP] AP already running, skipping")
|
||||
return
|
||||
}
|
||||
|
||||
console.log("🔴 Not connected to WiFi - Starting WiFi AP...")
|
||||
console.log(`[startAP] AP SSID: ${AP_SSID}`)
|
||||
console.log(`[startAP] AP Connection: ${AP_CONNECTION_NAME}`)
|
||||
|
||||
try {
|
||||
// Check if connection profile already exists
|
||||
console.log("[startAP] Checking if connection profile exists...")
|
||||
const existsResult = await $`sudo nmcli connection show ${AP_CONNECTION_NAME}`.nothrow().quiet()
|
||||
|
||||
if (existsResult.exitCode !== 0) {
|
||||
console.log("[startAP] Connection profile does not exist, creating...")
|
||||
|
||||
// Create AP connection profile (open network, no password)
|
||||
await $`sudo nmcli connection add type wifi ifname wlan0 con-name ${AP_CONNECTION_NAME} autoconnect no ssid ${AP_SSID} 802-11-wireless.mode ap 802-11-wireless.band bg 802-11-wireless.channel ${CONFIG.ap.channel} ipv4.method shared ipv4.addresses ${CONFIG.ap.ip}/24`
|
||||
console.log("[startAP] ✓ Connection profile created (open network)")
|
||||
} else {
|
||||
console.log("[startAP] Connection profile already exists, reusing")
|
||||
}
|
||||
|
||||
// Bring up the AP
|
||||
console.log("[startAP] Bringing up AP connection...")
|
||||
await $`sudo nmcli connection up ${AP_CONNECTION_NAME}`
|
||||
console.log("[startAP] ✓ AP connection activated")
|
||||
|
||||
apRunning = true
|
||||
console.log(`✓ AP started successfully!`)
|
||||
console.log(` SSID: ${AP_SSID}`)
|
||||
console.log(` Password: None (open network)`)
|
||||
console.log(` Connect and visit: http://${CONFIG.ap.ip}\n`)
|
||||
} catch (error) {
|
||||
console.error("[startAP] ERROR: Failed to start AP")
|
||||
console.error(error)
|
||||
apRunning = false
|
||||
}
|
||||
}
|
||||
|
||||
async function stopAP() {
|
||||
if (!apRunning) {
|
||||
console.log("[stopAP] AP not running, skipping")
|
||||
return
|
||||
}
|
||||
|
||||
console.log("🟢 Connected to WiFi - Stopping AP...")
|
||||
console.log(`[stopAP] Bringing down connection: ${AP_CONNECTION_NAME}`)
|
||||
|
||||
try {
|
||||
// Bring down the AP connection
|
||||
await $`sudo nmcli connection down ${AP_CONNECTION_NAME}`.nothrow()
|
||||
console.log("[stopAP] ✓ AP connection deactivated")
|
||||
|
||||
apRunning = false
|
||||
console.log("✓ AP stopped successfully\n")
|
||||
} catch (error) {
|
||||
console.error("[stopAP] ERROR: Failed to stop AP")
|
||||
console.error(error)
|
||||
// Set to false anyway to allow retry
|
||||
apRunning = false
|
||||
}
|
||||
}
|
||||
|
||||
async function checkAndManageAP() {
|
||||
const connected = await isConnectedToWiFi()
|
||||
|
||||
console.log(
|
||||
`[checkAndManageAP] WiFi: ${connected ? "connected" : "disconnected"}, AP: ${
|
||||
apRunning ? "running" : "stopped"
|
||||
}`
|
||||
)
|
||||
|
||||
if (connected && apRunning) {
|
||||
console.log("[checkAndManageAP] WiFi connected and AP running → stopping AP")
|
||||
await stopAP()
|
||||
} else if (!connected && !apRunning) {
|
||||
console.log("[checkAndManageAP] WiFi disconnected and AP stopped → starting AP")
|
||||
await startAP()
|
||||
} else if (!connected && apRunning) {
|
||||
// AP is running but no WiFi client connection
|
||||
// Check if our saved WiFi network is available
|
||||
console.log("[checkAndManageAP] AP running, checking if saved WiFi is available...")
|
||||
const savedNetwork = await findAvailableSavedNetwork()
|
||||
if (savedNetwork) {
|
||||
console.log(
|
||||
`[checkAndManageAP] Found available saved network: ${savedNetwork}, attempting connection...`
|
||||
)
|
||||
|
||||
// Try to connect first
|
||||
const connected = await tryConnect(savedNetwork)
|
||||
|
||||
if (connected) {
|
||||
console.log(`[checkAndManageAP] Successfully connected to ${savedNetwork}, stopping AP...`)
|
||||
await stopAP()
|
||||
} else {
|
||||
console.log(`[checkAndManageAP] Failed to connect to ${savedNetwork}, keeping AP running`)
|
||||
// Delete the failed connection profile
|
||||
try {
|
||||
await $`sudo nmcli connection delete ${savedNetwork}`.nothrow()
|
||||
console.log(`[checkAndManageAP] Deleted failed connection profile for ${savedNetwork}`)
|
||||
} catch (error) {
|
||||
console.error(`[checkAndManageAP] Failed to delete connection profile:`, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function findAvailableSavedNetwork(): Promise<string | null> {
|
||||
try {
|
||||
// Get all saved WiFi connections (exclude our AP)
|
||||
const savedConnections = await $`nmcli -t -f NAME,TYPE connection show`.quiet().text()
|
||||
const lines = savedConnections.trim().split("\n")
|
||||
|
||||
const savedWiFiNames: string[] = []
|
||||
for (const line of lines) {
|
||||
const [name, type] = line.split(":")
|
||||
if (type === "802-11-wireless" && name !== AP_CONNECTION_NAME) {
|
||||
savedWiFiNames.push(name!)
|
||||
}
|
||||
}
|
||||
|
||||
if (savedWiFiNames.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Get the actual SSIDs for logging
|
||||
const savedSSIDs: string[] = []
|
||||
for (const savedName of savedWiFiNames) {
|
||||
const connInfo = await $`nmcli -t -f 802-11-wireless.ssid connection show ${savedName}`
|
||||
.quiet()
|
||||
.nothrow()
|
||||
.text()
|
||||
const ssid = connInfo.split(":")[1]?.trim()
|
||||
if (ssid) {
|
||||
savedSSIDs.push(ssid)
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[findAvailableSavedNetwork] Saved WiFi networks: ${savedSSIDs.join(", ")}`)
|
||||
|
||||
// Scan for available networks
|
||||
const scanResult = await $`nmcli -t -f SSID device wifi list`.quiet().nothrow().text()
|
||||
const availableSSIDs = scanResult.trim().split("\n")
|
||||
|
||||
// Check if any saved network is available
|
||||
for (const savedName of savedWiFiNames) {
|
||||
// NetworkManager connection names often match SSIDs, but let's also check the connection's SSID
|
||||
const connInfo = await $`nmcli -t -f 802-11-wireless.ssid connection show ${savedName}`
|
||||
.quiet()
|
||||
.nothrow()
|
||||
.text()
|
||||
const ssid = connInfo.split(":")[1]?.trim()
|
||||
|
||||
if (ssid && availableSSIDs.includes(ssid)) {
|
||||
console.log(`[findAvailableSavedNetwork] Found available network: ${ssid}`)
|
||||
return savedName // Return the connection name
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
} catch (error) {
|
||||
console.error("[findAvailableSavedNetwork] ERROR checking for WiFi")
|
||||
console.error(error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function tryConnect(connectionName: string): Promise<boolean> {
|
||||
try {
|
||||
console.log(`[tryConnect] Attempting to connect to ${connectionName}...`)
|
||||
await $`sudo nmcli connection up ${connectionName}`
|
||||
console.log(`[tryConnect] Successfully connected to ${connectionName}`)
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error(`[tryConnect] Failed to connect to ${connectionName}:`, error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Initial check
|
||||
await checkAndManageAP()
|
||||
|
||||
// Check periodically
|
||||
setInterval(checkAndManageAP, CONFIG.checkInterval)
|
||||
|
||||
console.log("Monitor running...")
|
||||
74
src/services/server/components/ConnectingPage.tsx
Normal file
74
src/services/server/components/ConnectingPage.tsx
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import { Layout } from "./Layout";
|
||||
|
||||
type ConnectingPageProps = {
|
||||
ssid: string;
|
||||
};
|
||||
|
||||
export const ConnectingPage = ({ ssid }: ConnectingPageProps) => (
|
||||
<Layout title="Connecting...">
|
||||
<div id="connecting-state">
|
||||
<h1>⏳ Connecting to WiFi...</h1>
|
||||
<p>
|
||||
<strong>SSID:</strong> {ssid}
|
||||
</p>
|
||||
<p id="status">Testing connection... <span id="countdown">10</span>s remaining</p>
|
||||
<p>
|
||||
<small>Waiting to see if connection succeeds...</small>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div id="success-state" style="display: none;">
|
||||
<h1>✓ Connection Successful!</h1>
|
||||
<p>
|
||||
<strong>Connected to:</strong> {ssid}
|
||||
</p>
|
||||
<p>The Pi has switched to the new network.</p>
|
||||
<hr />
|
||||
<h3>Next Steps:</h3>
|
||||
<ol>
|
||||
<li>Switch your device to the <strong>{ssid}</strong> network</li>
|
||||
<li>Visit <a href="http://yellow-phone.local">http://yellow-phone.local</a></li>
|
||||
</ol>
|
||||
<p>
|
||||
<small>The AP will shut down automatically since the Pi is now connected to WiFi.</small>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div id="error-state" style="display: none;">
|
||||
<h1>❌ Connection Failed</h1>
|
||||
<p>Could not connect to <strong>{ssid}</strong></p>
|
||||
<p>The password may be incorrect, or the network is out of range.</p>
|
||||
<p>The AP is still running - you can try again.</p>
|
||||
<a href="/" role="button">← Try Again</a>
|
||||
</div>
|
||||
|
||||
<script>{`
|
||||
let countdown = 10;
|
||||
const countdownEl = document.getElementById('countdown');
|
||||
const connectingState = document.getElementById('connecting-state');
|
||||
const successState = document.getElementById('success-state');
|
||||
const errorState = document.getElementById('error-state');
|
||||
|
||||
const interval = setInterval(async () => {
|
||||
try {
|
||||
await fetch('/ping');
|
||||
// Still connected - decrement countdown
|
||||
countdown--;
|
||||
if (countdownEl) countdownEl.textContent = countdown;
|
||||
|
||||
if (countdown <= 0) {
|
||||
// 10 seconds passed and still connected = connection failed
|
||||
clearInterval(interval);
|
||||
connectingState.style.display = 'none';
|
||||
errorState.style.display = 'block';
|
||||
}
|
||||
} catch (e) {
|
||||
// Connection died = network switch happened = success!
|
||||
clearInterval(interval);
|
||||
connectingState.style.display = 'none';
|
||||
successState.style.display = 'block';
|
||||
}
|
||||
}, 1000);
|
||||
`}</script>
|
||||
</Layout>
|
||||
);
|
||||
53
src/services/server/components/IndexPage.tsx
Normal file
53
src/services/server/components/IndexPage.tsx
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import { Layout } from "./Layout";
|
||||
|
||||
export const IndexPage = () => (
|
||||
<Layout title="WiFi Setup">
|
||||
<h1>WiFi Configuration</h1>
|
||||
<form method="post" action="/save">
|
||||
<label>
|
||||
SSID (Network Name)
|
||||
<select name="ssid" id="ssid-select" required aria-busy="true">
|
||||
<option value="">Loading networks...</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Password
|
||||
<input type="password" name="password" placeholder="password123" required />
|
||||
</label>
|
||||
<button type="submit">Connect to Network</button>
|
||||
</form>
|
||||
<footer>
|
||||
<small>
|
||||
<a href="/logs">📋 View Service Logs</a>
|
||||
</small>
|
||||
</footer>
|
||||
|
||||
<script dangerouslySetInnerHTML={{__html: `
|
||||
(async () => {
|
||||
const select = document.getElementById('ssid-select');
|
||||
try {
|
||||
const res = await fetch('/api/networks');
|
||||
const data = await res.json();
|
||||
|
||||
select.setAttribute('aria-busy', 'false');
|
||||
|
||||
if (data.networks && data.networks.length > 0) {
|
||||
select.innerHTML = '<option value="">Select a network...</option>';
|
||||
data.networks.forEach(ssid => {
|
||||
const option = document.createElement('option');
|
||||
option.value = ssid;
|
||||
option.textContent = ssid;
|
||||
select.appendChild(option);
|
||||
});
|
||||
} else {
|
||||
select.innerHTML = '<option value="">No networks found</option>';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading networks:', error);
|
||||
select.setAttribute('aria-busy', 'false');
|
||||
select.innerHTML = '<option value="">Error loading networks</option>';
|
||||
}
|
||||
})();
|
||||
`}} />
|
||||
</Layout>
|
||||
);
|
||||
22
src/services/server/components/Layout.tsx
Normal file
22
src/services/server/components/Layout.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import type { Child } from "hono/jsx";
|
||||
|
||||
type LayoutProps = {
|
||||
title: string;
|
||||
children: Child;
|
||||
refresh?: string;
|
||||
};
|
||||
|
||||
export const Layout = ({ title, children, refresh }: LayoutProps) => (
|
||||
<html data-theme="dark">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
{refresh && <meta http-equiv="refresh" content={refresh} />}
|
||||
<title>{title}</title>
|
||||
<link rel="stylesheet" href="/pico.css" />
|
||||
</head>
|
||||
<body>
|
||||
<main class="container">{children}</main>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
62
src/services/server/components/LogsPage.tsx
Normal file
62
src/services/server/components/LogsPage.tsx
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import { Layout } from "./Layout";
|
||||
|
||||
type LogsPageProps = {
|
||||
logs: string;
|
||||
};
|
||||
|
||||
export const LogsPage = ({ logs }: LogsPageProps) => (
|
||||
<Layout title="Service Logs">
|
||||
<h1>📋 Service Logs</h1>
|
||||
<p>
|
||||
<small>
|
||||
<label>
|
||||
<input type="checkbox" id="auto-refresh" checked /> Auto-refresh
|
||||
</label>
|
||||
{" | "}
|
||||
<a href="/">← Back</a>
|
||||
</small>
|
||||
</p>
|
||||
<pre>
|
||||
<code id="logs-content">{logs.trim()}</code>
|
||||
</pre>
|
||||
|
||||
<script>{`
|
||||
let interval = null;
|
||||
const checkbox = document.getElementById('auto-refresh');
|
||||
const logsContent = document.getElementById('logs-content');
|
||||
|
||||
async function refreshLogs() {
|
||||
try {
|
||||
const res = await fetch('/api/logs');
|
||||
const data = await res.json();
|
||||
logsContent.textContent = data.logs;
|
||||
} catch (error) {
|
||||
console.error('Failed to refresh logs:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function startRefresh() {
|
||||
if (interval) clearInterval(interval);
|
||||
interval = setInterval(refreshLogs, 5000);
|
||||
}
|
||||
|
||||
function stopRefresh() {
|
||||
if (interval) {
|
||||
clearInterval(interval);
|
||||
interval = null;
|
||||
}
|
||||
}
|
||||
|
||||
checkbox.addEventListener('change', (e) => {
|
||||
if (e.target.checked) {
|
||||
startRefresh();
|
||||
} else {
|
||||
stopRefresh();
|
||||
}
|
||||
});
|
||||
|
||||
// Start auto-refresh by default
|
||||
startRefresh();
|
||||
`}</script>
|
||||
</Layout>
|
||||
);
|
||||
98
src/services/server/server.tsx
Normal file
98
src/services/server/server.tsx
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
#!/usr/bin/env bun
|
||||
|
||||
import { Hono } from "hono";
|
||||
import {join} from "node:path";
|
||||
import { $ } from "bun";
|
||||
import { IndexPage } from "./components/IndexPage";
|
||||
import { LogsPage } from "./components/LogsPage";
|
||||
import { ConnectingPage } from "./components/ConnectingPage";
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
// Ping endpoint for connectivity check
|
||||
app.get("/ping", (c) => {
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
// Serve static CSS
|
||||
app.get("/pico.css", async (c) => {
|
||||
const cssPath = join(import.meta.dir, "./static/pico.min.css");
|
||||
const file = Bun.file(cssPath);
|
||||
return new Response(file);
|
||||
});
|
||||
|
||||
// API endpoint to get available WiFi networks
|
||||
app.get("/api/networks", async (c) => {
|
||||
try {
|
||||
const result = await $`nmcli -t -f SSID device wifi list`.text();
|
||||
const networks = result
|
||||
.trim()
|
||||
.split('\n')
|
||||
.filter(ssid => ssid && ssid !== 'SSID') // Remove empty and header
|
||||
.filter((ssid, index, self) => self.indexOf(ssid) === index); // Remove duplicates
|
||||
return c.json({ networks });
|
||||
} catch (error) {
|
||||
return c.json({ networks: [], error: String(error) }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// API endpoint to get logs (for auto-refresh)
|
||||
app.get("/api/logs", async (c) => {
|
||||
try {
|
||||
const logs = await $`journalctl -u phone-ap.service -u phone-web.service -n 200 --no-pager`.text();
|
||||
return c.json({ logs: logs.trim() });
|
||||
} catch (error) {
|
||||
return c.json({ logs: '', error: String(error) }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// Main WiFi configuration page
|
||||
app.get("/", (c) => {
|
||||
return c.html(<IndexPage />);
|
||||
});
|
||||
|
||||
// Service logs with auto-refresh
|
||||
app.get("/logs", async (c) => {
|
||||
try {
|
||||
const logs =
|
||||
await $`journalctl -u phone-ap.service -u phone-web.service -n 200 --no-pager`.text();
|
||||
return c.html(<LogsPage logs={logs} />);
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to fetch logs: ${error}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle WiFi configuration submission
|
||||
app.post("/save", async (c) => {
|
||||
const formData = await c.req.parseBody();
|
||||
const ssid = formData.ssid as string;
|
||||
const password = formData.password as string;
|
||||
|
||||
// Return the connecting page immediately
|
||||
const response = c.html(<ConnectingPage ssid={ssid} />);
|
||||
|
||||
// Trigger connection in background after a short delay (allows response to be sent)
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
await $`sudo nmcli device wifi connect ${ssid} password ${password}`;
|
||||
console.log(`[WiFi] Successfully connected to ${ssid}`);
|
||||
} catch (error) {
|
||||
console.error(`[WiFi] Failed to connect to ${ssid}:`, error);
|
||||
|
||||
// Delete the failed connection profile so ap-monitor doesn't try to use it
|
||||
try {
|
||||
await $`sudo nmcli connection delete ${ssid}`.nothrow();
|
||||
console.log(`[WiFi] Deleted failed connection profile for ${ssid}`);
|
||||
} catch (deleteError) {
|
||||
console.error(`[WiFi] Failed to delete connection profile:`, deleteError);
|
||||
}
|
||||
}
|
||||
}, 1000); // 1 second delay
|
||||
|
||||
return response;
|
||||
});
|
||||
|
||||
export default { port: 80, fetch: app.fetch };
|
||||
|
||||
console.log("Server running on http://0.0.0.0:80");
|
||||
console.log("Access via WiFi or AP at http://yellow-phone.local");
|
||||
4
src/services/server/static/pico.min.css
vendored
Normal file
4
src/services/server/static/pico.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
11
src/utils/index.ts
Normal file
11
src/utils/index.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
export const ensure = <T>(value: T, message: string): T => {
|
||||
if (value === undefined || value === null) {
|
||||
throw new Error(message)
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
export const random = <T>(arr: ReadonlyArray<T>): T => {
|
||||
return arr[Math.floor(Math.random() * arr.length)]!
|
||||
}
|
||||
57
src/utils/signal.ts
Normal file
57
src/utils/signal.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
/**
|
||||
* How to use a Signal:
|
||||
*
|
||||
* Create a signal:
|
||||
* const chatSignal = new Signal<{ username: string, message: string }>()
|
||||
*
|
||||
* Connect to the signal:
|
||||
* const disconnect = chatSignal.connect((data) => {
|
||||
* const {username, message} = data;
|
||||
* console.log(`${username} said "${message}"`);
|
||||
* })
|
||||
*
|
||||
* Emit a signal:
|
||||
* chatSignal.emit({ username: "Chad", message: "Hey everyone, how's it going?" });
|
||||
*
|
||||
* Forward a signal:
|
||||
* const relaySignal = new Signal<{ username: string, message: string }>()
|
||||
* const disconnectRelay = chatSignal.connect(relaySignal)
|
||||
* // Now, when chatSignal emits, relaySignal will also emit the same data
|
||||
*
|
||||
* Disconnect a single listener:
|
||||
* disconnect(); // The disconnect function is returned when you connect to a signal
|
||||
*
|
||||
* Disconnect all listeners:
|
||||
* chatSignal.disconnect()
|
||||
*/
|
||||
|
||||
export class Signal<T extends object | void> {
|
||||
private listeners: Array<(data: T) => void> = []
|
||||
|
||||
connect(listenerOrSignal: Signal<T> | ((data: T) => void)) {
|
||||
let listener: (data: T) => void
|
||||
|
||||
// If it is a signal, forward the data to the signal
|
||||
if (listenerOrSignal instanceof Signal) {
|
||||
listener = (data: T) => listenerOrSignal.emit(data)
|
||||
} else {
|
||||
listener = listenerOrSignal
|
||||
}
|
||||
|
||||
this.listeners.push(listener)
|
||||
|
||||
return () => {
|
||||
this.listeners = this.listeners.filter((l) => l !== listener)
|
||||
}
|
||||
}
|
||||
|
||||
emit(data: T) {
|
||||
for (const listener of this.listeners) {
|
||||
listener(data)
|
||||
}
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.listeners = []
|
||||
}
|
||||
}
|
||||
86
src/utils/waiting-sounds.ts
Normal file
86
src/utils/waiting-sounds.ts
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import Buzz, { type Player } from "../buzz/index.ts"
|
||||
import { join } from "path"
|
||||
import type { Playback } from "../buzz/utils.ts"
|
||||
import { random } from "./index.ts"
|
||||
|
||||
export class WaitingSounds {
|
||||
currentPlayback?: Playback
|
||||
|
||||
constructor(private player: Player) {}
|
||||
|
||||
async start() {
|
||||
if (this.currentPlayback) return // Already playing
|
||||
|
||||
// Now play randomly play things
|
||||
const playedSounds = new Set<string>()
|
||||
let lastSoundDir: SoundDir | undefined
|
||||
do {
|
||||
const dir = this.getRandomSoundDir(lastSoundDir)
|
||||
const soundPath = getSound(dir, Array.from(playedSounds))
|
||||
playedSounds.add(soundPath)
|
||||
lastSoundDir = dir
|
||||
console.log(`🌭 playing ${soundPath}`)
|
||||
|
||||
const playback = await this.player.play(soundPath)
|
||||
this.currentPlayback = playback
|
||||
await playback.finished()
|
||||
} while (this.currentPlayback)
|
||||
}
|
||||
|
||||
async stop() {
|
||||
if (!this.currentPlayback) return
|
||||
await this.currentPlayback.finished()
|
||||
this.currentPlayback = undefined
|
||||
}
|
||||
|
||||
getRandomSoundDir(lastSoundDir?: SoundDir): SoundDir {
|
||||
if (lastSoundDir === "body-noises") {
|
||||
return "apology"
|
||||
}
|
||||
|
||||
const skipSpecialSounds =
|
||||
(lastSoundDir !== "typing" && lastSoundDir !== "clicking") || !lastSoundDir
|
||||
|
||||
const value = Math.random() * 100
|
||||
console.log(`🎲 got ${value}`)
|
||||
if (value > 95 && !skipSpecialSounds) {
|
||||
return "body-noises"
|
||||
} else if (value > 66 && !skipSpecialSounds) {
|
||||
return "stalling"
|
||||
} else if (value > 33) {
|
||||
return "clicking"
|
||||
} else {
|
||||
return "typing"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type SoundDir = (typeof soundDirs)[number]
|
||||
const soundDirs = [
|
||||
"apology",
|
||||
"background",
|
||||
"body-noises",
|
||||
"clicking",
|
||||
"greeting",
|
||||
"stalling",
|
||||
"typing",
|
||||
] as const
|
||||
export const soundDir = join(import.meta.dir, "../../", "sounds")
|
||||
|
||||
export const getSound = (dir: SoundDir, exclude: string[] = []): string => {
|
||||
const glob = new Bun.Glob("*.wav")
|
||||
const soundPaths = Array.from(glob.scanSync({ cwd: join(soundDir, dir), absolute: true }))
|
||||
|
||||
const filteredSoundPaths = soundPaths.filter((path) => !exclude.includes(path))
|
||||
if (filteredSoundPaths.length === 0) {
|
||||
return random(soundPaths)
|
||||
}
|
||||
|
||||
return random(filteredSoundPaths)
|
||||
}
|
||||
|
||||
const player = await Buzz.defaultPlayer()
|
||||
Buzz.setVolume(0.2)
|
||||
player.play(getSound("background"), { repeat: true })
|
||||
const x = new WaitingSounds(player)
|
||||
x.start()
|
||||
30
tsconfig.json
Normal file
30
tsconfig.json
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
// Environment setup & latest features
|
||||
"lib": ["ESNext"],
|
||||
"target": "ESNext",
|
||||
"module": "Preserve",
|
||||
"moduleDetection": "force",
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "hono/jsx",
|
||||
"allowJs": true,
|
||||
|
||||
// Bundler mode
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"noEmit": true,
|
||||
|
||||
// Best practices
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noImplicitOverride": true,
|
||||
|
||||
// Some stricter flags (disabled by default)
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noPropertyAccessFromIndexSignature": false
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user