diff --git a/.gitignore b/.gitignore index 74c83bb..375a2fb 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,6 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json # Finder (MacOS) folder config .DS_Store + +# TLS certs +certs/ diff --git a/docs/ggwave-gotchas.md b/docs/ggwave-gotchas.md index 201f075..9f5fed7 100644 --- a/docs/ggwave-gotchas.md +++ b/docs/ggwave-gotchas.md @@ -20,10 +20,8 @@ - **MacBook Air mic disabled when lid is closed**: If using a monitor, the laptop mic won't work. Use the monitor's mic (e.g., Studio Display) instead. - **Sample rate must match**: ggwave needs matching sample rates for encode/decode. Browser uses 48000Hz. Server must also use 48000Hz โ€” sox will resample from the device's native rate automatically. -## SSE with Bun - -- Bun's default idle timeout is 10 seconds, which kills SSE connections. Set `idleTimeout: 255` (max value) on `Bun.serve()`. - ## Half-Duplex Audio -- Both sides use the same audible protocol (GGWAVE_PROTOCOL_AUDIBLE_FAST). A `playing` flag stops mic processing during playback to prevent self-hearing/feedback. 300ms gap after playback before resuming listening. +- Both sides (server and browser) use the same audible protocol (GGWAVE_PROTOCOL_AUDIBLE_FAST). A `playing` flag stops mic processing during playback to prevent self-hearing/feedback. 300ms gap after playback before resuming listening. +- The browser uses `ScriptProcessorNode` to feed mic audio frames to ggwave for decoding. Frames are accumulated to `samplesPerFrame` size before decoding. +- Browser mic requires `getUserMedia` with `echoCancellation: false`, `noiseSuppression: false`, `autoGainControl: false` to preserve signal integrity. diff --git a/src/pages/index.tsx b/src/pages/index.tsx deleted file mode 100644 index 560503f..0000000 --- a/src/pages/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export default () =>

baudy

diff --git a/src/pages/phone.tsx b/src/pages/phone.tsx new file mode 100644 index 0000000..fd9aa5f --- /dev/null +++ b/src/pages/phone.tsx @@ -0,0 +1,400 @@ +import { define, stylesToCSS } from '@because/forge' + +// --------------------------------------------------------------------------- +// Forge components +// --------------------------------------------------------------------------- + +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 VictoryEmoji = define('VictoryEmoji', { + fontSize: 64, + marginBottom: 16, +}) + +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: 8, + 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)', +}) + +const ConnectBtn = define('ConnectBtn', { + 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 ConnectStatus = define('ConnectStatus', { + fontSize: 18, + color: '#888', + marginTop: 24, + textAlign: 'center', +}) + +// --------------------------------------------------------------------------- +// Client-side code โ€” real functions, serialized into + + + + + Corey's Screechy Audio Demo + ๐Ÿ”Š Turn your volume up on both devices! +
+ Connect +
+ +
+ + + + + + +
+ - - - - {/* 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')) @@ -649,134 +13,20 @@ app.get('/styles.css', c => 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}`) - } -}) +startup(PORT) -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' +export default { + ...app.defaults, + port: PORT, + idleTimeout: 255, + tls: { + key: Bun.file('./certs/key.pem'), + cert: Bun.file('./certs/cert.pem'), + }, } - -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 } diff --git a/src/server/terminal.ts b/src/server/terminal.ts new file mode 100644 index 0000000..d73a9a9 --- /dev/null +++ b/src/server/terminal.ts @@ -0,0 +1,129 @@ +import { loopbackTest, playAudio, startMicListener } from './audio' +import { handleGuess, getSecret } from './game' +import type { GuessResult } from './game' + +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 RED = '\x1b[31m' + +async function prompt(question: string): Promise { + process.stdout.write(question) + for await (const line of console) { + return line.trim() + } + return '' +} + +function getLocalHostname() { + const proc = Bun.spawnSync(['scutil', '--get', 'LocalHostName']) + if (proc.exitCode === 0) return proc.stdout.toString().trim() + '.local' + return 'localhost' +} + +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' +} + +function logGuess(result: GuessResult) { + const { type, guess, guessCount } = result + if (type === 'higher') { + console.log(`${DIM}Guess #${guessCount}:${RESET} ${BOLD}${guess}${RESET} โ†’ ${YELLOW}๐Ÿ“ข Higher!${RESET}`) + return + } + if (type === 'lower') { + console.log(`${DIM}Guess #${guessCount}:${RESET} ${BOLD}${guess}${RESET} โ†’ ${BLUE}๐Ÿ“ข Lower!${RESET}`) + return + } + console.log(`${DIM}Guess #${guessCount}:${RESET} ${BOLD}${guess}${RESET} โ†’ ${GREEN}๐ŸŽ‰ CORRECT!${RESET}`) + console.log() + console.log(`${CYAN}New round! Secret number: ${BOLD}${getSecret()}${RESET}`) + console.log(`${'โ”€'.repeat(40)}`) + console.log() +} + +async function onMessage(text: string) { + await Bun.sleep(500) + + if (text === 'HELLO') { + console.log(`${GREEN}${BOLD}Player connected via audio!${RESET}`) + await playAudio('HEY BUDDY') + return + } + + const result = await handleGuess(text) + if (result) logGuess(result) +} + +export async function startup(port: number) { + 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) + } + + 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... `) + } + + console.clear() + console.log() + console.log(`${BOLD}Corey's Screechy Audio Demo${RESET}`) + console.log(`${DIM}Speaker: ${speaker} ยท Mic: ${mic}${RESET}`) + console.log() + + const hostname = getLocalHostname() + const url = `https://${hostname}:${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(`${BOLD}Secret number: ${GREEN}${getSecret()}${RESET}`) + console.log(`${'โ”€'.repeat(40)}`) + console.log(`${DIM}๐ŸŽค Listening for guesses...${RESET}`) + console.log() + + startMicListener(onMessage) +}