Compare commits

..

No commits in common. "main" and "main" have entirely different histories.
main ... main

9 changed files with 119 additions and 846 deletions

View File

@ -19,15 +19,6 @@
- **Workaround**: Change the default input device in System Settings > Sound > Input, then use `sox -d`. Or install `switchaudio-osx` (`brew install switchaudio-osx`) to change it programmatically.
- **MacBook Air mic disabled when lid is closed**: If using a monitor, the laptop mic won't work. Use the monitor's mic (e.g., Studio Display) instead.
- **Sample rate must match**: ggwave needs matching sample rates for encode/decode. Browser uses 48000Hz. Server must also use 48000Hz — sox will resample from the device's native rate automatically.
- **macOS keeps the original self-signed local HTTPS flow**: On macOS the app serves HTTPS directly again, using the original `localhost` self-signed cert behavior.
## Linux Audio (sox + ALSA)
- **Mac-only helpers don't exist**: `SwitchAudioSource` and `scutil` are macOS-only.
- **`sox -d` may be wrong on Linux**: ALSA's `default` PCM can be playback-only. In that case, `sox -d` can play audio but fail to open the mic. Prefer explicit devices such as `plughw:0,1` for playback and `plughw:0,0` for capture.
- **Use env overrides when needed**: `BAUDY_CAPTURE_DEVICE=plughw:0,0 BAUDY_PLAYBACK_DEVICE=plughw:0,1 bun run index.tsx`
- **Low mixer gain can look like a decode bug**: If loopback hears only a tiny signal, check `amixer` and raise the speaker path (for example `amixer -q sset 'Speaker Analog' 100%`).
- **Prefer Tailscale Serve for phone access**: Run the app on local HTTP and put it behind `tailscale serve <port>` so the phone gets a real HTTPS `*.ts.net` origin for `getUserMedia()`.
## Half-Duplex Audio
@ -50,5 +41,4 @@
## getUserMedia Secure Context
- `navigator.mediaDevices` is `undefined` on non-secure origins. `*.local` mDNS addresses over HTTP are NOT treated as secure — only `localhost` and `127.0.0.1` are exempt.
- On Linux, the default fix is to run the app on local HTTP and expose it with `tailscale serve <port>` so the phone gets a valid HTTPS `*.ts.net` origin.
- On macOS, the app keeps its original self-signed local HTTPS flow. This works, but phones may show certificate warnings unless you trust the cert.
- Self-signed certs work but cause "connection is not private" browser warnings. For local dev, hosting the phone page on a separate HTTPS server or using a tunnel is cleaner.

View File

@ -1,139 +0,0 @@
const LINUX_AUDIO_TYPE = 'alsa'
const LOW_MIXER_PERCENT = 70
export interface AudioMixerLevel {
command: string
control: string
db?: number
percent: number
}
export interface AudioSetup {
captureArgs: string[]
captureLabel: string
mixerLevel?: AudioMixerLevel
playbackArgs: string[]
playbackLabel: string
}
interface LinuxDevice {
device: string
label: string
}
let audioSetup: AudioSetup | undefined
export function getAudioSetup() {
if (audioSetup) return audioSetup
audioSetup = process.platform === 'linux'
? getLinuxAudioSetup()
: getDefaultAudioSetup()
return audioSetup
}
export function isLowMixerLevel(level: AudioMixerLevel | undefined): level is AudioMixerLevel {
return !!level && level.percent < LOW_MIXER_PERCENT
}
export function refreshAudioSetup() {
audioSetup = undefined
return getAudioSetup()
}
export function setMixerLevel(control: string, percent: number) {
const proc = Bun.spawnSync(['amixer', '-q', 'sset', control, `${percent}%`], {
stdout: 'ignore',
stderr: 'ignore',
})
if (proc.exitCode !== 0) return false
audioSetup = undefined
return true
}
function commandExists(command: string) {
const proc = Bun.spawnSync(['which', command], { stdout: 'ignore', stderr: 'ignore' })
return proc.exitCode === 0
}
function getCommandOutput(args: string[]) {
try {
const proc = Bun.spawnSync(args, { stdout: 'pipe', stderr: 'ignore' })
if (proc.exitCode === 0) return proc.stdout.toString().trim()
} catch {
// ignored
}
}
function getDefaultAudioSetup(): AudioSetup {
return {
captureArgs: ['-d'],
captureLabel: getDefaultLabel('input'),
playbackArgs: ['-d'],
playbackLabel: getDefaultLabel('output'),
}
}
function getDefaultLabel(type: 'input' | 'output') {
if (process.platform === 'darwin' && commandExists('SwitchAudioSource')) {
const args = type === 'input' ? ['SwitchAudioSource', '-c', '-t', 'input'] : ['SwitchAudioSource', '-c']
return getCommandOutput(args) || `system default ${type}`
}
return `system default ${type}`
}
function getLinuxAudioSetup(): AudioSetup {
const capture = getLinuxDevice('capture')
const playback = getLinuxDevice('playback')
const mixerLevel = getLinuxMixerLevel()
return {
captureArgs: ['-t', LINUX_AUDIO_TYPE, capture.device],
captureLabel: capture.label,
mixerLevel,
playbackArgs: ['-t', LINUX_AUDIO_TYPE, playback.device],
playbackLabel: playback.label,
}
}
function getLinuxDevice(type: 'capture' | 'playback'): LinuxDevice {
const envName = type === 'capture' ? 'BAUDY_CAPTURE_DEVICE' : 'BAUDY_PLAYBACK_DEVICE'
const envDevice = process.env[envName]?.trim()
if (envDevice) return { device: envDevice, label: `${envDevice} (${envName})` }
const args = type === 'capture' ? ['arecord', '-l'] : ['aplay', '-l']
const output = getCommandOutput(args)
const match = output?.match(/card\s+(\d+):.*?device\s+(\d+):/s)
if (match) {
const [, card, device] = match
return { device: `plughw:${card},${device}`, label: `plughw:${card},${device}` }
}
return { device: 'default', label: 'default' }
}
function getLinuxMixerLevel(): AudioMixerLevel | undefined {
if (!commandExists('amixer')) return
for (const control of ['Speaker Analog', 'Speaker', 'Master', 'PCM']) {
const output = getCommandOutput(['amixer', 'get', control])
if (!output) continue
const percentMatch = output.match(/\[(\d+)%\]/)
if (!percentMatch) continue
const dbMatch = output.match(/\[(-?\d+(?:\.\d+)?)dB\]/)
return {
command: `amixer -q sset '${control}' 100%`,
control,
db: dbMatch ? Number(dbMatch[1]) : undefined,
percent: Number(percentMatch[1]),
}
}
}

View File

@ -1,19 +1,8 @@
import factory from 'ggwave'
import { getAudioSetup } from './audio-setup'
import { setRgbLedState } from './rgbled'
export const SAMPLE_RATE = 48000
export interface LoopbackTestResult {
captureBytes: number
capturePeak: number
micError?: string
ok: boolean
playbackError?: string
}
const ggwave = await factory()
ggwave.disableLog?.()
const params = ggwave.getDefaultParameters()
params.sampleRateInp = SAMPLE_RATE
params.sampleRateOut = SAMPLE_RATE
@ -22,65 +11,40 @@ const instance = ggwave.init(params)
let playing = false
function createPlaybackProcess() {
const { playbackArgs } = getAudioSetup()
return Bun.spawn([
'sox', '-q',
'-t', 'raw', '-r', String(SAMPLE_RATE), '-c', '1', '-b', '32', '-e', 'floating-point', '-',
...playbackArgs,
], {
stdin: 'pipe',
stdout: 'ignore',
stderr: 'pipe',
})
}
function createRecordingProcess() {
const { captureArgs } = getAudioSetup()
return Bun.spawn([
'sox', '-q',
...captureArgs,
'-t', 'raw', '-r', String(SAMPLE_RATE), '-c', '1', '-b', '32', '-e', 'floating-point', '-',
], {
stdout: 'pipe',
stderr: 'pipe',
})
}
function decodeBytes(data: Int8Array): string {
return Array.from(data).map(b => String.fromCharCode(b & 0xff)).join('')
}
function getChunkPeak(bytes: Uint8Array) {
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
let peak = 0
export async function playAudio(text: string) {
playing = true
for (let index = 0; index + 3 < bytes.byteLength; index += 4) {
const sample = Math.abs(view.getFloat32(index, true))
if (sample > peak) peak = sample
}
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))
return peak
const play = Bun.spawn(
['sox', '-t', 'raw', '-r', String(SAMPLE_RATE), '-c', '1', '-b', '32', '-e', 'floating-point', '-', '-d'],
{ stdin: 'pipe', stdout: 'ignore', stderr: 'ignore' }
)
play.stdin.write(rawBytes)
play.stdin.end()
await play.exited
await new Promise(r => setTimeout(r, 300))
playing = false
}
function getText(stream: ReadableStream<Uint8Array> | null | undefined) {
if (!stream) return Promise.resolve('')
return new Response(stream).text().catch(() => '')
}
function logProcessError(label: string, error: string) {
const text = error.trim()
if (!text) return
console.error(`${label}: ${text}`)
}
export async function loopbackTest(): Promise<LoopbackTestResult> {
export async function loopbackTest(): Promise<boolean> {
const testMessage = 'TEST'
const mic = createRecordingProcess()
const micErrorPromise = getText(mic.stderr)
const mic = Bun.spawn(
['sox', '-d', '-t', 'raw', '-r', String(SAMPLE_RATE), '-c', '1', '-b', '32', '-e', 'floating-point', '-'],
{ stdout: 'pipe', stderr: 'ignore' }
)
await new Promise(r => setTimeout(r, 200))
@ -92,8 +56,10 @@ export async function loopbackTest(): Promise<LoopbackTestResult> {
const rawBytes = new Uint8Array(waveform.length)
rawBytes.set(new Uint8Array(waveform.buffer, waveform.byteOffset, waveform.length))
const play = createPlaybackProcess()
const playbackErrorPromise = getText(play.stderr)
const play = Bun.spawn(
['sox', '-t', 'raw', '-r', String(SAMPLE_RATE), '-c', '1', '-b', '32', '-e', 'floating-point', '-', '-d'],
{ stdin: 'pipe', stdout: 'ignore', stderr: 'ignore' }
)
play.stdin.write(rawBytes)
play.stdin.end()
await play.exited
@ -101,9 +67,7 @@ export async function loopbackTest(): Promise<LoopbackTestResult> {
const reader = mic.stdout.getReader()
const bytesPerFrame = params.samplesPerFrame * 4
let buffer = new Uint8Array(0)
let captureBytes = 0
let capturePeak = 0
let ok = false
let decoded = false
const timeout = setTimeout(() => { mic.kill() }, 5000)
@ -112,81 +76,41 @@ export async function loopbackTest(): Promise<LoopbackTestResult> {
const { done, value } = await reader.read()
if (done) break
captureBytes += value.length
capturePeak = Math.max(capturePeak, getChunkPeak(value))
const newBuffer = new Uint8Array(buffer.length + value.length)
newBuffer.set(buffer)
newBuffer.set(value, buffer.length)
buffer = newBuffer
const newBuf = new Uint8Array(buffer.length + value.length)
newBuf.set(buffer)
newBuf.set(value, buffer.length)
buffer = newBuf
while (buffer.length >= bytesPerFrame) {
const frame = buffer.slice(0, bytesPerFrame)
buffer = buffer.slice(bytesPerFrame)
const result = ggwave.decode(instance, frame)
if (!result || result.length === 0) continue
if (result && result.length > 0) {
const text = decodeBytes(result)
if (text !== testMessage) continue
ok = true
if (text === testMessage) {
decoded = true
mic.kill()
break
}
if (ok) break
}
}
if (decoded) break
}
} catch {
// reader closed from kill
}
clearTimeout(timeout)
const [micError, playbackError] = await Promise.all([micErrorPromise, playbackErrorPromise])
return {
captureBytes,
capturePeak,
micError: micError.trim() || undefined,
ok,
playbackError: playbackError.trim() || undefined,
}
}
export async function playAudio(text: string) {
playing = true
setRgbLedState('chirping')
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
logProcessError('Playback error', await errorPromise)
await new Promise(r => setTimeout(r, 300))
} finally {
playing = false
setRgbLedState('listening')
}
return decoded
}
export function startMicListener(onMessage: (text: string) => void) {
setRgbLedState('listening')
const sox = Bun.spawn(
['sox', '-d', '-t', 'raw', '-r', String(SAMPLE_RATE), '-c', '1', '-b', '32', '-e', 'floating-point', '-'],
{ stdout: 'pipe', stderr: 'ignore' }
)
const sox = createRecordingProcess()
const errorPromise = getText(sox.stderr)
const reader = sox.stdout.getReader()
const bytesPerFrame = params.samplesPerFrame * 4
let buffer = new Uint8Array(0)
@ -197,28 +121,23 @@ export function startMicListener(onMessage: (text: string) => void) {
if (done) break
if (playing) continue
const newBuffer = new Uint8Array(buffer.length + value.length)
newBuffer.set(buffer)
newBuffer.set(value, buffer.length)
buffer = newBuffer
const newBuf = new Uint8Array(buffer.length + value.length)
newBuf.set(buffer)
newBuf.set(value, buffer.length)
buffer = newBuf
while (buffer.length >= bytesPerFrame) {
const frame = buffer.slice(0, bytesPerFrame)
buffer = buffer.slice(bytesPerFrame)
const decoded = ggwave.decode(instance, frame)
if (!decoded || decoded.length === 0) continue
if (decoded && decoded.length > 0) {
const text = decodeBytes(decoded)
onMessage(text)
}
}
logProcessError('Microphone error', await errorPromise)
}
}
processAudio().catch(async err => {
console.error('Mic error:', err)
logProcessError('Microphone error', await errorPromise)
})
processAudio().catch(err => console.error('Mic error:', err))
}

View File

@ -1,12 +1,10 @@
/** @jsxImportSource hono/jsx */
import { Hype } from '@because/hype'
import { PhonePage, stylesToCSS } from '../pages/phone'
import { getLocalTlsOptions } from './local-tls'
import { startup } from './terminal'
const PORT = Number(process.env.PORT) || 3000
const app = new Hype({ layout: false, logging: false })
const tls = await getLocalTlsOptions(PORT)
app.get('/ok', c => c.text('ok'))
@ -22,15 +20,28 @@ app.get('/ggwave.js', () =>
})
)
function logStartupError(error: unknown) {
console.error('Startup failed:', error)
startup(PORT)
async function ensureCerts() {
const certPath = './certs/cert.pem'
const keyPath = './certs/key.pem'
if (await Bun.file(certPath).exists()) return
const { mkdirSync } = await import('fs')
mkdirSync('./certs', { recursive: true })
Bun.spawnSync(['openssl', 'req', '-x509', '-newkey', 'rsa:2048',
'-keyout', keyPath, '-out', certPath,
'-days', '365', '-nodes', '-subj', '/CN=localhost'])
}
void startup(PORT).catch(logStartupError)
await ensureCerts()
export default {
...app.defaults,
port: PORT,
idleTimeout: 255,
...(tls ? { tls } : {}),
tls: {
key: Bun.file('./certs/key.pem'),
cert: Bun.file('./certs/cert.pem'),
},
}

View File

@ -1,67 +0,0 @@
import { mkdirSync } from 'node:fs'
import { getNetworkTargets } from './network'
const CERT_PATH = './certs/cert.pem'
const KEY_PATH = './certs/key.pem'
export interface LocalServerUrls {
primaryUrl: string
urls: string[]
}
export function getLocalServerUrls(port: number): LocalServerUrls {
const scheme = isLocalTlsEnabled() ? 'https' : 'http'
const { primaryHost, urls } = getNetworkTargets(port, scheme)
return {
primaryUrl: `${scheme}://${primaryHost}:${port}`,
urls,
}
}
export async function getLocalTlsOptions(_port: number) {
if (!isLocalTlsEnabled()) return
await ensureLocalTlsFiles()
return {
cert: Bun.file(CERT_PATH),
key: Bun.file(KEY_PATH),
}
}
export function isLocalTlsEnabled() {
return process.platform === 'darwin'
}
async function ensureLocalTlsFiles() {
const certExists = await Bun.file(CERT_PATH).exists()
const keyExists = await Bun.file(KEY_PATH).exists()
if (certExists && keyExists) return
const opensslCheck = Bun.spawnSync(['which', 'openssl'], {
stdout: 'ignore',
stderr: 'ignore',
})
if (opensslCheck.exitCode !== 0) {
throw new Error('openssl is required for local macOS HTTPS mode')
}
mkdirSync('./certs', { recursive: true })
const proc = Bun.spawnSync([
'openssl', 'req', '-x509', '-newkey', 'rsa:2048',
'-keyout', KEY_PATH,
'-out', CERT_PATH,
'-days', '365',
'-nodes',
'-subj', '/CN=localhost',
], {
stdout: 'ignore',
stderr: 'pipe',
})
if (proc.exitCode !== 0) {
throw new Error(`failed to generate local TLS certificate: ${proc.stderr.toString().trim()}`)
}
}

View File

@ -1,110 +0,0 @@
import { isIP } from 'node:net'
import { hostname as osHostname, networkInterfaces } from 'node:os'
const LOCALHOST = 'localhost'
const LOOPBACK_IP = '127.0.0.1'
export interface NetworkTargets {
hosts: string[]
ips: string[]
primaryHost: string
urls: string[]
}
export function getNetworkTargets(port: number, scheme = 'http'): NetworkTargets {
const envHost = getEnvHost()
const hosts = getHosts(envHost)
const ips = getIps(envHost)
const primaryHost = getPrimaryHost(envHost, hosts, ips)
const urls = getUrls(port, primaryHost, hosts, ips, scheme)
return { hosts, ips, primaryHost, urls }
}
function addUnique(values: string[], value: string | undefined) {
if (!value || values.includes(value)) return
values.push(value)
}
function getEnvHost() {
const value = process.env.BAUDY_HOST?.trim()
if (!value) return
try {
const url = new URL(value.includes('://') ? value : `https://${value}`)
return url.hostname
} catch {
return value
}
}
function getHosts(envHost: string | undefined) {
const hosts: string[] = []
const hostname = osHostname().trim()
addUnique(hosts, LOCALHOST)
if (envHost && isIP(envHost) === 0) addUnique(hosts, envHost)
if (hostname && hostname !== LOCALHOST && isIP(hostname) === 0) {
if (!hostname.includes('.') && (process.platform === 'darwin' || process.platform === 'linux')) {
addUnique(hosts, `${hostname}.local`)
}
addUnique(hosts, hostname)
}
return hosts
}
function getIpPriority(ip: string) {
if (ip.startsWith('192.168.')) return 0
if (ip.startsWith('10.')) return 1
if (/^172\.(1[6-9]|2\d|3[0-1])\./.test(ip)) return 2
if (/^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./.test(ip)) return 4
return 3
}
function getIps(envHost: string | undefined) {
const externalIps: string[] = []
const ips = networkInterfaces()
if (envHost && isIP(envHost) === 4) addUnique(externalIps, envHost)
for (const addresses of Object.values(ips)) {
for (const address of addresses ?? []) {
if (address.family !== 'IPv4' || address.internal) continue
if (address.address.startsWith('169.254.')) continue
addUnique(externalIps, address.address)
}
}
externalIps.sort((a, b) => getIpPriority(a) - getIpPriority(b) || a.localeCompare(b))
return [LOOPBACK_IP, ...externalIps]
}
function getPrimaryHost(envHost: string | undefined, hosts: string[], ips: string[]) {
if (envHost) return envHost
if (process.platform === 'darwin') {
return hosts.find(host => host.endsWith('.local'))
?? ips.find(ip => ip !== LOOPBACK_IP)
?? LOCALHOST
}
return ips.find(ip => ip !== LOOPBACK_IP)
?? hosts.find(host => host.endsWith('.local'))
?? hosts.find(host => host !== LOCALHOST)
?? LOCALHOST
}
function getUrls(port: number, primaryHost: string, hosts: string[], ips: string[], scheme: string) {
const values: string[] = []
addUnique(values, primaryHost)
for (const ip of ips) addUnique(values, ip)
for (const host of hosts) addUnique(values, host)
return values.map(host => `${scheme}://${host}:${port}`)
}

View File

@ -1,165 +0,0 @@
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

@ -1,9 +1,6 @@
import { getAudioSetup, isLowMixerLevel, refreshAudioSetup, setMixerLevel } from './audio-setup'
import { loopbackTest, playAudio, startMicListener } from './audio'
import { getSecret, handleGuess } from './game'
import { handleGuess, getSecret } 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'
@ -15,11 +12,6 @@ const BLUE = '\x1b[34m'
const CYAN = '\x1b[36m'
const RED = '\x1b[31m'
interface TailscaleInfo {
dnsName: string
url: string
}
async function prompt(question: string): Promise<string> {
process.stdout.write(question)
for await (const line of console) {
@ -28,90 +20,29 @@ async function prompt(question: string): Promise<string> {
return ''
}
function clearScreen() {
if (process.stdout.isTTY) console.clear()
function getLocalHostname() {
const proc = Bun.spawnSync(['scutil', '--get', 'LocalHostName'])
if (proc.exitCode === 0) return proc.stdout.toString().trim() + '.local'
return 'localhost'
}
function commandExists(command: string) {
const proc = Bun.spawnSync(['which', command], { stdout: 'ignore', stderr: 'ignore' })
return proc.exitCode === 0
}
function formatPeak(value: number) {
return value.toFixed(4)
}
function getAlternativeUrls(urls: string[], primaryUrl: string) {
const externalUrls = urls.filter(url => !url.includes('localhost'))
const visibleUrls = externalUrls.length > 0 ? externalUrls : urls
return visibleUrls
.filter(url => url !== primaryUrl)
.slice(0, 3)
}
function getCommandOutput(args: string[]) {
try {
const proc = Bun.spawnSync(args, { stdout: 'pipe', stderr: 'ignore' })
function getDeviceName(type: 'input' | 'output'): string {
const args = type === 'input' ? ['-c', '-t', 'input'] : ['-c']
const proc = Bun.spawnSync(['SwitchAudioSource', ...args])
if (proc.exitCode === 0) return proc.stdout.toString().trim()
} catch {
// ignored
}
}
function getLocalUrls(port: number) {
const { primaryUrl, urls } = getLocalServerUrls(port)
const alternativeUrls = getAlternativeUrls(urls, primaryUrl)
return { alternativeUrls, primaryUrl }
}
function getSoxInstallCommand() {
if (process.platform === 'darwin') return 'brew install sox'
if (process.platform === 'linux') return 'sudo apt install sox'
return 'install sox and make sure it is in your PATH'
}
function getTailscaleInfo(): TailscaleInfo | undefined {
if (!commandExists('tailscale')) return
const output = getCommandOutput(['tailscale', 'status', '--json'])
if (!output) return
try {
const status = JSON.parse(output) as { Self?: { DNSName?: string } }
const dnsName = status.Self?.DNSName?.replace(/\.$/, '')
if (!dnsName) return
return { dnsName, url: `https://${dnsName}` }
} catch {
return
}
}
async function logQrCode(url: string) {
try {
const QRCode = await import('qrcode')
const qr = await QRCode.toString(url, { type: 'terminal', small: true })
console.log(qr)
} catch {
console.log(`${BOLD}${CYAN}${url}${RESET}`)
console.log()
}
return 'system default'
}
function logGuess(result: GuessResult) {
const { type, guess, guessCount } = result
if (type === 'higher') {
console.log(`${DIM}Guess #${guessCount}:${RESET} ${BOLD}${guess}${RESET}${YELLOW}📢 Higher!${RESET}`)
return
}
if (type === 'lower') {
console.log(`${DIM}Guess #${guessCount}:${RESET} ${BOLD}${guess}${RESET}${BLUE}📢 Lower!${RESET}`)
return
}
console.log(`${DIM}Guess #${guessCount}:${RESET} ${BOLD}${guess}${RESET}${GREEN}🎉 CORRECT!${RESET}`)
console.log()
console.log(`${CYAN}New round! Secret number: ${BOLD}${getSecret()}${RESET}`)
@ -119,62 +50,6 @@ function logGuess(result: GuessResult) {
console.log()
}
function logLoopbackFailureDetails(result: Awaited<ReturnType<typeof loopbackTest>>) {
const audioSetup = getAudioSetup()
console.log(`${RED}${BOLD}✗ Couldn't hear the test chirp.${RESET}`)
console.log()
console.log(`${YELLOW}Try:${RESET}`)
console.log(' • Disconnect headphones — sound needs to travel through the air')
console.log(` • Check your system sound settings (output: "${audioSetup.playbackLabel}", input: "${audioSetup.captureLabel}")`)
if (result.captureBytes === 0) {
console.log(' • The microphone device did not produce any audio data')
} else if (result.capturePeak < 0.02) {
console.log(` • The microphone only heard a very quiet signal (peak ${formatPeak(result.capturePeak)})`)
}
if (result.micError) {
console.log(` • Mic error: ${result.micError.split('\n')[0]}`)
}
if (result.playbackError) {
console.log(` • Speaker error: ${result.playbackError.split('\n')[0]}`)
}
if (process.platform === 'linux' && isLowMixerLevel(audioSetup.mixerLevel)) {
const level = audioSetup.mixerLevel
const db = level.db === undefined ? '' : ` (${level.db.toFixed(1)} dB)`
console.log(`${level.control} is only at ${level.percent}%${db}; try: ${level.command}`)
}
if (process.platform === 'linux') {
console.log(' • Override SoX devices if auto-detection is wrong:')
console.log(' BAUDY_CAPTURE_DEVICE=plughw:0,0 BAUDY_PLAYBACK_DEVICE=plughw:0,1 bun run index.tsx')
}
console.log()
}
async function maybePause(question: string) {
if (!process.stdin.isTTY) return
await prompt(question)
}
async function maybeRaiseSpeakerGain() {
const audioSetup = getAudioSetup()
if (process.platform !== 'linux' || !process.stdin.isTTY || !isLowMixerLevel(audioSetup.mixerLevel)) return audioSetup
const answer = await prompt(` Raise ${audioSetup.mixerLevel.control} to 100% now? [Y/n] `)
if (answer && !/^y(es)?$/i.test(answer)) return audioSetup
if (!setMixerLevel(audioSetup.mixerLevel.control, 100)) return audioSetup
console.log(`${GREEN} Raised ${audioSetup.mixerLevel.control} to 100%.${RESET}`)
console.log()
return refreshAudioSetup()
}
async function onMessage(text: string) {
await Bun.sleep(500)
@ -185,110 +60,71 @@ async function onMessage(text: string) {
}
const result = await handleGuess(text)
if (!result) return
if (result.type === 'correct') setRgbLedState('correct')
logGuess(result)
if (result) 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`)
const soxCheck = Bun.spawnSync(['which', 'sox'])
if (soxCheck.exitCode !== 0) {
console.error(`\n ${BOLD}sox is not installed.${RESET} Run: brew install sox\n`)
process.exit(1)
}
let audioSetup = getAudioSetup()
const rgbLedStatus = getRgbLedStatus()
clearScreen()
console.clear()
console.log()
console.log(`${BOLD}Corey's Screechy Audio Demo${RESET} ${DIM}v${pkg.version}${RESET}`)
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)) {
const level = audioSetup.mixerLevel
const db = level.db === undefined ? '' : ` (${level.db.toFixed(1)} dB)`
console.log(`${YELLOW}${BOLD}⚠ Speaker gain looks low:${RESET} ${level.control} is ${level.percent}%${db}`)
console.log(`${DIM}Try:${RESET} ${BOLD}${level.command}${RESET}`)
const speaker = getDeviceName('output')
const mic = getDeviceName('input')
console.log(`${BOLD}Speaker:${RESET} ${GREEN}${speaker}${RESET}`)
console.log(`${BOLD}Microphone:${RESET} ${GREEN}${mic}${RESET}`)
console.log()
audioSetup = await maybeRaiseSpeakerGain()
}
console.log(`${YELLOW}${BOLD}🔊 Turn your volume up!${RESET}`)
console.log(`${DIM}Testing audio: playing a chirp and listening for it...${RESET}`)
console.log()
setRgbLedState('startup')
const loopback = await loopbackTest()
const loopbackOk = await loopbackTest()
if (loopback.ok) {
if (loopbackOk) {
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... ')
console.log(`${RED}${BOLD}✗ Couldn't hear the test chirp.${RESET}`)
console.log()
console.log(`${YELLOW}Try:${RESET}`)
console.log(` • Disconnect headphones — sound needs to travel through the air`)
console.log(` • Check System Settings > Sound (output: "${speaker}", input: "${mic}")`)
console.log(` • Turn your volume up`)
console.log()
await prompt(` Press Enter to continue anyway... `)
}
clearScreen()
console.clear()
console.log()
console.log(`${BOLD}Corey's Screechy Audio Demo${RESET} ${DIM}v${pkg.version}${RESET}`)
console.log(`${DIM}Speaker: ${audioSetup.playbackLabel} · Mic: ${audioSetup.captureLabel}${RESET}`)
console.log(`${DIM}Speaker: ${speaker} · Mic: ${mic}${RESET}`)
console.log()
const { alternativeUrls, primaryUrl } = getLocalUrls(port)
const tailscale = getTailscaleInfo()
const hostname = getLocalHostname()
const url = `https://${hostname}:${port}`
if (isLocalTlsEnabled()) {
console.log(`${GREEN}${BOLD}macOS local HTTPS mode${RESET}`)
console.log(`${GREEN}${BOLD}Scan QR code on your phone to play!${RESET}`)
console.log()
console.log(`${DIM}Open on your phone:${RESET} ${CYAN}${primaryUrl}${RESET}`)
console.log(`${DIM}This uses the original self-signed local HTTPS flow.${RESET}`)
console.log(`${DIM}First load may show a certificate warning.${RESET}`)
console.log()
await logQrCode(primaryUrl)
if (tailscale) {
console.log(`${DIM}Alternative:${RESET} ${BOLD}tailscale serve ${port}${RESET}${CYAN}${tailscale.url}${RESET}`)
try {
const QRCode = await import('qrcode')
const qr = await QRCode.toString(url, { type: 'terminal', small: true })
console.log(qr)
} catch {
console.log(`${BOLD}${CYAN}${url}${RESET}`)
console.log()
}
} else if (tailscale) {
console.log(`${GREEN}${BOLD}Phone access via Tailscale Serve${RESET}`)
console.log()
console.log(`${DIM}Run this once in another terminal:${RESET}`)
console.log(`${BOLD} tailscale serve ${port}${RESET}`)
console.log()
console.log(`${DIM}Then open on your phone:${RESET} ${CYAN}${tailscale.url}${RESET}`)
console.log()
await logQrCode(tailscale.url)
} else {
console.log(`${YELLOW}Phone microphone access needs HTTPS, and this local server is HTTP-only.${RESET}`)
console.log(`${DIM}Recommended:${RESET} run ${BOLD}tailscale serve ${port}${RESET} and open the resulting ${CYAN}https://<device>.<tailnet>.ts.net${RESET} URL.`)
console.log()
}
console.log(`${DIM}${isLocalTlsEnabled() ? 'Local HTTPS' : 'Local HTTP'}:${RESET} ${CYAN}${primaryUrl}${RESET}`)
if (alternativeUrls.length > 0) {
console.log(`${DIM}Other local ${isLocalTlsEnabled() ? 'HTTPS' : 'HTTP'} URLs:${RESET}`)
for (const alternativeUrl of alternativeUrls) {
console.log(`${DIM} ${alternativeUrl}${RESET}`)
}
}
console.log(`${DIM}Set BAUDY_HOST=<host-or-ip> to override local URL detection if needed.${RESET}`)
console.log()
console.log(`${BOLD}Secret number: ${GREEN}${getSecret()}${RESET}`)
console.log(`${'─'.repeat(40)}`)
console.log(`${DIM}🎤 Listening for guesses...${RESET}`)
console.log()
setRgbLedState('listening')
startMicListener(onMessage)
}

2
types.d.ts vendored
View File

@ -1,2 +0,0 @@
declare module 'ggwave'
declare module 'qrcode'