From 5ac02af171e21839b50b217d6565166e484e8573 Mon Sep 17 00:00:00 2001 From: Corey Johnson Date: Thu, 12 Mar 2026 09:57:48 -0700 Subject: [PATCH] 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 --- bun.lock | 105 ++++++ package.json | 6 +- src/server/index.tsx | 781 ++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 888 insertions(+), 4 deletions(-) create mode 100644 bun.lock diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..bc05008 --- /dev/null +++ b/bun.lock @@ -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=="], + } +} diff --git a/package.json b/package.json index b97cccb..be2e577 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "private": true, "scripts": { "toes": "bun run --watch index.tsx", - "start": "bun toes", + "start": "bun run index.tsx", "dev": "bun run --hot index.tsx" }, "toes": { @@ -20,6 +20,8 @@ "dependencies": { "@because/hype": "0.0.6", "@because/forge": "*", - "@because/howl": "*" + "@because/howl": "*", + "ggwave": "0.4.0", + "qrcode": "^1.5.4" } } diff --git a/src/server/index.tsx b/src/server/index.tsx index b8cb52e..e7687b1 100644 --- a/src/server/index.tsx +++ b/src/server/index.tsx @@ -1,5 +1,782 @@ 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 { + process.stdout.write(question) + for await (const line of console) { + return line.trim() + } + return '' +} + +// --------------------------------------------------------------------------- +// SSE +// --------------------------------------------------------------------------- + +type Sender = (data: Record) => void +const senders = new Set() + +function broadcast(data: Record) { + 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 { + 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 ( + + + + + Corey's Screechy Audio Demo + + + + + + {/* Game screen */} + + Guess the Number<br />1 โ€“ 100 + 50 + + -10 + -1 + +1 + +10 + + Guess! + + ๐Ÿ”Š Volume up! You should hear a chirp when you tap Guess. + + + {/* Victory screen */} + + + Ready + + + + + ) +} + +// --------------------------------------------------------------------------- +// 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()) + +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(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 }