From 28186bc0ce3244cb96ab1fc6ea988eca450ac774 Mon Sep 17 00:00:00 2001 From: Corey Johnson Date: Thu, 20 Nov 2025 18:18:47 -0800 Subject: [PATCH] wip --- scripts/bootstrap-services.ts | 98 ++++ scripts/bootstrap.ts | 81 +-- scripts/{cli.sh => cli.ts} | 0 scripts/deploy.ts | 20 +- scripts/setup-services.ts | 14 + src/agent/README.md | 6 +- src/buzz/README.md | 528 +++++++++++++++++++ src/buzz/index.ts | 10 +- src/phone.ts | 34 +- src/pins/FFI-LEARNINGS.md | 173 ------ src/services/ap-monitor.ts | 14 +- src/services/server/components/IndexPage.tsx | 5 - src/services/server/components/Layout.tsx | 13 +- src/services/server/components/LogsPage.tsx | 80 +-- src/services/server/server.tsx | 33 +- src/test-buzz.ts | 4 +- src/test-operator.ts | 4 +- 17 files changed, 744 insertions(+), 373 deletions(-) create mode 100644 scripts/bootstrap-services.ts rename scripts/{cli.sh => cli.ts} (100%) create mode 100644 scripts/setup-services.ts create mode 100644 src/buzz/README.md delete mode 100644 src/pins/FFI-LEARNINGS.md diff --git a/scripts/bootstrap-services.ts b/scripts/bootstrap-services.ts new file mode 100644 index 0000000..82b6c2b --- /dev/null +++ b/scripts/bootstrap-services.ts @@ -0,0 +1,98 @@ +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...") + + // 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 + const phoneServiceContent = `[Unit] +Description=Phone Application +After=network.target sound.target +Requires=sound.target + +[Service] +Type=simple +User=corey +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("\nStarting the services...") + await $`systemctl start phone-ap.service` + await $`systemctl start phone-web.service` + await $`systemctl start phone.service` + console.log("✓ Services started") +} diff --git a/scripts/bootstrap.ts b/scripts/bootstrap.ts index 905c196..d78bdbf 100755 --- a/scripts/bootstrap.ts +++ b/scripts/bootstrap.ts @@ -1,5 +1,4 @@ import { $ } from "bun" -import { writeFileSync } from "fs" console.log(` ========================================== @@ -16,8 +15,6 @@ if (process.getuid && process.getuid() !== 0) { // Get install directory from argument or use default 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}`) @@ -32,82 +29,6 @@ console.log(`✓ Dependencies installed`) console.log("\nInstalling 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(` -========================================== -✓ 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 +✅ Bootstrap complete! `) diff --git a/scripts/cli.sh b/scripts/cli.ts similarity index 100% rename from scripts/cli.sh rename to scripts/cli.ts diff --git a/scripts/deploy.ts b/scripts/deploy.ts index aa1a894..e4b6314 100755 --- a/scripts/deploy.ts +++ b/scripts/deploy.ts @@ -40,22 +40,10 @@ if (shouldBootstrap) { // 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() - -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") -} +// 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") console.log(` ✓ Deploy complete! diff --git a/scripts/setup-services.ts b/scripts/setup-services.ts new file mode 100644 index 0000000..f3f397a --- /dev/null +++ b/scripts/setup-services.ts @@ -0,0 +1,14 @@ +#!/usr/bin/env bun + +import { setupServices } from "./bootstrap-services" + +// Get install directory from argument or use default +const INSTALL_DIR = process.argv[2] || "/home/corey/phone" + +console.log(`Setting up services for: ${INSTALL_DIR}`) + +await setupServices(INSTALL_DIR) + +console.log(` +✓ Services configured and running! +`) diff --git a/src/agent/README.md b/src/agent/README.md index 7166f44..93a4020 100644 --- a/src/agent/README.md +++ b/src/agent/README.md @@ -19,7 +19,7 @@ const agent = new Agent({ }) // Set up event handlers -const player = await Buzz.defaultPlayer() +const player = await Buzz.player() let playback = player.playStream() agent.events.connect((event) => { @@ -43,7 +43,7 @@ agent.events.connect((event) => { await agent.start() // Continuously stream audio -const recorder = await Buzz.defaultRecorder() +const recorder = await Buzz.recorder() const recording = recorder.start() for await (const chunk of recording.stream()) { agent.sendAudio(chunk) @@ -53,7 +53,7 @@ for await (const chunk of recording.stream()) { ## VAD Pattern ```typescript -const recorder = await Buzz.defaultRecorder() +const recorder = await Buzz.recorder() const recording = recorder.start() const buffer = new RollingBuffer() diff --git a/src/buzz/README.md b/src/buzz/README.md new file mode 100644 index 0000000..1d9f419 --- /dev/null +++ b/src/buzz/README.md @@ -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) diff --git a/src/buzz/index.ts b/src/buzz/index.ts index fdccb1e..33d8c60 100644 --- a/src/buzz/index.ts +++ b/src/buzz/index.ts @@ -12,13 +12,9 @@ import { type FileRecording as FileRecordingType, } 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 defaultRecorder = (format?: AudioFormatType) => RecorderClass.create({ format }) - -const recorder = (label: string, format?: AudioFormatType) => +const recorder = (label?: string, format?: AudioFormatType) => RecorderClass.create({ label, format }) const getVolumeControl = async (cardNumber?: number): Promise => { @@ -85,9 +81,7 @@ const getVolume = async (label?: string): Promise => { const Buzz = { listDevices, - defaultPlayer, player, - defaultRecorder, recorder, setVolume, getVolume, diff --git a/src/phone.ts b/src/phone.ts index 47939c5..ab191ab 100644 --- a/src/phone.ts +++ b/src/phone.ts @@ -36,7 +36,7 @@ type PhoneContext = { type PhoneService = Service -const player = await Buzz.defaultPlayer() +const player = await Buzz.player() let dialTonePlayback: Buzz.Playback | undefined export const runPhone = async (agentId: string, agentKey: string) => { @@ -49,13 +49,15 @@ export const runPhone = async (agentId: string, agentKey: string) => { await Buzz.setVolume(0.4) 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)) - process.on("SIGTERM", () => cleanup(baresip)) + process.on("SIGINT", () => cleanup(baresip, ringer)) + process.on("SIGTERM", () => cleanup(baresip, ringer)) // Keep process running await new Promise(() => {}) @@ -134,9 +136,10 @@ const startBaresip = async (phoneService: PhoneService, hook: GPIO.Input, ringer return baresip } -const cleanup = (baresip: 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) @@ -189,7 +192,7 @@ const startListening = (service: Service, agent: Agent) => const abortAgent = new AbortController() new Promise(async (resolve) => { - const recorder = await Buzz.defaultRecorder() + const recorder = await Buzz.recorder() const listenPlayback = recorder.start() let backgroundNoisePlayback: Buzz.Playback | undefined let waitingForVoice = true @@ -384,6 +387,27 @@ const digitIncrement = (ctx: PhoneContext) => { 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 diff --git a/src/pins/FFI-LEARNINGS.md b/src/pins/FFI-LEARNINGS.md deleted file mode 100644 index 1e3adb0..0000000 --- a/src/pins/FFI-LEARNINGS.md +++ /dev/null @@ -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 diff --git a/src/services/ap-monitor.ts b/src/services/ap-monitor.ts index 6b9dab4..131126b 100644 --- a/src/services/ap-monitor.ts +++ b/src/services/ap-monitor.ts @@ -115,12 +115,6 @@ 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() @@ -134,7 +128,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 @@ -230,6 +224,12 @@ async function tryConnect(connectionName: string): Promise { } // Initial check +const connected = await isConnectedToWiFi() +console.log( + `[checkAndManageAP] WiFi: ${connected ? "connected" : "disconnected"}, AP: ${ + apRunning ? "running" : "stopped" + }`, +) await checkAndManageAP() // Check periodically diff --git a/src/services/server/components/IndexPage.tsx b/src/services/server/components/IndexPage.tsx index e258767..3a5cd66 100644 --- a/src/services/server/components/IndexPage.tsx +++ b/src/services/server/components/IndexPage.tsx @@ -16,11 +16,6 @@ export const IndexPage = () => ( - ); diff --git a/src/services/server/server.tsx b/src/services/server/server.tsx index d949c94..0fad238 100644 --- a/src/services/server/server.tsx +++ b/src/services/server/server.tsx @@ -1,5 +1,3 @@ -#!/usr/bin/env bun - import { Hono } from "hono" import { join } from "node:path" 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 app.get("/", (c) => { return c.html() }) -// Service logs with auto-refresh +// Service logs 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 phone-ap.service -u phone-web.service -n 200 --no-pager`.text() - return c.html() + const logs = await $`journalctl -u ${selectedService}.service -n 200 --no-pager --no-hostname`.text() + return c.html() } catch (error) { - throw new Error(`Failed to fetch logs: ${error}`) + return c.html() } }) @@ -93,7 +85,8 @@ app.post("/save", async (c) => { 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") diff --git a/src/test-buzz.ts b/src/test-buzz.ts index 3dffde2..2851fa2 100755 --- a/src/test-buzz.ts +++ b/src/test-buzz.ts @@ -21,7 +21,7 @@ console.log("") // Test 2: Create player console.log("🔊 Creating default player...") try { - const player = await Buzz.defaultPlayer() + const player = await Buzz.player() 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.defaultRecorder() + const recorder = await Buzz.recorder() console.log("✅ Recorder created\n") // Test 6: Stream recording with RMS diff --git a/src/test-operator.ts b/src/test-operator.ts index dc04b9b..5a56b8a 100755 --- a/src/test-operator.ts +++ b/src/test-operator.ts @@ -7,8 +7,8 @@ const runPhoneSystem = async (agentId: string, apiKey: string) => { console.log("📞 Phone System Starting\n") await Buzz.setVolume(0.4) - const recorder = await Buzz.defaultRecorder() - const player = await Buzz.defaultPlayer() + const recorder = await Buzz.recorder() + const player = await Buzz.player() const agent = new Agent({ agentId,