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>
144 lines
3.9 KiB
TypeScript
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))
|
|
}
|