Skip to content

Commit c79273a

Browse files
authored
fix: Modernize audio playback (#9560)
* fix: Modernize audio playback * fix: Fix loading on headless/Node
1 parent 52d935c commit c79273a

8 files changed

Lines changed: 40 additions & 135 deletions

File tree

core/inject.ts

Lines changed: 3 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -326,68 +326,7 @@ function bindDocumentEvents() {
326326
*/
327327
function loadSounds(pathToMedia: string, workspace: WorkspaceSvg) {
328328
const audioMgr = workspace.getAudioManager();
329-
audioMgr.load(
330-
[
331-
pathToMedia + 'click.mp3',
332-
pathToMedia + 'click.wav',
333-
pathToMedia + 'click.ogg',
334-
],
335-
'click',
336-
);
337-
audioMgr.load(
338-
[
339-
pathToMedia + 'disconnect.wav',
340-
pathToMedia + 'disconnect.mp3',
341-
pathToMedia + 'disconnect.ogg',
342-
],
343-
'disconnect',
344-
);
345-
audioMgr.load(
346-
[
347-
pathToMedia + 'delete.mp3',
348-
pathToMedia + 'delete.ogg',
349-
pathToMedia + 'delete.wav',
350-
],
351-
'delete',
352-
);
353-
354-
// Bind temporary hooks that preload the sounds.
355-
const soundBinds: browserEvents.Data[] = [];
356-
/**
357-
*
358-
*/
359-
function unbindSounds() {
360-
while (soundBinds.length) {
361-
const oldSoundBinding = soundBinds.pop();
362-
if (oldSoundBinding) {
363-
browserEvents.unbind(oldSoundBinding);
364-
}
365-
}
366-
audioMgr.preload();
367-
}
368-
369-
// These are bound on mouse/touch events with
370-
// Blockly.browserEvents.conditionalBind, so they restrict the touch
371-
// identifier that will be recognized. But this is really something that
372-
// happens on a click, not a drag, so that's not necessary.
373-
374-
// Android ignores any sound not loaded as a result of a user action.
375-
soundBinds.push(
376-
browserEvents.conditionalBind(
377-
document,
378-
'pointermove',
379-
null,
380-
unbindSounds,
381-
true,
382-
),
383-
);
384-
soundBinds.push(
385-
browserEvents.conditionalBind(
386-
document,
387-
'touchstart',
388-
null,
389-
unbindSounds,
390-
true,
391-
),
392-
);
329+
audioMgr.load([`${pathToMedia}click.mp3`], 'click');
330+
audioMgr.load([`${pathToMedia}disconnect.mp3`], 'disconnect');
331+
audioMgr.load([`${pathToMedia}delete.mp3`], 'delete');
393332
}

core/workspace_audio.ts

Lines changed: 37 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@
1212
*/
1313
// Former goog.module ID: Blockly.WorkspaceAudio
1414

15-
import * as userAgent from './utils/useragent.js';
1615
import type {WorkspaceSvg} from './workspace_svg.js';
1716

1817
/**
@@ -26,19 +25,26 @@ const SOUND_LIMIT = 100;
2625
*/
2726
export class WorkspaceAudio {
2827
/** Database of pre-loaded sounds. */
29-
private sounds = new Map<string, HTMLAudioElement>();
28+
private sounds = new Map<string, AudioBuffer>();
3029

3130
/** Time that the last sound was played. */
3231
private lastSound: Date | null = null;
3332

3433
/** Whether the audio is muted or not. */
3534
private muted: boolean = false;
3635

36+
/** Audio context used for playback. */
37+
private readonly context?: AudioContext;
38+
3739
/**
3840
* @param parentWorkspace The parent of the workspace this audio object
3941
* belongs to, or null.
4042
*/
41-
constructor(private parentWorkspace: WorkspaceSvg) {}
43+
constructor(private parentWorkspace: WorkspaceSvg) {
44+
if (window.AudioContext) {
45+
this.context = new AudioContext();
46+
}
47+
}
4248

4349
/**
4450
* Dispose of this audio manager.
@@ -47,73 +53,26 @@ export class WorkspaceAudio {
4753
*/
4854
dispose() {
4955
this.sounds.clear();
56+
this.context?.close();
5057
}
5158

5259
/**
5360
* Load an audio file. Cache it, ready for instantaneous playing.
5461
*
55-
* @param filenames List of file types in decreasing order of preference (i.e.
56-
* increasing size). E.g. ['media/go.mp3', 'media/go.wav'] Filenames
57-
* include path from Blockly's root. File extensions matter.
62+
* @param filenames Single-item array containing the URL for the sound file.
63+
* Any items after the first item are ignored.
5864
* @param name Name of sound.
5965
*/
60-
load(filenames: string[], name: string) {
66+
async load(filenames: string[], name: string) {
6167
if (!filenames.length) {
6268
return;
6369
}
64-
let audioTest;
65-
try {
66-
audioTest = new globalThis['Audio']();
67-
} catch {
68-
// No browser support for Audio.
69-
// IE can throw an error even if the Audio object exists.
70-
return;
71-
}
72-
let sound;
73-
for (let i = 0; i < filenames.length; i++) {
74-
const filename = filenames[i];
75-
const ext = filename.match(/\.(\w+)$/);
76-
if (ext && audioTest.canPlayType('audio/' + ext[1])) {
77-
// Found an audio format we can play.
78-
sound = new globalThis['Audio'](filename);
79-
break;
80-
}
81-
}
82-
if (sound) {
83-
this.sounds.set(name, sound);
84-
}
85-
}
86-
87-
/**
88-
* Preload all the audio files so that they play quickly when asked for.
89-
*
90-
* @internal
91-
*/
92-
preload() {
93-
for (const sound of this.sounds.values()) {
94-
sound.volume = 0.01;
95-
const playPromise = sound.play();
96-
// Edge does not return a promise, so we need to check.
97-
if (playPromise !== undefined) {
98-
// If we don't wait for the play request to complete before calling
99-
// pause() we will get an exception: (DOMException: The play() request
100-
// was interrupted) See more:
101-
// https://developers.google.com/web/updates/2017/06/play-request-was-interrupted
102-
playPromise.then(sound.pause).catch(
103-
// Play without user interaction was prevented.
104-
function () {},
105-
);
106-
} else {
107-
sound.pause();
108-
}
10970

110-
// iOS can only process one sound at a time. Trying to load more than one
111-
// corrupts the earlier ones. Just load one and leave the others
112-
// uncached.
113-
if (userAgent.IPAD || userAgent.IPHONE) {
114-
break;
115-
}
116-
}
71+
const response = await fetch(filenames[0]);
72+
const arrayBuffer = await response.arrayBuffer();
73+
this.context?.decodeAudioData(arrayBuffer, (audioBuffer) => {
74+
this.sounds.set(name, audioBuffer);
75+
});
11776
}
11877

11978
/**
@@ -123,8 +82,8 @@ export class WorkspaceAudio {
12382
* @param name Name of sound.
12483
* @param opt_volume Volume of sound (0-1).
12584
*/
126-
play(name: string, opt_volume?: number) {
127-
if (this.muted) {
85+
async play(name: string, opt_volume?: number) {
86+
if (this.muted || opt_volume === 0 || !this.context) {
12887
return;
12988
}
13089
const sound = this.sounds.get(name);
@@ -138,17 +97,24 @@ export class WorkspaceAudio {
13897
return;
13998
}
14099
this.lastSound = now;
141-
let mySound;
142-
if (userAgent.IPAD || userAgent.ANDROID) {
143-
// Creating a new audio node causes lag in Android and iPad. Android
144-
// refetches the file from the server, iPad uses a singleton audio
145-
// node which must be deleted and recreated for each new audio tag.
146-
mySound = sound;
147-
} else {
148-
mySound = sound.cloneNode() as HTMLAudioElement;
100+
101+
if (this.context.state === 'suspended') {
102+
await this.context.resume();
149103
}
150-
mySound.volume = opt_volume === undefined ? 1 : opt_volume;
151-
mySound.play();
104+
105+
const source = this.context.createBufferSource();
106+
const gainNode = this.context.createGain();
107+
gainNode.gain.value = opt_volume ?? 1;
108+
gainNode.connect(this.context.destination);
109+
source.buffer = sound;
110+
source.connect(gainNode);
111+
112+
source.addEventListener('ended', () => {
113+
source.disconnect();
114+
gainNode.disconnect();
115+
});
116+
117+
source.start();
152118
} else if (this.parentWorkspace) {
153119
// Maybe a workspace on a lower level knows about this sound.
154120
this.parentWorkspace.getAudioManager().play(name, opt_volume);

media/click.ogg

-4.75 KB
Binary file not shown.

media/click.wav

-3.69 KB
Binary file not shown.

media/delete.ogg

-5.6 KB
Binary file not shown.

media/delete.wav

-8.95 KB
Binary file not shown.

media/disconnect.ogg

-4.3 KB
Binary file not shown.

media/disconnect.wav

-1.46 KB
Binary file not shown.

0 commit comments

Comments
 (0)