forked from probablycorey/baudy
Compare commits
No commits in common. "fi" and "main" have entirely different histories.
53
README.md
53
README.md
|
|
@ -1,44 +1,37 @@
|
||||||
# Baudy Wi‑Fi Chirp Setup
|
# ggwave Audio POC
|
||||||
|
|
||||||
A proof-of-concept for provisioning Wi‑Fi over sound with [ggwave](https://github.com/ggerganov/ggwave).
|
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).
|
||||||
|
|
||||||
The phone and the server exchange short audio chirps to do a simple setup flow:
|
## Why
|
||||||
|
|
||||||
1. **Phone says hello** over audio.
|
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.
|
||||||
2. **Server scans nearby Wi‑Fi 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.
|
|
||||||
|
|
||||||
This is aimed at Raspberry Pi / Linux-style setup flows where networking may not be available yet.
|
## What this POC does
|
||||||
|
|
||||||
## Current assumptions
|
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:
|
||||||
|
|
||||||
- Wi‑Fi scanning and joining are implemented for **Linux via `nmcli`**.
|
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.
|
||||||
- The phone UI is still served as a normal web page for this repo's demo flow.
|
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.
|
||||||
- Passwords are sent as **plaintext audio payloads** in this POC.
|
3. **Phone receives result** — the answer (`390`) appears on the calculator display.
|
||||||
- Audio is **half-duplex**: only one side should chirp at a time.
|
|
||||||
|
|
||||||
## Run
|
The server also chirps the result back through the speakers (half-duplex — it stops listening while playing to avoid feedback).
|
||||||
|
|
||||||
```sh
|
## How to run
|
||||||
|
|
||||||
|
```
|
||||||
|
cd tmp
|
||||||
bun install
|
bun install
|
||||||
bun run index.tsx
|
bun run server.ts
|
||||||
```
|
```
|
||||||
|
|
||||||
Open the shown URL on your phone.
|
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).
|
||||||
|
|
||||||
For phone microphone access you need a secure origin:
|
## How it works
|
||||||
|
|
||||||
- **macOS:** the app serves local HTTPS directly
|
- **ggwave** handles encoding/decoding using multi-frequency FSK modulation with Reed-Solomon error correction. The AUDIBLE_FAST protocol uses audible frequencies (~1-6kHz range).
|
||||||
- **Linux:** prefer `tailscale serve <port>` and open the resulting `https://...ts.net` URL
|
- **Browser side** uses WebAudio API to play encoded waveforms. ggwave runs as WASM.
|
||||||
|
- **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).
|
||||||
|
|
||||||
## Notes
|
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.
|
||||||
|
|
||||||
- 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.
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
- **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
|
||||||
|
|
||||||
|
|
@ -50,5 +41,4 @@
|
||||||
## 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.
|
||||||
- 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.
|
- 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 macOS, the app keeps its original self-signed local HTTPS flow. This works, but phones may show certificate warnings unless you trust the cert.
|
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@
|
||||||
"release": "bash publish.sh"
|
"release": "bash publish.sh"
|
||||||
},
|
},
|
||||||
"toes": {
|
"toes": {
|
||||||
"icon": "📶"
|
"icon": "🖥️"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "latest"
|
"@types/bun": "latest"
|
||||||
|
|
|
||||||
1046
src/pages/phone.tsx
1046
src/pages/phone.tsx
File diff suppressed because it is too large
Load Diff
|
|
@ -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]),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,19 +1,8 @@
|
||||||
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
|
||||||
|
|
@ -22,65 +11,40 @@ 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('')
|
||||||
}
|
}
|
||||||
|
|
||||||
function getChunkPeak(bytes: Uint8Array) {
|
export async function playAudio(text: string) {
|
||||||
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
|
playing = true
|
||||||
let peak = 0
|
|
||||||
|
|
||||||
for (let index = 0; index + 3 < bytes.byteLength; index += 4) {
|
const waveform = ggwave.encode(
|
||||||
const sample = Math.abs(view.getFloat32(index, true))
|
instance, text,
|
||||||
if (sample > peak) peak = sample
|
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) {
|
export async function loopbackTest(): Promise<boolean> {
|
||||||
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> {
|
|
||||||
const testMessage = 'TEST'
|
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))
|
await new Promise(r => setTimeout(r, 200))
|
||||||
|
|
||||||
|
|
@ -92,8 +56,10 @@ export async function loopbackTest(): Promise<LoopbackTestResult> {
|
||||||
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 = createPlaybackProcess()
|
const play = Bun.spawn(
|
||||||
const playbackErrorPromise = getText(play.stderr)
|
['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.write(rawBytes)
|
||||||
play.stdin.end()
|
play.stdin.end()
|
||||||
await play.exited
|
await play.exited
|
||||||
|
|
@ -101,9 +67,7 @@ export async function loopbackTest(): Promise<LoopbackTestResult> {
|
||||||
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 captureBytes = 0
|
let decoded = false
|
||||||
let capturePeak = 0
|
|
||||||
let ok = false
|
|
||||||
|
|
||||||
const timeout = setTimeout(() => { mic.kill() }, 5000)
|
const timeout = setTimeout(() => { mic.kill() }, 5000)
|
||||||
|
|
||||||
|
|
@ -112,81 +76,41 @@ export async function loopbackTest(): Promise<LoopbackTestResult> {
|
||||||
const { done, value } = await reader.read()
|
const { done, value } = await reader.read()
|
||||||
if (done) break
|
if (done) break
|
||||||
|
|
||||||
captureBytes += value.length
|
const newBuf = new Uint8Array(buffer.length + value.length)
|
||||||
capturePeak = Math.max(capturePeak, getChunkPeak(value))
|
newBuf.set(buffer)
|
||||||
|
newBuf.set(value, buffer.length)
|
||||||
const newBuffer = new Uint8Array(buffer.length + value.length)
|
buffer = newBuf
|
||||||
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) continue
|
if (result && result.length > 0) {
|
||||||
|
const text = decodeBytes(result)
|
||||||
const text = decodeBytes(result)
|
if (text === testMessage) {
|
||||||
if (text !== testMessage) continue
|
decoded = true
|
||||||
|
mic.kill()
|
||||||
ok = true
|
break
|
||||||
mic.kill()
|
}
|
||||||
break
|
}
|
||||||
}
|
}
|
||||||
|
if (decoded) break
|
||||||
if (ok) 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) {
|
||||||
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 reader = sox.stdout.getReader()
|
||||||
const bytesPerFrame = params.samplesPerFrame * 4
|
const bytesPerFrame = params.samplesPerFrame * 4
|
||||||
let buffer = new Uint8Array(0)
|
let buffer = new Uint8Array(0)
|
||||||
|
|
@ -197,28 +121,23 @@ export function startMicListener(onMessage: (text: string) => void) {
|
||||||
if (done) break
|
if (done) break
|
||||||
if (playing) continue
|
if (playing) continue
|
||||||
|
|
||||||
const newBuffer = new Uint8Array(buffer.length + value.length)
|
const newBuf = new Uint8Array(buffer.length + value.length)
|
||||||
newBuffer.set(buffer)
|
newBuf.set(buffer)
|
||||||
newBuffer.set(value, buffer.length)
|
newBuf.set(value, buffer.length)
|
||||||
buffer = newBuffer
|
buffer = newBuf
|
||||||
|
|
||||||
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) continue
|
if (decoded && decoded.length > 0) {
|
||||||
|
const text = decodeBytes(decoded)
|
||||||
const text = decodeBytes(decoded)
|
onMessage(text)
|
||||||
onMessage(text)
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
logProcessError('Microphone error', await errorPromise)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
processAudio().catch(async err => {
|
processAudio().catch(err => console.error('Mic error:', err))
|
||||||
console.error('Mic error:', err)
|
|
||||||
logProcessError('Microphone error', await errorPromise)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,41 @@
|
||||||
// Number-guessing demo removed.
|
import { playAudio } from './audio'
|
||||||
// Wi‑Fi audio provisioning now lives in ./provisioning.ts and ./wifi.ts.
|
|
||||||
export {}
|
let secret = randomNumber()
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,10 @@
|
||||||
/** @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'))
|
||||||
|
|
||||||
|
|
@ -22,15 +20,28 @@ app.get('/ggwave.js', () =>
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
function logStartupError(error: unknown) {
|
startup(PORT)
|
||||||
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'])
|
||||||
}
|
}
|
||||||
|
|
||||||
void startup(PORT).catch(logStartupError)
|
await ensureCerts()
|
||||||
|
|
||||||
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'),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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}`)
|
|
||||||
}
|
|
||||||
|
|
@ -1,53 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
@ -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)
|
|
||||||
}
|
|
||||||
|
|
@ -1,9 +1,6 @@
|
||||||
import { getAudioSetup, isLowMixerLevel, refreshAudioSetup, setMixerLevel } from './audio-setup'
|
|
||||||
import { loopbackTest, playAudio, startMicListener } from './audio'
|
import { loopbackTest, playAudio, startMicListener } from './audio'
|
||||||
import { connectSelectedNetwork, getSelectedNetwork, scanAndStoreNetworks, selectScannedNetwork } from './provisioning'
|
import { handleGuess, getSecret } from './game'
|
||||||
import { getLocalServerUrls, isLocalTlsEnabled } from './local-tls'
|
import type { GuessResult } from './game'
|
||||||
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'
|
||||||
|
|
@ -15,13 +12,6 @@ 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) {
|
||||||
|
|
@ -30,302 +20,98 @@ async function prompt(question: string): Promise<string> {
|
||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearScreen() {
|
function getLocalHostname() {
|
||||||
if (process.stdout.isTTY) console.clear()
|
const proc = Bun.spawnSync(['scutil', '--get', 'LocalHostName'])
|
||||||
|
if (proc.exitCode === 0) return proc.stdout.toString().trim() + '.local'
|
||||||
|
return 'localhost'
|
||||||
}
|
}
|
||||||
|
|
||||||
function commandExists(command: string) {
|
function getDeviceName(type: 'input' | 'output'): string {
|
||||||
const proc = Bun.spawnSync(['which', command], { stdout: 'ignore', stderr: 'ignore' })
|
const args = type === 'input' ? ['-c', '-t', 'input'] : ['-c']
|
||||||
return proc.exitCode === 0
|
const proc = Bun.spawnSync(['SwitchAudioSource', ...args])
|
||||||
|
if (proc.exitCode === 0) return proc.stdout.toString().trim()
|
||||||
|
return 'system default'
|
||||||
}
|
}
|
||||||
|
|
||||||
function decodeToken(token: string) {
|
function logGuess(result: GuessResult) {
|
||||||
try {
|
const { type, guess, guessCount } = result
|
||||||
return Buffer.from(token, 'base64url').toString('utf8')
|
if (type === 'higher') {
|
||||||
} catch {
|
console.log(`${DIM}Guess #${guessCount}:${RESET} ${BOLD}${guess}${RESET} → ${YELLOW}📢 Higher!${RESET}`)
|
||||||
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()
|
|
||||||
} catch {
|
|
||||||
// ignored
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getConnectErrorText(code: WifiConnectCode) {
|
|
||||||
if (code === 'BAD_PASSWORD') return 'Bad password.'
|
|
||||||
if (code === 'NMCLI_MISSING') return 'nmcli is not installed.'
|
|
||||||
if (code === 'NOT_FOUND') return 'That network is no longer visible.'
|
|
||||||
if (code === 'NO_WIFI_DEVICE') return 'No Wi‑Fi device was found.'
|
|
||||||
if (code === 'PASSWORD_REQUIRED') return 'Password required.'
|
|
||||||
if (code === 'NOT_SUPPORTED') return 'Wi‑Fi provisioning is implemented for Linux only.'
|
|
||||||
return 'Wi‑Fi connection failed.'
|
|
||||||
}
|
|
||||||
|
|
||||||
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 Wi‑Fi device was found.'
|
|
||||||
if (code === 'NOT_SUPPORTED') return 'Wi‑Fi scanning is implemented for Linux only.'
|
|
||||||
return 'Scanning for Wi‑Fi 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
|
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}`)
|
||||||
|
console.log(`${'─'.repeat(40)}`)
|
||||||
|
console.log()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleIncomingMessage(text: string) {
|
async function onMessage(text: string) {
|
||||||
const trimmed = text.trim()
|
|
||||||
if (!trimmed) return
|
|
||||||
|
|
||||||
await Bun.sleep(500)
|
await Bun.sleep(500)
|
||||||
|
|
||||||
if (trimmed === 'HELLO') {
|
if (text === 'HELLO') {
|
||||||
console.log(`${GREEN}${BOLD}Phone connected via audio.${RESET}`)
|
console.log(`${GREEN}${BOLD}Player connected via audio!${RESET}`)
|
||||||
await playAudio('READY')
|
await playAudio('HEY BUDDY')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (trimmed === 'SCAN') {
|
const result = await handleGuess(text)
|
||||||
await handleScanRequest()
|
if (result) logGuess(result)
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (trimmed.startsWith('SELECT|')) {
|
|
||||||
await handleSelectRequest(trimmed)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (trimmed.startsWith('PASS|')) {
|
|
||||||
await handlePasswordRequest(trimmed)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`${DIM}Ignored unknown audio message:${RESET} ${trimmed}`)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handlePasswordRequest(message: string) {
|
export async function startup(port: number) {
|
||||||
const network = getSelectedNetwork()
|
const soxCheck = Bun.spawnSync(['which', 'sox'])
|
||||||
if (!network) {
|
if (soxCheck.exitCode !== 0) {
|
||||||
console.log(`${YELLOW}Received a password before any network was selected.${RESET}`)
|
console.error(`\n ${BOLD}sox is not installed.${RESET} Run: brew install sox\n`)
|
||||||
await playAudio('PASS_ERR|NO_SELECTION')
|
process.exit(1)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const token = message.slice('PASS|'.length)
|
console.clear()
|
||||||
const password = decodeToken(token)
|
console.log()
|
||||||
if (!password) {
|
console.log(`${BOLD}Corey's Screechy Audio Demo${RESET} ${DIM}v${pkg.version}${RESET}`)
|
||||||
console.log(`${YELLOW}Received an empty or undecodable password token for ${BOLD}${network.ssid}${RESET}${YELLOW}.${RESET}`)
|
console.log()
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
console.log(`${YELLOW}${BOLD}🔊 Turn your volume up!${RESET}`)
|
||||||
|
console.log(`${DIM}Testing audio: playing a chirp and listening for it...${RESET}`)
|
||||||
|
console.log()
|
||||||
|
|
||||||
|
const loopbackOk = await loopbackTest()
|
||||||
|
|
||||||
|
if (loopbackOk) {
|
||||||
|
console.log(`${GREEN}${BOLD}✓ Audio working!${RESET} ${DIM}Speaker → mic pipeline verified${RESET}`)
|
||||||
} else {
|
} else {
|
||||||
console.log(`${CYAN}Received Wi‑Fi password for ${BOLD}${network.ssid}${RESET}${CYAN}. Attempting connection...${RESET}`)
|
console.log(`${RED}${BOLD}✗ Couldn't hear the test chirp.${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 Wi‑Fi 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()
|
console.log()
|
||||||
await playAudio(`SCAN_ERR|${result.code}`)
|
console.log(`${YELLOW}Try:${RESET}`)
|
||||||
return
|
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`)
|
||||||
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 Wi‑Fi 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()
|
console.log()
|
||||||
return
|
await prompt(` Press Enter to continue anyway... `)
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`${RED}${BOLD}Connection failed:${RESET} ${BOLD}${result.network.ssid}${RESET} ${DIM}(${getConnectErrorText(result.code)})${RESET}`)
|
console.clear()
|
||||||
if (result.details) console.log(`${DIM}${result.details.split('\n')[0]}${RESET}`)
|
|
||||||
console.log()
|
console.log()
|
||||||
}
|
console.log(`${BOLD}Corey's Screechy Audio Demo${RESET} ${DIM}v${pkg.version}${RESET}`)
|
||||||
|
console.log(`${DIM}Speaker: ${speaker} · Mic: ${mic}${RESET}`)
|
||||||
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()
|
||||||
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) {
|
const hostname = getLocalHostname()
|
||||||
console.log(' • The microphone device did not produce any audio data')
|
const url = `https://${hostname}:${port}`
|
||||||
} 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(`${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} Wi‑Fi 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}Wi‑Fi 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 Wi‑Fi 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 })
|
||||||
|
|
@ -334,139 +120,11 @@ async function logQrCode(url: string) {
|
||||||
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)
|
console.log()
|
||||||
}
|
|
||||||
|
startMicListener(onMessage)
|
||||||
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 Wi‑Fi 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()
|
|
||||||
|
|
||||||
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 Wi‑Fi 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 Wi‑Fi 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 Wi‑Fi setup chirps...${RESET}`)
|
|
||||||
console.log()
|
|
||||||
|
|
||||||
setRgbLedState('listening')
|
|
||||||
startMicListener(queueIncomingMessage)
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,243 +0,0 @@
|
||||||
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
2
types.d.ts
vendored
|
|
@ -1,2 +0,0 @@
|
||||||
declare module 'ggwave'
|
|
||||||
declare module 'qrcode'
|
|
||||||
Loading…
Reference in New Issue
Block a user