diff --git a/README.md b/README.md index 9b5f140..a0e1fa4 100644 --- a/README.md +++ b/README.md @@ -13,17 +13,16 @@ A Bun-based deployment script that automates copying files to a Raspberry Pi and ### What It Does -1. **Creates directory** on the Pi at the configured path -2. **Copies files** from local `pi/` directory to the Pi -3. **Sets permissions** to make all TypeScript files executable -4. **Bootstrap (optional)**: If `--bootstrap` flag is passed, runs `bootstrap.ts` on the Pi with sudo -5. **Service management**: +1. **Copies files** from here to the pi (in ~/phone by default) +2. **Bootstrap (optional)**: If `--bootstrap` flag is passed it will bootstrap the pi with everything it needs +3. **Service management**: - Checks if `phone-ap.service` and `phone-web.service` exist - If they exist, restarts both services - If they don't exist and bootstrap wasn't run, warns the user ### Usage +<<<<<<< Updated upstream **Standard deployment** (just copy files and restart services): ```bash bun deploy.ts @@ -32,6 +31,11 @@ bun deploy.ts **First-time deployment** (copy files + run bootstrap): ```bash bun deploy.ts --bootstrap +======= +```bash +bun scripts/deploy.ts +# or bun deploy.ts --bootstrap +>>>>>>> Stashed changes ``` ### Services @@ -46,8 +50,12 @@ After deployment, the Pi is accessible at: - **Web URL**: http://yellow-phone.local - **WiFi Network**: yellow-phone-setup -### Requirements +### Local Requirements - Bun runtime +<<<<<<< Updated upstream - SSH access to `yellow-phone.local` - Local `pi/` directory with files to deploy +======= +- SSH access to `phone.local` +>>>>>>> Stashed changes diff --git a/basic-test.ts b/basic-test.ts index 4a8b872..3dffde2 100755 --- a/basic-test.ts +++ b/basic-test.ts @@ -5,7 +5,7 @@ * 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") diff --git a/sounds/stalling/hum1.wav b/hum1.wav similarity index 100% rename from sounds/stalling/hum1.wav rename to hum1.wav diff --git a/sounds/stalling/hum2.wav b/hum2.wav similarity index 100% rename from sounds/stalling/hum2.wav rename to hum2.wav diff --git a/scripts/deploy.ts b/scripts/deploy.ts index feb253e..9a45940 100755 --- a/scripts/deploy.ts +++ b/scripts/deploy.ts @@ -2,8 +2,13 @@ import { $ } from "bun" +<<<<<<< Updated upstream const PI_HOST = "yellow-phone.local" 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 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}"` } +// 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"` diff --git a/sounds/apology/excuse-me1.wav b/sounds/apology/excuse-me1.wav index 504afb2..02bd749 100644 Binary files a/sounds/apology/excuse-me1.wav and b/sounds/apology/excuse-me1.wav differ diff --git a/sounds/apology/excuse-me2.wav b/sounds/apology/excuse-me2.wav index 95f726e..29f7b3d 100644 Binary files a/sounds/apology/excuse-me2.wav and b/sounds/apology/excuse-me2.wav differ diff --git a/sounds/apology/excuse-me3.wav b/sounds/apology/excuse-me3.wav index b1026c6..e06f71a 100644 Binary files a/sounds/apology/excuse-me3.wav and b/sounds/apology/excuse-me3.wav differ diff --git a/sounds/body-noises/burp1.wav b/sounds/body-noises/burp1.wav index 0bbd034..c828aae 100644 Binary files a/sounds/body-noises/burp1.wav and b/sounds/body-noises/burp1.wav differ diff --git a/sounds/body-noises/burp2.wav b/sounds/body-noises/burp2.wav index f3f9fc2..b98baa5 100644 Binary files a/sounds/body-noises/burp2.wav and b/sounds/body-noises/burp2.wav differ diff --git a/sounds/body-noises/fart1.wav b/sounds/body-noises/fart1.wav index e273ca4..369a163 100644 Binary files a/sounds/body-noises/fart1.wav and b/sounds/body-noises/fart1.wav differ diff --git a/sounds/body-noises/fart2.wav b/sounds/body-noises/fart2.wav index 7d4b3b2..cee3178 100644 Binary files a/sounds/body-noises/fart2.wav and b/sounds/body-noises/fart2.wav differ diff --git a/sounds/stalling/cough1.wav b/sounds/stalling/cough1.wav index 0897991..85070db 100644 Binary files a/sounds/stalling/cough1.wav and b/sounds/stalling/cough1.wav differ diff --git a/sounds/stalling/cough2.wav b/sounds/stalling/cough2.wav index bc3f72c..a8f5a4a 100644 Binary files a/sounds/stalling/cough2.wav and b/sounds/stalling/cough2.wav differ diff --git a/sounds/stalling/hmm1.wav b/sounds/stalling/hmm1.wav index 460f5d1..2deca4d 100644 Binary files a/sounds/stalling/hmm1.wav and b/sounds/stalling/hmm1.wav differ diff --git a/sounds/stalling/hmm2.wav b/sounds/stalling/hmm2.wav index 56eac19..0d6e927 100644 Binary files a/sounds/stalling/hmm2.wav and b/sounds/stalling/hmm2.wav differ diff --git a/sounds/stalling/one-sec.wav b/sounds/stalling/one-sec.wav index daba071..8bd7022 100644 Binary files a/sounds/stalling/one-sec.wav and b/sounds/stalling/one-sec.wav differ diff --git a/sounds/stalling/sigh1.wav b/sounds/stalling/sigh1.wav index 5aa7c43..49e93cc 100644 Binary files a/sounds/stalling/sigh1.wav and b/sounds/stalling/sigh1.wav differ diff --git a/sounds/stalling/sigh2.wav b/sounds/stalling/sigh2.wav index 26bd5b3..365a6d3 100644 Binary files a/sounds/stalling/sigh2.wav and b/sounds/stalling/sigh2.wav differ diff --git a/sounds/stalling/sneeze.wav b/sounds/stalling/sneeze.wav index 83784c3..f9a8c25 100644 Binary files a/sounds/stalling/sneeze.wav and b/sounds/stalling/sneeze.wav differ diff --git a/sounds/stalling/uh-huh.wav b/sounds/stalling/uh-huh.wav index e09da43..ce96b5b 100644 Binary files a/sounds/stalling/uh-huh.wav and b/sounds/stalling/uh-huh.wav differ diff --git a/sounds/stalling/yeah.wav b/sounds/stalling/yeah.wav index 5895f1f..b46ba8c 100644 Binary files a/sounds/stalling/yeah.wav and b/sounds/stalling/yeah.wav differ diff --git a/src/buzz/player.ts b/src/buzz/player.ts index 04e251a..d1c8425 100644 --- a/src/buzz/player.ts +++ b/src/buzz/player.ts @@ -145,11 +145,7 @@ export class Player { "-", ] - const proc = Bun.spawn(["aplay", ...args], { - stdin: "pipe", - stdout: "pipe", - stderr: "pipe", - }) + const proc = Bun.spawn(["aplay", ...args], { stdin: "pipe", stdout: "pipe", stderr: "pipe" }) let bufferFinishTime = performance.now() const format = this.#format diff --git a/src/buzz/utils.ts b/src/buzz/utils.ts index 9c06042..5035405 100644 --- a/src/buzz/utils.ts +++ b/src/buzz/utils.ts @@ -1,66 +1,66 @@ // Audio format configuration export type AudioFormat = { - format?: string; - sampleRate?: number; - channels?: number; -}; + format?: string + sampleRate?: number + channels?: number +} // Default audio format for recordings and tone generation export const DEFAULT_AUDIO_FORMAT = { - format: 'S16_LE', + format: "S16_LE", sampleRate: 16000, channels: 1, -} as const; +} as const // Device from ALSA listing export type Device = { - id: string; // "default" or "plughw:1,0" - card: number; // ALSA card number - device: number; // ALSA device number - label: string; // Human-readable name - type: 'playback' | 'capture'; -}; + id: string // "default" or "plughw:1,0" + card: number // ALSA card number + device: number // ALSA device number + label: string // Human-readable name + type: "playback" | "capture" +} // Playback control handle export type Playback = { - isPlaying: boolean; - stop: () => Promise; - finished: () => Promise; -}; + isPlaying: boolean + stop: () => Promise + finished: () => Promise +} // Streaming playback handle export type StreamingPlayback = { - isPlaying: boolean; - write: (chunk: Uint8Array) => void; - stop: () => Promise; - bufferEmptyFor: number; // milliseconds since buffer became empty, 0 if not empty -}; + isPlaying: boolean + write: (chunk: Uint8Array) => void + stop: () => Promise + bufferEmptyFor: number // milliseconds since buffer became empty, 0 if not empty +} // Streaming recording control handle export type StreamingRecording = { - isRecording: boolean; - stream: () => ReadableStream; - stop: () => Promise; -}; + isRecording: boolean + stream: () => ReadableStream + stop: () => Promise +} // File recording control handle export type FileRecording = { - isRecording: boolean; - stop: () => Promise; -}; + isRecording: boolean + stop: () => Promise +} -const parseDeviceLine = (line: string, type: 'playback' | 'capture'): Device | undefined => { - if (!line.startsWith('card ')) return undefined; +const parseDeviceLine = (line: string, type: "playback" | "capture"): Device | undefined => { + if (!line.startsWith("card ")) return undefined - const match = line.match(/^card (\d+):\s+\w+\s+\[(.+?)\],\s+device (\d+):/); - if (!match) return undefined; + const match = line.match(/^card (\d+):\s+\w+\s+\[(.+?)\],\s+device (\d+):/) + if (!match) return undefined - const [, cardStr, label, deviceStr] = match; + const [, cardStr, label, deviceStr] = match - if (!cardStr || !label || !deviceStr) return undefined; + if (!cardStr || !label || !deviceStr) return undefined - const card = parseInt(cardStr); - const device = parseInt(deviceStr); + const card = parseInt(cardStr) + const device = parseInt(deviceStr) return { id: `plughw:${card},${device}`, @@ -68,79 +68,77 @@ const parseDeviceLine = (line: string, type: 'playback' | 'capture'): Device | u device, label, type, - }; -}; + } +} -const parseAlsaDevices = (output: string, type: 'playback' | 'capture'): Device[] => { +const parseAlsaDevices = (output: string, type: "playback" | "capture"): Device[] => { return output - .split('\n') - .map(line => parseDeviceLine(line, type)) - .filter(device => device !== undefined); -}; + .split("\n") + .map((line) => parseDeviceLine(line, type)) + .filter((device) => device !== undefined) +} export const listDevices = async (): Promise => { - const playbackOutput = await Bun.$`aplay -l`.text(); - const captureOutput = await Bun.$`arecord -l`.text(); + const playbackOutput = await Bun.$`aplay -l`.text() + const captureOutput = await Bun.$`arecord -l`.text() - const playback = parseAlsaDevices(playbackOutput, 'playback'); - const capture = parseAlsaDevices(captureOutput, 'capture'); + const playback = parseAlsaDevices(playbackOutput, "playback") + const capture = parseAlsaDevices(captureOutput, "capture") - return [...playback, ...capture]; -}; + return [...playback, ...capture] +} export const findDeviceByLabel = async ( label: string, - type?: 'playback' | 'capture' + type?: "playback" | "capture" ): Promise => { - const devices = await listDevices(); - const device = devices.find(d => - d.label === label && (!type || d.type === type) - ); + const devices = await listDevices() + const device = devices.find((d) => d.label === label && (!type || d.type === type)) if (!device) { - const typeStr = type ? ` (type: ${type})` : ''; - throw new Error(`Device not found: ${label}${typeStr}`); + const typeStr = type ? ` (type: ${type})` : "" + throw new Error(`Device not found: ${label}${typeStr}`) } - return device; -}; + return device +} export const calculateRMS = (chunk: Uint8Array): number => { - const samples = new Int16Array(chunk.buffer, chunk.byteOffset, chunk.byteLength / 2); - let sum = 0; + const samples = new Int16Array(chunk.buffer, chunk.byteOffset, chunk.byteLength / 2) + let sum = 0 for (const sample of samples) { - sum += sample * sample; + sum += sample * sample } - return Math.sqrt(sum / samples.length); -}; + return Math.sqrt(sum / samples.length) +} export const generateToneSamples = ( frequencies: number[], sampleRate: number, durationSeconds: number ): Uint8Array => { - const numSamples = Math.floor(sampleRate * durationSeconds); - const buffer = new ArrayBuffer(numSamples * 2); // 2 bytes per S16_LE sample - const samples = new Int16Array(buffer); + const numSamples = Math.floor(sampleRate * durationSeconds) + const buffer = new ArrayBuffer(numSamples * 2) // 2 bytes per S16_LE sample + const samples = new Int16Array(buffer) for (let i = 0; i < numSamples; i++) { - const t = i / sampleRate; - let value = 0; + const t = i / sampleRate + let value = 0 // Mix all frequencies together for (const freq of frequencies) { - value += Math.sin(2 * Math.PI * freq * t); + value += Math.sin(2 * Math.PI * freq * t) } // Average and scale to Int16 range - value = (value / frequencies.length) * 32767; - samples[i] = Math.round(value); + value = (value / frequencies.length) * 32767 + samples[i] = Math.round(value) } - return new Uint8Array(buffer); -}; + return new Uint8Array(buffer) +} export const streamTone = async ( stream: { write: (chunk: Uint8Array) => void; end: () => void }, @@ -148,20 +146,24 @@ export const streamTone = async ( durationMs: number, format: Required ): Promise => { - const infinite = durationMs === Infinity; - const durationSeconds = durationMs / 1000; + const infinite = durationMs === Infinity + const durationSeconds = durationMs / 1000 // Continuous tone - const samples = generateToneSamples(frequencies, format.sampleRate, infinite ? 1 : durationSeconds); + const samples = generateToneSamples( + frequencies, + format.sampleRate, + infinite ? 1 : durationSeconds + ) if (infinite) { // Loop 1-second chunks forever while (true) { - stream.write(samples); - await Bun.sleep(1000); + stream.write(samples) + await Bun.sleep(1000) } } else { - stream.write(samples); - stream.end(); + stream.write(samples) + stream.end() } -}; +} diff --git a/src/phone.ts b/src/phone.ts new file mode 100644 index 0000000..bf9156e --- /dev/null +++ b/src/phone.ts @@ -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 => ({ + 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) +} diff --git a/src/sip.ts b/src/sip.ts new file mode 100644 index 0000000..1beaf33 --- /dev/null +++ b/src/sip.ts @@ -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) + } +} diff --git a/src/test-buzz.ts b/src/test-buzz.ts new file mode 100755 index 0000000..3dffde2 --- /dev/null +++ b/src/test-buzz.ts @@ -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!") diff --git a/src/operator.ts b/src/test-operator.ts similarity index 95% rename from src/operator.ts rename to src/test-operator.ts index fc02e02..b3d8290 100755 --- a/src/operator.ts +++ b/src/test-operator.ts @@ -22,7 +22,7 @@ const runPhoneSystem = async (agentId: string, apiKey: string) => { let currentDialtone: Playback | undefined let currentBackgroundNoise: Playback | undefined let streamPlayback = player.playStream() - const waitingIndicator = new WaitingSounds(player, streamPlayback) + const waitingIndicator = new WaitingSounds(player) // Set up agent event listeners agent.events.connect((event) => { @@ -52,7 +52,7 @@ const runPhoneSystem = async (agentId: string, apiKey: string) => { break case "tool_call": - waitingIndicator.start() + waitingIndicator.start(streamPlayback) console.log(`🔧 Tool call: ${event.name}(${JSON.stringify(event.args)})`) break @@ -73,6 +73,14 @@ const runPhoneSystem = async (agentId: string, apiKey: string) => { case "error": console.error("Agent error:", event.error) + break + + case "ping": + break + + default: + console.log(`😵‍💫 ${event.type}`) + break } }) diff --git a/src/test-pins.ts b/src/test-pins.ts new file mode 100644 index 0000000..9b79c6b --- /dev/null +++ b/src/test-pins.ts @@ -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(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!`) \ No newline at end of file diff --git a/src/utils/waiting-sounds.ts b/src/utils/waiting-sounds.ts index a46925b..5d4e4e2 100644 --- a/src/utils/waiting-sounds.ts +++ b/src/utils/waiting-sounds.ts @@ -7,13 +7,13 @@ export class WaitingSounds { typingPlayback?: 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 this.#startTypingSounds() - this.#startSpeakingSounds() + this.#startSpeakingSounds(operatorStream) } async #startTypingSounds() { @@ -35,17 +35,15 @@ export class WaitingSounds { }) } - async #startSpeakingSounds() { + async #startSpeakingSounds(operatorStream: StreamingPlayback) { const playedSounds = new Set() let dir: SoundDir | undefined return new Promise(async (resolve) => { - // Don't start speaking until the stream playback buffer is empty! - while (this.streamPlayback.bufferEmptyFor < 1000) { + while (operatorStream.bufferEmptyFor < 1500) { await Bun.sleep(100) } do { - this.streamPlayback.bufferEmptyFor const lastSoundDir = dir const value = Math.random() * 100 if (lastSoundDir === "body-noises") {