baudy/src/server/audio.ts
Corey Johnson 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

144 lines
3.9 KiB
TypeScript

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))
}