Compare commits

...

10 Commits

Author SHA1 Message Date
953fb3aff1 wip 2025-11-24 09:41:49 -08:00
96d10c3df0 ok 2025-11-24 09:09:15 -08:00
616d4472d2 Merge pull request 'Getting this working probably took 1-3 years off my life.' (#4) from baresip into main
Reviewed-on: #4
2025-11-24 00:07:14 +00:00
8e8c884586 clean up 2025-11-23 16:05:02 -08:00
9ddb54d319 YES 2025-11-23 16:03:40 -08:00
61eb2bc895 wip 2025-11-23 15:40:22 -08:00
c07cb297e3 wip 2025-11-21 10:59:40 -08:00
28186bc0ce wip 2025-11-20 18:18:47 -08:00
bd9ab973b2 whatever 2025-11-20 16:16:47 -08:00
a5751b28f7 Merge pull request 'Move from polling to worker thread' (#3) from worker-test into main
Reviewed-on: #3
2025-11-19 21:21:05 +00:00
37 changed files with 1452 additions and 1106 deletions

3
.vscode/settings.json vendored Normal file
View File

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

View File

@ -1 +1 @@
<sip:yellow@probablycorey.sip.twilio.com;transport=tls>;auth_pass=zgm-kwx2bug5hwf3YGF;unregister_on_exit=yes;regint=300 <sip:yellow@probablycorey.sip.twilio.com>;auth_pass=zgm-kwx2bug5hwf3YGF;unregister_on_exit=yes

View File

@ -1,71 +1,26 @@
#
# baresip configuration
#
#------------------------------------------------------------------------------
# Core
poll_method epoll # poll, select, epoll ..
ring_aufile none
# Call
call_local_timeout 120
call_max_calls 4 call_max_calls 4
call_local_timeout 120
# Audio # Audio
audio_player alsa,default audio_player alsa,default
audio_source alsa,default audio_source alsa,default
audio_alert none audio_alert alsa,default
audio_alert_enable no
audio_level no
ausrc_format s16 # s16, float, ..
auplay_format s16 # s16, float, ..
auenc_format s16 # s16, float, ..
audec_format s16 # s16, float, ..
audio_buffer 20-160 # ms
# AVT - Audio/Video Transport ring_aufile /dev/null
rtp_tos 184
rtcp_mux no
jitter_buffer_delay 5-10 # frames
rtp_stats no
#------------------------------------------------------------------------------
# Modules # Modules
#------------------------------------------------------------------------------
module_path /usr/lib/baresip/modules module_path /usr/lib/baresip/modules
# UI Modules # Audio codec Modules
#module stdio.so
# Audio codec Modules (in order)
module g711.so module g711.so
# Audio driver Modules # Audio driver Modules
module alsa.so module alsa.so
# Media NAT modules
module stun.so
module turn.so
module ice.so
module httpd.so
#------------------------------------------------------------------------------
# Temporary Modules (loaded then unloaded)
module_tmp uuid.so
module_tmp account.so
#------------------------------------------------------------------------------
# Application Modules # Application Modules
module_app account.so
module_app contact.so
module_app debug_cmd.so
module_app menu.so module_app menu.so
module httpd.so
http_listen 0.0.0.0:8000 # httpd - HTTP Serve

View File

@ -6,10 +6,11 @@
"dependencies": { "dependencies": {
"hono": "^4.10.4", "hono": "^4.10.4",
"openai": "^6.9.0", "openai": "^6.9.0",
"robot3": "^1.2.0", "robot3": "1.1.1",
}, },
"devDependencies": { "devDependencies": {
"@types/bun": "latest", "@types/bun": "latest",
"prettier": "^3.6.2",
}, },
"peerDependencies": { "peerDependencies": {
"typescript": "^5", "typescript": "^5",
@ -21,17 +22,19 @@
"@types/node": ["@types/node@24.10.1", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ=="], "@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=="], "@types/react": ["@types/react@19.2.6", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-p/jUvulfgU7oKtj6Xpk8cA2Y1xKTtICGpJYeJXz2YVO2UcvjQgeRMLDGfDeqeRW2Ta+0QNFwcc8X3GH8SxZz6w=="],
"bun-types": ["bun-types@1.3.2", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-i/Gln4tbzKNuxP70OWhJRZz1MRfvqExowP7U6JKoI8cntFrtxg7RJK3jvz7wQW54UuvNC8tbKHHri5fy74FVqg=="], "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=="], "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"hono": ["hono@4.10.6", "", {}, "sha512-BIdolzGpDO9MQ4nu3AUuDwHZZ+KViNm+EZ75Ae55eMXMqLVhDFqEMXxtUe9Qh8hjL+pIna/frs2j6Y2yD5Ua/g=="], "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=="], "openai": ["openai@6.9.1", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-vQ5Rlt0ZgB3/BNmTa7bIijYFhz3YBceAA3Z4JuoMSBftBF9YqFHIEhZakSs+O/Ad7EaoEimZvHxD5ylRjN11Lg=="],
"robot3": ["robot3@1.2.0", "", {}, "sha512-Xin8KHqCKrD9Rqk1ZzZQYjsb6S9DRggcfwBqnVPeM3DLtNCJLxWWTrPJDYm3E+ZiTO7H3VMdgyPSkIbuYnYP2Q=="], "prettier": ["prettier@3.6.2", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ=="],
"robot3": ["robot3@1.1.1", "", {}, "sha512-kuD0oQg2KUE74FCQ1a5uoRsEJ/bUKrU1D3vnluop9X7LSiGLndejQgjUEcMqJMVzUA836HSXhtY7XNtQiPTCLQ=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],

View File

@ -7,7 +7,8 @@
"start": "bun run src/operator.ts" "start": "bun run src/operator.ts"
}, },
"devDependencies": { "devDependencies": {
"@types/bun": "latest" "@types/bun": "latest",
"prettier": "^3.6.2"
}, },
"peerDependencies": { "peerDependencies": {
"typescript": "^5" "typescript": "^5"
@ -15,7 +16,7 @@
"dependencies": { "dependencies": {
"hono": "^4.10.4", "hono": "^4.10.4",
"openai": "^6.9.0", "openai": "^6.9.0",
"robot3": "^1.2.0" "robot3": "1.1.1"
}, },
"prettier": { "prettier": {
"semi": false, "semi": false,

View File

@ -0,0 +1,108 @@
import { $ } from "bun"
import { writeFileSync } from "fs"
const AP_SERVICE_FILE = "/etc/systemd/system/phone-ap.service"
const WEB_SERVICE_FILE = "/etc/systemd/system/phone-web.service"
const PHONE_SERVICE_FILE = "/etc/systemd/system/phone.service"
export const setupServices = async (installDir: string) => {
console.log("\nInstalling systemd services...")
// Detect user from environment or use default
// SUDO_USER is set when running with sudo, which is what we want
const serviceUser = process.env.SERVICE_USER || process.env.SUDO_USER || process.env.USER || "corey"
const userUid = await $`id -u ${serviceUser}`.text().then((s) => s.trim())
console.log(`Setting up services for user: ${serviceUser} (UID: ${userUid})`)
// 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=Phone WiFi AP Monitor
After=network.target
Before=phone-web.service
[Service]
Type=simple
ExecStart=${bunPath} ${installDir}/src/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=Phone Web Server
After=network.target phone-ap.service
[Service]
Type=simple
ExecStart=${bunPath} ${installDir}/src/services/server/server.tsx
WorkingDirectory=${installDir}
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")
// Create phone service (system service with environment variables for audio access)
const phoneServiceContent = `[Unit]
Description=Phone Application
After=network.target sound.target
Requires=sound.target
[Service]
Type=simple
User=${serviceUser}
Group=audio
Environment=XDG_RUNTIME_DIR=/run/user/${userUid}
Environment=DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/${userUid}/bus
ExecStart=${bunPath} ${installDir}/src/main.ts
WorkingDirectory=${installDir}
EnvironmentFile=${installDir}/.env
Restart=on-failure
RestartSec=5
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target
`
writeFileSync(PHONE_SERVICE_FILE, phoneServiceContent, "utf8")
console.log("✓ Created phone.service")
await $`systemctl daemon-reload`
await $`systemctl enable phone-ap.service`
await $`systemctl enable phone-web.service`
await $`systemctl enable phone.service`
console.log("✓ Services enabled")
console.log("\nRestarting the services...")
await $`systemctl restart phone-ap.service`
await $`systemctl restart phone-web.service`
await $`systemctl restart phone.service`
console.log("✓ Services restarted")
}

View File

@ -1,5 +1,4 @@
import { $ } from "bun" import { $ } from "bun"
import { writeFileSync } from "fs"
console.log(` console.log(`
========================================== ==========================================
@ -15,96 +14,22 @@ if (process.getuid && process.getuid() !== 0) {
} }
// Get install directory from argument or use default // Get install directory from argument or use default
const INSTALL_DIR = process.argv[2] || "/home/corey/phone" const defaultUser = process.env.USER || "corey"
const AP_SERVICE_FILE = "/etc/systemd/system/phone-ap.service" const INSTALL_DIR = process.argv[2] || `/home/${defaultUser}/phone`
const WEB_SERVICE_FILE = "/etc/systemd/system/phone-web.service"
console.log(`Install directory: ${INSTALL_DIR}`) console.log(`Install directory: ${INSTALL_DIR}`)
console.log("\nStep 1: Ensuring directory exists...") console.log("\nEnsuring directory exists...")
await $`mkdir -p ${INSTALL_DIR}` await $`mkdir -p ${INSTALL_DIR}`
console.log(`✓ Directory ready: ${INSTALL_DIR}`) console.log(`✓ Directory ready: ${INSTALL_DIR}`)
console.log("\nStep 2: Installing dependencies...") console.log("\nInstalling dependencies...")
await $`cd ${INSTALL_DIR} && bun install` await $`cd ${INSTALL_DIR} && bun install`
console.log(`✓ Dependencies installed`) console.log(`✓ Dependencies installed`)
console.log("\nStep 3: Installing systemd services...") console.log("\nInstalling Baresip...")
// Find where bun is installed await $`sudo apt install -y baresip`
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=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=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(` console.log(`
========================================== Bootstrap complete!
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://phone.local
- If NOT connected: WiFi AP "phone-setup" will start automatically
Connect to the AP at the same address http://phone.local
To check status use ./cli
`) `)

View File

@ -1,178 +0,0 @@
#!/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(`
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🔧 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("");

130
scripts/cli.ts Normal file
View File

@ -0,0 +1,130 @@
#!/usr/bin/env bun
import {$} from "bun";
const SERVICES = {
ap: "phone-ap",
web: "phone-web",
phone: "phone",
} as const;
const COMMANDS = {
status: "Show service status",
logs: "Show recent logs (last 50 lines)",
tail: "Tail logs in real-time",
restart: "Restart service (requires sudo)",
stop: "Stop service (requires sudo)",
start: "Start service (requires sudo)",
} as const;
const showHelp = () => {
console.log(`
Phone CLI - Service Management
Usage: cli SERVICE COMMAND [-v]
Services:
ap WiFi AP Monitor (phone-ap.service)
web Web Server (phone-web.service)
phone Phone Application (phone.service)
Commands:
status Show service status
logs Show recent logs (last 50 lines)
tail Tail logs in real-time
restart Restart service (requires sudo)
stop Stop service (requires sudo)
start Start service (requires sudo)
Options:
-v Verbose mode - show actual systemd commands
Examples:
cli ap status
cli web logs
cli phone tail
cli -v ap status
sudo cli ap restart
`);
};
// Parse arguments
const args = process.argv.slice(2);
// Check for help
if (args.length === 0 || args[0] === "help") {
showHelp();
process.exit(0);
}
// Extract verbose flag and remaining args
const verbose = args.includes("-v");
const [service, command] = args.filter(arg => arg !== "-v");
// Validate service
if (!service || !(service in SERVICES)) {
console.error(`❌ Unknown service: ${service || "(missing)"}`);
console.log(`Available services: ${Object.keys(SERVICES).join(", ")}`);
process.exit(1);
}
// Validate command
if (!command || !(command in COMMANDS)) {
console.error(`❌ Unknown command: ${command || "(missing)"}`);
console.log(`Available commands: ${Object.keys(COMMANDS).join(", ")}`);
process.exit(1);
}
// Get systemd service name
const serviceName = SERVICES[service as keyof typeof SERVICES];
// Execute command
console.log(`\n🔧 Phone CLI - ${service} ${command}\n`);
const logCommand = (cmd: string) => {
if (verbose) {
console.log(`${cmd}\n`);
}
};
switch (command) {
case "status":
logCommand(`systemctl status ${serviceName}.service --no-pager -l`);
await $`systemctl status ${serviceName}.service --no-pager -l`.nothrow();
break;
case "logs":
console.log(`📋 Recent logs (last 50 lines):\n`);
logCommand(`journalctl -u ${serviceName}.service -n 50 --no-pager`);
await $`journalctl -u ${serviceName}.service -n 50 --no-pager`.nothrow();
break;
case "tail":
console.log(`📡 Tailing logs (Ctrl+C to stop)...\n`);
logCommand(`journalctl -u ${serviceName}.service -f --no-pager`);
await $`journalctl -u ${serviceName}.service -f --no-pager`.nothrow();
break;
case "restart":
console.log(`🔄 Restarting ${serviceName}.service...\n`);
logCommand(`sudo systemctl restart ${serviceName}.service`);
await $`sudo systemctl restart ${serviceName}.service`;
console.log(`${serviceName}.service restarted!`);
break;
case "stop":
console.log(`🛑 Stopping ${serviceName}.service...\n`);
logCommand(`sudo systemctl stop ${serviceName}.service`);
await $`sudo systemctl stop ${serviceName}.service`;
console.log(`${serviceName}.service stopped!`);
break;
case "start":
console.log(`▶️ Starting ${serviceName}.service...\n`);
logCommand(`sudo systemctl start ${serviceName}.service`);
await $`sudo systemctl start ${serviceName}.service`;
console.log(`${serviceName}.service started!`);
break;
}
console.log("");

View File

@ -2,8 +2,9 @@
import { $ } from "bun" import { $ } from "bun"
const defaultUser = process.env.USER ?? "corey"
const PI_HOST = process.env.PI_HOST ?? "phone.local" const PI_HOST = process.env.PI_HOST ?? "phone.local"
const PI_DIR = process.env.PI_DIR ?? "/home/corey/phone" const PI_DIR = process.env.PI_DIR ?? `/home/${defaultUser}/phone`
// Parse command line arguments // Parse command line arguments
const shouldBootstrap = process.argv.includes("--bootstrap") const shouldBootstrap = process.argv.includes("--bootstrap")
@ -40,22 +41,10 @@ if (shouldBootstrap) {
// make console beep // make console beep
await $`afplay /System/Library/Sounds/Blow.aiff` await $`afplay /System/Library/Sounds/Blow.aiff`
// Always check if services exist and restart them (whether we bootstrapped or not) // Always set up services on every deploy
console.log("Checking for existing services...") console.log("Setting up services...")
const apServiceExists = await $`ssh ${PI_HOST} "systemctl is-enabled phone-ap.service"` await $`ssh ${PI_HOST} "sudo bun ${PI_DIR}/scripts/setup-services.ts ${PI_DIR}"`
.nothrow() console.log("✓ Services configured and running\n")
.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(` console.log(`
Deploy complete! Deploy complete!

15
scripts/setup-services.ts Normal file
View File

@ -0,0 +1,15 @@
#!/usr/bin/env bun
import { setupServices } from "./bootstrap-services"
// Get install directory from argument or use default
const defaultUser = process.env.USER || "corey"
const INSTALL_DIR = process.argv[2] || `/home/${defaultUser}/phone`
console.log(`Setting up services for: ${INSTALL_DIR}`)
await setupServices(INSTALL_DIR)
console.log(`
Services configured and running!
`)

Binary file not shown.

Binary file not shown.

View File

@ -1,12 +1,12 @@
# Agent # Agent
A clean, reusable wrapper for ElevenLabs conversational AI WebSocket protocol. Uses Signal-based events and provides simple tool registration. A clean, reusable wrapper for ElevenLabs conversational AI WebSocket protocol. Uses events and provides simple tool registration.
## Basic Usage ## Basic Usage
```typescript ```typescript
import { Agent } from './pi/agent' import { Agent } from "./pi/agent"
import Buzz from './pi/buzz' import Buzz from "./pi/buzz"
const agent = new Agent({ const agent = new Agent({
agentId: process.env.ELEVEN_AGENT_ID!, agentId: process.env.ELEVEN_AGENT_ID!,
@ -14,27 +14,24 @@ const agent = new Agent({
tools: { tools: {
search_web: async (args) => { search_web: async (args) => {
return { results: [`Result for ${args.query}`] } return { results: [`Result for ${args.query}`] }
} },
} },
}) })
// Set up event handlers // Set up event handlers
const player = await Buzz.defaultPlayer() const player = await Buzz.player()
let playback = player.playStream() let playback = player.playStream()
agent.events.connect((event) => { agent.events.on((event) => {
if (event.type === 'audio') { if (event.type === "audio") {
const audioBuffer = Buffer.from(event.audioBase64, 'base64') const audioBuffer = Buffer.from(event.audioBase64, "base64")
if (!playback.isPlaying) playback = player.playStream() if (!playback.isPlaying) playback = player.playStream()
playback.write(audioBuffer) playback.write(audioBuffer)
} } else if (event.type === "interruption") {
else if (event.type === 'interruption') {
playback.stop() playback.stop()
} } else if (event.type === "user_transcript") {
else if (event.type === 'user_transcript') {
console.log(`User: ${event.transcript}`) console.log(`User: ${event.transcript}`)
} } else if (event.type === "agent_response") {
else if (event.type === 'agent_response') {
console.log(`Agent: ${event.response}`) console.log(`Agent: ${event.response}`)
} }
}) })
@ -43,7 +40,7 @@ agent.events.connect((event) => {
await agent.start() await agent.start()
// Continuously stream audio // Continuously stream audio
const recorder = await Buzz.defaultRecorder() const recorder = await Buzz.recorder()
const recording = recorder.start() const recording = recorder.start()
for await (const chunk of recording.stream()) { for await (const chunk of recording.stream()) {
agent.sendAudio(chunk) agent.sendAudio(chunk)
@ -53,7 +50,7 @@ for await (const chunk of recording.stream()) {
## VAD Pattern ## VAD Pattern
```typescript ```typescript
const recorder = await Buzz.defaultRecorder() const recorder = await Buzz.recorder()
const recording = recorder.start() const recording = recorder.start()
const buffer = new RollingBuffer() const buffer = new RollingBuffer()
@ -68,7 +65,7 @@ for await (const chunk of recording.stream()) {
if (rms > vadThreshold) { if (rms > vadThreshold) {
// Speech detected! Start conversation // Speech detected! Start conversation
agent = new Agent({ agentId, apiKey, tools }) agent = new Agent({ agentId, apiKey, tools })
agent.events.connect(eventHandler) agent.events.on(eventHandler)
await agent.start() await agent.start()
// Send buffered audio // Send buffered audio
@ -112,7 +109,7 @@ new Agent({
### Properties ### Properties
- `agent.events: Signal<AgentEvent>` - Connect to receive all events - `agent.events: Emitter<AgentEvent>` - Connect to receive all events
- `agent.isConnected: boolean` - Current connection state - `agent.isConnected: boolean` - Current connection state
- `agent.conversationId?: string` - Available after connected event - `agent.conversationId?: string` - Available after connected event
@ -121,11 +118,13 @@ new Agent({
All events are emitted through `agent.events`: All events are emitted through `agent.events`:
### Connection ### Connection
- `{ type: 'connected', conversationId, audioFormat }` - `{ type: 'connected', conversationId, audioFormat }`
- `{ type: 'disconnected' }` - `{ type: 'disconnected' }`
- `{ type: 'error', error }` - `{ type: 'error', error }`
### Conversation ### Conversation
- `{ type: 'user_transcript', transcript }` - `{ type: 'user_transcript', transcript }`
- `{ type: 'agent_response', response }` - `{ type: 'agent_response', response }`
- `{ type: 'agent_response_correction', original, corrected }` - `{ type: 'agent_response_correction', original, corrected }`
@ -134,11 +133,13 @@ All events are emitted through `agent.events`:
- `{ type: 'interruption', eventId }` - `{ type: 'interruption', eventId }`
### Tools ### Tools
- `{ type: 'tool_call', name, args, callId }` - `{ type: 'tool_call', name, args, callId }`
- `{ type: 'tool_result', name, result, callId }` - `{ type: 'tool_result', name, result, callId }`
- `{ type: 'tool_error', name, error, callId }` - `{ type: 'tool_error', name, error, callId }`
### Optional ### Optional
- `{ type: 'vad_score', score }` - `{ type: 'vad_score', score }`
- `{ type: 'ping', eventId, pingMs }` - `{ type: 'ping', eventId, pingMs }`
@ -146,7 +147,7 @@ All events are emitted through `agent.events`:
- **Generic**: Not tied to phone systems, works in any context - **Generic**: Not tied to phone systems, works in any context
- **Flexible audio**: You control when to send audio, Agent just handles WebSocket - **Flexible audio**: You control when to send audio, Agent just handles WebSocket
- **Event-driven**: All communication through Signal events, no throws - **Event-driven**: All communication through events, no throws
- **Simple tools**: Just pass a function map to constructor - **Simple tools**: Just pass a function map to constructor
- **Automatic buffering**: Sends buffered audio when connection opens - **Automatic buffering**: Sends buffered audio when connection opens
- **Automatic chunking**: Handles 8000-byte chunking internally - **Automatic chunking**: Handles 8000-byte chunking internally

View File

@ -1,4 +1,4 @@
import { Signal } from "../utils/signal" import { Emitter } from "../utils/emitter"
import type { AgentConfig, AgentEvent } from "./types" import type { AgentConfig, AgentEvent } from "./types"
type AgentState = "disconnected" | "connecting" | "connected" type AgentState = "disconnected" | "connecting" | "connected"
@ -11,7 +11,7 @@ export class Agent {
#chunkBuffer = new Uint8Array(0) #chunkBuffer = new Uint8Array(0)
#chunkSize = 8000 #chunkSize = 8000
public readonly events = new Signal<AgentEvent>() public readonly events = new Emitter<AgentEvent>()
public conversationId?: string public conversationId?: string
constructor(config: AgentConfig) { constructor(config: AgentConfig) {
@ -255,7 +255,7 @@ export class Agent {
}) })
} }
#handleClose = (): void => { #handleClose = (event: CloseEvent): void => {
this.#cleanup() this.#cleanup()
this.events.emit({ type: "disconnected" }) this.events.emit({ type: "disconnected" })
} }

528
src/buzz/README.md Normal file
View File

@ -0,0 +1,528 @@
# Buzz
High-level audio library for Bun using ALSA with streaming support and voice activity detection.
## Features
- Play audio files with repeat option
- Generate and play multi-frequency tones (dial tones, DTMF, etc.)
- Stream audio playback with buffer tracking
- Record audio to stream or file (WAV)
- Volume control via ALSA mixer
- Device discovery and selection
- Voice activity detection via RMS calculation
- Type-safe TypeScript API with namespace types
- Zero external dependencies (uses ALSA `aplay` and `arecord`)
## Requirements
- Bun 1.0+
- ALSA utilities (`aplay`, `arecord`, `amixer`)
- Linux system with ALSA support
- TypeScript 5.2+
## Quick Start
```typescript
import Buzz from "./buzz"
// Play an audio file
const player = await Buzz.player()
const playback = await player.play("./sounds/greeting.wav")
await playback.finished()
// Generate a dial tone
const dialTone = await player.playTone([350, 440], Infinity) // infinite duration
await Buzz.sleep(3000)
await dialTone.stop()
// Record audio
const recorder = await Buzz.recorder()
const recording = recorder.start()
for await (const chunk of recording.stream()) {
const rms = Buzz.calculateRMS(chunk)
if (rms > 5000) {
console.log("Speech detected!")
}
}
```
## API
### Buzz Module
#### `Buzz.player(label?, format?)`
Create a player. Omit `label` to use the default playback device.
```typescript
const player = await Buzz.player() // default device
const player = await Buzz.player(undefined, { sampleRate: 16000 }) // default device with custom format
const player = await Buzz.player("USB Audio") // specific device
const player = await Buzz.player("Speaker", { sampleRate: 44100 }) // specific device with format
```
#### `Buzz.recorder(label?, format?)`
Create a recorder. Omit `label` to use the default capture device.
```typescript
const recorder = await Buzz.recorder() // default device
const recorder = await Buzz.recorder(undefined, { sampleRate: 16000 }) // default device with custom format
const recorder = await Buzz.recorder("USB Microphone") // specific device
```
#### `Buzz.setVolume(volume, label?)`
Set playback volume (0.0 to 1.0).
```typescript
await Buzz.setVolume(0.5) // 50% on default device
await Buzz.setVolume(0.8, "Speaker") // 80% on specific device
```
#### `Buzz.getVolume(label?)`
Get current playback volume.
```typescript
const volume = await Buzz.getVolume() // returns 0.0 to 1.0
```
#### `Buzz.listDevices()`
List all available audio devices.
```typescript
const devices = await Buzz.listDevices()
// [
// { id: 'plughw:0,0', card: 0, device: 0, label: 'bcm2835 Headphones', type: 'playback' },
// { id: 'plughw:1,0', card: 1, device: 0, label: 'USB Audio', type: 'capture' }
// ]
```
#### `Buzz.calculateRMS(audioChunk)`
Calculate root mean square (RMS) for voice activity detection.
```typescript
const chunk: Uint8Array = // ... audio data
const rms = Buzz.calculateRMS(chunk)
if (rms > 5000) {
console.log("Voice detected!")
}
```
### Player
#### `player.play(filePath, options?)`
Play an audio file (WAV format).
```typescript
const playback = await player.play("./sounds/beep.wav")
const playback = await player.play("./music.wav", { repeat: true })
// Wait for playback to finish
await playback.finished()
// Stop playback
await playback.stop()
```
Returns: `Buzz.Playback`
Options:
- `repeat?: boolean` - Loop the file indefinitely (default: false)
#### `player.playTone(frequencies, duration)`
Generate and play a tone with one or more frequencies.
```typescript
// Dial tone (350 Hz + 440 Hz)
const dialTone = await player.playTone([350, 440], Infinity)
// DTMF "1" key (697 Hz + 1209 Hz) for 200ms
const dtmf = await player.playTone([697, 1209], 200)
// Single frequency beep
const beep = await player.playTone([440], 1000) // 440 Hz for 1 second
```
Returns: `Buzz.Playback`
#### `player.playStream()`
Create a streaming playback handle for real-time audio.
```typescript
const stream = player.playStream()
// Write audio chunks
stream.write(audioChunk1)
stream.write(audioChunk2)
// Check if buffer is empty
if (stream.bufferEmptyFor > 1000) {
console.log("Buffer empty for 1+ seconds")
}
// Stop streaming
await stream.stop()
```
Returns: `Buzz.StreamingPlayback`
### Recorder
#### `recorder.start()`
Start recording to a stream.
```typescript
const recording = recorder.start()
for await (const chunk of recording.stream()) {
// Process audio chunks (Uint8Array)
console.log("Received", chunk.byteLength, "bytes")
}
```
Returns: `Buzz.StreamingRecording`
#### `recorder.start(outputFile)`
Start recording to a WAV file.
```typescript
const recording = recorder.start("./output.wav")
// Stop when done
await Bun.sleep(5000)
await recording.stop()
```
Returns: `Buzz.FileRecording`
## Types
All types are available under the `Buzz` namespace:
```typescript
Buzz.AudioFormat // { format?, sampleRate?, channels? }
Buzz.Device // { id, card, device, label, type }
Buzz.Playback // { isPlaying, stop(), finished() }
Buzz.StreamingPlayback // { isPlaying, write(), stop(), bufferEmptyFor }
Buzz.StreamingRecording // { isRecording, stream(), stop() }
Buzz.FileRecording // { isRecording, stop() }
Buzz.Player // Player class type
Buzz.Recorder // Recorder class type
```
## Audio Format
Default format: `S16_LE`, 16000 Hz, mono
```typescript
type AudioFormat = {
format?: string // e.g., "S16_LE", "S32_LE"
sampleRate?: number // e.g., 16000, 44100, 48000
channels?: number // 1 = mono, 2 = stereo
}
```
Common formats:
- **Phone quality**: `{ sampleRate: 8000, channels: 1 }`
- **Voice/AI**: `{ sampleRate: 16000, channels: 1 }` (default)
- **CD quality**: `{ sampleRate: 44100, channels: 2 }`
- **Professional**: `{ sampleRate: 48000, channels: 2 }`
## Examples
### Voice Activity Detection
```typescript
import Buzz from "./buzz"
const recorder = await Buzz.recorder()
const player = await Buzz.player()
const recording = recorder.start()
let talking = false
for await (const chunk of recording.stream()) {
const rms = Buzz.calculateRMS(chunk)
if (rms > 5000 && !talking) {
console.log("🗣️ Started talking")
talking = true
} else if (rms < 1000 && talking) {
console.log("🤫 Stopped talking")
talking = false
}
}
```
### Streaming Playback with Buffer Tracking
```typescript
import Buzz from "./buzz"
const player = await Buzz.player()
const stream = player.playStream()
// Simulate receiving audio chunks from network
const chunks = [chunk1, chunk2, chunk3] // Uint8Array[]
for (const chunk of chunks) {
stream.write(chunk)
// Wait until buffer is nearly empty before requesting more
while (stream.bufferEmptyFor < 500) {
await Bun.sleep(100)
}
}
await stream.stop()
```
### Dial Tone with Voice Detection
```typescript
import Buzz from "./buzz"
await Buzz.setVolume(0.4)
const player = await Buzz.player()
const recorder = await Buzz.recorder()
// Play dial tone
const dialTone = await player.playTone([350, 440], Infinity)
// Wait for voice
const recording = recorder.start()
const vadThreshold = 5000
for await (const chunk of recording.stream()) {
const rms = Buzz.calculateRMS(chunk)
if (rms > vadThreshold) {
console.log("Voice detected, stopping dial tone")
await dialTone.stop()
break
}
}
```
### Play Sound Effects
```typescript
import Buzz from "./buzz"
const player = await Buzz.player()
// Play multiple sounds in sequence
const sounds = ["./start.wav", "./beep.wav", "./end.wav"]
for (const sound of sounds) {
const playback = await player.play(sound)
await playback.finished()
}
```
### Background Music Loop
```typescript
import Buzz from "./buzz"
const player = await Buzz.player()
// Play background music on repeat
const bgMusic = await player.play("./background.wav", { repeat: true })
// Stop after 30 seconds
await Bun.sleep(30000)
await bgMusic.stop()
```
### Record to File
```typescript
import Buzz from "./buzz"
const recorder = await Buzz.recorder()
console.log("Recording for 10 seconds...")
const recording = recorder.start("./output.wav")
await Bun.sleep(10000)
await recording.stop()
console.log("Saved to output.wav")
```
### Multi-Device Setup
```typescript
import Buzz from "./buzz"
// List all devices
const devices = await Buzz.listDevices()
console.log("Available devices:", devices)
// Use specific devices
const speaker = await Buzz.player("Speaker")
const mic = await Buzz.recorder("USB Microphone")
// Independent volume control
await Buzz.setVolume(0.8, "Speaker")
await Buzz.setVolume(1.0, "Headphones")
```
## Architecture
### ALSA Backend
Buzz wraps ALSA command-line tools (`aplay`, `arecord`) via Bun's subprocess API:
- **Playback**: Spawns `aplay` with stdin pipe for streaming or file path for file playback
- **Recording**: Spawns `arecord` with stdout pipe for streaming or file path for WAV output
- **Volume**: Uses `amixer` for volume control
**Benefits:**
- **Simple**: No C bindings or FFI required
- **Reliable**: ALSA tools are battle-tested
- **Flexible**: Full format support (sample rates, channels, encodings)
- **Portable**: Works on any Linux system with ALSA
### Streaming Architecture
Streaming playback uses Bun's subprocess stdin pipe:
1. Spawn `aplay` with raw audio format and stdin input
2. Write audio chunks to process stdin as they arrive
3. Track buffer duration based on bytes written
4. Calculate `bufferEmptyFor` using performance timestamps
This enables:
- Real-time playback of network streams (WebSocket, API responses)
- Buffer management for smooth playback
- Low-latency audio (<100ms with proper buffering)
### Voice Activity Detection
`calculateRMS()` computes the root mean square of audio samples:
```
RMS = sqrt(sum(sample²) / count)
```
This provides a simple but effective measure of audio energy:
- Silence: RMS < 1000
- Noise: RMS 1000-5000
- Speech: RMS > 5000
Adjust thresholds based on your microphone and environment.
## Device Selection
### By Default (Recommended)
```typescript
const player = await Buzz.player()
const recorder = await Buzz.recorder()
```
Uses ALSA default device (usually correct).
### By Label
```typescript
const devices = await Buzz.listDevices()
// Find device with label containing "USB"
const usbDevice = devices.find(d => d.label.includes("USB"))
const player = await Buzz.player(usbDevice.label)
```
Useful for multi-device setups (USB audio, HDMI, headphones).
## Error Handling
```typescript
try {
const player = await Buzz.player()
} catch (err) {
if (err.message.includes("No playback devices found")) {
console.error("No audio output devices available")
}
}
try {
await Buzz.setVolume(0.5)
} catch (err) {
if (err.message.includes("Failed to set volume")) {
console.error("Could not control volume (check mixer permissions)")
}
}
```
## Troubleshooting
### No devices found
Check ALSA devices:
```bash
aplay -l # list playback devices
arecord -l # list capture devices
```
### Volume control fails
Check mixer controls:
```bash
amixer scontrols
amixer sget Master
```
### Crackling or distortion
Try different buffer sizes by adjusting format:
```typescript
const player = await Buzz.player(undefined, {
sampleRate: 16000,
channels: 1,
format: "S16_LE"
})
```
### Device already in use
Only one process can use an ALSA device at a time. Stop other audio applications or use PulseAudio/PipeWire for mixing.
## Design Philosophy
- **Simple by default** - `player()` and `recorder()` work out of the box without arguments
- **Streaming-first** - Built for real-time audio (AI voice, telephony, WebRTC)
- **Type-safe** - Namespace types provide autocomplete and compile-time safety
- **Flexible** - Support for files, tones, and streams
- **Minimal dependencies** - Uses standard ALSA tools, no native bindings
## Performance
- **Latency**: ~50-100ms for streaming playback (depends on buffering)
- **CPU**: Minimal overhead (subprocess spawning + pipe I/O)
- **Memory**: Efficient streaming (no need to load entire files)
- **Voice detection**: `calculateRMS()` is fast (~1µs per chunk on modern hardware)
## References
- [ALSA documentation](https://www.alsa-project.org/wiki/Main_Page)
- [Bun subprocess API](https://bun.sh/docs/api/spawn)
- [Audio sample formats](https://en.wikipedia.org/wiki/Audio_bit_depth)

View File

@ -1,20 +1,21 @@
import { Player } from "./player.js" import { Player as PlayerClass } from "./player.js"
import { Recorder } from "./recorder.js" import { Recorder as RecorderClass } from "./recorder.js"
import { import {
listDevices, listDevices,
calculateRMS, calculateRMS,
findDeviceByLabel, findDeviceByLabel,
type AudioFormat, type AudioFormat as AudioFormatType,
type Device, type Device as DeviceType,
type Playback as PlaybackType,
type StreamingPlayback as StreamingPlaybackType,
type StreamingRecording as StreamingRecordingType,
type FileRecording as FileRecordingType,
} from "./utils.js" } from "./utils.js"
const defaultPlayer = (format?: AudioFormat) => Player.create({ format }) const player = (label?: string, format?: AudioFormatType) => PlayerClass.create({ label, format })
const player = (label: string, format?: AudioFormat) => Player.create({ label, format }) const recorder = (label?: string, format?: AudioFormatType) =>
RecorderClass.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 getVolumeControl = async (cardNumber?: number): Promise<string> => {
const output = cardNumber const output = cardNumber
@ -80,16 +81,20 @@ const getVolume = async (label?: string): Promise<number> => {
const Buzz = { const Buzz = {
listDevices, listDevices,
defaultPlayer,
player, player,
defaultRecorder,
recorder, recorder,
setVolume, setVolume,
getVolume, getVolume,
calculateRMS, calculateRMS,
} }
declare namespace Buzz {
export type Playback = PlaybackType
export type StreamingPlayback = StreamingPlaybackType
export type StreamingRecording = StreamingRecordingType
export type FileRecording = FileRecordingType
export type Player = PlayerClass
export type Recorder = RecorderClass
}
export default Buzz export default Buzz
export type { Device, AudioFormat }
export { type Player } from "./player.js"
export { type Recorder } from "./recorder.js"

26
src/main.ts Normal file
View File

@ -0,0 +1,26 @@
import { runPhone } from "./phone"
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)
}
console.log(`☎️ Starting phone with pid=${process.pid}`)
try {
await runPhone(agentId, apiKey)
} catch (error) {
console.error(`❌ Error starting phone: ${(error as Error).message}`)
process.exit(1)
}
console.log(`👋 Goodbye!`)

View File

@ -1,15 +1,24 @@
import { d, reduce, createMachine, state, transition, interpret, guard } from "robot3" import {
d,
reduce,
createMachine,
state,
transition,
interpret,
action,
invoke,
type Service,
} from "robot3"
import { Baresip } from "./sip" import { Baresip } from "./sip"
import { log } from "./utils/log" import log from "./utils/log"
import { sleep } from "bun" import { sleep } from "bun"
import { processStderr, processStdout } from "./utils/stdio"
import Buzz from "./buzz" import Buzz from "./buzz"
import { join } from "path" import { join } from "path"
import GPIO from "./pins" import GPIO from "./pins"
import { Agent } from "./agent" import { Agent } from "./agent"
import { searchWeb } from "./agent/tools" import { searchWeb } from "./agent/tools"
import { ring } from "./utils"
// TODO: Kill baresip process on exit import { getSound, WaitingSounds } from "./utils/waiting-sounds"
type CancelableTask = () => void type CancelableTask = () => void
@ -17,422 +26,433 @@ type PhoneContext = {
lastError?: string lastError?: string
peer?: string peer?: string
numberDialed: number numberDialed: number
cancelDialTone?: CancelableTask
cancelRinger?: CancelableTask cancelRinger?: CancelableTask
baresip: Baresip baresip: Baresip
startAgent: () => CancelableTask stopAgent?: CancelableTask
cancelAgent?: CancelableTask ringer: GPIO.Output
agentId: string
agentKey: string
} }
type PhoneService = Service<typeof phoneMachine>
const player = await Buzz.player()
let dialTonePlayback: Buzz.Playback | undefined
export const runPhone = async (agentId: string, agentKey: string) => {
const gpio = new GPIO() const gpio = new GPIO()
using ringer = gpio.output(17, { resetOnClose: true }) using ringer = gpio.output(17, { resetOnClose: true })
using hook = gpio.input(27, { pull: "up", debounce: 3 }) using hook = gpio.input(27, { pull: "up", debounce: 3 })
using rotaryInUse = gpio.input(22, { pull: "up", debounce: 3 }) using rotaryInUse = gpio.input(22, { pull: "up", debounce: 3 })
using rotaryNumber = gpio.input(23, { pull: "up", debounce: 3 }) using rotaryNumber = gpio.input(23, { pull: "up", debounce: 3 })
export const startPhone = async (agentId: string, apiKey: string) => { await Buzz.setVolume(0.3)
await Buzz.setVolume(0.4) log(`📞 Phone is ${hook.value ? "off hook" : "on hook"}`)
log.info(`📞 Hook ${hook.value}`)
let digit = 0 playStartRing(ringer)
hook.onChange((event) => { const phoneService = interpret(phoneMachine, () => {})
const type = event.value == 0 ? "hang_up" : "pick_up" listenForPhoneEvents(phoneService, hook, rotaryInUse, rotaryNumber)
log.info(`📞 Hook ${event.value} sending ${type}`) const baresip = await startBaresip(phoneService, hook, ringer)
if (type === "hang_up") { phoneService.send({ type: "config", baresip, agentId, agentKey, ringer })
ringer.value = 1
} else {
ringer.value = 0
}
})
rotaryInUse.onChange((event) => { process.on("SIGINT", () => cleanup(baresip, ringer))
if (event.value === 0) { process.on("SIGTERM", () => cleanup(baresip, ringer))
digit = 0
} else {
log.info(`📞 Dialed digit: ${digit}`)
}
})
rotaryNumber.onChange((event) => {
if (event.value === 1) {
digit += 1
}
})
// Keep process running // Keep process running
await new Promise(() => {}) await new Promise(() => {})
} }
const apiKey = process.env.ELEVEN_API_KEY const listenForPhoneEvents = (
const agentId = process.env.ELEVEN_AGENT_ID phoneService: PhoneService,
hook: GPIO.Input,
rotaryInUse: GPIO.Input,
rotaryNumber: GPIO.Input,
) => {
hook.onChange((event) => {
const type = event.value == 0 ? "hang-up" : "pick-up"
log(`📞 Hook ${event.value} sending ${type}`)
phoneService.send({ type })
})
if (!apiKey) { rotaryInUse.onChange((event) => {
console.error("❌ Error: ELEVEN_API_KEY environment variable is required") if (event.value === 0) {
process.exit(1) phoneService.send({ type: "dial-start" })
} else {
phoneService.send({ type: "dial-stop" })
}
})
rotaryNumber.onChange((event) => {
if (event.value === 1) {
phoneService.send({ type: "digit_increment" })
}
})
} }
if (!agentId) { const startBaresip = async (phoneService: PhoneService, hook: GPIO.Input, ringer: GPIO.Output) => {
console.error( const baresipConfig = join(import.meta.dir, "..", "baresip")
"❌ Error: ELEVEN_AGENT_ID environELEVEN_AGENT_ID=agent_5601k4taw2cvfjzrz6snxpgeh7x8 ELEVEN_API_KEY=sk_0313740f112c5992cb62ed96c974ab19b5916f1ea172471fment variable is required" const baresip = new Baresip(["/usr/bin/baresip", "-v", "-f", baresipConfig])
baresip.registrationSuccess.on(async () => {
log("🐻 server connected")
if (hook.value === 0) {
phoneService.send({ type: "initialized" })
} else {
phoneService.send({ type: "pick-up" })
}
})
baresip.callReceived.on(({ contact }) => {
log(`🐻 incoming call from ${contact}`)
phoneService.send({ type: "incoming-call", from: contact })
})
baresip.callEstablished.on(({ contact }) => {
log(`🐻 call established with ${contact}`)
phoneService.send({ type: "answered" })
})
baresip.hungUp.on(() => {
log("🐻 call hung up")
phoneService.send({ type: "remote-hang-up" })
})
baresip.connect().catch((error) => {
log.error("🐻 connection error:", error)
phoneService.send({ type: "error", message: error.message })
})
baresip.error.on(async ({ message }) => {
log.error("🐻 error:", message)
phoneService.send({ type: "error", message })
for (let i = 0; i < 4; i++) {
await ring(ringer, 500)
await sleep(250)
}
process.exit(1)
})
return baresip
}
const cleanup = (baresip: Baresip, ringer: GPIO.Output) => {
try {
log("🛑 Shutting down, stopping agent process")
playExitRing(ringer)
baresip.kill()
} catch (error) {
log.error("Error during shutdown:", error)
} finally {
process.exit(0)
}
}
const handleError = (ctx: PhoneContext, event: { type: "error"; message?: string }) => {
ctx.lastError = event.message
log.error(`Phone Error: ${event.message}`)
return ctx
}
const config = (
ctx: PhoneContext,
event: { baresip: Baresip; agentId: string; agentKey: string; ringer: GPIO.Output },
) => {
ctx.baresip = event.baresip
ctx.agentId = event.agentId
ctx.agentKey = event.agentKey
ctx.ringer = event.ringer
return ctx
}
const startAgent = (service: Service<typeof phoneMachine>, ctx: PhoneContext) => {
let streamPlayback = player.playStream()
const agent = new Agent({
agentId: ctx.agentId,
apiKey: ctx.agentKey,
tools: {
search_web: (args: { query: string }) => searchWeb(args.query),
},
})
handleAgentEvents(service, agent, streamPlayback)
const stopListening = startListening(service, agent)
ctx.stopAgent = () => {
stopListening()
dialTonePlayback?.stop()
streamPlayback.stop()
}
return ctx
}
const startListening = (service: Service<typeof phoneMachine>, agent: Agent) => {
const abortAgent = new AbortController()
new Promise<void>(async (resolve) => {
const recorder = await Buzz.recorder()
const listenPlayback = recorder.start()
let backgroundNoisePlayback: Buzz.Playback | undefined
let waitingForVoice = true
const maxPreBufferChunks = 4 // Keep ~1 second of audio before speech detection
let preConnectionBuffer: Uint8Array[] = []
agent.events.on(async (event) => {
if (event.type === "disconnected") abortAgent.abort()
})
for await (const chunk of listenPlayback.stream()) {
if (abortAgent.signal.aborted) {
agent.stop()
listenPlayback.stop()
backgroundNoisePlayback?.stop()
resolve()
break
}
if (waitingForVoice) {
preConnectionBuffer.push(chunk)
if (preConnectionBuffer.length > maxPreBufferChunks) {
preConnectionBuffer.shift()
}
const rms = Buzz.calculateRMS(chunk)
if (rms > 5000) {
dialTonePlayback?.stop()
service.send({ type: "start-agent" })
waitingForVoice = false
backgroundNoisePlayback = await player.play(getSound("background"), { repeat: true })
await agent.start()
// Send pre-buffered audio
for (const chunk of preConnectionBuffer) agent.sendAudio(chunk)
preConnectionBuffer = []
}
} else {
agent.sendAudio(chunk)
}
}
})
return () => abortAgent.abort()
}
const handleAgentEvents = (
service: Service<typeof phoneMachine>,
agent: Agent,
streamPlayback: Buzz.StreamingPlayback,
) => {
const waitingIndicator = new WaitingSounds(player)
agent.events.on(async (event) => {
switch (event.type) {
case "connected":
log("🤖 Connected to AI agent\n")
break
case "user_transcript":
log(`🤖 You: ${event.transcript}`)
break
case "agent_response":
log(`🤖 Agent: ${event.response}`)
break
case "audio":
await waitingIndicator.stop()
const audioBuffer = Buffer.from(event.audioBase64, "base64")
streamPlayback.write(audioBuffer)
break
case "interruption":
log("🤖 User interrupted")
await waitingIndicator.stop()
streamPlayback?.stop()
streamPlayback = player.playStream() // Reset playback stream
break
case "tool_call":
waitingIndicator.start(streamPlayback)
log(`🤖 Tool call: ${event.name}(${JSON.stringify(event.args)})`)
break
case "tool_result":
log(`🤖 Tool result: ${JSON.stringify(event.result)}`)
break
case "tool_error":
console.error(`❌ Tool error: ${event.error}`)
break
case "disconnected":
log(`🤖 👋 Conversation ended, returning to dialtone`)
streamPlayback?.stop()
service.send({ type: "remote-hang-up" })
break
case "error":
log.error("🤖 Agent error:", event.error)
break
case "ping":
break
default:
log.debug(`😵 Unknown agent event ${event.type}`)
break
}
})
}
const stopAgent = (ctx: PhoneContext) => {
ctx.stopAgent?.()
ctx.stopAgent = undefined
return ctx
}
const incomingCall = (ctx: PhoneContext, event: { type: "incoming-call"; from?: string }) => {
ctx.peer = event.from
return ctx
}
const hangUp = (ctx: PhoneContext) => {
console.log(`📞 Hanging up call`)
ctx.baresip.hangUp()
}
const answerCall = (ctx: PhoneContext) => {
log(`📞 Answering call`)
ctx.baresip.accept()
}
const makeCall = async (ctx: PhoneContext) => {
log(`Dialing number: ${ctx.numberDialed}`)
if (ctx.numberDialed === 1) {
ctx.baresip.dial("+13476229543")
} else if (ctx.numberDialed === 2) {
ctx.baresip.dial("+18109643563")
} else {
log.error(`No contact for number dialed: ${ctx.numberDialed}`)
}
return ctx
}
const startRinger = async (ctx: PhoneContext) => {
let abortController = new AbortController()
const keepRinging = async () => {
while (!abortController.signal.aborted) {
await ring(ctx.ringer, 2000, abortController.signal)
await sleep(4000)
}
}
keepRinging().catch((error) => log.error("Ringer error:", error))
ctx.cancelRinger = () => abortController.abort()
return ctx
}
const stopRinger = (ctx: PhoneContext) => {
ctx.cancelRinger?.()
ctx.cancelRinger = undefined
return ctx
}
async function startDialToneAndAgent(this: any, ctx: PhoneContext) {
ctx = await startAgent(this, ctx)
await dialTonePlayback?.stop()
dialTonePlayback = await player.playTone([350, 440], Infinity)
return ctx
}
const stopDialTone = () => {
dialTonePlayback?.stop()
}
const dialStart = (ctx: PhoneContext) => {
ctx.numberDialed = 0
return ctx
}
const digitIncrement = (ctx: PhoneContext) => {
ctx.numberDialed += 1
return ctx
}
const playStartRing = async (ringer: GPIO.Output) => {
// Three quick beeps, getting faster = energetic/welcoming
ringer.value = 1
await Bun.sleep(80)
ringer.value = 0
await Bun.sleep(120)
ringer.value = 1
await Bun.sleep(80)
ringer.value = 0
await Bun.sleep(100)
ringer.value = 1
await Bun.sleep(80)
ringer.value = 0
}
const playExitRing = async (ringer: GPIO.Output) => {
ringer.value = 0 // Always try and turn it off!
}
const t = transition
const r = reduce
const a = action
const phoneMachine = createMachine(
"booting",
// prettier-ignore
{
booting: state(
t("config", "initializing", r(config))
),
initializing: state(
t("initialized", "idle"),
t("pick-up", "ready"),
t("error", "fault", r(handleError))),
idle: state(
t("incoming-call", "incoming", r(incomingCall)),
t("pick-up", "ready")),
incoming: invoke(startRinger,
t("remote-hang-up", "idle", r(stopRinger)),
t("pick-up", "connected", r(stopRinger), a(answerCall))),
connected: state(
t("remote-hang-up", "ready"),
t("hang-up", "idle", a(hangUp))),
ready: invoke(startDialToneAndAgent,
t("dial-start", "dialing", a(stopDialTone), r(dialStart), a(stopAgent)),
t("hang-up", "idle", a(stopDialTone), a(stopAgent)),
t("start-agent", "connectToAgent", a(stopDialTone))),
connectToAgent: state(
t("hang-up", "idle", r(stopAgent)),
t("remote-hang-up", "ready", r(stopAgent))),
dialing: state(
t("dial-stop", "outgoing"),
t("digit_increment", "dialing", r(digitIncrement)),
t("hang-up", "idle")),
outgoing: invoke(makeCall,
t("answered", "connected"),
t("hang-up", "idle", a(hangUp))),
aborted: state(
t("hang-up", "idle")),
fault: state(),
},
) )
console.error(" Create an agent at https://elevenlabs.io/app/conversational-ai")
process.exit(1) d._onEnter = function (machine, to, state, prevState, event) {
log(`📱 ${machine.current} -> ${to} (${(event as any).type})`)
} }
await startPhone(agentId, apiKey)
const startBaresip = async (hook: GPIO.InputPin) => {
// const baresipConfig = join(import.meta.dir, "..", "baresip")
// const baresip = new Baresip(["/usr/bin/baresip", "-v", "-f", baresipConfig])
// baresip.registrationSuccess.connect(async () => {
// log.info("🐻 server connected")
// const result = await gpio.get(pins.hook)
// if (result.state === "low") {
// phoneService.send({ type: "initialized" })
// } else {
// phoneService.send({ type: "pick_up" })
// }
// })
// baresip.callReceived.connect(({ contact }) => {
// log.info(`🐻 incoming call from ${contact}`)
// phoneService.send({ type: "incoming_call", from: contact })
// })
// baresip.callEstablished.connect(({ contact }) => {
// log.info(`🐻 call established with ${contact}`)
// phoneService.send({ type: "answered" })
// })
// baresip.hungUp.connect(() => {
// log.info("🐻 call hung up")
// phoneService.send({ type: "remote_hang_up" })
// })
// baresip.connect().catch((error) => {
// log.error("🐻 connection error:", error)
// phoneService.send({ type: "error", message: error.message })
// })
// baresip.error.connect(async ({ message }) => {
// log.error("🐻 error:", message)
// phoneService.send({ type: "error", message })
// for (let i = 0; i < 4; i++) {
// await ring(500)
// await sleep(250)
// }
// process.exit(1)
// })
// const agent = new Agent({
// agentId,
// apiKey,
// tools: {
// search_web: (args: { query: string }) => searchWeb(args.query),
// },
// })
}
// handleAgentEvents(agent)
// const startAgent = () => {
// log.info("☎️ Starting agent conversation")
// if (agentProcess?.stdin) {
// agentProcess.stdin.write("start\n")
// } else {
// log.error("☎️ No agent process stdin available")
// phoneService.send({ type: "remote_hang_up" })
// }
// return () => {
// log.info("☎️ Stopping agent conversation")
// if (agentProcess?.stdin) {
// agentProcess.stdin.write("stop\n")
// }
// }
// }
// const context = (initial?: Partial<PhoneContext>): PhoneContext => ({
// numberDialed: 0,
// baresip,
// startAgent,
// ...initial,
// })
// const phoneMachine = createMachine(
// "initializing",
// // prettier-ignore
// {
// initializing: state(
// transition("initialized", "idle"),
// transition("pick_up", "ready", reduce(playDialTone)),
// transition("error", "fault", reduce(handleError))),
// idle: state(
// transition("incoming_call", "incoming", reduce(incomingCall)),
// transition("pick_up", "ready", reduce(playDialTone))),
// incoming: state(
// transition("remote_hang_up", "idle", reduce(stopRinger)),
// transition("pick_up", "connected", reduce(callAnswered))),
// connected: state(
// transition("remote_hang_up", "ready", reduce(playDialTone)),
// transition("hang_up", "idle", reduce(stopCall))),
// ready: state(
// transition("dial_start", "dialing", reduce(dialStart)),
// transition("dial_timeout", "aborted", reduce(stopDialTone)),
// transition("hang_up", "idle", reduce(stopDialTone))),
// dialing: state(
// transition("dial_stop", "outgoing", reduce(makeCall), guard((ctx) => !callAgentGuard(ctx))),
// transition("dial_stop", "connectedToAgent", reduce(makeAgentCall), guard((ctx) => callAgentGuard(ctx))),
// transition("digit_increment", "dialing", reduce(digitIncrement)),
// transition("hang_up", "idle", reduce(stopDialTone))),
// outgoing: state(
// transition("start_agent", "connectedToAgent"),
// transition("answered", "connected"),
// transition("hang_up", "idle", reduce(stopCall))),
// connectedToAgent: state(
// transition("remote_hang_up", "ready", reduce(stopAgent)),
// transition("hang_up", "idle", reduce(stopAgent))),
// aborted: state(
// transition("hang_up", "idle")),
// fault: state(),
// },
// context
// )
// const phoneService = interpret(phoneMachine, () => {})
// d._onEnter = function (machine, to, state, prevState, event) {
// log.info(`📱 ${machine.current} -> ${to} (${JSON.stringify(event)})`)
// }
// gpio.monitor(pins.hook, { bias: "pull-up" }, (event) => {
// const type = event.edge === "falling" ? "hang_up" : "pick_up"
// log.info(`📞 Hook ${event.edge} sending ${type}`)
// phoneService.send({ type })
// })
// gpio.monitor(pins.rotaryInUse, { bias: "pull-up", throttleMs: 90 }, (event) => {
// const type = event.edge === "falling" ? "dial_start" : "dial_stop"
// log.debug(`📞 Rotary in-use ${event.edge} sending ${type}`)
// phoneService.send({ type })
// })
// gpio.monitor(pins.rotaryNumber, { bias: "pull-up", throttleMs: 90 }, (event) => {
// if (event.edge !== "rising") return
// phoneService.send({ type: "digit_increment" })
// })
// // Graceful shutdown handling
// const cleanup = () => {
// log.info("🛑 Shutting down, stopping agent process")
// if (agentProcess?.stdin) {
// agentProcess.stdin.write("quit\n")
// }
// }
// process.on("SIGINT", cleanup)
// process.on("SIGTERM", cleanup)
// process.on("exit", cleanup)
// }
// const handleAgentEvents = (agent: Agent) => {
// agent.events.connect(async (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":
// await waitingIndicator.stop()
// const audioBuffer = Buffer.from(event.audioBase64, "base64")
// streamPlayback.write(audioBuffer)
// break
// case "interruption":
// console.log("🛑 User interrupted")
// streamPlayback?.stop()
// streamPlayback = player.playStream() // Reset playback stream
// break
// case "tool_call":
// waitingIndicator.start(streamPlayback)
// 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")
// streamPlayback?.stop()
// state = "WAITING_FOR_VOICE"
// phoneService.send({ type: "remote_hang_up" })
// break
// case "error":
// console.error("Agent error:", event.error)
// break
// case "ping":
// break
// default:
// console.log(`😵‍💫 ${event.type}`)
// break
// }
// })
// }
// const incomingCallRing = (): CancelableTask => {
// let abortController = new AbortController()
// const playRingtone = async () => {
// while (!abortController.signal.aborted) {
// await ring(2000, abortController.signal)
// await sleep(4000)
// }
// }
// playRingtone().catch((error) => log.error("Ringer error:", error))
// return () => abortController.abort()
// }
// const handleError = (ctx: PhoneContext, event: { type: "error"; message?: string }) => {
// ctx.lastError = event.message
// log.error(`Phone error: ${event.message}`)
// return ctx
// }
// const incomingCall = (ctx: PhoneContext, event: { type: "incoming_call"; from?: string }) => {
// ctx.peer = event.from
// ctx.cancelRinger = incomingCallRing()
// log.info(`Incoming call from ${event.from}`)
// return ctx
// }
// const stopRinger = (ctx: PhoneContext) => {
// ctx.cancelRinger?.()
// ctx.cancelRinger = undefined
// return ctx
// }
// const playDialTone = (ctx: PhoneContext) => {
// const tone = new ToneGenerator()
// tone.loopTone([350, 440])
// ctx.cancelDialTone = () => {
// tone.stop()
// }
// return ctx
// }
// const playOutgoingTone = () => {
// const tone = new ToneGenerator()
// let canceled = false
// const play = async () => {
// while (!canceled) {
// await tone.playTone([440, 480], 2000)
// await sleep(4000)
// }
// }
// play().catch((error) => log.error("Outgoing tone error:", error))
// return () => {
// tone.stop()
// canceled = true
// }
// }
// const dialStart = (ctx: PhoneContext) => {
// ctx.numberDialed = 0
// ctx = stopDialTone(ctx)
// return ctx
// }
// const makeCall = (ctx: PhoneContext) => {
// log.info(`Dialing number: ${ctx.numberDialed}`)
// if (ctx.numberDialed === 1) {
// ctx.baresip.dial("+13476229543")
// } else if (ctx.numberDialed === 2) {
// ctx.baresip.dial("+18109643563")
// } else {
// const playTone = async () => {
// const tone = new ToneGenerator()
// await tone.playTone([900], 200)
// await tone.playTone([1350], 200)
// await tone.playTone([1750], 200)
// }
// playTone().catch((error) => log.error("Error playing tone:", error))
// }
// return ctx
// }
// const makeAgentCall = (ctx: PhoneContext) => {
// log.info(`Calling agent`)
// ctx.cancelAgent = ctx.startAgent()
// return ctx
// }
// const callAgentGuard = (ctx: PhoneContext) => {
// return ctx.numberDialed === 10
// }
// const callAnswered = (ctx: PhoneContext) => {
// ctx.baresip.accept()
// ctx.cancelDialTone?.()
// ctx.cancelDialTone = undefined
// ctx.cancelRinger?.()
// ctx.cancelRinger = undefined
// return ctx
// }
// const stopCall = (ctx: PhoneContext) => {
// ctx.baresip.hangUp()
// return ctx
// }
// const stopAgent = (ctx: PhoneContext) => {
// log.info("🛑 Stopping agent")
// ctx.cancelAgent?.()
// ctx.cancelAgent = undefined
// return ctx
// }
// const stopDialTone = (ctx: PhoneContext) => {
// ctx.cancelDialTone?.()
// ctx.cancelDialTone = undefined
// return ctx
// }
// const digitIncrement = (ctx: PhoneContext) => {
// ctx.numberDialed += 1
// return ctx
// }

View File

@ -1,173 +0,0 @@
# Bun FFI Learnings
After researching GitHub examples and Bun's FFI documentation, here's what I found surprising and helpful.
## Surprising Discoveries
### 1. **String Handling is Simpler Than Expected**
I initially thought you'd need `CString` everywhere, but:
- For **args**: `FFIType.cstring` just needs `ptr(Buffer.from(str + "\0"))`
- For **returns**: `FFIType.cstring` automatically converts pointers to JS strings
- `CString` is mainly for **reading** C strings from pointers, not passing them
**Example from real code:**
```javascript
const str = Buffer.from("hello\0", "utf8");
myFunction(ptr(str)); // Clean and simple!
```
### 2. **No Type Wrappers Needed**
Unlike Node-FFI, Bun doesn't require defining structs or complex type wrappers. Just:
```javascript
add: {
args: [FFIType.i32, FFIType.i32],
returns: FFIType.i32,
}
```
### 3. **TinyCC JIT Compilation**
Bun embeds TinyCC and JIT-compiles C bindings on the fly. This means:
- 2-6x faster than Node-API
- Zero build step for type conversions
- Direct memory access without serialization
## Helpful Patterns
### Pattern 1: String Helper
```typescript
import { ptr } from "bun:ffi"
const cstr = (s: string) => ptr(Buffer.from(s + "\0"))
// Usage:
gpiod.open(cstr("/dev/gpiochip0"))
```
### Pattern 2: Resource Cleanup
Always use cleanup handlers:
```javascript
const cleanup = () => {
lib.symbols.release(resource)
lib.symbols.close(chip)
}
process.on("SIGINT", cleanup)
process.on("SIGTERM", cleanup)
```
### Pattern 3: Destructuring Symbols
```javascript
const {
symbols: { functionName }
} = dlopen(path, { /* defs */ })
// Call directly:
functionName(arg1, arg2)
```
## Common Mistakes to Avoid
1. **Don't forget null terminators** - `Buffer.from(str + "\0")` not `Buffer.from(str)`
2. **Pointer lifetime** - Keep TypedArrays alive while C code uses them
3. **Type mismatches** - `FFIType.i32` vs `FFIType.u32` matters!
4. **Missing cleanup** - C libraries don't have garbage collection
## Best Practices from Real Examples
1. **Use `suffix` for cross-platform library loading:**
```javascript
import { suffix } from "bun:ffi"
dlopen(`libname.${suffix}`, { /* ... */ })
```
2. **Check for null on resource creation:**
```javascript
const chip = lib.gpiod_chip_open(cstr(path))
if (!chip) {
console.error("Failed to open")
process.exit(1)
}
```
3. **Free configs after use:**
```javascript
const config = lib.create_config()
// ... use config ...
lib.free_config(config) // Don't leak!
```
## What Makes Bun FFI Special
- **Performance**: JIT compilation beats traditional FFI
- **Simplicity**: No build tools, no gyp, no node-gyp nightmares
- **TypeScript native**: Works seamlessly with TS type system
- **Built-in**: Ships with Bun, zero dependencies
## Hard-Won Lessons from GPIO Implementation
### 1. **Enum values MUST match the C header exactly**
We spent hours debugging because our constants were off by one:
```typescript
// WRONG - missing GPIOD_LINE_BIAS_AS_IS
export const GPIOD_LINE_BIAS_UNKNOWN = 1 // Actually should be 2!
export const GPIOD_LINE_BIAS_DISABLED = 2 // Actually should be 3!
export const GPIOD_LINE_BIAS_PULL_UP = 3 // Actually should be 4!
// CORRECT - includes AS_IS at position 1
export const GPIOD_LINE_BIAS_AS_IS = 1
export const GPIOD_LINE_BIAS_UNKNOWN = 2
export const GPIOD_LINE_BIAS_DISABLED = 3
export const GPIOD_LINE_BIAS_PULL_UP = 4
export const GPIOD_LINE_BIAS_PULL_DOWN = 5
```
**Lesson:** Always grep the header file for the complete enum, don't assume!
### 2. **Hardware debouncing requires correct constants**
With wrong constants, we were accidentally passing `BIAS_DISABLED` instead of `BIAS_PULL_UP`, which meant:
- No pull resistor (pin floated)
- Debouncing didn't work at all
- Got 6+ events per button press
After fixing: **Clean single events with 1ms debounce via kernel!**
### 3. **Edge detection is event-driven, not polling**
Don't poll `get_value()` in a loop! Use:
- `gpiod_line_request_wait_edge_events()` - blocks until interrupt
- `gpiod_line_request_read_edge_events()` - reads queued events
- Much more efficient, CPU sleeps until hardware event
### 4. **TypedArray to pointer needs `ptr()`**
When passing arrays to C functions:
```typescript
const offsets = new Uint32Array([21])
gpiod.gpiod_line_config_add_line_settings(
lineConfig,
ptr(offsets), // Need ptr() wrapper!
1,
lineSettings
)
```
### 5. **Signal handling for clean shutdown**
Generators don't run `finally` blocks if abandoned. Need:
```typescript
let shouldExit = false
process.on("SIGINT", () => { shouldExit = true })
while (!shouldExit) {
const ret = wait_edge_events(request, 100_000_000) // Use timeout!
// ...
}
```
### 6. **Button wiring determines logic**
- **GND button + pull-UP**: Press = FALLING edge (HIGH→LOW)
- **VCC button + pull-DOWN**: Press = RISING edge (LOW→HIGH)
Always check initial pin state to verify wiring!
## Resources Used
- Official Bun FFI docs: https://bun.com/docs/runtime/ffi
- libgpiod v2 C API: https://libgpiod.readthedocs.io/en/latest/core_api.html
- Python bindings examples: https://github.com/brgl/libgpiod/tree/master/bindings/python/examples
- Real examples: GitHub searches for bun FFI projects
- Community discussions: Bun issue tracker and HN threads

View File

@ -82,6 +82,8 @@ namespace GPIO {
export type InputOptions = Type.InputOptions export type InputOptions = Type.InputOptions
export type OutputOptions = Type.OutputOptions export type OutputOptions = Type.OutputOptions
export type InputEvent = Type.InputEvent export type InputEvent = Type.InputEvent
export type Input = import("./input").Input
export type Output = import("./output").Output
} }
export default GPIO export default GPIO

View File

@ -115,12 +115,6 @@ async function stopAP() {
async function checkAndManageAP() { async function checkAndManageAP() {
const connected = await isConnectedToWiFi() const connected = await isConnectedToWiFi()
console.log(
`[checkAndManageAP] WiFi: ${connected ? "connected" : "disconnected"}, AP: ${
apRunning ? "running" : "stopped"
}`
)
if (connected && apRunning) { if (connected && apRunning) {
console.log("[checkAndManageAP] WiFi connected and AP running → stopping AP") console.log("[checkAndManageAP] WiFi connected and AP running → stopping AP")
await stopAP() await stopAP()
@ -134,7 +128,7 @@ async function checkAndManageAP() {
const savedNetwork = await findAvailableSavedNetwork() const savedNetwork = await findAvailableSavedNetwork()
if (savedNetwork) { if (savedNetwork) {
console.log( console.log(
`[checkAndManageAP] Found available saved network: ${savedNetwork}, attempting connection...` `[checkAndManageAP] Found available saved network: ${savedNetwork}, attempting connection...`,
) )
// Try to connect first // Try to connect first
@ -230,6 +224,12 @@ async function tryConnect(connectionName: string): Promise<boolean> {
} }
// Initial check // Initial check
const connected = await isConnectedToWiFi()
console.log(
`[checkAndManageAP] WiFi: ${connected ? "connected" : "disconnected"}, AP: ${
apRunning ? "running" : "stopped"
}`,
)
await checkAndManageAP() await checkAndManageAP()
// Check periodically // Check periodically

View File

@ -16,11 +16,6 @@ export const IndexPage = () => (
</label> </label>
<button type="submit">Connect to Network</button> <button type="submit">Connect to Network</button>
</form> </form>
<footer>
<small>
<a href="/logs">📋 View Service Logs</a>
</small>
</footer>
<script dangerouslySetInnerHTML={{__html: ` <script dangerouslySetInnerHTML={{__html: `
(async () => { (async () => {

View File

@ -16,7 +16,18 @@ export const Layout = ({ title, children, refresh }: LayoutProps) => (
<link rel="stylesheet" href="/pico.css" /> <link rel="stylesheet" href="/pico.css" />
</head> </head>
<body> <body>
<main class="container">{children}</main> <main class="container">
<nav>
<ul>
<li><strong> Phone</strong></li>
</ul>
<ul>
<li><a href="/">WiFi Setup</a></li>
<li><a href="/logs">Logs</a></li>
</ul>
</nav>
{children}
</main>
</body> </body>
</html> </html>
); );

View File

@ -1,62 +1,40 @@
import { Layout } from "./Layout"; import { Layout } from "./Layout";
type LogsPageProps = { type LogsPageProps = {
service: string;
logs: string; logs: string;
}; };
export const LogsPage = ({ logs }: LogsPageProps) => ( export const LogsPage = ({ service, logs }: LogsPageProps) => (
<Layout title="Service Logs"> <Layout title="Service Logs">
<h1>📋 Service Logs</h1> <h1>📋 Service Logs</h1>
<p>
<small> <div role="group">
<label> <a
<input type="checkbox" id="auto-refresh" checked /> Auto-refresh href="/logs?service=phone-ap"
</label> role="button"
{" | "} aria-current={service === "phone-ap" ? "true" : undefined}
<a href="/"> Back</a> >
</small> 📡 WiFi AP
</p> </a>
<pre> <a
<code id="logs-content">{logs.trim()}</code> href="/logs?service=phone-web"
role="button"
aria-current={service === "phone-web" ? "true" : undefined}
>
🌐 Web Server
</a>
<a
href="/logs?service=phone"
role="button"
aria-current={service === "phone" ? "true" : undefined}
>
Phone App
</a>
</div>
<pre style="margin-top: 1rem;">
<code>{logs.trim()}</code>
</pre> </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> </Layout>
); );

View File

@ -1,5 +1,3 @@
#!/usr/bin/env bun
import { Hono } from "hono" import { Hono } from "hono"
import { join } from "node:path" import { join } from "node:path"
import { $ } from "bun" import { $ } from "bun"
@ -36,30 +34,24 @@ app.get("/api/networks", async (c) => {
} }
}) })
// 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 // Main WiFi configuration page
app.get("/", (c) => { app.get("/", (c) => {
return c.html(<IndexPage />) return c.html(<IndexPage />)
}) })
// Service logs with auto-refresh // Service logs
app.get("/logs", async (c) => { app.get("/logs", async (c) => {
const service = c.req.query("service") || "phone-ap"
const validServices = ["phone-ap", "phone-web", "phone"]
// Default to phone-ap if invalid service
const selectedService = validServices.includes(service) ? service : "phone-ap"
try { try {
const logs = const logs = await $`journalctl -u ${selectedService}.service -n 200 --no-pager --no-hostname`.text()
await $`journalctl -u phone-ap.service -u phone-web.service -n 200 --no-pager`.text() return c.html(<LogsPage service={selectedService} logs={logs} />)
return c.html(<LogsPage logs={logs} />)
} catch (error) { } catch (error) {
throw new Error(`Failed to fetch logs: ${error}`) return c.html(<LogsPage service={selectedService} logs={`Error loading logs: ${error}`} />)
} }
}) })
@ -93,7 +85,8 @@ app.post("/save", async (c) => {
return response return response
}) })
export default { port: 80, fetch: app.fetch } const port = process.env.PORT ? Number(process.env.PORT) : 80
export default { port, fetch: app.fetch }
console.log("Server running on http://0.0.0.0:80") console.log(`Server running on http://0.0.0.0:${port}`)
console.log("Access via WiFi or AP at http://phone.local") console.log("Access via WiFi or AP at http://phone.local")

View File

@ -1,15 +1,15 @@
import { log } from "./utils/log.ts" import log from "./utils/log.ts"
import { Signal } from "./utils/signal.ts" import { Emitter } from "./utils/emitter.ts"
import { processStdout, processStderr } from "./utils/stdio.ts" import { processStdout, processStderr } from "./utils/stdio.ts"
export class Baresip { export class Baresip {
baresipArgs: string[] baresipArgs: string[]
process?: Bun.PipedSubprocess process?: Bun.PipedSubprocess
callEstablished = new Signal<{ contact: string }>() callEstablished = new Emitter<{ contact: string }>()
callReceived = new Signal<{ contact: string }>() callReceived = new Emitter<{ contact: string }>()
hungUp = new Signal() hungUp = new Emitter()
error = new Signal<{ message: string }>() error = new Emitter<{ message: string }>()
registrationSuccess = new Signal() registrationSuccess = new Emitter()
constructor(baresipArgs: string[]) { constructor(baresipArgs: string[]) {
this.baresipArgs = baresipArgs this.baresipArgs = baresipArgs
@ -48,10 +48,10 @@ export class Baresip {
} }
disconnectAll() { disconnectAll() {
this.callEstablished.disconnect() this.callEstablished.removeAllListeners()
this.callReceived.disconnect() this.callReceived.removeAllListeners()
this.hungUp.disconnect() this.hungUp.removeAllListeners()
this.registrationSuccess.disconnect() this.registrationSuccess.removeAllListeners()
} }
kill() { kill() {

View File

@ -21,7 +21,7 @@ console.log("")
// Test 2: Create player // Test 2: Create player
console.log("🔊 Creating default player...") console.log("🔊 Creating default player...")
try { try {
const player = await Buzz.defaultPlayer() const player = await Buzz.player()
console.log("✅ Player created\n") console.log("✅ Player created\n")
// Test 3: Play sound file // Test 3: Play sound file
@ -42,7 +42,7 @@ try {
// Test 5: Create recorder // Test 5: Create recorder
console.log("🎤 Creating default recorder...") console.log("🎤 Creating default recorder...")
try { try {
const recorder = await Buzz.defaultRecorder() const recorder = await Buzz.recorder()
console.log("✅ Recorder created\n") console.log("✅ Recorder created\n")
// Test 6: Stream recording with RMS // Test 6: Stream recording with RMS

View File

@ -1,5 +1,4 @@
import Buzz from "./buzz/index.ts" import Buzz from "./buzz/index.ts"
import type { Playback } from "./buzz/utils.ts"
import { Agent } from "./agent/index.ts" import { Agent } from "./agent/index.ts"
import { searchWeb } from "./agent/tools.ts" import { searchWeb } from "./agent/tools.ts"
import { getSound, WaitingSounds } from "./utils/waiting-sounds.ts" import { getSound, WaitingSounds } from "./utils/waiting-sounds.ts"
@ -8,8 +7,8 @@ const runPhoneSystem = async (agentId: string, apiKey: string) => {
console.log("📞 Phone System Starting\n") console.log("📞 Phone System Starting\n")
await Buzz.setVolume(0.4) await Buzz.setVolume(0.4)
const recorder = await Buzz.defaultRecorder() const recorder = await Buzz.recorder()
const player = await Buzz.defaultPlayer() const player = await Buzz.player()
const agent = new Agent({ const agent = new Agent({
agentId, agentId,
@ -19,13 +18,13 @@ const runPhoneSystem = async (agentId: string, apiKey: string) => {
}, },
}) })
let currentDialtone: Playback | undefined let currentDialtone: Buzz.Playback | undefined
let currentBackgroundNoise: Playback | undefined let currentBackgroundNoise: Buzz.Playback | undefined
let streamPlayback = player.playStream() let streamPlayback = player.playStream()
const waitingIndicator = new WaitingSounds(player) const waitingIndicator = new WaitingSounds(player)
// Set up agent event listeners // Set up agent event listeners
agent.events.connect(async (event) => { agent.events.on(async (event) => {
switch (event.type) { switch (event.type) {
case "connected": case "connected":
console.log("✅ Connected to AI agent\n") console.log("✅ Connected to AI agent\n")
@ -163,7 +162,7 @@ if (!apiKey) {
if (!agentId) { if (!agentId) {
console.error( console.error(
"❌ Error: ELEVEN_AGENT_ID environELEVEN_AGENT_ID=agent_5601k4taw2cvfjzrz6snxpgeh7x8 ELEVEN_API_KEY=sk_0313740f112c5992cb62ed96c974ab19b5916f1ea172471fment variable is required" "❌ 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") console.error(" Create an agent at https://elevenlabs.io/app/conversational-ai")
process.exit(1) process.exit(1)

View File

@ -1,4 +1,4 @@
import { GPIO } from "./pins" import GPIO from "./pins"
console.log(`kill -9 ${process.pid}`) console.log(`kill -9 ${process.pid}`)

42
src/utils/emitter.ts Normal file
View File

@ -0,0 +1,42 @@
/**
* How to use Emitter:
*
* Create an emitter:
* const chat = new Emitter<{ username: string, message: string }>()
*
* Listen to events:
* const off = chat.on((data) => {
* const {username, message} = data;
* console.log(`${username} said "${message}"`);
* })
*
* Emit an event:
* chat.emit({ username: "Chad", message: "Hey everyone, how's it going?" });
*
* Remove a specific listener:
* off(); // The off function is returned when you add a listener
*
* Remove all listeners:
* chat.removeAllListeners()
*/
export class Emitter<T = void> {
private listeners: Array<(data: T) => void> = []
on(listener: (data: T) => void) {
this.listeners.push(listener)
return () => {
this.listeners = this.listeners.filter((l) => l !== listener)
}
}
emit(data: T) {
for (const listener of this.listeners) {
listener(data)
}
}
removeAllListeners() {
this.listeners = []
}
}

View File

@ -1,3 +1,5 @@
import type GPIO from "../pins"
export const ensure = <T>(value: T, message: string): T => { export const ensure = <T>(value: T, message: string): T => {
if (value === undefined || value === null) { if (value === undefined || value === null) {
throw new Error(message) throw new Error(message)
@ -9,3 +11,17 @@ export const ensure = <T>(value: T, message: string): T => {
export const random = <T>(arr: ReadonlyArray<T>): T => { export const random = <T>(arr: ReadonlyArray<T>): T => {
return arr[Math.floor(Math.random() * arr.length)]! return arr[Math.floor(Math.random() * arr.length)]!
} }
export const ring = async (ringer: GPIO.Output, duration: number, signal?: AbortSignal) => {
try {
const endAt = performance.now() + duration
while (performance.now() < endAt && !signal?.aborted) {
ringer.value = 1
await Bun.sleep(50)
ringer.value = 0
await Bun.sleep(50)
}
} finally {
ringer.value = 0
}
}

View File

@ -1,21 +1,21 @@
let showDebug = true let showDebug = process.env.DEBUG ?? false
let showInfo = true let showInfo = true
let showError = true
export function setLogLevel(level: "debug" | "info" | "error" | "none") { export function setLogLevel(level: "debug" | "info" | "error") {
showDebug = level === "debug" showDebug = level === "debug"
showInfo = level === "debug" || level === "info" showInfo = level === "debug" || level === "info"
showError = level !== "none"
} }
export const log = { const log = (...args: any[]) => {
debug: (...args: any[]) => { if (showInfo) console.log("👁️‍🗨️ INFO: ", ...args)
if (showDebug) console.debug("DEBUG: ", ...args)
},
info: (...args: any[]) => {
if (showInfo) console.log("INFO: ", ...args)
},
error: (...args: any[]) => {
if (showError) console.error("ERROR: ", ...args)
},
} }
log.debug = (...args: any[]) => {
if (showDebug) console.debug("🪲 DEBUG: ", ...args)
}
log.error = (...args: any[]) => {
console.error("💥 ERROR: ", ...args)
}
export default log

View File

@ -1,57 +0,0 @@
/**
* 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

@ -1,4 +1,4 @@
import { log } from "./log.ts" import log from "./log.ts"
export const LineSplitter = () => { export const LineSplitter = () => {
let buffer = "" let buffer = ""

View File

@ -1,21 +1,27 @@
import { type Player } from "../buzz/index.ts" import Buzz from "../buzz/index.ts"
import { join } from "path" import { join } from "path"
import type { Playback, StreamingPlayback } from "../buzz/utils.ts"
import { random } from "./index.ts" import { random } from "./index.ts"
import { log } from "console"
export class WaitingSounds { export class WaitingSounds {
typingPlayback?: Playback typingPlayback?: Buzz.Playback
speakingPlayback?: Playback speakingPlayback?: Buzz.Playback
playing = false
constructor(private player: Player) {} constructor(private player: Buzz.Player) {}
async start(operatorStream: StreamingPlayback) { async start(operatorStream: Buzz.StreamingPlayback) {
if (this.typingPlayback) return // Already playing if (this.playing) return // Already playing
this.playing = true
this.#startTypingSounds() this.#startTypingSounds()
this.#startSpeakingSounds(operatorStream) this.#startSpeakingSounds(operatorStream)
} }
get isPlaying() {
return this.playing
}
async #startTypingSounds() { async #startTypingSounds() {
return new Promise<void>(async (resolve) => { return new Promise<void>(async (resolve) => {
do { do {
@ -29,50 +35,53 @@ export class WaitingSounds {
const typingSound = getSound(dir) const typingSound = getSound(dir)
this.typingPlayback = await this.player.play(typingSound) this.typingPlayback = await this.player.play(typingSound)
await this.typingPlayback.finished() await this.typingPlayback.finished()
} while (this.typingPlayback) } while (this.isPlaying)
resolve() resolve()
}) })
} }
async #startSpeakingSounds(operatorStream: StreamingPlayback) { async #startSpeakingSounds(operatorStream: Buzz.StreamingPlayback) {
const playedSounds = new Set<string>() const playedSounds = new Set<string>()
let dir: SoundDir | undefined let dir: SoundDir | undefined
return new Promise<void>(async (resolve) => { return new Promise<void>(async (resolve) => {
// Don't start playing speaking sounds until the operator stream has been silent for a bit
while (operatorStream.bufferEmptyFor < 1500) { while (operatorStream.bufferEmptyFor < 1500) {
await Bun.sleep(100) await Bun.sleep(100)
} }
do { do {
const lastSoundDir = dir const lastSoundDir = dir
const value = Math.random() * 100
if (lastSoundDir === "body-noises") { if (lastSoundDir === "body-noises") {
dir = "apology" dir = "apology"
} else if (value > 99 && !lastSoundDir) {
dir = "body-noises"
} else if (value > 75 && !lastSoundDir) {
dir = "stalling"
} else { } else {
dir = undefined // sleep for 4-6 seconds
await Bun.sleep(1000) await Bun.sleep(4000 + Math.random() * 2000)
const value = Math.random() * 100
if (value > 95) {
dir = "body-noises"
} else {
dir = "stalling"
}
} }
if (dir) {
const speakingSound = getSound(dir, Array.from(playedSounds)) const speakingSound = getSound(dir, Array.from(playedSounds))
this.speakingPlayback = await this.player.play(speakingSound) this.speakingPlayback = await this.player.play(speakingSound)
playedSounds.add(speakingSound) playedSounds.add(speakingSound)
await this.speakingPlayback.finished() await this.speakingPlayback.finished()
} } while (this.isPlaying)
} while (this.typingPlayback)
resolve() resolve()
}) })
} }
async stop() { async stop() {
if (!this.typingPlayback) return log(`🛑 Stopping waiting sounds. Playing? ${this.playing}`)
if (!this.playing) return
this.playing = false
await Promise.all([this.typingPlayback.stop(), this.speakingPlayback?.finished()]) await Promise.all([this.typingPlayback?.stop(), this.speakingPlayback?.finished()])
this.typingPlayback = undefined log("🛑 Waiting sounds stopped")
} }
} }