Compare commits
8 Commits
1c717a5b47
...
a5751b28f7
| Author | SHA1 | Date | |
|---|---|---|---|
| a5751b28f7 | |||
| 781ad51d9b | |||
| dc902565f3 | |||
| 4ff9508933 | |||
| 843f3680e1 | |||
| a0cc0c85cf | |||
| 1527ce7d13 | |||
| 28164c2394 |
30
README.md
30
README.md
|
|
@ -13,27 +13,19 @@ 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
|
||||
|
||||
**Standard deployment** (just copy files and restart services):
|
||||
|
||||
```bash
|
||||
bun deploy.ts
|
||||
```
|
||||
|
||||
**First-time deployment** (copy files + run bootstrap):
|
||||
|
||||
```bash
|
||||
bun deploy.ts --bootstrap
|
||||
bun scripts/deploy.ts
|
||||
# or bun deploy.ts --bootstrap
|
||||
>>>>>>> Stashed changes
|
||||
```
|
||||
|
||||
### Services
|
||||
|
|
@ -50,8 +42,14 @@ After deployment, the Pi is accessible at:
|
|||
- **Web URL**: http://phone.local
|
||||
- **WiFi Network**: phone-setup
|
||||
|
||||
### Requirements
|
||||
### Local Requirements
|
||||
|
||||
- Bun runtime
|
||||
<<<<<<< HEAD
|
||||
<<<<<<< Updated upstream
|
||||
- # SSH access to `yellow-phone.local`
|
||||
- SSH access to `phone.local`
|
||||
- Local `pi/` directory with files to deploy
|
||||
> > > > > > > 1c717a5b4772147c3b7ce20f512452f13c8cc510
|
||||
- # Local `pi/` directory with files to deploy
|
||||
- SSH access to `phone.local`
|
||||
> > > > > > > Stashed changes
|
||||
|
|
|
|||
1
baresip/accounts
Normal file
1
baresip/accounts
Normal file
|
|
@ -0,0 +1 @@
|
|||
<sip:yellow@probablycorey.sip.twilio.com;transport=tls>;auth_pass=zgm-kwx2bug5hwf3YGF;unregister_on_exit=yes;regint=300
|
||||
71
baresip/config
Normal file
71
baresip/config
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
#
|
||||
# baresip configuration
|
||||
#
|
||||
|
||||
#------------------------------------------------------------------------------
|
||||
|
||||
# Core
|
||||
poll_method epoll # poll, select, epoll ..
|
||||
ring_aufile none
|
||||
|
||||
# Call
|
||||
call_local_timeout 120
|
||||
call_max_calls 4
|
||||
|
||||
# Audio
|
||||
audio_player alsa,default
|
||||
audio_source alsa,default
|
||||
audio_alert none
|
||||
audio_alert_enable no
|
||||
audio_level no
|
||||
ausrc_format s16 # s16, float, ..
|
||||
auplay_format s16 # s16, float, ..
|
||||
auenc_format s16 # s16, float, ..
|
||||
audec_format s16 # s16, float, ..
|
||||
audio_buffer 20-160 # ms
|
||||
|
||||
# AVT - Audio/Video Transport
|
||||
rtp_tos 184
|
||||
rtcp_mux no
|
||||
jitter_buffer_delay 5-10 # frames
|
||||
rtp_stats no
|
||||
|
||||
|
||||
#------------------------------------------------------------------------------
|
||||
# Modules
|
||||
|
||||
module_path /usr/lib/baresip/modules
|
||||
|
||||
# UI Modules
|
||||
#module stdio.so
|
||||
|
||||
# Audio codec Modules (in order)
|
||||
module g711.so
|
||||
|
||||
|
||||
# Audio driver Modules
|
||||
module alsa.so
|
||||
|
||||
# Media NAT modules
|
||||
module stun.so
|
||||
module turn.so
|
||||
module ice.so
|
||||
|
||||
module httpd.so
|
||||
|
||||
#------------------------------------------------------------------------------
|
||||
# Temporary Modules (loaded then unloaded)
|
||||
|
||||
module_tmp uuid.so
|
||||
module_tmp account.so
|
||||
|
||||
|
||||
#------------------------------------------------------------------------------
|
||||
# Application Modules
|
||||
|
||||
module_app contact.so
|
||||
module_app debug_cmd.so
|
||||
module_app menu.so
|
||||
|
||||
|
||||
http_listen 0.0.0.0:8000 # httpd - HTTP Serve
|
||||
3
bun.lock
3
bun.lock
|
|
@ -6,6 +6,7 @@
|
|||
"dependencies": {
|
||||
"hono": "^4.10.4",
|
||||
"openai": "^6.9.0",
|
||||
"robot3": "^1.2.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
|
|
@ -30,6 +31,8 @@
|
|||
|
||||
"openai": ["openai@6.9.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-n2sJRYmM+xfJ0l3OfH8eNnIyv3nQY7L08gZQu3dw6wSdfPtKAk92L83M2NIP5SS8Cl/bsBBG3yKzEOjkx0O+7A=="],
|
||||
|
||||
"robot3": ["robot3@1.2.0", "", {}, "sha512-Xin8KHqCKrD9Rqk1ZzZQYjsb6S9DRggcfwBqnVPeM3DLtNCJLxWWTrPJDYm3E+ZiTO7H3VMdgyPSkIbuYnYP2Q=="],
|
||||
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
||||
|
|
|
|||
|
|
@ -14,7 +14,8 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"hono": "^4.10.4",
|
||||
"openai": "^6.9.0"
|
||||
"openai": "^6.9.0",
|
||||
"robot3": "^1.2.0"
|
||||
},
|
||||
"prettier": {
|
||||
"semi": false,
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@
|
|||
|
||||
import { $ } from "bun"
|
||||
|
||||
const PI_HOST = process.env.PI_HOST || "phone.local"
|
||||
const PI_DIR = "/home/corey/phone"
|
||||
const PI_HOST = process.env.PI_HOST ?? "phone.local"
|
||||
const PI_DIR = process.env.PI_DIR ?? "/home/corey/phone"
|
||||
|
||||
// Parse command line arguments
|
||||
const shouldBootstrap = process.argv.includes("--bootstrap")
|
||||
|
|
@ -37,6 +37,9 @@ if (shouldBootstrap) {
|
|||
await $`ssh ${PI_HOST} "cd ${PI_DIR} && sudo bun ${PI_DIR}/scripts/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()
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
|
|||
47
src/hq.ts
47
src/hq.ts
|
|
@ -1,47 +0,0 @@
|
|||
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") {
|
||||
console.log(`🌭`, event.value)
|
||||
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!`)
|
||||
438
src/phone.ts
Normal file
438
src/phone.ts
Normal file
|
|
@ -0,0 +1,438 @@
|
|||
import { d, reduce, createMachine, state, transition, interpret, guard } from "robot3"
|
||||
import { Baresip } from "./sip"
|
||||
import { log } from "./utils/log"
|
||||
import { sleep } from "bun"
|
||||
import { processStderr, processStdout } from "./utils/stdio"
|
||||
import Buzz from "./buzz"
|
||||
import { join } from "path"
|
||||
import GPIO from "./pins"
|
||||
import { Agent } from "./agent"
|
||||
import { searchWeb } from "./agent/tools"
|
||||
|
||||
// TODO: Kill baresip process on exit
|
||||
|
||||
type CancelableTask = () => void
|
||||
|
||||
type PhoneContext = {
|
||||
lastError?: string
|
||||
peer?: string
|
||||
numberDialed: number
|
||||
cancelDialTone?: CancelableTask
|
||||
cancelRinger?: CancelableTask
|
||||
baresip: Baresip
|
||||
startAgent: () => CancelableTask
|
||||
cancelAgent?: CancelableTask
|
||||
}
|
||||
|
||||
const gpio = new GPIO()
|
||||
using ringer = gpio.output(17, { resetOnClose: true })
|
||||
using hook = gpio.input(27, { pull: "up", debounce: 3 })
|
||||
using rotaryInUse = gpio.input(22, { pull: "up", debounce: 3 })
|
||||
using rotaryNumber = gpio.input(23, { pull: "up", debounce: 3 })
|
||||
|
||||
export const startPhone = async (agentId: string, apiKey: string) => {
|
||||
await Buzz.setVolume(0.4)
|
||||
log.info(`📞 Hook ${hook.value}`)
|
||||
|
||||
let digit = 0
|
||||
|
||||
hook.onChange((event) => {
|
||||
const type = event.value == 0 ? "hang_up" : "pick_up"
|
||||
log.info(`📞 Hook ${event.value} sending ${type}`)
|
||||
if (type === "hang_up") {
|
||||
ringer.value = 1
|
||||
} else {
|
||||
ringer.value = 0
|
||||
}
|
||||
})
|
||||
|
||||
rotaryInUse.onChange((event) => {
|
||||
if (event.value === 0) {
|
||||
digit = 0
|
||||
} else {
|
||||
log.info(`📞 Dialed digit: ${digit}`)
|
||||
}
|
||||
})
|
||||
|
||||
rotaryNumber.onChange((event) => {
|
||||
if (event.value === 1) {
|
||||
digit += 1
|
||||
}
|
||||
})
|
||||
|
||||
// Keep process running
|
||||
await new Promise(() => {})
|
||||
}
|
||||
|
||||
const apiKey = process.env.ELEVEN_API_KEY
|
||||
const agentId = process.env.ELEVEN_AGENT_ID
|
||||
|
||||
if (!apiKey) {
|
||||
console.error("❌ Error: ELEVEN_API_KEY environment variable is required")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (!agentId) {
|
||||
console.error(
|
||||
"❌ Error: ELEVEN_AGENT_ID environELEVEN_AGENT_ID=agent_5601k4taw2cvfjzrz6snxpgeh7x8 ELEVEN_API_KEY=sk_0313740f112c5992cb62ed96c974ab19b5916f1ea172471fment variable is required"
|
||||
)
|
||||
console.error(" Create an agent at https://elevenlabs.io/app/conversational-ai")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
await startPhone(agentId, apiKey)
|
||||
|
||||
const startBaresip = async (hook: GPIO.InputPin) => {
|
||||
// const baresipConfig = join(import.meta.dir, "..", "baresip")
|
||||
// const baresip = new Baresip(["/usr/bin/baresip", "-v", "-f", baresipConfig])
|
||||
|
||||
// baresip.registrationSuccess.connect(async () => {
|
||||
// log.info("🐻 server connected")
|
||||
// const result = await gpio.get(pins.hook)
|
||||
// if (result.state === "low") {
|
||||
// phoneService.send({ type: "initialized" })
|
||||
// } else {
|
||||
// phoneService.send({ type: "pick_up" })
|
||||
// }
|
||||
// })
|
||||
|
||||
// baresip.callReceived.connect(({ contact }) => {
|
||||
// log.info(`🐻 incoming call from ${contact}`)
|
||||
// phoneService.send({ type: "incoming_call", from: contact })
|
||||
// })
|
||||
|
||||
// baresip.callEstablished.connect(({ contact }) => {
|
||||
// log.info(`🐻 call established with ${contact}`)
|
||||
// phoneService.send({ type: "answered" })
|
||||
// })
|
||||
|
||||
// baresip.hungUp.connect(() => {
|
||||
// log.info("🐻 call hung up")
|
||||
// phoneService.send({ type: "remote_hang_up" })
|
||||
// })
|
||||
|
||||
// baresip.connect().catch((error) => {
|
||||
// log.error("🐻 connection error:", error)
|
||||
// phoneService.send({ type: "error", message: error.message })
|
||||
// })
|
||||
|
||||
// baresip.error.connect(async ({ message }) => {
|
||||
// log.error("🐻 error:", message)
|
||||
// phoneService.send({ type: "error", message })
|
||||
// for (let i = 0; i < 4; i++) {
|
||||
// await ring(500)
|
||||
// await sleep(250)
|
||||
// }
|
||||
// process.exit(1)
|
||||
// })
|
||||
|
||||
// const agent = new Agent({
|
||||
// agentId,
|
||||
// apiKey,
|
||||
// tools: {
|
||||
// search_web: (args: { query: string }) => searchWeb(args.query),
|
||||
// },
|
||||
// })
|
||||
}
|
||||
|
||||
// handleAgentEvents(agent)
|
||||
|
||||
// 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)
|
||||
// }
|
||||
|
||||
// const handleAgentEvents = (agent: Agent) => {
|
||||
// agent.events.connect(async (event) => {
|
||||
// switch (event.type) {
|
||||
// case "connected":
|
||||
// console.log("✅ Connected to AI agent\n")
|
||||
// break
|
||||
|
||||
// case "user_transcript":
|
||||
// console.log(`👤 You: ${event.transcript}`)
|
||||
// break
|
||||
|
||||
// case "agent_response":
|
||||
// console.log(`🤖 Agent: ${event.response}`)
|
||||
// break
|
||||
|
||||
// case "audio":
|
||||
// await waitingIndicator.stop()
|
||||
// const audioBuffer = Buffer.from(event.audioBase64, "base64")
|
||||
// streamPlayback.write(audioBuffer)
|
||||
// break
|
||||
|
||||
// case "interruption":
|
||||
// console.log("🛑 User interrupted")
|
||||
// streamPlayback?.stop()
|
||||
// streamPlayback = player.playStream() // Reset playback stream
|
||||
// break
|
||||
|
||||
// case "tool_call":
|
||||
// waitingIndicator.start(streamPlayback)
|
||||
// console.log(`🔧 Tool call: ${event.name}(${JSON.stringify(event.args)})`)
|
||||
// break
|
||||
|
||||
// case "tool_result":
|
||||
// console.log(`✅ Tool result: ${JSON.stringify(event.result)}`)
|
||||
// break
|
||||
|
||||
// case "tool_error":
|
||||
// console.error(`❌ Tool error: ${event.error}`)
|
||||
// break
|
||||
|
||||
// case "disconnected":
|
||||
// console.log("\n👋 Conversation ended, returning to dialtone\n")
|
||||
// streamPlayback?.stop()
|
||||
// state = "WAITING_FOR_VOICE"
|
||||
// phoneService.send({ type: "remote_hang_up" })
|
||||
// break
|
||||
|
||||
// case "error":
|
||||
// console.error("Agent error:", event.error)
|
||||
// break
|
||||
|
||||
// case "ping":
|
||||
// break
|
||||
|
||||
// default:
|
||||
// console.log(`😵💫 ${event.type}`)
|
||||
// break
|
||||
// }
|
||||
// })
|
||||
// }
|
||||
|
||||
// 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
|
||||
// }
|
||||
|
|
@ -4,11 +4,12 @@ High-level GPIO library for Bun using libgpiod v2 with automatic resource manage
|
|||
|
||||
## Features
|
||||
|
||||
- Type-safe TypeScript API with autocomplete for pin names
|
||||
- True event-driven GPIO with worker-based architecture (<10ms latency)
|
||||
- Zero CPU usage when idle (blocking on hardware events)
|
||||
- Type-safe TypeScript API
|
||||
- Automatic resource cleanup with `using` keyword
|
||||
- Hardware debouncing via kernel
|
||||
- Event-driven input handling
|
||||
- Efficient multi-pin monitoring with input groups
|
||||
- Callback-based event handling with multiple listeners
|
||||
- Zero external dependencies (uses Bun FFI)
|
||||
|
||||
## Requirements
|
||||
|
|
@ -36,10 +37,13 @@ for (let i = 0; i < 10; i++) {
|
|||
using button = gpio.input(20, { pull: "up", debounce: 10 })
|
||||
console.log(button.value)
|
||||
|
||||
// Listen for button events
|
||||
for await (const event of button.events()) {
|
||||
// Listen for button events with callback
|
||||
button.onChange((event) => {
|
||||
console.log(event.value === 0 ? "Pressed!" : "Released")
|
||||
}
|
||||
})
|
||||
|
||||
// Keep process running
|
||||
await new Promise(() => {})
|
||||
```
|
||||
|
||||
## API
|
||||
|
|
@ -86,27 +90,6 @@ Options:
|
|||
- `debounce?: number` - Debounce period in milliseconds (default: 0)
|
||||
- `edge?: 'rising' | 'falling' | 'both'` - Edge detection (default: 'both')
|
||||
|
||||
#### `gpio.inputGroup(config)`
|
||||
|
||||
Monitor multiple inputs efficiently with a single file descriptor. Pin names are fully type-safe!
|
||||
|
||||
```typescript
|
||||
using inputs = gpio.inputGroup({
|
||||
hook: { pin: 20, pull: "up" },
|
||||
rotary: { pin: 21, pull: "up", debounce: 1 },
|
||||
button: { pin: 22, pull: "down" },
|
||||
})
|
||||
|
||||
// Access individual pins (fully typed!)
|
||||
console.log(inputs.pins.hook.value) // TypeScript knows about .hook
|
||||
console.log(inputs.pins.button.value) // TypeScript knows about .button
|
||||
|
||||
// Monitor all pins
|
||||
for await (const event of inputs.events()) {
|
||||
console.log(`${event.pin}: ${event.value}`) // event.pin is "hook" | "rotary" | "button"
|
||||
}
|
||||
```
|
||||
|
||||
#### `gpio.listChips()`
|
||||
|
||||
List available GPIO chips.
|
||||
|
|
@ -117,22 +100,30 @@ console.log(chips)
|
|||
// [{ path: '/dev/gpiochip0', name: 'pinctrl-bcm2835', label: '...', numLines: 58 }]
|
||||
```
|
||||
|
||||
### InputPin
|
||||
### Input
|
||||
|
||||
```typescript
|
||||
using button = gpio.input(20)
|
||||
|
||||
// Read current state
|
||||
// Read current state (cached from last event)
|
||||
const value: 0 | 1 = button.value
|
||||
|
||||
// Listen for changes (returns unsubscribe function)
|
||||
const unsubscribe = button.onChange((event) => {
|
||||
console.log(event.value, event.timestamp)
|
||||
})
|
||||
|
||||
// Add multiple listeners
|
||||
const unsub2 = button.onChange((event) => {
|
||||
console.log("Second listener:", event.value)
|
||||
})
|
||||
|
||||
// Remove specific listener
|
||||
unsubscribe()
|
||||
|
||||
// Wait for specific value
|
||||
await button.waitForValue(0) // wait for LOW
|
||||
await button.waitForValue(1, 5000) // wait for HIGH with 5s timeout
|
||||
|
||||
// Event stream
|
||||
for await (const event of button.events()) {
|
||||
console.log(event.value, event.timestamp)
|
||||
}
|
||||
```
|
||||
|
||||
### OutputPin
|
||||
|
|
@ -146,29 +137,23 @@ const value = led.value
|
|||
led.toggle()
|
||||
```
|
||||
|
||||
### InputGroup
|
||||
## Architecture
|
||||
|
||||
```typescript
|
||||
using inputs = gpio.inputGroup({
|
||||
switch: { pin: 16, pull: "up" },
|
||||
button: { pin: 20, pull: "up", debounce: 10 }
|
||||
})
|
||||
### Worker-Based Event Handling
|
||||
|
||||
// Access pins with full type safety
|
||||
inputs.pins.switch.value // ✓ TypeScript autocomplete
|
||||
inputs.pins.button.value // ✓ TypeScript autocomplete
|
||||
Each input spawns a dedicated Web Worker that:
|
||||
|
||||
// Wait for specific pin values
|
||||
await inputs.pins.button.waitForValue(0) // wait for button to go LOW
|
||||
await inputs.pins.switch.waitForValue(1, 3000) // wait for switch to go HIGH with timeout
|
||||
1. Blocks on `gpiod_line_request_wait_edge_events()` with `-1` timeout (infinite)
|
||||
2. Wakes instantly when hardware GPIO edge event occurs
|
||||
3. Reads event and posts message to main thread
|
||||
4. Main thread fires registered callbacks
|
||||
|
||||
// Monitor all pins
|
||||
for await (const event of inputs.events()) {
|
||||
event.pin // Type: 'switch' | 'button'
|
||||
event.value // Type: 0 | 1
|
||||
event.timestamp // Type: bigint (nanoseconds)
|
||||
}
|
||||
```
|
||||
**Benefits:**
|
||||
|
||||
- **True blocking**: Zero CPU usage when idle
|
||||
- **Low latency**: <10ms response time (vs 100ms with polling)
|
||||
- **Independent inputs**: Each input has its own worker
|
||||
- **Clean shutdown**: Workers terminated on close, kernel handles GPIO cleanup
|
||||
|
||||
## Resource Management
|
||||
|
||||
|
|
@ -176,17 +161,24 @@ for await (const event of inputs.events()) {
|
|||
|
||||
```typescript
|
||||
// Good - automatic cleanup
|
||||
{
|
||||
using led = gpio.output(17)
|
||||
using led = gpio.output(17) // Automatically released because of `using`
|
||||
led.value = 1
|
||||
} // Automatically released
|
||||
```
|
||||
|
||||
// Bad - manual cleanup required
|
||||
```typescript
|
||||
// Meh - manual cleanup required
|
||||
const led = gpio.output(17)
|
||||
led.value = 1
|
||||
led.close() // Must call manually
|
||||
```
|
||||
|
||||
```typescript
|
||||
// Bad - leaks resources
|
||||
const led = gpio.output(17)
|
||||
led.value = 1
|
||||
// Forgot to close() - resource leak!
|
||||
```
|
||||
|
||||
## Hardware Setup
|
||||
|
||||
### Pull Resistors
|
||||
|
|
@ -197,6 +189,7 @@ Pull resistors prevent floating input values when nothing is connected to the pi
|
|||
- **Pull-down + button to VCC**: When released, pin reads LOW (0). When pressed, pin reads HIGH (1).
|
||||
|
||||
**Important:** Match your pull resistor to your wiring:
|
||||
|
||||
- Button to ground → use `pull: "up"`
|
||||
- Button to VCC (3.3V) → use `pull: "down"`
|
||||
|
||||
|
|
@ -252,26 +245,27 @@ import { GPIO } from "@/pins"
|
|||
|
||||
const gpio = new GPIO()
|
||||
|
||||
using inputs = gpio.inputGroup({
|
||||
button: { pin: 20, pull: "up", debounce: 10 },
|
||||
switch: { pin: 16, pull: "up" }
|
||||
})
|
||||
|
||||
using button = gpio.input(20, { pull: "up", debounce: 10 })
|
||||
using switchInput = gpio.input(16, { pull: "up" })
|
||||
using led = gpio.output(21)
|
||||
|
||||
// Set LED based on switch state
|
||||
if (inputs.pins.switch.value === 1) {
|
||||
led.value = 1
|
||||
}
|
||||
led.value = switchInput.value
|
||||
|
||||
// Toggle LED when button pressed
|
||||
for await (const event of inputs.events()) {
|
||||
if (event.pin === "button" && event.value === 0) {
|
||||
button.onChange((event) => {
|
||||
if (event.value === 0) {
|
||||
led.toggle()
|
||||
} else if (event.pin === "switch") {
|
||||
}
|
||||
})
|
||||
|
||||
// Mirror switch to LED
|
||||
switchInput.onChange((event) => {
|
||||
led.value = event.value
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Keep process running
|
||||
await new Promise(() => {})
|
||||
```
|
||||
|
||||
### Rotary Phone Dialer
|
||||
|
|
@ -281,18 +275,31 @@ import { GPIO } from "@/pins"
|
|||
|
||||
const gpio = new GPIO()
|
||||
|
||||
using inputs = gpio.inputGroup({
|
||||
hook: { pin: 20, pull: "up" },
|
||||
rotary: { pin: 21, pull: "up", debounce: 1 },
|
||||
using hook = gpio.input(27, { pull: "up", debounce: 3 })
|
||||
using rotaryInUse = gpio.input(22, { pull: "up", debounce: 3 })
|
||||
using rotaryNumber = gpio.input(23, { pull: "up", debounce: 3 })
|
||||
|
||||
let digit = 0
|
||||
|
||||
hook.onChange((event) => {
|
||||
console.log(event.value === 0 ? "Phone picked up" : "Phone hung up")
|
||||
})
|
||||
|
||||
for await (const event of inputs.events()) {
|
||||
if (event.pin === "hook") {
|
||||
console.log(event.value === 0 ? "Phone picked up" : "Phone hung up")
|
||||
} else if (event.pin === "rotary" && event.value === 0) {
|
||||
console.log("Rotary pulse")
|
||||
rotaryInUse.onChange((event) => {
|
||||
if (event.value === 0) {
|
||||
digit = 0
|
||||
} else {
|
||||
console.log(`Dialed digit: ${digit}`)
|
||||
}
|
||||
})
|
||||
|
||||
rotaryNumber.onChange((event) => {
|
||||
if (event.value === 1) {
|
||||
digit += 1
|
||||
}
|
||||
})
|
||||
|
||||
await new Promise(() => {})
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
|
|
|||
101
src/pins/gpio-helpers.ts
Normal file
101
src/pins/gpio-helpers.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
import type { Pointer } from "bun:ffi"
|
||||
import { gpiod, GPIOD_LINE_DIRECTION_INPUT, GPIOD_LINE_DIRECTION_OUTPUT } from "./ffi"
|
||||
import { cstr, mapPullToLibgpiod, mapEdgeToLibgpiod } from "./utils"
|
||||
import type { PullMode, EdgeMode } from "./types"
|
||||
|
||||
type LineRequestResult = {
|
||||
chip: Pointer
|
||||
request: Pointer
|
||||
}
|
||||
|
||||
export type InputLineConfig = {
|
||||
chipPath: string
|
||||
offset: number
|
||||
pull: PullMode
|
||||
debounce: number
|
||||
edge: EdgeMode
|
||||
}
|
||||
|
||||
export type OutputLineConfig = {
|
||||
chipPath: string
|
||||
offset: number
|
||||
initialValue: 0 | 1
|
||||
}
|
||||
|
||||
const cleanup = (message: string): never => {
|
||||
throw new Error(message)
|
||||
}
|
||||
|
||||
const requestLine = (
|
||||
chipPath: string,
|
||||
offset: number,
|
||||
consumer: string,
|
||||
configureSettings: (settings: Pointer) => void
|
||||
): LineRequestResult => {
|
||||
const chip = gpiod.gpiod_chip_open(cstr(chipPath))
|
||||
if (!chip) cleanup("Failed to open GPIO chip")
|
||||
|
||||
const settings = gpiod.gpiod_line_settings_new()
|
||||
if (!settings) {
|
||||
gpiod.gpiod_chip_close(chip)
|
||||
cleanup("Failed to create line settings")
|
||||
}
|
||||
|
||||
configureSettings(settings!)
|
||||
|
||||
const lineConfig = gpiod.gpiod_line_config_new()
|
||||
if (!lineConfig) {
|
||||
gpiod.gpiod_line_settings_free(settings)
|
||||
gpiod.gpiod_chip_close(chip)
|
||||
cleanup("Failed to create line config")
|
||||
}
|
||||
|
||||
const offsets = new Uint32Array([offset])
|
||||
const ret = gpiod.gpiod_line_config_add_line_settings(lineConfig, offsets, 1, settings)
|
||||
gpiod.gpiod_line_settings_free(settings)
|
||||
|
||||
if (ret !== 0) {
|
||||
gpiod.gpiod_line_config_free(lineConfig)
|
||||
gpiod.gpiod_chip_close(chip)
|
||||
cleanup("Failed to add line settings")
|
||||
}
|
||||
|
||||
const requestConfig = gpiod.gpiod_request_config_new()
|
||||
if (!requestConfig) {
|
||||
gpiod.gpiod_line_config_free(lineConfig)
|
||||
gpiod.gpiod_chip_close(chip)
|
||||
cleanup("Failed to create request config")
|
||||
}
|
||||
|
||||
gpiod.gpiod_request_config_set_consumer(requestConfig, cstr(consumer))
|
||||
|
||||
const request = gpiod.gpiod_chip_request_lines(chip, requestConfig, lineConfig)
|
||||
gpiod.gpiod_request_config_free(requestConfig)
|
||||
gpiod.gpiod_line_config_free(lineConfig)
|
||||
|
||||
if (!request) {
|
||||
gpiod.gpiod_chip_close(chip)
|
||||
cleanup("Failed to request GPIO line")
|
||||
}
|
||||
|
||||
return { chip: chip!, request: request! }
|
||||
}
|
||||
|
||||
export const requestInputLine = (config: InputLineConfig): LineRequestResult => {
|
||||
return requestLine(config.chipPath, config.offset, "bun-gpio-input", (settings) => {
|
||||
gpiod.gpiod_line_settings_set_direction(settings, GPIOD_LINE_DIRECTION_INPUT)
|
||||
gpiod.gpiod_line_settings_set_bias(settings, mapPullToLibgpiod(config.pull))
|
||||
gpiod.gpiod_line_settings_set_edge_detection(settings, mapEdgeToLibgpiod(config.edge))
|
||||
|
||||
if (config.debounce > 0) {
|
||||
gpiod.gpiod_line_settings_set_debounce_period_us(settings, config.debounce * 1000)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export const requestOutputLine = (config: OutputLineConfig): LineRequestResult => {
|
||||
return requestLine(config.chipPath, config.offset, "bun-gpio", (settings) => {
|
||||
gpiod.gpiod_line_settings_set_direction(settings, GPIOD_LINE_DIRECTION_OUTPUT)
|
||||
gpiod.gpiod_line_settings_set_output_value(settings, config.initialValue)
|
||||
})
|
||||
}
|
||||
199
src/pins/gpio.ts
199
src/pins/gpio.ts
|
|
@ -1,199 +0,0 @@
|
|||
import { ptr } from "bun:ffi"
|
||||
import { readdir } from "node:fs/promises"
|
||||
import { gpiod, cstr, GPIOD_LINE_DIRECTION_OUTPUT, GPIOD_LINE_DIRECTION_INPUT } from "./ffi"
|
||||
import { OutputPin } from "./output"
|
||||
import { InputPin } from "./input"
|
||||
import { InputGroup } from "./input-group"
|
||||
import { ChipNotFoundError, PinInUseError } from "./errors"
|
||||
import { mapPullToLibgpiod, mapEdgeToLibgpiod, hashInputConfig } from "./utils"
|
||||
import type { OutputOptions, InputOptions, PullMode, ChipInfo } from "./types"
|
||||
|
||||
export class GPIO {
|
||||
#chipPath: string
|
||||
#resetOnClose: boolean
|
||||
|
||||
constructor(options?: { chip?: string; resetOnClose?: boolean }) {
|
||||
this.#chipPath = options?.chip ?? "/dev/gpiochip0"
|
||||
this.#resetOnClose = options?.resetOnClose ?? false
|
||||
}
|
||||
|
||||
output(pin: number, options?: OutputOptions): OutputPin {
|
||||
const initialValue = options?.initialValue ?? 0
|
||||
|
||||
const chip = gpiod.gpiod_chip_open(cstr(this.#chipPath))
|
||||
if (!chip) {
|
||||
throw new ChipNotFoundError(this.#chipPath)
|
||||
}
|
||||
|
||||
try {
|
||||
const reqConfig = gpiod.gpiod_request_config_new()
|
||||
gpiod.gpiod_request_config_set_consumer(reqConfig, cstr("bun-gpio"))
|
||||
|
||||
const lineSettings = gpiod.gpiod_line_settings_new()
|
||||
gpiod.gpiod_line_settings_set_direction(lineSettings, GPIOD_LINE_DIRECTION_OUTPUT)
|
||||
gpiod.gpiod_line_settings_set_output_value(lineSettings, initialValue)
|
||||
|
||||
const lineConfig = gpiod.gpiod_line_config_new()
|
||||
const offsets = new Uint32Array([pin])
|
||||
gpiod.gpiod_line_config_add_line_settings(lineConfig, ptr(offsets), 1, lineSettings)
|
||||
|
||||
const request = gpiod.gpiod_chip_request_lines(chip, reqConfig, lineConfig)
|
||||
|
||||
gpiod.gpiod_line_settings_free(lineSettings)
|
||||
gpiod.gpiod_line_config_free(lineConfig)
|
||||
gpiod.gpiod_request_config_free(reqConfig)
|
||||
|
||||
if (!request) {
|
||||
gpiod.gpiod_chip_close(chip)
|
||||
throw new PinInUseError(pin)
|
||||
}
|
||||
|
||||
let resetValue: 0 | 1 | undefined
|
||||
if (this.#resetOnClose) {
|
||||
const currentValue = gpiod.gpiod_line_request_get_value(request, pin)
|
||||
if (currentValue === -1) {
|
||||
console.warn(`Failed to read initial value for pin ${pin}, assuming 0`)
|
||||
resetValue = 0
|
||||
} else {
|
||||
resetValue = currentValue as 0 | 1
|
||||
}
|
||||
}
|
||||
|
||||
return new OutputPin(chip, request, pin, resetValue)
|
||||
} catch (err) {
|
||||
gpiod.gpiod_chip_close(chip)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
input(pin: number, options?: InputOptions): InputPin<"pin"> {
|
||||
const group = this.inputGroup({
|
||||
pin: { pin, ...options },
|
||||
})
|
||||
|
||||
return new InputPin(group, "pin")
|
||||
}
|
||||
|
||||
inputGroup<T extends Record<string, { pin: number } & InputOptions>>(
|
||||
config: T
|
||||
): InputGroup<keyof T & string> {
|
||||
const chip = gpiod.gpiod_chip_open(cstr(this.#chipPath))
|
||||
if (!chip) {
|
||||
throw new ChipNotFoundError(this.#chipPath)
|
||||
}
|
||||
|
||||
try {
|
||||
const reqConfig = gpiod.gpiod_request_config_new()
|
||||
gpiod.gpiod_request_config_set_consumer(reqConfig, cstr("bun-gpio"))
|
||||
|
||||
const lineConfig = gpiod.gpiod_line_config_new()
|
||||
|
||||
const groups = new Map<
|
||||
string,
|
||||
Array<{ name: string; pin: number; pull: PullMode; options: InputOptions }>
|
||||
>()
|
||||
|
||||
for (const [name, pinConfig] of Object.entries(config)) {
|
||||
const pull = pinConfig.pull ?? "up"
|
||||
const debounce = pinConfig.debounce ?? 0
|
||||
const edge = pinConfig.edge ?? "both"
|
||||
|
||||
const hash = hashInputConfig(pull, debounce, edge)
|
||||
if (!groups.has(hash)) groups.set(hash, [])
|
||||
groups.get(hash)!.push({ name, pin: pinConfig.pin, pull, options: pinConfig })
|
||||
}
|
||||
|
||||
for (const [hash, pins] of groups) {
|
||||
const firstPin = pins[0]
|
||||
if (!firstPin) continue
|
||||
|
||||
const pull = firstPin.options.pull ?? "up"
|
||||
const debounce = firstPin.options.debounce ?? 0
|
||||
const edge = firstPin.options.edge ?? "both"
|
||||
|
||||
const lineSettings = gpiod.gpiod_line_settings_new()
|
||||
gpiod.gpiod_line_settings_set_direction(lineSettings, GPIOD_LINE_DIRECTION_INPUT)
|
||||
gpiod.gpiod_line_settings_set_bias(lineSettings, mapPullToLibgpiod(pull))
|
||||
gpiod.gpiod_line_settings_set_edge_detection(lineSettings, mapEdgeToLibgpiod(edge))
|
||||
gpiod.gpiod_line_settings_set_debounce_period_us(lineSettings, debounce * 1000)
|
||||
|
||||
const offsets = new Uint32Array(pins.map((p) => p.pin))
|
||||
gpiod.gpiod_line_config_add_line_settings(
|
||||
lineConfig,
|
||||
ptr(offsets),
|
||||
pins.length,
|
||||
lineSettings
|
||||
)
|
||||
|
||||
gpiod.gpiod_line_settings_free(lineSettings)
|
||||
}
|
||||
|
||||
const request = gpiod.gpiod_chip_request_lines(chip, reqConfig, lineConfig)
|
||||
|
||||
gpiod.gpiod_line_config_free(lineConfig)
|
||||
gpiod.gpiod_request_config_free(reqConfig)
|
||||
|
||||
if (!request) {
|
||||
gpiod.gpiod_chip_close(chip)
|
||||
const firstConfig = Object.values(config)[0]
|
||||
throw new PinInUseError(firstConfig?.pin ?? 0)
|
||||
}
|
||||
|
||||
const pinMap: Record<string, { offset: number; pull: PullMode }> = {}
|
||||
for (const [name, pinConfig] of Object.entries(config)) {
|
||||
pinMap[name] = {
|
||||
offset: pinConfig.pin,
|
||||
pull: pinConfig.pull ?? "up",
|
||||
}
|
||||
}
|
||||
|
||||
return new InputGroup(chip, request, pinMap)
|
||||
} catch (err) {
|
||||
gpiod.gpiod_chip_close(chip)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
async listChips(): Promise<ChipInfo[]> {
|
||||
const chips: ChipInfo[] = []
|
||||
|
||||
try {
|
||||
const files = await readdir("/dev")
|
||||
const chipFiles = files.filter((f) => f.startsWith("gpiochip"))
|
||||
|
||||
for (const file of chipFiles) {
|
||||
const path = `/dev/${file}`
|
||||
|
||||
try {
|
||||
const chip = gpiod.gpiod_chip_open(cstr(path))
|
||||
if (!chip) continue
|
||||
|
||||
const info = gpiod.gpiod_chip_get_info(chip)
|
||||
if (!info) {
|
||||
gpiod.gpiod_chip_close(chip)
|
||||
continue
|
||||
}
|
||||
|
||||
const name = gpiod.gpiod_chip_info_get_name(info)
|
||||
const label = gpiod.gpiod_chip_info_get_label(info)
|
||||
const numLines = gpiod.gpiod_chip_info_get_num_lines(info)
|
||||
|
||||
chips.push({
|
||||
path,
|
||||
name: String(name || ""),
|
||||
label: String(label || ""),
|
||||
numLines: Number(numLines),
|
||||
})
|
||||
|
||||
gpiod.gpiod_chip_close(chip)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// /dev might not be accessible, return empty array
|
||||
}
|
||||
|
||||
return chips
|
||||
}
|
||||
}
|
||||
|
|
@ -1,17 +1,87 @@
|
|||
export { GPIO } from "./gpio"
|
||||
export {
|
||||
import { readdir } from "node:fs/promises"
|
||||
import { gpiod, cstr } from "./ffi"
|
||||
import { Output } from "./output"
|
||||
import { Input } from "./input"
|
||||
import type * as Type from "./types"
|
||||
import {
|
||||
GPIOError,
|
||||
PermissionError,
|
||||
PinInUseError,
|
||||
ChipNotFoundError,
|
||||
InvalidConfigError,
|
||||
} from "./errors"
|
||||
export type {
|
||||
InputOptions,
|
||||
OutputOptions,
|
||||
InputEvent,
|
||||
InputGroupEvent,
|
||||
ChipInfo,
|
||||
PullMode,
|
||||
EdgeMode,
|
||||
} from "./types"
|
||||
|
||||
class GPIO {
|
||||
#chipPath: string
|
||||
|
||||
constructor(options?: { chip?: string }) {
|
||||
this.#chipPath = options?.chip ?? "/dev/gpiochip0"
|
||||
}
|
||||
|
||||
output(pin: number, options?: Type.OutputOptions): Output {
|
||||
return new Output(this.#chipPath, pin, options)
|
||||
}
|
||||
|
||||
input(pin: number, options?: Type.InputOptions): Input {
|
||||
return new Input(this.#chipPath, pin, options)
|
||||
}
|
||||
|
||||
async listChips(): Promise<Type.ChipInfo[]> {
|
||||
const chips: Type.ChipInfo[] = []
|
||||
|
||||
try {
|
||||
const files = await readdir("/dev")
|
||||
const chipFiles = files.filter((f) => f.startsWith("gpiochip"))
|
||||
|
||||
for (const file of chipFiles) {
|
||||
const path = `/dev/${file}`
|
||||
|
||||
try {
|
||||
const chip = gpiod.gpiod_chip_open(cstr(path))
|
||||
if (!chip) continue
|
||||
|
||||
const info = gpiod.gpiod_chip_get_info(chip)
|
||||
if (!info) {
|
||||
gpiod.gpiod_chip_close(chip)
|
||||
continue
|
||||
}
|
||||
|
||||
const name = gpiod.gpiod_chip_info_get_name(info)
|
||||
const label = gpiod.gpiod_chip_info_get_label(info)
|
||||
const numLines = gpiod.gpiod_chip_info_get_num_lines(info)
|
||||
|
||||
chips.push({
|
||||
path,
|
||||
name: String(name || ""),
|
||||
label: String(label || ""),
|
||||
numLines: Number(numLines),
|
||||
})
|
||||
|
||||
gpiod.gpiod_chip_close(chip)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// /dev might not be accessible, return empty array
|
||||
}
|
||||
|
||||
return chips
|
||||
}
|
||||
|
||||
static Error = GPIOError
|
||||
static PermissionError = PermissionError
|
||||
static PinInUseError = PinInUseError
|
||||
static ChipNotFoundError = ChipNotFoundError
|
||||
static InvalidConfigError = InvalidConfigError
|
||||
}
|
||||
|
||||
namespace GPIO {
|
||||
export type PullMode = Type.PullMode
|
||||
export type EdgeMode = Type.EdgeMode
|
||||
export type InputOptions = Type.InputOptions
|
||||
export type OutputOptions = Type.OutputOptions
|
||||
export type InputEvent = Type.InputEvent
|
||||
}
|
||||
|
||||
export default GPIO
|
||||
|
|
|
|||
|
|
@ -1,194 +0,0 @@
|
|||
import type { Pointer } from "bun:ffi"
|
||||
import { gpiod } from "./ffi"
|
||||
import { mapLibgpiodEdgeToPressedState } from "./utils"
|
||||
import type { PullMode, InputEvent, InputGroupEvent, PinConfig } from "./types"
|
||||
|
||||
export class InputGroup<T extends string = string> {
|
||||
#closed = false
|
||||
#chip: Pointer
|
||||
#request: Pointer
|
||||
#pinMap: Map<string, { offset: number; pull: PullMode }>
|
||||
#offsetMap: Map<number, { name: string; pull: PullMode }>
|
||||
#eventBuffer: Pointer | undefined
|
||||
#eventListeners: Array<(event: InputGroupEvent<T>) => void> = []
|
||||
#closeHandlers: Array<() => void> = []
|
||||
|
||||
constructor(chip: Pointer, request: Pointer, pinConfig: PinConfig) {
|
||||
this.#chip = chip
|
||||
this.#request = request
|
||||
|
||||
this.#pinMap = new Map()
|
||||
this.#offsetMap = new Map()
|
||||
|
||||
for (const [name, config] of Object.entries(pinConfig)) {
|
||||
this.#pinMap.set(name, config)
|
||||
this.#offsetMap.set(config.offset, { name, pull: config.pull })
|
||||
}
|
||||
}
|
||||
|
||||
get pins(): Record<T, { readonly value: 0 | 1; waitForValue: (targetValue: 0 | 1, timeout?: number) => Promise<void> }> {
|
||||
const result = {} as Record<T, { readonly value: 0 | 1; waitForValue: (targetValue: 0 | 1, timeout?: number) => Promise<void> }>
|
||||
|
||||
for (const [name, config] of this.#pinMap) {
|
||||
const offset = config.offset
|
||||
const closed = () => this.#closed
|
||||
const request = this.#request
|
||||
const pinName = name
|
||||
|
||||
Object.defineProperty(result, name, {
|
||||
get: () => ({
|
||||
get value(): 0 | 1 {
|
||||
if (closed()) throw new Error("InputGroup is closed")
|
||||
const ret = gpiod.gpiod_line_request_get_value(request, offset)
|
||||
if (ret === -1) throw new Error("Failed to get pin value")
|
||||
return ret as 0 | 1
|
||||
},
|
||||
waitForValue: (targetValue: 0 | 1, timeout?: number) => this.#waitForPinValue(pinName as T, targetValue, timeout)
|
||||
}),
|
||||
enumerable: true
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
async #waitForPinValue(pinName: T, targetValue: 0 | 1, timeout?: number): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (this.#closed) {
|
||||
reject(new Error("InputGroup is closed"))
|
||||
return
|
||||
}
|
||||
|
||||
let timeoutId: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
const cleanup = () => {
|
||||
if (timeoutId) clearTimeout(timeoutId)
|
||||
this.#eventListeners = this.#eventListeners.filter((l) => l !== listener)
|
||||
this.#closeHandlers = this.#closeHandlers.filter((h) => h !== onClose)
|
||||
}
|
||||
|
||||
const onClose = () => {
|
||||
cleanup()
|
||||
reject(new Error("InputGroup closed while waiting"))
|
||||
}
|
||||
|
||||
const listener = (event: InputGroupEvent<T>) => {
|
||||
if (event.pin !== pinName) return
|
||||
if (event.value !== targetValue) return
|
||||
|
||||
cleanup()
|
||||
resolve()
|
||||
}
|
||||
|
||||
if (timeout) {
|
||||
timeoutId = setTimeout(() => {
|
||||
cleanup()
|
||||
reject(new Error(`Timeout waiting for pin ${pinName} to become ${targetValue}`))
|
||||
}, timeout)
|
||||
}
|
||||
|
||||
this.#eventListeners.push(listener)
|
||||
this.#closeHandlers.push(onClose)
|
||||
this.#startEventLoop()
|
||||
})
|
||||
}
|
||||
|
||||
async *events(): AsyncGenerator<InputGroupEvent<T>> {
|
||||
if (this.#closed) throw new Error("InputGroup is closed")
|
||||
|
||||
const eventQueue: InputGroupEvent<T>[] = []
|
||||
let resolve: (() => void) | undefined
|
||||
|
||||
const listener = (event: InputGroupEvent<T>) => {
|
||||
eventQueue.push(event)
|
||||
resolve?.()
|
||||
}
|
||||
|
||||
this.#eventListeners.push(listener)
|
||||
this.#startEventLoop()
|
||||
|
||||
try {
|
||||
while (!this.#closed) {
|
||||
if (eventQueue.length === 0) {
|
||||
await new Promise<void>((r) => {
|
||||
resolve = r
|
||||
})
|
||||
}
|
||||
|
||||
const event = eventQueue.shift()
|
||||
if (event) yield event
|
||||
}
|
||||
} finally {
|
||||
this.#eventListeners = this.#eventListeners.filter((l) => l !== listener)
|
||||
}
|
||||
}
|
||||
|
||||
#startEventLoop() {
|
||||
if (this.#eventBuffer !== undefined) return
|
||||
|
||||
const buffer = gpiod.gpiod_edge_event_buffer_new(1)
|
||||
if (!buffer) throw new Error("Failed to create event buffer")
|
||||
|
||||
this.#eventBuffer = buffer
|
||||
this.#runEventLoop()
|
||||
}
|
||||
|
||||
async #runEventLoop() {
|
||||
try {
|
||||
while (!this.#closed && this.#eventListeners.length > 0) {
|
||||
const ret = gpiod.gpiod_line_request_wait_edge_events(this.#request, 100_000_000)
|
||||
|
||||
if (ret === -1 || ret === 0) {
|
||||
await Bun.sleep(0)
|
||||
continue
|
||||
}
|
||||
|
||||
const numEvents = gpiod.gpiod_line_request_read_edge_events(
|
||||
this.#request,
|
||||
this.#eventBuffer!,
|
||||
1
|
||||
)
|
||||
|
||||
if (numEvents > 0) {
|
||||
const event = gpiod.gpiod_edge_event_buffer_get_event(this.#eventBuffer!, 0)
|
||||
const edgeType = gpiod.gpiod_edge_event_get_event_type(event)
|
||||
const timestamp = gpiod.gpiod_edge_event_get_timestamp_ns(event)
|
||||
const offset = gpiod.gpiod_edge_event_get_line_offset(event)
|
||||
|
||||
const pinInfo = this.#offsetMap.get(offset)
|
||||
if (!pinInfo) continue
|
||||
|
||||
const pressed = mapLibgpiodEdgeToPressedState(edgeType, pinInfo.pull)
|
||||
const value = (pressed ? (pinInfo.pull === "up" ? 0 : 1) : (pinInfo.pull === "up" ? 1 : 0)) as 0 | 1
|
||||
const inputEvent: InputGroupEvent<T> = { pin: pinInfo.name as T, value, timestamp }
|
||||
|
||||
for (const listener of this.#eventListeners) {
|
||||
listener(inputEvent)
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (this.#eventBuffer) {
|
||||
gpiod.gpiod_edge_event_buffer_free(this.#eventBuffer)
|
||||
this.#eventBuffer = undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
close() {
|
||||
if (this.#closed) return
|
||||
this.#closed = true
|
||||
|
||||
for (const handler of this.#closeHandlers) {
|
||||
handler()
|
||||
}
|
||||
this.#closeHandlers = []
|
||||
|
||||
gpiod.gpiod_line_request_release(this.#request)
|
||||
gpiod.gpiod_chip_close(this.#chip)
|
||||
}
|
||||
|
||||
[Symbol.dispose]() {
|
||||
this.close()
|
||||
}
|
||||
}
|
||||
84
src/pins/input-worker.ts
Normal file
84
src/pins/input-worker.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import { gpiod, GPIOD_EDGE_EVENT_RISING_EDGE } from "./ffi"
|
||||
import { requestInputLine } from "./gpio-helpers"
|
||||
import type { PullMode, EdgeMode } from "./types"
|
||||
|
||||
type WorkerConfig = {
|
||||
chipPath: string
|
||||
offset: number
|
||||
pull: PullMode
|
||||
debounce: number
|
||||
edge: EdgeMode
|
||||
}
|
||||
|
||||
type WorkerMessage =
|
||||
| { type: "ready"; initialValue: 0 | 1 }
|
||||
| { type: "event"; value: 0 | 1; timestamp: bigint }
|
||||
| { type: "error"; message: string }
|
||||
|
||||
const postMessage = (message: WorkerMessage) => {
|
||||
self.postMessage(message)
|
||||
}
|
||||
|
||||
const cleanup = (message: string): never => {
|
||||
postMessage({ type: "error", message })
|
||||
self.close()
|
||||
throw new Error(message)
|
||||
}
|
||||
|
||||
const mapEdgeToValue = (edgeType: number, pull: PullMode): 0 | 1 => {
|
||||
// Pull-up: rising edge = released (1), falling edge = pressed (0)
|
||||
// Pull-down: rising edge = pressed (1), falling edge = released (0)
|
||||
if (pull === "up") {
|
||||
return edgeType === GPIOD_EDGE_EVENT_RISING_EDGE ? 1 : 0
|
||||
}
|
||||
return edgeType === GPIOD_EDGE_EVENT_RISING_EDGE ? 1 : 0
|
||||
}
|
||||
|
||||
const run = (config: WorkerConfig) => {
|
||||
const { chip, request } = requestInputLine(config)
|
||||
|
||||
const initialValue = gpiod.gpiod_line_request_get_value(request, config.offset)
|
||||
if (initialValue === -1) {
|
||||
gpiod.gpiod_line_request_release(request)
|
||||
gpiod.gpiod_chip_close(chip)
|
||||
cleanup("Failed to read initial value")
|
||||
}
|
||||
|
||||
postMessage({ type: "ready", initialValue: initialValue as 0 | 1 })
|
||||
|
||||
const buffer = gpiod.gpiod_edge_event_buffer_new(1)
|
||||
if (!buffer) {
|
||||
gpiod.gpiod_line_request_release(request)
|
||||
gpiod.gpiod_chip_close(chip)
|
||||
cleanup("Failed to create event buffer")
|
||||
}
|
||||
|
||||
while (true) {
|
||||
// Block forever (-1 timeout) until edge event occurs
|
||||
const waitResult = gpiod.gpiod_line_request_wait_edge_events(request, -1)
|
||||
|
||||
if (waitResult === 1) {
|
||||
const numEvents = gpiod.gpiod_line_request_read_edge_events(request, buffer, 1)
|
||||
if (numEvents === -1) cleanup("Failed to read edge events")
|
||||
|
||||
const event = gpiod.gpiod_edge_event_buffer_get_event(buffer, 0)
|
||||
const edgeType = gpiod.gpiod_edge_event_get_event_type(event)
|
||||
const timestamp = gpiod.gpiod_edge_event_get_timestamp_ns(event)
|
||||
|
||||
const value = mapEdgeToValue(edgeType, config.pull)
|
||||
|
||||
postMessage({ type: "event", value, timestamp })
|
||||
} else if (waitResult === -1) {
|
||||
cleanup("GPIO wait_edge_events failed")
|
||||
}
|
||||
}
|
||||
|
||||
// Worker terminates - kernel cleans up GPIO resources automatically
|
||||
}
|
||||
|
||||
self.onmessage = (event: MessageEvent<WorkerConfig>) => {
|
||||
self.onmessage = () => {
|
||||
throw new Error("Worker already initialized")
|
||||
}
|
||||
run(event.data)
|
||||
}
|
||||
|
|
@ -1,36 +1,90 @@
|
|||
import type { Pointer } from "bun:ffi"
|
||||
import { InputGroup } from "./input-group"
|
||||
import type { PullMode, InputEvent } from "./types"
|
||||
import type { InputEvent, InputOptions } from "./types"
|
||||
|
||||
export class InputPin<T extends string = string> {
|
||||
#group: InputGroup<T>
|
||||
#pinName: T
|
||||
type WorkerMessage =
|
||||
| { type: "ready"; initialValue: 0 | 1 }
|
||||
| { type: "event"; value: 0 | 1; timestamp: bigint }
|
||||
| { type: "error"; message: string }
|
||||
|
||||
constructor(group: InputGroup<T>, pinName: T) {
|
||||
this.#group = group
|
||||
this.#pinName = pinName
|
||||
export class Input {
|
||||
#worker: Worker
|
||||
#callbacks = new Set<(event: InputEvent) => void>()
|
||||
#closed = false
|
||||
#lastValue: 0 | 1 = 0
|
||||
|
||||
constructor(chipPath: string, offset: number, options: InputOptions = {}) {
|
||||
const pull = options.pull ?? "up"
|
||||
const debounce = options.debounce ?? 0
|
||||
const edge = options.edge ?? "both"
|
||||
|
||||
this.#worker = new Worker(new URL("./input-worker.ts", import.meta.url).href)
|
||||
|
||||
this.#worker.onmessage = (msg: MessageEvent<WorkerMessage>) => {
|
||||
if (this.#closed) return
|
||||
|
||||
const data = msg.data
|
||||
|
||||
if (data.type === "ready") {
|
||||
this.#lastValue = data.initialValue
|
||||
} else if (data.type === "event") {
|
||||
this.#lastValue = data.value
|
||||
for (const callback of this.#callbacks) {
|
||||
callback({ value: data.value, timestamp: data.timestamp })
|
||||
}
|
||||
} else if (data.type === "error") {
|
||||
console.error(`GPIO Input Error (pin ${offset}):`, data.message)
|
||||
}
|
||||
}
|
||||
|
||||
this.#worker.postMessage({ chipPath, offset, pull, debounce, edge })
|
||||
}
|
||||
|
||||
get value(): 0 | 1 {
|
||||
return this.#group.pins[this.#pinName]!.value
|
||||
if (this.#closed) throw new Error("Input is closed")
|
||||
return this.#lastValue
|
||||
}
|
||||
|
||||
onChange(callback: (event: InputEvent) => void): () => void {
|
||||
if (this.#closed) throw new Error("Input is closed")
|
||||
this.#callbacks.add(callback)
|
||||
return () => this.#callbacks.delete(callback)
|
||||
}
|
||||
|
||||
async waitForValue(targetValue: 0 | 1, timeout?: number): Promise<void> {
|
||||
for await (const event of this.#group.events()) {
|
||||
if (event.value === targetValue) {
|
||||
return
|
||||
}
|
||||
}
|
||||
if (this.#closed) throw new Error("Input is closed")
|
||||
|
||||
if (this.#lastValue === targetValue) return
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let timeoutId: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
const cleanup = () => {
|
||||
if (timeoutId) clearTimeout(timeoutId)
|
||||
unsubscribe()
|
||||
}
|
||||
|
||||
async *events(): AsyncGenerator<InputEvent> {
|
||||
for await (const event of this.#group.events()) {
|
||||
yield { value: event.value, timestamp: event.timestamp }
|
||||
const unsubscribe = this.onChange((event) => {
|
||||
if (event.value === targetValue) {
|
||||
cleanup()
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
|
||||
if (!timeout) return
|
||||
|
||||
timeoutId = setTimeout(() => {
|
||||
cleanup()
|
||||
reject(new Error(`Timeout waiting for value ${targetValue}`))
|
||||
}, timeout)
|
||||
})
|
||||
}
|
||||
|
||||
close() {
|
||||
this.#group.close()
|
||||
if (this.#closed) return
|
||||
this.#closed = true
|
||||
|
||||
this.#callbacks.clear()
|
||||
this.#worker.onmessage = null
|
||||
this.#worker.terminate()
|
||||
}
|
||||
|
||||
[Symbol.dispose]() {
|
||||
|
|
|
|||
|
|
@ -1,30 +1,39 @@
|
|||
import type { Pointer } from "bun:ffi"
|
||||
import { gpiod } from "./ffi"
|
||||
import { requestOutputLine } from "./gpio-helpers"
|
||||
import type { OutputOptions } from "./types"
|
||||
|
||||
export class OutputPin {
|
||||
export class Output {
|
||||
#closed = false
|
||||
#chip: Pointer
|
||||
#request: Pointer
|
||||
#pin: number
|
||||
#offset: number
|
||||
#resetValue?: 0 | 1
|
||||
|
||||
constructor(chip: Pointer, request: Pointer, pin: number, resetValue?: 0 | 1) {
|
||||
constructor(chipPath: string, offset: number, options: OutputOptions = {}) {
|
||||
const initialValue = options.initialValue ?? 0
|
||||
const { chip, request } = requestOutputLine({ chipPath, offset, initialValue })
|
||||
|
||||
this.#chip = chip
|
||||
this.#request = request
|
||||
this.#pin = pin
|
||||
this.#resetValue = resetValue
|
||||
this.#offset = offset
|
||||
|
||||
if (options.resetOnClose) {
|
||||
const currentValue = gpiod.gpiod_line_request_get_value(request, offset)
|
||||
this.#resetValue = currentValue === -1 ? 0 : (currentValue as 0 | 1)
|
||||
}
|
||||
}
|
||||
|
||||
get value(): 0 | 1 {
|
||||
if (this.#closed) throw new Error("OutputPin is closed")
|
||||
const ret = gpiod.gpiod_line_request_get_value(this.#request, this.#pin)
|
||||
if (this.#closed) throw new Error("Output is closed")
|
||||
const ret = gpiod.gpiod_line_request_get_value(this.#request, this.#offset)
|
||||
if (ret === -1) throw new Error("Failed to get pin value")
|
||||
return ret as 0 | 1
|
||||
}
|
||||
|
||||
set value(val: 0 | 1) {
|
||||
if (this.#closed) throw new Error("OutputPin is closed")
|
||||
const ret = gpiod.gpiod_line_request_set_value(this.#request, this.#pin, val)
|
||||
if (this.#closed) throw new Error("Output is closed")
|
||||
const ret = gpiod.gpiod_line_request_set_value(this.#request, this.#offset, val)
|
||||
if (ret === -1) throw new Error("Failed to set pin value")
|
||||
}
|
||||
|
||||
|
|
@ -37,7 +46,7 @@ export class OutputPin {
|
|||
this.#closed = true
|
||||
|
||||
if (this.#resetValue !== undefined) {
|
||||
gpiod.gpiod_line_request_set_value(this.#request, this.#pin, this.#resetValue)
|
||||
gpiod.gpiod_line_request_set_value(this.#request, this.#offset, this.#resetValue)
|
||||
}
|
||||
|
||||
gpiod.gpiod_line_request_release(this.#request)
|
||||
|
|
|
|||
7
src/pins/tsconfig.worker.json
Normal file
7
src/pins/tsconfig.worker.json
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["ESNext", "WebWorker"]
|
||||
},
|
||||
"include": ["input-worker.ts"]
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@ export type InputOptions = {
|
|||
|
||||
export type OutputOptions = {
|
||||
initialValue?: 0 | 1 // default: 0
|
||||
resetOnClose?: boolean // default: false
|
||||
}
|
||||
|
||||
export type InputEvent = {
|
||||
|
|
@ -16,15 +17,9 @@ export type InputEvent = {
|
|||
timestamp: bigint // nanoseconds
|
||||
}
|
||||
|
||||
export type InputGroupEvent<T extends string = string> = InputEvent & {
|
||||
pin: T // name of the pin that fired
|
||||
}
|
||||
|
||||
export type ChipInfo = {
|
||||
path: string
|
||||
name: string
|
||||
label: string
|
||||
numLines: number
|
||||
}
|
||||
|
||||
export type PinConfig = Record<string, { offset: number; pull: PullMode }>
|
||||
|
|
|
|||
|
|
@ -38,10 +38,7 @@ export const mapEdgeToLibgpiod = (edge: EdgeMode): number => {
|
|||
// Hardware logic:
|
||||
// - Pull-up + button to GND: pressing pulls line LOW (falling edge = pressed)
|
||||
// - Pull-down + button to VCC: pressing pulls line HIGH (rising edge = pressed)
|
||||
export const mapLibgpiodEdgeToPressedState = (
|
||||
edgeType: number,
|
||||
pull: PullMode
|
||||
): boolean => {
|
||||
export const mapLibgpiodEdgeToPressedState = (edgeType: number, pull: PullMode): boolean => {
|
||||
if (pull === "up") {
|
||||
return edgeType === GPIOD_EDGE_EVENT_FALLING_EDGE
|
||||
} else if (pull === "down") {
|
||||
|
|
@ -51,10 +48,6 @@ export const mapLibgpiodEdgeToPressedState = (
|
|||
}
|
||||
}
|
||||
|
||||
export const hashInputConfig = (
|
||||
pull: PullMode,
|
||||
debounce: number,
|
||||
edge: EdgeMode
|
||||
): string => {
|
||||
export const hashInputConfig = (pull: PullMode, debounce: number, edge: EdgeMode): string => {
|
||||
return `${pull}-${debounce}-${edge}`
|
||||
}
|
||||
|
|
|
|||
114
src/sip.ts
Normal file
114
src/sip.ts
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
import { log } from "./utils/log.ts"
|
||||
import { Signal } from "./utils/signal.ts"
|
||||
import { processStdout, processStderr } from "./utils/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
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
|
||||
|
|
@ -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(async (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
|
||||
}
|
||||
})
|
||||
|
||||
39
src/test-pins.ts
Normal file
39
src/test-pins.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { GPIO } from "./pins"
|
||||
|
||||
console.log(`kill -9 ${process.pid}`)
|
||||
|
||||
const gpio = new GPIO()
|
||||
|
||||
using led = gpio.output(21)
|
||||
using button = gpio.input(20, { pull: "up", debounce: 10 })
|
||||
using switchInput = gpio.input(16, { pull: "up", debounce: 10 })
|
||||
|
||||
led.value = button.value
|
||||
|
||||
button.onChange((event) => {
|
||||
led.value = event.value
|
||||
})
|
||||
|
||||
const switchEvent = new Promise<void>(async (resolve) => {
|
||||
await switchInput.waitForValue(0)
|
||||
console.log("Switch pressed!")
|
||||
resolve()
|
||||
})
|
||||
|
||||
process.on("SIGINT", () => {
|
||||
button.close()
|
||||
switchInput.close()
|
||||
led.close()
|
||||
process.exit(0)
|
||||
})
|
||||
|
||||
process.on("SIGTERM", () => {
|
||||
button.close()
|
||||
switchInput.close()
|
||||
led.close()
|
||||
process.exit(0)
|
||||
})
|
||||
|
||||
await switchEvent
|
||||
|
||||
console.log(`👋 Goodbye!`)
|
||||
21
src/utils/log.ts
Normal file
21
src/utils/log.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
let showDebug = true
|
||||
let showInfo = true
|
||||
let showError = true
|
||||
|
||||
export function setLogLevel(level: "debug" | "info" | "error" | "none") {
|
||||
showDebug = level === "debug"
|
||||
showInfo = level === "debug" || level === "info"
|
||||
showError = level !== "none"
|
||||
}
|
||||
|
||||
export const log = {
|
||||
debug: (...args: any[]) => {
|
||||
if (showDebug) console.debug("DEBUG: ", ...args)
|
||||
},
|
||||
info: (...args: any[]) => {
|
||||
if (showInfo) console.log("INFO: ", ...args)
|
||||
},
|
||||
error: (...args: any[]) => {
|
||||
if (showError) console.error("ERROR: ", ...args)
|
||||
},
|
||||
}
|
||||
41
src/utils/stdio.ts
Normal file
41
src/utils/stdio.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import { log } from "./log.ts"
|
||||
|
||||
export const LineSplitter = () => {
|
||||
let buffer = ""
|
||||
return new TransformStream({
|
||||
transform(chunk, controller) {
|
||||
buffer += chunk
|
||||
const parts = buffer.split(/\n/)
|
||||
const lines = parts.slice(0, -1)
|
||||
buffer = parts.at(-1) || ""
|
||||
|
||||
for (const line of lines) {
|
||||
controller.enqueue(line)
|
||||
}
|
||||
},
|
||||
flush(controller) {
|
||||
if (buffer.length > 0) {
|
||||
controller.enqueue(buffer)
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export async function processStdout(
|
||||
process: Bun.ReadableSubprocess,
|
||||
onLine: (line: string) => void
|
||||
) {
|
||||
for await (const line of process.stdout
|
||||
.pipeThrough(new TextDecoderStream())
|
||||
.pipeThrough(LineSplitter())) {
|
||||
onLine(line)
|
||||
}
|
||||
}
|
||||
|
||||
export async function processStderr(process: Bun.ReadableSubprocess, prefix: string = "") {
|
||||
for await (const line of process.stderr
|
||||
.pipeThrough(new TextDecoderStream())
|
||||
.pipeThrough(LineSplitter())) {
|
||||
log.error(`❌ ${prefix}${line}`)
|
||||
}
|
||||
}
|
||||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -26,5 +26,6 @@
|
|||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noPropertyAccessFromIndexSignature": false
|
||||
}
|
||||
},
|
||||
"exclude": ["src/pins/input-worker.ts"]
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user