npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

yuzic-engine

v1.2.5

Published

Audio playback engine for yuzic — React Native, built to own crossfade, DSP and caching outright

Readme

yuzic-engine

An audio playback engine for React Native, built around an audio graph rather than a single player. Written for yuzic, a self-hosted music client, and usable on its own.

Apache-2.0. iOS and Android.

import { YuzicEngine } from 'yuzic-engine';

await YuzicEngine.setup({ progressIntervalMs: 1000 });
await YuzicEngine.setQueue(tracks, 0);
await YuzicEngine.setCrossfade({ durationSec: 8, mode: 'gapless-aware' });
await YuzicEngine.play();

const off = YuzicEngine.addListener(event => {
  if (event.type === 'progress') draw(event.progress);
});

Why a graph

Two tracks have to be audible at once for a crossfade, and an equalizer has to sit somewhere in the signal path. Neither is expressible against an API that plays one URL at a time — you can fade a single output down and back up, but the join is a hole rather than an overlap.

So the engine keeps two voices, each a player node feeding its own gain, summed into a mixer, through an EQ, to the output. A crossfade is the window where both are running and their gains are moving in opposite directions. A gapless join is the same machinery with a zero-length fade. Everything else — replay gain, speed, the equalizer — is a node in a graph that already exists.

The cost is that the unglamorous work is yours: audio sessions, interruptions, route changes, decoding, caching, and the lock screen. Most of this repository is that work, and docs/architecture.md explains the ten decisions it rests on.

What it does

Queue, natively. Set, append, insert, remove, move, clear; skip to next, previous or an index; read it back. The queue lives in native code because backgrounded JavaScript is suspended and the lock screen, the notification and the car have to keep working anyway.

Transport. Play, pause, stop, seek, volume, speed (0.25×–4×), repeat (off / one / all).

Crossfade. Configurable duration, with two modes: gapless-aware hard-cuts where a track is marked as following the previous one, so a segued album is not faded through its own joins; always fades everything. A user-initiated skip always cuts, because a fade after a button press reads as lag.

DSP. A ten-band equalizer, bypassed entirely when flat, and replay gain with album/track/auto modes and clipping protection.

Sources. Local files, and HTTP streaming with auth in query parameters or headers. Mutual TLS can import a PKCS#12 identity in memory and presents the same identity for ordinary server API requests and Media3/Core Audio streaming. Remote audio is fetched through a byte source with an on-device LRU cache keyed by media id — not by URL, because Subsonic and Jellyfin hand out URLs carrying a token that rotates, and keying on those re-downloads the same audio every session.

Platform integration. Background playback, lock-screen and notification controls with artwork, CarPlay, Android Auto and Android Automotive browse trees, audio focus, interruptions, route changes, becoming-noisy, and a sleep timer that fades rather than cuts.

Events. State changes, track changes with the time actually listened, progress, queue changes, and errors.

Formats

Core Audio and Media3 between them cover MP3, AAC/M4A, ALAC, FLAC and WAV. Two that they do not, and this engine decodes itself:

| | iOS | Android | | --- | --- | --- | | Ogg Vorbis | libvorbis, vendored | Media3 | | Ogg Opus | libopus, vendored | Media3 |

iOS has no decoder for either — an .ogg or .opus cannot be opened by Core Audio at all, so the failure is total rather than a quality loss. For a self-hosted library stored in one of them that is the difference between working and not, so libogg, libvorbis, libopus and libopusfile are vendored under ios/Vendor (BSD-3, see NOTICE) and decoded through the same cache and ranged requests as everything else. The decoder is chosen by reading the codec out of the identification packet in the first Ogg page, not by file extension — a stream URL does not have one.

Platform state

Derived from the modules rather than remembered, because this section has been wrong before by asserting a parity that had stopped being true hours earlier.

| | iOS | Android | | --- | --- | --- | | Playback, queue, transport | yes | yes | | Crossfade | yes | yes | | Equalizer, replay gain | yes | yes | | Lock screen, car | yes | yes | | Disk cache | yes | yes | | Mutual TLS for API and audio | yes | yes | | Cache management | yes | all but configureCache |

Android has always cached — SimpleCache sits in the data source chain, keyed by the host's MediaId so a rotating Subsonic or Jellyfin token does not re-download the same album. It was the management API that was missing, and only configureCache still is: Media3's evictor takes its limit as a constructor argument, so changing it needs either a second SimpleCache over one directory (documented as corrupting the index) or releasing the live one mid-track.

A method a platform lacks rejects with its own name and that platform's — setSpeed() is not implemented on android — rather than arriving as undefined and failing as a type error somewhere unrelated.

Using it

An Expo module. Add it to plugins in app.json so its config plugin can run: it declares background audio and the CarPlay scene, and without it the CarPlay screen never appears and nothing logs to say why.

{ "expo": { "plugins": ["yuzic-engine"] } }

Two things the plugin cannot do for you:

  1. The com.apple.developer.carplay-audio entitlement is granted by Apple per app, on request. Until it is, none of the CarPlay support appears in a car. The plugin deliberately does not fabricate the entitlement, because that trades a clear message for a signing failure.
  2. If your project commits its ios/ directory, the plugin's Info.plist changes only land on a prebuild.

To try CarPlay without a car: Xcode's Simulator has I/O → External Displays → CarPlay, which needs the entitlement the same way a head unit does.

On Android the car declarations are in the library's own manifest, so they merge into your app without a prebuild: the Android Auto key for a phone, and for Android Automotive in the car both its own key and the opt-in on the media service that puts the app in the car's media app. What the engine leaves to you is <uses-feature android:name="android.hardware.type.automotive" android:required="true" />, which Play expects on a build meant for cars and which makes a phone build uninstallable on phones.

To try Android without a car: Android Auto's Desktop Head Unit needs a real phone, and Play will not install Android Auto on an emulator. An Android Automotive emulator image works: install the app and pick it from the media app's source list. It exercises the case that matters most, too: force-stop the app and open the car's media app, and the library and playback both work with no JavaScript running.

Call clearBrowseTree() at sign-out. On Android the engine keeps the last tree and the last queue, encrypted, so that a car can show and resume them with the app closed, and clearing is what deletes them.

Give every browse node a unique id, such as its path. The same track under two folders needs two node ids; the Track inside keeps its own.

On iOS, a car can launch the app into its CarPlay scene with no phone window. If your host starts React Native only from its phone scene, start it when a CPTemplateApplicationScene connects too, or the car shows an empty library.

Building and testing

npm install
npm run typecheck    # both tsconfigs — see below
npm test             # the TypeScript side
swift test           # the iOS core: 390 tests, no Xcode project, no app

ios/Core is a SwiftPM target as well as part of the pod, which is what lets the engine's logic be built and run on any Mac with no app around it. The bridge in ios/YuzicEngineModule.swift is podspec-only and is therefore compiled by an app build and nothing else — worth knowing before trusting a green swift test on a change to it.

npm run typecheck runs tsconfig.json and tsconfig.build.json. The second is the one prepare uses when this package is installed from git, and it once diverged far enough that the package could not be installed at all while every local check passed.

Tools/mutate.py breaks one real behaviour at a time and checks that a test notices. It exists because a test suite can be green and vacuous — one of these was named for the crossfade and could not observe the crossfade curve.

Tools/parity.py compares the two native modules by signature, not by name, and exits non-zero when they disagree:

python3 Tools/parity.py

A name-only diff — which is what this repository used to recommend — is silent about the failure that actually happened: a setBrowseTree present on both platforms whose arity differed, arriving at a call site as a type error somewhere unrelated. This resolves each Record parameter to its field shape before comparing, so the platforms are free to name the same wire type differently (BrowseNodeRecord and FlatBrowseNodeRecord are the same six fields) without that reading as a mismatch. Deliberate gaps are declared in KNOWN_GAPS with the reason; a gap that gets closed is reported as a stale entry, so the list cannot quietly become a record of what used to be true.

Contributing

See CONTRIBUTING.md, which covers the one rule about where code may come from.