Compare commits

..

No commits in common. "main" and "pins" have entirely different histories.
main ... pins

69 changed files with 1358 additions and 2316 deletions

2
.gitattributes vendored Normal file
View File

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

2
.gitignore vendored
View File

@ -32,5 +32,3 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# Finder (MacOS) folder config
.DS_Store
.claude

View File

@ -13,19 +13,27 @@ A Bun-based deployment script that automates copying files to a Raspberry Pi and
### What It Does
1. **Copies files** from here to the pi (in ~/phone by default)
2. **Bootstrap (optional)**: If `--bootstrap` flag is passed it will bootstrap the pi with everything it needs
3. **Service management**:
1. **Creates directory** on the Pi at the configured path
2. **Copies files** from local `pi/` directory to the Pi
3. **Sets permissions** to make all TypeScript files executable
4. **Bootstrap (optional)**: If `--bootstrap` flag is passed, runs `bootstrap.ts` on the Pi with sudo
5. **Service management**:
- Checks if `phone-ap.service` and `phone-web.service` exist
- If they exist, restarts both services
- If they don't exist and bootstrap wasn't run, warns the user
### Usage
**Standard deployment** (just copy files and restart services):
```bash
bun scripts/deploy.ts
# or bun deploy.ts --bootstrap
>>>>>>> Stashed changes
bun deploy.ts
```
**First-time deployment** (copy files + run bootstrap):
```bash
bun deploy.ts --bootstrap
```
### Services
@ -42,14 +50,8 @@ After deployment, the Pi is accessible at:
- **Web URL**: http://phone.local
- **WiFi Network**: phone-setup
### Local Requirements
### Requirements
- Bun runtime
<<<<<<< HEAD
<<<<<<< Updated upstream
- # SSH access to `yellow-phone.local`
- SSH access to `phone.local`
> > > > > > > 1c717a5b4772147c3b7ce20f512452f13c8cc510
- # Local `pi/` directory with files to deploy
- SSH access to `phone.local`
> > > > > > > Stashed changes
- Local `pi/` directory with files to deploy

View File

@ -1 +0,0 @@
<sip:yellow@probablycorey.sip.twilio.com>;auth_pass=zgm-kwx2bug5hwf3YGF;unregister_on_exit=yes

View File

@ -1,26 +0,0 @@
call_max_calls 4
call_local_timeout 120
# Audio
audio_player alsa,default
audio_source alsa,default
audio_alert alsa,default
ring_aufile /dev/null
# Modules
#------------------------------------------------------------------------------
module_path /usr/lib/baresip/modules
# Audio codec Modules
module g711.so
# Audio driver Modules
module alsa.so
# Application Modules
module_app account.so
module_app menu.so
module httpd.so

View File

@ -6,11 +6,9 @@
"dependencies": {
"hono": "^4.10.4",
"openai": "^6.9.0",
"robot3": "1.1.1",
},
"devDependencies": {
"@types/bun": "latest",
"prettier": "^3.6.2",
},
"peerDependencies": {
"typescript": "^5",
@ -22,19 +20,15 @@
"@types/node": ["@types/node@24.10.1", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ=="],
"@types/react": ["@types/react@19.2.6", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-p/jUvulfgU7oKtj6Xpk8cA2Y1xKTtICGpJYeJXz2YVO2UcvjQgeRMLDGfDeqeRW2Ta+0QNFwcc8X3GH8SxZz6w=="],
"@types/react": ["@types/react@19.2.5", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-keKxkZMqnDicuvFoJbzrhbtdLSPhj/rZThDlKWCDbgXmUg0rEUFtRssDXKYmtXluZlIqiC5VqkCgRwzuyLHKHw=="],
"bun-types": ["bun-types@1.3.2", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-i/Gln4tbzKNuxP70OWhJRZz1MRfvqExowP7U6JKoI8cntFrtxg7RJK3jvz7wQW54UuvNC8tbKHHri5fy74FVqg=="],
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"csstype": ["csstype@3.2.2", "", {}, "sha512-D80T+tiqkd/8B0xNlbstWDG4x6aqVfO52+OlSUNIdkTvmNw0uQpJLeos2J/2XvpyidAFuTPmpad+tUxLndwj6g=="],
"hono": ["hono@4.10.6", "", {}, "sha512-BIdolzGpDO9MQ4nu3AUuDwHZZ+KViNm+EZ75Ae55eMXMqLVhDFqEMXxtUe9Qh8hjL+pIna/frs2j6Y2yD5Ua/g=="],
"openai": ["openai@6.9.1", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-vQ5Rlt0ZgB3/BNmTa7bIijYFhz3YBceAA3Z4JuoMSBftBF9YqFHIEhZakSs+O/Ad7EaoEimZvHxD5ylRjN11Lg=="],
"prettier": ["prettier@3.6.2", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ=="],
"robot3": ["robot3@1.1.1", "", {}, "sha512-kuD0oQg2KUE74FCQ1a5uoRsEJ/bUKrU1D3vnluop9X7LSiGLndejQgjUEcMqJMVzUA836HSXhtY7XNtQiPTCLQ=="],
"openai": ["openai@6.9.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-n2sJRYmM+xfJ0l3OfH8eNnIyv3nQY7L08gZQu3dw6wSdfPtKAk92L83M2NIP5SS8Cl/bsBBG3yKzEOjkx0O+7A=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],

View File

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

View File

@ -1,108 +0,0 @@
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,4 +1,5 @@
import { $ } from "bun"
import { writeFileSync } from "fs"
console.log(`
==========================================
@ -14,22 +15,96 @@ if (process.getuid && process.getuid() !== 0) {
}
// Get install directory from argument or use default
const defaultUser = process.env.USER || "corey"
const INSTALL_DIR = process.argv[2] || `/home/${defaultUser}/phone`
const INSTALL_DIR = process.argv[2] || "/home/corey/phone"
const AP_SERVICE_FILE = "/etc/systemd/system/phone-ap.service"
const WEB_SERVICE_FILE = "/etc/systemd/system/phone-web.service"
console.log(`Install directory: ${INSTALL_DIR}`)
console.log("\nEnsuring directory exists...")
console.log("\nStep 1: Ensuring directory exists...")
await $`mkdir -p ${INSTALL_DIR}`
console.log(`✓ Directory ready: ${INSTALL_DIR}`)
console.log("\nInstalling dependencies...")
console.log("\nStep 2: Installing dependencies...")
await $`cd ${INSTALL_DIR} && bun install`
console.log(`✓ Dependencies installed`)
console.log("\nInstalling Baresip...")
await $`sudo apt install -y baresip`
console.log("\nStep 3: Installing systemd services...")
// Find where bun is installed
const bunPath = await $`which bun`
.quiet()
.nothrow()
.text()
.then((p) => p.trim())
if (!bunPath) {
console.error("Error: bun not found in PATH. Please ensure bun is available system-wide.")
process.exit(1)
}
console.log(`Using bun at: ${bunPath}`)
// Create AP monitor service
const apServiceContent = `[Unit]
Description=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(`
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
`)

178
scripts/cli.sh Normal file
View File

@ -0,0 +1,178 @@
#!/usr/bin/env bun
import {$} from "bun";
const SERVICES = {
ap: "phone-ap",
web: "phone-web",
};
const commands = {
status: "Show status of all services",
logs: "Show recent logs from all services (last 50 lines)",
tail: "Tail logs from all services in real-time",
restart: "Restart all services",
stop: "Stop all services",
start: "Start all services",
"ap-status": "Show status of AP service",
"ap-logs": "Show recent logs from AP service (last 50 lines)",
"ap-tail": "Tail logs from AP service in real-time",
"ap-restart": "Restart AP service",
"ap-stop": "Stop AP service",
"ap-start": "Start AP service",
"web-status": "Show status of web service",
"web-logs": "Show recent logs from web service (last 50 lines)",
"web-tail": "Tail logs from web service in real-time",
"web-restart": "Restart web service",
"web-stop": "Stop web service",
"web-start": "Start web service",
help: "Show this help message",
};
const command = process.argv[2];
if (!command || command === "help") {
console.log(`
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("");

View File

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

View File

@ -1,15 +0,0 @@
#!/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.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
sounds/stalling/sigh2.wav Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
sounds/typing/typing1.wav Normal file

Binary file not shown.

View File

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

View File

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

View File

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

View File

@ -145,7 +145,11 @@ export class Player {
"-",
]
const proc = Bun.spawn(["aplay", ...args], { stdin: "pipe", stdout: "pipe", stderr: "pipe" })
const proc = Bun.spawn(["aplay", ...args], {
stdin: "pipe",
stdout: "pipe",
stderr: "pipe",
})
let bufferFinishTime = performance.now()
const format = this.#format

View File

@ -1,66 +1,66 @@
// Audio format configuration
export type AudioFormat = {
format?: string
sampleRate?: number
channels?: number
}
format?: string;
sampleRate?: number;
channels?: number;
};
// Default audio format for recordings and tone generation
export const DEFAULT_AUDIO_FORMAT = {
format: "S16_LE",
format: 'S16_LE',
sampleRate: 16000,
channels: 1,
} as const
} as const;
// Device from ALSA listing
export type Device = {
id: string // "default" or "plughw:1,0"
card: number // ALSA card number
device: number // ALSA device number
label: string // Human-readable name
type: "playback" | "capture"
}
id: string; // "default" or "plughw:1,0"
card: number; // ALSA card number
device: number; // ALSA device number
label: string; // Human-readable name
type: 'playback' | 'capture';
};
// Playback control handle
export type Playback = {
isPlaying: boolean
stop: () => Promise<void>
finished: () => Promise<void>
}
isPlaying: boolean;
stop: () => Promise<void>;
finished: () => Promise<void>;
};
// Streaming playback handle
export type StreamingPlayback = {
isPlaying: boolean
write: (chunk: Uint8Array) => void
stop: () => Promise<void>
bufferEmptyFor: number // milliseconds since buffer became empty, 0 if not empty
}
isPlaying: boolean;
write: (chunk: Uint8Array) => void;
stop: () => Promise<void>;
bufferEmptyFor: number; // milliseconds since buffer became empty, 0 if not empty
};
// Streaming recording control handle
export type StreamingRecording = {
isRecording: boolean
stream: () => ReadableStream<Uint8Array>
stop: () => Promise<void>
}
isRecording: boolean;
stream: () => ReadableStream<Uint8Array>;
stop: () => Promise<void>;
};
// File recording control handle
export type FileRecording = {
isRecording: boolean
stop: () => Promise<void>
}
isRecording: boolean;
stop: () => Promise<void>;
};
const parseDeviceLine = (line: string, type: "playback" | "capture"): Device | undefined => {
if (!line.startsWith("card ")) return undefined
const parseDeviceLine = (line: string, type: 'playback' | 'capture'): Device | undefined => {
if (!line.startsWith('card ')) return undefined;
const match = line.match(/^card (\d+):\s+\w+\s+\[(.+?)\],\s+device (\d+):/)
if (!match) return undefined
const match = line.match(/^card (\d+):\s+\w+\s+\[(.+?)\],\s+device (\d+):/);
if (!match) return undefined;
const [, cardStr, label, deviceStr] = match
const [, cardStr, label, deviceStr] = match;
if (!cardStr || !label || !deviceStr) return undefined
if (!cardStr || !label || !deviceStr) return undefined;
const card = parseInt(cardStr)
const device = parseInt(deviceStr)
const card = parseInt(cardStr);
const device = parseInt(deviceStr);
return {
id: `plughw:${card},${device}`,
@ -68,77 +68,79 @@ const parseDeviceLine = (line: string, type: "playback" | "capture"): Device | u
device,
label,
type,
}
}
};
};
const parseAlsaDevices = (output: string, type: "playback" | "capture"): Device[] => {
const parseAlsaDevices = (output: string, type: 'playback' | 'capture'): Device[] => {
return output
.split("\n")
.map((line) => parseDeviceLine(line, type))
.filter((device) => device !== undefined)
}
.split('\n')
.map(line => parseDeviceLine(line, type))
.filter(device => device !== undefined);
};
export const listDevices = async (): Promise<Device[]> => {
const playbackOutput = await Bun.$`aplay -l`.text()
const captureOutput = await Bun.$`arecord -l`.text()
const playbackOutput = await Bun.$`aplay -l`.text();
const captureOutput = await Bun.$`arecord -l`.text();
const playback = parseAlsaDevices(playbackOutput, "playback")
const capture = parseAlsaDevices(captureOutput, "capture")
const playback = parseAlsaDevices(playbackOutput, 'playback');
const capture = parseAlsaDevices(captureOutput, 'capture');
return [...playback, ...capture]
}
return [...playback, ...capture];
};
export const findDeviceByLabel = async (
label: string,
type?: "playback" | "capture"
type?: 'playback' | 'capture'
): Promise<Device> => {
const devices = await listDevices()
const device = devices.find((d) => d.label === label && (!type || d.type === type))
const devices = await listDevices();
const device = devices.find(d =>
d.label === label && (!type || d.type === type)
);
if (!device) {
const typeStr = type ? ` (type: ${type})` : ""
throw new Error(`Device not found: ${label}${typeStr}`)
const typeStr = type ? ` (type: ${type})` : '';
throw new Error(`Device not found: ${label}${typeStr}`);
}
return device
}
return device;
};
export const calculateRMS = (chunk: Uint8Array): number => {
const samples = new Int16Array(chunk.buffer, chunk.byteOffset, chunk.byteLength / 2)
let sum = 0
const samples = new Int16Array(chunk.buffer, chunk.byteOffset, chunk.byteLength / 2);
let sum = 0;
for (const sample of samples) {
sum += sample * sample
sum += sample * sample;
}
return Math.sqrt(sum / samples.length)
}
return Math.sqrt(sum / samples.length);
};
export const generateToneSamples = (
frequencies: number[],
sampleRate: number,
durationSeconds: number
): Uint8Array => {
const numSamples = Math.floor(sampleRate * durationSeconds)
const buffer = new ArrayBuffer(numSamples * 2) // 2 bytes per S16_LE sample
const samples = new Int16Array(buffer)
const numSamples = Math.floor(sampleRate * durationSeconds);
const buffer = new ArrayBuffer(numSamples * 2); // 2 bytes per S16_LE sample
const samples = new Int16Array(buffer);
for (let i = 0; i < numSamples; i++) {
const t = i / sampleRate
let value = 0
const t = i / sampleRate;
let value = 0;
// Mix all frequencies together
for (const freq of frequencies) {
value += Math.sin(2 * Math.PI * freq * t)
value += Math.sin(2 * Math.PI * freq * t);
}
// Average and scale to Int16 range
value = (value / frequencies.length) * 32767
samples[i] = Math.round(value)
value = (value / frequencies.length) * 32767;
samples[i] = Math.round(value);
}
return new Uint8Array(buffer)
}
return new Uint8Array(buffer);
};
export const streamTone = async (
stream: { write: (chunk: Uint8Array) => void; end: () => void },
@ -146,24 +148,20 @@ export const streamTone = async (
durationMs: number,
format: Required<AudioFormat>
): Promise<void> => {
const infinite = durationMs === Infinity
const durationSeconds = durationMs / 1000
const infinite = durationMs === Infinity;
const durationSeconds = durationMs / 1000;
// Continuous tone
const samples = generateToneSamples(
frequencies,
format.sampleRate,
infinite ? 1 : durationSeconds
)
const samples = generateToneSamples(frequencies, format.sampleRate, infinite ? 1 : durationSeconds);
if (infinite) {
// Loop 1-second chunks forever
while (true) {
stream.write(samples)
await Bun.sleep(1000)
stream.write(samples);
await Bun.sleep(1000);
}
} else {
stream.write(samples)
stream.end()
}
stream.write(samples);
stream.end();
}
};

47
src/hq.ts Normal file
View File

@ -0,0 +1,47 @@
import { GPIO } from "./pins"
console.log(`kill -9 ${process.pid}`)
const gpio = new GPIO({ resetOnClose: true })
// // Blink an LED
using led = gpio.output(21)
// Read a button
using inputs = gpio.inputGroup({
button: { pin: 20, pull: "up", debounce: 10 },
switch: { pin: 16, pull: "up", debounce: 10 }
})
led.value = inputs.pins.button.value
const iteratorEvents = new Promise(async (resolve) => {
for await (const event of inputs.events()) {
if (event.pin === "button") {
console.log(`🌭`, event.value)
led.value = event.value
}
}
})
const switchEvent = new Promise<void>(async (resolve) => {
await inputs.pins.switch.waitForValue(0)
console.log("Switch pressed!")
resolve()
})
process.on("SIGINT", () => {
inputs.close()
led.close()
process.exit(0)
})
process.on("SIGTERM", () => {
inputs.close()
process.exit(0)
})
await Promise.race([iteratorEvents, switchEvent])
console.log(`👋 Goodbye!`)

View File

@ -1,26 +0,0 @@
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,4 +1,5 @@
import Buzz from "./buzz/index.ts"
import type { Playback } from "./buzz/utils.ts"
import { Agent } from "./agent/index.ts"
import { searchWeb } from "./agent/tools.ts"
import { getSound, WaitingSounds } from "./utils/waiting-sounds.ts"
@ -7,8 +8,8 @@ const runPhoneSystem = async (agentId: string, apiKey: string) => {
console.log("📞 Phone System Starting\n")
await Buzz.setVolume(0.4)
const recorder = await Buzz.recorder()
const player = await Buzz.player()
const recorder = await Buzz.defaultRecorder()
const player = await Buzz.defaultPlayer()
const agent = new Agent({
agentId,
@ -18,13 +19,13 @@ const runPhoneSystem = async (agentId: string, apiKey: string) => {
},
})
let currentDialtone: Buzz.Playback | undefined
let currentBackgroundNoise: Buzz.Playback | undefined
let currentDialtone: Playback | undefined
let currentBackgroundNoise: Playback | undefined
let streamPlayback = player.playStream()
const waitingIndicator = new WaitingSounds(player)
const waitingIndicator = new WaitingSounds(player, streamPlayback)
// Set up agent event listeners
agent.events.on(async (event) => {
agent.events.connect(async (event) => {
switch (event.type) {
case "connected":
console.log("✅ Connected to AI agent\n")
@ -51,7 +52,7 @@ const runPhoneSystem = async (agentId: string, apiKey: string) => {
break
case "tool_call":
waitingIndicator.start(streamPlayback)
waitingIndicator.start()
console.log(`🔧 Tool call: ${event.name}(${JSON.stringify(event.args)})`)
break
@ -72,14 +73,6 @@ const runPhoneSystem = async (agentId: string, apiKey: string) => {
case "error":
console.error("Agent error:", event.error)
break
case "ping":
break
default:
console.log(`😵‍💫 ${event.type}`)
break
}
})
@ -162,7 +155,7 @@ if (!apiKey) {
if (!agentId) {
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")
process.exit(1)

View File

@ -1,458 +0,0 @@
import {
d,
reduce,
createMachine,
state,
transition,
interpret,
action,
invoke,
type Service,
} from "robot3"
import { Baresip } from "./sip"
import log from "./utils/log"
import { sleep } from "bun"
import Buzz from "./buzz"
import { join } from "path"
import GPIO from "./pins"
import { Agent } from "./agent"
import { searchWeb } from "./agent/tools"
import { ring } from "./utils"
import { getSound, WaitingSounds } from "./utils/waiting-sounds"
type CancelableTask = () => void
type PhoneContext = {
lastError?: string
peer?: string
numberDialed: number
cancelRinger?: CancelableTask
baresip: Baresip
stopAgent?: 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()
using ringer = gpio.output(17, { resetOnClose: true })
using hook = gpio.input(27, { pull: "up", debounce: 3 })
using rotaryInUse = gpio.input(22, { pull: "up", debounce: 3 })
using rotaryNumber = gpio.input(23, { pull: "up", debounce: 3 })
await Buzz.setVolume(0.3)
log(`📞 Phone is ${hook.value ? "off hook" : "on hook"}`)
playStartRing(ringer)
const phoneService = interpret(phoneMachine, () => {})
listenForPhoneEvents(phoneService, hook, rotaryInUse, rotaryNumber)
const baresip = await startBaresip(phoneService, hook, ringer)
phoneService.send({ type: "config", baresip, agentId, agentKey, ringer })
process.on("SIGINT", () => cleanup(baresip, ringer))
process.on("SIGTERM", () => cleanup(baresip, ringer))
// Keep process running
await new Promise(() => {})
}
const listenForPhoneEvents = (
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 })
})
rotaryInUse.onChange((event) => {
if (event.value === 0) {
phoneService.send({ type: "dial-start" })
} else {
phoneService.send({ type: "dial-stop" })
}
})
rotaryNumber.onChange((event) => {
if (event.value === 1) {
phoneService.send({ type: "digit_increment" })
}
})
}
const startBaresip = async (phoneService: PhoneService, hook: GPIO.Input, ringer: GPIO.Output) => {
const baresipConfig = join(import.meta.dir, "..", "baresip")
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(),
},
)
d._onEnter = function (machine, to, state, prevState, event) {
log(`📱 ${machine.current} -> ${to} (${(event as any).type})`)
}

173
src/pins/FFI-LEARNINGS.md Normal file
View File

@ -0,0 +1,173 @@
# 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

@ -4,12 +4,11 @@ High-level GPIO library for Bun using libgpiod v2 with automatic resource manage
## Features
- True event-driven GPIO with worker-based architecture (<10ms latency)
- Zero CPU usage when idle (blocking on hardware events)
- Type-safe TypeScript API
- Type-safe TypeScript API with autocomplete for pin names
- Automatic resource cleanup with `using` keyword
- Hardware debouncing via kernel
- Callback-based event handling with multiple listeners
- Event-driven input handling
- Efficient multi-pin monitoring with input groups
- Zero external dependencies (uses Bun FFI)
## Requirements
@ -37,13 +36,10 @@ for (let i = 0; i < 10; i++) {
using button = gpio.input(20, { pull: "up", debounce: 10 })
console.log(button.value)
// Listen for button events with callback
button.onChange((event) => {
// Listen for button events
for await (const event of button.events()) {
console.log(event.value === 0 ? "Pressed!" : "Released")
})
// Keep process running
await new Promise(() => {})
}
```
## API
@ -90,6 +86,27 @@ Options:
- `debounce?: number` - Debounce period in milliseconds (default: 0)
- `edge?: 'rising' | 'falling' | 'both'` - Edge detection (default: 'both')
#### `gpio.inputGroup(config)`
Monitor multiple inputs efficiently with a single file descriptor. Pin names are fully type-safe!
```typescript
using inputs = gpio.inputGroup({
hook: { pin: 20, pull: "up" },
rotary: { pin: 21, pull: "up", debounce: 1 },
button: { pin: 22, pull: "down" },
})
// Access individual pins (fully typed!)
console.log(inputs.pins.hook.value) // TypeScript knows about .hook
console.log(inputs.pins.button.value) // TypeScript knows about .button
// Monitor all pins
for await (const event of inputs.events()) {
console.log(`${event.pin}: ${event.value}`) // event.pin is "hook" | "rotary" | "button"
}
```
#### `gpio.listChips()`
List available GPIO chips.
@ -100,30 +117,22 @@ console.log(chips)
// [{ path: '/dev/gpiochip0', name: 'pinctrl-bcm2835', label: '...', numLines: 58 }]
```
### Input
### InputPin
```typescript
using button = gpio.input(20)
// Read current state (cached from last event)
// Read current state
const value: 0 | 1 = button.value
// Listen for changes (returns unsubscribe function)
const unsubscribe = button.onChange((event) => {
console.log(event.value, event.timestamp)
})
// Add multiple listeners
const unsub2 = button.onChange((event) => {
console.log("Second listener:", event.value)
})
// Remove specific listener
unsubscribe()
// Wait for specific value
await button.waitForValue(0) // wait for LOW
await button.waitForValue(1, 5000) // wait for HIGH with 5s timeout
// Event stream
for await (const event of button.events()) {
console.log(event.value, event.timestamp)
}
```
### OutputPin
@ -137,23 +146,29 @@ const value = led.value
led.toggle()
```
## Architecture
### InputGroup
### Worker-Based Event Handling
```typescript
using inputs = gpio.inputGroup({
switch: { pin: 16, pull: "up" },
button: { pin: 20, pull: "up", debounce: 10 }
})
Each input spawns a dedicated Web Worker that:
// Access pins with full type safety
inputs.pins.switch.value // ✓ TypeScript autocomplete
inputs.pins.button.value // ✓ TypeScript autocomplete
1. Blocks on `gpiod_line_request_wait_edge_events()` with `-1` timeout (infinite)
2. Wakes instantly when hardware GPIO edge event occurs
3. Reads event and posts message to main thread
4. Main thread fires registered callbacks
// Wait for specific pin values
await inputs.pins.button.waitForValue(0) // wait for button to go LOW
await inputs.pins.switch.waitForValue(1, 3000) // wait for switch to go HIGH with timeout
**Benefits:**
- **True blocking**: Zero CPU usage when idle
- **Low latency**: <10ms response time (vs 100ms with polling)
- **Independent inputs**: Each input has its own worker
- **Clean shutdown**: Workers terminated on close, kernel handles GPIO cleanup
// Monitor all pins
for await (const event of inputs.events()) {
event.pin // Type: 'switch' | 'button'
event.value // Type: 0 | 1
event.timestamp // Type: bigint (nanoseconds)
}
```
## Resource Management
@ -161,24 +176,17 @@ Each input spawns a dedicated Web Worker that:
```typescript
// Good - automatic cleanup
using led = gpio.output(17) // Automatically released because of `using`
{
using led = gpio.output(17)
led.value = 1
```
} // Automatically released
```typescript
// Meh - manual cleanup required
// Bad - manual cleanup required
const led = gpio.output(17)
led.value = 1
led.close() // Must call manually
```
```typescript
// Bad - leaks resources
const led = gpio.output(17)
led.value = 1
// Forgot to close() - resource leak!
```
## Hardware Setup
### Pull Resistors
@ -189,7 +197,6 @@ Pull resistors prevent floating input values when nothing is connected to the pi
- **Pull-down + button to VCC**: When released, pin reads LOW (0). When pressed, pin reads HIGH (1).
**Important:** Match your pull resistor to your wiring:
- Button to ground → use `pull: "up"`
- Button to VCC (3.3V) → use `pull: "down"`
@ -245,27 +252,26 @@ import { GPIO } from "@/pins"
const gpio = new GPIO()
using button = gpio.input(20, { pull: "up", debounce: 10 })
using switchInput = gpio.input(16, { pull: "up" })
using inputs = gpio.inputGroup({
button: { pin: 20, pull: "up", debounce: 10 },
switch: { pin: 16, pull: "up" }
})
using led = gpio.output(21)
// Set LED based on switch state
led.value = switchInput.value
if (inputs.pins.switch.value === 1) {
led.value = 1
}
// Toggle LED when button pressed
button.onChange((event) => {
if (event.value === 0) {
for await (const event of inputs.events()) {
if (event.pin === "button" && event.value === 0) {
led.toggle()
}
})
// Mirror switch to LED
switchInput.onChange((event) => {
} else if (event.pin === "switch") {
led.value = event.value
})
// Keep process running
await new Promise(() => {})
}
}
```
### Rotary Phone Dialer
@ -275,31 +281,18 @@ import { GPIO } from "@/pins"
const gpio = new GPIO()
using hook = gpio.input(27, { pull: "up", debounce: 3 })
using rotaryInUse = gpio.input(22, { pull: "up", debounce: 3 })
using rotaryNumber = gpio.input(23, { pull: "up", debounce: 3 })
using inputs = gpio.inputGroup({
hook: { pin: 20, pull: "up" },
rotary: { pin: 21, pull: "up", debounce: 1 },
})
let digit = 0
hook.onChange((event) => {
for await (const event of inputs.events()) {
if (event.pin === "hook") {
console.log(event.value === 0 ? "Phone picked up" : "Phone hung up")
})
rotaryInUse.onChange((event) => {
if (event.value === 0) {
digit = 0
} else {
console.log(`Dialed digit: ${digit}`)
} else if (event.pin === "rotary" && event.value === 0) {
console.log("Rotary pulse")
}
})
rotaryNumber.onChange((event) => {
if (event.value === 1) {
digit += 1
}
})
await new Promise(() => {})
```
## Troubleshooting

View File

@ -1,101 +0,0 @@
import type { Pointer } from "bun:ffi"
import { gpiod, GPIOD_LINE_DIRECTION_INPUT, GPIOD_LINE_DIRECTION_OUTPUT } from "./ffi"
import { cstr, mapPullToLibgpiod, mapEdgeToLibgpiod } from "./utils"
import type { PullMode, EdgeMode } from "./types"
type LineRequestResult = {
chip: Pointer
request: Pointer
}
export type InputLineConfig = {
chipPath: string
offset: number
pull: PullMode
debounce: number
edge: EdgeMode
}
export type OutputLineConfig = {
chipPath: string
offset: number
initialValue: 0 | 1
}
const cleanup = (message: string): never => {
throw new Error(message)
}
const requestLine = (
chipPath: string,
offset: number,
consumer: string,
configureSettings: (settings: Pointer) => void
): LineRequestResult => {
const chip = gpiod.gpiod_chip_open(cstr(chipPath))
if (!chip) cleanup("Failed to open GPIO chip")
const settings = gpiod.gpiod_line_settings_new()
if (!settings) {
gpiod.gpiod_chip_close(chip)
cleanup("Failed to create line settings")
}
configureSettings(settings!)
const lineConfig = gpiod.gpiod_line_config_new()
if (!lineConfig) {
gpiod.gpiod_line_settings_free(settings)
gpiod.gpiod_chip_close(chip)
cleanup("Failed to create line config")
}
const offsets = new Uint32Array([offset])
const ret = gpiod.gpiod_line_config_add_line_settings(lineConfig, offsets, 1, settings)
gpiod.gpiod_line_settings_free(settings)
if (ret !== 0) {
gpiod.gpiod_line_config_free(lineConfig)
gpiod.gpiod_chip_close(chip)
cleanup("Failed to add line settings")
}
const requestConfig = gpiod.gpiod_request_config_new()
if (!requestConfig) {
gpiod.gpiod_line_config_free(lineConfig)
gpiod.gpiod_chip_close(chip)
cleanup("Failed to create request config")
}
gpiod.gpiod_request_config_set_consumer(requestConfig, cstr(consumer))
const request = gpiod.gpiod_chip_request_lines(chip, requestConfig, lineConfig)
gpiod.gpiod_request_config_free(requestConfig)
gpiod.gpiod_line_config_free(lineConfig)
if (!request) {
gpiod.gpiod_chip_close(chip)
cleanup("Failed to request GPIO line")
}
return { chip: chip!, request: request! }
}
export const requestInputLine = (config: InputLineConfig): LineRequestResult => {
return requestLine(config.chipPath, config.offset, "bun-gpio-input", (settings) => {
gpiod.gpiod_line_settings_set_direction(settings, GPIOD_LINE_DIRECTION_INPUT)
gpiod.gpiod_line_settings_set_bias(settings, mapPullToLibgpiod(config.pull))
gpiod.gpiod_line_settings_set_edge_detection(settings, mapEdgeToLibgpiod(config.edge))
if (config.debounce > 0) {
gpiod.gpiod_line_settings_set_debounce_period_us(settings, config.debounce * 1000)
}
})
}
export const requestOutputLine = (config: OutputLineConfig): LineRequestResult => {
return requestLine(config.chipPath, config.offset, "bun-gpio", (settings) => {
gpiod.gpiod_line_settings_set_direction(settings, GPIOD_LINE_DIRECTION_OUTPUT)
gpiod.gpiod_line_settings_set_output_value(settings, config.initialValue)
})
}

199
src/pins/gpio.ts Normal file
View File

@ -0,0 +1,199 @@
import { ptr } from "bun:ffi"
import { readdir } from "node:fs/promises"
import { gpiod, cstr, GPIOD_LINE_DIRECTION_OUTPUT, GPIOD_LINE_DIRECTION_INPUT } from "./ffi"
import { OutputPin } from "./output"
import { InputPin } from "./input"
import { InputGroup } from "./input-group"
import { ChipNotFoundError, PinInUseError } from "./errors"
import { mapPullToLibgpiod, mapEdgeToLibgpiod, hashInputConfig } from "./utils"
import type { OutputOptions, InputOptions, PullMode, ChipInfo } from "./types"
export class GPIO {
#chipPath: string
#resetOnClose: boolean
constructor(options?: { chip?: string; resetOnClose?: boolean }) {
this.#chipPath = options?.chip ?? "/dev/gpiochip0"
this.#resetOnClose = options?.resetOnClose ?? false
}
output(pin: number, options?: OutputOptions): OutputPin {
const initialValue = options?.initialValue ?? 0
const chip = gpiod.gpiod_chip_open(cstr(this.#chipPath))
if (!chip) {
throw new ChipNotFoundError(this.#chipPath)
}
try {
const reqConfig = gpiod.gpiod_request_config_new()
gpiod.gpiod_request_config_set_consumer(reqConfig, cstr("bun-gpio"))
const lineSettings = gpiod.gpiod_line_settings_new()
gpiod.gpiod_line_settings_set_direction(lineSettings, GPIOD_LINE_DIRECTION_OUTPUT)
gpiod.gpiod_line_settings_set_output_value(lineSettings, initialValue)
const lineConfig = gpiod.gpiod_line_config_new()
const offsets = new Uint32Array([pin])
gpiod.gpiod_line_config_add_line_settings(lineConfig, ptr(offsets), 1, lineSettings)
const request = gpiod.gpiod_chip_request_lines(chip, reqConfig, lineConfig)
gpiod.gpiod_line_settings_free(lineSettings)
gpiod.gpiod_line_config_free(lineConfig)
gpiod.gpiod_request_config_free(reqConfig)
if (!request) {
gpiod.gpiod_chip_close(chip)
throw new PinInUseError(pin)
}
let resetValue: 0 | 1 | undefined
if (this.#resetOnClose) {
const currentValue = gpiod.gpiod_line_request_get_value(request, pin)
if (currentValue === -1) {
console.warn(`Failed to read initial value for pin ${pin}, assuming 0`)
resetValue = 0
} else {
resetValue = currentValue as 0 | 1
}
}
return new OutputPin(chip, request, pin, resetValue)
} catch (err) {
gpiod.gpiod_chip_close(chip)
throw err
}
}
input(pin: number, options?: InputOptions): InputPin<"pin"> {
const group = this.inputGroup({
pin: { pin, ...options },
})
return new InputPin(group, "pin")
}
inputGroup<T extends Record<string, { pin: number } & InputOptions>>(
config: T
): InputGroup<keyof T & string> {
const chip = gpiod.gpiod_chip_open(cstr(this.#chipPath))
if (!chip) {
throw new ChipNotFoundError(this.#chipPath)
}
try {
const reqConfig = gpiod.gpiod_request_config_new()
gpiod.gpiod_request_config_set_consumer(reqConfig, cstr("bun-gpio"))
const lineConfig = gpiod.gpiod_line_config_new()
const groups = new Map<
string,
Array<{ name: string; pin: number; pull: PullMode; options: InputOptions }>
>()
for (const [name, pinConfig] of Object.entries(config)) {
const pull = pinConfig.pull ?? "up"
const debounce = pinConfig.debounce ?? 0
const edge = pinConfig.edge ?? "both"
const hash = hashInputConfig(pull, debounce, edge)
if (!groups.has(hash)) groups.set(hash, [])
groups.get(hash)!.push({ name, pin: pinConfig.pin, pull, options: pinConfig })
}
for (const [hash, pins] of groups) {
const firstPin = pins[0]
if (!firstPin) continue
const pull = firstPin.options.pull ?? "up"
const debounce = firstPin.options.debounce ?? 0
const edge = firstPin.options.edge ?? "both"
const lineSettings = gpiod.gpiod_line_settings_new()
gpiod.gpiod_line_settings_set_direction(lineSettings, GPIOD_LINE_DIRECTION_INPUT)
gpiod.gpiod_line_settings_set_bias(lineSettings, mapPullToLibgpiod(pull))
gpiod.gpiod_line_settings_set_edge_detection(lineSettings, mapEdgeToLibgpiod(edge))
gpiod.gpiod_line_settings_set_debounce_period_us(lineSettings, debounce * 1000)
const offsets = new Uint32Array(pins.map((p) => p.pin))
gpiod.gpiod_line_config_add_line_settings(
lineConfig,
ptr(offsets),
pins.length,
lineSettings
)
gpiod.gpiod_line_settings_free(lineSettings)
}
const request = gpiod.gpiod_chip_request_lines(chip, reqConfig, lineConfig)
gpiod.gpiod_line_config_free(lineConfig)
gpiod.gpiod_request_config_free(reqConfig)
if (!request) {
gpiod.gpiod_chip_close(chip)
const firstConfig = Object.values(config)[0]
throw new PinInUseError(firstConfig?.pin ?? 0)
}
const pinMap: Record<string, { offset: number; pull: PullMode }> = {}
for (const [name, pinConfig] of Object.entries(config)) {
pinMap[name] = {
offset: pinConfig.pin,
pull: pinConfig.pull ?? "up",
}
}
return new InputGroup(chip, request, pinMap)
} catch (err) {
gpiod.gpiod_chip_close(chip)
throw err
}
}
async listChips(): Promise<ChipInfo[]> {
const chips: ChipInfo[] = []
try {
const files = await readdir("/dev")
const chipFiles = files.filter((f) => f.startsWith("gpiochip"))
for (const file of chipFiles) {
const path = `/dev/${file}`
try {
const chip = gpiod.gpiod_chip_open(cstr(path))
if (!chip) continue
const info = gpiod.gpiod_chip_get_info(chip)
if (!info) {
gpiod.gpiod_chip_close(chip)
continue
}
const name = gpiod.gpiod_chip_info_get_name(info)
const label = gpiod.gpiod_chip_info_get_label(info)
const numLines = gpiod.gpiod_chip_info_get_num_lines(info)
chips.push({
path,
name: String(name || ""),
label: String(label || ""),
numLines: Number(numLines),
})
gpiod.gpiod_chip_close(chip)
} catch {
continue
}
}
} catch {
// /dev might not be accessible, return empty array
}
return chips
}
}

View File

@ -1,89 +1,17 @@
import { readdir } from "node:fs/promises"
import { gpiod, cstr } from "./ffi"
import { Output } from "./output"
import { Input } from "./input"
import type * as Type from "./types"
import {
export { GPIO } from "./gpio"
export {
GPIOError,
PermissionError,
PinInUseError,
ChipNotFoundError,
InvalidConfigError,
} from "./errors"
class GPIO {
#chipPath: string
constructor(options?: { chip?: string }) {
this.#chipPath = options?.chip ?? "/dev/gpiochip0"
}
output(pin: number, options?: Type.OutputOptions): Output {
return new Output(this.#chipPath, pin, options)
}
input(pin: number, options?: Type.InputOptions): Input {
return new Input(this.#chipPath, pin, options)
}
async listChips(): Promise<Type.ChipInfo[]> {
const chips: Type.ChipInfo[] = []
try {
const files = await readdir("/dev")
const chipFiles = files.filter((f) => f.startsWith("gpiochip"))
for (const file of chipFiles) {
const path = `/dev/${file}`
try {
const chip = gpiod.gpiod_chip_open(cstr(path))
if (!chip) continue
const info = gpiod.gpiod_chip_get_info(chip)
if (!info) {
gpiod.gpiod_chip_close(chip)
continue
}
const name = gpiod.gpiod_chip_info_get_name(info)
const label = gpiod.gpiod_chip_info_get_label(info)
const numLines = gpiod.gpiod_chip_info_get_num_lines(info)
chips.push({
path,
name: String(name || ""),
label: String(label || ""),
numLines: Number(numLines),
})
gpiod.gpiod_chip_close(chip)
} catch {
continue
}
}
} catch {
// /dev might not be accessible, return empty array
}
return chips
}
static Error = GPIOError
static PermissionError = PermissionError
static PinInUseError = PinInUseError
static ChipNotFoundError = ChipNotFoundError
static InvalidConfigError = InvalidConfigError
}
namespace GPIO {
export type PullMode = Type.PullMode
export type EdgeMode = Type.EdgeMode
export type InputOptions = Type.InputOptions
export type OutputOptions = Type.OutputOptions
export type InputEvent = Type.InputEvent
export type Input = import("./input").Input
export type Output = import("./output").Output
}
export default GPIO
export type {
InputOptions,
OutputOptions,
InputEvent,
InputGroupEvent,
ChipInfo,
PullMode,
EdgeMode,
} from "./types"

194
src/pins/input-group.ts Normal file
View File

@ -0,0 +1,194 @@
import type { Pointer } from "bun:ffi"
import { gpiod } from "./ffi"
import { mapLibgpiodEdgeToPressedState } from "./utils"
import type { PullMode, InputEvent, InputGroupEvent, PinConfig } from "./types"
export class InputGroup<T extends string = string> {
#closed = false
#chip: Pointer
#request: Pointer
#pinMap: Map<string, { offset: number; pull: PullMode }>
#offsetMap: Map<number, { name: string; pull: PullMode }>
#eventBuffer: Pointer | undefined
#eventListeners: Array<(event: InputGroupEvent<T>) => void> = []
#closeHandlers: Array<() => void> = []
constructor(chip: Pointer, request: Pointer, pinConfig: PinConfig) {
this.#chip = chip
this.#request = request
this.#pinMap = new Map()
this.#offsetMap = new Map()
for (const [name, config] of Object.entries(pinConfig)) {
this.#pinMap.set(name, config)
this.#offsetMap.set(config.offset, { name, pull: config.pull })
}
}
get pins(): Record<T, { readonly value: 0 | 1; waitForValue: (targetValue: 0 | 1, timeout?: number) => Promise<void> }> {
const result = {} as Record<T, { readonly value: 0 | 1; waitForValue: (targetValue: 0 | 1, timeout?: number) => Promise<void> }>
for (const [name, config] of this.#pinMap) {
const offset = config.offset
const closed = () => this.#closed
const request = this.#request
const pinName = name
Object.defineProperty(result, name, {
get: () => ({
get value(): 0 | 1 {
if (closed()) throw new Error("InputGroup is closed")
const ret = gpiod.gpiod_line_request_get_value(request, offset)
if (ret === -1) throw new Error("Failed to get pin value")
return ret as 0 | 1
},
waitForValue: (targetValue: 0 | 1, timeout?: number) => this.#waitForPinValue(pinName as T, targetValue, timeout)
}),
enumerable: true
})
}
return result
}
async #waitForPinValue(pinName: T, targetValue: 0 | 1, timeout?: number): Promise<void> {
return new Promise((resolve, reject) => {
if (this.#closed) {
reject(new Error("InputGroup is closed"))
return
}
let timeoutId: ReturnType<typeof setTimeout> | undefined
const cleanup = () => {
if (timeoutId) clearTimeout(timeoutId)
this.#eventListeners = this.#eventListeners.filter((l) => l !== listener)
this.#closeHandlers = this.#closeHandlers.filter((h) => h !== onClose)
}
const onClose = () => {
cleanup()
reject(new Error("InputGroup closed while waiting"))
}
const listener = (event: InputGroupEvent<T>) => {
if (event.pin !== pinName) return
if (event.value !== targetValue) return
cleanup()
resolve()
}
if (timeout) {
timeoutId = setTimeout(() => {
cleanup()
reject(new Error(`Timeout waiting for pin ${pinName} to become ${targetValue}`))
}, timeout)
}
this.#eventListeners.push(listener)
this.#closeHandlers.push(onClose)
this.#startEventLoop()
})
}
async *events(): AsyncGenerator<InputGroupEvent<T>> {
if (this.#closed) throw new Error("InputGroup is closed")
const eventQueue: InputGroupEvent<T>[] = []
let resolve: (() => void) | undefined
const listener = (event: InputGroupEvent<T>) => {
eventQueue.push(event)
resolve?.()
}
this.#eventListeners.push(listener)
this.#startEventLoop()
try {
while (!this.#closed) {
if (eventQueue.length === 0) {
await new Promise<void>((r) => {
resolve = r
})
}
const event = eventQueue.shift()
if (event) yield event
}
} finally {
this.#eventListeners = this.#eventListeners.filter((l) => l !== listener)
}
}
#startEventLoop() {
if (this.#eventBuffer !== undefined) return
const buffer = gpiod.gpiod_edge_event_buffer_new(1)
if (!buffer) throw new Error("Failed to create event buffer")
this.#eventBuffer = buffer
this.#runEventLoop()
}
async #runEventLoop() {
try {
while (!this.#closed && this.#eventListeners.length > 0) {
const ret = gpiod.gpiod_line_request_wait_edge_events(this.#request, 100_000_000)
if (ret === -1 || ret === 0) {
await Bun.sleep(0)
continue
}
const numEvents = gpiod.gpiod_line_request_read_edge_events(
this.#request,
this.#eventBuffer!,
1
)
if (numEvents > 0) {
const event = gpiod.gpiod_edge_event_buffer_get_event(this.#eventBuffer!, 0)
const edgeType = gpiod.gpiod_edge_event_get_event_type(event)
const timestamp = gpiod.gpiod_edge_event_get_timestamp_ns(event)
const offset = gpiod.gpiod_edge_event_get_line_offset(event)
const pinInfo = this.#offsetMap.get(offset)
if (!pinInfo) continue
const pressed = mapLibgpiodEdgeToPressedState(edgeType, pinInfo.pull)
const value = (pressed ? (pinInfo.pull === "up" ? 0 : 1) : (pinInfo.pull === "up" ? 1 : 0)) as 0 | 1
const inputEvent: InputGroupEvent<T> = { pin: pinInfo.name as T, value, timestamp }
for (const listener of this.#eventListeners) {
listener(inputEvent)
}
}
}
} finally {
if (this.#eventBuffer) {
gpiod.gpiod_edge_event_buffer_free(this.#eventBuffer)
this.#eventBuffer = undefined
}
}
}
close() {
if (this.#closed) return
this.#closed = true
for (const handler of this.#closeHandlers) {
handler()
}
this.#closeHandlers = []
gpiod.gpiod_line_request_release(this.#request)
gpiod.gpiod_chip_close(this.#chip)
}
[Symbol.dispose]() {
this.close()
}
}

View File

@ -1,84 +0,0 @@
import { gpiod, GPIOD_EDGE_EVENT_RISING_EDGE } from "./ffi"
import { requestInputLine } from "./gpio-helpers"
import type { PullMode, EdgeMode } from "./types"
type WorkerConfig = {
chipPath: string
offset: number
pull: PullMode
debounce: number
edge: EdgeMode
}
type WorkerMessage =
| { type: "ready"; initialValue: 0 | 1 }
| { type: "event"; value: 0 | 1; timestamp: bigint }
| { type: "error"; message: string }
const postMessage = (message: WorkerMessage) => {
self.postMessage(message)
}
const cleanup = (message: string): never => {
postMessage({ type: "error", message })
self.close()
throw new Error(message)
}
const mapEdgeToValue = (edgeType: number, pull: PullMode): 0 | 1 => {
// Pull-up: rising edge = released (1), falling edge = pressed (0)
// Pull-down: rising edge = pressed (1), falling edge = released (0)
if (pull === "up") {
return edgeType === GPIOD_EDGE_EVENT_RISING_EDGE ? 1 : 0
}
return edgeType === GPIOD_EDGE_EVENT_RISING_EDGE ? 1 : 0
}
const run = (config: WorkerConfig) => {
const { chip, request } = requestInputLine(config)
const initialValue = gpiod.gpiod_line_request_get_value(request, config.offset)
if (initialValue === -1) {
gpiod.gpiod_line_request_release(request)
gpiod.gpiod_chip_close(chip)
cleanup("Failed to read initial value")
}
postMessage({ type: "ready", initialValue: initialValue as 0 | 1 })
const buffer = gpiod.gpiod_edge_event_buffer_new(1)
if (!buffer) {
gpiod.gpiod_line_request_release(request)
gpiod.gpiod_chip_close(chip)
cleanup("Failed to create event buffer")
}
while (true) {
// Block forever (-1 timeout) until edge event occurs
const waitResult = gpiod.gpiod_line_request_wait_edge_events(request, -1)
if (waitResult === 1) {
const numEvents = gpiod.gpiod_line_request_read_edge_events(request, buffer, 1)
if (numEvents === -1) cleanup("Failed to read edge events")
const event = gpiod.gpiod_edge_event_buffer_get_event(buffer, 0)
const edgeType = gpiod.gpiod_edge_event_get_event_type(event)
const timestamp = gpiod.gpiod_edge_event_get_timestamp_ns(event)
const value = mapEdgeToValue(edgeType, config.pull)
postMessage({ type: "event", value, timestamp })
} else if (waitResult === -1) {
cleanup("GPIO wait_edge_events failed")
}
}
// Worker terminates - kernel cleans up GPIO resources automatically
}
self.onmessage = (event: MessageEvent<WorkerConfig>) => {
self.onmessage = () => {
throw new Error("Worker already initialized")
}
run(event.data)
}

View File

@ -1,90 +1,36 @@
import type { InputEvent, InputOptions } from "./types"
import type { Pointer } from "bun:ffi"
import { InputGroup } from "./input-group"
import type { PullMode, InputEvent } from "./types"
type WorkerMessage =
| { type: "ready"; initialValue: 0 | 1 }
| { type: "event"; value: 0 | 1; timestamp: bigint }
| { type: "error"; message: string }
export class InputPin<T extends string = string> {
#group: InputGroup<T>
#pinName: T
export class Input {
#worker: Worker
#callbacks = new Set<(event: InputEvent) => void>()
#closed = false
#lastValue: 0 | 1 = 0
constructor(chipPath: string, offset: number, options: InputOptions = {}) {
const pull = options.pull ?? "up"
const debounce = options.debounce ?? 0
const edge = options.edge ?? "both"
this.#worker = new Worker(new URL("./input-worker.ts", import.meta.url).href)
this.#worker.onmessage = (msg: MessageEvent<WorkerMessage>) => {
if (this.#closed) return
const data = msg.data
if (data.type === "ready") {
this.#lastValue = data.initialValue
} else if (data.type === "event") {
this.#lastValue = data.value
for (const callback of this.#callbacks) {
callback({ value: data.value, timestamp: data.timestamp })
}
} else if (data.type === "error") {
console.error(`GPIO Input Error (pin ${offset}):`, data.message)
}
}
this.#worker.postMessage({ chipPath, offset, pull, debounce, edge })
constructor(group: InputGroup<T>, pinName: T) {
this.#group = group
this.#pinName = pinName
}
get value(): 0 | 1 {
if (this.#closed) throw new Error("Input is closed")
return this.#lastValue
}
onChange(callback: (event: InputEvent) => void): () => void {
if (this.#closed) throw new Error("Input is closed")
this.#callbacks.add(callback)
return () => this.#callbacks.delete(callback)
return this.#group.pins[this.#pinName]!.value
}
async waitForValue(targetValue: 0 | 1, timeout?: number): Promise<void> {
if (this.#closed) throw new Error("Input is closed")
if (this.#lastValue === targetValue) return
return new Promise((resolve, reject) => {
let timeoutId: ReturnType<typeof setTimeout> | undefined
const cleanup = () => {
if (timeoutId) clearTimeout(timeoutId)
unsubscribe()
}
const unsubscribe = this.onChange((event) => {
for await (const event of this.#group.events()) {
if (event.value === targetValue) {
cleanup()
resolve()
return
}
}
}
})
if (!timeout) return
timeoutId = setTimeout(() => {
cleanup()
reject(new Error(`Timeout waiting for value ${targetValue}`))
}, timeout)
})
async *events(): AsyncGenerator<InputEvent> {
for await (const event of this.#group.events()) {
yield { value: event.value, timestamp: event.timestamp }
}
}
close() {
if (this.#closed) return
this.#closed = true
this.#callbacks.clear()
this.#worker.onmessage = null
this.#worker.terminate()
this.#group.close()
}
[Symbol.dispose]() {

View File

@ -1,39 +1,30 @@
import type { Pointer } from "bun:ffi"
import { gpiod } from "./ffi"
import { requestOutputLine } from "./gpio-helpers"
import type { OutputOptions } from "./types"
export class Output {
export class OutputPin {
#closed = false
#chip: Pointer
#request: Pointer
#offset: number
#pin: number
#resetValue?: 0 | 1
constructor(chipPath: string, offset: number, options: OutputOptions = {}) {
const initialValue = options.initialValue ?? 0
const { chip, request } = requestOutputLine({ chipPath, offset, initialValue })
constructor(chip: Pointer, request: Pointer, pin: number, resetValue?: 0 | 1) {
this.#chip = chip
this.#request = request
this.#offset = offset
if (options.resetOnClose) {
const currentValue = gpiod.gpiod_line_request_get_value(request, offset)
this.#resetValue = currentValue === -1 ? 0 : (currentValue as 0 | 1)
}
this.#pin = pin
this.#resetValue = resetValue
}
get value(): 0 | 1 {
if (this.#closed) throw new Error("Output is closed")
const ret = gpiod.gpiod_line_request_get_value(this.#request, this.#offset)
if (this.#closed) throw new Error("OutputPin is closed")
const ret = gpiod.gpiod_line_request_get_value(this.#request, this.#pin)
if (ret === -1) throw new Error("Failed to get pin value")
return ret as 0 | 1
}
set value(val: 0 | 1) {
if (this.#closed) throw new Error("Output is closed")
const ret = gpiod.gpiod_line_request_set_value(this.#request, this.#offset, val)
if (this.#closed) throw new Error("OutputPin is closed")
const ret = gpiod.gpiod_line_request_set_value(this.#request, this.#pin, val)
if (ret === -1) throw new Error("Failed to set pin value")
}
@ -46,7 +37,7 @@ export class Output {
this.#closed = true
if (this.#resetValue !== undefined) {
gpiod.gpiod_line_request_set_value(this.#request, this.#offset, this.#resetValue)
gpiod.gpiod_line_request_set_value(this.#request, this.#pin, this.#resetValue)
}
gpiod.gpiod_line_request_release(this.#request)

View File

@ -1,7 +0,0 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"lib": ["ESNext", "WebWorker"]
},
"include": ["input-worker.ts"]
}

View File

@ -9,7 +9,6 @@ export type InputOptions = {
export type OutputOptions = {
initialValue?: 0 | 1 // default: 0
resetOnClose?: boolean // default: false
}
export type InputEvent = {
@ -17,9 +16,15 @@ export type InputEvent = {
timestamp: bigint // nanoseconds
}
export type InputGroupEvent<T extends string = string> = InputEvent & {
pin: T // name of the pin that fired
}
export type ChipInfo = {
path: string
name: string
label: string
numLines: number
}
export type PinConfig = Record<string, { offset: number; pull: PullMode }>

View File

@ -38,7 +38,10 @@ export const mapEdgeToLibgpiod = (edge: EdgeMode): number => {
// Hardware logic:
// - Pull-up + button to GND: pressing pulls line LOW (falling edge = pressed)
// - Pull-down + button to VCC: pressing pulls line HIGH (rising edge = pressed)
export const mapLibgpiodEdgeToPressedState = (edgeType: number, pull: PullMode): boolean => {
export const mapLibgpiodEdgeToPressedState = (
edgeType: number,
pull: PullMode
): boolean => {
if (pull === "up") {
return edgeType === GPIOD_EDGE_EVENT_FALLING_EDGE
} else if (pull === "down") {
@ -48,6 +51,10 @@ export const mapLibgpiodEdgeToPressedState = (edgeType: number, pull: PullMode):
}
}
export const hashInputConfig = (pull: PullMode, debounce: number, edge: EdgeMode): string => {
export const hashInputConfig = (
pull: PullMode,
debounce: number,
edge: EdgeMode
): string => {
return `${pull}-${debounce}-${edge}`
}

View File

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

View File

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

View File

@ -16,18 +16,7 @@ export const Layout = ({ title, children, refresh }: LayoutProps) => (
<link rel="stylesheet" href="/pico.css" />
</head>
<body>
<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>
<main class="container">{children}</main>
</body>
</html>
);

View File

@ -1,40 +1,62 @@
import { Layout } from "./Layout";
type LogsPageProps = {
service: string;
logs: string;
};
export const LogsPage = ({ service, logs }: LogsPageProps) => (
export const LogsPage = ({ logs }: LogsPageProps) => (
<Layout title="Service Logs">
<h1>📋 Service Logs</h1>
<div role="group">
<a
href="/logs?service=phone-ap"
role="button"
aria-current={service === "phone-ap" ? "true" : undefined}
>
📡 WiFi AP
</a>
<a
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>
<p>
<small>
<label>
<input type="checkbox" id="auto-refresh" checked /> Auto-refresh
</label>
{" | "}
<a href="/"> Back</a>
</small>
</p>
<pre>
<code id="logs-content">{logs.trim()}</code>
</pre>
<script>{`
let interval = null;
const checkbox = document.getElementById('auto-refresh');
const logsContent = document.getElementById('logs-content');
async function refreshLogs() {
try {
const res = await fetch('/api/logs');
const data = await res.json();
logsContent.textContent = data.logs;
} catch (error) {
console.error('Failed to refresh logs:', error);
}
}
function startRefresh() {
if (interval) clearInterval(interval);
interval = setInterval(refreshLogs, 5000);
}
function stopRefresh() {
if (interval) {
clearInterval(interval);
interval = null;
}
}
checkbox.addEventListener('change', (e) => {
if (e.target.checked) {
startRefresh();
} else {
stopRefresh();
}
});
// Start auto-refresh by default
startRefresh();
`}</script>
</Layout>
);

View File

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

View File

@ -1,114 +0,0 @@
import log from "./utils/log.ts"
import { Emitter } from "./utils/emitter.ts"
import { processStdout, processStderr } from "./utils/stdio.ts"
export class Baresip {
baresipArgs: string[]
process?: Bun.PipedSubprocess
callEstablished = new Emitter<{ contact: string }>()
callReceived = new Emitter<{ contact: string }>()
hungUp = new Emitter()
error = new Emitter<{ message: string }>()
registrationSuccess = new Emitter()
constructor(baresipArgs: string[]) {
this.baresipArgs = baresipArgs
}
async connect() {
this.process = Bun.spawn(this.baresipArgs, {
stdout: "pipe",
stderr: "pipe",
onExit: (_proc, exitCode, signalCode, error) => {
log.debug(`📞 Baresip process exited (code: ${exitCode}, signal: ${signalCode})`)
if (error) {
log.error("Process error:", error)
}
},
})
Promise.all([
processStdout(this.process, (line) => this.#parseLine(line)),
processStderr(this.process),
]).catch((error) => {
log.error("Error processing output:", error)
})
}
accept() {
executeCommand("a")
}
dial(phoneNumber: string) {
executeCommand(`d${phoneNumber}`)
}
hangUp() {
executeCommand("b")
}
disconnectAll() {
this.callEstablished.removeAllListeners()
this.callReceived.removeAllListeners()
this.hungUp.removeAllListeners()
this.registrationSuccess.removeAllListeners()
}
kill() {
if (!this.process) throw new Error("Process not started")
this.process.kill()
this.disconnectAll()
this.process = undefined
}
#parseLine(line: string) {
log.debug(`📞 Baresip: ${line}`)
const callEstablishedMatch = line.match(/Call established: (.+)/)
if (callEstablishedMatch) {
log.debug(`Call established with "${line}"`)
this.callEstablished.emit({ contact: callEstablishedMatch[1]! })
}
const callReceivedMatch = line.match(/Incoming call from: \+\d+ (\S+) -/)
if (callReceivedMatch) {
log.debug(`Incoming call from "${line}"`)
this.callReceived.emit({ contact: callReceivedMatch[1]!?.trim() })
}
const hangUpMatch = line.match(/(.+): session closed/)
if (hangUpMatch) {
log.debug(`Call hung up with "${line}"`)
this.hungUp.emit()
}
const callTerminatedMatch = line.match(/(.+) terminated \(duration: /)
if (callTerminatedMatch) {
log.debug(`⁉️ NOT HANDLED: Call terminated with "${line}"`)
}
const registrationSuccessMatch = line.match(/\[\d+ bindings?\]/)
if (registrationSuccessMatch) {
this.registrationSuccess.emit()
}
const registrationFailedMatch = line.match(/reg: sip:\S+ 403 Forbidden/)
const socketInUseMatch = line.match(/tcp: sock_bind:/)
if (registrationFailedMatch || socketInUseMatch) {
log.error(`⁉️ NOT HANDLED: Registration failed with "${line}"`)
this.error.emit({ message: line })
}
}
}
const executeCommand = async (command: string) => {
try {
const url = new URL(`/?${command}`, "http://127.0.0.1:8000")
const response = await Bun.fetch(url)
if (!response.ok) {
throw new Error(`Error executing command: ${response.statusText}`)
}
} catch (error) {
log.error("Failed to execute command:", error)
}
}

View File

@ -1,39 +0,0 @@
import GPIO from "./pins"
console.log(`kill -9 ${process.pid}`)
const gpio = new GPIO()
using led = gpio.output(21)
using button = gpio.input(20, { pull: "up", debounce: 10 })
using switchInput = gpio.input(16, { pull: "up", debounce: 10 })
led.value = button.value
button.onChange((event) => {
led.value = event.value
})
const switchEvent = new Promise<void>(async (resolve) => {
await switchInput.waitForValue(0)
console.log("Switch pressed!")
resolve()
})
process.on("SIGINT", () => {
button.close()
switchInput.close()
led.close()
process.exit(0)
})
process.on("SIGTERM", () => {
button.close()
switchInput.close()
led.close()
process.exit(0)
})
await switchEvent
console.log(`👋 Goodbye!`)

View File

@ -1,42 +0,0 @@
/**
* 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,5 +1,3 @@
import type GPIO from "../pins"
export const ensure = <T>(value: T, message: string): T => {
if (value === undefined || value === null) {
throw new Error(message)
@ -11,17 +9,3 @@ export const ensure = <T>(value: T, message: string): T => {
export const random = <T>(arr: ReadonlyArray<T>): T => {
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 +0,0 @@
let showDebug = process.env.DEBUG ?? false
let showInfo = true
export function setLogLevel(level: "debug" | "info" | "error") {
showDebug = level === "debug"
showInfo = level === "debug" || level === "info"
}
const log = (...args: any[]) => {
if (showInfo) console.log("👁️‍🗨️ INFO: ", ...args)
}
log.debug = (...args: any[]) => {
if (showDebug) console.debug("🪲 DEBUG: ", ...args)
}
log.error = (...args: any[]) => {
console.error("💥 ERROR: ", ...args)
}
export default log

57
src/utils/signal.ts Normal file
View File

@ -0,0 +1,57 @@
/**
* How to use a Signal:
*
* Create a signal:
* const chatSignal = new Signal<{ username: string, message: string }>()
*
* Connect to the signal:
* const disconnect = chatSignal.connect((data) => {
* const {username, message} = data;
* console.log(`${username} said "${message}"`);
* })
*
* Emit a signal:
* chatSignal.emit({ username: "Chad", message: "Hey everyone, how's it going?" });
*
* Forward a signal:
* const relaySignal = new Signal<{ username: string, message: string }>()
* const disconnectRelay = chatSignal.connect(relaySignal)
* // Now, when chatSignal emits, relaySignal will also emit the same data
*
* Disconnect a single listener:
* disconnect(); // The disconnect function is returned when you connect to a signal
*
* Disconnect all listeners:
* chatSignal.disconnect()
*/
export class Signal<T extends object | void> {
private listeners: Array<(data: T) => void> = []
connect(listenerOrSignal: Signal<T> | ((data: T) => void)) {
let listener: (data: T) => void
// If it is a signal, forward the data to the signal
if (listenerOrSignal instanceof Signal) {
listener = (data: T) => listenerOrSignal.emit(data)
} else {
listener = listenerOrSignal
}
this.listeners.push(listener)
return () => {
this.listeners = this.listeners.filter((l) => l !== listener)
}
}
emit(data: T) {
for (const listener of this.listeners) {
listener(data)
}
}
disconnect() {
this.listeners = []
}
}

View File

@ -1,41 +0,0 @@
import log from "./log.ts"
export const LineSplitter = () => {
let buffer = ""
return new TransformStream({
transform(chunk, controller) {
buffer += chunk
const parts = buffer.split(/\n/)
const lines = parts.slice(0, -1)
buffer = parts.at(-1) || ""
for (const line of lines) {
controller.enqueue(line)
}
},
flush(controller) {
if (buffer.length > 0) {
controller.enqueue(buffer)
}
},
})
}
export async function processStdout(
process: Bun.ReadableSubprocess,
onLine: (line: string) => void
) {
for await (const line of process.stdout
.pipeThrough(new TextDecoderStream())
.pipeThrough(LineSplitter())) {
onLine(line)
}
}
export async function processStderr(process: Bun.ReadableSubprocess, prefix: string = "") {
for await (const line of process.stderr
.pipeThrough(new TextDecoderStream())
.pipeThrough(LineSplitter())) {
log.error(`${prefix}${line}`)
}
}

View File

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

View File

@ -5,7 +5,7 @@
* Tests device listing, player, recorder, and tone generation
*/
import Buzz from "./buzz"
import Buzz from "./src/buzz"
console.log("🎵 Buzz Audio Library - Basic Test\n")
@ -21,7 +21,7 @@ console.log("")
// Test 2: Create player
console.log("🔊 Creating default player...")
try {
const player = await Buzz.player()
const player = await Buzz.defaultPlayer()
console.log("✅ Player created\n")
// Test 3: Play sound file
@ -42,7 +42,7 @@ try {
// Test 5: Create recorder
console.log("🎤 Creating default recorder...")
try {
const recorder = await Buzz.recorder()
const recorder = await Buzz.defaultRecorder()
console.log("✅ Recorder created\n")
// Test 6: Stream recording with RMS

View File

@ -26,6 +26,5 @@
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false
},
"exclude": ["src/pins/input-worker.ts"]
}
}