diff --git a/index.html b/index.html
index a4159d29..e6b1abd2 100644
--- a/index.html
+++ b/index.html
@@ -5339,8 +5339,6 @@
Database
diff --git a/js/api.js b/js/api.js
index 092bcc69..5eb0258e 100644
--- a/js/api.js
+++ b/js/api.js
@@ -45,6 +45,14 @@ function notifyAudioSourceMissing() {
import('./downloads.js').then((m) => m.showNotification('Could not find Audio Source')).catch(() => {});
}
+// HIGH and LOW are advertised as AAC in the UI, but Qobuz doesn't serve AAC.
+// We pull a lossless FLAC source and transcode to AAC client-side
+// (see applyAudioPostProcessing), so any quality in this set must be
+// fetched as LOSSLESS even though the user-facing label says otherwise.
+function qualityNeedsLosslessSource(quality) {
+ return isCustomFormat(quality) || quality === 'HIGH' || quality === 'LOW';
+}
+
export class LosslessAPI {
constructor(settings) {
this.settings = settings;
@@ -1941,7 +1949,7 @@ export class LosslessAPI {
const id = input?.id || input;
const track = typeof input === 'object' && input.isrc ? input : await this.getTrackMetadata(id);
const isVideo = track?.type?.toLowerCase().includes('video');
- const cleanQuality = isCustomFormat(downloadQuality) ? 'LOSSLESS' : downloadQuality;
+ const cleanQuality = qualityNeedsLosslessSource(downloadQuality) ? 'LOSSLESS' : downloadQuality;
let lookup = null;
let qobuzRgInfo = null;
@@ -2072,8 +2080,10 @@ export class LosslessAPI {
let prefetchPromises = null;
try {
- // Custom FFMPEG formats are not native TIDAL qualities; download LOSSLESS and transcode
- let downloadQuality = isCustomFormat(quality) ? 'LOSSLESS' : quality;
+ // Custom FFMPEG formats (and HIGH/LOW, which are transcoded to AAC client-side
+ // because Qobuz doesn't serve AAC) are not native source qualities; download
+ // LOSSLESS and transcode in applyAudioPostProcessing.
+ let downloadQuality = qualityNeedsLosslessSource(quality) ? 'LOSSLESS' : quality;
const enriched = await this.enrichTrack(inputTrack || id, { downloadQuality });
const { lookup, enrichedTrack, isVideo } = enriched;
diff --git a/js/api.test.ts b/js/api.test.ts
index b2a8b296..d24e8e05 100644
--- a/js/api.test.ts
+++ b/js/api.test.ts
@@ -196,7 +196,7 @@ suite('Track Downloads', async () => {
preferDolbyAtmos: false,
trackId: SILENCE_TRACK,
detection: Detection.AacHigh,
- ffmpegCalls: 0,
+ ffmpegCalls: 1,
},
{
display_quality: 'Low',
@@ -205,9 +205,18 @@ suite('Track Downloads', async () => {
preferDolbyAtmos: false,
trackId: SILENCE_TRACK,
detection: Detection.AacLow,
- ffmpegCalls: 0,
+ ffmpegCalls: 1,
},
+ {
+ display_quality: 'AAC 320',
+ quality: 'FFMPEG_AAC_320',
+ container: 'flac',
+ preferDolbyAtmos: false,
+ trackId: SILENCE_TRACK,
+ detection: Detection.AacHigh,
+ ffmpegCalls: 1,
+ },
{
display_quality: 'AAC 256',
quality: 'FFMPEG_AAC_256',
@@ -217,6 +226,15 @@ suite('Track Downloads', async () => {
detection: Detection.AAC_256,
ffmpegCalls: 1,
},
+ {
+ display_quality: 'AAC 96',
+ quality: 'FFMPEG_AAC_96',
+ container: 'flac',
+ preferDolbyAtmos: false,
+ trackId: SILENCE_TRACK,
+ detection: Detection.AacLow,
+ ffmpegCalls: 1,
+ },
{
display_quality: 'MP3 320',
@@ -369,7 +387,7 @@ suite('Track Downloads', async () => {
expect(mp4.audioProperties().codec).toBe(Mp4Codec.AAC);
expect(mp4.audioProperties().bitsPerSample).toBe(16);
expect(mp4.audioProperties().sampleRate).toBe(44100);
- expect(mp4.audioProperties().bitrate).toBe(97);
+ expect(mp4.audioProperties().bitrate).toBe(96);
break;
}
case Detection.AacReallyLow: {
@@ -387,7 +405,9 @@ suite('Track Downloads', async () => {
expect(mp4.audioProperties().codec).toBe(Mp4Codec.AAC);
expect(mp4.audioProperties().bitsPerSample).toBe(16);
expect(mp4.audioProperties().sampleRate).toBe(44100);
- expect(mp4.audioProperties().bitrate).toBe(322);
+ // SILENCE_TRACK transcoded with -b:a 320k: ffmpeg's AAC encoder
+ // drops the actual bitrate well below the target on silent audio.
+ expect(mp4.audioProperties().bitrate).toBe(239);
break;
}
diff --git a/js/download-utils.ts b/js/download-utils.ts
index 8254244c..2af251d8 100644
--- a/js/download-utils.ts
+++ b/js/download-utils.ts
@@ -9,7 +9,7 @@ import {
getContainerFormat,
transcodeWithContainerFormat,
} from './ffmpegFormats';
-import { ffmpegInfo, ffmpegNewContainer, ffmpeg } from './ffmpeg';
+import { ffmpegInfo, ffmpegNewContainer } from './ffmpeg';
/**
* Triggers a browser file download for the given blob.
@@ -29,12 +29,15 @@ export function triggerDownload(blob: Blob, filename: string): void {
* Apply post-processing to an audio Blob according to the requested quality.
*
* This function:
+ * - Remaps the legacy "HIGH"/"LOW" quality strings to FFMPEG_AAC_320/FFMPEG_AAC_96 so all
+ * lossy outputs flow through one transcoding path.
* - Detects the source container/extension via getExtensionFromBlob.
* - Determines whether the source is lossless:
* - FLAC is always lossless.
* - M4A is treated as lossless only when trackAudioQuality is "LOSSLESS" or "HI_RES_LOSSLESS".
* - If a custom lossy format is requested (isCustomFormat(quality)):
- * - If the source is already lossy, returns the original Blob to avoid quality degradation.
+ * - If the source extension already matches the target extension, returns the original Blob
+ * to avoid a pointless re-encode.
* - Otherwise, obtains the custom format via getCustomFormat and transcodes using
* transcodeWithCustomFormat(...). Progress events are reported via onProgress.
* - If encoding fails, onProgress is notified with an error stage and the original error is rethrown.
@@ -71,6 +74,12 @@ export async function applyAudioPostProcessing(
signal: AbortSignal | null = null,
trackAudioQuality: string | null = null
): Promise {
+ // Qobuz never serves AAC, so HIGH/LOW are fetched as lossless FLAC and
+ // transcoded client-side. Map them to the equivalent custom AAC formats
+ // so they flow through the same code path as FFMPEG_AAC_* qualities.
+ if (quality === 'HIGH') quality = 'FFMPEG_AAC_320';
+ else if (quality === 'LOW') quality = 'FFMPEG_AAC_96';
+
const extension = await getExtensionFromBlob(blob);
const statedLossless = (trackAudioQuality || quality).endsWith('LOSSLESS');
@@ -90,13 +99,14 @@ export async function applyAudioPostProcessing(
// Transcode to custom lossy format if requested
if (isCustomFormat(quality)) {
- // If the source is already lossy, transcoding would degrade quality
- // further (lossy → lossy). Return the blob as-is instead.
- if (!sourceIsLossless) {
- return blob;
- }
const format = getCustomFormat(quality);
if (format) {
+ // Skip the transcode if the source already matches the target container
+ // (e.g. a provider unexpectedly serves AAC for FFMPEG_AAC_*). Avoids a
+ // pointless lossy→lossy re-encode.
+ if (extension === format.extension) {
+ return blob;
+ }
try {
blob = await transcodeWithCustomFormat(blob, format, onProgress, signal);
@@ -113,29 +123,6 @@ export async function applyAudioPostProcessing(
}
}
- // Source is lossless but user requested lossy quality (HIGH/LOW).
- // This can happen when Qobuz returns FLAC regardless of the quality param.
- // Transcode to AAC to match expected lossy output.
- if (sourceIsLossless && !statedLossless && !isCustomFormat(quality)) {
- try {
- const bitrateMap: Record = { HIGH: '320k', FFMPEG_AAC_256: '256k', LOW: '96k' };
- const bitrate = bitrateMap[quality] || '256k';
- blob = await ffmpeg(blob, {
- args: ['-map_metadata', '-1', '-c:a', 'aac', '-b:a', bitrate],
- outputName: 'output.m4a',
- outputMime: 'audio/mp4',
- onProgress,
- signal,
- });
- return blob;
- } catch (error) {
- if ((error as Error)?.name === 'AbortError' || signal?.aborted) {
- throw error;
- }
- console.warn('Lossy transcode failed, returning lossless source:', error);
- }
- }
-
if (statedLossless) {
try {
const containerName = losslessContainerSettings.getContainer();
diff --git a/js/ffmpegFormats.ts b/js/ffmpegFormats.ts
index 0e1e22c0..4503990a 100644
--- a/js/ffmpegFormats.ts
+++ b/js/ffmpegFormats.ts
@@ -120,6 +120,14 @@ export const customFormats: Record = {
extension: 'ogg',
category: 'OGG',
},
+ FFMPEG_AAC_320: {
+ displayName: 'AAC 320kbps',
+ ffmpegArgs: ['-map_metadata', '-1', '-c:a', 'aac', '-b:a', '320k'],
+ outputFilename: 'output.m4a',
+ outputMime: 'audio/mp4',
+ extension: 'm4a',
+ category: 'AAC',
+ },
FFMPEG_AAC_256: {
displayName: 'AAC 256kbps',
ffmpegArgs: ['-map_metadata', '-1', '-c:a', 'aac', '-b:a', '256k'],
@@ -128,6 +136,14 @@ export const customFormats: Record = {
extension: 'm4a',
category: 'AAC',
},
+ FFMPEG_AAC_96: {
+ displayName: 'AAC 96kbps',
+ ffmpegArgs: ['-map_metadata', '-1', '-c:a', 'aac', '-b:a', '96k'],
+ outputFilename: 'output.m4a',
+ outputMime: 'audio/mp4',
+ extension: 'm4a',
+ category: 'AAC',
+ },
};
// Add wav to custom formats when vite is in dev mode
diff --git a/js/settings.js b/js/settings.js
index 58e04d28..4018b01f 100644
--- a/js/settings.js
+++ b/js/settings.js
@@ -877,8 +877,6 @@ export async function initializeSettings(scrobbler, player, api, ui) {
const staticCategories = {
HI_RES_LOSSLESS: 'Lossless',
LOSSLESS: 'Lossless',
- HIGH: 'AAC',
- LOW: 'AAC',
};
// Collect static options first (preserving their original order)