Add ggwave guessing game demo

Phone UI sends number guesses via ggwave audio chirps through the air,
server decodes from mic, responds with Higher/Lower via SSE + audio.
Includes loopback audio test on startup, QR code for phone, stepped
terminal UX, chunked victory message, and timeout error handling.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Corey Johnson 2026-03-12 09:57:48 -07:00
parent 9c20ce7d9c
commit 5ac02af171
3 changed files with 888 additions and 4 deletions

105
bun.lock Normal file
View File

@ -0,0 +1,105 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "baudy",
"dependencies": {
"@because/forge": "*",
"@because/howl": "*",
"@because/hype": "0.0.6",
"ggwave": "0.4.0",
"qrcode": "^1.5.4",
},
"devDependencies": {
"@types/bun": "latest",
},
"peerDependencies": {
"typescript": "^5.9.2",
},
},
},
"packages": {
"@because/forge": ["@because/forge@0.0.6", "https://npm.nose.space/@because/forge/-/forge-0.0.6.tgz", { "peerDependencies": { "typescript": "^5" } }, "sha512-Jzkvbvh/ph2plkMQ4DD2u8s3MSsQTQ5KzeeYWpzVzmA6SkU6DUPpDeenj8CkiXFcmNAoJlJxEFXWbCbOC9lPaQ=="],
"@because/howl": ["@because/howl@0.0.3", "https://npm.nose.space/@because/howl/-/howl-0.0.3.tgz", { "dependencies": { "lucide-static": "^0.555.0" }, "peerDependencies": { "@because/forge": "*", "typescript": "^5" } }, "sha512-D4WI1OmXpqI07Gpfqs0UbZ85qaF9ZapUVbKJqiN4FVZtpNAZnbyTfuKxy8qH0ratcoGRGSoQFis0Z3XvWlzpsw=="],
"@because/hype": ["@because/hype@0.0.6", "https://npm.nose.space/@because/hype/-/hype-0.0.6.tgz", { "dependencies": { "hono": "^4.10.4", "kleur": "^4.1.5" }, "peerDependencies": { "typescript": "^5" } }, "sha512-WSRPNoeTBR3nRcPTqfbu6+FUaNenCo/sN/CB2Ism7oiJwTap1i+1AlWPa+MF1eMQlNd2AYRlA3AAu6F52j6/fA=="],
"@types/bun": ["@types/bun@1.3.10", "https://npm.nose.space/@types/bun/-/bun-1.3.10.tgz", { "dependencies": { "bun-types": "1.3.10" } }, "sha512-0+rlrUrOrTSskibryHbvQkDOWRJwJZqZlxrUs1u4oOoTln8+WIXBPmAuCF35SWB2z4Zl3E84Nl/D0P7803nigQ=="],
"@types/node": ["@types/node@25.4.0", "https://npm.nose.space/@types/node/-/node-25.4.0.tgz", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-9wLpoeWuBlcbBpOY3XmzSTG3oscB6xjBEEtn+pYXTfhyXhIxC5FsBer2KTopBlvKEiW9l13po9fq+SJY/5lkhw=="],
"ansi-regex": ["ansi-regex@5.0.1", "https://npm.nose.space/ansi-regex/-/ansi-regex-5.0.1.tgz", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"ansi-styles": ["ansi-styles@4.3.0", "https://npm.nose.space/ansi-styles/-/ansi-styles-4.3.0.tgz", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"bun-types": ["bun-types@1.3.10", "https://npm.nose.space/bun-types/-/bun-types-1.3.10.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="],
"camelcase": ["camelcase@5.3.1", "https://npm.nose.space/camelcase/-/camelcase-5.3.1.tgz", {}, "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="],
"cliui": ["cliui@6.0.0", "https://npm.nose.space/cliui/-/cliui-6.0.0.tgz", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^6.2.0" } }, "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ=="],
"color-convert": ["color-convert@2.0.1", "https://npm.nose.space/color-convert/-/color-convert-2.0.1.tgz", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
"color-name": ["color-name@1.1.4", "https://npm.nose.space/color-name/-/color-name-1.1.4.tgz", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
"decamelize": ["decamelize@1.2.0", "https://npm.nose.space/decamelize/-/decamelize-1.2.0.tgz", {}, "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA=="],
"dijkstrajs": ["dijkstrajs@1.0.3", "https://npm.nose.space/dijkstrajs/-/dijkstrajs-1.0.3.tgz", {}, "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA=="],
"emoji-regex": ["emoji-regex@8.0.0", "https://npm.nose.space/emoji-regex/-/emoji-regex-8.0.0.tgz", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"find-up": ["find-up@4.1.0", "https://npm.nose.space/find-up/-/find-up-4.1.0.tgz", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="],
"get-caller-file": ["get-caller-file@2.0.5", "https://npm.nose.space/get-caller-file/-/get-caller-file-2.0.5.tgz", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
"ggwave": ["ggwave@0.4.0", "https://npm.nose.space/ggwave/-/ggwave-0.4.0.tgz", {}, "sha512-+sKq0aIEVJ7zHj4Vw+Sj/RPa91xp76ihaG5gsOKZ8ojM5+uUu3NFzAspozwBx/zeaThxP5VeIkA2bbsfWpUd2g=="],
"hono": ["hono@4.12.7", "https://npm.nose.space/hono/-/hono-4.12.7.tgz", {}, "sha512-jq9l1DM0zVIvsm3lv9Nw9nlJnMNPOcAtsbsgiUhWcFzPE99Gvo6yRTlszSLLYacMeQ6quHD6hMfId8crVHvexw=="],
"is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "https://npm.nose.space/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
"kleur": ["kleur@4.1.5", "https://npm.nose.space/kleur/-/kleur-4.1.5.tgz", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
"locate-path": ["locate-path@5.0.0", "https://npm.nose.space/locate-path/-/locate-path-5.0.0.tgz", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
"lucide-static": ["lucide-static@0.555.0", "https://npm.nose.space/lucide-static/-/lucide-static-0.555.0.tgz", {}, "sha512-FMMaYYsEYsUA6xlEzIMoKEV3oGnxIIvAN+AtLmYXvlTJptJTveJjVBQwvtA/zZLrD6KLEu89G95dQYlhivw5jQ=="],
"p-limit": ["p-limit@2.3.0", "https://npm.nose.space/p-limit/-/p-limit-2.3.0.tgz", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="],
"p-locate": ["p-locate@4.1.0", "https://npm.nose.space/p-locate/-/p-locate-4.1.0.tgz", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="],
"p-try": ["p-try@2.2.0", "https://npm.nose.space/p-try/-/p-try-2.2.0.tgz", {}, "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="],
"path-exists": ["path-exists@4.0.0", "https://npm.nose.space/path-exists/-/path-exists-4.0.0.tgz", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
"pngjs": ["pngjs@5.0.0", "https://npm.nose.space/pngjs/-/pngjs-5.0.0.tgz", {}, "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw=="],
"qrcode": ["qrcode@1.5.4", "https://npm.nose.space/qrcode/-/qrcode-1.5.4.tgz", { "dependencies": { "dijkstrajs": "^1.0.1", "pngjs": "^5.0.0", "yargs": "^15.3.1" }, "bin": { "qrcode": "bin/qrcode" } }, "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg=="],
"require-directory": ["require-directory@2.1.1", "https://npm.nose.space/require-directory/-/require-directory-2.1.1.tgz", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
"require-main-filename": ["require-main-filename@2.0.0", "https://npm.nose.space/require-main-filename/-/require-main-filename-2.0.0.tgz", {}, "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg=="],
"set-blocking": ["set-blocking@2.0.0", "https://npm.nose.space/set-blocking/-/set-blocking-2.0.0.tgz", {}, "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw=="],
"string-width": ["string-width@4.2.3", "https://npm.nose.space/string-width/-/string-width-4.2.3.tgz", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"strip-ansi": ["strip-ansi@6.0.1", "https://npm.nose.space/strip-ansi/-/strip-ansi-6.0.1.tgz", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"typescript": ["typescript@5.9.3", "https://npm.nose.space/typescript/-/typescript-5.9.3.tgz", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@7.18.2", "https://npm.nose.space/undici-types/-/undici-types-7.18.2.tgz", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
"which-module": ["which-module@2.0.1", "https://npm.nose.space/which-module/-/which-module-2.0.1.tgz", {}, "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ=="],
"wrap-ansi": ["wrap-ansi@6.2.0", "https://npm.nose.space/wrap-ansi/-/wrap-ansi-6.2.0.tgz", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="],
"y18n": ["y18n@4.0.3", "https://npm.nose.space/y18n/-/y18n-4.0.3.tgz", {}, "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ=="],
"yargs": ["yargs@15.4.1", "https://npm.nose.space/yargs/-/yargs-15.4.1.tgz", { "dependencies": { "cliui": "^6.0.0", "decamelize": "^1.2.0", "find-up": "^4.1.0", "get-caller-file": "^2.0.1", "require-directory": "^2.1.1", "require-main-filename": "^2.0.0", "set-blocking": "^2.0.0", "string-width": "^4.2.0", "which-module": "^2.0.0", "y18n": "^4.0.0", "yargs-parser": "^18.1.2" } }, "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A=="],
"yargs-parser": ["yargs-parser@18.1.3", "https://npm.nose.space/yargs-parser/-/yargs-parser-18.1.3.tgz", { "dependencies": { "camelcase": "^5.0.0", "decamelize": "^1.2.0" } }, "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ=="],
}
}

View File

@ -5,7 +5,7 @@
"private": true, "private": true,
"scripts": { "scripts": {
"toes": "bun run --watch index.tsx", "toes": "bun run --watch index.tsx",
"start": "bun toes", "start": "bun run index.tsx",
"dev": "bun run --hot index.tsx" "dev": "bun run --hot index.tsx"
}, },
"toes": { "toes": {
@ -20,6 +20,8 @@
"dependencies": { "dependencies": {
"@because/hype": "0.0.6", "@because/hype": "0.0.6",
"@because/forge": "*", "@because/forge": "*",
"@because/howl": "*" "@because/howl": "*",
"ggwave": "0.4.0",
"qrcode": "^1.5.4"
} }
} }

View File

@ -1,5 +1,782 @@
import { Hype } from '@because/hype' import { Hype } from '@because/hype'
import { define, stylesToCSS } from '@because/forge'
import factory from 'ggwave'
import { networkInterfaces } from 'os'
const app = new Hype({ok: true}) const PORT = Number(process.env.PORT) || 3000
const SAMPLE_RATE = 48000
export default app.defaults // ---------------------------------------------------------------------------
// ggwave
// ---------------------------------------------------------------------------
const ggwave = await factory()
const params = ggwave.getDefaultParameters()
params.sampleRateInp = SAMPLE_RATE
params.sampleRateOut = SAMPLE_RATE
params.sampleRate = SAMPLE_RATE
const instance = ggwave.init(params)
// ---------------------------------------------------------------------------
// Game state
// ---------------------------------------------------------------------------
let secret = randomNumber()
let guessCount = 0
let playing = false
function randomNumber() {
return Math.floor(Math.random() * 100) + 1
}
// ---------------------------------------------------------------------------
// ANSI helpers
// ---------------------------------------------------------------------------
const RESET = '\x1b[0m'
const BOLD = '\x1b[1m'
const DIM = '\x1b[2m'
const GREEN = '\x1b[32m'
const YELLOW = '\x1b[33m'
const BLUE = '\x1b[34m'
const CYAN = '\x1b[36m'
const MAGENTA = '\x1b[35m'
const RED = '\x1b[31m'
// ---------------------------------------------------------------------------
// Terminal I/O
// ---------------------------------------------------------------------------
async function prompt(question: string): Promise<string> {
process.stdout.write(question)
for await (const line of console) {
return line.trim()
}
return ''
}
// ---------------------------------------------------------------------------
// SSE
// ---------------------------------------------------------------------------
type Sender = (data: Record<string, unknown>) => void
const senders = new Set<Sender>()
function broadcast(data: Record<string, unknown>) {
for (const send of senders) {
try { send(data) }
catch { senders.delete(send) }
}
}
// ---------------------------------------------------------------------------
// Audio helpers
// ---------------------------------------------------------------------------
function decodeBytes(data: Int8Array): string {
return Array.from(data).map(b => String.fromCharCode(b & 0xff)).join('')
}
function getDeviceName(type: 'input' | 'output'): string {
const args = type === 'input' ? ['-c', '-t', 'input'] : ['-c']
const proc = Bun.spawnSync(['SwitchAudioSource', ...args])
if (proc.exitCode === 0) return proc.stdout.toString().trim()
return 'system default'
}
async function playAudio(text: string) {
playing = true
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 = 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
}
async function loopbackTest(): Promise<boolean> {
const testMessage = 'TEST'
// Start mic listener
const mic = Bun.spawn(
['sox', '-d', '-t', 'raw', '-r', String(SAMPLE_RATE), '-c', '1', '-b', '32', '-e', 'floating-point', '-'],
{ stdout: 'pipe', stderr: 'ignore' }
)
// Small delay for mic to start
await new Promise(r => setTimeout(r, 200))
// Play the test chirp
const waveform = ggwave.encode(
instance, testMessage,
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 = 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
// Listen for a bit to decode
const reader = mic.stdout.getReader()
const bytesPerFrame = params.samplesPerFrame * 4
let buffer = new Uint8Array(0)
let decoded = false
const timeout = setTimeout(() => {
mic.kill()
}, 5000)
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
const newBuf = new Uint8Array(buffer.length + value.length)
newBuf.set(buffer)
newBuf.set(value, buffer.length)
buffer = newBuf
while (buffer.length >= bytesPerFrame) {
const frame = buffer.slice(0, bytesPerFrame)
buffer = buffer.slice(bytesPerFrame)
const result = ggwave.decode(instance, frame)
if (result && result.length > 0) {
const text = decodeBytes(result)
if (text === testMessage) {
decoded = true
mic.kill()
break
}
}
}
if (decoded) break
}
} catch {
// reader closed from kill, that's fine
}
clearTimeout(timeout)
return decoded
}
// ---------------------------------------------------------------------------
// Mic listener
// ---------------------------------------------------------------------------
function startMicListener() {
const sox = Bun.spawn(
['sox', '-d', '-t', 'raw', '-r', String(SAMPLE_RATE), '-c', '1', '-b', '32', '-e', 'floating-point', '-'],
{ stdout: 'pipe', stderr: 'ignore' }
)
const reader = sox.stdout.getReader()
const bytesPerFrame = params.samplesPerFrame * 4
let buffer = new Uint8Array(0)
async function processAudio() {
while (true) {
const { done, value } = await reader.read()
if (done) break
if (playing) continue
const newBuf = new Uint8Array(buffer.length + value.length)
newBuf.set(buffer)
newBuf.set(value, buffer.length)
buffer = newBuf
while (buffer.length >= bytesPerFrame) {
const frame = buffer.slice(0, bytesPerFrame)
buffer = buffer.slice(bytesPerFrame)
const decoded = ggwave.decode(instance, frame)
if (decoded && decoded.length > 0) {
const text = decodeBytes(decoded)
handleGuess(text)
}
}
}
}
processAudio().catch(err => console.error('Mic error:', err))
}
// ---------------------------------------------------------------------------
// Game logic
// ---------------------------------------------------------------------------
async function handleGuess(guessStr: string) {
const guess = parseInt(guessStr, 10)
if (isNaN(guess) || guess < 1 || guess > 100) return
guessCount++
if (guess < secret) {
const hint = 'Higher!'
console.log(`${DIM}Guess #${guessCount}:${RESET} ${BOLD}${guess}${RESET}${YELLOW}📢 ${hint}${RESET}`)
broadcast({ type: 'hint', hint, guessCount })
await playAudio(hint)
return
}
if (guess > secret) {
const hint = 'Lower!'
console.log(`${DIM}Guess #${guessCount}:${RESET} ${BOLD}${guess}${RESET}${BLUE}📢 ${hint}${RESET}`)
broadcast({ type: 'hint', hint, guessCount })
await playAudio(hint)
return
}
// Correct!
console.log(`${DIM}Guess #${guessCount}:${RESET} ${BOLD}${guess}${RESET}${GREEN}🎉 CORRECT!${RESET}`)
const victoryMessage = [
`Congratulations! You found ${guess} in ${guessCount} ${guessCount === 1 ? 'guess' : 'guesses'}!`,
`This entire message was transmitted through sound waves using ggwave audio encoding at 48kHz.`,
`Each chirp you heard carried data through the air — no network, no bluetooth, just sound.`,
`The future of data-over-sound is here!`,
].join(' ')
broadcast({ type: 'sending_victory' })
console.log()
console.log(`${MAGENTA}📢 Sending victory message...${RESET}`)
const maxChunk = 100
const chunks: string[] = []
for (let i = 0; i < victoryMessage.length; i += maxChunk) {
chunks.push(victoryMessage.slice(i, i + maxChunk))
}
for (let i = 0; i < chunks.length; i++) {
console.log(`${DIM} chunk ${i + 1}/${chunks.length}: "${chunks[i]!.slice(0, 40)}..."${RESET}`)
await playAudio(chunks[i]!)
}
console.log(`${GREEN}✓ Victory message sent (${victoryMessage.length} bytes in ${chunks.length} chunks)${RESET}`)
console.log()
broadcast({ type: 'victory', message: victoryMessage })
secret = randomNumber()
guessCount = 0
console.log(`${CYAN}New round! Secret number: ${BOLD}${secret}${RESET}`)
console.log(`${'─'.repeat(40)}`)
console.log()
}
// ---------------------------------------------------------------------------
// Forge components (phone UI)
// ---------------------------------------------------------------------------
const Page = define('Page', {
fontFamily: '-apple-system, system-ui, sans-serif',
background: '#111',
color: '#fff',
height: '100dvh',
width: '100vw',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
userSelect: 'none',
WebkitUserSelect: 'none',
position: 'relative',
touchAction: 'manipulation',
})
const Screen = define('Screen', {
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
flex: 1,
width: '100%',
padding: 20,
})
const Title = define('Title', {
fontSize: 18,
color: '#888',
textAlign: 'center',
lineHeight: 1.4,
})
const GuessDisplay = define('GuessDisplay', {
fontSize: 96,
fontWeight: 'bold',
fontFamily: 'monospace',
lineHeight: 1,
margin: '24px 0',
})
const Controls = define('Controls', {
display: 'flex',
gap: 12,
marginBottom: 24,
})
const StepBtn = define('StepBtn', {
base: 'button',
fontSize: 24,
fontWeight: 'bold',
width: 64,
height: 64,
border: 'none',
borderRadius: 12,
background: '#333',
color: '#fff',
cursor: 'pointer',
WebkitTapHighlightColor: 'transparent',
touchAction: 'manipulation',
states: { ':active': { background: '#555' } },
})
const GuessBtn = define('GuessBtn', {
base: 'button',
fontSize: 24,
fontWeight: 'bold',
padding: '16px 48px',
border: 'none',
borderRadius: 16,
background: '#22c55e',
color: '#fff',
cursor: 'pointer',
WebkitTapHighlightColor: 'transparent',
touchAction: 'manipulation',
states: { ':active': { background: '#16a34a' } },
})
const HintText = define('HintText', {
fontSize: 36,
fontWeight: 'bold',
marginTop: 32,
minHeight: 50,
display: 'flex',
alignItems: 'center',
transition: 'opacity 0.15s ease',
})
const BigButton = define('BigButton', {
base: 'button',
fontSize: 28,
fontWeight: 'bold',
padding: '20px 48px',
border: 'none',
borderRadius: 16,
background: '#2563eb',
color: '#fff',
cursor: 'pointer',
WebkitTapHighlightColor: 'transparent',
touchAction: 'manipulation',
states: { ':active': { background: '#1d4ed8' } },
})
const VictoryText = define('VictoryText', {
fontSize: 22,
lineHeight: 1.5,
textAlign: 'center',
padding: '0 20px',
maxWidth: 380,
})
const StatusBar = define('StatusBar', {
position: 'fixed',
bottom: 0,
left: 0,
right: 0,
padding: '8px',
fontSize: 12,
color: '#555',
textAlign: 'center',
})
const VolumeWarning = define('VolumeWarning', {
fontSize: 14,
color: '#f59e0b',
textAlign: 'center',
marginTop: 16,
padding: '8px 16px',
background: 'rgba(245, 158, 11, 0.1)',
borderRadius: 8,
border: '1px solid rgba(245, 158, 11, 0.3)',
})
// ---------------------------------------------------------------------------
// Client script
// ---------------------------------------------------------------------------
const clientScript = `
let currentGuess = 50
let gg, ggInstance, audioContext
let sending = false
let waitTimeout
let guessSentAt = 0
const gameScreen = document.getElementById('game-screen')
const victoryScreen = document.getElementById('victory-screen')
const guessDisplay = document.getElementById('guess-display')
const hintArea = document.getElementById('hint-area')
const statusEl = document.getElementById('status')
const guessBtn = document.getElementById('guess-btn')
const victoryText = document.getElementById('victory-text')
const volumeWarning = document.getElementById('volume-warning')
function bypassSilentMode() {
if ('audioSession' in navigator) navigator.audioSession.type = 'playback'
}
async function init() {
// AudioContext must be created synchronously in gesture handler (iOS)
audioContext = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: 48000 })
const src = audioContext.createBufferSource()
src.buffer = audioContext.createBuffer(1, 1, 48000)
src.connect(audioContext.destination)
src.start()
bypassSilentMode()
statusEl.textContent = 'Initializing audio...'
gg = await ggwave_factory()
gg.disableLog()
ggInstance = gg.init(gg.getDefaultParameters())
statusEl.textContent = 'Ready'
}
async function sendGuess() {
if (sending || !gg) return
sending = true
guessSentAt = Date.now()
guessBtn.textContent = 'Sending...'
guessBtn.style.background = '#666'
if (volumeWarning) volumeWarning.style.display = 'none'
hintArea.textContent = ''
hintArea.style.opacity = '0'
const text = String(currentGuess)
if (audioContext.state === 'suspended') await audioContext.resume()
const rawBytes = gg.encode(ggInstance, text, gg.ProtocolId.GGWAVE_PROTOCOL_AUDIBLE_FAST, 50)
const bytesCopy = new Uint8Array(rawBytes.length)
bytesCopy.set(new Uint8Array(rawBytes.buffer, rawBytes.byteOffset, rawBytes.length))
const floats = new Float32Array(bytesCopy.buffer)
const buf = audioContext.createBuffer(1, floats.length, 48000)
buf.getChannelData(0).set(floats)
const source = audioContext.createBufferSource()
source.buffer = buf
source.connect(audioContext.destination)
await new Promise(resolve => { source.onended = resolve; source.start() })
guessBtn.textContent = 'Waiting...'
statusEl.textContent = 'Waiting for response...'
// If no SSE response within 1.5s, show volume hint and unlock
waitTimeout = setTimeout(function() {
hintArea.style.opacity = '0'
hintArea.textContent = "Failed to hear response. Make sure the computer volume is turned up!"
hintArea.style.color = '#ef4444'
void hintArea.offsetWidth
hintArea.style.opacity = '1'
unlockSending()
}, 5000)
}
function unlockSending() {
if (waitTimeout) { clearTimeout(waitTimeout); waitTimeout = undefined }
sending = false
guessBtn.textContent = 'Guess!'
guessBtn.style.background = '#22c55e'
statusEl.textContent = 'Ready'
}
function adjust(delta) {
if (sending) return
currentGuess = Math.max(1, Math.min(100, currentGuess + delta))
guessDisplay.textContent = currentGuess
hintArea.textContent = ''
hintArea.style.opacity = '0'
}
// SSE
const events = new EventSource('/events')
events.onmessage = function(e) {
const data = JSON.parse(e.data)
if (data.type === 'hint') {
var elapsed = Date.now() - guessSentAt
var remaining = Math.max(0, 2000 - elapsed)
setTimeout(function() {
hintArea.style.opacity = '0'
hintArea.textContent = data.hint
hintArea.style.color = data.hint === 'Higher!' ? '#f59e0b' : '#3b82f6'
void hintArea.offsetWidth
hintArea.style.opacity = '1'
unlockSending()
}, remaining)
}
if (data.type === 'sending_victory') {
hintArea.textContent = '🎉'
hintArea.style.color = '#22c55e'
guessBtn.textContent = 'Receiving...'
guessBtn.style.background = '#666'
statusEl.textContent = 'Receiving victory message...'
}
if (data.type === 'victory') {
gameScreen.style.display = 'none'
victoryScreen.style.display = 'flex'
victoryText.textContent = data.message
statusEl.textContent = ''
}
if (data.type === 'new_game') {
currentGuess = 50
guessDisplay.textContent = '50'
hintArea.textContent = ''
hintArea.style.color = '#fff'
victoryScreen.style.display = 'none'
gameScreen.style.display = 'flex'
unlockSending()
}
}
// Auto-init audio on first interaction
let initialized = false
async function ensureInit() {
if (initialized) return
initialized = true
await init()
}
// Button handlers
document.getElementById('guess-btn').addEventListener('click', async function() {
await ensureInit()
sendGuess()
})
document.getElementById('btn-minus-10').addEventListener('click', async function() { await ensureInit(); adjust(-10) })
document.getElementById('btn-minus-1').addEventListener('click', async function() { await ensureInit(); adjust(-1) })
document.getElementById('btn-plus-1').addEventListener('click', async function() { await ensureInit(); adjust(1) })
document.getElementById('btn-plus-10').addEventListener('click', async function() { await ensureInit(); adjust(10) })
document.getElementById('play-again-btn').addEventListener('click', async function() {
await fetch('/new-game', { method: 'POST' })
})
`
// ---------------------------------------------------------------------------
// Phone page
// ---------------------------------------------------------------------------
function PhonePage() {
return (
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
<title>Corey's Screechy Audio Demo</title>
<link rel="stylesheet" href="/styles.css" />
<script src="/ggwave.js">{""}</script>
</head>
<body style="margin:0;overflow:hidden;touch-action:manipulation">
<Page>
{/* Game screen */}
<Screen id="game-screen">
<Title>Guess the Number<br />1 100</Title>
<GuessDisplay id="guess-display">50</GuessDisplay>
<Controls>
<StepBtn id="btn-minus-10">-10</StepBtn>
<StepBtn id="btn-minus-1">-1</StepBtn>
<StepBtn id="btn-plus-1">+1</StepBtn>
<StepBtn id="btn-plus-10">+10</StepBtn>
</Controls>
<GuessBtn id="guess-btn">Guess!</GuessBtn>
<HintText id="hint-area" />
<VolumeWarning id="volume-warning">🔊 Volume up! You should hear a chirp when you tap Guess.</VolumeWarning>
</Screen>
{/* Victory screen */}
<Screen id="victory-screen" style="display:none">
<div style="font-size:64px;margin-bottom:16px">🎉</div>
<VictoryText id="victory-text" />
<div style="margin-top:32px">
<BigButton id="play-again-btn">Play Again</BigButton>
</div>
</Screen>
<StatusBar id="status">Ready</StatusBar>
</Page>
<script src="/client.js">{""}</script>
</body>
</html>
)
}
// ---------------------------------------------------------------------------
// Hype app
// ---------------------------------------------------------------------------
const app = new Hype({ layout: false, logging: false })
app.get('/ok', c => c.text('ok'))
app.get('/styles.css', c =>
c.text(stylesToCSS(), 200, { 'Content-Type': 'text/css; charset=utf-8' })
)
app.get('/', c => c.html(<PhonePage />))
app.get('/client.js', c =>
c.text(clientScript, 200, { 'Content-Type': 'application/javascript' })
)
app.get('/ggwave.js', () =>
new Response(Bun.file(new URL(import.meta.resolve('ggwave/ggwave.js')).pathname), {
headers: { 'Content-Type': 'application/javascript' },
})
)
app.sse('/events', (send, c) => {
senders.add(send)
send({ type: 'connected' })
console.log(`${GREEN}Player connected!${RESET}`)
return () => {
senders.delete(send)
console.log(`${DIM}Player disconnected${RESET}`)
}
})
app.post('/new-game', c => {
secret = randomNumber()
guessCount = 0
broadcast({ type: 'new_game' })
console.log()
console.log(`${CYAN}New round! Secret number: ${BOLD}${secret}${RESET}`)
console.log(`${'─'.repeat(40)}`)
console.log()
return c.text('ok')
})
// ---------------------------------------------------------------------------
// Startup — stepped, one thing at a time
// ---------------------------------------------------------------------------
function getLocalIP() {
const nets = networkInterfaces()
for (const name of Object.keys(nets)) {
for (const net of nets[name]!) {
if (net.family === 'IPv4' && !net.internal) return net.address
}
}
return 'localhost'
}
async function startup() {
const soxCheck = Bun.spawnSync(['which', 'sox'])
if (soxCheck.exitCode !== 0) {
console.error(`\n ${BOLD}sox is not installed.${RESET} Run: brew install sox\n`)
process.exit(1)
}
// ── Step 1: Audio loopback test ────────────────────────────────────────
console.clear()
console.log()
console.log(`${BOLD}Corey's Screechy Audio Demo${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 {
console.log(`${RED}${BOLD}✗ Couldn't hear the test chirp.${RESET}`)
console.log()
console.log(`${YELLOW}Try:${RESET}`)
console.log(` • Turn your volume up`)
console.log(` • Check System Settings > Sound (output: "${speaker}", input: "${mic}")`)
console.log(` • Disconnect headphones — sound needs to travel through the air`)
console.log()
await prompt(` Press Enter to continue anyway... `)
}
// ── Step 2: Show QR code ──────────────────────────────────────────────
console.clear()
console.log()
console.log(`${BOLD}Corey's Screechy Audio Demo${RESET}`)
console.log(`${DIM}Speaker: ${speaker} · Mic: ${mic}${RESET}`)
console.log()
const ip = getLocalIP()
const url = `http://${ip}:${PORT}`
console.log(`${GREEN}${BOLD}Scan QR code on your phone to play!${RESET}`)
console.log()
try {
const QRCode = await import('qrcode')
const qr = await QRCode.toString(url, { type: 'terminal', small: true })
console.log(qr)
} catch {
console.log(`${BOLD}${CYAN}${url}${RESET}`)
console.log()
}
console.log(`${DIM}Waiting for player...${RESET}`)
console.log()
// Wait for first SSE connection before showing game state
await new Promise<void>(resolve => {
const check = setInterval(() => {
if (senders.size > 0) {
clearInterval(check)
resolve()
}
}, 100)
})
// ── Step 3: Game on ───────────────────────────────────────────────────
console.log()
console.log(`${BOLD}Secret number: ${GREEN}${secret}${RESET}`)
console.log(`${'─'.repeat(40)}`)
console.log(`${DIM}🎤 Listening for guesses...${RESET}`)
console.log()
startMicListener()
}
startup()
export default { ...app.defaults, port: PORT, idleTimeout: 255 }