diff --git a/src/server/audio.ts b/src/server/audio.ts index 437bcf7..ad1a2d0 100644 --- a/src/server/audio.ts +++ b/src/server/audio.ts @@ -1,5 +1,6 @@ import factory from 'ggwave' import { getAudioSetup } from './audio-setup' +import { setRgbLedState } from './rgbled' export const SAMPLE_RATE = 48000 @@ -155,28 +156,35 @@ export async function loopbackTest(): Promise { export async function playAudio(text: string) { playing = true + setRgbLedState('chirping') - const waveform = ggwave.encode( - instance, text, - ggwave.ProtocolId.GGWAVE_PROTOCOL_AUDIBLE_FAST, - 50 - ) - const rawBytes = new Uint8Array(waveform.length) - rawBytes.set(new Uint8Array(waveform.buffer, waveform.byteOffset, waveform.length)) + try { + const waveform = ggwave.encode( + instance, text, + ggwave.ProtocolId.GGWAVE_PROTOCOL_AUDIBLE_FAST, + 50 + ) + const rawBytes = new Uint8Array(waveform.length) + rawBytes.set(new Uint8Array(waveform.buffer, waveform.byteOffset, waveform.length)) - const play = createPlaybackProcess() - const errorPromise = getText(play.stderr) - play.stdin.write(rawBytes) - play.stdin.end() - await play.exited + const play = createPlaybackProcess() + const errorPromise = getText(play.stderr) + play.stdin.write(rawBytes) + play.stdin.end() + await play.exited - logProcessError('Playback error', await errorPromise) + logProcessError('Playback error', await errorPromise) - await new Promise(r => setTimeout(r, 300)) - playing = false + await new Promise(r => setTimeout(r, 300)) + } finally { + playing = false + setRgbLedState('listening') + } } export function startMicListener(onMessage: (text: string) => void) { + setRgbLedState('listening') + const sox = createRecordingProcess() const errorPromise = getText(sox.stderr) const reader = sox.stdout.getReader() diff --git a/src/server/rgbled.ts b/src/server/rgbled.ts new file mode 100644 index 0000000..765a52f --- /dev/null +++ b/src/server/rgbled.ts @@ -0,0 +1,165 @@ +import { existsSync } from 'node:fs' + +const LED_PATTERNS: Record, 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 + +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 | 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) +} diff --git a/src/server/terminal.ts b/src/server/terminal.ts index 08e46b5..1db4d96 100644 --- a/src/server/terminal.ts +++ b/src/server/terminal.ts @@ -3,6 +3,7 @@ import { loopbackTest, playAudio, startMicListener } from './audio' import { getSecret, handleGuess } from './game' import type { GuessResult } from './game' import { getLocalServerUrls, isLocalTlsEnabled } from './local-tls' +import { getRgbLedStatus, setRgbLedState } from './rgbled' import pkg from '../../package.json' const RESET = '\x1b[0m' @@ -184,16 +185,21 @@ async function onMessage(text: string) { } 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) { if (!commandExists('sox')) { + setRgbLedState('error') console.error(`\n ${BOLD}sox is not installed.${RESET} Run: ${getSoxInstallCommand()}\n`) process.exit(1) } let audioSetup = getAudioSetup() + const rgbLedStatus = getRgbLedStatus() clearScreen() console.log() @@ -201,6 +207,10 @@ export async function startup(port: number) { console.log() console.log(`${BOLD}Speaker:${RESET} ${GREEN}${audioSetup.playbackLabel}${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() 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() + setRgbLedState('startup') const loopback = await loopbackTest() if (loopback.ok) { console.log(`${GREEN}${BOLD}✓ Audio working!${RESET} ${DIM}Speaker → mic pipeline verified${RESET}`) } else { + setRgbLedState('error') logLoopbackFailureDetails(loopback) 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() + setRgbLedState('listening') startMicListener(onMessage) }