From b62da32d4c51ecdc495704bd3aaf083196512f7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Michelle?= Date: Mon, 29 Dec 2025 10:22:11 +0100 Subject: [PATCH] FM Test --- .../app/studio/src/ui/pages/TestPage.sass | 6 +- .../src/ui/pages/test/FMRadioProcessor.ts | 259 ++++++------------ packages/app/studio/tsconfig.json | 3 - 3 files changed, 89 insertions(+), 179 deletions(-) diff --git a/packages/app/studio/src/ui/pages/TestPage.sass b/packages/app/studio/src/ui/pages/TestPage.sass index e95d950f..5cfb2db0 100644 --- a/packages/app/studio/src/ui/pages/TestPage.sass +++ b/packages/app/studio/src/ui/pages/TestPage.sass @@ -27,12 +27,10 @@ component padding: 8px 16px border: none border-radius: 4px - color: var(--color-cream) + color: var(--color-blue) cursor: pointer font-size: 14px - - &:hover - background: var(--color-bright) + background: var(--color-panel-background-dark) &:active opacity: 0.8 diff --git a/packages/app/studio/src/ui/pages/test/FMRadioProcessor.ts b/packages/app/studio/src/ui/pages/test/FMRadioProcessor.ts index eaea53e9..249c7ae6 100644 --- a/packages/app/studio/src/ui/pages/test/FMRadioProcessor.ts +++ b/packages/app/studio/src/ui/pages/test/FMRadioProcessor.ts @@ -1,206 +1,121 @@ -/** - * FM Radio Tuning Effect Processor - * - * Physical modeling of FM radio tuning: - * - FM modulates input audio onto a carrier - * - Demodulates with a local oscillator that can be offset - * - Frequency offset creates characteristic tuning artifacts: - * - Beat frequencies and pitch shifting - * - Increased noise floor - * - Signal distortion - */ +const TAU = 2.0 * Math.PI +const PI = Math.PI -// AudioWorklet global types (not available in standard lib) -declare const sampleRate: number -declare function registerProcessor(name: string, processor: typeof AudioWorkletProcessor): void -declare class AudioWorkletProcessor { - readonly port: MessagePort - process(inputs: Float32Array[][], outputs: Float32Array[][], parameters: Record): boolean -} -interface AudioParamDescriptor { - name: string - defaultValue?: number - minValue?: number - maxValue?: number - automationRate?: "a-rate" | "k-rate" -} - -// Constants - no magic numbers -const TWO_PI = 2.0 * Math.PI - -// Default parameter values -const DEFAULT_CARRIER_FREQUENCY = 10000.0 // Hz - carrier frequency for FM modulation -const DEFAULT_MODULATION_INDEX = 5.0 // FM modulation depth -const DEFAULT_FREQUENCY_OFFSET = 0.0 // 0 = tuned, 1 = completely off-tune -const DEFAULT_OFFSET_RANGE = 500.0 // Hz - max frequency offset when offset = 1 -const DEFAULT_NOISE_AMOUNT = 0.8 // How much noise at full offset +const DefaultCarrierFrequency = 10000.0 +const DefaultModulationIndex = 5.0 +const DefaultFrequencyOffset = 0.0 +const DefaultOffsetRange = 500.0 +const DefaultNoiseAmount = 0.8 +const IQLowpassCutoff = 5000.0 +const DCBlockCutoff = 20.0 class FMRadioProcessor extends AudioWorkletProcessor { - // Phase accumulators for oscillators - private carrierPhase: number = 0.0 - private localOscillatorPhase: number = 0.0 + readonly #invSampleRate: number + readonly #iqLowpassCoeff: number + readonly #dcBlockCoeff: number - // Integrator for FM modulation - private modulationIntegral: number = 0.0 - - // Simple lowpass filter state for demodulation - private lowpassState: number = 0.0 - private readonly lowpassCoeff: number - - // Noise generator state (simple LFSR-based) - private noiseState: number = 1 + #carrierPhase = 0.0 + #loPhase = 0.0 + #modIntegral = 0.0 + #iState = 0.0 + #qState = 0.0 + #prevPhase = 0.0 + #dcState = 0.0 + #dcPrev = 0.0 + #lfsr = 1 constructor() { super() - - // Lowpass filter coefficient for ~5kHz cutoff (audio bandwidth) - const cutoffFrequency = 5000.0 - this.lowpassCoeff = Math.exp(-TWO_PI * cutoffFrequency / sampleRate) + this.#invSampleRate = 1.0 / sampleRate + this.#iqLowpassCoeff = Math.exp(-TAU * IQLowpassCutoff * this.#invSampleRate) + this.#dcBlockCoeff = 1.0 - (TAU * DCBlockCutoff * this.#invSampleRate) } - static get parameterDescriptors(): AudioParamDescriptor[] { + static get parameterDescriptors() { return [ - { - name: "frequencyOffset", - defaultValue: DEFAULT_FREQUENCY_OFFSET, - minValue: 0.0, - maxValue: 1.0, - automationRate: "k-rate" - }, - { - name: "carrierFrequency", - defaultValue: DEFAULT_CARRIER_FREQUENCY, - minValue: 1000.0, - maxValue: 20000.0, - automationRate: "k-rate" - }, - { - name: "modulationIndex", - defaultValue: DEFAULT_MODULATION_INDEX, - minValue: 0.1, - maxValue: 20.0, - automationRate: "k-rate" - }, - { - name: "offsetRange", - defaultValue: DEFAULT_OFFSET_RANGE, - minValue: 10.0, - maxValue: 2000.0, - automationRate: "k-rate" - }, - { - name: "noiseAmount", - defaultValue: DEFAULT_NOISE_AMOUNT, - minValue: 0.0, - maxValue: 1.0, - automationRate: "k-rate" - } + {name: "frequencyOffset", defaultValue: DefaultFrequencyOffset, minValue: 0.0, maxValue: 1.0, automationRate: "k-rate"}, + {name: "carrierFrequency", defaultValue: DefaultCarrierFrequency, minValue: 1000.0, maxValue: 20000.0, automationRate: "k-rate"}, + {name: "modulationIndex", defaultValue: DefaultModulationIndex, minValue: 0.1, maxValue: 20.0, automationRate: "k-rate"}, + {name: "offsetRange", defaultValue: DefaultOffsetRange, minValue: 10.0, maxValue: 2000.0, automationRate: "k-rate"}, + {name: "noiseAmount", defaultValue: DefaultNoiseAmount, minValue: 0.0, maxValue: 1.0, automationRate: "k-rate"} ] } - /** - * Generate white noise using a simple LFSR - */ - private generateNoise(): number { - // Galois LFSR for pseudo-random noise - const bit = this.noiseState & 1 - this.noiseState >>>= 1 - if (bit) { - this.noiseState ^= 0xB400 // Taps for 16-bit LFSR - } - // Convert to -1..1 range - return (this.noiseState / 0x7FFF) * 2.0 - 1.0 + #noise(): number { + const bit = this.#lfsr & 1 + this.#lfsr >>>= 1 + if (bit) {this.#lfsr ^= 0xB400} + return (this.#lfsr / 0x7FFF) * 2.0 - 1.0 } - process( - inputs: Float32Array[][], - outputs: Float32Array[][], - parameters: Record - ): boolean { - const input = inputs[0] - const output = outputs[0] + #wrapPhase(p: number): number { + while (p > PI) {p -= TAU} + while (p < -PI) {p += TAU} + return p + } - if (!input || !input[0] || !output || !output[0]) { - return true - } + process(inputs: Float32Array[][], outputs: Float32Array[][], parameters: Record): boolean { + const input = inputs[0]?.[0] + const outL = outputs[0]?.[0] + const outR = outputs[0]?.[1] + if (!input || !outL) {return true} - // Get parameter values (k-rate, so just first sample) - const frequencyOffset = parameters.frequencyOffset[0] - const carrierFrequency = parameters.carrierFrequency[0] - const modulationIndex = parameters.modulationIndex[0] + const offset = parameters.frequencyOffset[0] + const carrierFreq = parameters.carrierFrequency[0] + const modIndex = parameters.modulationIndex[0] const offsetRange = parameters.offsetRange[0] - const noiseAmount = parameters.noiseAmount[0] + const noiseAmt = parameters.noiseAmount[0] - // Calculate local oscillator frequency based on offset - const localOscFreq = carrierFrequency + (frequencyOffset * offsetRange) + const loFreq = carrierFreq + offset * offsetRange + const carrierInc = TAU * carrierFreq * this.#invSampleRate + const loInc = TAU * loFreq * this.#invSampleRate + const noiseLevel = offset * noiseAmt + const demodGain = 1.0 / modIndex - // Phase increment per sample - const carrierPhaseInc = TWO_PI * carrierFrequency / sampleRate - const localOscPhaseInc = TWO_PI * localOscFreq / sampleRate + for (let i = 0; i < input.length; i++) { + const inp = input[i] - // Calculate noise level based on offset (more offset = more noise) - const noiseLevel = frequencyOffset * noiseAmount + // FM modulation: phase = carrier + modIndex * integral(input) + this.#modIntegral += inp + const fmSignal = Math.cos(this.#carrierPhase + modIndex * this.#modIntegral) - // Calculate signal level (inverse of offset for capture effect) - // Use a non-linear curve for sharper "capture" feel - const captureSharpness = 8.0 - const signalLevel = Math.pow(1.0 - frequencyOffset, captureSharpness) - - const inputChannel = input[0] - const outputLeft = output[0] - const outputRight = output[1] || output[0] - - for (let i = 0; i < inputChannel.length; i++) { - const inputSample = inputChannel[i] - - // === FM Modulation === - // Integrate the input signal for frequency modulation - this.modulationIntegral += inputSample - - // Calculate instantaneous phase of FM signal - const fmPhase = this.carrierPhase + modulationIndex * this.modulationIntegral - - // Generate FM modulated signal - const fmSignal = Math.cos(fmPhase) - - // Advance carrier phase - this.carrierPhase += carrierPhaseInc - if (this.carrierPhase > TWO_PI) { - this.carrierPhase -= TWO_PI - // Also wrap the modulation integral to prevent overflow - this.modulationIntegral = 0.0 + this.#carrierPhase += carrierInc + if (this.#carrierPhase > TAU) { + this.#carrierPhase -= TAU + this.#modIntegral = 0.0 // reset to prevent overflow } - // === FM Demodulation with offset local oscillator === - // Mix with local oscillator (multiply) - const localOsc = Math.cos(this.localOscillatorPhase) - const mixed = fmSignal * localOsc + // I/Q demodulation + const iMix = fmSignal * Math.cos(this.#loPhase) + const qMix = fmSignal * Math.sin(this.#loPhase) - // Advance local oscillator phase - this.localOscillatorPhase += localOscPhaseInc - if (this.localOscillatorPhase > TWO_PI) { - this.localOscillatorPhase -= TWO_PI - } + this.#loPhase += loInc + if (this.#loPhase > TAU) {this.#loPhase -= TAU} - // Lowpass filter to extract baseband - this.lowpassState = mixed + this.lowpassCoeff * (this.lowpassState - mixed) + // Lowpass I/Q + this.#iState = iMix + this.#iqLowpassCoeff * (this.#iState - iMix) + this.#qState = qMix + this.#iqLowpassCoeff * (this.#qState - qMix) - // The demodulated signal - let demodulated = this.lowpassState * 2.0 // Compensate for mixing loss + // Phase detection and differentiation + const phase = Math.atan2(this.#qState, this.#iState) + let phaseDiff = this.#wrapPhase(phase - this.#prevPhase) + this.#prevPhase = phase - // === Add noise based on frequency offset === - const noise = this.generateNoise() * noiseLevel + // Demodulated = phaseDiff / modIndex (recovers original signal) + let demod = phaseDiff * demodGain - // === Mix signal and noise === - // When tuned (offset=0): full signal, no noise - // When off-tune (offset=1): no signal, full noise - const finalSample = (demodulated * signalLevel) + noise + // DC block + const dc = demod - this.#dcPrev + this.#dcBlockCoeff * this.#dcState + this.#dcPrev = demod + this.#dcState = dc + demod = dc - // Output (mono to stereo) - outputLeft[i] = finalSample - outputRight[i] = finalSample + // Add noise + const out = demod + this.#noise() * noiseLevel + + outL[i] = out + if (outR) {outR[i] = out} } - return true } } diff --git a/packages/app/studio/tsconfig.json b/packages/app/studio/tsconfig.json index cae7c93e..a99ec4c4 100644 --- a/packages/app/studio/tsconfig.json +++ b/packages/app/studio/tsconfig.json @@ -3,9 +3,6 @@ "include": [ "src" ], - "exclude": [ - "src/ui/pages/test/*Processor.ts" - ], "compilerOptions": { "baseUrl": ".", "paths": {