djipco / webmidi

Tame the Web MIDI API. Send and receive MIDI messages with ease. Control instruments with user-friendly functions (playNote, sendPitchBend, etc.). React to MIDI input with simple event listeners (noteon, pitchbend, controlchange, etc.).
Apache License 2.0
1.54k stars 116 forks source link

Possible memory leak / event listener runoff #308

Closed braebo closed 1 year ago

braebo commented 1 year ago

Description It seems like whenever I enable webmidi.js from ableton in my Tone.js app, the performance slowly degrades and the tempo slows down progressively as midi events start to get clogged up. When I stop Ableton, the events continue to fire and the tempo slowly speeds back upm until the backlog of events is processed.

I'm not sure if this is my fault or not -- but the problem doesn't occur when playing the same midi clips as Tone.js events with webmidi off.

Environment:

Details I took a performance snapshot demonstrating the problem. It shows the heap / # of event listeners climbing over time. Screenshot 2022-11-20 at 9 28 05 AM

For the curious, the implementation looks like this: ```ts export const RealtimeMidi = () => { let dispose: () => void = () => void 0 const init = async () => { await WebMidi.enable().catch((err) => alert(err)) //· Drums ·············································································¬ const drumMidiMap: Record = { C2: DAW.rack.tracks[0].instrument.node as MonoSynth, D2: DAW.rack.tracks[1].instrument.node as MonoSynth, 'D#2': DAW.rack.tracks[2].instrument.node as MonoSynth, 'F#2': DAW.rack.tracks[3].instrument.node as MetalSynth, 'G#2': DAW.rack.tracks[4].instrument.node as MetalSynth, A2: DAW.rack.tracks[5].instrument.node as MembraneSynth, 'C#2': DAW.rack.tracks[6].instrument.node as MetalSynth, } as const const midiBus = WebMidi.getInputByName('IAC Driver Bus 1') const drumChannel = midiBus.channels[MIDI_CHANNEL_MAP['Drums']] drumChannel.addListener('noteon', (e) => { const synth = drumMidiMap[e.note.identifier] try { synth.triggerAttack('C2', now(), e.note.attack) } catch (err) { throw new Error('RealtimeMidiErr: ' + err) } }) drumChannel.addListener('noteoff', (e) => { const synth = drumMidiMap[e.note.identifier] try { synth.triggerRelease(now()) } catch (err) { throw new Error('RealtimeMidiErr: ' + err) } }) //· Synths ·············································································¬ const synthChannels = [] as any[] const synthMidiMap: Record = { Arp1: DAW.rack.tracks[7], Arp2: DAW.rack.tracks[8], Bass: DAW.rack.tracks[9], } as const await wait(100) let reverbSendAddedEvent: Subscription Object.entries(synthMidiMap).forEach(([synthName, track]) => { const channel = midiBus.channels[MIDI_CHANNEL_MAP[synthName as keyof typeof MIDI_CHANNEL_MAP]] channel.addListener('noteon', ({ note }) => { try { ;(track.instrument.node as MonoSynth).triggerAttack(note.identifier, now() + 0.01, note.attack) } catch (err) { throw new Error('RealtimeMidiErr: ' + err) } }) channel.addListener('noteoff', () => { try { ;(synthMidiMap[synthName].instrument.node as MonoSynth).triggerRelease(now()) } catch (err) { throw new Error('RealtimeMidiErr: ' + err) } }) // instrument track's prefader gain automation channel.addListener('controlchange-controller20', (e) => { if (e.rawValue) track.prefaderGain.gain.value = convertRange(e.rawValue, [0, 127], [0, 1]) }) // instrument's global filter frequency automation channel.addListener('controlchange-controller21', (e) => { if (e.rawValue) (track.instrument.node as MonoSynth).filter.frequency.value = convertRange(e.rawValue, [0, 127], [0, 10000]) }) //? Track Sends // const sendUpdateCallbacks = {} as Record void> reverbSendAddedEvent = DAW.events.update.subscribe((e) => { if (e.category === 'track' && ['add', 'remove'].includes(e.operation)) { if (e.data?.channelId === track.id) { const send = DAW.mixer.busses.find((s) => s.id === e.data?.busId) Object.entries(SEND_MAP).forEach(([key, value]) => { if (send && send.title.includes(value)) { sendUpdateCallbacks[key as keyof typeof SEND_MAP] = (e) => { if (e.rawValue) { send.channel.volume = convertRange(e.rawValue, [0, 127], [-25, 0]) } } } }) } } }) Object.keys(SEND_MAP).forEach((controlChange) => { channel.addListener(controlChange as keyof typeof SEND_MAP, (e: ControlChangeMessageEvent) => { if (sendUpdateCallbacks[controlChange as keyof typeof SEND_MAP]) sendUpdateCallbacks[controlChange as keyof typeof SEND_MAP](e) }) }) synthChannels.push(channel) }) //⌟ //· Master ·············································································¬ const busChannel = midiBus.channels[MIDI_CHANNEL_MAP['Busses']] // Global Filter busChannel.addListener('controlchange-controller24', (e) => { if (e.rawValue) DAW.mixer.filter.frequency.value = convertRange(e.rawValue, [0, 127], [0, 20000]) }) // Reverb & Delay DAW.mixer.busses.forEach((bus) => { if (bus.title === 'Reverb') { busChannel.addListener('controlchange-controller25', (e) => { if (e.rawValue) (bus as ReverbBus).reverb.node.wet.value = convertRange(e.rawValue, [0, 127], [0, 1]) }) } if (bus.title === 'Delay') { busChannel.addListener('controlchange-controller26', (e) => { if (e.rawValue) (bus as DelayBus).delay.node.wet.value = convertRange(e.rawValue, [0, 127], [0, 1]) }) } }) //⌟ dispose = () => { reverbSendAddedEvent.unsubscribe() drumChannel.removeListener() synthChannels.forEach((channel) => channel.removeListener()) } } return { init, dispose, } } ```

I'm hoping there is something obvious that I'm doing wrong here! Any advice would be greatly apreciated. Thanks!

djipco commented 1 year ago

Hmm... that's a hard one. What we would need to check is if the listeners are properly removed or not. You could use the getListeners() method and check the numbers of elements in the array to see if it matches what it's supposed to be. 11 000 listeners does not feel right but it's hard to say if the problem is with the library or your code...

djipco commented 1 year ago

Do you have a minimal working example that triggers the problem?

djipco commented 1 year ago

Did you finally resolve this problem? If not, could you submit a minimal working example that illustrates the problem?

braebo commented 1 year ago

Hey @djipco so sorry about this! Been absolutely slammed recently.

I believe I've sorted it out for the most part -- after combing through countless tracing logs and performance recordings I noticed that, while the source of the event listeners was quite opaque, the problem seemed to be caused by v8 being overwhelmed and failling to do any GC. By fiddling with the AudioContext lookahead, optimizing certain audio graphs, and replacing various requestAnimationFrame calls with requestIdleCallback, I was able to create enough breathing room in the event loop to allow GC to keep up with the midi events and WebAudioApi activity.

While I'm not entirely satisfied with the observablility story nor my lackluster understanding of exactly what's going on, I'm confident enough to say that WebMidi is not the cause of the problem.

Sorry for my delay in getting back to you, and thank you for following up on this! This library is amazing and the work you've done is top notch so thank you again!! 🙏

djipco commented 1 year ago

No worries, I totally understand. I'm glad you found the source of the issue. Cheers!