forked from probablycorey/baudy
play with LEDs
This commit is contained in:
parent
f434137f11
commit
8585d7e87f
|
|
@ -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<LoopbackTestResult> {
|
|||
|
||||
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()
|
||||
|
|
|
|||
165
src/server/rgbled.ts
Normal file
165
src/server/rgbled.ts
Normal 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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user