From 8c87edbdfdfc4f8f80c99d40914cf66b1c954181 Mon Sep 17 00:00:00 2001 From: ryohey Date: Tue, 19 Sep 2023 10:04:02 +0900 Subject: [PATCH 01/12] Use Float32Array instead of AudioContext.createBuffer --- example/src/index.ts | 25 ++++++++++++------------- lib/src/soundfont/loader.ts | 12 ++---------- 2 files changed, 14 insertions(+), 23 deletions(-) diff --git a/example/src/index.ts b/example/src/index.ts index ce23bad..6cdb3cb 100644 --- a/example/src/index.ts +++ b/example/src/index.ts @@ -44,14 +44,19 @@ const main = async () => { } const loadSoundFont = async () => { + let startDate = Date.now() + console.log("Loading soundfont...") soundFontData = await (await fetch(soundFontUrl)).arrayBuffer() + console.log( + `Soundfont loaded. (${Date.now() - startDate}ms, ${ + soundFontData.byteLength + } bytes)` + ) + startDate = Date.now() console.log("Parsing soundfont...") - const parsed = getSamplesFromSoundFont( - new Uint8Array(soundFontData), - context - ) - console.log("Soundfont parsed.") + const parsed = getSamplesFromSoundFont(new Uint8Array(soundFontData)) + console.log(`Soundfont parsed. (${Date.now() - startDate}ms)`) for (const sample of parsed) { postSynthMessage( @@ -157,10 +162,7 @@ const main = async () => { if (soundFontData === null) { return } - const samples = getSamplesFromSoundFont( - new Uint8Array(soundFontData), - context - ) + const samples = getSamplesFromSoundFont(new Uint8Array(soundFontData)) const sampleRate = 44100 const events = midiToSynthEvents(midi, sampleRate) @@ -195,10 +197,7 @@ const main = async () => { return } const worker = new Worker("/js/rendererWorker.js") - const samples = getSamplesFromSoundFont( - new Uint8Array(soundFontData), - context - ) + const samples = getSamplesFromSoundFont(new Uint8Array(soundFontData)) const sampleRate = 44100 const events = midiToSynthEvents(midi, sampleRate) const message: StartMessage = { diff --git a/lib/src/soundfont/loader.ts b/lib/src/soundfont/loader.ts index ca4c16d..469a4d5 100644 --- a/lib/src/soundfont/loader.ts +++ b/lib/src/soundfont/loader.ts @@ -25,10 +25,7 @@ export interface BufferCreator { ): AudioBuffer } -export const getSamplesFromSoundFont = ( - data: Uint8Array, - ctx: BufferCreator -) => { +export const getSamplesFromSoundFont = (data: Uint8Array) => { const parsed = parse(data) const result: SoundFontSample[] = [] @@ -103,13 +100,8 @@ export const getSamplesFromSoundFont = ( gen.endloopAddrsOffset const sample2 = sample.subarray(0, sample.length + sampleEnd) + const audioData = new Float32Array(sample2.length) - const audioBuffer = ctx.createBuffer( - 1, - sample2.length, - sampleHeader.sampleRate - ) - const audioData = audioBuffer.getChannelData(0) sample2.forEach((v, i) => { audioData[i] = v / 32767 }) From 0fcf3d522f34860acec8b956585afdc06ca3f9ee Mon Sep 17 00:00:00 2001 From: ryohey Date: Tue, 19 Sep 2023 10:23:24 +0900 Subject: [PATCH 02/12] Fix sample buffer length --- lib/src/soundfont/loader.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/src/soundfont/loader.ts b/lib/src/soundfont/loader.ts index 469a4d5..63b7b4a 100644 --- a/lib/src/soundfont/loader.ts +++ b/lib/src/soundfont/loader.ts @@ -99,7 +99,7 @@ export const getSamplesFromSoundFont = (data: Uint8Array) => { gen.endloopAddrsCoarseOffset * 32768 + gen.endloopAddrsOffset - const sample2 = sample.subarray(0, sample.length + sampleEnd) + const sample2 = sample.subarray(0, sample.length) const audioData = new Float32Array(sample2.length) sample2.forEach((v, i) => { From 475f9f83dd28b8bb4b540f71500027275da23633 Mon Sep 17 00:00:00 2001 From: ryohey Date: Tue, 19 Sep 2023 10:28:26 +0900 Subject: [PATCH 03/12] Performance improvement --- lib/src/soundfont/loader.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/lib/src/soundfont/loader.ts b/lib/src/soundfont/loader.ts index 63b7b4a..2a4e637 100644 --- a/lib/src/soundfont/loader.ts +++ b/lib/src/soundfont/loader.ts @@ -99,12 +99,11 @@ export const getSamplesFromSoundFont = (data: Uint8Array) => { gen.endloopAddrsCoarseOffset * 32768 + gen.endloopAddrsOffset - const sample2 = sample.subarray(0, sample.length) - const audioData = new Float32Array(sample2.length) + const audioData = new Float32Array(sample.length) - sample2.forEach((v, i) => { - audioData[i] = v / 32767 - }) + for (let i = 0; i < sample.length; i++) { + audioData[i] = sample[i] / 32767 + } const amplitudeEnvelope = { attackTime: timeCentToSec(gen.attackVolEnv), From 7e4af20f62f0499eded91d1af551174ade80542d Mon Sep 17 00:00:00 2001 From: ryohey Date: Wed, 20 Sep 2023 11:16:39 +0900 Subject: [PATCH 04/12] Load sample data and parameters separately --- example/src/index.ts | 46 +++++++----- lib/src/SynthEvent.ts | 22 ++++-- lib/src/processor/SampleTable.ts | 66 +++++++++++------ lib/src/processor/SynthEventHandler.ts | 13 ++-- lib/src/processor/SynthProcessorCore.ts | 24 +++---- lib/src/renderer/message.ts | 8 ++- lib/src/renderer/renderAudio.ts | 9 ++- lib/src/soundfont/loader.ts | 96 ++++++++++++++++++------- lib/src/soundfont/sampleToSynthEvent.ts | 12 ---- 9 files changed, 183 insertions(+), 113 deletions(-) delete mode 100644 lib/src/soundfont/sampleToSynthEvent.ts diff --git a/example/src/index.ts b/example/src/index.ts index 6cdb3cb..47f1795 100644 --- a/example/src/index.ts +++ b/example/src/index.ts @@ -2,7 +2,7 @@ import { AudioData, audioDataToAudioBuffer, CancelMessage, - getSamplesFromSoundFont, + getSampleEventsFromSoundFont, OutMessage, renderAudio, StartMessage, @@ -13,7 +13,8 @@ import { encode } from "wav-encoder" import { MIDIPlayer } from "./MIDIPlayer" import { midiToSynthEvents } from "./midiToSynthEvents" -const soundFontUrl = "soundfonts/A320U.sf2" +// const soundFontUrl = "soundfonts/A320U.sf2" +const soundFontUrl = "soundfonts/SGM-V2.01.sf2" const Sleep = (time: number) => new Promise((resolve) => setTimeout(resolve, time)) @@ -55,14 +56,13 @@ const main = async () => { startDate = Date.now() console.log("Parsing soundfont...") - const parsed = getSamplesFromSoundFont(new Uint8Array(soundFontData)) + const sampleEvents = getSampleEventsFromSoundFont( + new Uint8Array(soundFontData) + ) console.log(`Soundfont parsed. (${Date.now() - startDate}ms)`) - for (const sample of parsed) { - postSynthMessage( - sample, - [sample.sample.buffer] // transfer instead of copy - ) + for (const event of sampleEvents) { + postSynthMessage(event.event, event.transfer) } } @@ -162,7 +162,9 @@ const main = async () => { if (soundFontData === null) { return } - const samples = getSamplesFromSoundFont(new Uint8Array(soundFontData)) + const sampleEvents = getSampleEventsFromSoundFont( + new Uint8Array(soundFontData) + ) const sampleRate = 44100 const events = midiToSynthEvents(midi, sampleRate) @@ -177,14 +179,18 @@ const main = async () => { cancelButton.onclick = () => (cancel = true) exportPanel.appendChild(cancelButton) - const result = await renderAudio(samples, events, { - sampleRate, - bufferSize: 256, - cancel: () => cancel, - waitForEventLoop: waitForAnimationFrame, - onProgress: (numFrames, totalFrames) => - (progress.value = numFrames / totalFrames), - }) + const result = await renderAudio( + sampleEvents.map((event) => event.event), + events, + { + sampleRate, + bufferSize: 256, + cancel: () => cancel, + waitForEventLoop: waitForAnimationFrame, + onProgress: (numFrames, totalFrames) => + (progress.value = numFrames / totalFrames), + } + ) cancelButton.remove() @@ -197,12 +203,14 @@ const main = async () => { return } const worker = new Worker("/js/rendererWorker.js") - const samples = getSamplesFromSoundFont(new Uint8Array(soundFontData)) + const sampleEvents = getSampleEventsFromSoundFont( + new Uint8Array(soundFontData) + ) const sampleRate = 44100 const events = midiToSynthEvents(midi, sampleRate) const message: StartMessage = { type: "start", - samples, + samples: sampleEvents.map((e) => e.event), events, sampleRate, bufferSize: 128, diff --git a/lib/src/SynthEvent.ts b/lib/src/SynthEvent.ts index 0616729..f8854a6 100644 --- a/lib/src/SynthEvent.ts +++ b/lib/src/SynthEvent.ts @@ -7,9 +7,9 @@ export interface SampleLoop { end: number } -export interface SampleData { +export interface SampleParameter { name: string - buffer: BufferType + sampleID: number pitch: number loop: SampleLoop | null sampleStart: number @@ -25,15 +25,25 @@ export interface SampleData { volume: number // 0 to 1 } -export interface LoadSampleEvent { - type: "loadSample" - sample: SampleData +export interface SampleRange { bank: number instrument: number // GM Patch Number keyRange: [number, number] velRange: [number, number] } +export interface LoadSampleEvent { + type: "loadSample" + data: ArrayBuffer + sampleID: number +} + +export interface SampleParameterEvent { + type: "sampleParameter" + parameter: SampleParameter + range: SampleRange +} + export type MIDIEventBody = DistributiveOmit export type MIDIEvent = { @@ -44,7 +54,7 @@ export type MIDIEvent = { delayTime: number } -export type ImmediateEvent = LoadSampleEvent +export type ImmediateEvent = LoadSampleEvent | SampleParameterEvent export type SynthEvent = ImmediateEvent | MIDIEvent export const DrumInstrumentNumber = 128 diff --git a/lib/src/processor/SampleTable.ts b/lib/src/processor/SampleTable.ts index 4e8db25..9316247 100644 --- a/lib/src/processor/SampleTable.ts +++ b/lib/src/processor/SampleTable.ts @@ -1,36 +1,44 @@ -import { SampleData } from "../SynthEvent" +import { SampleParameter, SampleRange } from "../SynthEvent" -type Sample = SampleData - -export type SampleTableItem = Sample & { +export type SampleTableItem = SampleParameter & { velRange: [number, number] } +export type Sample = SampleParameter & { + buffer: ArrayBuffer +} + export class SampleTable { private samples: { + [sampleID: number]: Float32Array + } = {} + + private sampleParameters: { [bank: number]: { [instrument: number]: { [pitch: number]: SampleTableItem[] } } } = {} - addSample( - sample: Sample, - bank: number, - instrument: number, - keyRange: [number, number], - velRange: [number, number] - ) { + addSample(data: Float32Array, sampleID: number) { + this.samples[sampleID] = data + } + + addSampleParameter(parameter: SampleParameter, range: SampleRange) { + const { bank, instrument, keyRange, velRange } = range for (let i = keyRange[0]; i <= keyRange[1]; i++) { - if (this.samples[bank] === undefined) { - this.samples[bank] = {} + if (this.sampleParameters[bank] === undefined) { + this.sampleParameters[bank] = {} } - if (this.samples[bank][instrument] === undefined) { - this.samples[bank][instrument] = {} + if (this.sampleParameters[bank][instrument] === undefined) { + this.sampleParameters[bank][instrument] = {} } - if (this.samples[bank][instrument][i] === undefined) { - this.samples[bank][instrument][i] = [] + if (this.sampleParameters[bank][instrument][i] === undefined) { + this.sampleParameters[bank][instrument][i] = [] } - this.samples[bank][instrument][i].push({ ...sample, velRange }) + this.sampleParameters[bank][instrument][i].push({ + ...parameter, + velRange, + }) } } @@ -40,11 +48,25 @@ export class SampleTable { pitch: number, velocity: number ): Sample[] { - const samples = this.samples?.[bank]?.[instrument]?.[pitch] - return ( - samples?.filter( + const parameters = + this.sampleParameters?.[bank]?.[instrument]?.[pitch]?.filter( (s) => velocity >= s.velRange[0] && velocity <= s.velRange[1] ) ?? [] - ) + + const samples: Sample[] = [] + + for (const parameter of parameters) { + const buffer = this.samples[parameter.sampleID] + if (buffer === undefined) { + console.warn(`sample not found: ${parameter.sampleID}`) + continue + } + samples.push({ + ...parameter, + buffer, + }) + } + + return samples } } diff --git a/lib/src/processor/SynthEventHandler.ts b/lib/src/processor/SynthEventHandler.ts index 2995042..f3ece01 100644 --- a/lib/src/processor/SynthEventHandler.ts +++ b/lib/src/processor/SynthEventHandler.ts @@ -6,9 +6,9 @@ import { SynthEvent, } from "../SynthEvent" import { DistributiveOmit } from "../types" +import { SynthProcessorCore } from "./SynthProcessorCore" import { insertSorted } from "./insertSorted" import { logger } from "./logger" -import { SynthProcessorCore } from "./SynthProcessorCore" type DelayedEvent = MIDIEvent & { scheduledFrame: number } type RPNControllerEvent = DistributiveOmit @@ -80,14 +80,11 @@ export class SynthEventHandler { handleImmediateEvent(e: ImmediateEvent) { switch (e.type) { + case "sampleParameter": + this.processor.addSampleParameter(e.parameter, e.range) + break case "loadSample": - this.processor.loadSample( - e.sample, - e.bank, - e.instrument, - e.keyRange, - e.velRange - ) + this.processor.addSample(e.data, e.sampleID) break } } diff --git a/lib/src/processor/SynthProcessorCore.ts b/lib/src/processor/SynthProcessorCore.ts index 587346b..df3801e 100644 --- a/lib/src/processor/SynthProcessorCore.ts +++ b/lib/src/processor/SynthProcessorCore.ts @@ -1,6 +1,6 @@ -import { SampleData, SynthEvent } from "../SynthEvent" +import { SampleParameter, SampleRange, SynthEvent } from "../SynthEvent" import { logger } from "./logger" -import { SampleTable } from "./SampleTable" +import { Sample, SampleTable } from "./SampleTable" import { SynthEventHandler } from "./SynthEventHandler" import { WavetableOscillator } from "./WavetableOscillator" @@ -33,8 +33,6 @@ const initialChannelState = (): ChannelState => ({ const RHYTHM_CHANNEL = 9 const RHYTHM_BANK = 128 -type Sample = SampleData - export class SynthProcessorCore { private sampleTable = new SampleTable() private channels: { [key: number]: ChannelState } = {} @@ -63,18 +61,12 @@ export class SynthProcessorCore { return this.sampleTable.getSamples(bank, state.instrument, pitch, velocity) } - loadSample( - sample: SampleData, - bank: number, - instrument: number, - keyRange: [number, number], - velRange: [number, number] - ) { - const _sample: Sample = { - ...sample, - buffer: new Float32Array(sample.buffer), - } - this.sampleTable.addSample(_sample, bank, instrument, keyRange, velRange) + addSample(data: ArrayBuffer, sampleID: number) { + this.sampleTable.addSample(new Float32Array(data), sampleID) + } + + addSampleParameter(parameter: SampleParameter, range: SampleRange) { + this.sampleTable.addSampleParameter(parameter, range) } addEvent(e: SynthEvent) { diff --git a/lib/src/renderer/message.ts b/lib/src/renderer/message.ts index a12e9a2..942ac01 100644 --- a/lib/src/renderer/message.ts +++ b/lib/src/renderer/message.ts @@ -1,11 +1,15 @@ -import { LoadSampleEvent, SynthEvent } from "../SynthEvent" +import { + LoadSampleEvent, + SampleParameterEvent, + SynthEvent, +} from "../SynthEvent" export type InMessage = StartMessage | CancelMessage export type OutMessage = ProgressMessage | CompleteMessage export interface StartMessage { type: "start" - samples: LoadSampleEvent[] + samples: (LoadSampleEvent | SampleParameterEvent)[] events: SynthEvent[] sampleRate: number bufferSize?: number diff --git a/lib/src/renderer/renderAudio.ts b/lib/src/renderer/renderAudio.ts index f6746bf..3e21423 100644 --- a/lib/src/renderer/renderAudio.ts +++ b/lib/src/renderer/renderAudio.ts @@ -1,4 +1,9 @@ -import { AudioData, LoadSampleEvent, SynthEvent } from ".." +import { + AudioData, + LoadSampleEvent, + SampleParameterEvent, + SynthEvent, +} from ".." import { SynthProcessorCore } from "../processor/SynthProcessorCore" // returns in frame unit @@ -26,7 +31,7 @@ const isArrayZero = (arr: ArrayLike) => { } export const renderAudio = async ( - samples: LoadSampleEvent[], + samples: (LoadSampleEvent | SampleParameterEvent)[], events: SynthEvent[], options?: RenderAudioOptions ): Promise => { diff --git a/lib/src/soundfont/loader.ts b/lib/src/soundfont/loader.ts index 2a4e637..dde132c 100644 --- a/lib/src/soundfont/loader.ts +++ b/lib/src/soundfont/loader.ts @@ -6,16 +6,13 @@ import { getPresetGenerators, parse, } from "@ryohey/sf2parser" -import { SampleData } from "../SynthEvent" +import { + LoadSampleEvent, + SampleParameter, + SampleParameterEvent, + SampleRange, +} from "../SynthEvent" import { getPresetZones } from "./getPresetZones" -import { sampleToSynthEvent } from "./sampleToSynthEvent" - -export type SoundFontSample = SampleData & { - bank: number - instrument: number - keyRange: [number, number] - velRange: [number, number] -} export interface BufferCreator { createBuffer( @@ -25,9 +22,26 @@ export interface BufferCreator { ): AudioBuffer } -export const getSamplesFromSoundFont = (data: Uint8Array) => { +const parseSamplesFromSoundFont = (data: Uint8Array) => { const parsed = parse(data) - const result: SoundFontSample[] = [] + const result: { parameter: SampleParameter; range: SampleRange }[] = [] + const convertedSampleBuffers: { [key: number]: Float32Array } = {} + + function addSampleIfNeeded(sampleID: number) { + const cached = convertedSampleBuffers[sampleID] + if (cached) { + return cached + } + + const sample = parsed.samples[sampleID] + const audioData = new Float32Array(sample.length) + for (let i = 0; i < sample.length; i++) { + audioData[i] = sample[i] / 32767 + } + + convertedSampleBuffers[sampleID] = audioData + return audioData + } for (let i = 0; i < parsed.presetHeaders.length; i++) { const presetHeader = parsed.presetHeaders[i] @@ -51,8 +65,8 @@ export const getSamplesFromSoundFont = (data: Uint8Array) => { for (const zone of instrumentZones.filter( (zone) => zone.sampleID !== undefined )) { - const sample = parsed.samples[zone.sampleID!] - const sampleHeader = parsed.sampleHeaders[zone.sampleID!] + const sampleID = zone.sampleID! + const sampleHeader = parsed.sampleHeaders[sampleID] const { velRange: defaultVelRange, ...generatorDefault } = defaultInstrumentZone @@ -99,11 +113,7 @@ export const getSamplesFromSoundFont = (data: Uint8Array) => { gen.endloopAddrsCoarseOffset * 32768 + gen.endloopAddrsOffset - const audioData = new Float32Array(sample.length) - - for (let i = 0; i < sample.length; i++) { - audioData[i] = sample[i] / 32767 - } + const audioData = addSampleIfNeeded(sampleID) const amplitudeEnvelope = { attackTime: timeCentToSec(gen.attackVolEnv), @@ -112,8 +122,8 @@ export const getSamplesFromSoundFont = (data: Uint8Array) => { releaseTime: timeCentToSec(gen.releaseVolEnv) / 4, } - result.push({ - buffer: audioData.buffer, + const parameter: SampleParameter = { + sampleID: sampleID, pitch: -basePitch, name: sampleHeader.sampleName, sampleStart, @@ -125,22 +135,56 @@ export const getSamplesFromSoundFont = (data: Uint8Array) => { end: loopEnd, } : null, - instrument: presetHeader.preset, - bank: presetHeader.bank, - keyRange: [gen.keyRange.lo, gen.keyRange.hi], - velRange: [gen.velRange.lo, gen.velRange.hi], sampleRate: sampleHeader.sampleRate, amplitudeEnvelope, scaleTuning: gen.scaleTuning / 100, pan: (gen.pan ?? 0) / 500, exclusiveClass: gen.exclusiveClass, volume: 1 - gen.initialAttenuation / 1000, - }) + } + + const range: SampleRange = { + instrument: presetHeader.preset, + bank: presetHeader.bank, + keyRange: [gen.keyRange.lo, gen.keyRange.hi], + velRange: [gen.velRange.lo, gen.velRange.hi], + } + + result.push({ parameter, range }) } } } - return result.map(sampleToSynthEvent) + return { + parameters: result, + samples: convertedSampleBuffers, + } +} + +export const getSampleEventsFromSoundFont = ( + data: Uint8Array +): { + event: LoadSampleEvent | SampleParameterEvent + transfer?: Transferable[] +}[] => { + const { samples, parameters } = parseSamplesFromSoundFont(data) + + const loadSampleEvents: LoadSampleEvent[] = Object.entries(samples).map( + ([key, value]) => ({ + type: "loadSample", + sampleID: Number(key), + data: value.buffer, + }) + ) + + const sampleParameterEvents: SampleParameterEvent[] = parameters.map( + ({ parameter, range }) => ({ type: "sampleParameter", parameter, range }) + ) + + return [ + ...loadSampleEvents.map((event) => ({ event, transfer: [event.data] })), + ...sampleParameterEvents.map((event) => ({ event })), + ] } function convertTime(value: number) { diff --git a/lib/src/soundfont/sampleToSynthEvent.ts b/lib/src/soundfont/sampleToSynthEvent.ts deleted file mode 100644 index c6a00eb..0000000 --- a/lib/src/soundfont/sampleToSynthEvent.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { LoadSampleEvent, SoundFontSample } from ".." - -export const sampleToSynthEvent = ( - sample: SoundFontSample -): LoadSampleEvent => ({ - type: "loadSample", - sample, - bank: sample.bank, - instrument: sample.instrument, - keyRange: sample.keyRange, - velRange: sample.velRange, -}) From cacdd89aef03cb1f2def53f0690be3864219d1b5 Mon Sep 17 00:00:00 2001 From: ryohey Date: Thu, 21 Sep 2023 08:53:41 +0900 Subject: [PATCH 05/12] Separate SynthEventHandler into two classes --- lib/src/processor/SynthEventHandler.ts | 73 +--------------------- lib/src/processor/SynthEventScheduler.ts | 77 ++++++++++++++++++++++++ lib/src/processor/SynthProcessorCore.ts | 24 +++++--- 3 files changed, 95 insertions(+), 79 deletions(-) create mode 100644 lib/src/processor/SynthEventScheduler.ts diff --git a/lib/src/processor/SynthEventHandler.ts b/lib/src/processor/SynthEventHandler.ts index f3ece01..d0fce7c 100644 --- a/lib/src/processor/SynthEventHandler.ts +++ b/lib/src/processor/SynthEventHandler.ts @@ -1,16 +1,9 @@ import { ControllerEvent, MIDIControlEvents } from "midifile-ts" -import { - ImmediateEvent, - MIDIEvent, - MIDIEventBody, - SynthEvent, -} from "../SynthEvent" +import { ImmediateEvent, MIDIEventBody } from "../SynthEvent" import { DistributiveOmit } from "../types" import { SynthProcessorCore } from "./SynthProcessorCore" -import { insertSorted } from "./insertSorted" import { logger } from "./logger" -type DelayedEvent = MIDIEvent & { scheduledFrame: number } type RPNControllerEvent = DistributiveOmit interface RPN { @@ -21,62 +14,10 @@ interface RPN { } export class SynthEventHandler { - private processor: SynthProcessorCore - private scheduledEvents: DelayedEvent[] = [] - private currentEvents: DelayedEvent[] = [] private rpnEvents: { [channel: number]: RPN | undefined } = {} private bankSelectMSB: { [channel: number]: number | undefined } = {} - constructor(processor: SynthProcessorCore) { - this.processor = processor - } - - private get currentFrame(): number { - return this.processor.currentFrame - } - - addEvent(e: SynthEvent) { - logger.log(e) - - if ("delayTime" in e) { - // handle in process - insertSorted( - this.scheduledEvents, - { - ...e, - scheduledFrame: this.currentFrame + e.delayTime, - }, - "scheduledFrame" - ) - } else { - this.handleImmediateEvent(e) - } - } - - processScheduledEvents() { - if (this.scheduledEvents.length === 0) { - return - } - - while (true) { - const e = this.scheduledEvents[0] - if (e === undefined || e.scheduledFrame > this.currentFrame) { - // scheduledEvents are sorted by scheduledFrame, - // so we can break early instead of iterating through all scheduledEvents, - break - } - this.scheduledEvents.shift() - this.currentEvents.push(e) - } - - while (true) { - const e = this.currentEvents.pop() - if (e === undefined) { - break - } - this.handleDelayableEvent(e.midi) - } - } + constructor(private readonly processor: SynthProcessorCore) {} handleImmediateEvent(e: ImmediateEvent) { switch (e.type) { @@ -168,7 +109,6 @@ export class SynthEventHandler { this.processor.expression(e.channel, e.value) break case MIDIControlEvents.ALL_SOUNDS_OFF: - this.removeScheduledEvents(e.channel) this.processor.allSoundsOff(e.channel) break case MIDIControlEvents.ALL_NOTES_OFF: @@ -205,13 +145,4 @@ export class SynthEventHandler { } } } - - private removeScheduledEvents(channel: number) { - this.scheduledEvents = this.scheduledEvents.filter( - (e) => e.midi.channel !== channel - ) - this.currentEvents = this.currentEvents.filter( - (e) => e.midi.channel !== channel - ) - } } diff --git a/lib/src/processor/SynthEventScheduler.ts b/lib/src/processor/SynthEventScheduler.ts new file mode 100644 index 0000000..9949516 --- /dev/null +++ b/lib/src/processor/SynthEventScheduler.ts @@ -0,0 +1,77 @@ +import { + ImmediateEvent, + MIDIEvent, + MIDIEventBody, + SynthEvent, +} from "../SynthEvent" +import { insertSorted } from "./insertSorted" +import { logger } from "./logger" + +type DelayedEvent = MIDIEvent & { scheduledFrame: number } + +export class SynthEventScheduler { + private scheduledEvents: DelayedEvent[] = [] + private currentEvents: DelayedEvent[] = [] + + constructor( + private readonly getCurrentFrame: () => number, + private readonly onImmediateEvent: (e: ImmediateEvent) => void, + private readonly onDelayableEvent: (e: MIDIEventBody) => void + ) {} + + private get currentFrame(): number { + return this.getCurrentFrame() + } + + addEvent(e: SynthEvent) { + logger.log(e) + + if ("delayTime" in e) { + // handle in process + insertSorted( + this.scheduledEvents, + { + ...e, + scheduledFrame: this.currentFrame + e.delayTime, + }, + "scheduledFrame" + ) + } else { + this.onImmediateEvent(e) + } + } + + processScheduledEvents() { + if (this.scheduledEvents.length === 0) { + return + } + + while (true) { + const e = this.scheduledEvents[0] + if (e === undefined || e.scheduledFrame > this.currentFrame) { + // scheduledEvents are sorted by scheduledFrame, + // so we can break early instead of iterating through all scheduledEvents, + break + } + this.scheduledEvents.shift() + this.currentEvents.push(e) + } + + while (true) { + const e = this.currentEvents.pop() + if (e === undefined) { + break + } + this.onDelayableEvent(e.midi) + } + } + + removeScheduledEvents(channel: number) { + this.scheduledEvents = this.scheduledEvents.filter( + (e) => e.midi.channel !== channel + ) + this.currentEvents = this.currentEvents.filter( + (e) => e.midi.channel !== channel + ) + } +} diff --git a/lib/src/processor/SynthProcessorCore.ts b/lib/src/processor/SynthProcessorCore.ts index df3801e..62b77f0 100644 --- a/lib/src/processor/SynthProcessorCore.ts +++ b/lib/src/processor/SynthProcessorCore.ts @@ -2,6 +2,7 @@ import { SampleParameter, SampleRange, SynthEvent } from "../SynthEvent" import { logger } from "./logger" import { Sample, SampleTable } from "./SampleTable" import { SynthEventHandler } from "./SynthEventHandler" +import { SynthEventScheduler } from "./SynthEventScheduler" import { WavetableOscillator } from "./WavetableOscillator" interface ChannelState { @@ -36,12 +37,18 @@ const RHYTHM_BANK = 128 export class SynthProcessorCore { private sampleTable = new SampleTable() private channels: { [key: number]: ChannelState } = {} - private readonly eventHandler: SynthEventHandler - private readonly sampleRate: number - private readonly getCurrentFrame: () => number - - constructor(sampleRate: number, getCurrentFrame: () => number) { - this.eventHandler = new SynthEventHandler(this) + private readonly eventScheduler: SynthEventScheduler + + constructor( + private readonly sampleRate: number, + private readonly getCurrentFrame: () => number + ) { + const eventHandler = new SynthEventHandler(this) + this.eventScheduler = new SynthEventScheduler( + getCurrentFrame, + (e) => eventHandler.handleImmediateEvent(e), + (e) => eventHandler.handleDelayableEvent(e) + ) this.sampleRate = sampleRate this.getCurrentFrame = getCurrentFrame } @@ -70,7 +77,7 @@ export class SynthProcessorCore { } addEvent(e: SynthEvent) { - this.eventHandler.addEvent(e) + this.eventScheduler.addEvent(e) } noteOn(channel: number, pitch: number, velocity: number) { @@ -153,6 +160,7 @@ export class SynthProcessorCore { } allSoundsOff(channel: number) { + this.eventScheduler.removeScheduledEvents(channel) const state = this.getChannelState(channel) for (const key in state.oscillators) { @@ -220,7 +228,7 @@ export class SynthProcessorCore { } process(outputs: Float32Array[]): void { - this.eventHandler.processScheduledEvents() + this.eventScheduler.processScheduledEvents() for (const channel in this.channels) { const state = this.channels[channel] From 14c34a830469a1a6a63d1a0b2e0cca368f0ab7aa Mon Sep 17 00:00:00 2001 From: ryohey Date: Thu, 21 Sep 2023 09:12:29 +0900 Subject: [PATCH 06/12] Fix test configuration --- jest.config.js => lib/jest.config.js | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename jest.config.js => lib/jest.config.js (100%) diff --git a/jest.config.js b/lib/jest.config.js similarity index 100% rename from jest.config.js rename to lib/jest.config.js From dc8c00f0c332e8cce56d2ac9e930f23d7fd2dd98 Mon Sep 17 00:00:00 2001 From: ryohey Date: Thu, 21 Sep 2023 09:17:25 +0900 Subject: [PATCH 07/12] Add SynthEventScheduler tests --- lib/src/processor/SynthEventScheduler.test.ts | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 lib/src/processor/SynthEventScheduler.test.ts diff --git a/lib/src/processor/SynthEventScheduler.test.ts b/lib/src/processor/SynthEventScheduler.test.ts new file mode 100644 index 0000000..11fde95 --- /dev/null +++ b/lib/src/processor/SynthEventScheduler.test.ts @@ -0,0 +1,53 @@ +import { ImmediateEvent, MIDIEventBody } from "../SynthEvent" +import { SynthEventScheduler } from "./SynthEventScheduler" + +describe("SynthEventScheduler", () => { + it("should schedules events", () => { + let currentFrame = 0 + let onImmediateEvent = jest.fn((_e: ImmediateEvent) => {}) + let onDelayableEvent = jest.fn((_e: MIDIEventBody) => {}) + const scheduler = new SynthEventScheduler( + () => currentFrame, + (e) => onImmediateEvent(e), + (e) => onDelayableEvent(e) + ) + scheduler.addEvent({ + type: "midi", + midi: { + type: "channel", + subtype: "noteOn", + channel: 1, + noteNumber: 60, + velocity: 100, + }, + delayTime: 10, + }) + scheduler.addEvent({ + type: "midi", + midi: { + type: "channel", + subtype: "noteOff", + channel: 1, + noteNumber: 60, + velocity: 0, + }, + delayTime: 100, + }) + scheduler.addEvent({ + type: "midi", + midi: { + type: "channel", + subtype: "noteOn", + channel: 1, + noteNumber: 60, + velocity: 100, + }, + delayTime: 101, // This event should be ignored in first process + }) + currentFrame = 100 + scheduler.processScheduledEvents() + expect(onDelayableEvent.mock.calls.length).toBe(2) + expect(onDelayableEvent.mock.calls[0][0].subtype).toBe("noteOn") + expect(onDelayableEvent.mock.calls[1][0].subtype).toBe("noteOff") + }) +}) From b6b8927e0977223a51114a1fb26b29600b7b6997 Mon Sep 17 00:00:00 2001 From: ryohey Date: Thu, 21 Sep 2023 09:18:18 +0900 Subject: [PATCH 08/12] Fix event order --- lib/src/processor/SynthEventScheduler.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/src/processor/SynthEventScheduler.ts b/lib/src/processor/SynthEventScheduler.ts index 9949516..46d7959 100644 --- a/lib/src/processor/SynthEventScheduler.ts +++ b/lib/src/processor/SynthEventScheduler.ts @@ -58,7 +58,7 @@ export class SynthEventScheduler { } while (true) { - const e = this.currentEvents.pop() + const e = this.currentEvents.shift() if (e === undefined) { break } From aa011b9f6d93628dbffae0fbd3afd8cc92ab7430 Mon Sep 17 00:00:00 2001 From: ryohey Date: Thu, 21 Sep 2023 09:32:36 +0900 Subject: [PATCH 09/12] Add GitHub Actions --- .github/workflows/node.js.yml | 30 ++++++++++++++++++++++++++++++ .github/workflows/npm-publish.yml | 23 +++++++++++++++++++++++ 2 files changed, 53 insertions(+) create mode 100644 .github/workflows/node.js.yml create mode 100644 .github/workflows/npm-publish.yml diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml new file mode 100644 index 0000000..85c47d8 --- /dev/null +++ b/.github/workflows/node.js.yml @@ -0,0 +1,30 @@ +# This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node +# For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions + +name: Node.js CI + +on: + push: + branches: ["main"] + pull_request: + branches: ["main"] + +jobs: + build: + runs-on: ubuntu-latest + + strategy: + matrix: + node-version: [18.x] + # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ + + steps: + - uses: actions/checkout@v3 + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v3 + with: + node-version: ${{ matrix.node-version }} + cache: "npm" + - run: npm ci + - run: npm run build --if-present + - run: npm test diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml new file mode 100644 index 0000000..3ebbe17 --- /dev/null +++ b/.github/workflows/npm-publish.yml @@ -0,0 +1,23 @@ +# This workflow will run tests using node and then publish a package to GitHub Packages when a release is created +# For more information see: https://help.github.com/actions/language-and-framework-guides/publishing-nodejs-packages + +name: Node.js Package + +on: + release: + types: [created] + +jobs: + publish-npm: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 + with: + node-version: 18 + registry-url: https://registry.npmjs.org/ + - run: npm ci + - run: npm run build + - run: npm run publish + env: + NODE_AUTH_TOKEN: ${{secrets.npm_token}} From c8e976b21e6c0c7146680dfd95a30e784869e906 Mon Sep 17 00:00:00 2001 From: ryohey Date: Thu, 21 Sep 2023 09:32:52 +0900 Subject: [PATCH 10/12] Bump version --- lib/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/package.json b/lib/package.json index 3af7d5d..3017750 100644 --- a/lib/package.json +++ b/lib/package.json @@ -1,6 +1,6 @@ { "name": "@ryohey/wavelet", - "version": "0.6.3", + "version": "0.7.0", "description": "A wavetable synthesizer that never stops the UI thread created by AudioWorklet.", "main": "dist/index.js", "types": "dist/index.d.ts", From f22adfc915eec9887702ea953f4a17b4a4f62d35 Mon Sep 17 00:00:00 2001 From: ryohey Date: Thu, 21 Sep 2023 09:33:05 +0900 Subject: [PATCH 11/12] Run npm install --- package-lock.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package-lock.json b/package-lock.json index c95e395..2606606 100644 --- a/package-lock.json +++ b/package-lock.json @@ -69,7 +69,7 @@ }, "lib": { "name": "@ryohey/wavelet", - "version": "0.5.3", + "version": "0.7.0", "license": "MIT", "dependencies": { "@ryohey/sf2parser": "^1.2.0", From d85cb9643145e3bd4b8bcd50aea421d9bbd1a368 Mon Sep 17 00:00:00 2001 From: ryohey Date: Thu, 21 Sep 2023 09:53:43 +0900 Subject: [PATCH 12/12] Fix test --- lib/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/package.json b/lib/package.json index 3017750..3205900 100644 --- a/lib/package.json +++ b/lib/package.json @@ -8,7 +8,7 @@ "scripts": { "start": "rollup --config --watch", "build": "rollup --config", - "test": "jest" + "test": "jest --roots ./src" }, "author": "ryohey", "license": "MIT",