Compare commits

..

5 Commits

Author SHA1 Message Date
789825614e Bump version to 0.0.7
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 13:35:17 -07:00
7cce1f6bbd Merge probablycorey/refactor-server-split
Resolve conflicts: accept server split, apply hint reorder
to terminal.ts, add JSX pragma to phone.tsx

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 13:34:28 -07:00
11366d62b2 Add bunx support for easy sharing
- Add bin entry and CLI shim for `bunx baudy`
- Add JSX pragmas so hono/jsx resolves without tsconfig
- Add bunfig.toml, .npmignore for publishing
- Reorder error hints to prioritize headphone disconnect

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 13:33:34 -07:00
cfdffa685f Document response timing, sample rate, and secure context gotchas
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 13:06:53 -07:00
37dd74c30d Refactor server monolith into focused modules with sound-only communication
Split 783-line src/server/index.tsx into:
- src/server/audio.ts: ggwave init, playback, mic listener
- src/server/game.ts: pure game logic, returns GuessResult
- src/server/terminal.ts: console output, startup, handshake routing
- src/pages/phone.tsx: Forge components + serialized client JS

Phone page is fully standalone after load — all communication via ggwave
audio (HELLO/HEY BUDDY handshake, guess responses). Added sendAndWait()
for clean half-duplex request/response flow with configurable timeout.
Server waits 500ms before replying to give phone time to switch to listening.
Added TLS support for getUserMedia on mobile.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 12:57:18 -07:00
12 changed files with 769 additions and 769 deletions

3
.gitignore vendored
View File

@ -37,3 +37,6 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# Finder (MacOS) folder config
.DS_Store
# TLS certs
certs/

11
.npmignore Normal file
View File

@ -0,0 +1,11 @@
.context/
tmp/
bun.lock
src/
docs/
cli.ts
index.tsx
tsconfig.json
bunfig.toml
CLAUDE.md
.npmrc

2
bunfig.toml Normal file
View File

@ -0,0 +1,2 @@
jsx = "react-jsx"
jsxImportSource = "hono/jsx"

2
cli.ts Normal file
View File

@ -0,0 +1,2 @@
#!/usr/bin/env bun
import './src/server'

View File

@ -10,7 +10,7 @@
## iOS Safari Audio
- **AudioContext must be created synchronously** inside a user gesture handler (click/tap). Any `await` before `new AudioContext()` breaks the gesture chain and Safari blocks audio permanently for that context.
- **Silent mode bypass**: `navigator.audioSession.type = 'playback'` (iOS 17+) switches WebAudio from ringer channel to media channel, bypassing the hardware mute switch. Without this, the silent switch kills all WebAudio output.
- **Silent mode bypass**: `navigator.audioSession.type = 'play-and-record'` (iOS 17+) bypasses the hardware mute switch. Do NOT use `'playback'` if you also need `getUserMedia` — the `'playback'` category tells iOS "speaker only" and blocks mic access with "AudioSession category is not compatible with audio capture".
- **Unlock pattern**: Create AudioContext → play a silent buffer → then await async init. Never reverse this order.
## macOS Microphone (sox + CoreAudio)
@ -20,10 +20,25 @@
- **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.
- 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.
## Response Timing
- After sending a chirp, the sender must wait ~500ms before switching to listening mode. Without this gap, the receiver's response arrives while the sender is still in "playing" state and gets ignored.
- The receiver should also wait ~500ms after hearing a message before playing its response. This gives the sender time to finish playback, clear its buffer, and switch to listening.
- Both delays are needed: sender waits 500ms after its own playback, receiver waits 500ms before replying. Without both, the response window is missed.
- Use a `sendAndWait(text, timeout)` pattern on the client: set `playing=true`, play the chirp, sleep 500ms, set `playing=false`, then await a promise that resolves when the mic decoder receives a message (or times out).
## Sample Rate
- ggwave must be initialized with explicit sample rates matching the AudioContext: set `sampleRateInp`, `sampleRateOut`, and `sampleRate` on the params object. Using `getDefaultParameters()` without setting these may silently fail to decode if the AudioContext's actual rate differs from the default.
- iOS may not honor the requested `sampleRate` in the AudioContext constructor. Always read `audioContext.sampleRate` after creation and pass that to ggwave, don't assume 48000.
## getUserMedia Secure Context
- `navigator.mediaDevices` is `undefined` on non-secure origins. `*.local` mDNS addresses over HTTP are NOT treated as secure — only `localhost` and `127.0.0.1` are exempt.
- Self-signed certs work but cause "connection is not private" browser warnings. For local dev, hosting the phone page on a separate HTTPS server or using a tunnel is cleaner.

View File

@ -1,8 +1,11 @@
{
"name": "baudy",
"version": "0.0.7",
"module": "index.tsx",
"type": "module",
"private": true,
"bin": {
"baudy": "dist/cli.js"
},
"scripts": {
"toes": "bun run --watch index.tsx",
"start": "bun run index.tsx",

View File

@ -1 +0,0 @@
export default () => <h1>baudy</h1>

401
src/pages/phone.tsx Normal file
View File

@ -0,0 +1,401 @@
/** @jsxImportSource hono/jsx */
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 <script>
// ---------------------------------------------------------------------------
/* eslint-disable */
// @ts-nocheck — this function is serialized and runs in the browser, not on the server
function clientMain() {
var currentGuess = 50
var playing = false
var busy = false
var gg, ggInstance, audioContext
var pendingResolve
var connectScreen = document.getElementById('connect-screen')
var connectStatus = document.getElementById('connect-status')
var guessDisplay = document.getElementById('guess-display')
var hintArea = document.getElementById('hint-area')
var statusEl = document.getElementById('status')
var guessBtn = document.getElementById('guess-btn')
var gameScreen = document.getElementById('game-screen')
var victoryScreen = document.getElementById('victory-screen')
var victoryText = document.getElementById('victory-text')
function bypassSilentMode() {
if ('audioSession' in navigator) navigator.audioSession.type = 'play-and-record'
}
function sleep(ms) {
return new Promise(function (resolve) { setTimeout(resolve, ms) })
}
function playChirp(text) {
var rawBytes = gg.encode(
ggInstance, text,
gg.ProtocolId.GGWAVE_PROTOCOL_AUDIBLE_FAST, 50
)
var bytesCopy = new Uint8Array(rawBytes.length)
bytesCopy.set(new Uint8Array(rawBytes.buffer, rawBytes.byteOffset, rawBytes.length))
var floats = new Float32Array(bytesCopy.buffer)
var buf = audioContext.createBuffer(1, floats.length, audioContext.sampleRate)
buf.getChannelData(0).set(floats)
var source = audioContext.createBufferSource()
source.buffer = buf
source.connect(audioContext.destination)
return new Promise(function (resolve) { source.onended = resolve; source.start() })
}
async function sendAndWait(text, timeoutMs) {
playing = true
if (audioContext.state === 'suspended') await audioContext.resume()
await playChirp(text)
await sleep(500)
playing = false
return new Promise(function (resolve) {
var timer
if (timeoutMs) {
timer = setTimeout(function () {
pendingResolve = undefined
resolve(undefined)
}, timeoutMs)
}
pendingResolve = function (msg) {
if (timer) clearTimeout(timer)
pendingResolve = undefined
resolve(msg)
}
})
}
function startListening(stream) {
var source = audioContext.createMediaStreamSource(stream)
var processor = audioContext.createScriptProcessor(4096, 1, 1)
var samplesPerFrame = gg.getDefaultParameters().samplesPerFrame
var buffer = new Float32Array(0)
processor.onaudioprocess = function (e) {
if (playing) return
var input = e.inputBuffer.getChannelData(0)
var newBuf = new Float32Array(buffer.length + input.length)
newBuf.set(buffer)
newBuf.set(input, buffer.length)
buffer = newBuf
while (buffer.length >= samplesPerFrame) {
var frame = buffer.slice(0, samplesPerFrame)
buffer = buffer.slice(samplesPerFrame)
var bytes = new Uint8Array(frame.buffer, frame.byteOffset, frame.byteLength)
var result = gg.decode(ggInstance, bytes)
if (result && result.length > 0) {
var text = Array.from(result)
.map(function (b) { return String.fromCharCode(b & 0xff) })
.join('')
if (pendingResolve) pendingResolve(text)
}
}
}
source.connect(processor)
processor.connect(audioContext.destination)
}
async function connect() {
try {
// AudioContext MUST be created synchronously in gesture handler (iOS)
audioContext = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: 48000 })
var src = audioContext.createBufferSource()
src.buffer = audioContext.createBuffer(1, 1, 48000)
src.connect(audioContext.destination)
src.start()
bypassSilentMode()
connectStatus.textContent = 'Initializing audio (' + audioContext.sampleRate + ' Hz)...'
gg = await ggwave_factory()
gg.disableLog()
var ggParams = gg.getDefaultParameters()
ggParams.sampleRateInp = audioContext.sampleRate
ggParams.sampleRateOut = audioContext.sampleRate
ggParams.sampleRate = audioContext.sampleRate
ggInstance = gg.init(ggParams)
connectStatus.textContent = 'Requesting microphone...'
var stream = await navigator.mediaDevices.getUserMedia({
audio: { echoCancellation: false, noiseSuppression: false, autoGainControl: false },
})
startListening(stream)
connectStatus.textContent = 'Connecting via audio...'
var response = await sendAndWait('HELLO', 8000)
if (response === 'HEY BUDDY') {
connectScreen.style.display = 'none'
gameScreen.style.display = 'flex'
statusEl.textContent = 'Ready'
} else {
connectStatus.textContent = 'No response heard. Make sure the server is running and volume is up!'
connectStatus.style.color = '#ef4444'
}
} catch (err) {
connectStatus.textContent = err.message || 'Failed to connect'
connectStatus.style.color = '#ef4444'
}
}
async function sendGuess() {
if (busy || !gg) return
busy = true
guessBtn.textContent = 'Sending...'
guessBtn.style.background = '#666'
hintArea.textContent = ''
hintArea.style.opacity = '0'
statusEl.textContent = 'Listening for response...'
guessBtn.textContent = 'Listening...'
var response = await sendAndWait(String(currentGuess), 8000)
if (response === 'Higher!' || response === 'Lower!') {
hintArea.style.opacity = '0'
hintArea.textContent = response
hintArea.style.color = response === 'Higher!' ? '#f59e0b' : '#3b82f6'
void hintArea.offsetWidth
hintArea.style.opacity = '1'
} else if (response === 'Correct!') {
gameScreen.style.display = 'none'
victoryScreen.style.display = 'flex'
victoryText.textContent = 'You found ' + currentGuess + '!'
statusEl.textContent = ''
} else {
hintArea.textContent = 'No response heard. Volume up on both devices!'
hintArea.style.color = '#ef4444'
hintArea.style.opacity = '1'
}
busy = false
guessBtn.textContent = 'Guess!'
guessBtn.style.background = '#22c55e'
statusEl.textContent = 'Ready'
}
function adjust(delta) {
if (busy) return
currentGuess = Math.max(1, Math.min(100, currentGuess + delta))
guessDisplay.textContent = currentGuess
hintArea.textContent = ''
hintArea.style.opacity = '0'
}
window.connect = connect
window.adjust = function (delta) { adjust(delta) }
window.sendGuess = function () { sendGuess() }
}
// ---------------------------------------------------------------------------
// Phone page component
// ---------------------------------------------------------------------------
export 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>
<Screen id="connect-screen">
<Title>Corey's Screechy Audio Demo</Title>
<VolumeWarning>🔊 Turn your volume up on both devices!</VolumeWarning>
<div style="margin-top:32px">
<ConnectBtn onclick="connect()">Connect</ConnectBtn>
</div>
<ConnectStatus id="connect-status" />
</Screen>
<Screen id="game-screen" style="display:none">
<Title>Guess the Number<br />1 100</Title>
<GuessDisplay id="guess-display">50</GuessDisplay>
<Controls>
<StepBtn onclick="adjust(-10)">-10</StepBtn>
<StepBtn onclick="adjust(-1)">-1</StepBtn>
<StepBtn onclick="adjust(1)">+1</StepBtn>
<StepBtn onclick="adjust(10)">+10</StepBtn>
</Controls>
<GuessBtn id="guess-btn" onclick="sendGuess()">Guess!</GuessBtn>
<HintText id="hint-area" />
</Screen>
<Screen id="victory-screen" style="display:none">
<VictoryEmoji>🎉</VictoryEmoji>
<VictoryText id="victory-text" />
</Screen>
<StatusBar id="status" />
</Page>
<script dangerouslySetInnerHTML={{ __html: `(${clientMain})()` }} />
</body>
</html>
)
}
export { stylesToCSS }

143
src/server/audio.ts Normal file
View File

@ -0,0 +1,143 @@
import factory from 'ggwave'
export const SAMPLE_RATE = 48000
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)
let playing = false
function decodeBytes(data: Int8Array): string {
return Array.from(data).map(b => String.fromCharCode(b & 0xff)).join('')
}
export 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
}
export async function loopbackTest(): Promise<boolean> {
const testMessage = 'TEST'
const mic = Bun.spawn(
['sox', '-d', '-t', 'raw', '-r', String(SAMPLE_RATE), '-c', '1', '-b', '32', '-e', 'floating-point', '-'],
{ stdout: 'pipe', stderr: 'ignore' }
)
await new Promise(r => setTimeout(r, 200))
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
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
}
clearTimeout(timeout)
return decoded
}
export function startMicListener(onMessage: (text: string) => void) {
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)
onMessage(text)
}
}
}
}
processAudio().catch(err => console.error('Mic error:', err))
}

41
src/server/game.ts Normal file
View File

@ -0,0 +1,41 @@
import { playAudio } from './audio'
let secret = randomNumber()
let guessCount = 0
function randomNumber() {
return Math.floor(Math.random() * 100) + 1
}
export function getSecret() {
return secret
}
export type GuessResult = {
type: 'higher' | 'lower' | 'correct'
guess: number
guessCount: number
}
export async function handleGuess(guessStr: string): Promise<GuessResult | undefined> {
const guess = parseInt(guessStr, 10)
if (isNaN(guess) || guess < 1 || guess > 100) return
guessCount++
if (guess < secret) {
await playAudio('Higher!')
return { type: 'higher', guess, guessCount }
}
if (guess > secret) {
await playAudio('Lower!')
return { type: 'lower', guess, guessCount }
}
await playAudio('Correct!')
const result: GuessResult = { type: 'correct', guess, guessCount }
secret = randomNumber()
guessCount = 0
return result
}

View File

@ -1,644 +1,9 @@
/** @jsxImportSource hono/jsx */
import { Hype } from '@because/hype'
import { define, stylesToCSS } from '@because/forge'
import factory from 'ggwave'
import { networkInterfaces } from 'os'
import { PhonePage, stylesToCSS } from '../pages/phone'
import { startup } from './terminal'
const PORT = Number(process.env.PORT) || 3000
const SAMPLE_RATE = 48000
// ---------------------------------------------------------------------------
// 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'))
@ -649,134 +14,20 @@ app.get('/styles.css', c =>
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}`)
}
})
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<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 }

129
src/server/terminal.ts Normal file
View File

@ -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<string> {
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(` • Disconnect headphones — sound needs to travel through the air`)
console.log(` • Check System Settings > Sound (output: "${speaker}", input: "${mic}")`)
console.log(` • Turn your volume up`)
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)
}