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:
parent
9c20ce7d9c
commit
5ac02af171
105
bun.lock
Normal file
105
bun.lock
Normal 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=="],
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<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 }
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user