diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..25fa621 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "typescript.tsdk": "node_modules/typescript/lib" +} diff --git a/baresip/accounts b/baresip/accounts index 39cce39..8493601 100644 --- a/baresip/accounts +++ b/baresip/accounts @@ -1 +1 @@ -;auth_pass=zgm-kwx2bug5hwf3YGF;unregister_on_exit=yes;regint=300 \ No newline at end of file +;auth_pass=zgm-kwx2bug5hwf3YGF;unregister_on_exit=yes \ No newline at end of file diff --git a/baresip/config b/baresip/config index 9485572..28e956c 100644 --- a/baresip/config +++ b/baresip/config @@ -1,71 +1,26 @@ -# -# baresip configuration -# - -#------------------------------------------------------------------------------ - -# Core -poll_method epoll # poll, select, epoll .. -ring_aufile none - -# Call +call_max_calls 4 call_local_timeout 120 -call_max_calls 4 # Audio -audio_player alsa,default -audio_source alsa,default -audio_alert none -audio_alert_enable no -audio_level no -ausrc_format s16 # s16, float, .. -auplay_format s16 # s16, float, .. -auenc_format s16 # s16, float, .. -audec_format s16 # s16, float, .. -audio_buffer 20-160 # ms +audio_player alsa,default +audio_source alsa,default +audio_alert alsa,default -# AVT - Audio/Video Transport -rtp_tos 184 -rtcp_mux no -jitter_buffer_delay 5-10 # frames -rtp_stats no +ring_aufile /dev/null - -#------------------------------------------------------------------------------ # Modules +#------------------------------------------------------------------------------ -module_path /usr/lib/baresip/modules - -# UI Modules -#module stdio.so - -# Audio codec Modules (in order) -module g711.so +module_path /usr/lib/baresip/modules +# Audio codec Modules +module g711.so # Audio driver Modules -module alsa.so +module alsa.so -# Media NAT modules -module stun.so -module turn.so -module ice.so - -module httpd.so - -#------------------------------------------------------------------------------ -# Temporary Modules (loaded then unloaded) - -module_tmp uuid.so -module_tmp account.so - - -#------------------------------------------------------------------------------ # Application Modules +module_app account.so +module_app menu.so -module_app contact.so -module_app debug_cmd.so -module_app menu.so - - -http_listen 0.0.0.0:8000 # httpd - HTTP Serve \ No newline at end of file +module httpd.so \ No newline at end of file diff --git a/bun.lock b/bun.lock index b555245..0fb9ba4 100644 --- a/bun.lock +++ b/bun.lock @@ -6,10 +6,11 @@ "dependencies": { "hono": "^4.10.4", "openai": "^6.9.0", - "robot3": "^1.2.0", + "robot3": "1.1.1", }, "devDependencies": { "@types/bun": "latest", + "prettier": "^3.6.2", }, "peerDependencies": { "typescript": "^5", @@ -21,17 +22,19 @@ "@types/node": ["@types/node@24.10.1", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ=="], - "@types/react": ["@types/react@19.2.5", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-keKxkZMqnDicuvFoJbzrhbtdLSPhj/rZThDlKWCDbgXmUg0rEUFtRssDXKYmtXluZlIqiC5VqkCgRwzuyLHKHw=="], + "@types/react": ["@types/react@19.2.6", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-p/jUvulfgU7oKtj6Xpk8cA2Y1xKTtICGpJYeJXz2YVO2UcvjQgeRMLDGfDeqeRW2Ta+0QNFwcc8X3GH8SxZz6w=="], "bun-types": ["bun-types@1.3.2", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-i/Gln4tbzKNuxP70OWhJRZz1MRfvqExowP7U6JKoI8cntFrtxg7RJK3jvz7wQW54UuvNC8tbKHHri5fy74FVqg=="], - "csstype": ["csstype@3.2.2", "", {}, "sha512-D80T+tiqkd/8B0xNlbstWDG4x6aqVfO52+OlSUNIdkTvmNw0uQpJLeos2J/2XvpyidAFuTPmpad+tUxLndwj6g=="], + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], "hono": ["hono@4.10.6", "", {}, "sha512-BIdolzGpDO9MQ4nu3AUuDwHZZ+KViNm+EZ75Ae55eMXMqLVhDFqEMXxtUe9Qh8hjL+pIna/frs2j6Y2yD5Ua/g=="], - "openai": ["openai@6.9.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-n2sJRYmM+xfJ0l3OfH8eNnIyv3nQY7L08gZQu3dw6wSdfPtKAk92L83M2NIP5SS8Cl/bsBBG3yKzEOjkx0O+7A=="], + "openai": ["openai@6.9.1", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-vQ5Rlt0ZgB3/BNmTa7bIijYFhz3YBceAA3Z4JuoMSBftBF9YqFHIEhZakSs+O/Ad7EaoEimZvHxD5ylRjN11Lg=="], - "robot3": ["robot3@1.2.0", "", {}, "sha512-Xin8KHqCKrD9Rqk1ZzZQYjsb6S9DRggcfwBqnVPeM3DLtNCJLxWWTrPJDYm3E+ZiTO7H3VMdgyPSkIbuYnYP2Q=="], + "prettier": ["prettier@3.6.2", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ=="], + + "robot3": ["robot3@1.1.1", "", {}, "sha512-kuD0oQg2KUE74FCQ1a5uoRsEJ/bUKrU1D3vnluop9X7LSiGLndejQgjUEcMqJMVzUA836HSXhtY7XNtQiPTCLQ=="], "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], diff --git a/package.json b/package.json index 8c0ca93..cc164c2 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,8 @@ "start": "bun run src/operator.ts" }, "devDependencies": { - "@types/bun": "latest" + "@types/bun": "latest", + "prettier": "^3.6.2" }, "peerDependencies": { "typescript": "^5" @@ -15,7 +16,7 @@ "dependencies": { "hono": "^4.10.4", "openai": "^6.9.0", - "robot3": "^1.2.0" + "robot3": "1.1.1" }, "prettier": { "semi": false, diff --git a/scripts/bootstrap-services.ts b/scripts/bootstrap-services.ts new file mode 100644 index 0000000..1a26163 --- /dev/null +++ b/scripts/bootstrap-services.ts @@ -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") +} diff --git a/scripts/bootstrap.ts b/scripts/bootstrap.ts index de73d65..63e3180 100755 --- a/scripts/bootstrap.ts +++ b/scripts/bootstrap.ts @@ -1,5 +1,4 @@ import { $ } from "bun" -import { writeFileSync } from "fs" console.log(` ========================================== @@ -15,96 +14,22 @@ 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" +const defaultUser = process.env.USER || "corey" +const INSTALL_DIR = process.argv[2] || `/home/${defaultUser}/phone` console.log(`Install directory: ${INSTALL_DIR}`) -console.log("\nStep 1: Ensuring directory exists...") +console.log("\nEnsuring directory exists...") await $`mkdir -p ${INSTALL_DIR}` console.log(`✓ Directory ready: ${INSTALL_DIR}`) -console.log("\nStep 2: Installing dependencies...") +console.log("\nInstalling dependencies...") await $`cd ${INSTALL_DIR} && bun install` console.log(`✓ Dependencies installed`) -console.log("\nStep 3: Installing systemd services...") -// Find where bun is installed -const bunPath = await $`which bun` - .quiet() - .nothrow() - .text() - .then((p) => p.trim()) -if (!bunPath) { - console.error("Error: bun not found in PATH. Please ensure bun is available system-wide.") - process.exit(1) -} -console.log(`Using bun at: ${bunPath}`) - -// Create AP monitor service -const apServiceContent = `[Unit] -Description=Phone WiFi AP Monitor -After=network.target -Before=phone-web.service - -[Service] -Type=simple -ExecStart=${bunPath} ${INSTALL_DIR}/services/ap-monitor.ts -Restart=on-failure -RestartSec=5 -StandardOutput=journal -StandardError=journal - -[Install] -WantedBy=multi-user.target -` -writeFileSync(AP_SERVICE_FILE, apServiceContent, "utf8") -console.log("✓ Created phone-ap.service") - -// Create web server service -const webServiceContent = `[Unit] -Description=Phone Web Server -After=network.target phone-ap.service - -[Service] -Type=simple -ExecStart=${bunPath} ${INSTALL_DIR}/services/server/server.tsx -WorkingDirectory=${INSTALL_DIR} -Restart=on-failure -RestartSec=5 -StandardOutput=journal -StandardError=journal - -[Install] -WantedBy=multi-user.target -` -writeFileSync(WEB_SERVICE_FILE, webServiceContent, "utf8") -console.log("✓ Created phone-web.service") - -await $`systemctl daemon-reload` -await $`systemctl enable phone-ap.service` -await $`systemctl enable phone-web.service` -console.log("✓ Services enabled") - -console.log("\nStep 4: Starting the services...") -await $`systemctl start phone-ap.service` -await $`systemctl start phone-web.service` -console.log("✓ Services started") +console.log("\nInstalling Baresip...") +await $`sudo apt install -y baresip` 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..ac7a03d 100755 --- a/scripts/deploy.ts +++ b/scripts/deploy.ts @@ -2,8 +2,9 @@ import { $ } from "bun" +const defaultUser = process.env.USER ?? "corey" const PI_HOST = process.env.PI_HOST ?? "phone.local" -const PI_DIR = process.env.PI_DIR ?? "/home/corey/phone" +const PI_DIR = process.env.PI_DIR ?? `/home/${defaultUser}/phone` // Parse command line arguments const shouldBootstrap = process.argv.includes("--bootstrap") @@ -40,22 +41,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..11033a4 --- /dev/null +++ b/scripts/setup-services.ts @@ -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! +`) diff --git a/sounds/stalling/sigh2.wav b/sounds/stalling/sigh2.wav deleted file mode 100644 index 365a6d3..0000000 Binary files a/sounds/stalling/sigh2.wav and /dev/null differ diff --git a/sounds/typing/typing2.wav b/sounds/typing/typing.wav similarity index 100% rename from sounds/typing/typing2.wav rename to sounds/typing/typing.wav diff --git a/sounds/typing/typing1.wav b/sounds/typing/typing1.wav deleted file mode 100644 index 4b48c62..0000000 Binary files a/sounds/typing/typing1.wav and /dev/null differ 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/agent/index.ts b/src/agent/index.ts index a03a95a..913f2a2 100644 --- a/src/agent/index.ts +++ b/src/agent/index.ts @@ -255,7 +255,7 @@ export class Agent { }) } - #handleClose = (): void => { + #handleClose = (event: CloseEvent): void => { this.#cleanup() this.events.emit({ type: "disconnected" }) } 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 f70d0b8..33d8c60 100644 --- a/src/buzz/index.ts +++ b/src/buzz/index.ts @@ -1,20 +1,21 @@ -import { Player } from "./player.js" -import { Recorder } from "./recorder.js" +import { Player as PlayerClass } from "./player.js" +import { Recorder as RecorderClass } from "./recorder.js" import { listDevices, calculateRMS, findDeviceByLabel, - type AudioFormat, - type Device, + type AudioFormat as AudioFormatType, + type Device as DeviceType, + type Playback as PlaybackType, + type StreamingPlayback as StreamingPlaybackType, + type StreamingRecording as StreamingRecordingType, + type FileRecording as FileRecordingType, } from "./utils.js" -const defaultPlayer = (format?: AudioFormat) => Player.create({ format }) +const player = (label?: string, format?: AudioFormatType) => PlayerClass.create({ label, format }) -const player = (label: string, format?: AudioFormat) => Player.create({ label, format }) - -const defaultRecorder = (format?: AudioFormat) => Recorder.create({ format }) - -const recorder = (label: string, format?: AudioFormat) => Recorder.create({ label, format }) +const recorder = (label?: string, format?: AudioFormatType) => + RecorderClass.create({ label, format }) const getVolumeControl = async (cardNumber?: number): Promise => { const output = cardNumber @@ -80,16 +81,20 @@ const getVolume = async (label?: string): Promise => { const Buzz = { listDevices, - defaultPlayer, player, - defaultRecorder, recorder, setVolume, getVolume, calculateRMS, } +declare namespace Buzz { + export type Playback = PlaybackType + export type StreamingPlayback = StreamingPlaybackType + export type StreamingRecording = StreamingRecordingType + export type FileRecording = FileRecordingType + export type Player = PlayerClass + export type Recorder = RecorderClass +} + export default Buzz -export type { Device, AudioFormat } -export { type Player } from "./player.js" -export { type Recorder } from "./recorder.js" diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..eeedf87 --- /dev/null +++ b/src/main.ts @@ -0,0 +1,26 @@ +import { runPhone } from "./phone" + +const apiKey = process.env.ELEVEN_API_KEY +const agentId = process.env.ELEVEN_AGENT_ID + +if (!apiKey) { + console.error("❌ Error: ELEVEN_API_KEY environment variable is required") + process.exit(1) +} + +if (!agentId) { + console.error( + "❌ Error: ELEVEN_AGENT_ID environELEVEN_AGENT_ID=agent_5601k4taw2cvfjzrz6snxpgeh7x8 ELEVEN_API_KEY=sk_0313740f112c5992cb62ed96c974ab19b5916f1ea172471fment variable is required", + ) + console.error(" Create an agent at https://elevenlabs.io/app/conversational-ai") + process.exit(1) +} + +console.log(`☎️ Starting phone with pid=${process.pid}`) +try { + await runPhone(agentId, apiKey) +} catch (error) { + console.error(`❌ Error starting phone: ${(error as Error).message}`) + process.exit(1) +} +console.log(`👋 Goodbye!`) diff --git a/src/phone.ts b/src/phone.ts index c2f677b..a1f5ab3 100644 --- a/src/phone.ts +++ b/src/phone.ts @@ -1,15 +1,24 @@ -import { d, reduce, createMachine, state, transition, interpret, guard } from "robot3" +import { + d, + reduce, + createMachine, + state, + transition, + interpret, + action, + invoke, + type Service, +} from "robot3" import { Baresip } from "./sip" -import { log } from "./utils/log" +import log from "./utils/log" import { sleep } from "bun" -import { processStderr, processStdout } from "./utils/stdio" import Buzz from "./buzz" import { join } from "path" import GPIO from "./pins" import { Agent } from "./agent" import { searchWeb } from "./agent/tools" - -// TODO: Kill baresip process on exit +import { ring } from "./utils" +import { getSound, WaitingSounds } from "./utils/waiting-sounds" type CancelableTask = () => void @@ -17,422 +26,432 @@ type PhoneContext = { lastError?: string peer?: string numberDialed: number - cancelDialTone?: CancelableTask cancelRinger?: CancelableTask baresip: Baresip - startAgent: () => CancelableTask - cancelAgent?: CancelableTask + stopAgent?: CancelableTask + ringer: GPIO.Output + agentId: string + agentKey: string } -const gpio = new GPIO() -using ringer = gpio.output(17, { resetOnClose: true }) -using hook = gpio.input(27, { pull: "up", debounce: 3 }) -using rotaryInUse = gpio.input(22, { pull: "up", debounce: 3 }) -using rotaryNumber = gpio.input(23, { pull: "up", debounce: 3 }) +type PhoneService = Service -export const startPhone = async (agentId: string, apiKey: string) => { - await Buzz.setVolume(0.4) - log.info(`📞 Hook ${hook.value}`) +const player = await Buzz.player() +let dialTonePlayback: Buzz.Playback | undefined - let digit = 0 +export const runPhone = async (agentId: string, agentKey: string) => { + const gpio = new GPIO() + using ringer = gpio.output(17, { resetOnClose: true }) + using hook = gpio.input(27, { pull: "up", debounce: 3 }) + using rotaryInUse = gpio.input(22, { pull: "up", debounce: 3 }) + using rotaryNumber = gpio.input(23, { pull: "up", debounce: 3 }) - hook.onChange((event) => { - const type = event.value == 0 ? "hang_up" : "pick_up" - log.info(`📞 Hook ${event.value} sending ${type}`) - if (type === "hang_up") { - ringer.value = 1 - } else { - ringer.value = 0 - } - }) + await Buzz.setVolume(0.2) + log(`📞 Phone is ${hook.value ? "off hook" : "on hook"}`) - rotaryInUse.onChange((event) => { - if (event.value === 0) { - digit = 0 - } else { - log.info(`📞 Dialed digit: ${digit}`) - } - }) + playStartRing(ringer) - rotaryNumber.onChange((event) => { - if (event.value === 1) { - digit += 1 - } - }) + const phoneService = interpret(phoneMachine, () => {}) + listenForPhoneEvents(phoneService, hook, rotaryInUse, rotaryNumber) + const baresip = await startBaresip(phoneService, hook, ringer) + phoneService.send({ type: "config", baresip, agentId, agentKey, ringer }) + + process.on("SIGINT", () => cleanup(baresip, ringer)) + process.on("SIGTERM", () => cleanup(baresip, ringer)) // Keep process running await new Promise(() => {}) } -const apiKey = process.env.ELEVEN_API_KEY -const agentId = process.env.ELEVEN_AGENT_ID +const listenForPhoneEvents = ( + phoneService: PhoneService, + hook: GPIO.Input, + rotaryInUse: GPIO.Input, + rotaryNumber: GPIO.Input, +) => { + hook.onChange((event) => { + const type = event.value == 0 ? "hang-up" : "pick-up" + log(`📞 Hook ${event.value} sending ${type}`) + phoneService.send({ type }) + }) -if (!apiKey) { - console.error("❌ Error: ELEVEN_API_KEY environment variable is required") - process.exit(1) + rotaryInUse.onChange((event) => { + if (event.value === 0) { + phoneService.send({ type: "dial-start" }) + } else { + phoneService.send({ type: "dial-stop" }) + } + }) + + rotaryNumber.onChange((event) => { + if (event.value === 1) { + phoneService.send({ type: "digit_increment" }) + } + }) } -if (!agentId) { - console.error( - "❌ Error: ELEVEN_AGENT_ID environELEVEN_AGENT_ID=agent_5601k4taw2cvfjzrz6snxpgeh7x8 ELEVEN_API_KEY=sk_0313740f112c5992cb62ed96c974ab19b5916f1ea172471fment variable is required" - ) - console.error(" Create an agent at https://elevenlabs.io/app/conversational-ai") - process.exit(1) +const startBaresip = async (phoneService: PhoneService, hook: GPIO.Input, ringer: GPIO.Output) => { + const baresipConfig = join(import.meta.dir, "..", "baresip") + const baresip = new Baresip(["/usr/bin/baresip", "-v", "-f", baresipConfig]) + + baresip.registrationSuccess.connect(async () => { + log("🐻 server connected") + if (hook.value === 0) { + phoneService.send({ type: "initialized" }) + } else { + phoneService.send({ type: "pick-up" }) + } + }) + + baresip.callReceived.connect(({ contact }) => { + log(`🐻 incoming call from ${contact}`) + phoneService.send({ type: "incoming-call", from: contact }) + }) + + baresip.callEstablished.connect(({ contact }) => { + log(`🐻 call established with ${contact}`) + phoneService.send({ type: "answered" }) + }) + + baresip.hungUp.connect(() => { + log("🐻 call hung up") + phoneService.send({ type: "remote-hang-up" }) + }) + + baresip.connect().catch((error) => { + log.error("🐻 connection error:", error) + phoneService.send({ type: "error", message: error.message }) + }) + + baresip.error.connect(async ({ message }) => { + log.error("🐻 error:", message) + phoneService.send({ type: "error", message }) + for (let i = 0; i < 4; i++) { + await ring(ringer, 500) + await sleep(250) + } + process.exit(1) + }) + + return baresip } -await startPhone(agentId, apiKey) - -const startBaresip = async (hook: GPIO.InputPin) => { - // const baresipConfig = join(import.meta.dir, "..", "baresip") - // const baresip = new Baresip(["/usr/bin/baresip", "-v", "-f", baresipConfig]) - - // baresip.registrationSuccess.connect(async () => { - // log.info("🐻 server connected") - // const result = await gpio.get(pins.hook) - // if (result.state === "low") { - // phoneService.send({ type: "initialized" }) - // } else { - // phoneService.send({ type: "pick_up" }) - // } - // }) - - // baresip.callReceived.connect(({ contact }) => { - // log.info(`🐻 incoming call from ${contact}`) - // phoneService.send({ type: "incoming_call", from: contact }) - // }) - - // baresip.callEstablished.connect(({ contact }) => { - // log.info(`🐻 call established with ${contact}`) - // phoneService.send({ type: "answered" }) - // }) - - // baresip.hungUp.connect(() => { - // log.info("🐻 call hung up") - // phoneService.send({ type: "remote_hang_up" }) - // }) - - // baresip.connect().catch((error) => { - // log.error("🐻 connection error:", error) - // phoneService.send({ type: "error", message: error.message }) - // }) - - // baresip.error.connect(async ({ message }) => { - // log.error("🐻 error:", message) - // phoneService.send({ type: "error", message }) - // for (let i = 0; i < 4; i++) { - // await ring(500) - // await sleep(250) - // } - // process.exit(1) - // }) - - // const agent = new Agent({ - // agentId, - // apiKey, - // tools: { - // search_web: (args: { query: string }) => searchWeb(args.query), - // }, - // }) +const cleanup = (baresip: Baresip, ringer: GPIO.Output) => { + try { + log("🛑 Shutting down, stopping agent process") + playExitRing(ringer) + baresip.kill() + } catch (error) { + log.error("Error during shutdown:", error) + } finally { + process.exit(0) + } } -// handleAgentEvents(agent) +const handleError = (ctx: PhoneContext, event: { type: "error"; message?: string }) => { + ctx.lastError = event.message + log.error(`Phone Error: ${event.message}`) + return ctx +} -// const startAgent = () => { -// log.info("☎️ Starting agent conversation") +const config = ( + ctx: PhoneContext, + event: { baresip: Baresip; agentId: string; agentKey: string; ringer: GPIO.Output }, +) => { + ctx.baresip = event.baresip + ctx.agentId = event.agentId + ctx.agentKey = event.agentKey + ctx.ringer = event.ringer + return ctx +} -// if (agentProcess?.stdin) { -// agentProcess.stdin.write("start\n") -// } else { -// log.error("☎️ No agent process stdin available") -// phoneService.send({ type: "remote_hang_up" }) -// } +const startAgent = (service: Service, ctx: PhoneContext) => { + let streamPlayback = player.playStream() -// return () => { -// log.info("☎️ Stopping agent conversation") -// if (agentProcess?.stdin) { -// agentProcess.stdin.write("stop\n") -// } -// } -// } + const agent = new Agent({ + agentId: ctx.agentId, + apiKey: ctx.agentKey, + tools: { + search_web: (args: { query: string }) => searchWeb(args.query), + }, + }) -// const context = (initial?: Partial): PhoneContext => ({ -// numberDialed: 0, -// baresip, -// startAgent, -// ...initial, -// }) + handleAgentEvents(service, agent, streamPlayback) + const stopListening = startListening(service, agent) -// const phoneMachine = createMachine( -// "initializing", -// // prettier-ignore -// { -// initializing: state( -// transition("initialized", "idle"), -// transition("pick_up", "ready", reduce(playDialTone)), -// transition("error", "fault", reduce(handleError))), -// idle: state( -// transition("incoming_call", "incoming", reduce(incomingCall)), -// transition("pick_up", "ready", reduce(playDialTone))), -// incoming: state( -// transition("remote_hang_up", "idle", reduce(stopRinger)), -// transition("pick_up", "connected", reduce(callAnswered))), -// connected: state( -// transition("remote_hang_up", "ready", reduce(playDialTone)), -// transition("hang_up", "idle", reduce(stopCall))), -// ready: state( -// transition("dial_start", "dialing", reduce(dialStart)), -// transition("dial_timeout", "aborted", reduce(stopDialTone)), -// transition("hang_up", "idle", reduce(stopDialTone))), -// dialing: state( -// transition("dial_stop", "outgoing", reduce(makeCall), guard((ctx) => !callAgentGuard(ctx))), -// transition("dial_stop", "connectedToAgent", reduce(makeAgentCall), guard((ctx) => callAgentGuard(ctx))), -// transition("digit_increment", "dialing", reduce(digitIncrement)), -// transition("hang_up", "idle", reduce(stopDialTone))), -// outgoing: state( -// transition("start_agent", "connectedToAgent"), -// transition("answered", "connected"), -// transition("hang_up", "idle", reduce(stopCall))), -// connectedToAgent: state( -// transition("remote_hang_up", "ready", reduce(stopAgent)), -// transition("hang_up", "idle", reduce(stopAgent))), -// aborted: state( -// transition("hang_up", "idle")), -// fault: state(), -// }, -// context -// ) + ctx.stopAgent = () => { + stopListening() + dialTonePlayback?.stop() + streamPlayback.stop() + } -// const phoneService = interpret(phoneMachine, () => {}) + return ctx +} -// d._onEnter = function (machine, to, state, prevState, event) { -// log.info(`📱 ${machine.current} -> ${to} (${JSON.stringify(event)})`) -// } +const startListening = (service: Service, agent: Agent) => { + const abortAgent = new AbortController() -// gpio.monitor(pins.hook, { bias: "pull-up" }, (event) => { -// const type = event.edge === "falling" ? "hang_up" : "pick_up" -// log.info(`📞 Hook ${event.edge} sending ${type}`) -// phoneService.send({ type }) -// }) + new Promise(async (resolve) => { + const recorder = await Buzz.recorder() + const listenPlayback = recorder.start() + let backgroundNoisePlayback: Buzz.Playback | undefined + let waitingForVoice = true + const maxPreBufferChunks = 4 // Keep ~1 second of audio before speech detection -// gpio.monitor(pins.rotaryInUse, { bias: "pull-up", throttleMs: 90 }, (event) => { -// const type = event.edge === "falling" ? "dial_start" : "dial_stop" -// log.debug(`📞 Rotary in-use ${event.edge} sending ${type}`) -// phoneService.send({ type }) -// }) + let preConnectionBuffer: Uint8Array[] = [] -// gpio.monitor(pins.rotaryNumber, { bias: "pull-up", throttleMs: 90 }, (event) => { -// if (event.edge !== "rising") return -// phoneService.send({ type: "digit_increment" }) -// }) + agent.events.connect(async (event) => { + if (event.type === "disconnected") abortAgent.abort() + }) -// // Graceful shutdown handling -// const cleanup = () => { -// log.info("🛑 Shutting down, stopping agent process") -// if (agentProcess?.stdin) { -// agentProcess.stdin.write("quit\n") -// } -// } + for await (const chunk of listenPlayback.stream()) { + if (abortAgent.signal.aborted) { + agent.stop() + listenPlayback.stop() + backgroundNoisePlayback?.stop() -// process.on("SIGINT", cleanup) -// process.on("SIGTERM", cleanup) -// process.on("exit", cleanup) -// } + resolve() + break + } -// const handleAgentEvents = (agent: Agent) => { -// agent.events.connect(async (event) => { -// switch (event.type) { -// case "connected": -// console.log("✅ Connected to AI agent\n") -// break + if (waitingForVoice) { + preConnectionBuffer.push(chunk) + if (preConnectionBuffer.length > maxPreBufferChunks) { + preConnectionBuffer.shift() + } -// case "user_transcript": -// console.log(`👤 You: ${event.transcript}`) -// break + const rms = Buzz.calculateRMS(chunk) + if (rms > 5000) { + dialTonePlayback?.stop() + service.send({ type: "start-agent" }) + waitingForVoice = false -// case "agent_response": -// console.log(`🤖 Agent: ${event.response}`) -// break + backgroundNoisePlayback = await player.play(getSound("background"), { repeat: true }) -// case "audio": -// await waitingIndicator.stop() -// const audioBuffer = Buffer.from(event.audioBase64, "base64") -// streamPlayback.write(audioBuffer) -// break + await agent.start() -// case "interruption": -// console.log("🛑 User interrupted") -// streamPlayback?.stop() -// streamPlayback = player.playStream() // Reset playback stream -// break + // Send pre-buffered audio + for (const chunk of preConnectionBuffer) agent.sendAudio(chunk) + preConnectionBuffer = [] + } + } else { + agent.sendAudio(chunk) + } + } + }) -// case "tool_call": -// waitingIndicator.start(streamPlayback) -// console.log(`🔧 Tool call: ${event.name}(${JSON.stringify(event.args)})`) -// break + return () => abortAgent.abort() +} -// case "tool_result": -// console.log(`✅ Tool result: ${JSON.stringify(event.result)}`) -// break +const handleAgentEvents = ( + service: Service, + agent: Agent, + streamPlayback: Buzz.StreamingPlayback, +) => { + const waitingIndicator = new WaitingSounds(player) -// case "tool_error": -// console.error(`❌ Tool error: ${event.error}`) -// break + agent.events.connect(async (event) => { + switch (event.type) { + case "connected": + log("🤖 Connected to AI agent\n") + break -// case "disconnected": -// console.log("\n👋 Conversation ended, returning to dialtone\n") -// streamPlayback?.stop() -// state = "WAITING_FOR_VOICE" -// phoneService.send({ type: "remote_hang_up" }) -// break + case "user_transcript": + log(`🤖 You: ${event.transcript}`) + break -// case "error": -// console.error("Agent error:", event.error) -// break + case "agent_response": + log(`🤖 Agent: ${event.response}`) + break -// case "ping": -// break + case "audio": + await waitingIndicator.stop() + const audioBuffer = Buffer.from(event.audioBase64, "base64") + streamPlayback.write(audioBuffer) + break -// default: -// console.log(`😵‍💫 ${event.type}`) -// break -// } -// }) -// } + case "interruption": + log("🤖 User interrupted") + streamPlayback?.stop() + streamPlayback = player.playStream() // Reset playback stream + break -// const incomingCallRing = (): CancelableTask => { -// let abortController = new AbortController() + case "tool_call": + waitingIndicator.start(streamPlayback) + log(`🤖 Tool call: ${event.name}(${JSON.stringify(event.args)})`) + break -// const playRingtone = async () => { -// while (!abortController.signal.aborted) { -// await ring(2000, abortController.signal) -// await sleep(4000) -// } -// } -// playRingtone().catch((error) => log.error("Ringer error:", error)) + case "tool_result": + log(`🤖 Tool result: ${JSON.stringify(event.result)}`) + break -// return () => abortController.abort() -// } + case "tool_error": + console.error(`❌ Tool error: ${event.error}`) + break -// const handleError = (ctx: PhoneContext, event: { type: "error"; message?: string }) => { -// ctx.lastError = event.message -// log.error(`Phone error: ${event.message}`) -// return ctx -// } + case "disconnected": + log(`🤖 👋 Conversation ended, returning to dialtone`) + streamPlayback?.stop() + service.send({ type: "remote-hang-up" }) + break -// const incomingCall = (ctx: PhoneContext, event: { type: "incoming_call"; from?: string }) => { -// ctx.peer = event.from -// ctx.cancelRinger = incomingCallRing() -// log.info(`Incoming call from ${event.from}`) + case "error": + log.error("🤖 Agent error:", event.error) + break -// return ctx -// } + case "ping": + break -// const stopRinger = (ctx: PhoneContext) => { -// ctx.cancelRinger?.() -// ctx.cancelRinger = undefined -// return ctx -// } + default: + log.debug(`😵 Unknown agent event ${event.type}`) + break + } + }) +} -// const playDialTone = (ctx: PhoneContext) => { -// const tone = new ToneGenerator() +const stopAgent = (ctx: PhoneContext) => { + ctx.stopAgent?.() + ctx.stopAgent = undefined + return ctx +} -// tone.loopTone([350, 440]) +const incomingCall = (ctx: PhoneContext, event: { type: "incoming-call"; from?: string }) => { + ctx.peer = event.from + return ctx +} -// ctx.cancelDialTone = () => { -// tone.stop() -// } +const hangUp = (ctx: PhoneContext) => { + console.log(`📞 Hanging up call`) + ctx.baresip.hangUp() +} -// return ctx -// } +const answerCall = (ctx: PhoneContext) => { + log(`📞 Answering call`) + ctx.baresip.accept() +} -// const playOutgoingTone = () => { -// const tone = new ToneGenerator() -// let canceled = false +const makeCall = async (ctx: PhoneContext) => { + log(`Dialing number: ${ctx.numberDialed}`) + if (ctx.numberDialed === 1) { + ctx.baresip.dial("+13476229543") + } else if (ctx.numberDialed === 2) { + ctx.baresip.dial("+18109643563") + } else { + log.error(`No contact for number dialed: ${ctx.numberDialed}`) + } -// const play = async () => { -// while (!canceled) { -// await tone.playTone([440, 480], 2000) -// await sleep(4000) -// } -// } + return ctx +} -// play().catch((error) => log.error("Outgoing tone error:", error)) +const startRinger = async (ctx: PhoneContext) => { + let abortController = new AbortController() + const keepRinging = async () => { + while (!abortController.signal.aborted) { + await ring(ctx.ringer, 2000, abortController.signal) + await sleep(4000) + } + } + keepRinging().catch((error) => log.error("Ringer error:", error)) -// return () => { -// tone.stop() -// canceled = true -// } -// } + ctx.cancelRinger = () => abortController.abort() -// const dialStart = (ctx: PhoneContext) => { -// ctx.numberDialed = 0 -// ctx = stopDialTone(ctx) + return ctx +} -// return ctx -// } +const stopRinger = (ctx: PhoneContext) => { + ctx.cancelRinger?.() + ctx.cancelRinger = undefined + return ctx +} -// const makeCall = (ctx: PhoneContext) => { -// log.info(`Dialing number: ${ctx.numberDialed}`) -// if (ctx.numberDialed === 1) { -// ctx.baresip.dial("+13476229543") -// } else if (ctx.numberDialed === 2) { -// ctx.baresip.dial("+18109643563") -// } else { -// const playTone = async () => { -// const tone = new ToneGenerator() -// await tone.playTone([900], 200) -// await tone.playTone([1350], 200) -// await tone.playTone([1750], 200) -// } -// playTone().catch((error) => log.error("Error playing tone:", error)) -// } +async function startDialToneAndAgent(this: any, ctx: PhoneContext) { + ctx = await startAgent(this, ctx) -// return ctx -// } + await dialTonePlayback?.stop() + dialTonePlayback = await player.playTone([350, 440], Infinity) -// const makeAgentCall = (ctx: PhoneContext) => { -// log.info(`Calling agent`) -// ctx.cancelAgent = ctx.startAgent() + return ctx +} -// return ctx -// } +const stopDialTone = () => { + dialTonePlayback?.stop() +} -// const callAgentGuard = (ctx: PhoneContext) => { -// return ctx.numberDialed === 10 -// } +const dialStart = (ctx: PhoneContext) => { + ctx.numberDialed = 0 + return ctx +} -// const callAnswered = (ctx: PhoneContext) => { -// ctx.baresip.accept() +const digitIncrement = (ctx: PhoneContext) => { + ctx.numberDialed += 1 + return ctx +} -// ctx.cancelDialTone?.() -// ctx.cancelDialTone = undefined +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) -// ctx.cancelRinger?.() -// ctx.cancelRinger = undefined + ringer.value = 1 + await Bun.sleep(80) + ringer.value = 0 + await Bun.sleep(100) -// return ctx -// } + ringer.value = 1 + await Bun.sleep(80) + ringer.value = 0 +} -// const stopCall = (ctx: PhoneContext) => { -// ctx.baresip.hangUp() -// return ctx -// } +const playExitRing = async (ringer: GPIO.Output) => { + ringer.value = 0 // Always try and turn it off! +} -// const stopAgent = (ctx: PhoneContext) => { -// log.info("🛑 Stopping agent") -// ctx.cancelAgent?.() -// ctx.cancelAgent = undefined -// return ctx -// } +const t = transition +const r = reduce +const a = action -// const stopDialTone = (ctx: PhoneContext) => { -// ctx.cancelDialTone?.() -// ctx.cancelDialTone = undefined +const phoneMachine = createMachine( + "booting", + // prettier-ignore + { + booting: state( + t("config", "initializing", r(config)) + ), + initializing: state( + t("initialized", "idle"), + t("pick-up", "ready"), + t("error", "fault", r(handleError))), + idle: state( + t("incoming-call", "incoming", r(incomingCall)), + t("pick-up", "ready")), + incoming: invoke(startRinger, + t("remote-hang-up", "idle", r(stopRinger)), + t("pick-up", "connected", r(stopRinger), a(answerCall))), + connected: state( + t("remote-hang-up", "ready"), + t("hang-up", "idle", a(hangUp))), + ready: invoke(startDialToneAndAgent, + t("dial-start", "dialing", a(stopDialTone), r(dialStart), a(stopAgent)), + t("hang-up", "idle", a(stopDialTone), a(stopAgent)), + t("start-agent", "connectToAgent", a(stopDialTone))), + connectToAgent: state( + t("hang-up", "idle", r(stopAgent)), + t("remote-hang-up", "ready", r(stopAgent))), + dialing: state( + t("dial-stop", "outgoing"), + t("digit_increment", "dialing", r(digitIncrement)), + t("hang-up", "idle")), + outgoing: invoke(makeCall, + t("answered", "connected"), + t("hang-up", "idle", a(hangUp))), + aborted: state( + t("hang-up", "idle")), + fault: state(), + }, +) -// return ctx -// } - -// const digitIncrement = (ctx: PhoneContext) => { -// ctx.numberDialed += 1 -// return ctx -// } +d._onEnter = function (machine, to, state, prevState, event) { + log(`📱 ${machine.current} -> ${to} (${(event as any).type})`) +} 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/pins/index.ts b/src/pins/index.ts index 34b0606..a856869 100644 --- a/src/pins/index.ts +++ b/src/pins/index.ts @@ -82,6 +82,8 @@ namespace GPIO { export type InputOptions = Type.InputOptions export type OutputOptions = Type.OutputOptions export type InputEvent = Type.InputEvent + export type Input = import("./input").Input + export type Output = import("./output").Output } export default GPIO 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/sip.ts b/src/sip.ts index afbe9a6..722a4fe 100644 --- a/src/sip.ts +++ b/src/sip.ts @@ -1,4 +1,4 @@ -import { log } from "./utils/log.ts" +import log from "./utils/log.ts" import { Signal } from "./utils/signal.ts" import { processStdout, processStderr } from "./utils/stdio.ts" 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 ad33fd4..5a56b8a 100755 --- a/src/test-operator.ts +++ b/src/test-operator.ts @@ -1,5 +1,4 @@ import Buzz from "./buzz/index.ts" -import type { Playback } from "./buzz/utils.ts" import { Agent } from "./agent/index.ts" import { searchWeb } from "./agent/tools.ts" import { getSound, WaitingSounds } from "./utils/waiting-sounds.ts" @@ -8,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, @@ -19,8 +18,8 @@ const runPhoneSystem = async (agentId: string, apiKey: string) => { }, }) - let currentDialtone: Playback | undefined - let currentBackgroundNoise: Playback | undefined + let currentDialtone: Buzz.Playback | undefined + let currentBackgroundNoise: Buzz.Playback | undefined let streamPlayback = player.playStream() const waitingIndicator = new WaitingSounds(player) diff --git a/src/test-pins.ts b/src/test-pins.ts index 0ff95c2..561c9e0 100644 --- a/src/test-pins.ts +++ b/src/test-pins.ts @@ -1,4 +1,4 @@ -import { GPIO } from "./pins" +import GPIO from "./pins" console.log(`kill -9 ${process.pid}`) diff --git a/src/utils/index.ts b/src/utils/index.ts index 0d0fcd6..4a06864 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,3 +1,5 @@ +import type GPIO from "../pins" + export const ensure = (value: T, message: string): T => { if (value === undefined || value === null) { throw new Error(message) @@ -9,3 +11,17 @@ export const ensure = (value: T, message: string): T => { export const random = (arr: ReadonlyArray): T => { return arr[Math.floor(Math.random() * arr.length)]! } + +export const ring = async (ringer: GPIO.Output, duration: number, signal?: AbortSignal) => { + try { + const endAt = performance.now() + duration + while (performance.now() < endAt && !signal?.aborted) { + ringer.value = 1 + await Bun.sleep(50) + ringer.value = 0 + await Bun.sleep(50) + } + } finally { + ringer.value = 0 + } +} diff --git a/src/utils/log.ts b/src/utils/log.ts index 301611f..33e1c00 100644 --- a/src/utils/log.ts +++ b/src/utils/log.ts @@ -1,21 +1,21 @@ let showDebug = true let showInfo = true -let showError = true -export function setLogLevel(level: "debug" | "info" | "error" | "none") { +export function setLogLevel(level: "debug" | "info" | "error") { showDebug = level === "debug" showInfo = level === "debug" || level === "info" - showError = level !== "none" } -export const log = { - debug: (...args: any[]) => { - if (showDebug) console.debug("DEBUG: ", ...args) - }, - info: (...args: any[]) => { - if (showInfo) console.log("INFO: ", ...args) - }, - error: (...args: any[]) => { - if (showError) console.error("ERROR: ", ...args) - }, +const log = (...args: any[]) => { + if (showInfo) console.log("👁️‍🗨️ INFO: ", ...args) } + +log.debug = (...args: any[]) => { + if (showDebug) console.debug("🪲 DEBUG: ", ...args) +} + +log.error = (...args: any[]) => { + console.error("💥 ERROR: ", ...args) +} + +export default log diff --git a/src/utils/signal.ts b/src/utils/signal.ts index c10609c..e537562 100644 --- a/src/utils/signal.ts +++ b/src/utils/signal.ts @@ -13,10 +13,6 @@ * Emit a signal: * chatSignal.emit({ username: "Chad", message: "Hey everyone, how's it going?" }); * - * Forward a signal: - * const relaySignal = new Signal<{ username: string, message: string }>() - * const disconnectRelay = chatSignal.connect(relaySignal) - * // Now, when chatSignal emits, relaySignal will also emit the same data * * Disconnect a single listener: * disconnect(); // The disconnect function is returned when you connect to a signal @@ -55,3 +51,46 @@ export class Signal { this.listeners = [] } } + +/** + * How to use Emitter: + * + * Create an emitter: + * const chat = new Emitter<{ username: string, message: string }>() + * + * Listen to events: + * const off = chat.on((data) => { + * const {username, message} = data; + * console.log(`${username} said "${message}"`); + * }) + * + * Emit an event: + * chat.emit({ username: "Chad", message: "Hey everyone, how's it going?" }); + * + * Remove a specific listener: + * off(); // The off function is returned when you add a listener + * + * Remove all listeners: + * chat.removeAllListeners() + */ +export class Emitter { + private listeners: Array<(data: T) => void> = [] + + on(listener: (data: T) => void) { + this.listeners.push(listener) + + return () => { + this.listeners = this.listeners.filter((l) => l !== listener) + } + } + + emit(data: T) { + for (const listener of this.listeners) { + listener(data) + } + } + + removeAllListeners() { + this.listeners = [] + } +} diff --git a/src/utils/stdio.ts b/src/utils/stdio.ts index 0165074..a1d2888 100644 --- a/src/utils/stdio.ts +++ b/src/utils/stdio.ts @@ -1,4 +1,4 @@ -import { log } from "./log.ts" +import log from "./log.ts" export const LineSplitter = () => { let buffer = "" diff --git a/src/utils/waiting-sounds.ts b/src/utils/waiting-sounds.ts index 5d4e4e2..3b0c49a 100644 --- a/src/utils/waiting-sounds.ts +++ b/src/utils/waiting-sounds.ts @@ -1,15 +1,15 @@ -import { type Player } from "../buzz/index.ts" +import Buzz from "../buzz/index.ts" import { join } from "path" -import type { Playback, StreamingPlayback } from "../buzz/utils.ts" import { random } from "./index.ts" +import { log } from "console" export class WaitingSounds { - typingPlayback?: Playback - speakingPlayback?: Playback + typingPlayback?: Buzz.Playback + speakingPlayback?: Buzz.Playback - constructor(private player: Player) {} + constructor(private player: Buzz.Player) {} - async start(operatorStream: StreamingPlayback) { + async start(operatorStream: Buzz.StreamingPlayback) { if (this.typingPlayback) return // Already playing this.#startTypingSounds() @@ -35,44 +35,50 @@ export class WaitingSounds { }) } - async #startSpeakingSounds(operatorStream: StreamingPlayback) { + async #startSpeakingSounds(operatorStream: Buzz.StreamingPlayback) { const playedSounds = new Set() let dir: SoundDir | undefined return new Promise(async (resolve) => { + // Don't start playing speaking sounds until the operator stream has been silent for a bit while (operatorStream.bufferEmptyFor < 1500) { await Bun.sleep(100) } do { const lastSoundDir = dir - const value = Math.random() * 100 if (lastSoundDir === "body-noises") { dir = "apology" - } else if (value > 99 && !lastSoundDir) { - dir = "body-noises" - } else if (value > 75 && !lastSoundDir) { - dir = "stalling" } else { - dir = undefined - await Bun.sleep(1000) + // sleep for 4-6 seconds + 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)) - this.speakingPlayback = await this.player.play(speakingSound) - playedSounds.add(speakingSound) - await this.speakingPlayback.finished() - } + const speakingSound = getSound(dir, Array.from(playedSounds)) + this.speakingPlayback = await this.player.play(speakingSound) + playedSounds.add(speakingSound) + await this.speakingPlayback.finished() } while (this.typingPlayback) resolve() }) } async stop() { + log(`🛑 Stopping waiting sounds. Has typingPlayback: ${!!this.typingPlayback}`) if (!this.typingPlayback) return - await Promise.all([this.typingPlayback.stop(), this.speakingPlayback?.finished()]) + // Quicky undefine this to stop the loops + const typingPlayback = this.typingPlayback this.typingPlayback = undefined + + await Promise.all([typingPlayback.stop(), this.speakingPlayback?.finished()]) + log("🛑 Waiting sounds stopped") } }