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>
147 lines
5.8 KiB
HTML
147 lines
5.8 KiB
HTML
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
|
|
<title>Audio Calculator</title>
|
|
<script src="/ggwave.js"></script>
|
|
<style>
|
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
body { font-family: -apple-system, system-ui, sans-serif; background: #111; color: #fff; height: 100dvh; display: flex; flex-direction: column; }
|
|
#display { padding: 20px; text-align: right; font-size: 48px; font-family: monospace; min-height: 100px; display: flex; align-items: flex-end; justify-content: flex-end; word-break: break-all; }
|
|
#status { padding: 8px 20px; font-size: 14px; color: #888; text-align: center; }
|
|
.grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 1px; flex: 1; padding: 1px; }
|
|
.grid button {
|
|
font-size: 28px; border: none; background: #333; color: #fff;
|
|
cursor: pointer; min-height: 64px;
|
|
-webkit-tap-highlight-color: transparent;
|
|
}
|
|
.grid button:active { background: #555; }
|
|
.grid button.op { background: #f90; }
|
|
.grid button.op:active { background: #fc3; }
|
|
.grid button.eq { background: #2a2; }
|
|
.grid button.eq:active { background: #3c3; }
|
|
.grid button.clear { background: #c33; }
|
|
.grid button.clear:active { background: #e55; }
|
|
.result { color: #0f0; }
|
|
.sending { color: #f90; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div id="display">0</div>
|
|
<div id="status">Tap any button to start</div>
|
|
<div class="grid">
|
|
<button class="clear" data-action="clear">C</button>
|
|
<button data-action="input" data-value="(">(</button>
|
|
<button data-action="input" data-value=")">)</button>
|
|
<button class="op" data-action="input" data-value="/">÷</button>
|
|
|
|
<button data-action="input" data-value="7">7</button>
|
|
<button data-action="input" data-value="8">8</button>
|
|
<button data-action="input" data-value="9">9</button>
|
|
<button class="op" data-action="input" data-value="*">×</button>
|
|
|
|
<button data-action="input" data-value="4">4</button>
|
|
<button data-action="input" data-value="5">5</button>
|
|
<button data-action="input" data-value="6">6</button>
|
|
<button class="op" data-action="input" data-value="-">−</button>
|
|
|
|
<button data-action="input" data-value="1">1</button>
|
|
<button data-action="input" data-value="2">2</button>
|
|
<button data-action="input" data-value="3">3</button>
|
|
<button class="op" data-action="input" data-value="+">+</button>
|
|
|
|
<button data-action="input" data-value="0" style="grid-column: span 2">0</button>
|
|
<button data-action="input" data-value=".">.</button>
|
|
<button class="eq" data-action="send">=</button>
|
|
</div>
|
|
|
|
<script>
|
|
const display = document.getElementById('display')
|
|
const statusEl = document.getElementById('status')
|
|
let expression = ''
|
|
let gg, ggInstance, audioContext
|
|
|
|
// iOS 17+: bypass hardware silent switch by routing WebAudio
|
|
// through the media/playback channel instead of the ringer channel.
|
|
function bypassSilentMode() {
|
|
if ('audioSession' in navigator) navigator.audioSession.type = 'playback'
|
|
}
|
|
|
|
async function sendExpression(expr) {
|
|
if (!gg || !audioContext) return
|
|
if (audioContext.state === 'suspended') await audioContext.resume()
|
|
|
|
display.textContent = expr
|
|
display.className = 'sending'
|
|
statusEl.textContent = 'Sending...'
|
|
|
|
// encode() returns Int8Array of raw F32 bytes on the WASM heap — must copy out
|
|
const rawBytes = gg.encode(ggInstance, expr, 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() })
|
|
statusEl.textContent = 'Waiting for result...'
|
|
}
|
|
|
|
// SSE for results from server
|
|
const events = new EventSource('/events')
|
|
events.onmessage = (e) => {
|
|
const data = JSON.parse(e.data)
|
|
if (data.type === 'received') {
|
|
display.textContent = data.result
|
|
display.className = 'result'
|
|
statusEl.textContent = data.expression + ' = ' + data.result
|
|
expression = data.result
|
|
}
|
|
}
|
|
|
|
// iOS Safari requires AudioContext to be created synchronously inside a
|
|
// user gesture handler. Any await before creation breaks the gesture chain.
|
|
document.querySelector('.grid').addEventListener('click', async (e) => {
|
|
const btn = e.target.closest('button')
|
|
if (!btn) return
|
|
|
|
if (!audioContext) {
|
|
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()
|
|
}
|
|
|
|
if (!gg) {
|
|
statusEl.textContent = 'Initializing...'
|
|
gg = await ggwave_factory()
|
|
gg.disableLog()
|
|
ggInstance = gg.init(gg.getDefaultParameters())
|
|
statusEl.textContent = 'Ready'
|
|
}
|
|
|
|
const action = btn.dataset.action
|
|
if (action === 'clear') {
|
|
expression = ''
|
|
display.textContent = '0'
|
|
display.className = ''
|
|
statusEl.textContent = 'Ready'
|
|
} else if (action === 'input') {
|
|
expression += btn.dataset.value
|
|
display.textContent = expression
|
|
display.className = ''
|
|
} else if (action === 'send' && expression) {
|
|
await sendExpression(expression)
|
|
}
|
|
})
|
|
</script>
|
|
</body>
|
|
</html>
|