This commit is contained in:
Corey Johnson 2025-11-24 09:09:15 -08:00
parent 616d4472d2
commit 96d10c3df0
10 changed files with 197 additions and 290 deletions

View File

@ -5,174 +5,126 @@ import {$} from "bun";
const SERVICES = { const SERVICES = {
ap: "phone-ap", ap: "phone-ap",
web: "phone-web", web: "phone-web",
}; phone: "phone",
} as const;
const commands = { const COMMANDS = {
status: "Show status of all services", status: "Show service status",
logs: "Show recent logs from all services (last 50 lines)", logs: "Show recent logs (last 50 lines)",
tail: "Tail logs from all services in real-time", tail: "Tail logs in real-time",
restart: "Restart all services", restart: "Restart service (requires sudo)",
stop: "Stop all services", stop: "Stop service (requires sudo)",
start: "Start all services", start: "Start service (requires sudo)",
"ap-status": "Show status of AP service", } as const;
"ap-logs": "Show recent logs from AP service (last 50 lines)",
"ap-tail": "Tail logs from AP service in real-time",
"ap-restart": "Restart AP service",
"ap-stop": "Stop AP service",
"ap-start": "Start AP service",
"web-status": "Show status of web service",
"web-logs": "Show recent logs from web service (last 50 lines)",
"web-tail": "Tail logs from web service in real-time",
"web-restart": "Restart web service",
"web-stop": "Stop web service",
"web-start": "Start web service",
help: "Show this help message",
};
const command = process.argv[2]; const showHelp = () => {
if (!command || command === "help") {
console.log(` console.log(`
Phone CLI - Service Management Tool Phone CLI - Service Management
Usage: bun cli <command> Usage: cli SERVICE COMMAND [-v]
All Services: Services:
status Show status of all services ap WiFi AP Monitor (phone-ap.service)
logs Show recent logs from all services (last 50 lines) web Web Server (phone-web.service)
tail Tail logs from all services in real-time phone Phone Application (phone.service)
restart Restart all services
stop Stop all services
start Start all services
AP Service (phone-ap): Commands:
ap-status Show AP status status Show service status
ap-logs Show AP logs (last 50 lines) logs Show recent logs (last 50 lines)
ap-tail Tail AP logs in real-time tail Tail logs in real-time
ap-restart Restart AP service restart Restart service (requires sudo)
ap-stop Stop AP service stop Stop service (requires sudo)
ap-start Start AP service start Start service (requires sudo)
Web Service (phone-web): Options:
web-status Show web status -v Verbose mode - show actual systemd commands
web-logs Show web logs (last 50 lines)
web-tail Tail web logs in real-time
web-restart Restart web service
web-stop Stop web service
web-start Start web service
Examples: Examples:
bun cli status cli ap status
bun cli ap-logs cli web logs
bun cli web-tail cli phone tail
sudo bun cli ap-restart cli -v ap status
sudo cli ap restart
`); `);
};
// Parse arguments
const args = process.argv.slice(2);
// Check for help
if (args.length === 0 || args[0] === "help") {
showHelp();
process.exit(0); process.exit(0);
} }
if (!Object.keys(commands).includes(command)) { // Extract verbose flag and remaining args
console.error(`❌ Unknown command: ${command}`); const verbose = args.includes("-v");
console.log(`Run 'bun cli.ts help' to see available commands`); const [service, command] = args.filter(arg => arg !== "-v");
// Validate service
if (!service || !(service in SERVICES)) {
console.error(`❌ Unknown service: ${service || "(missing)"}`);
console.log(`Available services: ${Object.keys(SERVICES).join(", ")}`);
process.exit(1); process.exit(1);
} }
console.log(`\n🔧 Phone CLI - ${command}\n`); // Validate command
if (!command || !(command in COMMANDS)) {
console.error(`❌ Unknown command: ${command || "(missing)"}`);
console.log(`Available commands: ${Object.keys(COMMANDS).join(", ")}`);
process.exit(1);
}
// Parse service-specific commands // Get systemd service name
const match = command.match(/^(ap|web)-(.+)$/); const serviceName = SERVICES[service as keyof typeof SERVICES];
if (match) {
const [, prefix, action] = match;
const service = SERVICES[prefix as keyof typeof SERVICES];
switch (action) { // Execute command
case "status": console.log(`\n🔧 Phone CLI - ${service} ${command}\n`);
console.log(`━━━ ${service}.service ━━━`);
await $`systemctl status ${service}.service --no-pager -l`.nothrow();
break;
case "logs": const logCommand = (cmd: string) => {
console.log(`📋 Recent logs (last 50 lines):\n`); if (verbose) {
await $`journalctl -u ${service}.service -n 50 --no-pager`.nothrow(); console.log(`${cmd}\n`);
break;
case "tail":
console.log(`📡 Tailing logs (Ctrl+C to stop)...\n`);
await $`journalctl -u ${service}.service -f --no-pager`.nothrow();
break;
case "restart":
console.log(`🔄 Restarting ${service}.service...\n`);
await $`sudo systemctl restart ${service}.service`;
console.log(`${service}.service restarted!`);
break;
case "stop":
console.log(`🛑 Stopping ${service}.service...\n`);
await $`sudo systemctl stop ${service}.service`;
console.log(`${service}.service stopped!`);
break;
case "start":
console.log(`▶️ Starting ${service}.service...\n`);
await $`sudo systemctl start ${service}.service`;
console.log(`${service}.service started!`);
break;
} }
} else { };
// All-services commands
const allServices = Object.values(SERVICES);
switch (command) { switch (command) {
case "status": case "status":
for (const service of allServices) { logCommand(`systemctl status ${serviceName}.service --no-pager -l`);
console.log(`━━━ ${service}.service ━━━`); await $`systemctl status ${serviceName}.service --no-pager -l`.nothrow();
await $`systemctl status ${service}.service --no-pager -l`.nothrow(); break;
console.log("");
}
break;
case "logs": case "logs":
console.log("📋 Recent logs (last 50 lines):\n"); console.log(`📋 Recent logs (last 50 lines):\n`);
const serviceFlags = allServices.map(s => `-u ${s}.service`).join(" "); logCommand(`journalctl -u ${serviceName}.service -n 50 --no-pager`);
await $`journalctl ${serviceFlags} -n 50 --no-pager`.nothrow(); await $`journalctl -u ${serviceName}.service -n 50 --no-pager`.nothrow();
break; break;
case "tail": case "tail":
console.log("📡 Tailing logs (Ctrl+C to stop)...\n"); console.log(`📡 Tailing logs (Ctrl+C to stop)...\n`);
const tailFlags = allServices.map(s => `-u ${s}.service`).join(" "); logCommand(`journalctl -u ${serviceName}.service -f --no-pager`);
await $`journalctl ${tailFlags} -f --no-pager`.nothrow(); await $`journalctl -u ${serviceName}.service -f --no-pager`.nothrow();
break; break;
case "restart": case "restart":
console.log("🔄 Restarting services...\n"); console.log(`🔄 Restarting ${serviceName}.service...\n`);
for (const service of allServices) { logCommand(`sudo systemctl restart ${serviceName}.service`);
console.log(`Restarting ${service}.service...`); await $`sudo systemctl restart ${serviceName}.service`;
await $`sudo systemctl restart ${service}.service`; console.log(`${serviceName}.service restarted!`);
console.log(`${service}.service restarted`); break;
}
console.log("\n✓ All services restarted!");
break;
case "stop": case "stop":
console.log("🛑 Stopping services...\n"); console.log(`🛑 Stopping ${serviceName}.service...\n`);
for (const service of allServices) { logCommand(`sudo systemctl stop ${serviceName}.service`);
console.log(`Stopping ${service}.service...`); await $`sudo systemctl stop ${serviceName}.service`;
await $`sudo systemctl stop ${service}.service`; console.log(`${serviceName}.service stopped!`);
console.log(`${service}.service stopped`); break;
}
console.log("\n✓ All services stopped!");
break;
case "start": case "start":
console.log("▶️ Starting services...\n"); console.log(`▶️ Starting ${serviceName}.service...\n`);
for (const service of allServices) { logCommand(`sudo systemctl start ${serviceName}.service`);
console.log(`Starting ${service}.service...`); await $`sudo systemctl start ${serviceName}.service`;
await $`sudo systemctl start ${service}.service`; console.log(`${serviceName}.service started!`);
console.log(`${service}.service started`); break;
}
console.log("\n✓ All services started!");
break;
}
} }
console.log(""); console.log("");

View File

@ -1,12 +1,12 @@
# Agent # Agent
A clean, reusable wrapper for ElevenLabs conversational AI WebSocket protocol. Uses Signal-based events and provides simple tool registration. A clean, reusable wrapper for ElevenLabs conversational AI WebSocket protocol. Uses events and provides simple tool registration.
## Basic Usage ## Basic Usage
```typescript ```typescript
import { Agent } from './pi/agent' import { Agent } from "./pi/agent"
import Buzz from './pi/buzz' import Buzz from "./pi/buzz"
const agent = new Agent({ const agent = new Agent({
agentId: process.env.ELEVEN_AGENT_ID!, agentId: process.env.ELEVEN_AGENT_ID!,
@ -14,27 +14,24 @@ const agent = new Agent({
tools: { tools: {
search_web: async (args) => { search_web: async (args) => {
return { results: [`Result for ${args.query}`] } return { results: [`Result for ${args.query}`] }
} },
} },
}) })
// Set up event handlers // Set up event handlers
const player = await Buzz.player() const player = await Buzz.player()
let playback = player.playStream() let playback = player.playStream()
agent.events.connect((event) => { agent.events.on((event) => {
if (event.type === 'audio') { if (event.type === "audio") {
const audioBuffer = Buffer.from(event.audioBase64, 'base64') const audioBuffer = Buffer.from(event.audioBase64, "base64")
if (!playback.isPlaying) playback = player.playStream() if (!playback.isPlaying) playback = player.playStream()
playback.write(audioBuffer) playback.write(audioBuffer)
} } else if (event.type === "interruption") {
else if (event.type === 'interruption') {
playback.stop() playback.stop()
} } else if (event.type === "user_transcript") {
else if (event.type === 'user_transcript') {
console.log(`User: ${event.transcript}`) console.log(`User: ${event.transcript}`)
} } else if (event.type === "agent_response") {
else if (event.type === 'agent_response') {
console.log(`Agent: ${event.response}`) console.log(`Agent: ${event.response}`)
} }
}) })
@ -68,7 +65,7 @@ for await (const chunk of recording.stream()) {
if (rms > vadThreshold) { if (rms > vadThreshold) {
// Speech detected! Start conversation // Speech detected! Start conversation
agent = new Agent({ agentId, apiKey, tools }) agent = new Agent({ agentId, apiKey, tools })
agent.events.connect(eventHandler) agent.events.on(eventHandler)
await agent.start() await agent.start()
// Send buffered audio // Send buffered audio
@ -112,7 +109,7 @@ new Agent({
### Properties ### Properties
- `agent.events: Signal<AgentEvent>` - Connect to receive all events - `agent.events: Emitter<AgentEvent>` - Connect to receive all events
- `agent.isConnected: boolean` - Current connection state - `agent.isConnected: boolean` - Current connection state
- `agent.conversationId?: string` - Available after connected event - `agent.conversationId?: string` - Available after connected event
@ -121,11 +118,13 @@ new Agent({
All events are emitted through `agent.events`: All events are emitted through `agent.events`:
### Connection ### Connection
- `{ type: 'connected', conversationId, audioFormat }` - `{ type: 'connected', conversationId, audioFormat }`
- `{ type: 'disconnected' }` - `{ type: 'disconnected' }`
- `{ type: 'error', error }` - `{ type: 'error', error }`
### Conversation ### Conversation
- `{ type: 'user_transcript', transcript }` - `{ type: 'user_transcript', transcript }`
- `{ type: 'agent_response', response }` - `{ type: 'agent_response', response }`
- `{ type: 'agent_response_correction', original, corrected }` - `{ type: 'agent_response_correction', original, corrected }`
@ -134,11 +133,13 @@ All events are emitted through `agent.events`:
- `{ type: 'interruption', eventId }` - `{ type: 'interruption', eventId }`
### Tools ### Tools
- `{ type: 'tool_call', name, args, callId }` - `{ type: 'tool_call', name, args, callId }`
- `{ type: 'tool_result', name, result, callId }` - `{ type: 'tool_result', name, result, callId }`
- `{ type: 'tool_error', name, error, callId }` - `{ type: 'tool_error', name, error, callId }`
### Optional ### Optional
- `{ type: 'vad_score', score }` - `{ type: 'vad_score', score }`
- `{ type: 'ping', eventId, pingMs }` - `{ type: 'ping', eventId, pingMs }`
@ -146,7 +147,7 @@ All events are emitted through `agent.events`:
- **Generic**: Not tied to phone systems, works in any context - **Generic**: Not tied to phone systems, works in any context
- **Flexible audio**: You control when to send audio, Agent just handles WebSocket - **Flexible audio**: You control when to send audio, Agent just handles WebSocket
- **Event-driven**: All communication through Signal events, no throws - **Event-driven**: All communication through events, no throws
- **Simple tools**: Just pass a function map to constructor - **Simple tools**: Just pass a function map to constructor
- **Automatic buffering**: Sends buffered audio when connection opens - **Automatic buffering**: Sends buffered audio when connection opens
- **Automatic chunking**: Handles 8000-byte chunking internally - **Automatic chunking**: Handles 8000-byte chunking internally

View File

@ -1,4 +1,4 @@
import { Signal } from "../utils/signal" import { Emitter } from "../utils/emitter"
import type { AgentConfig, AgentEvent } from "./types" import type { AgentConfig, AgentEvent } from "./types"
type AgentState = "disconnected" | "connecting" | "connected" type AgentState = "disconnected" | "connecting" | "connected"
@ -11,7 +11,7 @@ export class Agent {
#chunkBuffer = new Uint8Array(0) #chunkBuffer = new Uint8Array(0)
#chunkSize = 8000 #chunkSize = 8000
public readonly events = new Signal<AgentEvent>() public readonly events = new Emitter<AgentEvent>()
public conversationId?: string public conversationId?: string
constructor(config: AgentConfig) { constructor(config: AgentConfig) {

View File

@ -46,7 +46,7 @@ export const runPhone = async (agentId: string, agentKey: string) => {
using rotaryInUse = gpio.input(22, { pull: "up", debounce: 3 }) using rotaryInUse = gpio.input(22, { pull: "up", debounce: 3 })
using rotaryNumber = gpio.input(23, { pull: "up", debounce: 3 }) using rotaryNumber = gpio.input(23, { pull: "up", debounce: 3 })
await Buzz.setVolume(0.2) await Buzz.setVolume(0.3)
log(`📞 Phone is ${hook.value ? "off hook" : "on hook"}`) log(`📞 Phone is ${hook.value ? "off hook" : "on hook"}`)
playStartRing(ringer) playStartRing(ringer)
@ -94,7 +94,7 @@ const startBaresip = async (phoneService: PhoneService, hook: GPIO.Input, ringer
const baresipConfig = join(import.meta.dir, "..", "baresip") const baresipConfig = join(import.meta.dir, "..", "baresip")
const baresip = new Baresip(["/usr/bin/baresip", "-v", "-f", baresipConfig]) const baresip = new Baresip(["/usr/bin/baresip", "-v", "-f", baresipConfig])
baresip.registrationSuccess.connect(async () => { baresip.registrationSuccess.on(async () => {
log("🐻 server connected") log("🐻 server connected")
if (hook.value === 0) { if (hook.value === 0) {
phoneService.send({ type: "initialized" }) phoneService.send({ type: "initialized" })
@ -103,17 +103,17 @@ const startBaresip = async (phoneService: PhoneService, hook: GPIO.Input, ringer
} }
}) })
baresip.callReceived.connect(({ contact }) => { baresip.callReceived.on(({ contact }) => {
log(`🐻 incoming call from ${contact}`) log(`🐻 incoming call from ${contact}`)
phoneService.send({ type: "incoming-call", from: contact }) phoneService.send({ type: "incoming-call", from: contact })
}) })
baresip.callEstablished.connect(({ contact }) => { baresip.callEstablished.on(({ contact }) => {
log(`🐻 call established with ${contact}`) log(`🐻 call established with ${contact}`)
phoneService.send({ type: "answered" }) phoneService.send({ type: "answered" })
}) })
baresip.hungUp.connect(() => { baresip.hungUp.on(() => {
log("🐻 call hung up") log("🐻 call hung up")
phoneService.send({ type: "remote-hang-up" }) phoneService.send({ type: "remote-hang-up" })
}) })
@ -123,7 +123,7 @@ const startBaresip = async (phoneService: PhoneService, hook: GPIO.Input, ringer
phoneService.send({ type: "error", message: error.message }) phoneService.send({ type: "error", message: error.message })
}) })
baresip.error.connect(async ({ message }) => { baresip.error.on(async ({ message }) => {
log.error("🐻 error:", message) log.error("🐻 error:", message)
phoneService.send({ type: "error", message }) phoneService.send({ type: "error", message })
for (let i = 0; i < 4; i++) { for (let i = 0; i < 4; i++) {
@ -200,7 +200,7 @@ const startListening = (service: Service<typeof phoneMachine>, agent: Agent) =>
let preConnectionBuffer: Uint8Array[] = [] let preConnectionBuffer: Uint8Array[] = []
agent.events.connect(async (event) => { agent.events.on(async (event) => {
if (event.type === "disconnected") abortAgent.abort() if (event.type === "disconnected") abortAgent.abort()
}) })
@ -250,7 +250,7 @@ const handleAgentEvents = (
) => { ) => {
const waitingIndicator = new WaitingSounds(player) const waitingIndicator = new WaitingSounds(player)
agent.events.connect(async (event) => { agent.events.on(async (event) => {
switch (event.type) { switch (event.type) {
case "connected": case "connected":
log("🤖 Connected to AI agent\n") log("🤖 Connected to AI agent\n")
@ -271,9 +271,14 @@ const handleAgentEvents = (
break break
case "interruption": case "interruption":
log("🤖 User interrupted") if (waitingIndicator.isPlaying) {
streamPlayback?.stop() log("🤖 User interrupted but doing a tool lookup")
streamPlayback = player.playStream() // Reset playback stream } else {
log("🤖 User interrupted")
await waitingIndicator.stop()
streamPlayback?.stop()
streamPlayback = player.playStream() // Reset playback stream
}
break break
case "tool_call": case "tool_call":

View File

@ -1,15 +1,15 @@
import log from "./utils/log.ts" import log from "./utils/log.ts"
import { Signal } from "./utils/signal.ts" import { Emitter } from "./utils/emitter.ts"
import { processStdout, processStderr } from "./utils/stdio.ts" import { processStdout, processStderr } from "./utils/stdio.ts"
export class Baresip { export class Baresip {
baresipArgs: string[] baresipArgs: string[]
process?: Bun.PipedSubprocess process?: Bun.PipedSubprocess
callEstablished = new Signal<{ contact: string }>() callEstablished = new Emitter<{ contact: string }>()
callReceived = new Signal<{ contact: string }>() callReceived = new Emitter<{ contact: string }>()
hungUp = new Signal() hungUp = new Emitter()
error = new Signal<{ message: string }>() error = new Emitter<{ message: string }>()
registrationSuccess = new Signal() registrationSuccess = new Emitter()
constructor(baresipArgs: string[]) { constructor(baresipArgs: string[]) {
this.baresipArgs = baresipArgs this.baresipArgs = baresipArgs
@ -48,10 +48,10 @@ export class Baresip {
} }
disconnectAll() { disconnectAll() {
this.callEstablished.disconnect() this.callEstablished.removeAllListeners()
this.callReceived.disconnect() this.callReceived.removeAllListeners()
this.hungUp.disconnect() this.hungUp.removeAllListeners()
this.registrationSuccess.disconnect() this.registrationSuccess.removeAllListeners()
} }
kill() { kill() {

View File

@ -24,7 +24,7 @@ const runPhoneSystem = async (agentId: string, apiKey: string) => {
const waitingIndicator = new WaitingSounds(player) const waitingIndicator = new WaitingSounds(player)
// Set up agent event listeners // Set up agent event listeners
agent.events.connect(async (event) => { agent.events.on(async (event) => {
switch (event.type) { switch (event.type) {
case "connected": case "connected":
console.log("✅ Connected to AI agent\n") console.log("✅ Connected to AI agent\n")
@ -162,7 +162,7 @@ if (!apiKey) {
if (!agentId) { if (!agentId) {
console.error( console.error(
"❌ Error: ELEVEN_AGENT_ID environELEVEN_AGENT_ID=agent_5601k4taw2cvfjzrz6snxpgeh7x8 ELEVEN_API_KEY=sk_0313740f112c5992cb62ed96c974ab19b5916f1ea172471fment variable is required" "❌ Error: ELEVEN_AGENT_ID environELEVEN_AGENT_ID=agent_5601k4taw2cvfjzrz6snxpgeh7x8 ELEVEN_API_KEY=sk_0313740f112c5992cb62ed96c974ab19b5916f1ea172471fment variable is required",
) )
console.error(" Create an agent at https://elevenlabs.io/app/conversational-ai") console.error(" Create an agent at https://elevenlabs.io/app/conversational-ai")
process.exit(1) process.exit(1)

42
src/utils/emitter.ts Normal file
View File

@ -0,0 +1,42 @@
/**
* How to use Emitter:
*
* Create an emitter:
* const chat = new Emitter<{ username: string, message: string }>()
*
* Listen to events:
* const off = chat.on((data) => {
* const {username, message} = data;
* console.log(`${username} said "${message}"`);
* })
*
* Emit an event:
* chat.emit({ username: "Chad", message: "Hey everyone, how's it going?" });
*
* Remove a specific listener:
* off(); // The off function is returned when you add a listener
*
* Remove all listeners:
* chat.removeAllListeners()
*/
export class Emitter<T = void> {
private listeners: Array<(data: T) => void> = []
on(listener: (data: T) => void) {
this.listeners.push(listener)
return () => {
this.listeners = this.listeners.filter((l) => l !== listener)
}
}
emit(data: T) {
for (const listener of this.listeners) {
listener(data)
}
}
removeAllListeners() {
this.listeners = []
}
}

View File

@ -1,4 +1,4 @@
let showDebug = true let showDebug = process.env.DEBUG ?? false
let showInfo = true let showInfo = true
export function setLogLevel(level: "debug" | "info" | "error") { export function setLogLevel(level: "debug" | "info" | "error") {

View File

@ -1,96 +0,0 @@
/**
* How to use a Signal:
*
* Create a signal:
* const chatSignal = new Signal<{ username: string, message: string }>()
*
* Connect to the signal:
* const disconnect = chatSignal.connect((data) => {
* const {username, message} = data;
* console.log(`${username} said "${message}"`);
* })
*
* Emit a signal:
* chatSignal.emit({ username: "Chad", message: "Hey everyone, how's it going?" });
*
*
* Disconnect a single listener:
* disconnect(); // The disconnect function is returned when you connect to a signal
*
* Disconnect all listeners:
* chatSignal.disconnect()
*/
export class Signal<T extends object | void> {
private listeners: Array<(data: T) => void> = []
connect(listenerOrSignal: Signal<T> | ((data: T) => void)) {
let listener: (data: T) => void
// If it is a signal, forward the data to the signal
if (listenerOrSignal instanceof Signal) {
listener = (data: T) => listenerOrSignal.emit(data)
} else {
listener = listenerOrSignal
}
this.listeners.push(listener)
return () => {
this.listeners = this.listeners.filter((l) => l !== listener)
}
}
emit(data: T) {
for (const listener of this.listeners) {
listener(data)
}
}
disconnect() {
this.listeners = []
}
}
/**
* How to use Emitter:
*
* Create an emitter:
* const chat = new Emitter<{ username: string, message: string }>()
*
* Listen to events:
* const off = chat.on((data) => {
* const {username, message} = data;
* console.log(`${username} said "${message}"`);
* })
*
* Emit an event:
* chat.emit({ username: "Chad", message: "Hey everyone, how's it going?" });
*
* Remove a specific listener:
* off(); // The off function is returned when you add a listener
*
* Remove all listeners:
* chat.removeAllListeners()
*/
export class Emitter<T = void> {
private listeners: Array<(data: T) => void> = []
on(listener: (data: T) => void) {
this.listeners.push(listener)
return () => {
this.listeners = this.listeners.filter((l) => l !== listener)
}
}
emit(data: T) {
for (const listener of this.listeners) {
listener(data)
}
}
removeAllListeners() {
this.listeners = []
}
}

View File

@ -6,16 +6,22 @@ import { log } from "console"
export class WaitingSounds { export class WaitingSounds {
typingPlayback?: Buzz.Playback typingPlayback?: Buzz.Playback
speakingPlayback?: Buzz.Playback speakingPlayback?: Buzz.Playback
playing = false
constructor(private player: Buzz.Player) {} constructor(private player: Buzz.Player) {}
async start(operatorStream: Buzz.StreamingPlayback) { async start(operatorStream: Buzz.StreamingPlayback) {
if (this.typingPlayback) return // Already playing if (this.playing) return // Already playing
this.playing = true
this.#startTypingSounds() this.#startTypingSounds()
this.#startSpeakingSounds(operatorStream) this.#startSpeakingSounds(operatorStream)
} }
get isPlaying() {
return this.playing
}
async #startTypingSounds() { async #startTypingSounds() {
return new Promise<void>(async (resolve) => { return new Promise<void>(async (resolve) => {
do { do {
@ -29,7 +35,7 @@ export class WaitingSounds {
const typingSound = getSound(dir) const typingSound = getSound(dir)
this.typingPlayback = await this.player.play(typingSound) this.typingPlayback = await this.player.play(typingSound)
await this.typingPlayback.finished() await this.typingPlayback.finished()
} while (this.typingPlayback) } while (this.isPlaying)
resolve() resolve()
}) })
@ -64,20 +70,17 @@ export class WaitingSounds {
this.speakingPlayback = await this.player.play(speakingSound) this.speakingPlayback = await this.player.play(speakingSound)
playedSounds.add(speakingSound) playedSounds.add(speakingSound)
await this.speakingPlayback.finished() await this.speakingPlayback.finished()
} while (this.typingPlayback) } while (this.isPlaying)
resolve() resolve()
}) })
} }
async stop() { async stop() {
log(`🛑 Stopping waiting sounds. Has typingPlayback: ${!!this.typingPlayback}`) log(`🛑 Stopping waiting sounds. Has typingPlayback: ${!!this.typingPlayback}`)
if (!this.typingPlayback) return if (!this.playing) return
this.playing = false
// Quicky undefine this to stop the loops await Promise.all([this.typingPlayback?.stop(), this.speakingPlayback?.finished()])
const typingPlayback = this.typingPlayback
this.typingPlayback = undefined
await Promise.all([typingPlayback.stop(), this.speakingPlayback?.finished()])
log("🛑 Waiting sounds stopped") log("🛑 Waiting sounds stopped")
} }
} }