aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/music-player.js
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-24 21:08:34 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-24 21:08:34 +0200
commitf4ed927fa873133c5fed73fb7dd60e7747fc3dc2 (patch)
treed126c5e505c342ee6b02aadc1e46b1a026c2a027 /packages/meshbay-hub/src/meshbay_hub/static/music-player.js
parent75d1f8b93dfa0bffda3a59a6d143b06dcc3ca67f (diff)
downloadmeshbay-f4ed927fa873133c5fed73fb7dd60e7747fc3dc2.tar.gz
feat(music): transcode WMA/Musepack to AAC so they actually play
Tagging and covers for these two formats landed already, but neither one decodes in any mainstream browser's <audio> element at all — a real library scan turned up 273 such files that would show up correctly in the Music app and then simply fail on click. This closes that gap: the node transcodes to AAC/M4A on request (a one-shot whole-file conversion, not live-piped like video's fMP4 segments — an audio file is small enough that streaming it buys nothing), caches the result under its own content hash the same way a TMDB poster or a MusicBrainz cover is cached, and serves it back through the ordinary file_req/chunk path. That path used to assume anything in the media cache was thumbnail-sized (single chunk, always); generalized it to slice a cached blob the same way a real file on disk gets sliced, since a transcoded track can be several MB. New MNP pair (`audio_transcode_req`/`_resp`, version bump to 0.9), shares its concurrency cap with video's transcode pool rather than getting its own — both are real ffmpeg processes on the same node. Every other audio format is untouched: this only fires for .wma/.mpc, the two extensions that need it.
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/music-player.js')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/music-player.js45
1 files changed, 37 insertions, 8 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/music-player.js b/packages/meshbay-hub/src/meshbay_hub/static/music-player.js
index 690efc9..b75d73c 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/music-player.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/music-player.js
@@ -16,11 +16,15 @@ import { CHUNK_SIZE, pipelinedDownload } from './file-utils.js';
* calls `onPlayQueue(tracks, startIndex)`, threaded down from group-page.js,
* to hand this component a new queue.
*
- * No streaming, no MSE, no node-side transcode pool: a track is a few
- * megabytes, so it is downloaded and decrypted once through the same chunk
- * pipeline Files already uses (file-utils.js's pipelinedDownload), then
- * played from a blob URL — the deliberate simplification recorded in
- * musicbay.md §2.2.
+ * No streaming, no MSE, no node-side transcode pool for the common case: a
+ * track is a few megabytes, so it is downloaded and decrypted once through
+ * the same chunk pipeline Files already uses (file-utils.js's
+ * pipelinedDownload), then played from a blob URL — the deliberate
+ * simplification recorded in musicbay.md §2.2. WMA and Musepack are the one
+ * exception (`NEEDS_TRANSCODE_RE` below): neither decodes in a browser's
+ * <audio> element at all, tagged correctly or not, so those two go through
+ * `transport.requestAudioTranscode` first — a node-side, cached-after-once
+ * AAC/M4A conversion — before the same download/blob path runs.
*/
const MIME_BY_EXT = {
@@ -28,6 +32,11 @@ const MIME_BY_EXT = {
wav: 'audio/wav', aac: 'audio/aac', m4a: 'audio/mp4',
};
+// Kept in sync with the node's BROWSER_INCOMPATIBLE_AUDIO_EXTS
+// (webrtc_server.py) — both name the same two formats no mainstream
+// browser's <audio> element decodes natively.
+const NEEDS_TRANSCODE_RE = /\.(wma|mpc)$/i;
+
function guessMime(name) {
const ext = (name || '').split('.').pop().toLowerCase();
return MIME_BY_EXT[ext] || 'audio/mpeg';
@@ -186,10 +195,30 @@ function MusicPlayerBar({ transportRef, gekRef, queue, onClose }) {
if (cached) return cached.url;
const transport = transportRef.current;
if (!transport || !transport.connected) throw new Error(t('music.err_transport'));
- const totalChunks = Math.ceil(entry.size / CHUNK_SIZE);
- const chunks = await pipelinedDownload(transport, gekRef.current, entry.id, totalChunks);
- const blob = new Blob(chunks, { type: guessMime(entry.name) });
+
+ let downloadId = entry.id;
+ let downloadSize = entry.size;
+ let mime = guessMime(entry.name);
+ if (NEEDS_TRANSCODE_RE.test(entry.name)) {
+ let info;
+ try {
+ info = await transport.requestAudioTranscode(entry.id);
+ } catch (err) {
+ throw new Error(t('music.err_transcode'));
+ }
+ downloadId = info.hash;
+ downloadSize = info.size;
+ mime = info.mime || 'audio/mp4';
+ }
+
+ const totalChunks = Math.ceil(downloadSize / CHUNK_SIZE);
+ const chunks = await pipelinedDownload(transport, gekRef.current, downloadId, totalChunks);
+ const blob = new Blob(chunks, { type: mime });
const url = URL.createObjectURL(blob);
+ // Keyed by the track's own id, not `downloadId` — the transcode cache
+ // hash is an implementation detail of getting there, and a second play
+ // of the same track must still hit this cache rather than re-requesting
+ // a transcode the node already ran once.
blobCacheRef.current.set(entry.id, { url, order: blobInsertRef.current++ });
evictOldBlobs();
return url;