FM Test
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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<string, Float32Array>): 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<string, Float32Array>
|
||||
): 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<string, Float32Array>): 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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,9 +3,6 @@
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"exclude": [
|
||||
"src/ui/pages/test/*Processor.ts"
|
||||
],
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
|
||||
Reference in New Issue
Block a user