pachet commited on
Commit
964781b
·
1 Parent(s): f8a7926

Make playback stop cancel scheduled notes

Browse files
Files changed (1) hide show
  1. apps/theme_lab/static/app.js +63 -8
apps/theme_lab/static/app.js CHANGED
@@ -4,7 +4,10 @@ const state = {
4
  selectedThemeId: null,
5
  currentSource: null,
6
  audioContext: null,
 
 
7
  scheduled: [],
 
8
  midiAccess: null,
9
  midiOutputId: "",
10
  composerSuggestTimer: null,
@@ -157,12 +160,33 @@ function midiToHz(pitch) {
157
  }
158
 
159
  function stopPlayback() {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
160
  for (const node of state.scheduled) {
161
  try {
162
  if (typeof node === "number") {
163
  window.clearTimeout(node);
164
  } else {
165
- node.stop();
 
166
  }
167
  } catch {
168
  // Already stopped.
@@ -172,6 +196,10 @@ function stopPlayback() {
172
  const output = selectedMidiOutput();
173
  if (output) {
174
  if (typeof output.clear === "function") output.clear();
 
 
 
 
175
  for (let channel = 0; channel < 16; channel += 1) {
176
  output.send([0xb0 + channel, 123, 0]);
177
  output.send([0xb0 + channel, 120, 0]);
@@ -234,24 +262,36 @@ async function refreshMidiOutputs() {
234
 
235
  function playMidiNotes(notes, bpm = 120, output) {
236
  stopPlayback();
 
237
  const secondsPerBeat = 60 / bpm;
238
- const startAt = performance.now() + 70;
239
  for (const note of notes) {
240
  const pitch = Number(note.pitch);
241
  const velocity = Math.max(1, Math.min(127, Number(note.velocity || 72)));
242
- const begin = startAt + Number(note.start_beat) * secondsPerBeat * 1000;
 
243
  const duration = Math.max(0.04, Number(note.duration_beats) * secondsPerBeat);
244
- const end = begin + duration * 1000;
245
- output.send([0x90, pitch, velocity], begin);
246
- output.send([0x80, pitch, 0], end);
 
 
 
 
 
 
 
 
247
  }
248
  const lastEnd = Math.max(
249
  0,
250
  ...notes.map((note) => (Number(note.start_beat) + Number(note.duration_beats)) * secondsPerBeat * 1000),
251
  );
252
  const timeout = window.setTimeout(() => {
 
253
  state.scheduled = state.scheduled.filter((item) => item !== timeout);
254
- }, lastEnd + 200);
 
255
  state.scheduled.push(timeout);
256
  }
257
 
@@ -263,15 +303,22 @@ async function playNotes(notes, bpm = 120) {
263
  }
264
 
265
  stopPlayback();
 
266
  if (!state.audioContext) {
267
  state.audioContext = new AudioContext();
268
  }
269
  const ctx = state.audioContext;
270
  if (ctx.state === "suspended") await ctx.resume();
 
 
 
 
 
271
  const secondsPerBeat = 60 / bpm;
272
  const startAt = ctx.currentTime + 0.06;
273
 
274
  for (const note of notes) {
 
275
  const osc = ctx.createOscillator();
276
  const gain = ctx.createGain();
277
  const begin = startAt + Number(note.start_beat) * secondsPerBeat;
@@ -283,7 +330,15 @@ async function playNotes(notes, bpm = 120) {
283
  gain.gain.setValueAtTime(0.0001, begin);
284
  gain.gain.exponentialRampToValueAtTime(0.14, begin + 0.015);
285
  gain.gain.exponentialRampToValueAtTime(0.0001, end);
286
- osc.connect(gain).connect(ctx.destination);
 
 
 
 
 
 
 
 
287
  osc.start(begin);
288
  osc.stop(end + 0.02);
289
  state.scheduled.push(osc);
 
4
  selectedThemeId: null,
5
  currentSource: null,
6
  audioContext: null,
7
+ masterGain: null,
8
+ playbackSession: 0,
9
  scheduled: [],
10
+ activeMidiNotes: [],
11
  midiAccess: null,
12
  midiOutputId: "",
13
  composerSuggestTimer: null,
 
160
  }
161
 
162
  function stopPlayback() {
163
+ state.playbackSession += 1;
164
+ const ctx = state.audioContext;
165
+ if (state.masterGain && ctx) {
166
+ try {
167
+ state.masterGain.gain.cancelScheduledValues(ctx.currentTime);
168
+ state.masterGain.gain.setValueAtTime(0, ctx.currentTime);
169
+ } catch {
170
+ // Already disconnected.
171
+ }
172
+ const oldMaster = state.masterGain;
173
+ window.setTimeout(() => {
174
+ try {
175
+ oldMaster.disconnect();
176
+ } catch {
177
+ // Already disconnected.
178
+ }
179
+ }, 60);
180
+ state.masterGain = null;
181
+ }
182
+
183
  for (const node of state.scheduled) {
184
  try {
185
  if (typeof node === "number") {
186
  window.clearTimeout(node);
187
  } else {
188
+ if (typeof node.stop === "function" && ctx) node.stop(ctx.currentTime);
189
+ if (typeof node.disconnect === "function") node.disconnect();
190
  }
191
  } catch {
192
  // Already stopped.
 
196
  const output = selectedMidiOutput();
197
  if (output) {
198
  if (typeof output.clear === "function") output.clear();
199
+ for (const note of state.activeMidiNotes) {
200
+ output.send([0x80 + note.channel, note.pitch, 0]);
201
+ }
202
+ state.activeMidiNotes = [];
203
  for (let channel = 0; channel < 16; channel += 1) {
204
  output.send([0xb0 + channel, 123, 0]);
205
  output.send([0xb0 + channel, 120, 0]);
 
262
 
263
  function playMidiNotes(notes, bpm = 120, output) {
264
  stopPlayback();
265
+ const session = state.playbackSession;
266
  const secondsPerBeat = 60 / bpm;
267
+ const startDelay = 70;
268
  for (const note of notes) {
269
  const pitch = Number(note.pitch);
270
  const velocity = Math.max(1, Math.min(127, Number(note.velocity || 72)));
271
+ const channel = 0;
272
+ const begin = startDelay + Number(note.start_beat) * secondsPerBeat * 1000;
273
  const duration = Math.max(0.04, Number(note.duration_beats) * secondsPerBeat);
274
+ const noteOn = window.setTimeout(() => {
275
+ if (session !== state.playbackSession) return;
276
+ state.activeMidiNotes.push({ channel, pitch });
277
+ output.send([0x90 + channel, pitch, velocity]);
278
+ }, begin);
279
+ const noteOff = window.setTimeout(() => {
280
+ if (session !== state.playbackSession) return;
281
+ output.send([0x80 + channel, pitch, 0]);
282
+ state.activeMidiNotes = state.activeMidiNotes.filter((item) => item.channel !== channel || item.pitch !== pitch);
283
+ }, begin + duration * 1000);
284
+ state.scheduled.push(noteOn, noteOff);
285
  }
286
  const lastEnd = Math.max(
287
  0,
288
  ...notes.map((note) => (Number(note.start_beat) + Number(note.duration_beats)) * secondsPerBeat * 1000),
289
  );
290
  const timeout = window.setTimeout(() => {
291
+ if (session !== state.playbackSession) return;
292
  state.scheduled = state.scheduled.filter((item) => item !== timeout);
293
+ state.activeMidiNotes = [];
294
+ }, startDelay + lastEnd + 200);
295
  state.scheduled.push(timeout);
296
  }
297
 
 
303
  }
304
 
305
  stopPlayback();
306
+ const session = state.playbackSession;
307
  if (!state.audioContext) {
308
  state.audioContext = new AudioContext();
309
  }
310
  const ctx = state.audioContext;
311
  if (ctx.state === "suspended") await ctx.resume();
312
+ if (session !== state.playbackSession) return;
313
+ const master = ctx.createGain();
314
+ master.gain.value = 1;
315
+ master.connect(ctx.destination);
316
+ state.masterGain = master;
317
  const secondsPerBeat = 60 / bpm;
318
  const startAt = ctx.currentTime + 0.06;
319
 
320
  for (const note of notes) {
321
+ if (session !== state.playbackSession) break;
322
  const osc = ctx.createOscillator();
323
  const gain = ctx.createGain();
324
  const begin = startAt + Number(note.start_beat) * secondsPerBeat;
 
330
  gain.gain.setValueAtTime(0.0001, begin);
331
  gain.gain.exponentialRampToValueAtTime(0.14, begin + 0.015);
332
  gain.gain.exponentialRampToValueAtTime(0.0001, end);
333
+ osc.connect(gain).connect(master);
334
+ osc.addEventListener("ended", () => {
335
+ try {
336
+ osc.disconnect();
337
+ gain.disconnect();
338
+ } catch {
339
+ // Already disconnected.
340
+ }
341
+ });
342
  osc.start(begin);
343
  osc.stop(end + 0.02);
344
  state.scheduled.push(osc);