Skip to content

Convert more functions to async/await #91

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 1 commit into
base: dev
Choose a base branch
from
Draft
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
194 changes: 99 additions & 95 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -841,7 +841,6 @@ let audioElement = [],
supportsFileSystemAPI, // browser supports File System API (may be disabled via config.yaml)
useFileSystemAPI, // load music from local device when in web server mode
userPresets,
waitingMetadata = 0,
wasMuted, // mute status before switching to microphone input
webServer; // web server available? (boolean)

Expand Down Expand Up @@ -1140,14 +1139,22 @@ const toggleDisplay = ( el, status ) => {
el.style.display = status ? '' : 'none';
}

// promise-compatible `onloadeddata` event handler for media elements
const waitForLoadedData = async audioEl => new Promise( ( resolve, reject ) => {
/**
* Wait for a media element to load data
* @param {HTMLMediaElement} audioEl
* @returns {Promise<void>}
*/
const waitForLoadedData = audioEl => new Promise((resolve, reject) => {
const cleanup = () => {
audioEl.onloadeddata = null;
audioEl.onerror = null;
};
audioEl.onerror = () => {
audioEl.onerror = audioEl.onloadeddata = null;
reject();
}
cleanup();
reject(new Error("Failed to load media element"));
};
audioEl.onloadeddata = () => {
audioEl.onerror = audioEl.onloadeddata = null;
cleanup();
debugLog( 'onLoadedData', { mediaEl: audioEl.id.slice(-1) } );
resolve();
};
Expand Down Expand Up @@ -1219,52 +1226,49 @@ function addMetadata( metadata, target ) {
* @param {object} { album, artist, codec, duration, title }
* @returns {Promise} resolves to 1 when song added, or 0 if queue is full
*/
function addSongToPlayQueue( fileObject, content ) {
async function addSongToPlayQueue( fileObject, content ) {

return new Promise( resolve => {
if ( queueLength() >= MAX_QUEUED_SONGS ) {
resolve(0);
return;
}
if ( queueLength() >= MAX_QUEUED_SONGS ) {
return 0;
}

const { fileName, baseName, extension } = parsePath( fileExplorer.decodeChars( fileObject.file ) ),
uri = normalizeSlashes( fileObject.file ),
newEl = document.createElement('li'), // create new list element
trackData = newEl.dataset;
const { fileName, baseName, extension } = parsePath( fileExplorer.decodeChars( fileObject.file ) ),
uri = normalizeSlashes( fileObject.file ),
newEl = document.createElement('li'), // create new list element
trackData = newEl.dataset;

Object.assign( trackData, DATASET_TEMPLATE ); // initialize element's dataset attributes
Object.assign( trackData, DATASET_TEMPLATE ); // initialize element's dataset attributes

if ( ! content )
content = parseTrackName( baseName );
if ( ! content )
content = parseTrackName( baseName );

trackData.album = content.album || '';
trackData.artist = content.artist || '';
trackData.title = content.title || fileName || uri.slice( uri.lastIndexOf('//') + 2 );
trackData.duration = content.duration || '';
trackData.codec = content.codec || extension.toUpperCase();
trackData.album = content.album || '';
trackData.artist = content.artist || '';
trackData.title = content.title || fileName || uri.slice( uri.lastIndexOf('//') + 2 );
trackData.duration = content.duration || '';
trackData.codec = content.codec || extension.toUpperCase();
// trackData.subs = + !! fileObject.subs; // show 'subs' badge in the playqueue (TO-DO: resolve CSS conflict)

trackData.file = uri; // for web server access
newEl.handle = fileObject.handle; // for File System API access
newEl.dirHandle = fileObject.dirHandle;
newEl.subs = fileObject.subs; // only defined when coming from the file explorer (not playlists)
trackData.file = uri; // for web server access
newEl.handle = fileObject.handle; // for File System API access
newEl.dirHandle = fileObject.dirHandle;
newEl.subs = fileObject.subs; // only defined when coming from the file explorer (not playlists)

playlist.appendChild( newEl );
playlist.appendChild( newEl );

if ( FILE_EXT_AUDIO.includes( extension ) || ! extension ) {
// disable retrieving metadata of video files for now - https://github.com/Borewit/music-metadata-browser/issues/950
trackData.retrieve = 1; // flag this item as needing metadata
retrieveMetadata();
}
if ( FILE_EXT_AUDIO.includes( extension ) || ! extension ) {
// disable retrieving metadata of video files for now - https://github.com/Borewit/music-metadata-browser/issues/950
trackData.retrieve = 1; // flag this item as needing metadata
await retrieveMetadata();
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't really need to wait for retrieveMetadata(), as it doesn't return any value and can run in parallel. I could be mistaken, but I think this would slow down the process of adding multiple files to the play queue.

Copy link
Author

@Borewit Borewit Jul 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see no limit with this recursion, so this could potentially spin up a large number of tasks, equal to the total queued items. This can easily drain memory and become a bottleneck, rather then a speed boost. I strongly advice against that. If you do:

  1. Limit the number of parallel tasks to max 4
  2. Handle the promise in the recursion

It has been proved it can become an issue, and therefor I even documented strategies against it: https://github.com/Borewit/music-metadata?tab=readme-ov-file#how-can-i-traverse-a-long-list-of-files

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The limit control is done in retrieveMetadata() itself. A recursive call is made at the end, after a request resolves.

async function retrieveMetadata() {
	// leave when we already have enough concurrent requests pending
	if ( waitingMetadata >= MAX_METADATA_REQUESTS )
		return;

https://github.com/hvianna/audioMotion.js/blob/a6b6e3e45fd8f0dfa69a485309e53d9969757eb0/src/index.js#L3299C1-L3302C10

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In see.
In that case better to take Math.min(MAX_METADATA_REQUESTS, queue.length) from the queue, and run those. No need to introduce an arbitrary counter for that.

Sorry, I have not analyzed what you do with the remainder of the queue, and how you trigger new queue processing requests.

If the goal is to process the entire queue with respecting MAX_METADATA_REQUESTS in parallel, I suggest using something like p-limit, to easily control the number of parallel async tasks.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In see. In that case better to take Math.min(MAX_METADATA_REQUESTS, queue.length) from the queue, and run those. No need to introduce an arbitrary counter for that.

I don't think it's so simple, as queue here is the entire play queue, not just the files waiting to be parsed for metadata.

Copy link
Author

@Borewit Borewit Jul 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MAX_METADATA_REQUESTS seems to serve a dual role: it limits not only the number of parallel metadata requests, but also the number of tracks in the queue for which metadata will be retrieved. This means only a few tracks will ever get metadata assigned, while the rest remain untouched. Is it really beneficial to prioritize just the first few, while potentially ignoring the rest?

It’s true that metadata retrieval is an expensive operation. The cost breaks down into:

  1. I/O time: reading the file (can benefit from parallelization, especially for remote files)
  2. Processing time: extracting metadata (runs on the main thread, inherently single-threaded in JavaScript)

But more important than parallelism or CPU-bound limits is something that hasn't been discussed yet:

Metadata extraction, even when well-structured, runs on the main thread. If we attempt to process the entire queue at once (even asynchronously), we risk blocking the UI and degrading application responsiveness. The most important goal in my onion, is not get the metadata fast, it’s to do so without compromising user experience.

Correct me if I am wrong, the rationale behind MAX_METADATA_REQUESTS is to balance responsiveness with progressive enhancement. But currently, it limits the total number of tracks processed, not just concurrency, which results in metadata being retrieved only for a small portion of the playlist.

Taking a step back, and doing one step at a time:

I’ve updated the code to restore controlled parallel processing, ensuring that we still respect performance limits while improving structure through async/await. I also removed waitingMetadata, and all promises are now properly tracked.

The main goal of this PR remains improving maintainability and clarity, without sacrificing UX.

Maybe introduce a better way of processing the metadata in a different PR. Maybe run the metadata extraction in a worker thread?

Looking forward to your thoughts, @hvianna.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MAX_METADATA_REQUESTS seems to serve a dual role: it limits not only the number of parallel metadata requests, but also the number of tracks in the queue for which metadata will be retrieved. This means only a few tracks will ever get metadata assigned, while the rest remain untouched.

😮 If it's only retrieving the metadata for the first four entries in the queue, something is off. Its intended purpose is solely to limit the number of concurrent requests, and it should parse all tracks in the queue eventually. You can check how it's working the dev branch.

Maybe introduce a better way of processing the metadata in a different PR. Maybe run the metadata extraction in a worker thread?

Would appreciate your help on that, once we have this current version released!

Copy link
Author

@Borewit Borewit Jul 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well on the dev branch that is what retrieveMetadata does.

But retrieveMetadata is called by addSongToPlayQueue and addSongToPlayQueue is also spinned in parallel.

So on the dev branch the number of parallel tasks spinned up, equal the number of tracks you added, by the total number of tracks remaining without metadata on the queue. Then a lot of things are trying to do the same thing, and somewhere in that mess waitingMetadata limits the number of parallel tasks.

Here Knuth's optimization principle comes into play:

Premature optimization is the root of all evil. ref

My advice:

  1. Use async/await where possible. Easier to read, less likely to make mistakes.
  2. All Promise are handled, including and rejected (errors) are never completely ignored, If you an error in the your browser of unhanded promise, fix it.
  3. Only perform parallel execution when everything else is under control. Good structure will give you more performance advantage, I promise.

}

if ( queueLength() == 1 && ! isPlaying() )
loadSong(0).then( () => resolve(1) );
else {
if ( playlistPos > queueLength() - 3 )
loadSong( NEXT_TRACK );
resolve(1);
}
});
if ( queueLength() === 1 && ! isPlaying() ) {
await loadSong( 0 );
} else {
if ( playlistPos > queueLength() - 3 )
await loadSong( NEXT_TRACK );
}
return 1;
}

/**
Expand Down Expand Up @@ -2017,7 +2021,7 @@ function keyboardControls( event ) {
}

/**
* Sets (or removes) the `src` attribute of a audio element and
* Sets (or removes) the `src` attribute of an audio element and
* releases any data blob (File System API) previously in use by it
*
* @param {object} audio element
Expand All @@ -2040,22 +2044,28 @@ function loadAudioSource( audioEl, newSource ) {
/**
* Load a file blob into an audio element
*
* @param {object} audio element
* @param {object} file blob
* @param {boolean} `true` to start playing
* @returns {Promise} resolves to a string containing the URL created for the blob
* @param {Blob} fileBlob The audio file blob
* @param {HTMLAudioElement} audioEl The audio element
* @param {boolean} playIt When `true` will start playing
* @returns {Promise<string>} Resolves to blob URL
*/
async function loadFileBlob( fileBlob, audioEl, playIt ) {
const url = URL.createObjectURL( fileBlob );
loadAudioSource( audioEl, url );
async function loadFileBlob(fileBlob, audioEl, playIt) {
const url = URL.createObjectURL(fileBlob);
loadAudioSource(audioEl, url);

try {
await waitForLoadedData( audioEl );
if ( playIt )
audioEl.play();
await waitForLoadedData(audioEl);
if (playIt) {
try {
await audioEl.play();
} catch (err) {
consoleLog("Playback failed:", err);
}
}
return url;
} catch (err) {
throw new Error("Failed to load audio from Blob");
}
catch ( e ) {}

return url;
}

/**
Expand Down Expand Up @@ -3295,60 +3305,54 @@ async function retrieveBackgrounds() {
}

/**
* Retrieve metadata for files in the play queue
* Retrieve metadata for the first MAX_METADATA_REQUESTS files in the play queue,
* which have no metadata assigned yet
*/
async function retrieveMetadata() {
// leave when we already have enough concurrent requests pending
if ( waitingMetadata >= MAX_METADATA_REQUESTS )
return;

// find the first play queue item for which we haven't retrieved the metadata yet
const queueItem = Array.from( playlist.children ).find( el => el.dataset.retrieve );

if ( queueItem ) {
// find the first MAX_METADATA_REQUESTS items for which we haven't retrieved the metadata yet
const retrievalQueue = Array.from(playlist.children).filter(el => el.dataset.retrieve).slice(0, MAX_METADATA_REQUESTS);

// Execute in parallel
return Promise.all(retrievalQueue.map(async queueItem => {
let uri = queueItem.dataset.file,
revoke = false;

waitingMetadata++;
delete queueItem.dataset.retrieve;

queryMetadata: {
if ( queueItem.handle ) {
try {
if ( await queueItem.handle.requestPermission() != 'granted' )
break queryMetadata;

uri = URL.createObjectURL( await queueItem.handle.getFile() );
revoke = true;
}
catch( e ) {
break queryMetadata;
}
if ( queueItem.handle ) {
try {
if ( await queueItem.handle.requestPermission() !== 'granted' )
return;

uri = URL.createObjectURL( await queueItem.handle.getFile() );
revoke = true;
}
catch( e ) {
consoleLog(`Error converting queued file="${queueItem.handle.file}" to URI`, e);
return;
}
}

try {
const metadata = await mm.fetchFromUrl( uri, { skipPostHeaders: true } );
if ( metadata ) {
addMetadata( metadata, queueItem ); // add metadata to play queue item
try {
const metadata = await mm.fetchFromUrl( uri, { skipPostHeaders: true } );
Comment on lines +3324 to +3339
Copy link
Owner

@hvianna hvianna Jul 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Edit: just saw your previous comment on this..
Did you inadvertently revert this? It's still using fetchFromUrl(). I think this is now dependent on changes in #89

addMetadata( metadata, queueItem ); // add metadata to play queue item
syncMetadataToAudioElements( queueItem );
if ( ! ( metadata.common.picture && metadata.common.picture.length ) ) {
getFolderCover( queueItem ).then( cover => {
queueItem.dataset.cover = cover;
syncMetadataToAudioElements( queueItem );
if ( ! ( metadata.common.picture && metadata.common.picture.length ) ) {
getFolderCover( queueItem ).then( cover => {
queueItem.dataset.cover = cover;
syncMetadataToAudioElements( queueItem );
});
}
}
});
}
catch( e ) {}

if ( revoke )
URL.revokeObjectURL( uri );
}
catch( e ) {
consoleLog(`Failed to fetch or add metadata for queued file="${queueItem.handle.file}"`, e);
}

waitingMetadata--;
retrieveMetadata(); // call again to continue processing the queue
}
if ( revoke )
URL.revokeObjectURL( uri );
}));
}

/**
Expand Down
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy