This commit is contained in:
Corey Johnson 2025-11-18 16:48:03 -08:00
parent 1862956531
commit 28164c2394
30 changed files with 697 additions and 102 deletions

View File

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

View File

@ -5,7 +5,7 @@
* Tests device listing, player, recorder, and tone generation * Tests device listing, player, recorder, and tone generation
*/ */
import Buzz from "./src/buzz" import Buzz from "./buzz"
console.log("🎵 Buzz Audio Library - Basic Test\n") console.log("🎵 Buzz Audio Library - Basic Test\n")

View File

@ -2,8 +2,13 @@
import { $ } from "bun" import { $ } from "bun"
<<<<<<< Updated upstream
const PI_HOST = "yellow-phone.local" const PI_HOST = "yellow-phone.local"
const PI_DIR = "/home/corey/yellow-phone" const PI_DIR = "/home/corey/yellow-phone"
=======
const PI_HOST = process.env.PI_HOST ?? "phone.local"
const PI_DIR = process.env.PI_DIR ?? "/home/corey/phone"
>>>>>>> Stashed changes
// Parse command line arguments // Parse command line arguments
const shouldBootstrap = process.argv.includes("--bootstrap") const shouldBootstrap = process.argv.includes("--bootstrap")
@ -34,6 +39,9 @@ if (shouldBootstrap) {
await $`ssh ${PI_HOST} "cd ${PI_DIR} && sudo bun bootstrap.ts ${PI_DIR}"` await $`ssh ${PI_HOST} "cd ${PI_DIR} && sudo bun bootstrap.ts ${PI_DIR}"`
} }
// make console beep
await $`afplay /System/Library/Sounds/Blow.aiff`
// Always check if services exist and restart them (whether we bootstrapped or not) // Always check if services exist and restart them (whether we bootstrapped or not)
console.log("Checking for existing services...") console.log("Checking for existing services...")
const apServiceExists = await $`ssh ${PI_HOST} "systemctl is-enabled phone-ap.service"` const apServiceExists = await $`ssh ${PI_HOST} "systemctl is-enabled phone-ap.service"`

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

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

View File

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

340
src/phone.ts Normal file
View File

@ -0,0 +1,340 @@
import { d, reduce, createMachine, state, transition, interpret, guard } from "robot3"
import { Baresip } from "./sip"
import { log } from "./log"
import { gpio } from "./gpio"
import { sleep } from "bun"
import { ToneGenerator } from "./tone"
import { ring } from "./utils"
import { pins } from "./pins"
import { processStderr, processStdout } from "./stdio"
type CancelableTask = () => void
type PhoneContext = {
lastError?: string
peer?: string
numberDialed: number
cancelDialTone?: CancelableTask
cancelRinger?: CancelableTask
baresip: Baresip
startAgent: () => CancelableTask
cancelAgent?: CancelableTask
}
const incomingCallRing = (): CancelableTask => {
let abortController = new AbortController()
const playRingtone = async () => {
while (!abortController.signal.aborted) {
await ring(2000, abortController.signal)
await sleep(4000)
}
}
playRingtone().catch((error) => log.error("Ringer error:", error))
return () => abortController.abort()
}
const handleError = (ctx: PhoneContext, event: { type: "error"; message?: string }) => {
ctx.lastError = event.message
log.error(`Phone error: ${event.message}`)
return ctx
}
const incomingCall = (ctx: PhoneContext, event: { type: "incoming_call"; from?: string }) => {
ctx.peer = event.from
ctx.cancelRinger = incomingCallRing()
log.info(`Incoming call from ${event.from}`)
return ctx
}
const stopRinger = (ctx: PhoneContext) => {
ctx.cancelRinger?.()
ctx.cancelRinger = undefined
return ctx
}
const playDialTone = (ctx: PhoneContext) => {
const tone = new ToneGenerator()
tone.loopTone([350, 440])
ctx.cancelDialTone = () => {
tone.stop()
}
return ctx
}
const playOutgoingTone = () => {
const tone = new ToneGenerator()
let canceled = false
const play = async () => {
while (!canceled) {
await tone.playTone([440, 480], 2000)
await sleep(4000)
}
}
play().catch((error) => log.error("Outgoing tone error:", error))
return () => {
tone.stop()
canceled = true
}
}
const dialStart = (ctx: PhoneContext) => {
ctx.numberDialed = 0
ctx = stopDialTone(ctx)
return ctx
}
const makeCall = (ctx: PhoneContext) => {
log.info(`Dialing number: ${ctx.numberDialed}`)
if (ctx.numberDialed === 1) {
ctx.baresip.dial("+13476229543")
} else if (ctx.numberDialed === 2) {
ctx.baresip.dial("+18109643563")
} else {
const playTone = async () => {
const tone = new ToneGenerator()
await tone.playTone([900], 200)
await tone.playTone([1350], 200)
await tone.playTone([1750], 200)
}
playTone().catch((error) => log.error("Error playing tone:", error))
}
return ctx
}
const makeAgentCall = (ctx: PhoneContext) => {
log.info(`Calling agent`)
ctx.cancelAgent = ctx.startAgent()
return ctx
}
const callAgentGuard = (ctx: PhoneContext) => {
return ctx.numberDialed === 10
}
const callAnswered = (ctx: PhoneContext) => {
ctx.baresip.accept()
ctx.cancelDialTone?.()
ctx.cancelDialTone = undefined
ctx.cancelRinger?.()
ctx.cancelRinger = undefined
return ctx
}
const stopCall = (ctx: PhoneContext) => {
ctx.baresip.hangUp()
return ctx
}
const stopAgent = (ctx: PhoneContext) => {
log.info("🛑 Stopping agent")
ctx.cancelAgent?.()
ctx.cancelAgent = undefined
return ctx
}
const stopDialTone = (ctx: PhoneContext) => {
ctx.cancelDialTone?.()
ctx.cancelDialTone = undefined
return ctx
}
const digitIncrement = (ctx: PhoneContext) => {
ctx.numberDialed += 1
return ctx
}
export const startPhone = async () => {
Bun.spawn({
cmd: "amixer -c 0 set PCM 20%".split(" "),
})
const baresip = new Baresip(["/usr/bin/baresip", "-v", "-f", "/home/corey/code/tone/baresip"])
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)
})
let agentProcess: Bun.Subprocess
const initializeAgent = () => {
agentProcess = Bun.spawn({
cwd: "/home/corey/code/tone/packages/py-agent",
cmd: ["/home/corey/.local/bin/uv", "run", "main.py"],
stdin: "pipe",
stdout: "pipe",
stderr: "pipe",
env: { ...process.env, PYTHONUNBUFFERED: "1" },
})
log.info("☎️ Started agent process", agentProcess.pid)
processStdout(agentProcess, (line) => {
log.info(`🐍 ${line}`)
if (line === "Starting agent session") {
log.info(`💎 HEY! THE AGENT STARTED`)
} else if (line.startsWith("Conversation ended.")) {
phoneService.send({ type: "remote_hang_up" })
}
})
processStderr(agentProcess)
agentProcess.exited.then((code) => {
log.error(`💥 Agent process exited with code ${code}`)
phoneService.send({ type: "remote_hang_up" })
})
}
initializeAgent()
const startAgent = () => {
log.info("☎️ Starting agent conversation")
if (agentProcess?.stdin) {
agentProcess.stdin.write("start\n")
} else {
log.error("☎️ No agent process stdin available")
phoneService.send({ type: "remote_hang_up" })
}
return () => {
log.info("☎️ Stopping agent conversation")
if (agentProcess?.stdin) {
agentProcess.stdin.write("stop\n")
}
}
}
const context = (initial?: Partial<PhoneContext>): PhoneContext => ({
numberDialed: 0,
baresip,
startAgent,
...initial,
})
const phoneMachine = createMachine(
"initializing",
// prettier-ignore
{
initializing: state(
transition("initialized", "idle"),
transition("pick_up", "ready", reduce(playDialTone)),
transition("error", "fault", reduce(handleError))),
idle: state(
transition("incoming_call", "incoming", reduce(incomingCall)),
transition("pick_up", "ready", reduce(playDialTone))),
incoming: state(
transition("remote_hang_up", "idle", reduce(stopRinger)),
transition("pick_up", "connected", reduce(callAnswered))),
connected: state(
transition("remote_hang_up", "ready", reduce(playDialTone)),
transition("hang_up", "idle", reduce(stopCall))),
ready: state(
transition("dial_start", "dialing", reduce(dialStart)),
transition("dial_timeout", "aborted", reduce(stopDialTone)),
transition("hang_up", "idle", reduce(stopDialTone))),
dialing: state(
transition("dial_stop", "outgoing", reduce(makeCall), guard((ctx) => !callAgentGuard(ctx))),
transition("dial_stop", "connectedToAgent", reduce(makeAgentCall), guard((ctx) => callAgentGuard(ctx))),
transition("digit_increment", "dialing", reduce(digitIncrement)),
transition("hang_up", "idle", reduce(stopDialTone))),
outgoing: state(
transition("start_agent", "connectedToAgent"),
transition("answered", "connected"),
transition("hang_up", "idle", reduce(stopCall))),
connectedToAgent: state(
transition("remote_hang_up", "ready", reduce(stopAgent)),
transition("hang_up", "idle", reduce(stopAgent))),
aborted: state(
transition("hang_up", "idle")),
fault: state(),
},
context
)
const phoneService = interpret(phoneMachine, () => {})
d._onEnter = function (machine, to, state, prevState, event) {
log.info(`📱 ${machine.current} -> ${to} (${JSON.stringify(event)})`)
}
gpio.monitor(pins.hook, { bias: "pull-up" }, (event) => {
const type = event.edge === "falling" ? "hang_up" : "pick_up"
log.info(`📞 Hook ${event.edge} sending ${type}`)
phoneService.send({ type })
})
gpio.monitor(pins.rotaryInUse, { bias: "pull-up", throttleMs: 90 }, (event) => {
const type = event.edge === "falling" ? "dial_start" : "dial_stop"
log.debug(`📞 Rotary in-use ${event.edge} sending ${type}`)
phoneService.send({ type })
})
gpio.monitor(pins.rotaryNumber, { bias: "pull-up", throttleMs: 90 }, (event) => {
if (event.edge !== "rising") return
phoneService.send({ type: "digit_increment" })
})
// Graceful shutdown handling
const cleanup = () => {
log.info("🛑 Shutting down, stopping agent process")
if (agentProcess?.stdin) {
agentProcess.stdin.write("quit\n")
}
}
process.on("SIGINT", cleanup)
process.on("SIGTERM", cleanup)
process.on("exit", cleanup)
}

117
src/sip.ts Normal file
View File

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

72
src/test-buzz.ts Executable file
View File

@ -0,0 +1,72 @@
#!/usr/bin/env bun
/**
* Basic functionality test for Buzz library
* Tests device listing, player, recorder, and tone generation
*/
import Buzz from "./buzz"
console.log("🎵 Buzz Audio Library - Basic Test\n")
// Test 1: List devices
console.log("📋 Listing devices...")
const devices = await Buzz.listDevices()
console.log(`Found ${devices.length} device(s):`)
devices.forEach((d) => {
console.log(` ${d.type.padEnd(8)} ${d.label} (${d.id})`)
})
console.log("")
// Test 2: Create player
console.log("🔊 Creating default player...")
try {
const player = await Buzz.defaultPlayer()
console.log("✅ Player created\n")
// Test 3: Play sound file
console.log("🔊 Playing greeting sound...")
const playback = await player.play("./sounds/greeting/greet1.wav")
await playback.finished()
console.log("✅ Sound played\n")
// Test 4: Play tone
console.log("🎵 Playing 440Hz tone for 1 second...")
const tone = await player.playTone([440], 1000)
await tone.finished()
console.log("✅ Tone played\n")
} catch (error) {
console.log(`⚠️ Skipping player tests: ${error instanceof Error ? error.message : error}\n`)
}
// Test 5: Create recorder
console.log("🎤 Creating default recorder...")
try {
const recorder = await Buzz.defaultRecorder()
console.log("✅ Recorder created\n")
// Test 6: Stream recording with RMS
console.log("📊 Recording for 2 seconds with RMS monitoring...")
const recording = recorder.start()
let chunkCount = 0
let maxRMS = 0
setTimeout(async () => {
await recording.stop()
}, 2000)
for await (const chunk of recording.stream()) {
chunkCount++
const rms = Buzz.calculateRMS(chunk)
if (rms > maxRMS) maxRMS = rms
if (chunkCount % 20 === 0) {
console.log(` RMS: ${Math.round(rms)}`)
}
}
console.log(`✅ Recorded ${chunkCount} chunks, max RMS: ${Math.round(maxRMS)}\n`)
} catch (error) {
console.log(`⚠️ Skipping recorder tests: ${error instanceof Error ? error.message : error}\n`)
}
console.log("✅ All tests complete!")

View File

@ -22,7 +22,7 @@ const runPhoneSystem = async (agentId: string, apiKey: string) => {
let currentDialtone: Playback | undefined let currentDialtone: Playback | undefined
let currentBackgroundNoise: Playback | undefined let currentBackgroundNoise: Playback | undefined
let streamPlayback = player.playStream() let streamPlayback = player.playStream()
const waitingIndicator = new WaitingSounds(player, streamPlayback) const waitingIndicator = new WaitingSounds(player)
// Set up agent event listeners // Set up agent event listeners
agent.events.connect((event) => { agent.events.connect((event) => {
@ -52,7 +52,7 @@ const runPhoneSystem = async (agentId: string, apiKey: string) => {
break break
case "tool_call": case "tool_call":
waitingIndicator.start() waitingIndicator.start(streamPlayback)
console.log(`🔧 Tool call: ${event.name}(${JSON.stringify(event.args)})`) console.log(`🔧 Tool call: ${event.name}(${JSON.stringify(event.args)})`)
break break
@ -73,6 +73,14 @@ const runPhoneSystem = async (agentId: string, apiKey: string) => {
case "error": case "error":
console.error("Agent error:", event.error) console.error("Agent error:", event.error)
break
case "ping":
break
default:
console.log(`😵‍💫 ${event.type}`)
break
} }
}) })

46
src/test-pins.ts Normal file
View File

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

View File

@ -7,13 +7,13 @@ export class WaitingSounds {
typingPlayback?: Playback typingPlayback?: Playback
speakingPlayback?: Playback speakingPlayback?: Playback
constructor(private player: Player, private streamPlayback: StreamingPlayback) {} constructor(private player: Player) {}
async start() { async start(operatorStream: StreamingPlayback) {
if (this.typingPlayback) return // Already playing if (this.typingPlayback) return // Already playing
this.#startTypingSounds() this.#startTypingSounds()
this.#startSpeakingSounds() this.#startSpeakingSounds(operatorStream)
} }
async #startTypingSounds() { async #startTypingSounds() {
@ -35,17 +35,15 @@ export class WaitingSounds {
}) })
} }
async #startSpeakingSounds() { async #startSpeakingSounds(operatorStream: StreamingPlayback) {
const playedSounds = new Set<string>() const playedSounds = new Set<string>()
let dir: SoundDir | undefined let dir: SoundDir | undefined
return new Promise<void>(async (resolve) => { return new Promise<void>(async (resolve) => {
// Don't start speaking until the stream playback buffer is empty! while (operatorStream.bufferEmptyFor < 1500) {
while (this.streamPlayback.bufferEmptyFor < 1000) {
await Bun.sleep(100) await Bun.sleep(100)
} }
do { do {
this.streamPlayback.bufferEmptyFor
const lastSoundDir = dir const lastSoundDir = dir
const value = Math.random() * 100 const value = Math.random() * 100
if (lastSoundDir === "body-noises") { if (lastSoundDir === "body-noises") {