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