diff --git a/.gitignore b/.gitignore
index 74c83bb..375a2fb 100644
--- a/.gitignore
+++ b/.gitignore
@@ -37,3 +37,6 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# Finder (MacOS) folder config
.DS_Store
+
+# TLS certs
+certs/
diff --git a/docs/ggwave-gotchas.md b/docs/ggwave-gotchas.md
index 201f075..9f5fed7 100644
--- a/docs/ggwave-gotchas.md
+++ b/docs/ggwave-gotchas.md
@@ -20,10 +20,8 @@
- **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 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.
+- 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. 300ms gap after playback before resuming listening.
+- 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.
diff --git a/src/pages/index.tsx b/src/pages/index.tsx
deleted file mode 100644
index 560503f..0000000
--- a/src/pages/index.tsx
+++ /dev/null
@@ -1 +0,0 @@
-export default () =>
baudy
diff --git a/src/pages/phone.tsx b/src/pages/phone.tsx
new file mode 100644
index 0000000..fd9aa5f
--- /dev/null
+++ b/src/pages/phone.tsx
@@ -0,0 +1,400 @@
+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
+
+
+
+
+ Corey's Screechy Audio Demo
+ ๐ Turn your volume up on both devices!
+
+ Connect
+
+
+
+
+
+ Guess the Number
1 โ 100
+ 50
+
+ -10
+ -1
+ +1
+ +10
+
+ Guess!
+
+
+
+
+ ๐
+
+
+
+
+
+
+
+