Skip to content

Commit

Permalink
More fixes + polish for FM synth filter internalization
Browse files Browse the repository at this point in the history
 * Fixed FM synth demo to work with updated method
 * Fixed some bugs when loading envelopes from presets and other sources
 * Fix some potential filter instability issues in FM synth's filter module
 * Add some NaN filtering protections to FM synth as a last line of defense
  • Loading branch information
Ameobea committed Jan 16, 2024
1 parent 405c012 commit e0c5ab7
Show file tree
Hide file tree
Showing 11 changed files with 166 additions and 93 deletions.
2 changes: 1 addition & 1 deletion engine/dsp/src/filters/biquad.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ impl BiquadFilter {
) -> (f32, f32, f32, f32, f32) {
// From: https://webaudio.github.io/web-audio-api/#filters-characteristics
let computed_frequency = freq;
let normalized_freq = computed_frequency / NYQUIST;
let normalized_freq = crate::clamp(0.005, 0.99, computed_frequency / NYQUIST);
let w0 = PI * normalized_freq;
#[allow(non_snake_case)]
let A = 10.0_f32.powf(gain / 40.);
Expand Down
14 changes: 2 additions & 12 deletions engine/wavetable/src/fm/filter/dynabandpass.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,8 @@ fn compute_modified_dynabandpass_filter_bandwidth(
fn compute_filter_cutoff_frequencies(center_frequency: f32, base_bandwidth: f32) -> (f32, f32) {
let bandwidth =
compute_modified_dynabandpass_filter_bandwidth(10., base_bandwidth, center_frequency);
let highpass_freq = dsp::clamp(10., NYQUIST - 10., center_frequency - bandwidth / 2.);
let lowpass_freq = dsp::clamp(10., NYQUIST - 10., center_frequency + bandwidth / 2.);
let highpass_freq = dsp::clamp(10., NYQUIST - 100., center_frequency - bandwidth / 2.);
let lowpass_freq = dsp::clamp(10., NYQUIST - 100., center_frequency + bandwidth / 2.);
(lowpass_freq, highpass_freq)
}

Expand Down Expand Up @@ -105,15 +105,5 @@ impl DynabandpassFilter {
&PRECOMPUTED_BASE_Q_FACTORS,
&self.highpass_cutoff_freqs,
);

if frame
.iter()
.any(|&sample| sample.is_nan() || sample < -10. || sample > 10.)
{
panic!(
"{:?} \n\n\n {:?} \n\n\n {:?}",
self.lowpass_cutoff_freqs, self.highpass_cutoff_freqs, frame
);
}
}
}
25 changes: 25 additions & 0 deletions engine/wavetable/src/fm/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,17 @@ use self::{
};

extern "C" {
pub(crate) fn log_panic(ptr: *const u8, len: usize);

pub(crate) fn log_err(ptr: *const u8, len: usize);

fn on_gate_cb(midi_number: usize, voice_ix: usize);

fn on_ungate_cb(midi_number: usize, voice_ix: usize);
}

pub fn log_err_str(s: &str) { unsafe { log_err(s.as_ptr(), s.len()) } }

pub static mut MIDI_CONTROL_VALUES: [f32; 1024] = [0.; 1024];
const GAIN_ENVELOPE_PHASE_BUF_INDEX: usize = 255;
const FILTER_ENVELOPE_PHASE_BUF_INDEX: usize = 254;
Expand Down Expand Up @@ -1726,6 +1730,8 @@ pub struct FMSynthContext {
PolySynth<Box<dyn Fn(usize, usize, u8, Option<f32>)>, Box<dyn Fn(usize, usize, Option<f32>)>>,
}

static mut DID_LOG_NAN: bool = false;

impl FMSynthContext {
pub fn generate(&mut self, cur_bpm: f32, cur_frame_start_beat: f32) {
for (voice_ix, voice) in self.voices.iter_mut().enumerate() {
Expand Down Expand Up @@ -1802,6 +1808,25 @@ impl FMSynthContext {
for i in 0..FRAME_SIZE {
self.main_output_buffer[i] *= self.master_gain;
}

let mut found_nan = false;
for sample in &mut self.main_output_buffer {
if sample.is_nan() || !sample.is_finite() {
found_nan = true;
*sample = 0.;
}
}

if found_nan {
if unsafe { !DID_LOG_NAN } {
unsafe {
DID_LOG_NAN = true;
}
log_err_str(&format!(
"NaN, Inf, or -Inf detected in output buffer from FM synth"
));
}
}
}

pub fn update_operator_enabled_statuses(&mut self) {
Expand Down
2 changes: 1 addition & 1 deletion engine/wavetable/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,7 @@ pub fn init_wavetable(
waveform_length: usize,
base_frequency: f32,
) -> *mut WaveTable {
common::set_raw_panic_hook(crate::fm::log_err);
common::set_raw_panic_hook(crate::fm::log_panic);

let settings = WaveTableSettings {
waveforms_per_dimension,
Expand Down
23 changes: 19 additions & 4 deletions public/FMSynthAWP.js
Original file line number Diff line number Diff line change
Expand Up @@ -431,13 +431,27 @@ class FMSynthAWP extends AudioWorkletProcessor {
};
}

handleWasmPanic = (ptr, len) => {
readStringFromWasmMemory = (ptr, len) => {
const mem = new Uint8Array(this.getWasmMemoryBuffer().buffer);
const slice = mem.subarray(ptr, ptr + len);
const str = String.fromCharCode(...slice);
return String.fromCharCode(...slice);
};

handleWasmPanic = (ptr, len) => {
const str = this.readStringFromWasmMemory(ptr, len);
throw new Error(str);
};

logErr = (ptr, len) => {
const str = this.readStringFromWasmMemory(ptr, len);
console.error(str);
};

log = (ptr, len) => {
const str = this.readStringFromWasmMemory(ptr, len);
console.log(str);
};

setOperatorState(operatorIx, mappedSamplesByMIDINumber) {
const entries = Object.entries(mappedSamplesByMIDINumber);
this.wasmInstance.exports.fm_synth_set_mapped_sample_midi_number_count(
Expand Down Expand Up @@ -490,8 +504,9 @@ class FMSynthAWP extends AudioWorkletProcessor {
async initWasm(wasmBytes, modulationMatrix, outputWeights, adsrs) {
const importObject = {
env: {
log_err: this.handleWasmPanic,
log_raw: (ptr, len, _level) => this.handleWasmPanic(ptr, len),
log_panic: this.handleWasmPanic,
log_err: (ptr, len) => this.logErr(ptr, len),
log_raw: (ptr, len, _level) => this.log(ptr, len),
debug1: (v1, v2, v3) => console.log({ v1, v2, v3 }),
on_gate_cb: (midiNumber, voiceIx) => {
this.port.postMessage({ type: 'onGate', midiNumber, voiceIx });
Expand Down
77 changes: 28 additions & 49 deletions src/fmDemo/FMSynthDemo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,13 @@ import { createRoot } from 'react-dom/client';

import './fmDemo.scss';
import { mkControlPanelADSR2WithSize } from 'src/controls/adsr2/ControlPanelADSR2';
import FilterConfig, { FilterContainer } from 'src/fmDemo/FilterConfig';
import FilterConfig from 'src/fmDemo/FilterConfig';
import { Presets, type SerializedFMSynthDemoState } from 'src/fmDemo/presets';
import { ConnectedFMSynthUI } from 'src/fmSynth/FMSynthUI';
import FMSynth, { type Adsr } from 'src/graphEditor/nodes/CustomAudio/FMSynth/FMSynth';
import FMSynth, {
FilterParamControlSource,
type Adsr,
} from 'src/graphEditor/nodes/CustomAudio/FMSynth/FMSynth';
import 'src/index.scss';
import { MIDIInput } from 'src/midiKeyboard/midiInput';
import { MidiKeyboard } from 'src/midiKeyboard/MidiKeyboard';
Expand All @@ -28,8 +31,6 @@ import { FilterType } from 'src/synthDesigner/FilterType';

initGlobals();

const VOICE_COUNT = 10;

const GlobalState: {
octaveOffset: number;
globalVolume: number;
Expand Down Expand Up @@ -66,11 +67,6 @@ if (!environmentIsValid) {
const ctx = new AudioContext();
const mainGain = new GainNode(ctx);
mainGain.gain.value = 0.1;
const filters = new Array(VOICE_COUNT).fill(null).map(() => {
const filter = new FilterContainer(ctx, GlobalState.filterParams);
filter.getOutput().connect(mainGain);
return filter;
});

// Disable context menu on mobile that can be caused by long holds on keys
if (window.screen.width < 1000) {
Expand Down Expand Up @@ -167,21 +163,8 @@ GlobalState.filterADSREnabled = serialized!.filterADSREnabled ?? true;
GlobalState.selectedMIDIInputName = serialized!.selectedMIDIInputName;
GlobalState.lastLoadedPreset = serialized!.lastLoadedPreset;

const voiceGains = new Array(VOICE_COUNT).fill(null).map((_i, voiceIx) => {
const gain = new GainNode(ctx);
gain.gain.value = 1;
const filterBypassed = serialized?.filterBypassed ?? false;
if (filterBypassed) {
gain.connect(mainGain);
} else {
gain.connect(filters[voiceIx].getInput());
}
return gain;
});

if (!R.isNil(serialized?.filterParams)) {
GlobalState.filterParams = serialized!.filterParams;
filters.forEach(filter => filter.setAll(GlobalState.filterParams));
}

const baseTheme = {
Expand Down Expand Up @@ -219,15 +202,16 @@ const synth = new FMSynth(ctx, undefined, {
midiNode: midiInputNode,
onInitialized: () => {
const awpNode = synth.getAWPNode()!;

voiceGains.forEach((voiceGain, voiceIx) => awpNode.connect(voiceGain, voiceIx));

filters.forEach((filter, i) => {
awpNode.connect(filter.csns.frequency, VOICE_COUNT + i, 0);
// Filter is overridden if ADSR is disabled, meaning that the frequency slider from the UI
// controls the fliter's frequency completely
filter.csns.frequency.setIsOverridden(!GlobalState.filterADSREnabled);
});
awpNode.connect(mainGain);

synth.setFilterParams(GlobalState.filterParams);
synth.setFilterBypassed(GlobalState.filterBypassed);
synth.handleFilterFrequencyChange(
GlobalState.filterParams.frequency,
GlobalState.filterADSREnabled
? FilterParamControlSource.Envelope
: FilterParamControlSource.Manual
);
},
});

Expand Down Expand Up @@ -328,19 +312,9 @@ const MainControlPanel: React.FC = () => {
);
};

const bypassFilter = () => {
voiceGains.forEach((node, voiceIx) => {
node.disconnect(filters[voiceIx].getInput());
node.connect(mainGain);
});
};
const bypassFilter = () => synth.setFilterBypassed(true);

const unBypassFilter = () => {
voiceGains.forEach((node, voiceIx) => {
node.disconnect(mainGain);
node.connect(filters[voiceIx].getInput());
});
};
const unBypassFilter = () => synth.setFilterBypassed(false);

interface PresetsControlPanelProps {
setOctaveOffset: (newOctaveOffset: number) => void;
Expand Down Expand Up @@ -398,7 +372,7 @@ const PresetsControlPanel: React.FC<PresetsControlPanelProps> = ({
lenSamples: { type: 'constant', value: filterEnvelope.lenSamples },
});
GlobalState.filterParams = preset.filterParams;
filters.forEach(filter => filter.setAll(GlobalState.filterParams));
synth.setFilterParams(GlobalState.filterParams);
if (preset.filterBypassed !== GlobalState.filterBypassed) {
if (preset.filterBypassed) {
bypassFilter();
Expand All @@ -407,9 +381,12 @@ const PresetsControlPanel: React.FC<PresetsControlPanelProps> = ({
}
}
GlobalState.filterBypassed = preset.filterBypassed;
if (GlobalState.filterADSREnabled !== (preset.filterADSREnabled ?? false)) {
filters.forEach(filter => filter.csns.frequency.setIsOverridden(!preset.filterADSREnabled));
}
synth.handleFilterFrequencyChange(
preset.filterParams.frequency,
preset.filterADSREnabled
? FilterParamControlSource.Envelope
: FilterParamControlSource.Manual
);
GlobalState.filterADSREnabled = preset.filterADSREnabled ?? false;

// Disconnect main output to avoid any horrific artifacts while we're switching
Expand Down Expand Up @@ -683,7 +660,6 @@ const FMSynthDemo: React.FC = () => {
isHidden={false}
/>
<FilterConfig
filters={filters}
initialState={{
params: GlobalState.filterParams,
envelope: normalizeEnvelope({
Expand All @@ -705,7 +681,10 @@ const FMSynthDemo: React.FC = () => {
lenSamples: { type: 'constant', value: envelope.lenSamples },
});
if (GlobalState.filterADSREnabled !== enableADSR) {
filters.forEach(filter => filter.csns.frequency.setIsOverridden(!enableADSR));
synth.handleFilterFrequencyChange(
params.frequency,
enableADSR ? FilterParamControlSource.Envelope : FilterParamControlSource.Manual
);
}
GlobalState.filterADSREnabled = enableADSR;

Expand Down
16 changes: 10 additions & 6 deletions src/fmDemo/FilterConfig.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,22 +68,28 @@ export class FilterContainer {
}

const handleFilterChange = (
filters: FilterContainer[],
synth: FMSynth,
state: { params: FilterParams; envelope: Adsr; bypass: boolean; enableADSR: boolean },
key: string,
val: any
) => {
const newState = { ...state, envelope: { ...state.envelope }, params: { ...state.params } };
switch (key) {
case 'frequency':
newState.params[key] = val;
synth.handleFilterFrequencyChange(val);
break;
case 'Q':
newState.params[key] = val;
synth.handleFilterQChange(val);
break;
case 'gain':
newState.params[key] = val;
filters.forEach(filter => filter.set(key, val));
synth.handleFilterGainChange(val);
break;
case 'type': {
filters.forEach(filter => filter.setType(val));
newState.params.type = val;
synth.handleFilterTypeChange(val);
break;
}
case 'enable envelope': {
Expand Down Expand Up @@ -120,7 +126,6 @@ interface FilterConfigProps {
bypass: boolean;
enableADSR: boolean;
};
filters: FilterContainer[];
onChange: (params: FilterParams, envelope: Adsr, bypass: boolean, enableADSR: boolean) => void;
vcId: string | undefined;
adsrDebugName?: string;
Expand All @@ -129,7 +134,6 @@ interface FilterConfigProps {

const FilterConfig: React.FC<FilterConfigProps> = ({
initialState,
filters,
onChange,
vcId,
adsrDebugName,
Expand Down Expand Up @@ -183,7 +187,7 @@ const FilterConfig: React.FC<FilterConfigProps> = ({
settings={settings}
state={controlPanelState}
onChange={(key: string, val: any) => {
const newState = handleFilterChange(filters, state, key, val);
const newState = handleFilterChange(synth, state, key, val);
onChange(newState.params, newState.envelope, newState.bypass, newState.enableADSR);
setState(newState);
}}
Expand Down
Loading

0 comments on commit e0c5ab7

Please sign in to comment.