Skip to content

yuzic-engine 1.0.0

Choose a tag to compare

@eftpmc eftpmc released this 09 Sep 21:15
· 9 commits to main since this release
977e900

An audio playback engine for React Native, built around an audio graph rather than a single player. iOS and Android, Apache-2.0.

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.

The API is what 1.0.0 commits to, not the code. It has been in production in yuzic across both platforms for months. Semver here is a promise about the JavaScript surface: the methods on YuzicEngine, the event vocabulary, and the exported types.

What it does

  • Queue, natively — set, append, insert, remove, move, clear, skip. It lives in native code because backgrounded JavaScript is suspended while the lock screen, the notification and the car still have to work.
  • Crossfade with a gapless-aware mode that hard-cuts where a track follows the previous one, so a segued album is not faded through its own joins. A gapless join is the same machinery with a zero-length fade.
  • DSP — a ten-band equalizer, bypassed entirely when flat, and replay gain with album/track/auto modes.
  • Sources — local files and HTTP streaming, mutual TLS on both platforms, and an on-device LRU cache keyed by media id rather than URL, because Subsonic and Jellyfin rotate tokens through the URL.
  • Vorbis and Opus on iOS, which Core Audio cannot open at all.
  • Platform integration — background playback, lock-screen and notification controls, CarPlay and Android Auto browse trees, audio focus, interruptions, route changes, and a sleep timer that fades rather than cuts.

Fixed in this release

  • A seek no longer ends the stream it is seeking within. StreamingByteSource.cancel() stopped its producer — which for the HTTP producer cancels the task and invalidates the session, irreversibly — while resume() only cleared a flag. Since every seek cancels and resumes the source being decoded from, the first seek ended a transcoded stream for good and every read afterwards timed out: silence, then a track that ended itself. Streamed audio only; a downloaded file never takes that path.
  • An estimated length is no longer mistaken for the end of a track. For a sequential source the reported length is duration × bitrate until the stream ends, and an empty read at that figure was treated as a clean end-of-file, which the engine follows by advancing the queue.

Known gaps

configureCache is absent on Android, deliberately rather than stubbed, so it rejects by name at the bridge. Tools/parity.py declares it; every other method agrees across the two platforms by signature and event vocabulary.