EKO Web
A web audio engine you build players with. True gapless playback, crossfade, loudness normalization and ReplayGain tag reading, in 6.6 kB gzipped, with no runtime dependencies and no framework of its own.
What it gives you over an <audio> tag
- True gapless. The next track is decoded and scheduled to the sample while the current one is still playing, so albums and live sets run without a seam. Not a
setTimeoutapproximation. - Crossfade as the same decision, not a separate feature. A boundary is gapless, crossfaded, or a gap.
- Loudness normalization from a track's ReplayGain tag, or measured from the decoded audio when there is no tag.
- Click free transport. Play, pause and seek all ramp instead of cutting, with no API of their own.
- A real graph: an insert point for your own EQ or effects, and an analyser tap for a spectrum.
- A queue with shuffle, repeat and a back button that walks what you actually played.
Install
$ npm install @rpxl/eko-web
React and Vue are optional peer dependencies. Install whichever you use, or neither.
A working player in five minutes
Three steps. At the end of them you have audio playing with a working transport, and nothing else is required.
1. Make an engine and give it tracks
import { EkoWebEngine } from "@rpxl/eko-web";
const engine = new EkoWebEngine();
engine.setQueue([
{ id: "1", src: "/audio/01.mp3" },
{ id: "2", src: "/audio/02.mp3" },
]);
2. Play from a click
button.addEventListener("click", () => engine.play());
play() on page load silently does nothing. This is the single most common reason a first attempt appears broken.3. Show what is happening
// discrete state: track, queue position, play/pause, volume engine.subscribe(() => { const { track, index, queueLength, paused } = engine.getSnapshot(); title.textContent = track?.id ?? "Nothing loaded"; position.textContent = `${index + 1} / ${queueLength}`; }); // the clock: every frame while playing engine.on("timeupdate", ({ currentTime, duration }) => { bar.value = currentTime; bar.max = duration; });
That is the whole API surface you need for a player. Everything below is refinement.
Plain JavaScript, complete
The whole thing, start to finish. Paste it into an HTML file and it works.
<button id="play">Play</button>
<span id="now"></span>
<progress id="bar" value="0" max="1"></progress>
<script type="module">
import { EkoWebEngine } from "@rpxl/eko-web";
const engine = new EkoWebEngine({ transition: "gapless" });
engine.setQueue([
{ id: "1", src: "/audio/01.mp3" },
{ id: "2", src: "/audio/02.mp3" },
]);
// Discrete state changes rarely, so redraw the labels here.
engine.subscribe(() => {
const s = engine.getSnapshot();
play.textContent = s.paused ? "Play" : "Pause";
now.textContent = s.track ? `${s.index + 1} / ${s.queueLength}` : "";
});
// The clock changes every frame, so keep it off the path above.
engine.on("timeupdate", ({ currentTime, duration }) => {
bar.value = currentTime;
bar.max = duration || 1;
});
play.addEventListener("click", () => {
engine.paused ? engine.play() : engine.pause();
});
</script>
React, complete
One file, nothing omitted. The important part is where the engine lives, and useEkoWebEngine handles that.
import { useEkoWebEngine, useEkoPlayer, useEkoTime } from "@rpxl/eko-web/react";
const TRACKS = [
{ id: "1", src: "/audio/01.mp3" },
{ id: "2", src: "/audio/02.mp3" },
];
export function Player() {
// Built once, destroyed on unmount, and safe under StrictMode's double mount. The
// queue loads after mount, so a server render never starts a fetch.
const engine = useEkoWebEngine({ transition: "gapless" }, { queue: TRACKS });
const { paused, index, queueLength, play, pause, next, previous } = useEkoPlayer(engine);
return (
<div>
<button onClick={previous}>Prev</button>
<button onClick={paused ? play : pause}>{paused ? "Play" : "Pause"}</button>
<button onClick={next}>Next</button>
<span>{index + 1} / {queueLength}</span>
<Progress engine={engine} />
</div>
);
}
// Its own component on purpose: currentTime changes every frame, so only this
// re-renders while playing. Inline it and your whole tree redraws at 60fps.
function Progress({ engine }) {
const { currentTime, duration } = useEkoTime(engine);
return <progress value={currentTime} max={duration || 1} />;
}
Options like transition and crossfadeSeconds are only read when an engine is built. Change one and the hook builds a new engine, carries over the queue, the position in it, shuffle, repeat, volume and mute, and destroys the old one. Playback stops at that point. shuffle and repeat in the options are starting values: change them later with setShuffle and setRepeat.
To share one engine across your app instead, build it yourself with new EkoWebEngine(), pass it to useEkoPlayer and useEkoTime, and call destroy() when you are finished with it.
Vue 3, complete
The same player as a single-file component.
<script setup>
import { useEkoWebEngine, useEkoPlayer, useEkoTime } from "@rpxl/eko-web/vue";
// Destroyed when the component unmounts. Pass the options as a getter, like
// () => ({ transition: mode.value }), and changing them rebuilds the engine.
const engine = useEkoWebEngine({ transition: "gapless" }, {
queue: [
{ id: "1", src: "/audio/01.mp3" },
{ id: "2", src: "/audio/02.mp3" },
],
});
const player = useEkoPlayer(engine);
const { currentTime, duration } = useEkoTime(engine);
</script>
<template>
<button @click="player.previous()">Prev</button>
<button @click="player.paused ? player.play() : player.pause()">
{{ player.paused ? "Play" : "Pause" }}
</button>
<button @click="player.next()">Next</button>
<span>{{ player.index + 1 }} / {{ player.queueLength }}</span>
<progress :value="currentTime" :max="duration || 1" />
</template>
tests/documented-patterns.test.ts renders the React one under StrictMode and asserts the engine is built once however many times the component renders, and destroyed on unmount. If the recommended shape stops being correct, the suite fails.Track boundaries
What happens between two tracks is one decision, set once when you build the engine.
new EkoWebEngine({ transition: "gapless" }); // the default
new EkoWebEngine({ transition: "crossfade", crossfadeSeconds: 3 });
new EkoWebEngine({ transition: "gap" }); // stop, then start
Gapless and crossfade both need the next track decoded ahead of the boundary, so both arm during the
current track. If the next track has to stream instead (see long files),
that one boundary degrades to a gap and the trackchange event tells you it did, rather than
pretending otherwise.
Loudness
Tracks from different sources arrive at wildly different levels. The engine evens that out, and you choose where the number comes from.
new EkoWebEngine({ normalize: "auto" }); // tag if there is one, else measure
new EkoWebEngine({ normalize: "tags" }); // only a tag, never measure
new EkoWebEngine({ normalize: "measure" }); // always measure, ignore tags
new EkoWebEngine({ normalize: false });
Measuring needs the decoded samples, which a streamed track does not have. On that path
"auto" falls back to unity gain and warns once.
Reading a tag is your call, not the engine's, so the parsers stay out of your bundle unless you ask for them. See ReplayGain tags.
Long files
A track is either decoded whole into memory or streamed through an <audio> element.
Decoding is what makes sample accurate scheduling possible; streaming is what keeps a two hour DJ set from
eating a gigabyte of memory.
new EkoWebEngine({ source: "auto" }); // decide per track by Content-Length
new EkoWebEngine({ source: "buffer" });
new EkoWebEngine({ source: "element" });
// or per track, which beats the engine's guess
engine.setQueue([{ id: "set", src: "/dj-set.flac", source: "element" }]);
"auto" guesses from the file's size, and compressed size predicts decoded size badly for lossy formats. If you know a track is long, say so per track.What it cannot do
Worth saying plainly, because a lot of web audio marketing does not.
- It is not bit-perfect and cannot be. Every browser decodes the file and resamples it to the output device's rate. There is no exclusive device access from a web page. A single track through EKO Web sounds identical to the same track through a plain
<audio>tag. - It reads no metadata beyond ReplayGain tags. Title, artist and cover art are yours to supply.
- It has no UI. It gives you state and transport; what that looks like is your job.
What it improves is the experience: the seams between tracks, and the jump in volume across a playlist. If you want bit-perfect output, that needs a native app, which is what the EKO Mac app is for.
ReplayGain tags
An opt-in subpath, so the parsers only reach your bundle if you import them.
import { readReplayGain } from "@rpxl/eko-web/replaygain";
const { gainDb, peak } = await readReplayGain("/audio/01.flac");
engine.setQueue([{ id: "1", src: "/audio/01.flac", gainDb }]);
Reads Vorbis comments (FLAC, Ogg Vorbis, Ogg FLAC), ID3v2 and APEv2 (MP3), and both MP4 layouts: the iTunes freeform atoms most taggers write, and the metadata keys scheme ffmpeg writes. Gain and peak only.
It asks for the first 64 kB with a Range request rather than pulling the whole file, and only looks at the tail if the head had no tags, which is where APEv2 and non-faststart MP4 keep theirs. A missing tag is never an error: a 404, a CORS rejection or a file that is not audio all resolve to an empty result, because this runs on the path to playing a track.
Media Session
import { attachMediaSession } from "@rpxl/eko-web/media-session";
const detach = attachMediaSession(engine, {
metadata: (track) => ({ title: track.title, artist: track.artist, artwork: [...] }),
});
Wires the OS lock screen and hardware media keys. The reason this is a module rather than three lines in
your app: a gapless boundary changes track with no src swap and no element event, so hand rolled
wiring never fires and the lock screen shows the wrong song for the rest of the queue. This listens to the
engine instead.
Options
| Option | Default | What it does |
|---|---|---|
transition | "gapless" | "gapless", "crossfade" or "gap". |
crossfadeSeconds | 3 | Overlap length. Ignored unless crossfading. |
normalize | "auto" | "auto", "tags", "measure" or false. |
targetLufs | -16 | The level normalization aims for. |
source | "auto" | "auto", "buffer" or "element". |
bufferMaxBytes | 50 MB | Above this, "auto" streams instead of decoding. |
fadeSeconds | 0.01 | The ramp on play, pause, seek and skip. |
context | created lazily | Supply your own AudioContext to share one. |
Methods
| Method | What it does |
|---|---|
setQueue(tracks, startIndex?) | Replace the queue, optionally starting somewhere other than the first track. |
play() / pause() | Transport. play() is async and needs a user gesture. |
next() / previous() | Skip. previous() walks what was actually played, which matters under shuffle. |
skipTo(index) | Play a specific track: what a playlist row's click handler calls. |
seek(seconds) | Seek within the current track. |
setShuffle(on) / setRepeat(mode) | Repeat is "none", "one" or "all". |
setVolume(v) / setMuted(on) | Volume is 0 to 1. |
setInserts(nodes) | Splice your own nodes into the graph, for an EQ or effects. |
queue | The tracks you queued, in queue order. A copy: call setQueue to change it. |
analyser | An AnalyserNode tap for a spectrum or waveform. |
destroy() | Tear everything down and close the context. |
Events
engine.on(name, fn) returns an unsubscribe function.
| Event | Fires when |
|---|---|
trackchange | A boundary was crossed. Carries what the transition actually was, which is not always what you asked for. |
timeupdate | Every frame while playing, after a seek, and at the end. |
durationchange | A track loaded and its length is known. |
play / pause / ended | Transport state changed. |
loadstart / loadedmetadata / canplay | Loading progress for a track. |
error | Something failed. Carries a coded EkoError you can branch on rather than a string. |