FM-синтез звука в браузере. Часть 2

от автора

Рассмотрим FM-синтез на реальном примере. Сделаем эмулятор синтезатора Yamaha DX7 и добавим в него возможность читать картриджы с инструментами.

Yamaha DX7

Yamaha DX7

первая часть статьи здесь — https://habr.com/ru/articles/1052640/


Немного о синтезаторе

Yamaha DX7 появился в 1983г. За счёт широких возможностей настройки звуков и низкой цены стал очень широко применяться. Половина поп-музыки 80-х годов прошлого века написана с его использованием.

Внутри устройства 6 генераторов синусоиды. Они могут соединяться друг с другом в 32 презаданные схемы (на картинке синтезатора выше они нарисованы на корпусе).

Каждый генератор имеет собственную частоту (кратную нажатой клавише или постоянную) и огибающую.

При соединении генераторы создают фазовую модуляцию, что позволяет менять звук в широких пределах. Исторически сложилось, что это называется FM-синтезом (т.е. аплитудно-частотным синтезом), хотя это и некорректно.

Подробней о внутреннем устройстве пресетов для Yamaha DX7 можно прочитать здесь — https://www.muzines.co.uk/search/understanding%20the%20dx7

За прошедшее время для этого синтезатора наделали несметное количество инструментов от гитар с органами до барабанов. Если погуглить ( https://ya.ru/search/?text=dx7+syx ) то можно найти достаточно много с хорошим звучанием.

Эмуляция DX7

Сделаем эмулятор в виде плагина для Minium Studio чтоб можно было не просто тыкнуть кнопку для воспроизведения единичного звука, а послушать звучание пресетов в полноценной музыкальной композиции.

Загрузка пресетов из файла

Формат файлов картриджей с инструментами (обычно это *.syx) можно посмотреть здесь — https://github.com/asb2m10/dexed/blob/master/Documentation/sysex-format.txt

Параметры пресетов зависят от используемых в DX7 микросхем и поэтому выглядят ужасно. Например так описываются параметры ADSR-огибающей:

Огибающие в синтезаторах Yamaha DX7 отличаются от классических ADSR-огибающих, но не настолько сильно, как может показаться. Каждая из четырех точек имеет два редактируемых параметра: скорость и уровень, при этом скорость управляет временем, необходимым для перехода огибающей от одной точки к следующей, а уровень — громкостью этой точки.

Самое запутанное — это то, что регуляторы скорости инвертированы, поэтому на DX7 для увеличения времени огибающей необходимо опустить ползунок. Кроме того, уровень затухания также устанавливает начальный уровень. Причём повышение уровня проходит экспоненциально, а понижение — линейно.

В таком виде использовать пресеты неудобно. Выбросим малозначимые параметры, а остальные будем конвертировать в человеческий вид.

Структура данных каждого пресета в эмуляторе выглядит так:

type SynthPreset = {label: string; //название пресетаmixID: number; //номер схемы соединения операторовoperators: {   //массив на 6 операторовconstantFrequency: number; //или константная частотаfrequencyRatio: number;    //или делитель частоты играемой нотыdetune: number; //смещение частоты в полутонахvolume: number;envelope: { //огибающая ADSR, длительность и уровни громкостиattack: { duration: number; values: number[]; }; //атакаdecay: { duration: number; values: number[]; };  //спадsustain: { duration: number; values: number[]; };//основнойrelease: number; //время до полного затухания}; }[];feedbackRatio: number;  //сила модуляции в кольцевом соединенииmodulationRatio: number;//сила модуляции в обычном соединении};

как видно, параметров для такого разнообразного зучания синтезатора очень немного. Даже как-то странно.

Код парсинга двоичных файлов пресетов в структуру находится в https://github.com/surikov/webaudiopluginexample/blob/main/dx7/ui/src/loadersys.ts

Описание работы синтезатора

Код для воспроизведения звука в файле dx7synth.ts:

Скрытый текст
function newDX7FMSynth1(): MZXBX_AudioPerformerPlugin {console.log('create newDX7FMSynth1 v1.02');let matrixConnectionAlgorithmsDX7: ConnectionSchemeDX7[] = [{ outputMix: [0, 2], modulationMatrix: [[1], [], [3], [4], [5], []], feedbackMatrix: [[], [], [], [], [], [5]] }, { outputMix: [0, 2], modulationMatrix: [[1], [], [3], [4], [5], []], feedbackMatrix: [[], [1], [], [], [], []] }, { outputMix: [0, 3], modulationMatrix: [[1], [2], [], [4], [5], []], feedbackMatrix: [[], [], [], [], [], [5]] }, { outputMix: [0, 3], modulationMatrix: [[1], [2], [], [4], [5], []], feedbackMatrix: [[], [], [], [], [], [3]] }, { outputMix: [0, 2, 4], modulationMatrix: [[1], [], [3], [], [5], []], feedbackMatrix: [[], [], [], [], [], [5]] }, { outputMix: [0, 2, 4], modulationMatrix: [[1], [], [3], [], [5], []], feedbackMatrix: [[], [], [], [], [], [4]] }, { outputMix: [0, 2], modulationMatrix: [[1], [], [3, 4], [], [5], []], feedbackMatrix: [[], [], [], [], [], [5]] }, { outputMix: [0, 2], modulationMatrix: [[1], [], [3, 4], [], [5], []], feedbackMatrix: [[], [], [], [3], [], []] }, { outputMix: [0, 2], modulationMatrix: [[1], [], [3, 4], [], [5], []], feedbackMatrix: [[], [1], [], [], [], []] }, { outputMix: [0, 3], modulationMatrix: [[1], [2], [], [4, 5], [], []], feedbackMatrix: [[], [], [2], [], [], []] }, { outputMix: [0, 3], modulationMatrix: [[1], [2], [], [4, 5], [], []], feedbackMatrix: [[], [], [], [], [], [5]] }, { outputMix: [0, 2], modulationMatrix: [[1], [], [3, 4, 5], [], [], []], feedbackMatrix: [[], [1], [], [], [], []] }, { outputMix: [0, 2], modulationMatrix: [[1], [], [3, 4, 5], [], [], []], feedbackMatrix: [[], [], [], [], [], [5]] }, { outputMix: [0, 2], modulationMatrix: [[1], [], [3], [4, 5], [], []], feedbackMatrix: [[], [], [], [], [], [5]] }, { outputMix: [0, 2], modulationMatrix: [[1], [], [3], [4, 5], [], []], feedbackMatrix: [[], [1], [], [], [], []] }, { outputMix: [0], modulationMatrix: [[1, 2, 4], [], [3], [], [5], []], feedbackMatrix: [[], [], [], [], [], [5]] }, { outputMix: [0], modulationMatrix: [[1, 2, 4], [], [3], [], [5], []], feedbackMatrix: [[], [1], [], [], [], []] }, { outputMix: [0], modulationMatrix: [[1, 2, 3], [], [2], [], [5], []], feedbackMatrix: [[], [], [], [4], [], []] }, { outputMix: [0, 3, 4], modulationMatrix: [[1], [2], [], [5], [5], []], feedbackMatrix: [[], [], [], [], [], [5]] }, { outputMix: [0, 1, 3], modulationMatrix: [[2], [2], [], [4, 5], [], []], feedbackMatrix: [[], [], [2], [], [], []] }, { outputMix: [0, 1, 3, 4], modulationMatrix: [[2], [2], [], [5], [5], []], feedbackMatrix: [[], [], [2], [], [], []] }, { outputMix: [0, 2, 3, 4], modulationMatrix: [[1], [], [5], [5], [5], []], feedbackMatrix: [[], [], [], [], [], [5]] }, { outputMix: [0, 1, 3, 4], modulationMatrix: [[], [2], [], [5], [5], []], feedbackMatrix: [[], [], [], [], [], [5]] }, { outputMix: [0, 1, 2, 3, 4], modulationMatrix: [[], [], [5], [5], [5], []], feedbackMatrix: [[], [], [], [], [], [5]] }, { outputMix: [0, 1, 2, 3, 4], modulationMatrix: [[], [], [], [5], [5], []], feedbackMatrix: [[], [], [], [], [], [5]] }, { outputMix: [0, 1, 3], modulationMatrix: [[], [2], [], [4, 5], [], []], feedbackMatrix: [[], [], [], [], [], [5]] }, { outputMix: [0, 1, 3], modulationMatrix: [[], [2], [], [4, 5], [], []], feedbackMatrix: [[], [], [2], [], [], []] }, { outputMix: [0, 2, 5], modulationMatrix: [[1], [], [3], [4], [], []], feedbackMatrix: [[], [], [], [], [4], []] }, { outputMix: [0, 1, 2, 4], modulationMatrix: [[], [], [3], [], [5], []], feedbackMatrix: [[], [], [], [], [], [5]] }, { outputMix: [0, 1, 2, 5], modulationMatrix: [[], [], [3], [4], [], []], feedbackMatrix: [[], [], [], [], [4], []] }, { outputMix: [0, 1, 2, 3, 4], modulationMatrix: [[], [], [], [], [5], []], feedbackMatrix: [[], [], [], [], [], [5]] }, { outputMix: [0, 1, 2, 3, 4, 5], modulationMatrix: [[], [], [], [], [], []], feedbackMatrix: [[], [], [], [], [], [5]] }];class MiniumFMOperator {audioContext: AudioContext;operatorOut: GainNode;feedbackLevel: GainNode;phaseDelay: DelayNode;carrier: OscillatorNode;modulationLevel: GainNode;envelope: GainNode;constructor(cntxt: AudioContext) {this.audioContext = cntxt;this.operatorOut = this.audioContext.createGain();this.modulationLevel = this.audioContext.createGain();this.feedbackLevel = this.audioContext.createGain();this.envelope = this.audioContext.createGain();this.phaseDelay = this.audioContext.createDelay();this.carrier = this.audioContext.createOscillator();this.envelope.connect(this.operatorOut);this.phaseDelay.connect(this.envelope);this.modulationLevel.connect(this.phaseDelay.delayTime);this.feedbackLevel.connect(this.phaseDelay.delayTime);this.carrier.connect(this.phaseDelay);this.operatorOut.gain.value = 0;this.phaseDelay.delayTime.value = 0;this.envelope.gain.value = 0;this.carrier.start(this.audioContext.currentTime);}addFrequencySlide(when: number, frequency: number, modulationRatio: number, feedbackRatio: number) {this.carrier.frequency.linearRampToValueAtTime(frequency, when);this.modulationLevel.gain.linearRampToValueAtTime(modulationRatio / frequency, when);this.phaseDelay.delayTime.linearRampToValueAtTime(1.1 * modulationRatio / frequency, when);this.feedbackLevel.gain.linearRampToValueAtTime(feedbackRatio / frequency, when);}startPlayFrequency(volume: number, attack: SynthSlope, decay: SynthSlope, sustain: SynthSlope, release: number, when: number, duration: number, frequency: number, modulationRatio: number, feedbackRatio: number) {this.envelope.gain.cancelScheduledValues(this.audioContext.currentTime);this.envelope.gain.setValueAtTime(0, this.audioContext.currentTime);this.envelope.gain.setValueCurveAtTime(attack.values, when, attack.duration);this.envelope.gain.setValueCurveAtTime(decay.values, when + attack.duration, decay.duration);this.envelope.gain.setValueCurveAtTime(sustain.values, when + attack.duration + decay.duration, sustain.duration);this.envelope.gain.cancelAndHoldAtTime(when + duration);this.envelope.gain.linearRampToValueAtTime(0, when + duration + release);this.carrier.frequency.value = frequency;this.modulationLevel.gain.linearRampToValueAtTime(modulationRatio / frequency, when);this.phaseDelay.delayTime.linearRampToValueAtTime(1.1 * modulationRatio / frequency, when);this.feedbackLevel.gain.linearRampToValueAtTime(feedbackRatio / frequency, when);this.operatorOut.gain.value = volume;}cancelOperator() {this.operatorOut.disconnect();this.envelope.disconnect();this.phaseDelay.disconnect();this.modulationLevel.disconnect();this.feedbackLevel.disconnect();this.carrier.disconnect();this.carrier.stop();}}class MinumFMVoice {operators: MiniumFMOperator[];locktime: number = 0;audioContext: AudioContext;output: GainNode;mixID: number;constructor(mixID: number, audioContext: AudioContext, to: AudioNode) {this.mixID = mixID;this.audioContext = audioContext;this.output = this.audioContext.createGain();this.operators = [new MiniumFMOperator(this.audioContext), new MiniumFMOperator(this.audioContext), new MiniumFMOperator(this.audioContext), new MiniumFMOperator(this.audioContext), new MiniumFMOperator(this.audioContext), new MiniumFMOperator(this.audioContext)];this.connectOperators();this.output.connect(to);}connectOperators() {let mix = matrixConnectionAlgorithmsDX7[this.mixID - 1];for (let cid = 0; cid < mix.modulationMatrix.length; cid++) {let carrier = this.operators[cid];let modulatorIds = mix.modulationMatrix[cid];for (let mm = 0; mm < modulatorIds.length; mm++) {let mid = modulatorIds[mm];let modulator = this.operators[mid];modulator.operatorOut.connect(carrier.modulationLevel);}}for (let cid = 0; cid < mix.feedbackMatrix.length; cid++) {let carrier = this.operators[cid];let fbIds = mix.feedbackMatrix[cid];for (let ff = 0; ff < fbIds.length; ff++) {let fid = fbIds[ff];let fbmodulator = this.operators[fid];fbmodulator.operatorOut.connect(carrier.feedbackLevel);}}for (let ii = 0; ii < mix.outputMix.length; ii++) {let outIdx = mix.outputMix[ii];this.operators[outIdx].operatorOut.connect(this.output);}}startPlayNote(volume: number, preset: SynthPreset, when: number, note: number, slides: MZXBX_SlideItem[]) {this.output.gain.value = 0.33 * volume / 100;let duration = slides.reduce((sm, cur) => sm + cur.duration, 0);for (let ii = 0; ii < 6; ii++) {let info = preset.operators[ii];if (info.enabled) {let frequency = info.constantFrequency;if (!(frequency)) {let noteFreq = 440 * Math.pow(2, (note - 69) / 12);let detuneRatio = Math.pow(Math.exp(Math.log(2) / 1024), info.detune);frequency = noteFreq * detuneRatio * info.frequencyRatio;if (preset.transpose > 0) frequency = frequency * 2;if (preset.transpose < 0) frequency = frequency * 0.5;}this.operators[ii].startPlayFrequency(info.volume, info.envelope.attack, info.envelope.decay, info.envelope.sustain, info.envelope.release, when, duration, frequency, preset.modulationRatio, preset.feedbackRatio);//, slides);let otime = when + duration + info.envelope.release + 0.01;if (this.locktime < otime) {this.locktime = otime;}if (info.frequencyRatio && (slides.length > 1 || slides[0].delta != 0)) {let next = when;for (let ff = 0; ff < slides.length; ff++) {next = next + slides[ff].duration;let noteFreq = 440 * Math.pow(2, (note + slides[ff].delta - 69) / 12);let detuneRatio = Math.pow(Math.exp(Math.log(2) / 1024), info.detune);let slideFreq = noteFreq * detuneRatio * info.frequencyRatio;this.operators[ii].addFrequencySlide(next, slideFreq, preset.modulationRatio, preset.feedbackRatio);}}}}}cancelVoice() {for (let ii = 0; ii < this.operators.length; ii++) {this.operators[ii].cancelOperator();}this.output.disconnect();}}class MiniumFMSynth {cache: MinumFMVoice[] = [];audioContext: AudioContext;mixOutput: GainNode;constructor() {}init(audioContext: AudioContext) {this.audioContext = audioContext;this.mixOutput = this.audioContext.createGain();}takeVox(mxid: number): MinumFMVoice {for (let ii = 0; ii < this.cache.length; ii++) {if (this.cache[ii].locktime < this.audioContext.currentTime && mxid == this.cache[ii].mixID) {return this.cache[ii];}}let vx: MinumFMVoice = new MinumFMVoice(mxid, this.audioContext, this.mixOutput);this.cache.push(vx);return vx;}cancelSynth() {for (let ii = 0; ii < this.cache.length; ii++) {this.cache[ii].cancelVoice();}this.cache = [];}scheduleStrum(volume: number, preset: SynthPreset, when: number, pitches: number[], slides: MZXBX_SlideItem[]) {if (when > this.audioContext.currentTime + 0.05) {for (let ii = 0; ii < pitches.length; ii++) {let vox = this.takeVox(preset.mixID);vox.startPlayNote(volume, preset, when, pitches[ii], slides);}} else {console.log(when, 'is too late for', this.audioContext.currentTime);}}}class MiniumPluginDX7Bridge implements MZXBX_AudioPerformerPlugin {synth: MiniumFMSynth | null = null;fm: FMParameter | null = null;launch(context: AudioContext, parameters: string): number {if (this.synth) {//} else {this.synth = new MiniumFMSynth();this.synth.init(context);}this.fm = (parameters as any) as FMParameter;return 1;}busy(): null | string {return null;}cancel(): void {if (this.synth) {this.synth.cancelSynth();}}output(): AudioNode | null {if (this.synth) {return this.synth.mixOutput;} else {return null;}}strum(whenStart: number, zpitches: number[], tempo: number, mzbxslide: MZXBX_SlideItem[]): void {if (this.synth) {if (this.fm) {this.synth.scheduleStrum(this.fm.volume, this.fm.preset, whenStart, zpitches, mzbxslide);}}}}return new MiniumPluginDX7Bridge();}

Как это работает

  • основной класс это MiniumFMSynth

  • при вызове его метода scheduleStrum он создаёт (или берёт из кеша) экземпляр класса MinumFMVoice и вызывает его метод startPlayNote

  • отдельный голос в методе connectOperators соединяет операторы MiniumFMOperator по указанной схеме

  • каждый оператор в методе startPlayFrequency выставляет частоту, громкость, уровень модуляции, значения громкости для ADSR-огибающей и запускает осциллятор

Дополнительно: кеш нужен т.к. в обычной 3-минутной песне тысячи нот. Браузер просто начнёт тормозить если для каждого звука создавать новый аудио-узел. Кроме того, при пересоединении аудио-узлов в кольцо браузер может крашится.

В массиве matrixConnectionAlgorithmsDX7 лежат схемы всех 32-х соединений операторов (осцилляторов).

Например схема 13 с картинки на корпусе синтезатора:

mix

mix

в массиве соответствует строке:

, { outputMix: [0, 2], modulationMatrix: [[1], [], [3, 4, 5], [], [], []], feedbackMatrix: [[], [], [], [], [], [5]] }

индексация начинается с 0, а не с 1 как на картинке

  • на аудиовыход подключаются операторы 1 и 3

  • на вход оператора 1 подключается оператор 2, на вход оператора 3 подключаются операторы 4, 5 и 6

  • оператор 6 подключается ещё и на собственный вход

Вот, вобщем-то и всё. Весь код отвечающий за звук убирается в 300 строк.

UI

В качестве UI у нас будет простой список заранее спрарсенных пресетов с кнопкой Import для загрузки из файла. Добавим немного ретро-стиля для красоты:

UI

UI

При желании можно задать свой пресет текстом и импортировать его. Пример пресетов лежит папке https://github.com/surikov/webaudiopluginexample/tree/main/dx7/dx7aux в файле miniumPluginPreset.json

Заключение

Мы рассмотрели алгоритм FM-синтеза в браузере на примере эмулятора известного синтезатора Yamaha DX7

ссылка на оригинал статьи https://habr.com/ru/articles/1078826/