diff --git a/docs/ggwave-gotchas.md b/docs/ggwave-gotchas.md index 7ceeb82..b82051e 100644 --- a/docs/ggwave-gotchas.md +++ b/docs/ggwave-gotchas.md @@ -19,6 +19,15 @@ - **Workaround**: Change the default input device in System Settings > Sound > Input, then use `sox -d`. Or install `switchaudio-osx` (`brew install switchaudio-osx`) to change it programmatically. - **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. +- **macOS keeps the original self-signed local HTTPS flow**: On macOS the app serves HTTPS directly again, using the original `localhost` self-signed cert behavior. + +## Linux Audio (sox + ALSA) + +- **Mac-only helpers don't exist**: `SwitchAudioSource` and `scutil` are macOS-only. +- **`sox -d` may be wrong on Linux**: ALSA's `default` PCM can be playback-only. In that case, `sox -d` can play audio but fail to open the mic. Prefer explicit devices such as `plughw:0,1` for playback and `plughw:0,0` for capture. +- **Use env overrides when needed**: `BAUDY_CAPTURE_DEVICE=plughw:0,0 BAUDY_PLAYBACK_DEVICE=plughw:0,1 bun run index.tsx` +- **Low mixer gain can look like a decode bug**: If loopback hears only a tiny signal, check `amixer` and raise the speaker path (for example `amixer -q sset 'Speaker Analog' 100%`). +- **Prefer Tailscale Serve for phone access**: Run the app on local HTTP and put it behind `tailscale serve ` so the phone gets a real HTTPS `*.ts.net` origin for `getUserMedia()`. ## Half-Duplex Audio @@ -41,4 +50,5 @@ ## 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. +- On Linux, the default fix is to run the app on local HTTP and expose it with `tailscale serve ` so the phone gets a valid HTTPS `*.ts.net` origin. +- On macOS, the app keeps its original self-signed local HTTPS flow. This works, but phones may show certificate warnings unless you trust the cert. diff --git a/src/server/audio-setup.ts b/src/server/audio-setup.ts new file mode 100644 index 0000000..dda91c6 --- /dev/null +++ b/src/server/audio-setup.ts @@ -0,0 +1,139 @@ +const LINUX_AUDIO_TYPE = 'alsa' +const LOW_MIXER_PERCENT = 70 + +export interface AudioMixerLevel { + command: string + control: string + db?: number + percent: number +} + +export interface AudioSetup { + captureArgs: string[] + captureLabel: string + mixerLevel?: AudioMixerLevel + playbackArgs: string[] + playbackLabel: string +} + +interface LinuxDevice { + device: string + label: string +} + +let audioSetup: AudioSetup | undefined + +export function getAudioSetup() { + if (audioSetup) return audioSetup + + audioSetup = process.platform === 'linux' + ? getLinuxAudioSetup() + : getDefaultAudioSetup() + + return audioSetup +} + +export function isLowMixerLevel(level: AudioMixerLevel | undefined): level is AudioMixerLevel { + return !!level && level.percent < LOW_MIXER_PERCENT +} + +export function refreshAudioSetup() { + audioSetup = undefined + return getAudioSetup() +} + +export function setMixerLevel(control: string, percent: number) { + const proc = Bun.spawnSync(['amixer', '-q', 'sset', control, `${percent}%`], { + stdout: 'ignore', + stderr: 'ignore', + }) + + if (proc.exitCode !== 0) return false + + audioSetup = undefined + return true +} + +function commandExists(command: string) { + const proc = Bun.spawnSync(['which', command], { stdout: 'ignore', stderr: 'ignore' }) + return proc.exitCode === 0 +} + +function getCommandOutput(args: string[]) { + try { + const proc = Bun.spawnSync(args, { stdout: 'pipe', stderr: 'ignore' }) + if (proc.exitCode === 0) return proc.stdout.toString().trim() + } catch { + // ignored + } +} + +function getDefaultAudioSetup(): AudioSetup { + return { + captureArgs: ['-d'], + captureLabel: getDefaultLabel('input'), + playbackArgs: ['-d'], + playbackLabel: getDefaultLabel('output'), + } +} + +function getDefaultLabel(type: 'input' | 'output') { + if (process.platform === 'darwin' && commandExists('SwitchAudioSource')) { + const args = type === 'input' ? ['SwitchAudioSource', '-c', '-t', 'input'] : ['SwitchAudioSource', '-c'] + return getCommandOutput(args) || `system default ${type}` + } + + return `system default ${type}` +} + +function getLinuxAudioSetup(): AudioSetup { + const capture = getLinuxDevice('capture') + const playback = getLinuxDevice('playback') + const mixerLevel = getLinuxMixerLevel() + + return { + captureArgs: ['-t', LINUX_AUDIO_TYPE, capture.device], + captureLabel: capture.label, + mixerLevel, + playbackArgs: ['-t', LINUX_AUDIO_TYPE, playback.device], + playbackLabel: playback.label, + } +} + +function getLinuxDevice(type: 'capture' | 'playback'): LinuxDevice { + const envName = type === 'capture' ? 'BAUDY_CAPTURE_DEVICE' : 'BAUDY_PLAYBACK_DEVICE' + const envDevice = process.env[envName]?.trim() + + if (envDevice) return { device: envDevice, label: `${envDevice} (${envName})` } + + const args = type === 'capture' ? ['arecord', '-l'] : ['aplay', '-l'] + const output = getCommandOutput(args) + const match = output?.match(/card\s+(\d+):.*?device\s+(\d+):/s) + + if (match) { + const [, card, device] = match + return { device: `plughw:${card},${device}`, label: `plughw:${card},${device}` } + } + + return { device: 'default', label: 'default' } +} + +function getLinuxMixerLevel(): AudioMixerLevel | undefined { + if (!commandExists('amixer')) return + + for (const control of ['Speaker Analog', 'Speaker', 'Master', 'PCM']) { + const output = getCommandOutput(['amixer', 'get', control]) + if (!output) continue + + const percentMatch = output.match(/\[(\d+)%\]/) + if (!percentMatch) continue + + const dbMatch = output.match(/\[(-?\d+(?:\.\d+)?)dB\]/) + return { + command: `amixer -q sset '${control}' 100%`, + control, + db: dbMatch ? Number(dbMatch[1]) : undefined, + percent: Number(percentMatch[1]), + } + } +} diff --git a/src/server/audio.ts b/src/server/audio.ts index 96b4d62..437bcf7 100644 --- a/src/server/audio.ts +++ b/src/server/audio.ts @@ -1,8 +1,18 @@ import factory from 'ggwave' +import { getAudioSetup } from './audio-setup' export const SAMPLE_RATE = 48000 +export interface LoopbackTestResult { + captureBytes: number + capturePeak: number + micError?: string + ok: boolean + playbackError?: string +} + const ggwave = await factory() +ggwave.disableLog?.() const params = ggwave.getDefaultParameters() params.sampleRateInp = SAMPLE_RATE params.sampleRateOut = SAMPLE_RATE @@ -11,10 +21,138 @@ const instance = ggwave.init(params) let playing = false +function createPlaybackProcess() { + const { playbackArgs } = getAudioSetup() + + return Bun.spawn([ + 'sox', '-q', + '-t', 'raw', '-r', String(SAMPLE_RATE), '-c', '1', '-b', '32', '-e', 'floating-point', '-', + ...playbackArgs, + ], { + stdin: 'pipe', + stdout: 'ignore', + stderr: 'pipe', + }) +} + +function createRecordingProcess() { + const { captureArgs } = getAudioSetup() + + return Bun.spawn([ + 'sox', '-q', + ...captureArgs, + '-t', 'raw', '-r', String(SAMPLE_RATE), '-c', '1', '-b', '32', '-e', 'floating-point', '-', + ], { + stdout: 'pipe', + stderr: 'pipe', + }) +} + function decodeBytes(data: Int8Array): string { return Array.from(data).map(b => String.fromCharCode(b & 0xff)).join('') } +function getChunkPeak(bytes: Uint8Array) { + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength) + let peak = 0 + + for (let index = 0; index + 3 < bytes.byteLength; index += 4) { + const sample = Math.abs(view.getFloat32(index, true)) + if (sample > peak) peak = sample + } + + return peak +} + +function getText(stream: ReadableStream | null | undefined) { + if (!stream) return Promise.resolve('') + + return new Response(stream).text().catch(() => '') +} + +function logProcessError(label: string, error: string) { + const text = error.trim() + if (!text) return + console.error(`${label}: ${text}`) +} + +export async function loopbackTest(): Promise { + const testMessage = 'TEST' + const mic = createRecordingProcess() + const micErrorPromise = getText(mic.stderr) + + 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 = createPlaybackProcess() + const playbackErrorPromise = getText(play.stderr) + 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 captureBytes = 0 + let capturePeak = 0 + let ok = false + + const timeout = setTimeout(() => { mic.kill() }, 5000) + + try { + while (true) { + const { done, value } = await reader.read() + if (done) break + + captureBytes += value.length + capturePeak = Math.max(capturePeak, getChunkPeak(value)) + + const newBuffer = new Uint8Array(buffer.length + value.length) + newBuffer.set(buffer) + newBuffer.set(value, buffer.length) + buffer = newBuffer + + 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) continue + + const text = decodeBytes(result) + if (text !== testMessage) continue + + ok = true + mic.kill() + break + } + + if (ok) break + } + } catch { + // reader closed from kill + } + + clearTimeout(timeout) + + const [micError, playbackError] = await Promise.all([micErrorPromise, playbackErrorPromise]) + + return { + captureBytes, + capturePeak, + micError: micError.trim() || undefined, + ok, + playbackError: playbackError.trim() || undefined, + } +} + export async function playAudio(text: string) { playing = true @@ -26,91 +164,21 @@ export async function playAudio(text: string) { 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' } - ) + const play = createPlaybackProcess() + const errorPromise = getText(play.stderr) play.stdin.write(rawBytes) play.stdin.end() await play.exited + logProcessError('Playback error', await errorPromise) + await new Promise(r => setTimeout(r, 300)) playing = false } -export async function loopbackTest(): Promise { - 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 sox = createRecordingProcess() + const errorPromise = getText(sox.stderr) const reader = sox.stdout.getReader() const bytesPerFrame = params.samplesPerFrame * 4 let buffer = new Uint8Array(0) @@ -121,23 +189,28 @@ export function startMicListener(onMessage: (text: string) => void) { if (done) break if (playing) continue - const newBuf = new Uint8Array(buffer.length + value.length) - newBuf.set(buffer) - newBuf.set(value, buffer.length) - buffer = newBuf + const newBuffer = new Uint8Array(buffer.length + value.length) + newBuffer.set(buffer) + newBuffer.set(value, buffer.length) + buffer = newBuffer 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) - } + if (!decoded || decoded.length === 0) continue + + const text = decodeBytes(decoded) + onMessage(text) } } + + logProcessError('Microphone error', await errorPromise) } - processAudio().catch(err => console.error('Mic error:', err)) + processAudio().catch(async err => { + console.error('Mic error:', err) + logProcessError('Microphone error', await errorPromise) + }) } diff --git a/src/server/index.tsx b/src/server/index.tsx index 6d14166..353a44b 100644 --- a/src/server/index.tsx +++ b/src/server/index.tsx @@ -1,10 +1,12 @@ /** @jsxImportSource hono/jsx */ import { Hype } from '@because/hype' import { PhonePage, stylesToCSS } from '../pages/phone' +import { getLocalTlsOptions } from './local-tls' import { startup } from './terminal' const PORT = Number(process.env.PORT) || 3000 const app = new Hype({ layout: false, logging: false }) +const tls = await getLocalTlsOptions(PORT) app.get('/ok', c => c.text('ok')) @@ -20,28 +22,15 @@ app.get('/ggwave.js', () => }) ) -startup(PORT) - -async function ensureCerts() { - const certPath = './certs/cert.pem' - const keyPath = './certs/key.pem' - if (await Bun.file(certPath).exists()) return - - const { mkdirSync } = await import('fs') - mkdirSync('./certs', { recursive: true }) - Bun.spawnSync(['openssl', 'req', '-x509', '-newkey', 'rsa:2048', - '-keyout', keyPath, '-out', certPath, - '-days', '365', '-nodes', '-subj', '/CN=localhost']) +function logStartupError(error: unknown) { + console.error('Startup failed:', error) } -await ensureCerts() +void startup(PORT).catch(logStartupError) export default { ...app.defaults, port: PORT, idleTimeout: 255, - tls: { - key: Bun.file('./certs/key.pem'), - cert: Bun.file('./certs/cert.pem'), - }, + ...(tls ? { tls } : {}), } diff --git a/src/server/local-tls.ts b/src/server/local-tls.ts new file mode 100644 index 0000000..1f47a7a --- /dev/null +++ b/src/server/local-tls.ts @@ -0,0 +1,67 @@ +import { mkdirSync } from 'node:fs' +import { getNetworkTargets } from './network' + +const CERT_PATH = './certs/cert.pem' +const KEY_PATH = './certs/key.pem' + +export interface LocalServerUrls { + primaryUrl: string + urls: string[] +} + +export function getLocalServerUrls(port: number): LocalServerUrls { + const scheme = isLocalTlsEnabled() ? 'https' : 'http' + const { primaryHost, urls } = getNetworkTargets(port, scheme) + + return { + primaryUrl: `${scheme}://${primaryHost}:${port}`, + urls, + } +} + +export async function getLocalTlsOptions(_port: number) { + if (!isLocalTlsEnabled()) return + + await ensureLocalTlsFiles() + + return { + cert: Bun.file(CERT_PATH), + key: Bun.file(KEY_PATH), + } +} + +export function isLocalTlsEnabled() { + return process.platform === 'darwin' +} + +async function ensureLocalTlsFiles() { + const certExists = await Bun.file(CERT_PATH).exists() + const keyExists = await Bun.file(KEY_PATH).exists() + if (certExists && keyExists) return + + const opensslCheck = Bun.spawnSync(['which', 'openssl'], { + stdout: 'ignore', + stderr: 'ignore', + }) + if (opensslCheck.exitCode !== 0) { + throw new Error('openssl is required for local macOS HTTPS mode') + } + + mkdirSync('./certs', { recursive: true }) + + const proc = Bun.spawnSync([ + 'openssl', 'req', '-x509', '-newkey', 'rsa:2048', + '-keyout', KEY_PATH, + '-out', CERT_PATH, + '-days', '365', + '-nodes', + '-subj', '/CN=localhost', + ], { + stdout: 'ignore', + stderr: 'pipe', + }) + + if (proc.exitCode !== 0) { + throw new Error(`failed to generate local TLS certificate: ${proc.stderr.toString().trim()}`) + } +} diff --git a/src/server/network.ts b/src/server/network.ts new file mode 100644 index 0000000..82aa94c --- /dev/null +++ b/src/server/network.ts @@ -0,0 +1,110 @@ +import { isIP } from 'node:net' +import { hostname as osHostname, networkInterfaces } from 'node:os' + +const LOCALHOST = 'localhost' +const LOOPBACK_IP = '127.0.0.1' + +export interface NetworkTargets { + hosts: string[] + ips: string[] + primaryHost: string + urls: string[] +} + +export function getNetworkTargets(port: number, scheme = 'http'): NetworkTargets { + const envHost = getEnvHost() + const hosts = getHosts(envHost) + const ips = getIps(envHost) + const primaryHost = getPrimaryHost(envHost, hosts, ips) + const urls = getUrls(port, primaryHost, hosts, ips, scheme) + + return { hosts, ips, primaryHost, urls } +} + +function addUnique(values: string[], value: string | undefined) { + if (!value || values.includes(value)) return + values.push(value) +} + +function getEnvHost() { + const value = process.env.BAUDY_HOST?.trim() + if (!value) return + + try { + const url = new URL(value.includes('://') ? value : `https://${value}`) + return url.hostname + } catch { + return value + } +} + +function getHosts(envHost: string | undefined) { + const hosts: string[] = [] + const hostname = osHostname().trim() + + addUnique(hosts, LOCALHOST) + + if (envHost && isIP(envHost) === 0) addUnique(hosts, envHost) + + if (hostname && hostname !== LOCALHOST && isIP(hostname) === 0) { + if (!hostname.includes('.') && (process.platform === 'darwin' || process.platform === 'linux')) { + addUnique(hosts, `${hostname}.local`) + } + addUnique(hosts, hostname) + } + + return hosts +} + +function getIpPriority(ip: string) { + if (ip.startsWith('192.168.')) return 0 + if (ip.startsWith('10.')) return 1 + if (/^172\.(1[6-9]|2\d|3[0-1])\./.test(ip)) return 2 + if (/^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./.test(ip)) return 4 + return 3 +} + +function getIps(envHost: string | undefined) { + const externalIps: string[] = [] + const ips = networkInterfaces() + + if (envHost && isIP(envHost) === 4) addUnique(externalIps, envHost) + + for (const addresses of Object.values(ips)) { + for (const address of addresses ?? []) { + if (address.family !== 'IPv4' || address.internal) continue + if (address.address.startsWith('169.254.')) continue + addUnique(externalIps, address.address) + } + } + + externalIps.sort((a, b) => getIpPriority(a) - getIpPriority(b) || a.localeCompare(b)) + + return [LOOPBACK_IP, ...externalIps] +} + +function getPrimaryHost(envHost: string | undefined, hosts: string[], ips: string[]) { + if (envHost) return envHost + + if (process.platform === 'darwin') { + return hosts.find(host => host.endsWith('.local')) + ?? ips.find(ip => ip !== LOOPBACK_IP) + ?? LOCALHOST + } + + return ips.find(ip => ip !== LOOPBACK_IP) + ?? hosts.find(host => host.endsWith('.local')) + ?? hosts.find(host => host !== LOCALHOST) + ?? LOCALHOST +} + +function getUrls(port: number, primaryHost: string, hosts: string[], ips: string[], scheme: string) { + const values: string[] = [] + + addUnique(values, primaryHost) + + for (const ip of ips) addUnique(values, ip) + for (const host of hosts) addUnique(values, host) + + return values.map(host => `${scheme}://${host}:${port}`) +} diff --git a/src/server/terminal.ts b/src/server/terminal.ts index ad57b71..08e46b5 100644 --- a/src/server/terminal.ts +++ b/src/server/terminal.ts @@ -1,6 +1,8 @@ +import { getAudioSetup, isLowMixerLevel, refreshAudioSetup, setMixerLevel } from './audio-setup' import { loopbackTest, playAudio, startMicListener } from './audio' -import { handleGuess, getSecret } from './game' +import { getSecret, handleGuess } from './game' import type { GuessResult } from './game' +import { getLocalServerUrls, isLocalTlsEnabled } from './local-tls' import pkg from '../../package.json' const RESET = '\x1b[0m' @@ -12,6 +14,11 @@ const BLUE = '\x1b[34m' const CYAN = '\x1b[36m' const RED = '\x1b[31m' +interface TailscaleInfo { + dnsName: string + url: string +} + async function prompt(question: string): Promise { process.stdout.write(question) for await (const line of console) { @@ -20,29 +27,90 @@ async function prompt(question: string): Promise { return '' } -function getLocalHostname() { - const proc = Bun.spawnSync(['scutil', '--get', 'LocalHostName']) - if (proc.exitCode === 0) return proc.stdout.toString().trim() + '.local' - return 'localhost' +function clearScreen() { + if (process.stdout.isTTY) console.clear() } -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 commandExists(command: string) { + const proc = Bun.spawnSync(['which', command], { stdout: 'ignore', stderr: 'ignore' }) + return proc.exitCode === 0 +} + +function formatPeak(value: number) { + return value.toFixed(4) +} + +function getAlternativeUrls(urls: string[], primaryUrl: string) { + const externalUrls = urls.filter(url => !url.includes('localhost')) + const visibleUrls = externalUrls.length > 0 ? externalUrls : urls + + return visibleUrls + .filter(url => url !== primaryUrl) + .slice(0, 3) +} + +function getCommandOutput(args: string[]) { + try { + const proc = Bun.spawnSync(args, { stdout: 'pipe', stderr: 'ignore' }) + if (proc.exitCode === 0) return proc.stdout.toString().trim() + } catch { + // ignored + } +} + +function getLocalUrls(port: number) { + const { primaryUrl, urls } = getLocalServerUrls(port) + const alternativeUrls = getAlternativeUrls(urls, primaryUrl) + + return { alternativeUrls, primaryUrl } +} + +function getSoxInstallCommand() { + if (process.platform === 'darwin') return 'brew install sox' + if (process.platform === 'linux') return 'sudo apt install sox' + return 'install sox and make sure it is in your PATH' +} + +function getTailscaleInfo(): TailscaleInfo | undefined { + if (!commandExists('tailscale')) return + + const output = getCommandOutput(['tailscale', 'status', '--json']) + if (!output) return + + try { + const status = JSON.parse(output) as { Self?: { DNSName?: string } } + const dnsName = status.Self?.DNSName?.replace(/\.$/, '') + if (!dnsName) return + return { dnsName, url: `https://${dnsName}` } + } catch { + return + } +} + +async function logQrCode(url: string) { + 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() + } } 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}`) @@ -50,6 +118,62 @@ function logGuess(result: GuessResult) { console.log() } +function logLoopbackFailureDetails(result: Awaited>) { + const audioSetup = getAudioSetup() + + 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 your system sound settings (output: "${audioSetup.playbackLabel}", input: "${audioSetup.captureLabel}")`) + + if (result.captureBytes === 0) { + console.log(' โ€ข The microphone device did not produce any audio data') + } else if (result.capturePeak < 0.02) { + console.log(` โ€ข The microphone only heard a very quiet signal (peak ${formatPeak(result.capturePeak)})`) + } + + if (result.micError) { + console.log(` โ€ข Mic error: ${result.micError.split('\n')[0]}`) + } + + if (result.playbackError) { + console.log(` โ€ข Speaker error: ${result.playbackError.split('\n')[0]}`) + } + + if (process.platform === 'linux' && isLowMixerLevel(audioSetup.mixerLevel)) { + const level = audioSetup.mixerLevel + const db = level.db === undefined ? '' : ` (${level.db.toFixed(1)} dB)` + console.log(` โ€ข ${level.control} is only at ${level.percent}%${db}; try: ${level.command}`) + } + + if (process.platform === 'linux') { + console.log(' โ€ข Override SoX devices if auto-detection is wrong:') + console.log(' BAUDY_CAPTURE_DEVICE=plughw:0,0 BAUDY_PLAYBACK_DEVICE=plughw:0,1 bun run index.tsx') + } + + console.log() +} + +async function maybePause(question: string) { + if (!process.stdin.isTTY) return + await prompt(question) +} + +async function maybeRaiseSpeakerGain() { + const audioSetup = getAudioSetup() + if (process.platform !== 'linux' || !process.stdin.isTTY || !isLowMixerLevel(audioSetup.mixerLevel)) return audioSetup + + const answer = await prompt(` Raise ${audioSetup.mixerLevel.control} to 100% now? [Y/n] `) + if (answer && !/^y(es)?$/i.test(answer)) return audioSetup + + if (!setMixerLevel(audioSetup.mixerLevel.control, 100)) return audioSetup + + console.log(`${GREEN} Raised ${audioSetup.mixerLevel.control} to 100%.${RESET}`) + console.log() + return refreshAudioSetup() +} + async function onMessage(text: string) { await Bun.sleep(500) @@ -64,63 +188,90 @@ async function onMessage(text: string) { } 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`) + if (!commandExists('sox')) { + console.error(`\n ${BOLD}sox is not installed.${RESET} Run: ${getSoxInstallCommand()}\n`) process.exit(1) } - console.clear() + let audioSetup = getAudioSetup() + + clearScreen() console.log() console.log(`${BOLD}Corey's Screechy Audio Demo${RESET} ${DIM}v${pkg.version}${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(`${BOLD}Speaker:${RESET} ${GREEN}${audioSetup.playbackLabel}${RESET}`) + console.log(`${BOLD}Microphone:${RESET} ${GREEN}${audioSetup.captureLabel}${RESET}`) console.log() + if (process.platform === 'linux' && isLowMixerLevel(audioSetup.mixerLevel)) { + const level = audioSetup.mixerLevel + const db = level.db === undefined ? '' : ` (${level.db.toFixed(1)} dB)` + console.log(`${YELLOW}${BOLD}โš  Speaker gain looks low:${RESET} ${level.control} is ${level.percent}%${db}`) + console.log(`${DIM}Try:${RESET} ${BOLD}${level.command}${RESET}`) + console.log() + audioSetup = await maybeRaiseSpeakerGain() + } + 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() + const loopback = await loopbackTest() - if (loopbackOk) { + if (loopback.ok) { 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... `) + logLoopbackFailureDetails(loopback) + await maybePause(' Press Enter to continue anyway... ') } - console.clear() + clearScreen() console.log() console.log(`${BOLD}Corey's Screechy Audio Demo${RESET} ${DIM}v${pkg.version}${RESET}`) - console.log(`${DIM}Speaker: ${speaker} ยท Mic: ${mic}${RESET}`) + console.log(`${DIM}Speaker: ${audioSetup.playbackLabel} ยท Mic: ${audioSetup.captureLabel}${RESET}`) console.log() - const hostname = getLocalHostname() - const url = `https://${hostname}:${port}` + const { alternativeUrls, primaryUrl } = getLocalUrls(port) + const tailscale = getTailscaleInfo() - console.log(`${GREEN}${BOLD}Scan QR code on your phone to play!${RESET}`) - console.log() + if (isLocalTlsEnabled()) { + console.log(`${GREEN}${BOLD}macOS local HTTPS mode${RESET}`) + console.log() + console.log(`${DIM}Open on your phone:${RESET} ${CYAN}${primaryUrl}${RESET}`) + console.log(`${DIM}This uses the original self-signed local HTTPS flow.${RESET}`) + console.log(`${DIM}First load may show a certificate warning.${RESET}`) + console.log() + await logQrCode(primaryUrl) - 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}`) + if (tailscale) { + console.log(`${DIM}Alternative:${RESET} ${BOLD}tailscale serve ${port}${RESET} โ†’ ${CYAN}${tailscale.url}${RESET}`) + console.log() + } + } else if (tailscale) { + console.log(`${GREEN}${BOLD}Phone access via Tailscale Serve${RESET}`) + console.log() + console.log(`${DIM}Run this once in another terminal:${RESET}`) + console.log(`${BOLD} tailscale serve ${port}${RESET}`) + console.log() + console.log(`${DIM}Then open on your phone:${RESET} ${CYAN}${tailscale.url}${RESET}`) + console.log() + await logQrCode(tailscale.url) + } else { + console.log(`${YELLOW}Phone microphone access needs HTTPS, and this local server is HTTP-only.${RESET}`) + console.log(`${DIM}Recommended:${RESET} run ${BOLD}tailscale serve ${port}${RESET} and open the resulting ${CYAN}https://..ts.net${RESET} URL.`) console.log() } + console.log(`${DIM}${isLocalTlsEnabled() ? 'Local HTTPS' : 'Local HTTP'}:${RESET} ${CYAN}${primaryUrl}${RESET}`) + if (alternativeUrls.length > 0) { + console.log(`${DIM}Other local ${isLocalTlsEnabled() ? 'HTTPS' : 'HTTP'} URLs:${RESET}`) + for (const alternativeUrl of alternativeUrls) { + console.log(`${DIM} ${alternativeUrl}${RESET}`) + } + } + console.log(`${DIM}Set BAUDY_HOST= to override local URL detection if needed.${RESET}`) + console.log() + console.log(`${BOLD}Secret number: ${GREEN}${getSecret()}${RESET}`) console.log(`${'โ”€'.repeat(40)}`) console.log(`${DIM}๐ŸŽค Listening for guesses...${RESET}`) diff --git a/types.d.ts b/types.d.ts new file mode 100644 index 0000000..2ce9fdc --- /dev/null +++ b/types.d.ts @@ -0,0 +1,2 @@ +declare module 'ggwave' +declare module 'qrcode'