play with LEDs
This commit is contained in:
parent
f434137f11
commit
8585d7e87f
|
|
@ -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
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 { 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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user