Compare commits
2 Commits
bd9ab973b2
...
c07cb297e3
| Author | SHA1 | Date | |
|---|---|---|---|
| c07cb297e3 | |||
| 28186bc0ce |
|
|
@ -1 +1 @@
|
||||||
<sip:yellow@probablycorey.sip.twilio.com;transport=tls>;auth_pass=zgm-kwx2bug5hwf3YGF;unregister_on_exit=yes;regint=300
|
<sip:yellow@probablycorey.sip.twilio.com;transport=tls>;auth_pass=zgm-kwx2bug5hwf3YGF;unregister_on_exit=yes
|
||||||
|
|
@ -51,13 +51,17 @@ module stun.so
|
||||||
module turn.so
|
module turn.so
|
||||||
module ice.so
|
module ice.so
|
||||||
|
|
||||||
|
# STUN Server
|
||||||
|
stun_host stun.l.google.com
|
||||||
|
stun_port 19302
|
||||||
|
|
||||||
module httpd.so
|
module httpd.so
|
||||||
|
|
||||||
#------------------------------------------------------------------------------
|
#------------------------------------------------------------------------------
|
||||||
# Temporary Modules (loaded then unloaded)
|
# Temporary Modules (loaded then unloaded)
|
||||||
|
|
||||||
module_tmp uuid.so
|
module uuid.so
|
||||||
module_tmp account.so
|
module account.so
|
||||||
|
|
||||||
|
|
||||||
#------------------------------------------------------------------------------
|
#------------------------------------------------------------------------------
|
||||||
|
|
|
||||||
108
scripts/bootstrap-services.ts
Normal file
108
scripts/bootstrap-services.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
import { $ } from "bun"
|
||||||
|
import { writeFileSync } from "fs"
|
||||||
|
|
||||||
|
const AP_SERVICE_FILE = "/etc/systemd/system/phone-ap.service"
|
||||||
|
const WEB_SERVICE_FILE = "/etc/systemd/system/phone-web.service"
|
||||||
|
const PHONE_SERVICE_FILE = "/etc/systemd/system/phone.service"
|
||||||
|
|
||||||
|
export const setupServices = async (installDir: string) => {
|
||||||
|
console.log("\nInstalling systemd services...")
|
||||||
|
|
||||||
|
// Detect user from environment or use default
|
||||||
|
// SUDO_USER is set when running with sudo, which is what we want
|
||||||
|
const serviceUser = process.env.SERVICE_USER || process.env.SUDO_USER || process.env.USER || "corey"
|
||||||
|
const userUid = await $`id -u ${serviceUser}`.text().then((s) => s.trim())
|
||||||
|
|
||||||
|
console.log(`Setting up services for user: ${serviceUser} (UID: ${userUid})`)
|
||||||
|
|
||||||
|
// Find where bun is installed
|
||||||
|
const bunPath = await $`which bun`
|
||||||
|
.quiet()
|
||||||
|
.nothrow()
|
||||||
|
.text()
|
||||||
|
.then((p) => p.trim())
|
||||||
|
|
||||||
|
if (!bunPath) {
|
||||||
|
console.error("Error: bun not found in PATH. Please ensure bun is available system-wide.")
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
console.log(`Using bun at: ${bunPath}`)
|
||||||
|
|
||||||
|
// Create AP monitor service
|
||||||
|
const apServiceContent = `[Unit]
|
||||||
|
Description=Phone WiFi AP Monitor
|
||||||
|
After=network.target
|
||||||
|
Before=phone-web.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
ExecStart=${bunPath} ${installDir}/src/services/ap-monitor.ts
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
`
|
||||||
|
writeFileSync(AP_SERVICE_FILE, apServiceContent, "utf8")
|
||||||
|
console.log("✓ Created phone-ap.service")
|
||||||
|
|
||||||
|
// Create web server service
|
||||||
|
const webServiceContent = `[Unit]
|
||||||
|
Description=Phone Web Server
|
||||||
|
After=network.target phone-ap.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
ExecStart=${bunPath} ${installDir}/src/services/server/server.tsx
|
||||||
|
WorkingDirectory=${installDir}
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
`
|
||||||
|
writeFileSync(WEB_SERVICE_FILE, webServiceContent, "utf8")
|
||||||
|
console.log("✓ Created phone-web.service")
|
||||||
|
|
||||||
|
// Create phone service (system service with environment variables for audio access)
|
||||||
|
const phoneServiceContent = `[Unit]
|
||||||
|
Description=Phone Application
|
||||||
|
After=network.target sound.target
|
||||||
|
Requires=sound.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=${serviceUser}
|
||||||
|
Group=audio
|
||||||
|
Environment=XDG_RUNTIME_DIR=/run/user/${userUid}
|
||||||
|
Environment=DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/${userUid}/bus
|
||||||
|
ExecStart=${bunPath} ${installDir}/src/main.ts
|
||||||
|
WorkingDirectory=${installDir}
|
||||||
|
EnvironmentFile=${installDir}/.env
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
`
|
||||||
|
writeFileSync(PHONE_SERVICE_FILE, phoneServiceContent, "utf8")
|
||||||
|
console.log("✓ Created phone.service")
|
||||||
|
|
||||||
|
await $`systemctl daemon-reload`
|
||||||
|
await $`systemctl enable phone-ap.service`
|
||||||
|
await $`systemctl enable phone-web.service`
|
||||||
|
await $`systemctl enable phone.service`
|
||||||
|
console.log("✓ Services enabled")
|
||||||
|
|
||||||
|
console.log("\nRestarting the services...")
|
||||||
|
await $`systemctl restart phone-ap.service`
|
||||||
|
await $`systemctl restart phone-web.service`
|
||||||
|
await $`systemctl restart phone.service`
|
||||||
|
console.log("✓ Services restarted")
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import { $ } from "bun"
|
import { $ } from "bun"
|
||||||
import { writeFileSync } from "fs"
|
|
||||||
|
|
||||||
console.log(`
|
console.log(`
|
||||||
==========================================
|
==========================================
|
||||||
|
|
@ -15,9 +14,8 @@ if (process.getuid && process.getuid() !== 0) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get install directory from argument or use default
|
// Get install directory from argument or use default
|
||||||
const INSTALL_DIR = process.argv[2] || "/home/corey/phone"
|
const defaultUser = process.env.USER || "corey"
|
||||||
const AP_SERVICE_FILE = "/etc/systemd/system/phone-ap.service"
|
const INSTALL_DIR = process.argv[2] || `/home/${defaultUser}/phone`
|
||||||
const WEB_SERVICE_FILE = "/etc/systemd/system/phone-web.service"
|
|
||||||
|
|
||||||
console.log(`Install directory: ${INSTALL_DIR}`)
|
console.log(`Install directory: ${INSTALL_DIR}`)
|
||||||
|
|
||||||
|
|
@ -32,82 +30,6 @@ console.log(`✓ Dependencies installed`)
|
||||||
console.log("\nInstalling Baresip...")
|
console.log("\nInstalling Baresip...")
|
||||||
await $`sudo apt install -y baresip`
|
await $`sudo apt install -y baresip`
|
||||||
|
|
||||||
console.log("\nInstalling 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("\nStarting the services...")
|
|
||||||
await $`systemctl start phone-ap.service`
|
|
||||||
await $`systemctl start phone-web.service`
|
|
||||||
console.log("✓ Services started")
|
|
||||||
|
|
||||||
console.log(`
|
console.log(`
|
||||||
==========================================
|
✅ Bootstrap complete!
|
||||||
✓ Bootstrap complete!
|
|
||||||
==========================================
|
|
||||||
|
|
||||||
Both services are now running and will start automatically on boot:
|
|
||||||
- phone-ap.service: Monitors WiFi and manages AP
|
|
||||||
- phone-web.service: Web server for configuration
|
|
||||||
|
|
||||||
How it works:
|
|
||||||
- If connected to WiFi: Access at http://phone.local
|
|
||||||
- If NOT connected: WiFi AP "phone-setup" will start automatically
|
|
||||||
Connect to the AP at the same address http://phone.local
|
|
||||||
|
|
||||||
To check status use ./cli
|
|
||||||
`)
|
`)
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,9 @@
|
||||||
|
|
||||||
import { $ } from "bun"
|
import { $ } from "bun"
|
||||||
|
|
||||||
|
const defaultUser = process.env.USER ?? "corey"
|
||||||
const PI_HOST = process.env.PI_HOST ?? "phone.local"
|
const PI_HOST = process.env.PI_HOST ?? "phone.local"
|
||||||
const PI_DIR = process.env.PI_DIR ?? "/home/corey/phone"
|
const PI_DIR = process.env.PI_DIR ?? `/home/${defaultUser}/phone`
|
||||||
|
|
||||||
// Parse command line arguments
|
// Parse command line arguments
|
||||||
const shouldBootstrap = process.argv.includes("--bootstrap")
|
const shouldBootstrap = process.argv.includes("--bootstrap")
|
||||||
|
|
@ -40,22 +41,10 @@ if (shouldBootstrap) {
|
||||||
// make console beep
|
// make console beep
|
||||||
await $`afplay /System/Library/Sounds/Blow.aiff`
|
await $`afplay /System/Library/Sounds/Blow.aiff`
|
||||||
|
|
||||||
// Always check if services exist and restart them (whether we bootstrapped or not)
|
// Always set up services on every deploy
|
||||||
console.log("Checking for existing services...")
|
console.log("Setting up services...")
|
||||||
const apServiceExists = await $`ssh ${PI_HOST} "systemctl is-enabled phone-ap.service"`
|
await $`ssh ${PI_HOST} "sudo bun ${PI_DIR}/scripts/setup-services.ts ${PI_DIR}"`
|
||||||
.nothrow()
|
console.log("✓ Services configured and running\n")
|
||||||
.quiet()
|
|
||||||
const webServiceExists = await $`ssh ${PI_HOST} "systemctl is-enabled phone-web.service"`
|
|
||||||
.nothrow()
|
|
||||||
.quiet()
|
|
||||||
|
|
||||||
if (apServiceExists.exitCode === 0 && webServiceExists.exitCode === 0) {
|
|
||||||
console.log("Restarting services...")
|
|
||||||
await $`ssh ${PI_HOST} "sudo systemctl restart phone-ap.service phone-web.service"`
|
|
||||||
console.log("✓ Services restarted\n")
|
|
||||||
} else if (!shouldBootstrap) {
|
|
||||||
console.log("Services not installed. Run with --bootstrap to install.\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`
|
console.log(`
|
||||||
✓ Deploy complete!
|
✓ Deploy complete!
|
||||||
|
|
|
||||||
15
scripts/setup-services.ts
Normal file
15
scripts/setup-services.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
#!/usr/bin/env bun
|
||||||
|
|
||||||
|
import { setupServices } from "./bootstrap-services"
|
||||||
|
|
||||||
|
// Get install directory from argument or use default
|
||||||
|
const defaultUser = process.env.USER || "corey"
|
||||||
|
const INSTALL_DIR = process.argv[2] || `/home/${defaultUser}/phone`
|
||||||
|
|
||||||
|
console.log(`Setting up services for: ${INSTALL_DIR}`)
|
||||||
|
|
||||||
|
await setupServices(INSTALL_DIR)
|
||||||
|
|
||||||
|
console.log(`
|
||||||
|
✓ Services configured and running!
|
||||||
|
`)
|
||||||
|
|
@ -19,7 +19,7 @@ const agent = new Agent({
|
||||||
})
|
})
|
||||||
|
|
||||||
// Set up event handlers
|
// Set up event handlers
|
||||||
const player = await Buzz.defaultPlayer()
|
const player = await Buzz.player()
|
||||||
let playback = player.playStream()
|
let playback = player.playStream()
|
||||||
|
|
||||||
agent.events.connect((event) => {
|
agent.events.connect((event) => {
|
||||||
|
|
@ -43,7 +43,7 @@ agent.events.connect((event) => {
|
||||||
await agent.start()
|
await agent.start()
|
||||||
|
|
||||||
// Continuously stream audio
|
// Continuously stream audio
|
||||||
const recorder = await Buzz.defaultRecorder()
|
const recorder = await Buzz.recorder()
|
||||||
const recording = recorder.start()
|
const recording = recorder.start()
|
||||||
for await (const chunk of recording.stream()) {
|
for await (const chunk of recording.stream()) {
|
||||||
agent.sendAudio(chunk)
|
agent.sendAudio(chunk)
|
||||||
|
|
@ -53,7 +53,7 @@ for await (const chunk of recording.stream()) {
|
||||||
## VAD Pattern
|
## VAD Pattern
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
const recorder = await Buzz.defaultRecorder()
|
const recorder = await Buzz.recorder()
|
||||||
const recording = recorder.start()
|
const recording = recorder.start()
|
||||||
const buffer = new RollingBuffer()
|
const buffer = new RollingBuffer()
|
||||||
|
|
||||||
|
|
|
||||||
528
src/buzz/README.md
Normal file
528
src/buzz/README.md
Normal file
|
|
@ -0,0 +1,528 @@
|
||||||
|
# Buzz
|
||||||
|
|
||||||
|
High-level audio library for Bun using ALSA with streaming support and voice activity detection.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Play audio files with repeat option
|
||||||
|
- Generate and play multi-frequency tones (dial tones, DTMF, etc.)
|
||||||
|
- Stream audio playback with buffer tracking
|
||||||
|
- Record audio to stream or file (WAV)
|
||||||
|
- Volume control via ALSA mixer
|
||||||
|
- Device discovery and selection
|
||||||
|
- Voice activity detection via RMS calculation
|
||||||
|
- Type-safe TypeScript API with namespace types
|
||||||
|
- Zero external dependencies (uses ALSA `aplay` and `arecord`)
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Bun 1.0+
|
||||||
|
- ALSA utilities (`aplay`, `arecord`, `amixer`)
|
||||||
|
- Linux system with ALSA support
|
||||||
|
- TypeScript 5.2+
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import Buzz from "./buzz"
|
||||||
|
|
||||||
|
// Play an audio file
|
||||||
|
const player = await Buzz.player()
|
||||||
|
const playback = await player.play("./sounds/greeting.wav")
|
||||||
|
await playback.finished()
|
||||||
|
|
||||||
|
// Generate a dial tone
|
||||||
|
const dialTone = await player.playTone([350, 440], Infinity) // infinite duration
|
||||||
|
await Buzz.sleep(3000)
|
||||||
|
await dialTone.stop()
|
||||||
|
|
||||||
|
// Record audio
|
||||||
|
const recorder = await Buzz.recorder()
|
||||||
|
const recording = recorder.start()
|
||||||
|
|
||||||
|
for await (const chunk of recording.stream()) {
|
||||||
|
const rms = Buzz.calculateRMS(chunk)
|
||||||
|
if (rms > 5000) {
|
||||||
|
console.log("Speech detected!")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
### Buzz Module
|
||||||
|
|
||||||
|
#### `Buzz.player(label?, format?)`
|
||||||
|
|
||||||
|
Create a player. Omit `label` to use the default playback device.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const player = await Buzz.player() // default device
|
||||||
|
const player = await Buzz.player(undefined, { sampleRate: 16000 }) // default device with custom format
|
||||||
|
const player = await Buzz.player("USB Audio") // specific device
|
||||||
|
const player = await Buzz.player("Speaker", { sampleRate: 44100 }) // specific device with format
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `Buzz.recorder(label?, format?)`
|
||||||
|
|
||||||
|
Create a recorder. Omit `label` to use the default capture device.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const recorder = await Buzz.recorder() // default device
|
||||||
|
const recorder = await Buzz.recorder(undefined, { sampleRate: 16000 }) // default device with custom format
|
||||||
|
const recorder = await Buzz.recorder("USB Microphone") // specific device
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `Buzz.setVolume(volume, label?)`
|
||||||
|
|
||||||
|
Set playback volume (0.0 to 1.0).
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
await Buzz.setVolume(0.5) // 50% on default device
|
||||||
|
await Buzz.setVolume(0.8, "Speaker") // 80% on specific device
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `Buzz.getVolume(label?)`
|
||||||
|
|
||||||
|
Get current playback volume.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const volume = await Buzz.getVolume() // returns 0.0 to 1.0
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `Buzz.listDevices()`
|
||||||
|
|
||||||
|
List all available audio devices.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const devices = await Buzz.listDevices()
|
||||||
|
// [
|
||||||
|
// { id: 'plughw:0,0', card: 0, device: 0, label: 'bcm2835 Headphones', type: 'playback' },
|
||||||
|
// { id: 'plughw:1,0', card: 1, device: 0, label: 'USB Audio', type: 'capture' }
|
||||||
|
// ]
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `Buzz.calculateRMS(audioChunk)`
|
||||||
|
|
||||||
|
Calculate root mean square (RMS) for voice activity detection.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const chunk: Uint8Array = // ... audio data
|
||||||
|
const rms = Buzz.calculateRMS(chunk)
|
||||||
|
if (rms > 5000) {
|
||||||
|
console.log("Voice detected!")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Player
|
||||||
|
|
||||||
|
#### `player.play(filePath, options?)`
|
||||||
|
|
||||||
|
Play an audio file (WAV format).
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const playback = await player.play("./sounds/beep.wav")
|
||||||
|
const playback = await player.play("./music.wav", { repeat: true })
|
||||||
|
|
||||||
|
// Wait for playback to finish
|
||||||
|
await playback.finished()
|
||||||
|
|
||||||
|
// Stop playback
|
||||||
|
await playback.stop()
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns: `Buzz.Playback`
|
||||||
|
|
||||||
|
Options:
|
||||||
|
- `repeat?: boolean` - Loop the file indefinitely (default: false)
|
||||||
|
|
||||||
|
#### `player.playTone(frequencies, duration)`
|
||||||
|
|
||||||
|
Generate and play a tone with one or more frequencies.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Dial tone (350 Hz + 440 Hz)
|
||||||
|
const dialTone = await player.playTone([350, 440], Infinity)
|
||||||
|
|
||||||
|
// DTMF "1" key (697 Hz + 1209 Hz) for 200ms
|
||||||
|
const dtmf = await player.playTone([697, 1209], 200)
|
||||||
|
|
||||||
|
// Single frequency beep
|
||||||
|
const beep = await player.playTone([440], 1000) // 440 Hz for 1 second
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns: `Buzz.Playback`
|
||||||
|
|
||||||
|
#### `player.playStream()`
|
||||||
|
|
||||||
|
Create a streaming playback handle for real-time audio.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const stream = player.playStream()
|
||||||
|
|
||||||
|
// Write audio chunks
|
||||||
|
stream.write(audioChunk1)
|
||||||
|
stream.write(audioChunk2)
|
||||||
|
|
||||||
|
// Check if buffer is empty
|
||||||
|
if (stream.bufferEmptyFor > 1000) {
|
||||||
|
console.log("Buffer empty for 1+ seconds")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop streaming
|
||||||
|
await stream.stop()
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns: `Buzz.StreamingPlayback`
|
||||||
|
|
||||||
|
### Recorder
|
||||||
|
|
||||||
|
#### `recorder.start()`
|
||||||
|
|
||||||
|
Start recording to a stream.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const recording = recorder.start()
|
||||||
|
|
||||||
|
for await (const chunk of recording.stream()) {
|
||||||
|
// Process audio chunks (Uint8Array)
|
||||||
|
console.log("Received", chunk.byteLength, "bytes")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns: `Buzz.StreamingRecording`
|
||||||
|
|
||||||
|
#### `recorder.start(outputFile)`
|
||||||
|
|
||||||
|
Start recording to a WAV file.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const recording = recorder.start("./output.wav")
|
||||||
|
|
||||||
|
// Stop when done
|
||||||
|
await Bun.sleep(5000)
|
||||||
|
await recording.stop()
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns: `Buzz.FileRecording`
|
||||||
|
|
||||||
|
## Types
|
||||||
|
|
||||||
|
All types are available under the `Buzz` namespace:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
Buzz.AudioFormat // { format?, sampleRate?, channels? }
|
||||||
|
Buzz.Device // { id, card, device, label, type }
|
||||||
|
Buzz.Playback // { isPlaying, stop(), finished() }
|
||||||
|
Buzz.StreamingPlayback // { isPlaying, write(), stop(), bufferEmptyFor }
|
||||||
|
Buzz.StreamingRecording // { isRecording, stream(), stop() }
|
||||||
|
Buzz.FileRecording // { isRecording, stop() }
|
||||||
|
Buzz.Player // Player class type
|
||||||
|
Buzz.Recorder // Recorder class type
|
||||||
|
```
|
||||||
|
|
||||||
|
## Audio Format
|
||||||
|
|
||||||
|
Default format: `S16_LE`, 16000 Hz, mono
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
type AudioFormat = {
|
||||||
|
format?: string // e.g., "S16_LE", "S32_LE"
|
||||||
|
sampleRate?: number // e.g., 16000, 44100, 48000
|
||||||
|
channels?: number // 1 = mono, 2 = stereo
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Common formats:
|
||||||
|
- **Phone quality**: `{ sampleRate: 8000, channels: 1 }`
|
||||||
|
- **Voice/AI**: `{ sampleRate: 16000, channels: 1 }` (default)
|
||||||
|
- **CD quality**: `{ sampleRate: 44100, channels: 2 }`
|
||||||
|
- **Professional**: `{ sampleRate: 48000, channels: 2 }`
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
### Voice Activity Detection
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import Buzz from "./buzz"
|
||||||
|
|
||||||
|
const recorder = await Buzz.recorder()
|
||||||
|
const player = await Buzz.player()
|
||||||
|
|
||||||
|
const recording = recorder.start()
|
||||||
|
let talking = false
|
||||||
|
|
||||||
|
for await (const chunk of recording.stream()) {
|
||||||
|
const rms = Buzz.calculateRMS(chunk)
|
||||||
|
|
||||||
|
if (rms > 5000 && !talking) {
|
||||||
|
console.log("🗣️ Started talking")
|
||||||
|
talking = true
|
||||||
|
} else if (rms < 1000 && talking) {
|
||||||
|
console.log("🤫 Stopped talking")
|
||||||
|
talking = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Streaming Playback with Buffer Tracking
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import Buzz from "./buzz"
|
||||||
|
|
||||||
|
const player = await Buzz.player()
|
||||||
|
const stream = player.playStream()
|
||||||
|
|
||||||
|
// Simulate receiving audio chunks from network
|
||||||
|
const chunks = [chunk1, chunk2, chunk3] // Uint8Array[]
|
||||||
|
|
||||||
|
for (const chunk of chunks) {
|
||||||
|
stream.write(chunk)
|
||||||
|
|
||||||
|
// Wait until buffer is nearly empty before requesting more
|
||||||
|
while (stream.bufferEmptyFor < 500) {
|
||||||
|
await Bun.sleep(100)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await stream.stop()
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dial Tone with Voice Detection
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import Buzz from "./buzz"
|
||||||
|
|
||||||
|
await Buzz.setVolume(0.4)
|
||||||
|
|
||||||
|
const player = await Buzz.player()
|
||||||
|
const recorder = await Buzz.recorder()
|
||||||
|
|
||||||
|
// Play dial tone
|
||||||
|
const dialTone = await player.playTone([350, 440], Infinity)
|
||||||
|
|
||||||
|
// Wait for voice
|
||||||
|
const recording = recorder.start()
|
||||||
|
const vadThreshold = 5000
|
||||||
|
|
||||||
|
for await (const chunk of recording.stream()) {
|
||||||
|
const rms = Buzz.calculateRMS(chunk)
|
||||||
|
|
||||||
|
if (rms > vadThreshold) {
|
||||||
|
console.log("Voice detected, stopping dial tone")
|
||||||
|
await dialTone.stop()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Play Sound Effects
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import Buzz from "./buzz"
|
||||||
|
|
||||||
|
const player = await Buzz.player()
|
||||||
|
|
||||||
|
// Play multiple sounds in sequence
|
||||||
|
const sounds = ["./start.wav", "./beep.wav", "./end.wav"]
|
||||||
|
|
||||||
|
for (const sound of sounds) {
|
||||||
|
const playback = await player.play(sound)
|
||||||
|
await playback.finished()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Background Music Loop
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import Buzz from "./buzz"
|
||||||
|
|
||||||
|
const player = await Buzz.player()
|
||||||
|
|
||||||
|
// Play background music on repeat
|
||||||
|
const bgMusic = await player.play("./background.wav", { repeat: true })
|
||||||
|
|
||||||
|
// Stop after 30 seconds
|
||||||
|
await Bun.sleep(30000)
|
||||||
|
await bgMusic.stop()
|
||||||
|
```
|
||||||
|
|
||||||
|
### Record to File
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import Buzz from "./buzz"
|
||||||
|
|
||||||
|
const recorder = await Buzz.recorder()
|
||||||
|
|
||||||
|
console.log("Recording for 10 seconds...")
|
||||||
|
const recording = recorder.start("./output.wav")
|
||||||
|
|
||||||
|
await Bun.sleep(10000)
|
||||||
|
await recording.stop()
|
||||||
|
|
||||||
|
console.log("Saved to output.wav")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Multi-Device Setup
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import Buzz from "./buzz"
|
||||||
|
|
||||||
|
// List all devices
|
||||||
|
const devices = await Buzz.listDevices()
|
||||||
|
console.log("Available devices:", devices)
|
||||||
|
|
||||||
|
// Use specific devices
|
||||||
|
const speaker = await Buzz.player("Speaker")
|
||||||
|
const mic = await Buzz.recorder("USB Microphone")
|
||||||
|
|
||||||
|
// Independent volume control
|
||||||
|
await Buzz.setVolume(0.8, "Speaker")
|
||||||
|
await Buzz.setVolume(1.0, "Headphones")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### ALSA Backend
|
||||||
|
|
||||||
|
Buzz wraps ALSA command-line tools (`aplay`, `arecord`) via Bun's subprocess API:
|
||||||
|
|
||||||
|
- **Playback**: Spawns `aplay` with stdin pipe for streaming or file path for file playback
|
||||||
|
- **Recording**: Spawns `arecord` with stdout pipe for streaming or file path for WAV output
|
||||||
|
- **Volume**: Uses `amixer` for volume control
|
||||||
|
|
||||||
|
**Benefits:**
|
||||||
|
|
||||||
|
- **Simple**: No C bindings or FFI required
|
||||||
|
- **Reliable**: ALSA tools are battle-tested
|
||||||
|
- **Flexible**: Full format support (sample rates, channels, encodings)
|
||||||
|
- **Portable**: Works on any Linux system with ALSA
|
||||||
|
|
||||||
|
### Streaming Architecture
|
||||||
|
|
||||||
|
Streaming playback uses Bun's subprocess stdin pipe:
|
||||||
|
|
||||||
|
1. Spawn `aplay` with raw audio format and stdin input
|
||||||
|
2. Write audio chunks to process stdin as they arrive
|
||||||
|
3. Track buffer duration based on bytes written
|
||||||
|
4. Calculate `bufferEmptyFor` using performance timestamps
|
||||||
|
|
||||||
|
This enables:
|
||||||
|
- Real-time playback of network streams (WebSocket, API responses)
|
||||||
|
- Buffer management for smooth playback
|
||||||
|
- Low-latency audio (<100ms with proper buffering)
|
||||||
|
|
||||||
|
### Voice Activity Detection
|
||||||
|
|
||||||
|
`calculateRMS()` computes the root mean square of audio samples:
|
||||||
|
|
||||||
|
```
|
||||||
|
RMS = sqrt(sum(sample²) / count)
|
||||||
|
```
|
||||||
|
|
||||||
|
This provides a simple but effective measure of audio energy:
|
||||||
|
- Silence: RMS < 1000
|
||||||
|
- Noise: RMS 1000-5000
|
||||||
|
- Speech: RMS > 5000
|
||||||
|
|
||||||
|
Adjust thresholds based on your microphone and environment.
|
||||||
|
|
||||||
|
## Device Selection
|
||||||
|
|
||||||
|
### By Default (Recommended)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const player = await Buzz.player()
|
||||||
|
const recorder = await Buzz.recorder()
|
||||||
|
```
|
||||||
|
|
||||||
|
Uses ALSA default device (usually correct).
|
||||||
|
|
||||||
|
### By Label
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const devices = await Buzz.listDevices()
|
||||||
|
// Find device with label containing "USB"
|
||||||
|
const usbDevice = devices.find(d => d.label.includes("USB"))
|
||||||
|
|
||||||
|
const player = await Buzz.player(usbDevice.label)
|
||||||
|
```
|
||||||
|
|
||||||
|
Useful for multi-device setups (USB audio, HDMI, headphones).
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
try {
|
||||||
|
const player = await Buzz.player()
|
||||||
|
} catch (err) {
|
||||||
|
if (err.message.includes("No playback devices found")) {
|
||||||
|
console.error("No audio output devices available")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Buzz.setVolume(0.5)
|
||||||
|
} catch (err) {
|
||||||
|
if (err.message.includes("Failed to set volume")) {
|
||||||
|
console.error("Could not control volume (check mixer permissions)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### No devices found
|
||||||
|
|
||||||
|
Check ALSA devices:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
aplay -l # list playback devices
|
||||||
|
arecord -l # list capture devices
|
||||||
|
```
|
||||||
|
|
||||||
|
### Volume control fails
|
||||||
|
|
||||||
|
Check mixer controls:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
amixer scontrols
|
||||||
|
amixer sget Master
|
||||||
|
```
|
||||||
|
|
||||||
|
### Crackling or distortion
|
||||||
|
|
||||||
|
Try different buffer sizes by adjusting format:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const player = await Buzz.player(undefined, {
|
||||||
|
sampleRate: 16000,
|
||||||
|
channels: 1,
|
||||||
|
format: "S16_LE"
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Device already in use
|
||||||
|
|
||||||
|
Only one process can use an ALSA device at a time. Stop other audio applications or use PulseAudio/PipeWire for mixing.
|
||||||
|
|
||||||
|
## Design Philosophy
|
||||||
|
|
||||||
|
- **Simple by default** - `player()` and `recorder()` work out of the box without arguments
|
||||||
|
- **Streaming-first** - Built for real-time audio (AI voice, telephony, WebRTC)
|
||||||
|
- **Type-safe** - Namespace types provide autocomplete and compile-time safety
|
||||||
|
- **Flexible** - Support for files, tones, and streams
|
||||||
|
- **Minimal dependencies** - Uses standard ALSA tools, no native bindings
|
||||||
|
|
||||||
|
## Performance
|
||||||
|
|
||||||
|
- **Latency**: ~50-100ms for streaming playback (depends on buffering)
|
||||||
|
- **CPU**: Minimal overhead (subprocess spawning + pipe I/O)
|
||||||
|
- **Memory**: Efficient streaming (no need to load entire files)
|
||||||
|
- **Voice detection**: `calculateRMS()` is fast (~1µs per chunk on modern hardware)
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [ALSA documentation](https://www.alsa-project.org/wiki/Main_Page)
|
||||||
|
- [Bun subprocess API](https://bun.sh/docs/api/spawn)
|
||||||
|
- [Audio sample formats](https://en.wikipedia.org/wiki/Audio_bit_depth)
|
||||||
|
|
@ -12,13 +12,9 @@ import {
|
||||||
type FileRecording as FileRecordingType,
|
type FileRecording as FileRecordingType,
|
||||||
} from "./utils.js"
|
} from "./utils.js"
|
||||||
|
|
||||||
const defaultPlayer = (format?: AudioFormatType) => PlayerClass.create({ format })
|
const player = (label?: string, format?: AudioFormatType) => PlayerClass.create({ label, format })
|
||||||
|
|
||||||
const player = (label: string, format?: AudioFormatType) => PlayerClass.create({ label, format })
|
const recorder = (label?: string, format?: AudioFormatType) =>
|
||||||
|
|
||||||
const defaultRecorder = (format?: AudioFormatType) => RecorderClass.create({ format })
|
|
||||||
|
|
||||||
const recorder = (label: string, format?: AudioFormatType) =>
|
|
||||||
RecorderClass.create({ label, format })
|
RecorderClass.create({ label, format })
|
||||||
|
|
||||||
const getVolumeControl = async (cardNumber?: number): Promise<string> => {
|
const getVolumeControl = async (cardNumber?: number): Promise<string> => {
|
||||||
|
|
@ -85,9 +81,7 @@ const getVolume = async (label?: string): Promise<number> => {
|
||||||
|
|
||||||
const Buzz = {
|
const Buzz = {
|
||||||
listDevices,
|
listDevices,
|
||||||
defaultPlayer,
|
|
||||||
player,
|
player,
|
||||||
defaultRecorder,
|
|
||||||
recorder,
|
recorder,
|
||||||
setVolume,
|
setVolume,
|
||||||
getVolume,
|
getVolume,
|
||||||
|
|
|
||||||
36
src/phone.ts
36
src/phone.ts
|
|
@ -36,7 +36,7 @@ type PhoneContext = {
|
||||||
|
|
||||||
type PhoneService = Service<typeof phoneMachine>
|
type PhoneService = Service<typeof phoneMachine>
|
||||||
|
|
||||||
const player = await Buzz.defaultPlayer()
|
const player = await Buzz.player()
|
||||||
let dialTonePlayback: Buzz.Playback | undefined
|
let dialTonePlayback: Buzz.Playback | undefined
|
||||||
|
|
||||||
export const runPhone = async (agentId: string, agentKey: string) => {
|
export const runPhone = async (agentId: string, agentKey: string) => {
|
||||||
|
|
@ -46,16 +46,18 @@ export const runPhone = async (agentId: string, agentKey: string) => {
|
||||||
using rotaryInUse = gpio.input(22, { pull: "up", debounce: 3 })
|
using rotaryInUse = gpio.input(22, { pull: "up", debounce: 3 })
|
||||||
using rotaryNumber = gpio.input(23, { pull: "up", debounce: 3 })
|
using rotaryNumber = gpio.input(23, { pull: "up", debounce: 3 })
|
||||||
|
|
||||||
await Buzz.setVolume(0.4)
|
await Buzz.setVolume(0.2)
|
||||||
log(`📞 Phone is ${hook.value ? "off hook" : "on hook"}`)
|
log(`📞 Phone is ${hook.value ? "off hook" : "on hook"}`)
|
||||||
|
|
||||||
|
playStartRing(ringer)
|
||||||
|
|
||||||
const phoneService = interpret(phoneMachine, () => {})
|
const phoneService = interpret(phoneMachine, () => {})
|
||||||
listenForPhoneEvents(phoneService, hook, rotaryInUse, rotaryNumber)
|
listenForPhoneEvents(phoneService, hook, rotaryInUse, rotaryNumber)
|
||||||
const baresip = await startBaresip(phoneService, hook, ringer)
|
const baresip = await startBaresip(phoneService, hook, ringer)
|
||||||
phoneService.send({ type: "config", baresip, agentId, agentKey, ringer })
|
phoneService.send({ type: "config", baresip, agentId, agentKey, ringer })
|
||||||
|
|
||||||
process.on("SIGINT", () => cleanup(baresip))
|
process.on("SIGINT", () => cleanup(baresip, ringer))
|
||||||
process.on("SIGTERM", () => cleanup(baresip))
|
process.on("SIGTERM", () => cleanup(baresip, ringer))
|
||||||
|
|
||||||
// Keep process running
|
// Keep process running
|
||||||
await new Promise(() => {})
|
await new Promise(() => {})
|
||||||
|
|
@ -134,9 +136,10 @@ const startBaresip = async (phoneService: PhoneService, hook: GPIO.Input, ringer
|
||||||
return baresip
|
return baresip
|
||||||
}
|
}
|
||||||
|
|
||||||
const cleanup = (baresip: Baresip) => {
|
const cleanup = (baresip: Baresip, ringer: GPIO.Output) => {
|
||||||
try {
|
try {
|
||||||
log("🛑 Shutting down, stopping agent process")
|
log("🛑 Shutting down, stopping agent process")
|
||||||
|
playExitRing(ringer)
|
||||||
baresip.kill()
|
baresip.kill()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error("Error during shutdown:", error)
|
log.error("Error during shutdown:", error)
|
||||||
|
|
@ -189,7 +192,7 @@ const startListening = (service: Service<typeof phoneMachine>, agent: Agent) =>
|
||||||
const abortAgent = new AbortController()
|
const abortAgent = new AbortController()
|
||||||
|
|
||||||
new Promise<void>(async (resolve) => {
|
new Promise<void>(async (resolve) => {
|
||||||
const recorder = await Buzz.defaultRecorder()
|
const recorder = await Buzz.recorder()
|
||||||
const listenPlayback = recorder.start()
|
const listenPlayback = recorder.start()
|
||||||
let backgroundNoisePlayback: Buzz.Playback | undefined
|
let backgroundNoisePlayback: Buzz.Playback | undefined
|
||||||
let waitingForVoice = true
|
let waitingForVoice = true
|
||||||
|
|
@ -384,6 +387,27 @@ const digitIncrement = (ctx: PhoneContext) => {
|
||||||
return ctx
|
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 t = transition
|
||||||
const r = reduce
|
const r = reduce
|
||||||
const a = action
|
const a = action
|
||||||
|
|
|
||||||
|
|
@ -1,173 +0,0 @@
|
||||||
# Bun FFI Learnings
|
|
||||||
|
|
||||||
After researching GitHub examples and Bun's FFI documentation, here's what I found surprising and helpful.
|
|
||||||
|
|
||||||
## Surprising Discoveries
|
|
||||||
|
|
||||||
### 1. **String Handling is Simpler Than Expected**
|
|
||||||
I initially thought you'd need `CString` everywhere, but:
|
|
||||||
- For **args**: `FFIType.cstring` just needs `ptr(Buffer.from(str + "\0"))`
|
|
||||||
- For **returns**: `FFIType.cstring` automatically converts pointers to JS strings
|
|
||||||
- `CString` is mainly for **reading** C strings from pointers, not passing them
|
|
||||||
|
|
||||||
**Example from real code:**
|
|
||||||
```javascript
|
|
||||||
const str = Buffer.from("hello\0", "utf8");
|
|
||||||
myFunction(ptr(str)); // Clean and simple!
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. **No Type Wrappers Needed**
|
|
||||||
Unlike Node-FFI, Bun doesn't require defining structs or complex type wrappers. Just:
|
|
||||||
```javascript
|
|
||||||
add: {
|
|
||||||
args: [FFIType.i32, FFIType.i32],
|
|
||||||
returns: FFIType.i32,
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. **TinyCC JIT Compilation**
|
|
||||||
Bun embeds TinyCC and JIT-compiles C bindings on the fly. This means:
|
|
||||||
- 2-6x faster than Node-API
|
|
||||||
- Zero build step for type conversions
|
|
||||||
- Direct memory access without serialization
|
|
||||||
|
|
||||||
## Helpful Patterns
|
|
||||||
|
|
||||||
### Pattern 1: String Helper
|
|
||||||
```typescript
|
|
||||||
import { ptr } from "bun:ffi"
|
|
||||||
const cstr = (s: string) => ptr(Buffer.from(s + "\0"))
|
|
||||||
|
|
||||||
// Usage:
|
|
||||||
gpiod.open(cstr("/dev/gpiochip0"))
|
|
||||||
```
|
|
||||||
|
|
||||||
### Pattern 2: Resource Cleanup
|
|
||||||
Always use cleanup handlers:
|
|
||||||
```javascript
|
|
||||||
const cleanup = () => {
|
|
||||||
lib.symbols.release(resource)
|
|
||||||
lib.symbols.close(chip)
|
|
||||||
}
|
|
||||||
process.on("SIGINT", cleanup)
|
|
||||||
process.on("SIGTERM", cleanup)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Pattern 3: Destructuring Symbols
|
|
||||||
```javascript
|
|
||||||
const {
|
|
||||||
symbols: { functionName }
|
|
||||||
} = dlopen(path, { /* defs */ })
|
|
||||||
|
|
||||||
// Call directly:
|
|
||||||
functionName(arg1, arg2)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Common Mistakes to Avoid
|
|
||||||
|
|
||||||
1. **Don't forget null terminators** - `Buffer.from(str + "\0")` not `Buffer.from(str)`
|
|
||||||
2. **Pointer lifetime** - Keep TypedArrays alive while C code uses them
|
|
||||||
3. **Type mismatches** - `FFIType.i32` vs `FFIType.u32` matters!
|
|
||||||
4. **Missing cleanup** - C libraries don't have garbage collection
|
|
||||||
|
|
||||||
## Best Practices from Real Examples
|
|
||||||
|
|
||||||
1. **Use `suffix` for cross-platform library loading:**
|
|
||||||
```javascript
|
|
||||||
import { suffix } from "bun:ffi"
|
|
||||||
dlopen(`libname.${suffix}`, { /* ... */ })
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Check for null on resource creation:**
|
|
||||||
```javascript
|
|
||||||
const chip = lib.gpiod_chip_open(cstr(path))
|
|
||||||
if (!chip) {
|
|
||||||
console.error("Failed to open")
|
|
||||||
process.exit(1)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Free configs after use:**
|
|
||||||
```javascript
|
|
||||||
const config = lib.create_config()
|
|
||||||
// ... use config ...
|
|
||||||
lib.free_config(config) // Don't leak!
|
|
||||||
```
|
|
||||||
|
|
||||||
## What Makes Bun FFI Special
|
|
||||||
|
|
||||||
- **Performance**: JIT compilation beats traditional FFI
|
|
||||||
- **Simplicity**: No build tools, no gyp, no node-gyp nightmares
|
|
||||||
- **TypeScript native**: Works seamlessly with TS type system
|
|
||||||
- **Built-in**: Ships with Bun, zero dependencies
|
|
||||||
|
|
||||||
## Hard-Won Lessons from GPIO Implementation
|
|
||||||
|
|
||||||
### 1. **Enum values MUST match the C header exactly**
|
|
||||||
We spent hours debugging because our constants were off by one:
|
|
||||||
```typescript
|
|
||||||
// WRONG - missing GPIOD_LINE_BIAS_AS_IS
|
|
||||||
export const GPIOD_LINE_BIAS_UNKNOWN = 1 // Actually should be 2!
|
|
||||||
export const GPIOD_LINE_BIAS_DISABLED = 2 // Actually should be 3!
|
|
||||||
export const GPIOD_LINE_BIAS_PULL_UP = 3 // Actually should be 4!
|
|
||||||
|
|
||||||
// CORRECT - includes AS_IS at position 1
|
|
||||||
export const GPIOD_LINE_BIAS_AS_IS = 1
|
|
||||||
export const GPIOD_LINE_BIAS_UNKNOWN = 2
|
|
||||||
export const GPIOD_LINE_BIAS_DISABLED = 3
|
|
||||||
export const GPIOD_LINE_BIAS_PULL_UP = 4
|
|
||||||
export const GPIOD_LINE_BIAS_PULL_DOWN = 5
|
|
||||||
```
|
|
||||||
**Lesson:** Always grep the header file for the complete enum, don't assume!
|
|
||||||
|
|
||||||
### 2. **Hardware debouncing requires correct constants**
|
|
||||||
With wrong constants, we were accidentally passing `BIAS_DISABLED` instead of `BIAS_PULL_UP`, which meant:
|
|
||||||
- No pull resistor (pin floated)
|
|
||||||
- Debouncing didn't work at all
|
|
||||||
- Got 6+ events per button press
|
|
||||||
|
|
||||||
After fixing: **Clean single events with 1ms debounce via kernel!**
|
|
||||||
|
|
||||||
### 3. **Edge detection is event-driven, not polling**
|
|
||||||
Don't poll `get_value()` in a loop! Use:
|
|
||||||
- `gpiod_line_request_wait_edge_events()` - blocks until interrupt
|
|
||||||
- `gpiod_line_request_read_edge_events()` - reads queued events
|
|
||||||
- Much more efficient, CPU sleeps until hardware event
|
|
||||||
|
|
||||||
### 4. **TypedArray to pointer needs `ptr()`**
|
|
||||||
When passing arrays to C functions:
|
|
||||||
```typescript
|
|
||||||
const offsets = new Uint32Array([21])
|
|
||||||
gpiod.gpiod_line_config_add_line_settings(
|
|
||||||
lineConfig,
|
|
||||||
ptr(offsets), // Need ptr() wrapper!
|
|
||||||
1,
|
|
||||||
lineSettings
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5. **Signal handling for clean shutdown**
|
|
||||||
Generators don't run `finally` blocks if abandoned. Need:
|
|
||||||
```typescript
|
|
||||||
let shouldExit = false
|
|
||||||
process.on("SIGINT", () => { shouldExit = true })
|
|
||||||
|
|
||||||
while (!shouldExit) {
|
|
||||||
const ret = wait_edge_events(request, 100_000_000) // Use timeout!
|
|
||||||
// ...
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6. **Button wiring determines logic**
|
|
||||||
- **GND button + pull-UP**: Press = FALLING edge (HIGH→LOW)
|
|
||||||
- **VCC button + pull-DOWN**: Press = RISING edge (LOW→HIGH)
|
|
||||||
|
|
||||||
Always check initial pin state to verify wiring!
|
|
||||||
|
|
||||||
## Resources Used
|
|
||||||
|
|
||||||
- Official Bun FFI docs: https://bun.com/docs/runtime/ffi
|
|
||||||
- libgpiod v2 C API: https://libgpiod.readthedocs.io/en/latest/core_api.html
|
|
||||||
- Python bindings examples: https://github.com/brgl/libgpiod/tree/master/bindings/python/examples
|
|
||||||
- Real examples: GitHub searches for bun FFI projects
|
|
||||||
- Community discussions: Bun issue tracker and HN threads
|
|
||||||
|
|
@ -115,12 +115,6 @@ async function stopAP() {
|
||||||
async function checkAndManageAP() {
|
async function checkAndManageAP() {
|
||||||
const connected = await isConnectedToWiFi()
|
const connected = await isConnectedToWiFi()
|
||||||
|
|
||||||
console.log(
|
|
||||||
`[checkAndManageAP] WiFi: ${connected ? "connected" : "disconnected"}, AP: ${
|
|
||||||
apRunning ? "running" : "stopped"
|
|
||||||
}`
|
|
||||||
)
|
|
||||||
|
|
||||||
if (connected && apRunning) {
|
if (connected && apRunning) {
|
||||||
console.log("[checkAndManageAP] WiFi connected and AP running → stopping AP")
|
console.log("[checkAndManageAP] WiFi connected and AP running → stopping AP")
|
||||||
await stopAP()
|
await stopAP()
|
||||||
|
|
@ -134,7 +128,7 @@ async function checkAndManageAP() {
|
||||||
const savedNetwork = await findAvailableSavedNetwork()
|
const savedNetwork = await findAvailableSavedNetwork()
|
||||||
if (savedNetwork) {
|
if (savedNetwork) {
|
||||||
console.log(
|
console.log(
|
||||||
`[checkAndManageAP] Found available saved network: ${savedNetwork}, attempting connection...`
|
`[checkAndManageAP] Found available saved network: ${savedNetwork}, attempting connection...`,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Try to connect first
|
// Try to connect first
|
||||||
|
|
@ -230,6 +224,12 @@ async function tryConnect(connectionName: string): Promise<boolean> {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initial check
|
// Initial check
|
||||||
|
const connected = await isConnectedToWiFi()
|
||||||
|
console.log(
|
||||||
|
`[checkAndManageAP] WiFi: ${connected ? "connected" : "disconnected"}, AP: ${
|
||||||
|
apRunning ? "running" : "stopped"
|
||||||
|
}`,
|
||||||
|
)
|
||||||
await checkAndManageAP()
|
await checkAndManageAP()
|
||||||
|
|
||||||
// Check periodically
|
// Check periodically
|
||||||
|
|
|
||||||
|
|
@ -16,11 +16,6 @@ export const IndexPage = () => (
|
||||||
</label>
|
</label>
|
||||||
<button type="submit">Connect to Network</button>
|
<button type="submit">Connect to Network</button>
|
||||||
</form>
|
</form>
|
||||||
<footer>
|
|
||||||
<small>
|
|
||||||
<a href="/logs">📋 View Service Logs</a>
|
|
||||||
</small>
|
|
||||||
</footer>
|
|
||||||
|
|
||||||
<script dangerouslySetInnerHTML={{__html: `
|
<script dangerouslySetInnerHTML={{__html: `
|
||||||
(async () => {
|
(async () => {
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,18 @@ export const Layout = ({ title, children, refresh }: LayoutProps) => (
|
||||||
<link rel="stylesheet" href="/pico.css" />
|
<link rel="stylesheet" href="/pico.css" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<main class="container">{children}</main>
|
<main class="container">
|
||||||
|
<nav>
|
||||||
|
<ul>
|
||||||
|
<li><strong>☎️ Phone</strong></li>
|
||||||
|
</ul>
|
||||||
|
<ul>
|
||||||
|
<li><a href="/">WiFi Setup</a></li>
|
||||||
|
<li><a href="/logs">Logs</a></li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,62 +1,40 @@
|
||||||
import { Layout } from "./Layout";
|
import { Layout } from "./Layout";
|
||||||
|
|
||||||
type LogsPageProps = {
|
type LogsPageProps = {
|
||||||
|
service: string;
|
||||||
logs: string;
|
logs: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const LogsPage = ({ logs }: LogsPageProps) => (
|
export const LogsPage = ({ service, logs }: LogsPageProps) => (
|
||||||
<Layout title="Service Logs">
|
<Layout title="Service Logs">
|
||||||
<h1>📋 Service Logs</h1>
|
<h1>📋 Service Logs</h1>
|
||||||
<p>
|
|
||||||
<small>
|
<div role="group">
|
||||||
<label>
|
<a
|
||||||
<input type="checkbox" id="auto-refresh" checked /> Auto-refresh
|
href="/logs?service=phone-ap"
|
||||||
</label>
|
role="button"
|
||||||
{" | "}
|
aria-current={service === "phone-ap" ? "true" : undefined}
|
||||||
<a href="/">← Back</a>
|
>
|
||||||
</small>
|
📡 WiFi AP
|
||||||
</p>
|
</a>
|
||||||
<pre>
|
<a
|
||||||
<code id="logs-content">{logs.trim()}</code>
|
href="/logs?service=phone-web"
|
||||||
|
role="button"
|
||||||
|
aria-current={service === "phone-web" ? "true" : undefined}
|
||||||
|
>
|
||||||
|
🌐 Web Server
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="/logs?service=phone"
|
||||||
|
role="button"
|
||||||
|
aria-current={service === "phone" ? "true" : undefined}
|
||||||
|
>
|
||||||
|
☎️ Phone App
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<pre style="margin-top: 1rem;">
|
||||||
|
<code>{logs.trim()}</code>
|
||||||
</pre>
|
</pre>
|
||||||
|
|
||||||
<script>{`
|
|
||||||
let interval = null;
|
|
||||||
const checkbox = document.getElementById('auto-refresh');
|
|
||||||
const logsContent = document.getElementById('logs-content');
|
|
||||||
|
|
||||||
async function refreshLogs() {
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/logs');
|
|
||||||
const data = await res.json();
|
|
||||||
logsContent.textContent = data.logs;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to refresh logs:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function startRefresh() {
|
|
||||||
if (interval) clearInterval(interval);
|
|
||||||
interval = setInterval(refreshLogs, 5000);
|
|
||||||
}
|
|
||||||
|
|
||||||
function stopRefresh() {
|
|
||||||
if (interval) {
|
|
||||||
clearInterval(interval);
|
|
||||||
interval = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
checkbox.addEventListener('change', (e) => {
|
|
||||||
if (e.target.checked) {
|
|
||||||
startRefresh();
|
|
||||||
} else {
|
|
||||||
stopRefresh();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Start auto-refresh by default
|
|
||||||
startRefresh();
|
|
||||||
`}</script>
|
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
#!/usr/bin/env bun
|
|
||||||
|
|
||||||
import { Hono } from "hono"
|
import { Hono } from "hono"
|
||||||
import { join } from "node:path"
|
import { join } from "node:path"
|
||||||
import { $ } from "bun"
|
import { $ } from "bun"
|
||||||
|
|
@ -36,30 +34,24 @@ app.get("/api/networks", async (c) => {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// API endpoint to get logs (for auto-refresh)
|
|
||||||
app.get("/api/logs", async (c) => {
|
|
||||||
try {
|
|
||||||
const logs =
|
|
||||||
await $`journalctl -u phone-ap.service -u phone-web.service -n 200 --no-pager`.text()
|
|
||||||
return c.json({ logs: logs.trim() })
|
|
||||||
} catch (error) {
|
|
||||||
return c.json({ logs: "", error: String(error) }, 500)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Main WiFi configuration page
|
// Main WiFi configuration page
|
||||||
app.get("/", (c) => {
|
app.get("/", (c) => {
|
||||||
return c.html(<IndexPage />)
|
return c.html(<IndexPage />)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Service logs with auto-refresh
|
// Service logs
|
||||||
app.get("/logs", async (c) => {
|
app.get("/logs", async (c) => {
|
||||||
|
const service = c.req.query("service") || "phone-ap"
|
||||||
|
const validServices = ["phone-ap", "phone-web", "phone"]
|
||||||
|
|
||||||
|
// Default to phone-ap if invalid service
|
||||||
|
const selectedService = validServices.includes(service) ? service : "phone-ap"
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const logs =
|
const logs = await $`journalctl -u ${selectedService}.service -n 200 --no-pager --no-hostname`.text()
|
||||||
await $`journalctl -u phone-ap.service -u phone-web.service -n 200 --no-pager`.text()
|
return c.html(<LogsPage service={selectedService} logs={logs} />)
|
||||||
return c.html(<LogsPage logs={logs} />)
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(`Failed to fetch logs: ${error}`)
|
return c.html(<LogsPage service={selectedService} logs={`Error loading logs: ${error}`} />)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -93,7 +85,8 @@ app.post("/save", async (c) => {
|
||||||
return response
|
return response
|
||||||
})
|
})
|
||||||
|
|
||||||
export default { port: 80, fetch: app.fetch }
|
const port = process.env.PORT ? Number(process.env.PORT) : 80
|
||||||
|
export default { port, fetch: app.fetch }
|
||||||
|
|
||||||
console.log("Server running on http://0.0.0.0:80")
|
console.log(`Server running on http://0.0.0.0:${port}`)
|
||||||
console.log("Access via WiFi or AP at http://phone.local")
|
console.log("Access via WiFi or AP at http://phone.local")
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ console.log("")
|
||||||
// Test 2: Create player
|
// Test 2: Create player
|
||||||
console.log("🔊 Creating default player...")
|
console.log("🔊 Creating default player...")
|
||||||
try {
|
try {
|
||||||
const player = await Buzz.defaultPlayer()
|
const player = await Buzz.player()
|
||||||
console.log("✅ Player created\n")
|
console.log("✅ Player created\n")
|
||||||
|
|
||||||
// Test 3: Play sound file
|
// Test 3: Play sound file
|
||||||
|
|
@ -42,7 +42,7 @@ try {
|
||||||
// Test 5: Create recorder
|
// Test 5: Create recorder
|
||||||
console.log("🎤 Creating default recorder...")
|
console.log("🎤 Creating default recorder...")
|
||||||
try {
|
try {
|
||||||
const recorder = await Buzz.defaultRecorder()
|
const recorder = await Buzz.recorder()
|
||||||
console.log("✅ Recorder created\n")
|
console.log("✅ Recorder created\n")
|
||||||
|
|
||||||
// Test 6: Stream recording with RMS
|
// Test 6: Stream recording with RMS
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,8 @@ const runPhoneSystem = async (agentId: string, apiKey: string) => {
|
||||||
console.log("📞 Phone System Starting\n")
|
console.log("📞 Phone System Starting\n")
|
||||||
await Buzz.setVolume(0.4)
|
await Buzz.setVolume(0.4)
|
||||||
|
|
||||||
const recorder = await Buzz.defaultRecorder()
|
const recorder = await Buzz.recorder()
|
||||||
const player = await Buzz.defaultPlayer()
|
const player = await Buzz.player()
|
||||||
|
|
||||||
const agent = new Agent({
|
const agent = new Agent({
|
||||||
agentId,
|
agentId,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import Buzz from "../buzz/index.ts"
|
import Buzz from "../buzz/index.ts"
|
||||||
import { join } from "path"
|
import { join } from "path"
|
||||||
import { random } from "./index.ts"
|
import { random } from "./index.ts"
|
||||||
|
import { log } from "console"
|
||||||
|
|
||||||
export class WaitingSounds {
|
export class WaitingSounds {
|
||||||
typingPlayback?: Buzz.Playback
|
typingPlayback?: Buzz.Playback
|
||||||
|
|
@ -38,39 +39,42 @@ export class WaitingSounds {
|
||||||
const playedSounds = new Set<string>()
|
const playedSounds = new Set<string>()
|
||||||
let dir: SoundDir | undefined
|
let dir: SoundDir | undefined
|
||||||
return new Promise<void>(async (resolve) => {
|
return new Promise<void>(async (resolve) => {
|
||||||
|
// Don't start playing speaking sounds until the operator stream has been silent for a bit
|
||||||
while (operatorStream.bufferEmptyFor < 1500) {
|
while (operatorStream.bufferEmptyFor < 1500) {
|
||||||
await Bun.sleep(100)
|
await Bun.sleep(100)
|
||||||
}
|
}
|
||||||
|
|
||||||
do {
|
do {
|
||||||
const lastSoundDir = dir
|
const lastSoundDir = dir
|
||||||
const value = Math.random() * 100
|
|
||||||
if (lastSoundDir === "body-noises") {
|
if (lastSoundDir === "body-noises") {
|
||||||
dir = "apology"
|
dir = "apology"
|
||||||
} else if (value > 99 && !lastSoundDir) {
|
|
||||||
dir = "body-noises"
|
|
||||||
} else if (value > 75 && !lastSoundDir) {
|
|
||||||
dir = "stalling"
|
|
||||||
} else {
|
} else {
|
||||||
dir = undefined
|
// sleep for 4-6 seconds
|
||||||
await Bun.sleep(1000)
|
await Bun.sleep(4000 + Math.random() * 2000)
|
||||||
|
const value = Math.random() * 100
|
||||||
|
|
||||||
|
if (value > 95) {
|
||||||
|
dir = "body-noises"
|
||||||
|
} else {
|
||||||
|
dir = "stalling"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (dir) {
|
const speakingSound = getSound(dir, Array.from(playedSounds))
|
||||||
const speakingSound = getSound(dir, Array.from(playedSounds))
|
this.speakingPlayback = await this.player.play(speakingSound)
|
||||||
this.speakingPlayback = await this.player.play(speakingSound)
|
playedSounds.add(speakingSound)
|
||||||
playedSounds.add(speakingSound)
|
await this.speakingPlayback.finished()
|
||||||
await this.speakingPlayback.finished()
|
|
||||||
}
|
|
||||||
} while (this.typingPlayback)
|
} while (this.typingPlayback)
|
||||||
resolve()
|
resolve()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async stop() {
|
async stop() {
|
||||||
|
log(`🛑 Stopping waiting sounds. Has typingPlayback: ${!!this.typingPlayback}`)
|
||||||
if (!this.typingPlayback) return
|
if (!this.typingPlayback) return
|
||||||
|
|
||||||
await Promise.all([this.typingPlayback.stop(), this.speakingPlayback?.finished()])
|
await Promise.all([this.typingPlayback.stop(), this.speakingPlayback?.finished()])
|
||||||
|
log("🛑 Waiting sounds stopped")
|
||||||
this.typingPlayback = undefined
|
this.typingPlayback = undefined
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user