Initial commit

This commit is contained in:
Corey Johnson 2025-11-17 10:54:37 -08:00
commit 962dd3fb70
66 changed files with 2705 additions and 0 deletions

2
.gitattributes vendored Normal file
View File

@ -0,0 +1,2 @@
# Auto detect text files and perform LF normalization
* text=auto

34
.gitignore vendored Normal file
View 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
View File

@ -0,0 +1,3 @@
{
"typescript.tsdk": "node_modules/typescript/lib"
}

106
CLAUDE.md Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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"
`)

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
sounds/clicking/mouse1.wav Normal file

Binary file not shown.

BIN
sounds/clicking/mouse2.wav Normal file

Binary file not shown.

BIN
sounds/clicking/mouse3.wav Normal file

Binary file not shown.

BIN
sounds/greeting/greet1.wav Normal file

Binary file not shown.

BIN
sounds/greeting/greet2.wav Normal file

Binary file not shown.

BIN
sounds/greeting/greet3.wav Normal file

Binary file not shown.

BIN
sounds/greeting/greet4.wav Normal file

Binary file not shown.

BIN
sounds/greeting/greet5.wav Normal file

Binary file not shown.

BIN
sounds/greeting/greet6.wav Normal file

Binary file not shown.

BIN
sounds/greeting/greet7.wav Normal file

Binary file not shown.

BIN
sounds/greeting/greet8.wav Normal file

Binary file not shown.

BIN
sounds/stalling/cough1.wav Normal file

Binary file not shown.

BIN
sounds/stalling/cough2.wav Normal file

Binary file not shown.

BIN
sounds/stalling/hmm1.wav Normal file

Binary file not shown.

BIN
sounds/stalling/hmm2.wav Normal file

Binary file not shown.

BIN
sounds/stalling/hum1.wav Normal file

Binary file not shown.

BIN
sounds/stalling/hum2.wav Normal file

Binary file not shown.

BIN
sounds/stalling/one-sec.wav Normal file

Binary file not shown.

BIN
sounds/stalling/sigh1.wav Normal file

Binary file not shown.

BIN
sounds/stalling/sigh2.wav Normal file

Binary file not shown.

BIN
sounds/stalling/sneeze.wav Normal file

Binary file not shown.

BIN
sounds/stalling/uh-huh.wav Normal file

Binary file not shown.

BIN
sounds/stalling/yeah.wav Normal file

Binary file not shown.

BIN
sounds/typing/typing1.wav Normal file

Binary file not shown.

BIN
sounds/typing/typing2.wav Normal file

Binary file not shown.

157
src/agent/README.md Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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...")

View 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>
);

View 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>
);

View 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>
);

View 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>
);

View 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");

File diff suppressed because one or more lines are too long

11
src/utils/index.ts Normal file
View 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
View 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 = []
}
}

View 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
View 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
}
}