feat: complete Quantumancy web app — full frontend + docs
Frontend (React 18 + TS + Vite): - Landing: glitching hero, live /api/stats veil ticker, mode cards, featured spirits - Séance: three.js shader ghost (hue/form per entity, mood + audio-reactive), Ouija planchette board spelling utterances, transcript with TTS replay, entity dossier, direct contact streaming, passive/active listening - Modes: Wire Ghost telemetry panel, EVP mic anomaly detection, WebUSB RTL-SDR sweep + waterfall (hardware pass pending), Ouija/Direct Contact - Codex: public registry + entity dossiers, rarity tiers, i18n EN/ES complete - State: VeilSocket (reconnect/backoff), seance reducer, auth context - 72 vitest tests green; served by FastAPI at :7777 Docs: README + as-built plans 3-7
This commit is contained in:
339
frontend/src/lib/sdr.ts
Normal file
339
frontend/src/lib/sdr.ts
Normal file
@@ -0,0 +1,339 @@
|
||||
// Best-effort WebUSB driver for RTL2832U + R820T("T") based SDR dongles.
|
||||
//
|
||||
// ⚠️ HARDWARE PASS REQUIRED: this driver is structured from the public
|
||||
// librtlsdr register documentation and has NOT been validated against a real
|
||||
// device in this environment (no RTL-SDR attached). The control-transfer
|
||||
// sequences below follow the known-good init order (demod power-up, R820T
|
||||
// tuner init via I2C repeater, sample-rate set, FIR, bulk streaming) but
|
||||
// expect to debug register pokes with a logic analyzer / librtlsdr -T.
|
||||
// Every entry point fails soft: callers must treat any thrown error as
|
||||
// "this vessel cannot hear the radio dead" and degrade gracefully.
|
||||
|
||||
import { powerSpectrumDb } from './fft'
|
||||
|
||||
export type RtlSampleBlock = {
|
||||
/** Center frequency this block was captured at, Hz. */
|
||||
centerHz: number
|
||||
/** Sample rate used, Hz. */
|
||||
sampleRateHz: number
|
||||
/** Interleaved unsigned I/Q bytes, zero-centered: [i,q,i,q…] */
|
||||
iq: Uint8Array
|
||||
}
|
||||
|
||||
export type SweepCallbacks = {
|
||||
/** Per-tune power spectrum (dB values, fftSize/2 entries). */
|
||||
onSpectrum?: (centerHz: number, db: Float64Array) => void
|
||||
onError?: (err: Error) => void
|
||||
}
|
||||
|
||||
// USB identification for RTL2832U dongles.
|
||||
export const RTL2832U_VENDOR = 0x0bda
|
||||
export const RTL2832U_PRODUCTS = [0x2832, 0x2834, 0x2838, 0x2837]
|
||||
|
||||
// Request types used by librtlsdr.
|
||||
const CTRL_IN = 0xc0
|
||||
const CTRL_OUT = 0x40
|
||||
const DEMOD = 0x03
|
||||
const USB_EPA = 0x02
|
||||
const SYS = 0x09
|
||||
const PAGE_USB = 0x01
|
||||
|
||||
export function isSupported(): boolean {
|
||||
return (
|
||||
typeof navigator !== 'undefined' &&
|
||||
'usb' in navigator &&
|
||||
typeof navigator.usb?.requestDevice === 'function'
|
||||
)
|
||||
}
|
||||
|
||||
export class RtlSdr {
|
||||
private device: USBDevice | null = null
|
||||
private interfaceNumber = 0
|
||||
private endpointIn = 0x81
|
||||
private running = false
|
||||
private sampleRate = 2_048_000
|
||||
|
||||
get isOpen(): boolean {
|
||||
return this.device?.opened ?? false
|
||||
}
|
||||
|
||||
/** Ask the browser for an RTL2832U device. Throws if none chosen/found. */
|
||||
async requestDevice(): Promise<void> {
|
||||
if (!isSupported()) {
|
||||
throw new Error('WebUSB is not available in this vessel (Chromium required)')
|
||||
}
|
||||
const filters = [
|
||||
{ vendorId: RTL2832U_VENDOR, productId: 0x2832 },
|
||||
{ vendorId: RTL2832U_VENDOR, productId: 0x2834 },
|
||||
{ vendorId: RTL2832U_VENDOR, productId: 0x2838 },
|
||||
{ vendorId: RTL2832U_VENDOR, productId: 0x2837 },
|
||||
]
|
||||
this.device = await navigator.usb.requestDevice({ filters })
|
||||
}
|
||||
|
||||
/** Open, claim, and run the RTL2832U + R820T init sequence. */
|
||||
async open(sampleRateHz = 2_048_000): Promise<void> {
|
||||
const dev = this.device
|
||||
if (!dev) throw new Error('no device selected')
|
||||
this.sampleRate = sampleRateHz
|
||||
await dev.open()
|
||||
|
||||
// Find the first bulk-IN endpoint.
|
||||
const iface = dev.configuration?.interfaces[0]
|
||||
const alt = iface?.alternates[0]
|
||||
const ep = alt?.endpoints.find((e) => e.direction === 'in' && e.type === 'bulk')
|
||||
if (iface && alt && ep) {
|
||||
this.interfaceNumber = iface.interfaceNumber
|
||||
this.endpointIn = ep.endpointNumber
|
||||
}
|
||||
|
||||
// Detach kernel driver (Linux) — ignore failure: may not be supported.
|
||||
await this.claim()
|
||||
|
||||
// --- Init sequence (HARDWARE PASS REQUIRED) ---
|
||||
// Order follows librtlsdr: USB reset → demod init → tuner I2C init.
|
||||
await this.demodWrite(1, 0x01, 0x14, 1) // soft reset
|
||||
await this.demodWrite(1, 0x01, 0x10, 1)
|
||||
await this.demodWrite(0, 0x01, 0x08, 2) // demod_ctl
|
||||
await this.demodWrite(0, 0x06, 0x80, 1)
|
||||
await this.demodWrite(1, 0x15, 0x00, 1) // suspend off
|
||||
await this.demodWrite(1, 0x16, 0x00, 1)
|
||||
await this.demodWrite(1, 0x17, 0x00, 1)
|
||||
await this.demodWrite(1, 0x18, 0x00, 1)
|
||||
await this.demodWrite(1, 0x19, 0x00, 1)
|
||||
await this.demodWrite(1, 0x1a, 0x00, 1)
|
||||
await this.demodWrite(1, 0x1b, 0x00, 1)
|
||||
await this.demodWrite(1, 0x1c, 0x00, 1)
|
||||
await this.demodWrite(1, 0x0d, 0x83, 1) // standby off
|
||||
await this.demodWrite(1, 0x0b, 0x1b, 1) // AGC mode
|
||||
|
||||
// Power on tuner through I2C repeater (R820T at 0x1a).
|
||||
await this.i2cWrite(0x1a, 0x05, 0x8f) // LNA power on
|
||||
await this.i2cWrite(0x1a, 0x08, 0x80) // mixer
|
||||
await this.i2cWrite(0x1a, 0x0a, 0x10) // IF filter
|
||||
|
||||
await this.setSampleRate(this.sampleRate)
|
||||
await this.setFrequency(98_000_000)
|
||||
|
||||
// Reset endpoint before streaming.
|
||||
await this.writeReg(USB_EPA, 0x0001, 0xffff, 2)
|
||||
await this.demodWrite(1, 0x02, 0x00, 1)
|
||||
await this.demodWrite(0, 0x02, 0x40, 2)
|
||||
await this.demodWrite(1, 0x02, 0x00, 1) // enable test mode off
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
this.running = false
|
||||
const dev = this.device
|
||||
if (dev?.opened) {
|
||||
try {
|
||||
await this.demodWrite(1, 0x01, 0x10, 1) // suspend
|
||||
} catch {
|
||||
/* device may already be gone */
|
||||
}
|
||||
await dev.releaseInterface(this.interfaceNumber).catch(() => undefined)
|
||||
await dev.close().catch(() => undefined)
|
||||
}
|
||||
}
|
||||
|
||||
/** Tune the R820T mixer PLL. Hz. (HARDWARE PASS REQUIRED) */
|
||||
async setFrequency(hz: number): Promise<void> {
|
||||
// R820T fractional-N PLL programming via I2C. Simplified to the
|
||||
// integer part + common divider ratio; real librtlsdr computes the
|
||||
// exact sdm/vco from a 28.8MHz crystal reference. Marked for hardware.
|
||||
const loHz = hz + 3_570_000 // R820T IF offset
|
||||
const ref = 28_800_000
|
||||
const mixDiv = 2
|
||||
const nint = Math.floor(loHz / (ref * mixDiv))
|
||||
const vco = loHz % (ref * mixDiv)
|
||||
const sdm = Math.min(0xffff, Math.floor((vco * 65536) / (ref * mixDiv)))
|
||||
const reg = nint & 0x3f
|
||||
await this.i2cWrite(0x1a, 0x10, reg)
|
||||
await this.i2cWrite(0x1a, 0x11, (sdm >> 8) & 0xff)
|
||||
await this.i2cWrite(0x1a, 0x12, sdm & 0xff)
|
||||
}
|
||||
|
||||
/** Program demod resampling rate for the requested sample rate. */
|
||||
async setSampleRate(hz: number): Promise<void> {
|
||||
const crystal = 28_800_000
|
||||
const rsampRatio = Math.floor(((crystal * 2 ** 22) / hz) & 0x0ffffffc)
|
||||
await this.demodWrite(1, 0x9f, (rsampRatio >> 16) & 0xffff, 2)
|
||||
await this.demodWrite(1, 0xa1, rsampRatio & 0xffff, 2)
|
||||
this.sampleRate = hz
|
||||
}
|
||||
|
||||
/** Read one block of I/Q samples. Length must be multiple of 512. */
|
||||
async readSamples(bytes: number): Promise<Uint8Array> {
|
||||
const dev = this.device
|
||||
if (!dev?.opened) throw new Error('device not open')
|
||||
const res = await dev.transferIn(this.endpointIn, bytes)
|
||||
if (!res.data) throw new Error('bulk read failed')
|
||||
return new Uint8Array(res.data.buffer, res.data.byteOffset, res.data.byteLength)
|
||||
}
|
||||
|
||||
/**
|
||||
* Continuous sweep across [startHz, endHz] in `stepHz` steps, emitting a
|
||||
* power spectrum per tuning step. Runs until `stopSweep()`.
|
||||
*/
|
||||
async sweep(
|
||||
startHz: number,
|
||||
endHz: number,
|
||||
stepHz: number,
|
||||
cb: SweepCallbacks,
|
||||
fftSize = 512,
|
||||
settleMs = 25,
|
||||
): Promise<void> {
|
||||
if (this.running) return
|
||||
this.running = true
|
||||
let hz = startHz
|
||||
const readBytes = fftSize * 2 * 2 // 2 samples per fft point, unsigned iq
|
||||
try {
|
||||
while (this.running) {
|
||||
await this.setFrequency(hz)
|
||||
await new Promise((r) => setTimeout(r, settleMs))
|
||||
try {
|
||||
await this.readSamples(16384) // discard: PLL settle
|
||||
const iq = await this.readSamples(readBytes)
|
||||
const f64 = new Float64Array(iq.length)
|
||||
for (let i = 0; i < iq.length; i++) f64[i] = (iq[i] - 127.5) / 128
|
||||
const db = powerSpectrumDb(f64)
|
||||
cb.onSpectrum?.(hz, db)
|
||||
} catch (err) {
|
||||
cb.onError?.(err instanceof Error ? err : new Error(String(err)))
|
||||
}
|
||||
hz += stepHz
|
||||
if (hz > endHz) hz = startHz
|
||||
if (!this.running) break
|
||||
}
|
||||
} finally {
|
||||
this.running = false
|
||||
}
|
||||
}
|
||||
|
||||
stopSweep(): void {
|
||||
this.running = false
|
||||
}
|
||||
|
||||
// ---- Low-level USB helpers (private; HARDWARE PASS REQUIRED) ----
|
||||
|
||||
private async claim(): Promise<void> {
|
||||
const dev = this.device
|
||||
if (!dev) throw new Error('no device')
|
||||
try {
|
||||
// Best-effort kernel driver detach; unsupported on some platforms.
|
||||
const anyDev = dev as unknown as {
|
||||
claimInterface(n: number): Promise<void>
|
||||
}
|
||||
await anyDev.claimInterface(this.interfaceNumber)
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
`could not claim the radio dead (interface busy?) — ${String(err)}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private async writeReg(
|
||||
block: number,
|
||||
address: number,
|
||||
value: number,
|
||||
length: number,
|
||||
): Promise<void> {
|
||||
const dev = this.device
|
||||
if (!dev) throw new Error('no device')
|
||||
const data = new Uint8Array([value & 0xff, (value >> 8) & 0xff])
|
||||
await dev.controlTransferOut({
|
||||
requestType: 'vendor',
|
||||
recipient: 'device',
|
||||
request: 0,
|
||||
value: (block << 8) | 0x10,
|
||||
index: address,
|
||||
}, data.subarray(0, length))
|
||||
}
|
||||
|
||||
private async demodWrite(
|
||||
page: number,
|
||||
address: number,
|
||||
value: number,
|
||||
length: number,
|
||||
): Promise<void> {
|
||||
// Demod registers are paged: index = (page << 8) | address.
|
||||
const dev = this.device
|
||||
if (!dev) throw new Error('no device')
|
||||
const data = new Uint8Array([value & 0xff, (value >> 8) & 0xff])
|
||||
await dev.controlTransferOut({
|
||||
requestType: 'vendor',
|
||||
recipient: 'device',
|
||||
request: 0,
|
||||
value: (DEMOD << 8) | 0x10,
|
||||
index: (page << 8) | address,
|
||||
}, data.subarray(0, length))
|
||||
}
|
||||
|
||||
private async i2cWrite(i2cAddr: number, reg: number, value: number): Promise<void> {
|
||||
// I2C repeater: librtlsdr writes tuner registers by tunneling through
|
||||
// the demod's I2C master. Simplified single-byte write.
|
||||
const dev = this.device
|
||||
if (!dev) throw new Error('no device')
|
||||
await this.demodWrite(1, 0x02, 0x41, 1) // repeater on
|
||||
await dev.controlTransferOut({
|
||||
requestType: 'vendor',
|
||||
recipient: 'device',
|
||||
request: 0,
|
||||
value: (0x02 << 8) | 0x10,
|
||||
index: (i2cAddr << 8) | reg,
|
||||
}, new Uint8Array([value & 0xff]))
|
||||
await this.demodWrite(1, 0x02, 0x01, 1) // repeater off
|
||||
}
|
||||
|
||||
// Silence unused-warnings for constants kept for the hardware pass.
|
||||
private static readonly _refs = { CTRL_IN, CTRL_OUT, SYS, PAGE_USB }
|
||||
}
|
||||
|
||||
/** Rolling noise-floor + spike detector over sweep spectra (pure, testable). */
|
||||
export class SpectrumAnomalyDetector {
|
||||
private floor: Float64Array | null = null
|
||||
private lastEmit = -Infinity
|
||||
constructor(
|
||||
private readonly thresholdDb = 10,
|
||||
private readonly throttleMs = 2000,
|
||||
private readonly alpha = 0.1,
|
||||
) {}
|
||||
|
||||
/** Returns (peakHz, magnitudeDbOverFloor) when a spike fires, else null. */
|
||||
process(
|
||||
centerHz: number,
|
||||
sampleRateHz: number,
|
||||
db: Float64Array,
|
||||
nowMs: number,
|
||||
): { frequency: number; magnitude: number } | null {
|
||||
if (!this.floor || this.floor.length !== db.length) {
|
||||
this.floor = Float64Array.from(db)
|
||||
return null
|
||||
}
|
||||
let peak = 0
|
||||
let peakBin = -1
|
||||
for (let i = 0; i < db.length; i++) {
|
||||
const dev = db[i] - this.floor[i]
|
||||
if (dev > peak) {
|
||||
peak = dev
|
||||
peakBin = i
|
||||
}
|
||||
this.floor[i] += this.alpha * (db[i] - this.floor[i])
|
||||
}
|
||||
if (peakBin >= 0 && peak >= this.thresholdDb && nowMs - this.lastEmit >= this.throttleMs) {
|
||||
this.lastEmit = nowMs
|
||||
const binHz = sampleRateHz / 2 / db.length
|
||||
const offset = (peakBin - db.length / 2) * binHz
|
||||
return { frequency: (centerHz + offset) / 1e6, magnitude: peak }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.floor = null
|
||||
this.lastEmit = -Infinity
|
||||
}
|
||||
}
|
||||
|
||||
export const SWEEP_START_MHZ = 88
|
||||
export const SWEEP_END_MHZ = 108
|
||||
Reference in New Issue
Block a user