Move from polling to worker thread #3
20
README.md
20
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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
|
|
|
|||
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.
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<void>;
|
||||
finished: () => Promise<void>;
|
||||
};
|
||||
isPlaying: boolean
|
||||
stop: () => Promise<void>
|
||||
finished: () => Promise<void>
|
||||
}
|
||||
|
||||
// Streaming playback handle
|
||||
export type StreamingPlayback = {
|
||||
isPlaying: boolean;
|
||||
write: (chunk: Uint8Array) => void;
|
||||
stop: () => Promise<void>;
|
||||
bufferEmptyFor: number; // milliseconds since buffer became empty, 0 if not empty
|
||||
};
|
||||
isPlaying: boolean
|
||||
write: (chunk: Uint8Array) => void
|
||||
stop: () => Promise<void>
|
||||
bufferEmptyFor: number // milliseconds since buffer became empty, 0 if not empty
|
||||
}
|
||||
|
||||
// Streaming recording control handle
|
||||
export type StreamingRecording = {
|
||||
isRecording: boolean;
|
||||
stream: () => ReadableStream<Uint8Array>;
|
||||
stop: () => Promise<void>;
|
||||
};
|
||||
isRecording: boolean
|
||||
stream: () => ReadableStream<Uint8Array>
|
||||
stop: () => Promise<void>
|
||||
}
|
||||
|
||||
// File recording control handle
|
||||
export type FileRecording = {
|
||||
isRecording: boolean;
|
||||
stop: () => Promise<void>;
|
||||
};
|
||||
isRecording: boolean
|
||||
stop: () => Promise<void>
|
||||
}
|
||||
|
||||
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<Device[]> => {
|
||||
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<Device> => {
|
||||
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<AudioFormat>
|
||||
): Promise<void> => {
|
||||
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()
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
|||
340
src/phone.ts
Normal file
340
src/phone.ts
Normal 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
117
src/sip.ts
Normal 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
72
src/test-buzz.ts
Executable 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!")
|
||||
|
|
@ -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
|
||||
}
|
||||
})
|
||||
|
||||
46
src/test-pins.ts
Normal file
46
src/test-pins.ts
Normal 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!`)
|
||||
|
|
@ -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<string>()
|
||||
let dir: SoundDir | undefined
|
||||
return new Promise<void>(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") {
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user