Skip to content

Repository files navigation

EKO Web

A web audio engine you build real players with.

True gapless playback, crossfade, loudness normalization and ReplayGain tags. 6.6 kB gzipped, no runtime dependencies, and no framework of its own.

npm CI license bundle

Live demo  ·  Documentation  ·  npm

The player running, with a live segmented spectrum

The live demo, running this library. Not a mockup.

npm install @rpxl/eko-web
import { EkoWebEngine } from "@rpxl/eko-web";

const engine = new EkoWebEngine(); // gapless by default
engine.setQueue([
  { id: "1", src: "/audio/01.flac" },
  { id: "2", src: "/audio/02.flac" }, // starts the instant track 1 ends
]);
await engine.play(); // call from a click: browsers require it

It's the web-grade sibling of the native EKO player. EKO on the desktop is bit-perfect; EKO Web is not, and doesn't pretend to be. See the scope below.

What it does (and what it honestly can't)

✅ What you get over a bare <audio> tag:

  • True gapless: albums, live sets, classical, DJ mixes flow with zero silence between tracks (sample-accurate scheduling, not a setTimeout hack).
  • Crossfade, when you want it instead: a boundary is one decision, not two features. Set transition to "gapless", "crossfade" or "gap" and the engine schedules the rest. Both sides of a crossfade keep their own normalization gain while they overlap, so the blend doesn't undo the loudness work.
  • Loudness normalization: ReplayGain/LUFS so tracks don't jump in volume across a playlist. The single most audible "this sounds better" win on the web. Uses a track's own precomputed gain tag when you have one, or measures it from the decoded audio when you don't.
  • Click-free transport: play, pause and seek all ramp instead of cutting, with no API of their own. It just happens.
  • A queue that drives the prefetch: shuffle, repeat one, repeat all, and a back button that walks what you actually played rather than the index below you. The queue has to live in the library because gapless arms the next track during the current one, so something has to answer "what plays next" before the boundary arrives.
  • A real audio graph: clean gain staging, an insert point for your own EQ or effects (setInserts), and an analyser tap for a spectrum or waveform.
  • A streaming fallback for long files: a DJ set or a podcast streams through an <audio> element instead of being decoded whole, so memory stays flat at any length. You lose sample-accurate gapless on that track's boundaries; everything else still applies.

❌ What it cannot do, and why:

  • It is not bit-perfect and not "higher fidelity." Every browser decodes the file, resamples it to the output device's rate, and mixes it through the OS. eko-web rides the same path, so a single track sounds identical to a plain <audio> element. There is no exclusive-mode / hog-mode device access from a web page, so bit-perfect output is physically off the table. That's a browser/OS limit, not an effort one.

In short: eko-web makes the experience better (gapless + sane loudness), not the fidelity. If you need bit-perfect, that's what the native EKO app is for.

Install

npm install @rpxl/eko-web   # or: pnpm add @rpxl/eko-web
The demo player, light theme The demo player, dark theme

Quick start

import { EkoWebEngine } from "@rpxl/eko-web";

const engine = new EkoWebEngine({ normalize: true }); // gapless is the default transition

engine.on("timeupdate", ({ currentTime, duration }) => {
  /* update your UI */
});

engine.setQueue([
  { id: "1", src: "/audio/track1.flac" },
  { id: "2", src: "/audio/track2.flac" }, // plays gaplessly after track 1
]);
await engine.play(); // resumes the AudioContext on this user gesture

Queue and transport

const engine = new EkoWebEngine({ transition: "crossfade", crossfadeSeconds: 3 });

engine.setQueue(tracks, 4); // start on the fifth track
engine.setShuffle(true);
engine.setRepeat("all"); // or "one" to loop the current track

engine.next();
engine.previous(); // steps back through what actually played, which matters under shuffle
engine.skipTo(9); // what a playlist row's click handler calls
engine.queue; // the tracks you queued, in queue order (a copy)

engine.subscribe(() => {
  const { index, queueLength, track, lastTransition } = engine.getSnapshot();
});

subscribe plus getSnapshot is the useSyncExternalStore contract, so a React or Vue binding is a few lines. The snapshot deliberately leaves out currentTime: it changes every frame and would re-render your whole tree at 60fps. Read that from the engine.

React (@rpxl/eko-web/react)

import { useEkoWebEngine, useEkoPlayer, useEkoTime } from "@rpxl/eko-web/react";

const TRACKS = [
  { id: "1", src: "/audio/01.mp3" },
  { id: "2", src: "/audio/02.mp3" },
];

function Player() {
  const engine = useEkoWebEngine({ transition: "gapless" }, { queue: TRACKS });
  const { paused, index, queueLength, play, pause, next } = useEkoPlayer(engine);
  return (
    <>
      <button onClick={paused ? play : pause}>
        {paused ? "Play" : "Pause"} {index + 1} / {queueLength}
      </button>
      <Progress engine={engine} />
    </>
  );
}

function Progress({ engine }) {
  const { currentTime, duration } = useEkoTime(engine); // only this re-renders per frame
  return <progress value={currentTime} max={duration || 1} />;
}

useEkoWebEngine owns the engine. It builds one when the component mounts and destroys it on unmount, so the AudioContext is released. It also survives StrictMode's development double mount. Options like transition and crossfadeSeconds are only read by the constructor, so when one changes the hook builds a new engine and carries over the queue, the position in it, shuffle, repeat, volume and mute. Playback stops at that point. The queue in the second argument is loaded once, after mount, so a server render never starts a fetch.

useEkoPlayer is useSyncExternalStore over the engine's snapshot, so it is concurrent-safe and gives the right value during server rendering. useEkoTime is separate on purpose: currentTime changes every frame, and keeping it out of the snapshot means scrubbing re-renders your progress bar instead of your track list.

If you would rather own the engine yourself (one shared by the whole app, say), skip useEkoWebEngine and pass your own to the other two hooks. Remember to call engine.destroy() when you are done with it.

Vue 3 (@rpxl/eko-web/vue)

<script setup>
import { useEkoWebEngine, useEkoPlayer, useEkoTime } from "@rpxl/eko-web/vue";

const TRACKS = [
  { id: "1", src: "/audio/01.mp3" },
  { id: "2", src: "/audio/02.mp3" },
];
const engine = useEkoWebEngine({ transition: "gapless" }, { queue: TRACKS });
const player = useEkoPlayer(engine); // a readonly ref over the snapshot
const { currentTime, duration } = useEkoTime(engine);
</script>

<template>
  <button @click="player.paused ? player.play() : player.pause()">
    {{ player.paused ? "Play" : "Pause" }} {{ player.index + 1 }} / {{ player.queueLength }}
  </button>
  <progress :value="currentTime" :max="duration || 1" />
</template>

useEkoWebEngine returns a readonly ref, which the other two composables take as is. The engine is destroyed when the component unmounts (or when an effectScope it runs in stops). Pass the options as a ref or a getter, for example () => ({ transition: mode.value }), and changing them rebuilds the engine the same way the React hook does.

Installing

Both frameworks are optional peer dependencies, so you install whichever you use and neither ends up in the other's bundle. The library itself has no runtime dependencies.

Loudness tags (@rpxl/eko-web/replaygain)

import { readReplayGain } from "@rpxl/eko-web/replaygain";

const { gainDb, peak } = await readReplayGain("/audio/track.flac");
engine.setQueue([{ id: "1", src: "/audio/track.flac", gainDb }]);

Reads ReplayGain tags out of a file's header: 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, nothing else.

It asks for the first 64KB with an HTTP Range request rather than pulling the whole file, and only fetches the tail if the head had no tags, which is where APEv2 and non-faststart MP4 keep theirs. A server that ignores Range just sends everything and that works too.

A missing tag is never an error. A 404, a CORS rejection, a file that is not audio: all of them resolve to an empty result, because this runs on the path to playing a track and a thrown error there would turn a missing tag into a track that will not play.

The engine does not import this. That is deliberate: it stays opt-in so a player that does not want tag parsing does not ship the parsers. Reading the tag is your call, and the engine takes the answer as gainDb.

Where normalization gets its number

new EkoWebEngine({ normalize: "auto" }); // the default
  • "auto" prefers a track's gainDb, and measures the decoded audio when there is none.
  • "tags" uses gainDb only, and leaves a track alone when it has none.
  • "measure" always measures and ignores gainDb.
  • false applies no normalization.

true still means "auto". Measuring needs the decoded samples, so on the streaming path (long files played through an <audio> element) there is nothing to measure: "auto" falls back to unity gain there and warns once, not once per track.

Lock screen and media keys (@rpxl/eko-web/media-session)

import { attachMediaSession } from "@rpxl/eko-web/media-session";

const detach = attachMediaSession(engine, {
  metadata: (track) => ({ title: track.id, artist: "...", artwork: [...] }),
});

Wires navigator.mediaSession to the engine: the transport actions, the metadata, and the position state on a throttle rather than every frame.

The reason this is a module and not 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, so it stays right.

It feature-detects, so it is a harmless no-op in a browser without the API, and it registers each action independently, because browsers support different subsets and one unsupported action should not take the rest down with it.

Drop into an existing <audio>-based player

eko-web ships an HTMLMediaElement-compatible facade (@rpxl/eko-web/element) so it slots into players that drive an <audio> element (e.g. via a mediaRef) with no fork: you immediately get loudness normalization. (True gapless needs the engine to own the queue; see the docs.)

Status

v0.1, feature-complete engine, not yet published to npm. Done: the engine (buffer playback with true gapless queueing, crossfade, a streaming fallback for long files, loudness normalization from a tag or measured, click-free play/pause/seek), the queue (shuffle, repeat, history), the replaygain and media-session subpaths, React and Vue bindings, coded errors you can branch on, the EkoAudioElement facade (@rpxl/eko-web/element), and the ear-test player in examples/player/. See examples/README.md; it needs a build first, it is not a no-build page. 417 unit tests, dual ESM/CJS build with types.

Not yet: a WebCodecs source strategy.

The example player is the fastest way to hear the parts a test can't prove. Load a few files, then try crossfade against gapless against gap on the same boundary, and shuffle with the queue position readout visible.

Contributing

CONTRIBUTING.md covers the setup, the scope decisions, and the standard tests are held to here. The short version: the core stays framework-independent, the library never claims fidelity it cannot deliver, and a test that has never failed has not been verified.

Anything you can hear is the most valuable kind of bug report. The suite asserts scheduling maths and cannot hear a click.

License

MIT. See LICENSE.

About

EKO's web playback engine: gapless and loudness-normalized Web Audio, framework-agnostic.

Resources

Code of conduct

Contributing

Security policy

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages