Compare commits

..

3 Commits
main ... fi

Author SHA1 Message Date
7446785586 see about wifi 2026-04-19 16:38:19 -07:00
8585d7e87f play with LEDs 2026-04-18 19:39:11 -07:00
f434137f11 get it working on linux 2026-04-16 22:17:58 -07:00
15 changed files with 2172 additions and 486 deletions

View File

@ -1,37 +1,44 @@
# ggwave Audio POC # Baudy WiFi Chirp Setup
Proof-of-concept for data-over-sound communication using the [ggwave](https://github.com/ggerganov/ggwave) library. This validates the browser-to-server audio pipeline that will eventually be used for WiFi provisioning on Raspberry Pi (Toes devices). A proof-of-concept for provisioning WiFi over sound with [ggwave](https://github.com/ggerganov/ggwave).
## Why The phone and the server exchange short audio chirps to do a simple setup flow:
Toes devices (Raspberry Pis) need a way to receive WiFi credentials during initial setup. The device has no network connection yet, so we can't use HTTP. Instead, the user's phone encodes the credentials as audio chirps and plays them through the speaker. The Pi's microphone picks up the chirps and decodes them. No Bluetooth pairing, no QR codes, no special hardware — just sound. 1. **Phone says hello** over audio.
2. **Server scans nearby WiFi networks** with `nmcli`.
3. **Server chirps the SSID list back** to the phone.
4. **Phone selects a network** and, if needed, enters the password.
5. **Phone chirps the password back** to the server.
6. **Server tries to join the network** and chirps the result.
## What this POC does This is aimed at Raspberry Pi / Linux-style setup flows where networking may not be available yet.
It's a calculator. The phone sends math expressions as audio, the server decodes them and sends back the answer. This is a minimal end-to-end test of the full pipeline: ## Current assumptions
1. **Phone (browser)** — calculator UI. User types `78*5` and hits `=`. The expression is encoded as an audible chirp using ggwave's AUDIBLE_FAST protocol and played through the phone speaker. - WiFi scanning and joining are implemented for **Linux via `nmcli`**.
2. **Server (Bun)** — listens on the microphone via `sox`, feeds audio frames to ggwave for decoding. When it decodes an expression, it evaluates it and sends the result back via SSE. - The phone UI is still served as a normal web page for this repo's demo flow.
3. **Phone receives result** — the answer (`390`) appears on the calculator display. - Passwords are sent as **plaintext audio payloads** in this POC.
- Audio is **half-duplex**: only one side should chirp at a time.
The server also chirps the result back through the speakers (half-duplex — it stops listening while playing to avoid feedback). ## Run
## How to run ```sh
```
cd tmp
bun install bun install
bun run server.ts bun run index.tsx
``` ```
Open `http://<hostname>:8888` on your phone. Make sure the server machine's default audio input is a working microphone (check System Settings > Sound > Input on macOS). Open the shown URL on your phone.
## How it works For phone microphone access you need a secure origin:
- **ggwave** handles encoding/decoding using multi-frequency FSK modulation with Reed-Solomon error correction. The AUDIBLE_FAST protocol uses audible frequencies (~1-6kHz range). - **macOS:** the app serves local HTTPS directly
- **Browser side** uses WebAudio API to play encoded waveforms. ggwave runs as WASM. - **Linux:** prefer `tailscale serve <port>` and open the resulting `https://...ts.net` URL
- **Server side** uses `sox -d` to capture mic audio as raw 48kHz float32 samples, then feeds frames to ggwave for decoding.
- **Half-duplex** — both sides use the same frequency band, so only one can transmit at a time. The server stops processing mic input while playing back results.
- **SSE** is used as a reliable fallback channel to push results to the phone (vs trying to decode audio on the phone in a noisy environment).
See [docs/ggwave-gotchas.md](docs/ggwave-gotchas.md) for hard-won lessons about iOS audio, macOS mic permissions, WASM heap management, and sample rate matching. ## Notes
- Nearby network lists are deduped by SSID and trimmed to the strongest entries.
- The browser uses WebAudio + ggwave WASM for chirp encode/decode.
- The server uses `sox` for audio capture/playback.
- The server waits for the phone to switch back into listening mode before replying.
See [docs/ggwave-gotchas.md](docs/ggwave-gotchas.md) for platform-specific lessons about iOS audio, macOS microphone permissions, Linux ALSA device selection, and ggwave WASM behavior.

View File

@ -19,6 +19,15 @@
- **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. - **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. - **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. - **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 ## Half-Duplex Audio
@ -41,4 +50,5 @@
## getUserMedia Secure Context ## 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. - `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.
- 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. - 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.

View File

@ -13,7 +13,7 @@
"release": "bash publish.sh" "release": "bash publish.sh"
}, },
"toes": { "toes": {
"icon": "🖥️" "icon": "📶"
}, },
"devDependencies": { "devDependencies": {
"@types/bun": "latest" "@types/bun": "latest"

File diff suppressed because it is too large Load Diff

139
src/server/audio-setup.ts Normal file
View File

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

View File

@ -1,41 +1,3 @@
import { playAudio } from './audio' // Number-guessing demo removed.
// WiFi audio provisioning now lives in ./provisioning.ts and ./wifi.ts.
let secret = randomNumber() export {}
let guessCount = 0
function randomNumber() {
return Math.floor(Math.random() * 100) + 1
}
export function getSecret() {
return secret
}
export type GuessResult = {
type: 'higher' | 'lower' | 'correct'
guess: number
guessCount: number
}
export async function handleGuess(guessStr: string): Promise<GuessResult | undefined> {
const guess = parseInt(guessStr, 10)
if (isNaN(guess) || guess < 1 || guess > 100) return
guessCount++
if (guess < secret) {
await playAudio('Higher!')
return { type: 'higher', guess, guessCount }
}
if (guess > secret) {
await playAudio('Lower!')
return { type: 'lower', guess, guessCount }
}
await playAudio('Correct!')
const result: GuessResult = { type: 'correct', guess, guessCount }
secret = randomNumber()
guessCount = 0
return result
}

View File

@ -1,10 +1,12 @@
/** @jsxImportSource hono/jsx */ /** @jsxImportSource hono/jsx */
import { Hype } from '@because/hype' import { Hype } from '@because/hype'
import { PhonePage, stylesToCSS } from '../pages/phone' import { PhonePage, stylesToCSS } from '../pages/phone'
import { getLocalTlsOptions } from './local-tls'
import { startup } from './terminal' import { startup } from './terminal'
const PORT = Number(process.env.PORT) || 3000 const PORT = Number(process.env.PORT) || 3000
const app = new Hype({ layout: false, logging: false }) const app = new Hype({ layout: false, logging: false })
const tls = await getLocalTlsOptions(PORT)
app.get('/ok', c => c.text('ok')) app.get('/ok', c => c.text('ok'))
@ -20,28 +22,15 @@ app.get('/ggwave.js', () =>
}) })
) )
startup(PORT) function logStartupError(error: unknown) {
console.error('Startup failed:', error)
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'])
} }
await ensureCerts() void startup(PORT).catch(logStartupError)
export default { export default {
...app.defaults, ...app.defaults,
port: PORT, port: PORT,
idleTimeout: 255, idleTimeout: 255,
tls: { ...(tls ? { tls } : {}),
key: Bun.file('./certs/key.pem'),
cert: Bun.file('./certs/cert.pem'),
},
} }

67
src/server/local-tls.ts Normal file
View File

@ -0,0 +1,67 @@
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()}`)
}
}

110
src/server/network.ts Normal file
View File

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

@ -0,0 +1,53 @@
import { connectToWifiNetwork, type WifiNetwork, scanWifiNetworks, type WifiScanResult } from './wifi'
let scannedNetworks: WifiNetwork[] = []
let selectedNetwork: WifiNetwork | undefined
export interface SelectScannedNetworkResult {
code: SelectScannedNetworkCode
network?: WifiNetwork
}
export type SelectScannedNetworkCode = 'INVALID_SELECTION' | 'NETWORK_NOT_FOUND' | 'OK'
export async function connectSelectedNetwork(password: string) {
if (!selectedNetwork) return
return connectToWifiNetwork(selectedNetwork, password)
}
export function getScannedNetworks() {
return scannedNetworks
}
export function getSelectedNetwork() {
return selectedNetwork
}
export async function scanAndStoreNetworks() {
const result = await scanWifiNetworks()
if (result.code === 'OK') {
scannedNetworks = result.networks
selectedNetwork = undefined
} else {
scannedNetworks = []
selectedNetwork = undefined
}
return result satisfies WifiScanResult
}
export function selectScannedNetwork(idText: string) {
const id = Number.parseInt(idText, 10)
if (!Number.isInteger(id) || id < 1) {
return { code: 'INVALID_SELECTION' } satisfies SelectScannedNetworkResult
}
const network = scannedNetworks.find(candidate => candidate.id === id)
if (!network) {
return { code: 'NETWORK_NOT_FOUND' } satisfies SelectScannedNetworkResult
}
selectedNetwork = network
return { code: 'OK', network } satisfies SelectScannedNetworkResult
}

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

@ -1,6 +1,9 @@
import { getAudioSetup, isLowMixerLevel, refreshAudioSetup, setMixerLevel } from './audio-setup'
import { loopbackTest, playAudio, startMicListener } from './audio' import { loopbackTest, playAudio, startMicListener } from './audio'
import { handleGuess, getSecret } from './game' import { connectSelectedNetwork, getSelectedNetwork, scanAndStoreNetworks, selectScannedNetwork } from './provisioning'
import type { GuessResult } from './game' import { getLocalServerUrls, isLocalTlsEnabled } from './local-tls'
import { getRgbLedStatus, setRgbLedState } from './rgbled'
import type { WifiConnectCode, WifiConnectResult, WifiNetwork, WifiScanCode } from './wifi'
import pkg from '../../package.json' import pkg from '../../package.json'
const RESET = '\x1b[0m' const RESET = '\x1b[0m'
@ -12,6 +15,13 @@ const BLUE = '\x1b[34m'
const CYAN = '\x1b[36m' const CYAN = '\x1b[36m'
const RED = '\x1b[31m' const RED = '\x1b[31m'
let messageChain = Promise.resolve()
interface TailscaleInfo {
dnsName: string
url: string
}
async function prompt(question: string): Promise<string> { async function prompt(question: string): Promise<string> {
process.stdout.write(question) process.stdout.write(question)
for await (const line of console) { for await (const line of console) {
@ -20,98 +30,302 @@ async function prompt(question: string): Promise<string> {
return '' return ''
} }
function getLocalHostname() { function clearScreen() {
const proc = Bun.spawnSync(['scutil', '--get', 'LocalHostName']) if (process.stdout.isTTY) console.clear()
if (proc.exitCode === 0) return proc.stdout.toString().trim() + '.local'
return 'localhost'
} }
function getDeviceName(type: 'input' | 'output'): string { function commandExists(command: string) {
const args = type === 'input' ? ['-c', '-t', 'input'] : ['-c'] const proc = Bun.spawnSync(['which', command], { stdout: 'ignore', stderr: 'ignore' })
const proc = Bun.spawnSync(['SwitchAudioSource', ...args]) return proc.exitCode === 0
}
function decodeToken(token: string) {
try {
return Buffer.from(token, 'base64url').toString('utf8')
} catch {
return ''
}
}
function encodeToken(text: string) {
return Buffer.from(text, 'utf8').toString('base64url')
}
function formatPeak(value: number) {
return value.toFixed(4)
}
function formatSecurity(network: WifiNetwork) {
if (network.isOpen) return 'open'
return network.security || 'secured'
}
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' })
if (proc.exitCode === 0) return proc.stdout.toString().trim() if (proc.exitCode === 0) return proc.stdout.toString().trim()
return 'system default' } catch {
// ignored
}
} }
function logGuess(result: GuessResult) { function getConnectErrorText(code: WifiConnectCode) {
const { type, guess, guessCount } = result if (code === 'BAD_PASSWORD') return 'Bad password.'
if (type === 'higher') { if (code === 'NMCLI_MISSING') return 'nmcli is not installed.'
console.log(`${DIM}Guess #${guessCount}:${RESET} ${BOLD}${guess}${RESET}${YELLOW}📢 Higher!${RESET}`) if (code === 'NOT_FOUND') return 'That network is no longer visible.'
return if (code === 'NO_WIFI_DEVICE') return 'No WiFi device was found.'
} if (code === 'PASSWORD_REQUIRED') return 'Password required.'
if (type === 'lower') { if (code === 'NOT_SUPPORTED') return 'WiFi provisioning is implemented for Linux only.'
console.log(`${DIM}Guess #${guessCount}:${RESET} ${BOLD}${guess}${RESET}${BLUE}📢 Lower!${RESET}`) return 'WiFi connection failed.'
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}`)
console.log(`${'─'.repeat(40)}`)
console.log()
} }
async function onMessage(text: string) { function getLocalUrls(port: number) {
const { primaryUrl, urls } = getLocalServerUrls(port)
const alternativeUrls = getAlternativeUrls(urls, primaryUrl)
return { alternativeUrls, primaryUrl }
}
function getLoopbackPauseQuestion() {
return ' Press Enter to continue anyway... '
}
function getResultMessage(result: WifiConnectResult) {
return `RESULT|${result.code}|${encodeToken(result.network.ssid)}`
}
function getScanErrorMessage(code: WifiScanCode) {
if (code === 'NMCLI_MISSING') return 'NetworkManager (nmcli) is not installed.'
if (code === 'NO_WIFI_DEVICE') return 'No WiFi device was found.'
if (code === 'NOT_SUPPORTED') return 'WiFi scanning is implemented for Linux only.'
return 'Scanning for WiFi networks failed.'
}
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 handleIncomingMessage(text: string) {
const trimmed = text.trim()
if (!trimmed) return
await Bun.sleep(500) await Bun.sleep(500)
if (text === 'HELLO') { if (trimmed === 'HELLO') {
console.log(`${GREEN}${BOLD}Player connected via audio!${RESET}`) console.log(`${GREEN}${BOLD}Phone connected via audio.${RESET}`)
await playAudio('HEY BUDDY') await playAudio('READY')
return return
} }
const result = await handleGuess(text) if (trimmed === 'SCAN') {
if (result) logGuess(result) await handleScanRequest()
return
} }
export async function startup(port: number) { if (trimmed.startsWith('SELECT|')) {
const soxCheck = Bun.spawnSync(['which', 'sox']) await handleSelectRequest(trimmed)
if (soxCheck.exitCode !== 0) { return
console.error(`\n ${BOLD}sox is not installed.${RESET} Run: brew install sox\n`)
process.exit(1)
} }
console.clear() if (trimmed.startsWith('PASS|')) {
console.log() await handlePasswordRequest(trimmed)
console.log(`${BOLD}Corey's Screechy Audio Demo${RESET} ${DIM}v${pkg.version}${RESET}`) return
console.log() }
const speaker = getDeviceName('output') console.log(`${DIM}Ignored unknown audio message:${RESET} ${trimmed}`)
const mic = getDeviceName('input') }
console.log(`${BOLD}Speaker:${RESET} ${GREEN}${speaker}${RESET}`)
console.log(`${BOLD}Microphone:${RESET} ${GREEN}${mic}${RESET}`)
console.log()
console.log(`${YELLOW}${BOLD}🔊 Turn your volume up!${RESET}`) async function handlePasswordRequest(message: string) {
console.log(`${DIM}Testing audio: playing a chirp and listening for it...${RESET}`) const network = getSelectedNetwork()
console.log() if (!network) {
console.log(`${YELLOW}Received a password before any network was selected.${RESET}`)
await playAudio('PASS_ERR|NO_SELECTION')
return
}
const loopbackOk = await loopbackTest() const token = message.slice('PASS|'.length)
const password = decodeToken(token)
if (loopbackOk) { if (!password) {
console.log(`${GREEN}${BOLD}✓ Audio working!${RESET} ${DIM}Speaker → mic pipeline verified${RESET}`) console.log(`${YELLOW}Received an empty or undecodable password token for ${BOLD}${network.ssid}${RESET}${YELLOW}.${RESET}`)
} else { } else {
console.log(`${CYAN}Received WiFi password for ${BOLD}${network.ssid}${RESET}${CYAN}. Attempting connection...${RESET}`)
}
await playAudio('CONNECTING')
const result = await connectSelectedNetwork(password)
if (!result) {
await playAudio('PASS_ERR|NO_SELECTION')
return
}
logConnectionResult(result)
if (result.code === 'OK') setRgbLedState('correct')
await playAudio(getResultMessage(result))
}
async function handleScanRequest() {
console.log(`${CYAN}Scanning for WiFi networks...${RESET}`)
const result = await scanAndStoreNetworks()
if (result.code !== 'OK') {
console.log(`${RED}${getScanErrorMessage(result.code)}${RESET}`)
if (result.details) console.log(`${DIM}${result.details.split('\n')[0]}${RESET}`)
console.log()
await playAudio(`SCAN_ERR|${result.code}`)
return
}
logNetworks(result.networks)
await playAudio(`NETS_BEGIN|${result.networks.length}`)
for (const network of result.networks) {
const security = network.isOpen ? 'OPEN' : 'LOCKED'
await playAudio(`NET|${network.id}|${security}|${network.signal}|${encodeToken(network.ssid)}`)
}
await playAudio('NETS_END')
}
async function handleSelectRequest(message: string) {
const idText = message.slice('SELECT|'.length)
const result = selectScannedNetwork(idText)
if (result.code !== 'OK' || !result.network) {
console.log(`${YELLOW}Received invalid WiFi selection:${RESET} ${idText}`)
await playAudio(`SELECT_ERR|${result.code}`)
return
}
const network = result.network
console.log(`${BLUE}Selected:${RESET} ${BOLD}${network.ssid}${RESET} ${DIM}(${network.signal}% · ${formatSecurity(network)})${RESET}`)
if (!network.isOpen) {
await playAudio('PASSWORD')
return
}
console.log(`${CYAN}Connecting to open network ${BOLD}${network.ssid}${RESET}${CYAN}...${RESET}`)
await playAudio('CONNECTING')
const connectResult = await connectSelectedNetwork('')
if (!connectResult) {
await playAudio('SELECT_ERR|NETWORK_NOT_FOUND')
return
}
logConnectionResult(connectResult)
if (connectResult.code === 'OK') setRgbLedState('correct')
await playAudio(getResultMessage(connectResult))
}
function logConnectionResult(result: WifiConnectResult) {
if (result.code === 'OK') {
console.log(`${GREEN}${BOLD}Connected:${RESET} ${BOLD}${result.network.ssid}${RESET}`)
console.log()
return
}
console.log(`${RED}${BOLD}Connection failed:${RESET} ${BOLD}${result.network.ssid}${RESET} ${DIM}(${getConnectErrorText(result.code)})${RESET}`)
if (result.details) console.log(`${DIM}${result.details.split('\n')[0]}${RESET}`)
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(`${RED}${BOLD}✗ Couldn't hear the test chirp.${RESET}`)
console.log() console.log()
console.log(`${YELLOW}Try:${RESET}`) console.log(`${YELLOW}Try:${RESET}`)
console.log(` • Disconnect headphones — sound needs to travel through the air`) console.log(' • Disconnect headphones — sound needs to travel through the air')
console.log(` • Check System Settings > Sound (output: "${speaker}", input: "${mic}")`) console.log(` • Check your system sound settings (output: "${audioSetup.playbackLabel}", input: "${audioSetup.captureLabel}")`)
console.log(` • Turn your volume up`)
console.log() if (result.captureBytes === 0) {
await prompt(` Press Enter to continue anyway... `) 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)})`)
} }
console.clear() if (result.micError) {
console.log() console.log(` • Mic error: ${result.micError.split('\n')[0]}`)
console.log(`${BOLD}Corey's Screechy Audio Demo${RESET} ${DIM}v${pkg.version}${RESET}`) }
console.log(`${DIM}Speaker: ${speaker} · Mic: ${mic}${RESET}`)
console.log()
const hostname = getLocalHostname() if (result.playbackError) {
const url = `https://${hostname}:${port}` 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(`${GREEN}${BOLD}Scan QR code on your phone to play!${RESET}`)
console.log() console.log()
}
function logNetworks(networks: WifiNetwork[]) {
console.log(`${GREEN}${BOLD}Found ${networks.length} WiFi network${networks.length === 1 ? '' : 's'}.${RESET}`)
if (networks.length === 0) {
console.log(`${DIM}No visible SSIDs were returned by nmcli.${RESET}`)
console.log()
return
}
for (const network of networks) {
console.log(`${DIM}${network.id}.${RESET} ${BOLD}${network.ssid}${RESET} ${DIM}(${network.signal}% · ${formatSecurity(network)})${RESET}`)
}
console.log()
}
function logWifiProvisioningHint() {
if (process.platform !== 'linux') {
console.log(`${YELLOW}WiFi scan/connect commands are only implemented on Linux via nmcli.${RESET}`)
console.log()
return
}
if (!commandExists('nmcli')) {
console.log(`${YELLOW}nmcli was not found. Install NetworkManager if you want WiFi scan/connect to work.${RESET}`)
console.log()
}
}
async function logQrCode(url: string) {
try { try {
const QRCode = await import('qrcode') const QRCode = await import('qrcode')
const qr = await QRCode.toString(url, { type: 'terminal', small: true }) const qr = await QRCode.toString(url, { type: 'terminal', small: true })
@ -120,11 +334,139 @@ export async function startup(port: number) {
console.log(`${BOLD}${CYAN}${url}${RESET}`) console.log(`${BOLD}${CYAN}${url}${RESET}`)
console.log() console.log()
} }
}
console.log(`${BOLD}Secret number: ${GREEN}${getSecret()}${RESET}`) async function maybePause(question: string) {
console.log(`${'─'.repeat(40)}`) if (!process.stdin.isTTY) return
console.log(`${DIM}🎤 Listening for guesses...${RESET}`) 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()
}
function queueIncomingMessage(text: string) {
messageChain = messageChain
.then(() => handleIncomingMessage(text))
.catch(error => {
console.error('Message handling failed:', error)
})
}
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()
console.log(`${BOLD}Baudy WiFi Chirp Setup${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() console.log()
startMicListener(onMessage) logWifiProvisioningHint()
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}`)
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()
if (loopback.ok) {
console.log(`${GREEN}${BOLD}✓ Audio working!${RESET} ${DIM}Speaker → mic pipeline verified${RESET}`)
} else {
setRgbLedState('error')
logLoopbackFailureDetails(loopback)
await maybePause(getLoopbackPauseQuestion())
}
clearScreen()
console.log()
console.log(`${BOLD}Baudy WiFi Chirp Setup${RESET} ${DIM}v${pkg.version}${RESET}`)
console.log(`${DIM}Speaker: ${audioSetup.playbackLabel} · Mic: ${audioSetup.captureLabel}${RESET}`)
console.log()
const { alternativeUrls, primaryUrl } = getLocalUrls(port)
const tailscale = getTailscaleInfo()
if (isLocalTlsEnabled()) {
console.log(`${GREEN}${BOLD}macOS local HTTPS mode${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}`)
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}Phone flow:${RESET}`)
console.log(`${DIM}1.${RESET} Connect audio`)
console.log(`${DIM}2.${RESET} Scan nearby WiFi networks`)
console.log(`${DIM}3.${RESET} Select a network on the phone`)
console.log(`${DIM}4.${RESET} Enter the password and chirp it back`)
console.log()
console.log(`${DIM}🎤 Listening for WiFi setup chirps...${RESET}`)
console.log()
setRgbLedState('listening')
startMicListener(queueIncomingMessage)
} }

243
src/server/wifi.ts Normal file
View File

@ -0,0 +1,243 @@
const MAX_NETWORKS = 8
export interface WifiConnectResult {
code: WifiConnectCode
details?: string
network: WifiNetwork
}
export interface WifiNetwork {
id: number
isOpen: boolean
security: string
signal: number
ssid: string
}
export interface WifiScanResult {
code: WifiScanCode
details?: string
networks: WifiNetwork[]
}
interface CommandResult {
exitCode: number
stderr: string
stdout: string
}
interface ParsedWifiRecord {
security: string
signal: number
ssid: string
}
export type WifiConnectCode =
| 'BAD_PASSWORD'
| 'FAILED'
| 'NMCLI_MISSING'
| 'NOT_FOUND'
| 'NO_WIFI_DEVICE'
| 'OK'
| 'PASSWORD_REQUIRED'
| 'NOT_SUPPORTED'
export type WifiScanCode = 'NMCLI_MISSING' | 'NO_WIFI_DEVICE' | 'OK' | 'SCAN_FAILED' | 'NOT_SUPPORTED'
export async function connectToWifiNetwork(network: WifiNetwork, password: string) {
if (process.platform !== 'linux') {
return { code: 'NOT_SUPPORTED', network } satisfies WifiConnectResult
}
if (!commandExists('nmcli')) {
return { code: 'NMCLI_MISSING', network } satisfies WifiConnectResult
}
if (!network.isOpen && !password.trim()) {
return { code: 'PASSWORD_REQUIRED', network } satisfies WifiConnectResult
}
const device = await findWifiDevice()
if (!device) {
return { code: 'NO_WIFI_DEVICE', network } satisfies WifiConnectResult
}
const args = ['nmcli', '--colors', 'no', '--wait', '30', 'device', 'wifi', 'connect', network.ssid, 'ifname', device]
if (!network.isOpen) args.push('password', password)
const command = await runCommand(args)
if (command.exitCode === 0) {
return { code: 'OK', details: command.stdout, network } satisfies WifiConnectResult
}
return {
code: classifyConnectError(command),
details: command.stderr || command.stdout,
network,
} satisfies WifiConnectResult
}
export async function scanWifiNetworks() {
if (process.platform !== 'linux') {
return { code: 'NOT_SUPPORTED', networks: [] } satisfies WifiScanResult
}
if (!commandExists('nmcli')) {
return { code: 'NMCLI_MISSING', networks: [] } satisfies WifiScanResult
}
const device = await findWifiDevice()
if (!device) {
return { code: 'NO_WIFI_DEVICE', networks: [] } satisfies WifiScanResult
}
const command = await runCommand([
'nmcli', '--colors', 'no', '--escape', 'no', '--mode', 'multiline',
'--fields', 'SSID,SIGNAL,SECURITY', 'device', 'wifi', 'list', 'ifname', device, '--rescan', 'yes',
])
if (command.exitCode !== 0) {
return {
code: 'SCAN_FAILED',
details: command.stderr || command.stdout,
networks: [],
} satisfies WifiScanResult
}
return {
code: 'OK',
networks: parseWifiList(command.stdout),
} satisfies WifiScanResult
}
function classifyConnectError(command: CommandResult): WifiConnectCode {
const text = `${command.stdout}\n${command.stderr}`.toLowerCase()
if (text.includes('secrets were required') || text.includes('wrong password') || text.includes('authentication')) {
return 'BAD_PASSWORD'
}
if (text.includes('no network with ssid') || text.includes('could not find') || text.includes('not found')) {
return 'NOT_FOUND'
}
return 'FAILED'
}
function commandExists(command: string) {
const proc = Bun.spawnSync(['which', command], {
stdout: 'ignore',
stderr: 'ignore',
})
return proc.exitCode === 0
}
async function findWifiDevice() {
const command = await runCommand(['nmcli', '--colors', 'no', '--terse', '--fields', 'DEVICE,TYPE', 'device', 'status'])
if (command.exitCode !== 0) return
for (const rawLine of command.stdout.split('\n')) {
const line = rawLine.trim()
if (!line) continue
const [device, type] = line.split(':')
if (type === 'wifi' && device) return device
}
}
function getOpenState(security: string) {
const normalized = security.trim()
return normalized === '' || normalized === '--'
}
function normalizeNmcliLine(line: string) {
return line.trim()
}
function parseSignal(value: string) {
const signal = Number.parseInt(value, 10)
if (Number.isNaN(signal)) return 0
return Math.max(0, Math.min(100, signal))
}
function parseWifiList(output: string) {
const bySsid = new Map<string, ParsedWifiRecord>()
const records = output.split(/\n\s*\n/g)
for (const record of records) {
const parsed = parseWifiRecord(record)
if (!parsed || !parsed.ssid) continue
const existing = bySsid.get(parsed.ssid)
if (!existing || parsed.signal > existing.signal) bySsid.set(parsed.ssid, parsed)
}
return [...bySsid.values()]
.sort((left, right) => right.signal - left.signal || left.ssid.localeCompare(right.ssid))
.slice(0, MAX_NETWORKS)
.map((record, index) => ({
id: index + 1,
isOpen: getOpenState(record.security),
security: record.security,
signal: record.signal,
ssid: record.ssid,
})) satisfies WifiNetwork[]
}
function parseWifiRecord(record: string) {
let security = ''
let signal = 0
let ssid = ''
for (const rawLine of record.split('\n')) {
const line = rawLine.trim()
if (!line) continue
if (line.startsWith('SSID:')) {
ssid = normalizeNmcliLine(line.slice(5))
continue
}
if (line.startsWith('SIGNAL:')) {
signal = parseSignal(line.slice(7).trim())
continue
}
if (line.startsWith('SECURITY:')) {
security = normalizeNmcliLine(line.slice(9))
}
}
if (!ssid) return
return {
security,
signal,
ssid,
} satisfies ParsedWifiRecord
}
async function runCommand(args: string[]) {
const proc = Bun.spawn(args, {
stdout: 'pipe',
stderr: 'pipe',
})
const [stdout, stderr, exitCode] = await Promise.all([
getText(proc.stdout),
getText(proc.stderr),
proc.exited,
])
return {
exitCode,
stderr: stderr.trim(),
stdout: stdout.trim(),
} satisfies CommandResult
}
function getText(stream: ReadableStream<Uint8Array> | null | undefined) {
if (!stream) return Promise.resolve('')
return new Response(stream).text().catch(() => '')
}

2
types.d.ts vendored Normal file
View File

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