Live demo EKO for Mac GitHub
DOCUMENTATION

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.

EKO Web is a separate product from the EKO Mac app. They share a name and a design language and nothing else: EKO is an app you listen with, EKO Web is a library you build with. Open source under the MIT license.

What it gives you over an <audio> tag

Install

$ npm install @rpxl/eko-web

React and Vue are optional peer dependencies. Install whichever you use, or neither.

Ships ESM and CJS with types. The core has no runtime dependencies, and nothing runs at import time, so it is safe to import during a server render.

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());
It has to be a click, or a tap, or a keypress. Browsers refuse to start audio without one, and calling 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>
These three are tested, not just written: 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.

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

OptionDefaultWhat it does
transition"gapless""gapless", "crossfade" or "gap".
crossfadeSeconds3Overlap length. Ignored unless crossfading.
normalize"auto""auto", "tags", "measure" or false.
targetLufs-16The level normalization aims for.
source"auto""auto", "buffer" or "element".
bufferMaxBytes50 MBAbove this, "auto" streams instead of decoding.
fadeSeconds0.01The ramp on play, pause, seek and skip.
contextcreated lazilySupply your own AudioContext to share one.

Methods

MethodWhat 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.
queueThe tracks you queued, in queue order. A copy: call setQueue to change it.
analyserAn AnalyserNode tap for a spectrum or waveform.
destroy()Tear everything down and close the context.

Events

engine.on(name, fn) returns an unsubscribe function.

EventFires when
trackchangeA boundary was crossed. Carries what the transition actually was, which is not always what you asked for.
timeupdateEvery frame while playing, after a seek, and at the end.
durationchangeA track loaded and its length is known.
play / pause / endedTransport state changed.
loadstart / loadedmetadata / canplayLoading progress for a track.
errorSomething failed. Carries a coded EkoError you can branch on rather than a string.
There are three working demos in the repo, the same player built with no framework, with React and with Vue, so you can compare what each binding actually buys you. Try the live one.