Compare commits
No commits in common. "789825614eecd89642d4b4bb051f3b8960127d3c" and "5ac02af171e21839b50b217d6565166e484e8573" have entirely different histories.
789825614e
...
5ac02af171
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -37,6 +37,3 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
|||
|
||||
# Finder (MacOS) folder config
|
||||
.DS_Store
|
||||
|
||||
# TLS certs
|
||||
certs/
|
||||
|
|
|
|||
11
.npmignore
11
.npmignore
|
|
@ -1,11 +0,0 @@
|
|||
.context/
|
||||
tmp/
|
||||
bun.lock
|
||||
src/
|
||||
docs/
|
||||
cli.ts
|
||||
index.tsx
|
||||
tsconfig.json
|
||||
bunfig.toml
|
||||
CLAUDE.md
|
||||
.npmrc
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
jsx = "react-jsx"
|
||||
jsxImportSource = "hono/jsx"
|
||||
|
|
@ -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 = '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".
|
||||
- **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.
|
||||
- **Unlock pattern**: Create AudioContext → play a silent buffer → then await async init. Never reverse this order.
|
||||
|
||||
## macOS Microphone (sox + CoreAudio)
|
||||
|
|
@ -20,25 +20,10 @@
|
|||
- **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 (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.
|
||||
- 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.
|
||||
|
|
|
|||
|
|
@ -1,11 +1,8 @@
|
|||
{
|
||||
"name": "baudy",
|
||||
"version": "0.0.7",
|
||||
"module": "index.tsx",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"baudy": "dist/cli.js"
|
||||
},
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"toes": "bun run --watch index.tsx",
|
||||
"start": "bun run index.tsx",
|
||||
|
|
|
|||
1
src/pages/index.tsx
Normal file
1
src/pages/index.tsx
Normal file
|
|
@ -0,0 +1 @@
|
|||
export default () => <h1>baudy</h1>
|
||||
|
|
@ -1,401 +0,0 @@
|
|||
/** @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 }
|
||||
|
|
@ -1,143 +0,0 @@
|
|||
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))
|
||||
}
|
||||
|
|
@ -1,41 +0,0 @@
|
|||
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
|
||||
}
|
||||
|
|
@ -1,9 +1,644 @@
|
|||
/** @jsxImportSource hono/jsx */
|
||||
import { Hype } from '@because/hype'
|
||||
import { PhonePage, stylesToCSS } from '../pages/phone'
|
||||
import { startup } from './terminal'
|
||||
import { define, stylesToCSS } from '@because/forge'
|
||||
import factory from 'ggwave'
|
||||
import { networkInterfaces } from 'os'
|
||||
|
||||
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'))
|
||||
|
|
@ -14,20 +649,134 @@ 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' },
|
||||
})
|
||||
)
|
||||
|
||||
startup(PORT)
|
||||
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}`)
|
||||
}
|
||||
})
|
||||
|
||||
export default {
|
||||
...app.defaults,
|
||||
port: PORT,
|
||||
idleTimeout: 255,
|
||||
tls: {
|
||||
key: Bun.file('./certs/key.pem'),
|
||||
cert: Bun.file('./certs/cert.pem'),
|
||||
},
|
||||
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'
|
||||
}
|
||||
|
||||
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 }
|
||||
|
|
|
|||
|
|
@ -1,129 +0,0 @@
|
|||
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)
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user