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
96 lines
2.7 KiB
TypeScript
96 lines
2.7 KiB
TypeScript
// 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
|
||
}
|