Skip to content

Commit

Permalink
Merge pull request jspsych#3179 from jspsych/audio-player
Browse files Browse the repository at this point in the history
Add AudioPlayer class that handles both WebAudio and HTML5 audio
  • Loading branch information
jodeleeuw authored Jul 16, 2024
2 parents 7679794 + bbaddbe commit 3f06cfa
Show file tree
Hide file tree
Showing 14 changed files with 1,272 additions and 601 deletions.
8 changes: 8 additions & 0 deletions .changeset/fresh-doors-watch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"jspsych": major
"@jspsych/plugin-audio-button-response": minor
"@jspsych/plugin-audio-keyboard-response": minor
"@jspsych/plugin-audio-slider-response": minor
---

Changed plugins to use AudioPlayer class; added tests using AudioPlayer mock; plugins now use AudioPlayerInterface.
5 changes: 0 additions & 5 deletions .changeset/silly-cycles-sneeze.md

This file was deleted.

5 changes: 5 additions & 0 deletions .changeset/thick-berries-arrive.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@jspsych/test-utils": minor
---

clickTarget method now respects disabled tag on form elements.
168 changes: 167 additions & 1 deletion docs/reference/jspsych-pluginAPI.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ The pluginAPI module contains functions that are useful when developing plugins.

## Keyboard Input


### cancelAllKeyboardResponses

```javascript
Expand Down Expand Up @@ -217,7 +218,172 @@ var listener = jsPsych.pluginAPI.getKeyboardResponse({
});
```

## Media
## Audio

All audio-related functionality is handled by the AudioPlayer class.

### getAudioPlayer

```javascript
jsPsych.pluginAPI.getAudioPlayer(filepath)
```

#### Return value

Returns a Promise that resolves to an instance of an AudioPlayer class that holds the buffer of the audio file when it finishes loading.

#### Description

Gets an AudioPlayer class instance which has methods that can be used to play or stop audio that can be played with the WebAudio API or an audio object that can be played as HTML5 Audio.

It is strongly recommended that you preload audio files before calling this method. This method will load the files if they are not preloaded, but this may result in delays during the experiment as audio is downloaded.

#### Examples

##### HTML 5 Audio and WebAudio API

```javascript
const audio = await jsPsych.pluginAPI.getAudioPlayer('my-sound.mp3')

audio.play()

```

See the `audio-keyboard-response` plugin for an example in a fuller context.

---

### play

```javascript
const audio = jsPsych.pluginAPI.getAudioPlayer(filepath)

audio.play()
```

#### Return value

Returns nothing.

#### Description

Method that belongs to the AudioPlayer class. Plays the audio loaded into the audio buffer of the AudioPlayer instance for a particular file. If the audio is a HTML5 audio object it plays it. If the audio is a Webaudio API object it starts it.

#### Example

##### HTML 5 Audio and WebAudio API

```javascript
const audio = await jsPsych.pluginAPI.getAudioPlayer('my-sound.mp3');

audio.play();

```

See the `audio-keyboard-response` plugin for an example in a fuller context.

---

### stop

```javascript
const audio = jsPsych.pluginAPI.getAudioPlayer(filepath);

audio.play();
```

#### Return value

Returns nothing.

#### Description

Method that belongs to the AudioPlayer class. Stops the audio loaded into the audio buffer of the AudioPlayer instance for a particular file. If the audio is an HTML5 audio object it pauses it. If the audio is a Webaudio API object it stops it.

#### Example

##### HTML 5 Audio and WebAudio API

```javascript
const audio = await jsPsych.pluginAPI.getAudioPlayer('my-sound.mp3');

audio.play();

audio.stop();

```

See the `audio-keyboard-response` plugin for an example in a fuller context.

---

### addEventListener

```javascript
const audio = jsPsych.pluginAPI.getAudioPlayer(filepath);

audio.addEventListener(eventName, callback);
```

#### Return value

Returns nothing.

#### Description

Method that belongs to the AudioPlayer class. Adds an event listener to the media Element that corresponds to
the AudioPlayer class instance.

#### Example

```javascript
const audio = await jsPsych.pluginAPI.getAudioPlayer('my-sound.mp3');

audio.play();

audio.addEventListener('ended', end_trial());

```

See the `audio-keyboard-response` plugin for an example in a fuller context.

---

### removeEventListener

```javascript
const audio = jsPsych.pluginAPI.getAudioPlayer(filepath);

audio.removeEventListener(eventName, callback);
```

#### Return value

Returns nothing.

#### Description

Method that belongs to the AudioPlayer class. Removes an event listener from the media Element that corresponds to
the AudioPlayer class instance.

#### Example

```javascript
const audio = await jsPsych.pluginAPI.getAudioPlayer('my-sound.mp3');

audio.play();

audio.addEventListener('ended', end_trial());

audio.removeEventListener('ended', end_trial());

```

See the `audio-keyboard-response` plugin for an example in a fuller context.

---

## Other Media

### getAudioBuffer

Expand Down
101 changes: 101 additions & 0 deletions packages/jspsych/src/modules/plugin-api/AudioPlayer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
export interface AudioPlayerOptions {
useWebAudio: boolean;
audioContext?: AudioContext;
}

export interface AudioPlayerInterface {
load(): Promise<void>;
play(): void;
stop(): void;
addEventListener(eventName: string, callback: EventListenerOrEventListenerObject): void;
removeEventListener(eventName: string, callback: EventListenerOrEventListenerObject): void;
}

export class AudioPlayer implements AudioPlayerInterface {
private audio: HTMLAudioElement | AudioBufferSourceNode;
private webAudioBuffer: AudioBuffer;
private audioContext: AudioContext | null;
private useWebAudio: boolean;
private src: string;

constructor(src: string, options: AudioPlayerOptions = { useWebAudio: false }) {
this.src = src;
this.useWebAudio = options.useWebAudio;
this.audioContext = options.audioContext || null;
}

async load() {
if (this.useWebAudio) {
this.webAudioBuffer = await this.preloadWebAudio(this.src);
} else {
this.audio = await this.preloadHTMLAudio(this.src);
}
}

play() {
if (this.audio instanceof HTMLAudioElement) {
this.audio.play();
} else {
// If audio is not HTMLAudioElement, it must be a WebAudio API object, so create a source node.
if (!this.audio) this.audio = this.getAudioSourceNode(this.webAudioBuffer);
this.audio.start();
}
}

stop() {
if (this.audio instanceof HTMLAudioElement) {
this.audio.pause();
this.audio.currentTime = 0;
} else {
this.audio!.stop();
// Regenerate source node for audio since the previous one is stopped and unusable.
this.audio = this.getAudioSourceNode(this.webAudioBuffer);
}
}

addEventListener(eventName: string, callback: EventListenerOrEventListenerObject) {
// If WebAudio buffer exists but source node doesn't, create it.
if (!this.audio && this.webAudioBuffer)
this.audio = this.getAudioSourceNode(this.webAudioBuffer);
this.audio.addEventListener(eventName, callback);
}

removeEventListener(eventName: string, callback: EventListenerOrEventListenerObject) {
// If WebAudio buffer exists but source node doesn't, create it.
if (!this.audio && this.webAudioBuffer)
this.audio = this.getAudioSourceNode(this.webAudioBuffer);
this.audio.removeEventListener(eventName, callback);
}

private getAudioSourceNode(audioBuffer: AudioBuffer): AudioBufferSourceNode {
const source = this.audioContext!.createBufferSource();
source.buffer = audioBuffer;
source.connect(this.audioContext!.destination);
return source;
}

private async preloadWebAudio(src: string): Promise<AudioBuffer> {
const buffer = await fetch(src);
const arrayBuffer = await buffer.arrayBuffer();
const audioBuffer = await this.audioContext!.decodeAudioData(arrayBuffer);
const source = this.audioContext!.createBufferSource();
source.buffer = audioBuffer;
source.connect(this.audioContext!.destination);
return audioBuffer;
}

private async preloadHTMLAudio(src: string): Promise<HTMLAudioElement> {
return new Promise<HTMLAudioElement>((resolve, reject) => {
const audio = new Audio(src);
audio.addEventListener("canplaythrough", () => {
resolve(audio);
});
audio.addEventListener("error", (err) => {
reject(err);
});
audio.addEventListener("abort", (err) => {
reject(err);
});
});
}
}
Loading

0 comments on commit 3f06cfa

Please sign in to comment.