Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8585d7e87f | |||
| f434137f11 |
|
|
@ -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.
|
- **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.
|
- **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.
|
- **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 <port>` so the phone gets a real HTTPS `*.ts.net` origin for `getUserMedia()`.
|
||||||
|
|
||||||
## Half-Duplex Audio
|
## Half-Duplex Audio
|
||||||
|
|
||||||
|
|
@ -41,4 +50,5 @@
|
||||||
## getUserMedia Secure Context
|
## 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.
|
- `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 <port>` 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.
|
||||||
|
|
|
||||||
139
src/server/audio-setup.ts
Normal file
139
src/server/audio-setup.ts
Normal file
|
|
@ -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]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,8 +1,19 @@
|
||||||
import factory from 'ggwave'
|
import factory from 'ggwave'
|
||||||
|
import { getAudioSetup } from './audio-setup'
|
||||||
|
import { setRgbLedState } from './rgbled'
|
||||||
|
|
||||||
export const SAMPLE_RATE = 48000
|
export const SAMPLE_RATE = 48000
|
||||||
|
|
||||||
|
export interface LoopbackTestResult {
|
||||||
|
captureBytes: number
|
||||||
|
capturePeak: number
|
||||||
|
micError?: string
|
||||||
|
ok: boolean
|
||||||
|
playbackError?: string
|
||||||
|
}
|
||||||
|
|
||||||
const ggwave = await factory()
|
const ggwave = await factory()
|
||||||
|
ggwave.disableLog?.()
|
||||||
const params = ggwave.getDefaultParameters()
|
const params = ggwave.getDefaultParameters()
|
||||||
params.sampleRateInp = SAMPLE_RATE
|
params.sampleRateInp = SAMPLE_RATE
|
||||||
params.sampleRateOut = SAMPLE_RATE
|
params.sampleRateOut = SAMPLE_RATE
|
||||||
|
|
@ -11,40 +22,65 @@ const instance = ggwave.init(params)
|
||||||
|
|
||||||
let playing = false
|
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 {
|
function decodeBytes(data: Int8Array): string {
|
||||||
return Array.from(data).map(b => String.fromCharCode(b & 0xff)).join('')
|
return Array.from(data).map(b => String.fromCharCode(b & 0xff)).join('')
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function playAudio(text: string) {
|
function getChunkPeak(bytes: Uint8Array) {
|
||||||
playing = true
|
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
|
||||||
|
let peak = 0
|
||||||
|
|
||||||
const waveform = ggwave.encode(
|
for (let index = 0; index + 3 < bytes.byteLength; index += 4) {
|
||||||
instance, text,
|
const sample = Math.abs(view.getFloat32(index, true))
|
||||||
ggwave.ProtocolId.GGWAVE_PROTOCOL_AUDIBLE_FAST,
|
if (sample > peak) peak = sample
|
||||||
50
|
}
|
||||||
)
|
|
||||||
const rawBytes = new Uint8Array(waveform.length)
|
|
||||||
rawBytes.set(new Uint8Array(waveform.buffer, waveform.byteOffset, waveform.length))
|
|
||||||
|
|
||||||
const play = Bun.spawn(
|
return peak
|
||||||
['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> {
|
function getText(stream: ReadableStream<Uint8Array> | null | undefined) {
|
||||||
const testMessage = 'TEST'
|
if (!stream) return Promise.resolve('')
|
||||||
|
|
||||||
const mic = Bun.spawn(
|
return new Response(stream).text().catch(() => '')
|
||||||
['sox', '-d', '-t', 'raw', '-r', String(SAMPLE_RATE), '-c', '1', '-b', '32', '-e', 'floating-point', '-'],
|
}
|
||||||
{ stdout: 'pipe', stderr: 'ignore' }
|
|
||||||
)
|
function logProcessError(label: string, error: string) {
|
||||||
|
const text = error.trim()
|
||||||
|
if (!text) return
|
||||||
|
console.error(`${label}: ${text}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loopbackTest(): Promise<LoopbackTestResult> {
|
||||||
|
const testMessage = 'TEST'
|
||||||
|
const mic = createRecordingProcess()
|
||||||
|
const micErrorPromise = getText(mic.stderr)
|
||||||
|
|
||||||
await new Promise(r => setTimeout(r, 200))
|
await new Promise(r => setTimeout(r, 200))
|
||||||
|
|
||||||
|
|
@ -56,10 +92,8 @@ export async function loopbackTest(): Promise<boolean> {
|
||||||
const rawBytes = new Uint8Array(waveform.length)
|
const rawBytes = new Uint8Array(waveform.length)
|
||||||
rawBytes.set(new Uint8Array(waveform.buffer, waveform.byteOffset, waveform.length))
|
rawBytes.set(new Uint8Array(waveform.buffer, waveform.byteOffset, waveform.length))
|
||||||
|
|
||||||
const play = Bun.spawn(
|
const play = createPlaybackProcess()
|
||||||
['sox', '-t', 'raw', '-r', String(SAMPLE_RATE), '-c', '1', '-b', '32', '-e', 'floating-point', '-', '-d'],
|
const playbackErrorPromise = getText(play.stderr)
|
||||||
{ stdin: 'pipe', stdout: 'ignore', stderr: 'ignore' }
|
|
||||||
)
|
|
||||||
play.stdin.write(rawBytes)
|
play.stdin.write(rawBytes)
|
||||||
play.stdin.end()
|
play.stdin.end()
|
||||||
await play.exited
|
await play.exited
|
||||||
|
|
@ -67,7 +101,9 @@ export async function loopbackTest(): Promise<boolean> {
|
||||||
const reader = mic.stdout.getReader()
|
const reader = mic.stdout.getReader()
|
||||||
const bytesPerFrame = params.samplesPerFrame * 4
|
const bytesPerFrame = params.samplesPerFrame * 4
|
||||||
let buffer = new Uint8Array(0)
|
let buffer = new Uint8Array(0)
|
||||||
let decoded = false
|
let captureBytes = 0
|
||||||
|
let capturePeak = 0
|
||||||
|
let ok = false
|
||||||
|
|
||||||
const timeout = setTimeout(() => { mic.kill() }, 5000)
|
const timeout = setTimeout(() => { mic.kill() }, 5000)
|
||||||
|
|
||||||
|
|
@ -76,41 +112,81 @@ export async function loopbackTest(): Promise<boolean> {
|
||||||
const { done, value } = await reader.read()
|
const { done, value } = await reader.read()
|
||||||
if (done) break
|
if (done) break
|
||||||
|
|
||||||
const newBuf = new Uint8Array(buffer.length + value.length)
|
captureBytes += value.length
|
||||||
newBuf.set(buffer)
|
capturePeak = Math.max(capturePeak, getChunkPeak(value))
|
||||||
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) {
|
while (buffer.length >= bytesPerFrame) {
|
||||||
const frame = buffer.slice(0, bytesPerFrame)
|
const frame = buffer.slice(0, bytesPerFrame)
|
||||||
buffer = buffer.slice(bytesPerFrame)
|
buffer = buffer.slice(bytesPerFrame)
|
||||||
|
|
||||||
const result = ggwave.decode(instance, frame)
|
const result = ggwave.decode(instance, frame)
|
||||||
if (result && result.length > 0) {
|
if (!result || result.length === 0) continue
|
||||||
const text = decodeBytes(result)
|
|
||||||
if (text === testMessage) {
|
const text = decodeBytes(result)
|
||||||
decoded = true
|
if (text !== testMessage) continue
|
||||||
mic.kill()
|
|
||||||
break
|
ok = true
|
||||||
}
|
mic.kill()
|
||||||
}
|
break
|
||||||
}
|
}
|
||||||
if (decoded) break
|
|
||||||
|
if (ok) break
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// reader closed from kill
|
// reader closed from kill
|
||||||
}
|
}
|
||||||
|
|
||||||
clearTimeout(timeout)
|
clearTimeout(timeout)
|
||||||
return decoded
|
|
||||||
|
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
|
||||||
|
setRgbLedState('chirping')
|
||||||
|
|
||||||
|
try {
|
||||||
|
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 = 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))
|
||||||
|
} finally {
|
||||||
|
playing = false
|
||||||
|
setRgbLedState('listening')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function startMicListener(onMessage: (text: string) => void) {
|
export function startMicListener(onMessage: (text: string) => void) {
|
||||||
const sox = Bun.spawn(
|
setRgbLedState('listening')
|
||||||
['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 reader = sox.stdout.getReader()
|
||||||
const bytesPerFrame = params.samplesPerFrame * 4
|
const bytesPerFrame = params.samplesPerFrame * 4
|
||||||
let buffer = new Uint8Array(0)
|
let buffer = new Uint8Array(0)
|
||||||
|
|
@ -121,23 +197,28 @@ export function startMicListener(onMessage: (text: string) => void) {
|
||||||
if (done) break
|
if (done) break
|
||||||
if (playing) continue
|
if (playing) continue
|
||||||
|
|
||||||
const newBuf = new Uint8Array(buffer.length + value.length)
|
const newBuffer = new Uint8Array(buffer.length + value.length)
|
||||||
newBuf.set(buffer)
|
newBuffer.set(buffer)
|
||||||
newBuf.set(value, buffer.length)
|
newBuffer.set(value, buffer.length)
|
||||||
buffer = newBuf
|
buffer = newBuffer
|
||||||
|
|
||||||
while (buffer.length >= bytesPerFrame) {
|
while (buffer.length >= bytesPerFrame) {
|
||||||
const frame = buffer.slice(0, bytesPerFrame)
|
const frame = buffer.slice(0, bytesPerFrame)
|
||||||
buffer = buffer.slice(bytesPerFrame)
|
buffer = buffer.slice(bytesPerFrame)
|
||||||
|
|
||||||
const decoded = ggwave.decode(instance, frame)
|
const decoded = ggwave.decode(instance, frame)
|
||||||
if (decoded && decoded.length > 0) {
|
if (!decoded || decoded.length === 0) continue
|
||||||
const text = decodeBytes(decoded)
|
|
||||||
onMessage(text)
|
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)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
/** @jsxImportSource hono/jsx */
|
/** @jsxImportSource hono/jsx */
|
||||||
import { Hype } from '@because/hype'
|
import { Hype } from '@because/hype'
|
||||||
import { PhonePage, stylesToCSS } from '../pages/phone'
|
import { PhonePage, stylesToCSS } from '../pages/phone'
|
||||||
|
import { getLocalTlsOptions } from './local-tls'
|
||||||
import { startup } from './terminal'
|
import { startup } from './terminal'
|
||||||
|
|
||||||
const PORT = Number(process.env.PORT) || 3000
|
const PORT = Number(process.env.PORT) || 3000
|
||||||
const app = new Hype({ layout: false, logging: false })
|
const app = new Hype({ layout: false, logging: false })
|
||||||
|
const tls = await getLocalTlsOptions(PORT)
|
||||||
|
|
||||||
app.get('/ok', c => c.text('ok'))
|
app.get('/ok', c => c.text('ok'))
|
||||||
|
|
||||||
|
|
@ -20,28 +22,15 @@ app.get('/ggwave.js', () =>
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
startup(PORT)
|
function logStartupError(error: unknown) {
|
||||||
|
console.error('Startup failed:', error)
|
||||||
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'])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await ensureCerts()
|
void startup(PORT).catch(logStartupError)
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
...app.defaults,
|
...app.defaults,
|
||||||
port: PORT,
|
port: PORT,
|
||||||
idleTimeout: 255,
|
idleTimeout: 255,
|
||||||
tls: {
|
...(tls ? { tls } : {}),
|
||||||
key: Bun.file('./certs/key.pem'),
|
|
||||||
cert: Bun.file('./certs/cert.pem'),
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
67
src/server/local-tls.ts
Normal file
67
src/server/local-tls.ts
Normal file
|
|
@ -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()}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
110
src/server/network.ts
Normal file
110
src/server/network.ts
Normal file
|
|
@ -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}`)
|
||||||
|
}
|
||||||
165
src/server/rgbled.ts
Normal file
165
src/server/rgbled.ts
Normal file
|
|
@ -0,0 +1,165 @@
|
||||||
|
import { existsSync } from 'node:fs'
|
||||||
|
|
||||||
|
const LED_PATTERNS: Record<Exclude<RgbLedState, 'off'>, RgbLedPattern> = {
|
||||||
|
chirping: {
|
||||||
|
colors: ['331400', 'ff5500', 'ffcc00', 'ff5500', '331400'],
|
||||||
|
duration: '90ms',
|
||||||
|
repeat: true,
|
||||||
|
},
|
||||||
|
correct: {
|
||||||
|
colors: ['002200', '22cc55', 'ffdd55', '22cc55', '002200'],
|
||||||
|
duration: '140ms',
|
||||||
|
restoreState: 'listening',
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
colors: ['220000', 'ff0033', '220000', '220000', 'ff0033', '220000'],
|
||||||
|
duration: '120ms',
|
||||||
|
repeat: true,
|
||||||
|
},
|
||||||
|
listening: {
|
||||||
|
colors: ['001833', '0066ff', '33ccff', '0066ff', '001833'],
|
||||||
|
duration: '500ms',
|
||||||
|
repeat: true,
|
||||||
|
},
|
||||||
|
startup: {
|
||||||
|
colors: ['220022', 'ff00aa', 'ff8800', 'ffcc00', '00cc88', '3399ff'],
|
||||||
|
duration: '140ms',
|
||||||
|
repeat: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const RGBLED_BIN = process.env.BAUDY_RGBLED_BIN?.trim() || 'rgbled'
|
||||||
|
const RGBLED_ORDER = process.env.BAUDY_RGBLED_ORDER?.trim() || 'brg'
|
||||||
|
const SPI_DEVICE = '/dev/spidev0.0'
|
||||||
|
|
||||||
|
let activeProcess: SpawnedProcess | undefined
|
||||||
|
let available: boolean | undefined
|
||||||
|
let currentState: RgbLedState | undefined
|
||||||
|
let stateVersion = 0
|
||||||
|
|
||||||
|
interface RgbLedPattern {
|
||||||
|
colors: string[]
|
||||||
|
duration: string
|
||||||
|
repeat?: boolean
|
||||||
|
restoreState?: RgbLedState
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RgbLedState = 'chirping' | 'correct' | 'error' | 'listening' | 'off' | 'startup'
|
||||||
|
|
||||||
|
type SpawnedProcess = ReturnType<typeof Bun.spawn>
|
||||||
|
|
||||||
|
export function getRgbLedStatus() {
|
||||||
|
if (!isRgbLedAvailable()) return
|
||||||
|
return `${RGBLED_BIN} (order ${RGBLED_ORDER}, animated)`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isRgbLedAvailable() {
|
||||||
|
if (available !== undefined) return available
|
||||||
|
if (process.platform !== 'linux') return available = false
|
||||||
|
if (process.env.BAUDY_RGBLED?.trim() === '0') return available = false
|
||||||
|
if (!existsSync(SPI_DEVICE)) return available = false
|
||||||
|
if (!commandExists(RGBLED_BIN)) return available = false
|
||||||
|
|
||||||
|
const probe = Bun.spawnSync([RGBLED_BIN, '--order', RGBLED_ORDER, 'off'], {
|
||||||
|
stdout: 'ignore',
|
||||||
|
stderr: 'ignore',
|
||||||
|
})
|
||||||
|
|
||||||
|
available = probe.exitCode === 0
|
||||||
|
return available
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setRgbLedState(state: RgbLedState) {
|
||||||
|
if (!isRgbLedAvailable()) return false
|
||||||
|
|
||||||
|
currentState = state
|
||||||
|
stateVersion++
|
||||||
|
stopActiveProcess()
|
||||||
|
|
||||||
|
if (state === 'off') return runRgbLedSync(['off'])
|
||||||
|
|
||||||
|
const pattern = LED_PATTERNS[state]
|
||||||
|
const process = spawnPattern(pattern)
|
||||||
|
if (!process) return false
|
||||||
|
|
||||||
|
activeProcess = process
|
||||||
|
void trackPatternExit(process, pattern, state, stateVersion)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPatternArgs(pattern: RgbLedPattern) {
|
||||||
|
const args = [RGBLED_BIN, '--order', RGBLED_ORDER, 'lerp', ...pattern.colors, '--duration', pattern.duration]
|
||||||
|
|
||||||
|
if (pattern.repeat) args.push('--loop', 'true')
|
||||||
|
|
||||||
|
return args
|
||||||
|
}
|
||||||
|
|
||||||
|
function commandExists(command: string) {
|
||||||
|
if (command.includes('/')) return existsSync(command)
|
||||||
|
|
||||||
|
const proc = Bun.spawnSync(['which', command], {
|
||||||
|
stdout: 'ignore',
|
||||||
|
stderr: 'ignore',
|
||||||
|
})
|
||||||
|
|
||||||
|
return proc.exitCode === 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function getText(stream: ReadableStream<Uint8Array> | null | undefined) {
|
||||||
|
if (!stream) return Promise.resolve('')
|
||||||
|
return new Response(stream).text().catch(() => '')
|
||||||
|
}
|
||||||
|
|
||||||
|
function runRgbLedSync(args: string[]) {
|
||||||
|
const proc = Bun.spawnSync([RGBLED_BIN, '--order', RGBLED_ORDER, ...args], {
|
||||||
|
stdout: 'ignore',
|
||||||
|
stderr: 'ignore',
|
||||||
|
})
|
||||||
|
|
||||||
|
if (proc.exitCode === 0) return true
|
||||||
|
|
||||||
|
available = false
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
function spawnPattern(pattern: RgbLedPattern) {
|
||||||
|
try {
|
||||||
|
return Bun.spawn(buildPatternArgs(pattern), {
|
||||||
|
stdin: 'ignore',
|
||||||
|
stdout: 'ignore',
|
||||||
|
stderr: 'pipe',
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
available = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopActiveProcess() {
|
||||||
|
if (!activeProcess) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
activeProcess.kill()
|
||||||
|
} catch {
|
||||||
|
// ignored
|
||||||
|
}
|
||||||
|
|
||||||
|
activeProcess = undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
async function trackPatternExit(process: SpawnedProcess, pattern: RgbLedPattern, state: RgbLedState, version: number) {
|
||||||
|
const errorPromise = getText(process.stderr)
|
||||||
|
const exitCode = await process.exited
|
||||||
|
const error = (await errorPromise).trim()
|
||||||
|
|
||||||
|
if (activeProcess === process) activeProcess = undefined
|
||||||
|
if (version !== stateVersion || currentState !== state) return
|
||||||
|
|
||||||
|
if (exitCode !== 0) {
|
||||||
|
available = false
|
||||||
|
if (error) console.error(`RGB LED error: ${error.split('\n')[0]}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pattern.restoreState) setRgbLedState(pattern.restoreState)
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
|
import { getAudioSetup, isLowMixerLevel, refreshAudioSetup, setMixerLevel } from './audio-setup'
|
||||||
import { loopbackTest, playAudio, startMicListener } from './audio'
|
import { loopbackTest, playAudio, startMicListener } from './audio'
|
||||||
import { handleGuess, getSecret } from './game'
|
import { getSecret, handleGuess } from './game'
|
||||||
import type { GuessResult } from './game'
|
import type { GuessResult } from './game'
|
||||||
|
import { getLocalServerUrls, isLocalTlsEnabled } from './local-tls'
|
||||||
|
import { getRgbLedStatus, setRgbLedState } from './rgbled'
|
||||||
import pkg from '../../package.json'
|
import pkg from '../../package.json'
|
||||||
|
|
||||||
const RESET = '\x1b[0m'
|
const RESET = '\x1b[0m'
|
||||||
|
|
@ -12,6 +15,11 @@ const BLUE = '\x1b[34m'
|
||||||
const CYAN = '\x1b[36m'
|
const CYAN = '\x1b[36m'
|
||||||
const RED = '\x1b[31m'
|
const RED = '\x1b[31m'
|
||||||
|
|
||||||
|
interface TailscaleInfo {
|
||||||
|
dnsName: string
|
||||||
|
url: string
|
||||||
|
}
|
||||||
|
|
||||||
async function prompt(question: string): Promise<string> {
|
async function prompt(question: string): Promise<string> {
|
||||||
process.stdout.write(question)
|
process.stdout.write(question)
|
||||||
for await (const line of console) {
|
for await (const line of console) {
|
||||||
|
|
@ -20,29 +28,90 @@ async function prompt(question: string): Promise<string> {
|
||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
function getLocalHostname() {
|
function clearScreen() {
|
||||||
const proc = Bun.spawnSync(['scutil', '--get', 'LocalHostName'])
|
if (process.stdout.isTTY) console.clear()
|
||||||
if (proc.exitCode === 0) return proc.stdout.toString().trim() + '.local'
|
|
||||||
return 'localhost'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getDeviceName(type: 'input' | 'output'): string {
|
function commandExists(command: string) {
|
||||||
const args = type === 'input' ? ['-c', '-t', 'input'] : ['-c']
|
const proc = Bun.spawnSync(['which', command], { stdout: 'ignore', stderr: 'ignore' })
|
||||||
const proc = Bun.spawnSync(['SwitchAudioSource', ...args])
|
return proc.exitCode === 0
|
||||||
if (proc.exitCode === 0) return proc.stdout.toString().trim()
|
}
|
||||||
return 'system default'
|
|
||||||
|
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) {
|
function logGuess(result: GuessResult) {
|
||||||
const { type, guess, guessCount } = result
|
const { type, guess, guessCount } = result
|
||||||
|
|
||||||
if (type === 'higher') {
|
if (type === 'higher') {
|
||||||
console.log(`${DIM}Guess #${guessCount}:${RESET} ${BOLD}${guess}${RESET} → ${YELLOW}📢 Higher!${RESET}`)
|
console.log(`${DIM}Guess #${guessCount}:${RESET} ${BOLD}${guess}${RESET} → ${YELLOW}📢 Higher!${RESET}`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type === 'lower') {
|
if (type === 'lower') {
|
||||||
console.log(`${DIM}Guess #${guessCount}:${RESET} ${BOLD}${guess}${RESET} → ${BLUE}📢 Lower!${RESET}`)
|
console.log(`${DIM}Guess #${guessCount}:${RESET} ${BOLD}${guess}${RESET} → ${BLUE}📢 Lower!${RESET}`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`${DIM}Guess #${guessCount}:${RESET} ${BOLD}${guess}${RESET} → ${GREEN}🎉 CORRECT!${RESET}`)
|
console.log(`${DIM}Guess #${guessCount}:${RESET} ${BOLD}${guess}${RESET} → ${GREEN}🎉 CORRECT!${RESET}`)
|
||||||
console.log()
|
console.log()
|
||||||
console.log(`${CYAN}New round! Secret number: ${BOLD}${getSecret()}${RESET}`)
|
console.log(`${CYAN}New round! Secret number: ${BOLD}${getSecret()}${RESET}`)
|
||||||
|
|
@ -50,6 +119,62 @@ function logGuess(result: GuessResult) {
|
||||||
console.log()
|
console.log()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function logLoopbackFailureDetails(result: Awaited<ReturnType<typeof loopbackTest>>) {
|
||||||
|
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) {
|
async function onMessage(text: string) {
|
||||||
await Bun.sleep(500)
|
await Bun.sleep(500)
|
||||||
|
|
||||||
|
|
@ -60,71 +185,110 @@ async function onMessage(text: string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await handleGuess(text)
|
const result = await handleGuess(text)
|
||||||
if (result) logGuess(result)
|
if (!result) return
|
||||||
|
|
||||||
|
if (result.type === 'correct') setRgbLedState('correct')
|
||||||
|
logGuess(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function startup(port: number) {
|
export async function startup(port: number) {
|
||||||
const soxCheck = Bun.spawnSync(['which', 'sox'])
|
if (!commandExists('sox')) {
|
||||||
if (soxCheck.exitCode !== 0) {
|
setRgbLedState('error')
|
||||||
console.error(`\n ${BOLD}sox is not installed.${RESET} Run: brew install sox\n`)
|
console.error(`\n ${BOLD}sox is not installed.${RESET} Run: ${getSoxInstallCommand()}\n`)
|
||||||
process.exit(1)
|
process.exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
console.clear()
|
let audioSetup = getAudioSetup()
|
||||||
|
const rgbLedStatus = getRgbLedStatus()
|
||||||
|
|
||||||
|
clearScreen()
|
||||||
console.log()
|
console.log()
|
||||||
console.log(`${BOLD}Corey's Screechy Audio Demo${RESET} ${DIM}v${pkg.version}${RESET}`)
|
console.log(`${BOLD}Corey's Screechy Audio Demo${RESET} ${DIM}v${pkg.version}${RESET}`)
|
||||||
console.log()
|
console.log()
|
||||||
|
console.log(`${BOLD}Speaker:${RESET} ${GREEN}${audioSetup.playbackLabel}${RESET}`)
|
||||||
const speaker = getDeviceName('output')
|
console.log(`${BOLD}Microphone:${RESET} ${GREEN}${audioSetup.captureLabel}${RESET}`)
|
||||||
const mic = getDeviceName('input')
|
if (process.platform === 'linux') {
|
||||||
console.log(`${BOLD}Speaker:${RESET} ${GREEN}${speaker}${RESET}`)
|
const ledValue = rgbLedStatus ? `${GREEN}${rgbLedStatus}${RESET}` : `${DIM}not available${RESET}`
|
||||||
console.log(`${BOLD}Microphone:${RESET} ${GREEN}${mic}${RESET}`)
|
console.log(`${BOLD}RGB LED:${RESET} ${ledValue}`)
|
||||||
|
}
|
||||||
console.log()
|
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(`${YELLOW}${BOLD}🔊 Turn your volume up!${RESET}`)
|
||||||
console.log(`${DIM}Testing audio: playing a chirp and listening for it...${RESET}`)
|
console.log(`${DIM}Testing audio: playing a chirp and listening for it...${RESET}`)
|
||||||
console.log()
|
console.log()
|
||||||
|
|
||||||
const loopbackOk = await loopbackTest()
|
setRgbLedState('startup')
|
||||||
|
const loopback = await loopbackTest()
|
||||||
|
|
||||||
if (loopbackOk) {
|
if (loopback.ok) {
|
||||||
console.log(`${GREEN}${BOLD}✓ Audio working!${RESET} ${DIM}Speaker → mic pipeline verified${RESET}`)
|
console.log(`${GREEN}${BOLD}✓ Audio working!${RESET} ${DIM}Speaker → mic pipeline verified${RESET}`)
|
||||||
} else {
|
} else {
|
||||||
console.log(`${RED}${BOLD}✗ Couldn't hear the test chirp.${RESET}`)
|
setRgbLedState('error')
|
||||||
console.log()
|
logLoopbackFailureDetails(loopback)
|
||||||
console.log(`${YELLOW}Try:${RESET}`)
|
await maybePause(' Press Enter to continue anyway... ')
|
||||||
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()
|
clearScreen()
|
||||||
console.log()
|
console.log()
|
||||||
console.log(`${BOLD}Corey's Screechy Audio Demo${RESET} ${DIM}v${pkg.version}${RESET}`)
|
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()
|
console.log()
|
||||||
|
|
||||||
const hostname = getLocalHostname()
|
const { alternativeUrls, primaryUrl } = getLocalUrls(port)
|
||||||
const url = `https://${hostname}:${port}`
|
const tailscale = getTailscaleInfo()
|
||||||
|
|
||||||
console.log(`${GREEN}${BOLD}Scan QR code on your phone to play!${RESET}`)
|
if (isLocalTlsEnabled()) {
|
||||||
console.log()
|
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 {
|
if (tailscale) {
|
||||||
const QRCode = await import('qrcode')
|
console.log(`${DIM}Alternative:${RESET} ${BOLD}tailscale serve ${port}${RESET} → ${CYAN}${tailscale.url}${RESET}`)
|
||||||
const qr = await QRCode.toString(url, { type: 'terminal', small: true })
|
console.log()
|
||||||
console.log(qr)
|
}
|
||||||
} catch {
|
} else if (tailscale) {
|
||||||
console.log(`${BOLD}${CYAN}${url}${RESET}`)
|
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://<device>.<tailnet>.ts.net${RESET} URL.`)
|
||||||
console.log()
|
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=<host-or-ip> to override local URL detection if needed.${RESET}`)
|
||||||
|
console.log()
|
||||||
|
|
||||||
console.log(`${BOLD}Secret number: ${GREEN}${getSecret()}${RESET}`)
|
console.log(`${BOLD}Secret number: ${GREEN}${getSecret()}${RESET}`)
|
||||||
console.log(`${'─'.repeat(40)}`)
|
console.log(`${'─'.repeat(40)}`)
|
||||||
console.log(`${DIM}🎤 Listening for guesses...${RESET}`)
|
console.log(`${DIM}🎤 Listening for guesses...${RESET}`)
|
||||||
console.log()
|
console.log()
|
||||||
|
|
||||||
|
setRgbLedState('listening')
|
||||||
startMicListener(onMessage)
|
startMicListener(onMessage)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
2
types.d.ts
vendored
Normal file
2
types.d.ts
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
declare module 'ggwave'
|
||||||
|
declare module 'qrcode'
|
||||||
Loading…
Reference in New Issue
Block a user