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:
Indiana
2026-07-20 21:11:49 +00:00
parent 1ec9ea5863
commit 6edbbbbc2a
48 changed files with 10494 additions and 168 deletions

95
frontend/src/lib/fft.ts Normal file
View File

@@ -0,0 +1,95 @@
// Radix-2 Cooley–Tukey FFT, in-place, iterative, bit-reversal ordered.
// Used by the RTL-SDR spectrum sweep. Length must be a power of two.
export type ComplexArray = { re: Float64Array; im: Float64Array }
export function nextPow2(n: number): number {
let p = 1
while (p < n) p <<= 1
return p
}
/**
* In-place iterative radix-2 FFT. `re`/`im` must have identical length
* and that length must be a power of two.
*/
export function fftInPlace(re: Float64Array, im: Float64Array): void {
const n = re.length
if (n !== im.length) throw new Error('re/im length mismatch')
if ((n & (n - 1)) !== 0) throw new Error('FFT length must be a power of two')
if (n <= 1) return
// Bit-reversal permutation.
for (let i = 1, j = 0; i < n; i++) {
let bit = n >> 1
for (; j & bit; bit >>= 1) j ^= bit
j ^= bit
if (i < j) {
const tr = re[i]
re[i] = re[j]
re[j] = tr
const ti = im[i]
im[i] = im[j]
im[j] = ti
}
}
for (let len = 2; len <= n; len <<= 1) {
const half = len >> 1
const ang = (-2 * Math.PI) / len
const wr = Math.cos(ang)
const wi = Math.sin(ang)
for (let i = 0; i < n; i += len) {
let cwr = 1
let cwi = 0
for (let j = 0; j < half; j++) {
const uR = re[i + j]
const uI = im[i + j]
const vR = re[i + j + half] * cwr - im[i + j + half] * cwi
const vI = re[i + j + half] * cwi + im[i + j + half] * cwr
re[i + j] = uR + vR
im[i + j] = uI + vI
re[i + j + half] = uR - vR
im[i + j + half] = uI - vI
const nwr = cwr * wr - cwi * wi
cwi = cwr * wi + cwi * wr
cwr = nwr
}
}
}
}
/** Convenience wrapper returning magnitude spectrum (first n/2 bins). */
export function magnitudeSpectrum(re: Float64Array, im: Float64Array): Float64Array {
const r = Float64Array.from(re)
const i = Float64Array.from(im)
fftInPlace(r, i)
const half = r.length >> 1
const mags = new Float64Array(half)
for (let k = 0; k < half; k++) {
mags[k] = Math.hypot(r[k], i[k])
}
return mags
}
/**
* Power spectrum in dBFS-ish units from interleaved I/Q samples.
* `iq` layout: [i0, q0, i1, q1, ...]. Returns n/2 dB values.
*/
export function powerSpectrumDb(iq: Float64Array): Float64Array {
const n = nextPow2(iq.length >> 1)
const re = new Float64Array(n)
const im = new Float64Array(n)
for (let k = 0; k < n && 2 * k + 1 < iq.length; k++) {
re[k] = iq[2 * k]
im[k] = iq[2 * k + 1]
}
fftInPlace(re, im)
const half = n >> 1
const out = new Float64Array(half)
for (let k = 0; k < half; k++) {
const power = (re[k] * re[k] + im[k] * im[k]) / (n * n)
out[k] = 10 * Math.log10(power + 1e-12)
}
return out
}