Skip to content

Commit

Permalink
feat: preload sounds before they are played
Browse files Browse the repository at this point in the history
  • Loading branch information
aradzie committed Nov 8, 2024
1 parent 1611c23 commit 5f6429c
Show file tree
Hide file tree
Showing 8 changed files with 114 additions and 66 deletions.
File renamed without changes.
1 change: 0 additions & 1 deletion packages/keybr-sound/lib/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,2 @@
export * from "./library.ts";
export * from "./loader.ts";
export * from "./types.ts";
108 changes: 87 additions & 21 deletions packages/keybr-sound/lib/library.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,97 @@
import { loadPlayer, nullPlayer } from "./loader.ts";
import { type Player, type SoundAssets, type SoundName } from "./types.ts";
import { expectType, request } from "@keybr/request";
import { getAudioContext } from "./audiocontext.ts";
import { pickPlayableUrl } from "./mediatypes.ts";
import { nullPlayer, WebAudioPlayer } from "./player.ts";
import {
type Player,
type PlayerConfig,
type SoundAssets,
type SoundName,
} from "./types.ts";

const library = {
players: new Map<SoundName, Player>(),
};
class PlayerLoader {
buffer: ArrayBuffer | null = null;
player: Player | null = null;

export function loadSounds(assets: SoundAssets): void {
constructor(readonly config: PlayerConfig) {}

/**
* Stage one: we load sound data, but we don't create players yet
* because there was no user gesture and AudioContext is not available.
*/
async load() {
try {
const url = pickPlayableUrl(this.config.urls);
if (url != null) {
const response = await request
.use(expectType("audio/*"))
.GET(url)
.send();
this.buffer = await response.arrayBuffer();
} else {
this.player = nullPlayer;
}
} catch (err) {
this.player = nullPlayer;
throw err;
}
}

/**
* Stage two: we convert the loaded sound data into players.
* We assume that at this point there was a user gesture
* and AudioContext is already available.
*/
async init() {
if (this.buffer != null && this.player == null) {
try {
const context = getAudioContext();
if (context != null) {
const buffer = await context.decodeAudioData(this.buffer);
const player = new WebAudioPlayer(context, buffer);
this.buffer = null;
this.player = player;
} else {
this.buffer = null;
this.player = nullPlayer;
}
} catch (err) {
this.buffer = null;
this.player = nullPlayer;
throw err;
}
}
return this.player ?? nullPlayer;
}
}

const loaders = new Map<SoundName, PlayerLoader>();

export function loadSounds(assets: SoundAssets) {
for (const [name, config] of Object.entries(assets)) {
if (!library.players.has(name)) {
library.players.set(name, nullPlayer());
loadPlayer(config).then(
(player) => {
library.players.set(name, player);
},
(error) => {
console.log(error);
},
);
let loader = loaders.get(name);
if (loader == null || loader.config !== config) {
loader = new PlayerLoader(config);
loaders.set(name, loader);
loader.load().catch(catchError);
}
}
}

export function playSound(name: SoundName, volume: number = 1): void {
const player = library.players.get(name);
if (player == null) {
export function playSound(name: SoundName, volume: number = 1) {
const loader = loaders.get(name);
if (loader == null) {
throw new Error(String(name));
}
player.volume(volume);
player.play();
loader
.init()
.then((player) => {
player.volume(volume);
player.play();
})
.catch(catchError);
}

function catchError(err: any) {
console.error(err);
}
23 changes: 0 additions & 23 deletions packages/keybr-sound/lib/loader.ts

This file was deleted.

28 changes: 17 additions & 11 deletions packages/keybr-sound/lib/mediatypes.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,30 @@
const audio = new Audio();

const supportedMediaTypes = [
[".aac", "audio/aac;"],
[".mp3", "audio/mpeg;"],
[".ogg", 'audio/ogg; codecs="vorbis"'],
[".wav", 'audio/wav; codecs="1"'],
].filter(([ext, mediaType]) => {
const audio = new Audio();
const canPlayType = audio.canPlayType(mediaType);
return canPlayType === "probably" || canPlayType === "maybe";
switch (audio.canPlayType(mediaType)) {
case "maybe":
case "probably":
return true;
default:
return false;
}
});

export function pickUrl(urls: readonly string[]): string | null {
let p: number;
export function pickPlayableUrl(urls: readonly string[]): string | null {
let index;
for (let url of urls) {
p = url.indexOf("#");
if (p !== -1) {
url = url.substring(0, p);
index = url.indexOf("#");
if (index !== -1) {
url = url.substring(0, index);
}
p = url.indexOf("?");
if (p !== -1) {
url = url.substring(0, p);
index = url.indexOf("?");
if (index !== -1) {
url = url.substring(0, index);
}
for (const [ext] of supportedMediaTypes) {
if (url.endsWith(ext)) {
Expand Down
16 changes: 8 additions & 8 deletions packages/keybr-sound/lib/player.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { type Player } from "./types.ts";

export class NullPlayer implements Player {
play(offset?: number, duration?: number): void {}
stop(): void {}
volume(volume: number): void {}
}
export const nullPlayer = new (class NullPlayer implements Player {
play(offset?: number, duration?: number) {}
stop() {}
volume(volume: number) {}
})();

export class WebAudioPlayer implements Player {
readonly #context: AudioContext;
Expand All @@ -20,7 +20,7 @@ export class WebAudioPlayer implements Player {
this.#source = null;
}

play(offset?: number, duration?: number): void {
play(offset?: number, duration?: number) {
this.stop();
const source = this.#context.createBufferSource();
this.#source = source;
Expand All @@ -32,14 +32,14 @@ export class WebAudioPlayer implements Player {
source.start(0, offset, duration);
}

stop(): void {
stop() {
if (this.#source != null) {
this.#source.stop();
this.#source = null;
}
}

volume(volume: number): void {
volume(volume: number) {
this.#gain.gain.value = volume;
}
}
2 changes: 1 addition & 1 deletion packages/keybr-sound/lib/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export type SoundName = string | number;
export type SoundName = symbol | string | number;

export type SoundAssets = {
readonly [name: SoundName]: PlayerConfig;
Expand Down
2 changes: 1 addition & 1 deletion packages/keybr-textinput-sounds/lib/player.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ export function useSoundPlayer() {
export function makeSoundPlayer(settings: Settings) {
const playSounds = settings.get(textDisplayProps.playSounds);
const soundVolume = settings.get(textDisplayProps.soundVolume);
loadSounds(textInputSounds);
return (feedback: Feedback) => {
loadSounds(textInputSounds);
if (playSounds === PlaySounds.All) {
switch (feedback) {
case Feedback.Succeeded:
Expand Down

0 comments on commit 5f6429c

Please sign in to comment.