play with LEDs

This commit is contained in:
Pat Nakajima 2026-04-18 19:39:11 -07:00
parent f434137f11
commit 8585d7e87f
3 changed files with 202 additions and 16 deletions

View File

@ -1,5 +1,6 @@
import factory from 'ggwave' import factory from 'ggwave'
import { getAudioSetup } from './audio-setup' import { getAudioSetup } from './audio-setup'
import { setRgbLedState } from './rgbled'
export const SAMPLE_RATE = 48000 export const SAMPLE_RATE = 48000
@ -155,7 +156,9 @@ export async function loopbackTest(): Promise<LoopbackTestResult> {
export async function playAudio(text: string) { export async function playAudio(text: string) {
playing = true playing = true
setRgbLedState('chirping')
try {
const waveform = ggwave.encode( const waveform = ggwave.encode(
instance, text, instance, text,
ggwave.ProtocolId.GGWAVE_PROTOCOL_AUDIBLE_FAST, ggwave.ProtocolId.GGWAVE_PROTOCOL_AUDIBLE_FAST,
@ -173,10 +176,15 @@ export async function playAudio(text: string) {
logProcessError('Playback error', await errorPromise) logProcessError('Playback error', await errorPromise)
await new Promise(r => setTimeout(r, 300)) await new Promise(r => setTimeout(r, 300))
} finally {
playing = false playing = false
setRgbLedState('listening')
}
} }
export function startMicListener(onMessage: (text: string) => void) { export function startMicListener(onMessage: (text: string) => void) {
setRgbLedState('listening')
const sox = createRecordingProcess() const sox = createRecordingProcess()
const errorPromise = getText(sox.stderr) const errorPromise = getText(sox.stderr)
const reader = sox.stdout.getReader() const reader = sox.stdout.getReader()

165
src/server/rgbled.ts Normal file
View File

@ -0,0 +1,165 @@
import { existsSync } from 'node:fs'
const LED_PATTERNS: Record<Exclude<RgbLedState, 'off'>, RgbLedPattern> = {
chirping: {
colors: ['331400', 'ff5500', 'ffcc00', 'ff5500', '331400'],
duration: '90ms',
repeat: true,
},
correct: {
colors: ['002200', '22cc55', 'ffdd55', '22cc55', '002200'],
duration: '140ms',
restoreState: 'listening',
},
error: {
colors: ['220000', 'ff0033', '220000', '220000', 'ff0033', '220000'],
duration: '120ms',
repeat: true,
},
listening: {
colors: ['001833', '0066ff', '33ccff', '0066ff', '001833'],
duration: '500ms',
repeat: true,
},
startup: {
colors: ['220022', 'ff00aa', 'ff8800', 'ffcc00', '00cc88', '3399ff'],
duration: '140ms',
repeat: true,
},
}
const RGBLED_BIN = process.env.BAUDY_RGBLED_BIN?.trim() || 'rgbled'
const RGBLED_ORDER = process.env.BAUDY_RGBLED_ORDER?.trim() || 'brg'
const SPI_DEVICE = '/dev/spidev0.0'
let activeProcess: SpawnedProcess | undefined
let available: boolean | undefined
let currentState: RgbLedState | undefined
let stateVersion = 0
interface RgbLedPattern {
colors: string[]
duration: string
repeat?: boolean
restoreState?: RgbLedState
}
export type RgbLedState = 'chirping' | 'correct' | 'error' | 'listening' | 'off' | 'startup'
type SpawnedProcess = ReturnType<typeof Bun.spawn>
export function getRgbLedStatus() {
if (!isRgbLedAvailable()) return
return `${RGBLED_BIN} (order ${RGBLED_ORDER}, animated)`
}
export function isRgbLedAvailable() {
if (available !== undefined) return available
if (process.platform !== 'linux') return available = false
if (process.env.BAUDY_RGBLED?.trim() === '0') return available = false
if (!existsSync(SPI_DEVICE)) return available = false
if (!commandExists(RGBLED_BIN)) return available = false
const probe = Bun.spawnSync([RGBLED_BIN, '--order', RGBLED_ORDER, 'off'], {
stdout: 'ignore',
stderr: 'ignore',
})
available = probe.exitCode === 0
return available
}
export function setRgbLedState(state: RgbLedState) {
if (!isRgbLedAvailable()) return false
currentState = state
stateVersion++
stopActiveProcess()
if (state === 'off') return runRgbLedSync(['off'])
const pattern = LED_PATTERNS[state]
const process = spawnPattern(pattern)
if (!process) return false
activeProcess = process
void trackPatternExit(process, pattern, state, stateVersion)
return true
}
function buildPatternArgs(pattern: RgbLedPattern) {
const args = [RGBLED_BIN, '--order', RGBLED_ORDER, 'lerp', ...pattern.colors, '--duration', pattern.duration]
if (pattern.repeat) args.push('--loop', 'true')
return args
}
function commandExists(command: string) {
if (command.includes('/')) return existsSync(command)
const proc = Bun.spawnSync(['which', command], {
stdout: 'ignore',
stderr: 'ignore',
})
return proc.exitCode === 0
}
function getText(stream: ReadableStream<Uint8Array> | null | undefined) {
if (!stream) return Promise.resolve('')
return new Response(stream).text().catch(() => '')
}
function runRgbLedSync(args: string[]) {
const proc = Bun.spawnSync([RGBLED_BIN, '--order', RGBLED_ORDER, ...args], {
stdout: 'ignore',
stderr: 'ignore',
})
if (proc.exitCode === 0) return true
available = false
return false
}
function spawnPattern(pattern: RgbLedPattern) {
try {
return Bun.spawn(buildPatternArgs(pattern), {
stdin: 'ignore',
stdout: 'ignore',
stderr: 'pipe',
})
} catch {
available = false
return
}
}
function stopActiveProcess() {
if (!activeProcess) return
try {
activeProcess.kill()
} catch {
// ignored
}
activeProcess = undefined
}
async function trackPatternExit(process: SpawnedProcess, pattern: RgbLedPattern, state: RgbLedState, version: number) {
const errorPromise = getText(process.stderr)
const exitCode = await process.exited
const error = (await errorPromise).trim()
if (activeProcess === process) activeProcess = undefined
if (version !== stateVersion || currentState !== state) return
if (exitCode !== 0) {
available = false
if (error) console.error(`RGB LED error: ${error.split('\n')[0]}`)
return
}
if (pattern.restoreState) setRgbLedState(pattern.restoreState)
}

View File

@ -3,6 +3,7 @@ import { loopbackTest, playAudio, startMicListener } from './audio'
import { getSecret, handleGuess } from './game' import { getSecret, handleGuess } from './game'
import type { GuessResult } from './game' import type { GuessResult } from './game'
import { getLocalServerUrls, isLocalTlsEnabled } from './local-tls' import { getLocalServerUrls, isLocalTlsEnabled } from './local-tls'
import { getRgbLedStatus, setRgbLedState } from './rgbled'
import pkg from '../../package.json' import pkg from '../../package.json'
const RESET = '\x1b[0m' const RESET = '\x1b[0m'
@ -184,16 +185,21 @@ async function onMessage(text: string) {
} }
const result = await handleGuess(text) const result = await handleGuess(text)
if (result) logGuess(result) if (!result) return
if (result.type === 'correct') setRgbLedState('correct')
logGuess(result)
} }
export async function startup(port: number) { export async function startup(port: number) {
if (!commandExists('sox')) { if (!commandExists('sox')) {
setRgbLedState('error')
console.error(`\n ${BOLD}sox is not installed.${RESET} Run: ${getSoxInstallCommand()}\n`) console.error(`\n ${BOLD}sox is not installed.${RESET} Run: ${getSoxInstallCommand()}\n`)
process.exit(1) process.exit(1)
} }
let audioSetup = getAudioSetup() let audioSetup = getAudioSetup()
const rgbLedStatus = getRgbLedStatus()
clearScreen() clearScreen()
console.log() console.log()
@ -201,6 +207,10 @@ export async function startup(port: number) {
console.log() console.log()
console.log(`${BOLD}Speaker:${RESET} ${GREEN}${audioSetup.playbackLabel}${RESET}`) console.log(`${BOLD}Speaker:${RESET} ${GREEN}${audioSetup.playbackLabel}${RESET}`)
console.log(`${BOLD}Microphone:${RESET} ${GREEN}${audioSetup.captureLabel}${RESET}`) console.log(`${BOLD}Microphone:${RESET} ${GREEN}${audioSetup.captureLabel}${RESET}`)
if (process.platform === 'linux') {
const ledValue = rgbLedStatus ? `${GREEN}${rgbLedStatus}${RESET}` : `${DIM}not available${RESET}`
console.log(`${BOLD}RGB LED:${RESET} ${ledValue}`)
}
console.log() console.log()
if (process.platform === 'linux' && isLowMixerLevel(audioSetup.mixerLevel)) { if (process.platform === 'linux' && isLowMixerLevel(audioSetup.mixerLevel)) {
@ -216,11 +226,13 @@ export async function startup(port: number) {
console.log(`${DIM}Testing audio: playing a chirp and listening for it...${RESET}`) console.log(`${DIM}Testing audio: playing a chirp and listening for it...${RESET}`)
console.log() console.log()
setRgbLedState('startup')
const loopback = await loopbackTest() const loopback = await loopbackTest()
if (loopback.ok) { if (loopback.ok) {
console.log(`${GREEN}${BOLD}✓ Audio working!${RESET} ${DIM}Speaker → mic pipeline verified${RESET}`) console.log(`${GREEN}${BOLD}✓ Audio working!${RESET} ${DIM}Speaker → mic pipeline verified${RESET}`)
} else { } else {
setRgbLedState('error')
logLoopbackFailureDetails(loopback) logLoopbackFailureDetails(loopback)
await maybePause(' Press Enter to continue anyway... ') await maybePause(' Press Enter to continue anyway... ')
} }
@ -277,5 +289,6 @@ export async function startup(port: number) {
console.log(`${DIM}🎤 Listening for guesses...${RESET}`) console.log(`${DIM}🎤 Listening for guesses...${RESET}`)
console.log() console.log()
setRgbLedState('listening')
startMicListener(onMessage) startMicListener(onMessage)
} }