forked from probablycorey/baudy
Includes a working Bun server and iOS Safari calculator UI that uses data-over-sound encoding/decoding via the ggwave library. Phone encodes expressions as audible chirps, Mac server decodes via microphone, evaluates, and sends result back via SSE. Tested reliably with ambient noise using Studio Display microphone. Includes gotchas documentation covering iOS audio, macOS mic permissions, WASM heap, and sample rate requirements. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
156 lines
4.6 KiB
TypeScript
156 lines
4.6 KiB
TypeScript
import factory from 'ggwave'
|
|
|
|
const PORT = 8888
|
|
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)
|
|
|
|
// SSE clients waiting for results
|
|
const clients = new Set<ReadableStreamDefaultController>()
|
|
|
|
// Half-duplex: don't process mic while playing
|
|
let playing = false
|
|
|
|
function broadcast(data: object) {
|
|
const msg = `data: ${JSON.stringify(data)}\n\n`
|
|
for (const controller of clients) {
|
|
try { controller.enqueue(new TextEncoder().encode(msg)) }
|
|
catch { clients.delete(controller) }
|
|
}
|
|
}
|
|
|
|
function evaluate(expr: string): string {
|
|
if (!/^[\d+\-*/.() ]+$/.test(expr)) return 'ERR'
|
|
try { return String(new Function(`return (${expr})`)()) }
|
|
catch { return 'ERR' }
|
|
}
|
|
|
|
function decodeBytes(data: Int8Array): string {
|
|
return Array.from(data).map(b => String.fromCharCode(b & 0xff)).join('')
|
|
}
|
|
|
|
async function playResult(text: string) {
|
|
playing = true
|
|
broadcast({ type: 'playing', result: text })
|
|
|
|
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
|
|
broadcast({ type: 'ready' })
|
|
console.log('Played result:', text)
|
|
}
|
|
|
|
function startMicListener() {
|
|
// Uses default device (-d). Explicit CoreAudio device names produce all-zero
|
|
// data due to macOS TCC permission scoping.
|
|
const sox = Bun.spawn(
|
|
['sox', '-d', '-t', 'raw', '-r', String(SAMPLE_RATE), '-c', '1', '-b', '32', '-e', 'floating-point', '-'],
|
|
{ stdout: 'pipe', stderr: 'pipe' }
|
|
)
|
|
|
|
new Response(sox.stderr).text().then(err => {
|
|
const deviceLine = err.split('\n').find(l => l.includes('Input File'))
|
|
if (deviceLine) console.log('Mic:', deviceLine.trim())
|
|
})
|
|
|
|
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)
|
|
const result = evaluate(text)
|
|
console.log(`${text} = ${result}`)
|
|
broadcast({ type: 'received', expression: text, result })
|
|
await playResult(result)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
processAudio().catch(err => console.error('Mic error:', err))
|
|
}
|
|
|
|
Bun.serve({
|
|
port: PORT,
|
|
idleTimeout: 255,
|
|
async fetch(req) {
|
|
const url = new URL(req.url)
|
|
|
|
if (url.pathname === '/ok') return new Response('ok')
|
|
|
|
if (url.pathname === '/') {
|
|
return new Response(Bun.file(import.meta.dir + '/index.html'), {
|
|
headers: { 'Content-Type': 'text/html' },
|
|
})
|
|
}
|
|
|
|
if (url.pathname === '/ggwave.js') {
|
|
return new Response(Bun.file(import.meta.dir + '/node_modules/ggwave/ggwave.js'), {
|
|
headers: { 'Content-Type': 'application/javascript' },
|
|
})
|
|
}
|
|
|
|
if (url.pathname === '/ggwave.wasm') {
|
|
return new Response(Bun.file(import.meta.dir + '/node_modules/ggwave/ggwave.wasm'), {
|
|
headers: { 'Content-Type': 'application/wasm' },
|
|
})
|
|
}
|
|
|
|
if (url.pathname === '/events') {
|
|
const stream = new ReadableStream({
|
|
start(controller) {
|
|
clients.add(controller)
|
|
controller.enqueue(new TextEncoder().encode(`data: ${JSON.stringify({ type: 'ready' })}\n\n`))
|
|
},
|
|
cancel(controller) { clients.delete(controller) },
|
|
})
|
|
return new Response(stream, {
|
|
headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' },
|
|
})
|
|
}
|
|
|
|
return new Response('not found', { status: 404 })
|
|
},
|
|
})
|
|
|
|
console.log(`Listening on http://localhost:${PORT}`)
|
|
startMicListener()
|