Skip to content
Open
Show file tree
Hide file tree
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
2 changes: 0 additions & 2 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -5339,8 +5339,6 @@ <h4 class="autoeq-database-title">Database</h4>
<select id="download-quality-setting">
<option value="HI_RES_LOSSLESS">Hi-Res Lossless (24-bit)</option>
<option value="LOSSLESS">Lossless (16-bit)</option>
<option value="HIGH">AAC 320kbps</option>
<option value="LOW">AAC 96kbps</option>
</select>
</div>
<div class="setting-item" id="hi-res-download-warning" style="display: none">
Expand Down
16 changes: 13 additions & 3 deletions js/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,14 @@
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;
Expand Down Expand Up @@ -1941,7 +1949,7 @@
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;
Expand Down Expand Up @@ -2072,8 +2080,10 @@
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;
Expand Down Expand Up @@ -2281,7 +2291,7 @@
if (error.message === RATE_LIMIT_ERROR_MESSAGE) {
throw error;
}
throw new Error('Download failed. The stream may require a proxy.');

Check failure on line 2294 in js/api.js

View workflow job for this annotation

GitHub Actions / test

[chromium] js/api.test.ts > Track Downloads > 'AAC 256'

Error: Download failed. The stream may require a proxy. ❯ LosslessAPI.downloadTrack js/api.js:2294:18 ❯ downloadTrack js/api.test.ts:109:15 ❯ js/api.test.ts:300:21

Check failure on line 2294 in js/api.js

View workflow job for this annotation

GitHub Actions / test

[chromium] js/api.test.ts > Track Downloads > 'Dolby Atmos'

Error: Download failed. The stream may require a proxy. ❯ LosslessAPI.downloadTrack js/api.js:2294:18 ❯ downloadTrack js/api.test.ts:109:15 ❯ js/api.test.ts:300:21
}
}

Expand Down
28 changes: 24 additions & 4 deletions js/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,7 @@
preferDolbyAtmos: false,
trackId: SILENCE_TRACK,
detection: Detection.AacHigh,
ffmpegCalls: 0,
ffmpegCalls: 1,
},
{
display_quality: 'Low',
Expand All @@ -205,9 +205,18 @@
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',
Expand All @@ -217,6 +226,15 @@
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',
Expand Down Expand Up @@ -280,7 +298,7 @@
vi.mocked(losslessContainerSettings.getContainer).mockReturnValue(container);

const blob = await downloadTrack(trackId, quality);
expect(ffmpeg).toHaveBeenCalledTimes(ffmpegCalls);

Check failure on line 301 in js/api.test.ts

View workflow job for this annotation

GitHub Actions / test

[chromium] js/api.test.ts > Track Downloads > 'HD Lossless (FLAC)'

AssertionError: expected "vi.fn()" to be called 1 times, but got 0 times ❯ js/api.test.ts:301:23
const file = await FileRef.fromBlob(blob);
const stream = file.file().stream();

Expand Down Expand Up @@ -342,7 +360,7 @@
break;
}
case Detection.Mp4Flac: {
expect(file.file()).toBeInstanceOf(Mp4File);

Check failure on line 363 in js/api.test.ts

View workflow job for this annotation

GitHub Actions / test

[chromium] js/api.test.ts > Track Downloads > 'HD Lossless (Unchanged)'

AssertionError: expected FlacFile{ _valid: true, …(14) } to be an instance of Mp4File ❯ js/api.test.ts:363:36
const codec = stsdData.toString().substring(20, 24);
expect(codec).toBe('fLaC');
break;
Expand All @@ -369,7 +387,7 @@
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: {
Expand All @@ -387,7 +405,9 @@
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);
Comment on lines +408 to +410
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check FFmpeg AAC encoder configuration

echo "=== FFMPEG_AAC_320 encoder arguments ==="
rg -nP -A5 "FFMPEG_AAC_320" js/ffmpegFormats.ts

echo -e "\n=== FFmpeg invocation in applyAudioPostProcessing ==="
rg -nP -A10 "ffmpeg.*-b:a|ffmpeg.*aac" js/download-utils.ts

Repository: monochrome-music/monochrome

Length of output: 422


🏁 Script executed:

# Check the test context around lines 408-410
cat -n js/api.test.ts | sed -n '390,420p'

Repository: monochrome-music/monochrome

Length of output: 1916


🏁 Script executed:

# Find SILENCE_TRACK definition
rg -n "SILENCE_TRACK|trackId.*46022548" js/

Repository: monochrome-music/monochrome

Length of output: 1105


🏁 Script executed:

# Look for other AAC bitrate test expectations
rg -n "AacHigh|AacLow|bitrate.*toBe" js/api.test.ts | head -20

Repository: monochrome-music/monochrome

Length of output: 1055


🏁 Script executed:

# Check git history for the 239 bitrate expectation
git log -p --all -S "toBe(239)" -- js/api.test.ts | head -100

Repository: monochrome-music/monochrome

Length of output: 53


🏁 Script executed:

# Search for any discussion or issues about AAC bitrate
rg -i "aac|bitrate|silent" js/ --type-list | head -5 && rg -i "aachigh|codec" js/api.test.ts | head -20

Repository: monochrome-music/monochrome

Length of output: 1353


🏁 Script executed:

# Check if there are any comments explaining the SILENCE_TRACK choice
cat -n js/api.test.ts | sed -n '90,110p'

Repository: monochrome-music/monochrome

Length of output: 944


🏁 Script executed:

# Compare the pattern: check what bitrate targets are used for each format
rg -n "b:a.*k|bitrate" js/ffmpegFormats.ts | grep -i "aac\|mp3\|vorbis"

Repository: monochrome-music/monochrome

Length of output: 616


Investigate AAC 320k bitrate deviation and add validation with non-silent audio.

The AacHigh bitrate expectation of 239 kbps significantly underperforms the 320k target (75% of target). While the comment notes this is due to SILENCE_TRACK, the pattern is concerning:

  • AAC 320k: 239 kbps (75% of target) ← outlier
  • AAC 256k: 263 kbps (103% of target)
  • AAC 96k: 96 kbps (100% of target)
  • MP3 320k: 322 kbps (101% of target)
  • Ogg 320k: 314 kbps (98% of target)

Only AAC at 320k deviates significantly. The FFmpeg arguments (-c:a aac -b:a 320k) are correct, but this pattern suggests either AAC encoder behavior at 320k specifically differs from other bitrates/codecs, or there's an underlying configuration issue.

Add a test case for AacHigh using TRACK_ATMOS (or another non-silent track) to verify the encoder approaches the target bitrate under normal conditions and confirm the 239 kbps result is specific to silent audio rather than a systemic encoder problem.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@js/api.test.ts` around lines 408 - 410, The current AacHigh test asserts a
239 kbps result on SILENCE_TRACK which may be misleading; add a parallel test
that encodes TRACK_ATMOS (or any non-silent track) with the same AAC settings
(e.g., the existing AacHigh test setup that calls ffmpeg with "-c:a aac -b:a
320k") and assert the resulting mp4.audioProperties().bitrate is close to the
target (e.g., within a reasonable tolerance of 320 kbps) to confirm the 239 kbps
result is specific to SILENCE_TRACK rather than a systemic encoder issue;
reference the AacHigh test, SILENCE_TRACK, TRACK_ATMOS, and
mp4.audioProperties().bitrate when locating and adding the new test.

break;
}

Expand Down Expand Up @@ -429,7 +449,7 @@
expect(file.file()).toBeInstanceOf(OggFile);
const ogg = file.file() as OggFile;
expect(ogg.audioProperties().sampleRate).toBe(44100);
expect(ogg.audioProperties().bitrate).toBe(314);

Check failure on line 452 in js/api.test.ts

View workflow job for this annotation

GitHub Actions / test

[chromium] js/api.test.ts > Track Downloads > 'OGG 320'

AssertionError: expected 330 to be 314 // Object.is equality - Expected + Received - 314 + 330 ❯ js/api.test.ts:452:54
break;
}

Expand All @@ -437,7 +457,7 @@
expect(file.file()).toBeInstanceOf(OggFile);
const ogg = file.file() as OggFile;
expect(ogg.audioProperties().sampleRate).toBe(44100);
expect(ogg.audioProperties().bitrate).toBe(253);

Check failure on line 460 in js/api.test.ts

View workflow job for this annotation

GitHub Actions / test

[chromium] js/api.test.ts > Track Downloads > 'OGG 256'

AssertionError: expected 265 to be 253 // Object.is equality - Expected + Received - 253 + 265 ❯ js/api.test.ts:460:54
break;
}

Expand All @@ -445,7 +465,7 @@
expect(file.file()).toBeInstanceOf(OggFile);
const ogg = file.file() as OggFile;
expect(ogg.audioProperties().sampleRate).toBe(44100);
expect(ogg.audioProperties().bitrate).toBe(130);

Check failure on line 468 in js/api.test.ts

View workflow job for this annotation

GitHub Actions / test

[chromium] js/api.test.ts > Track Downloads > 'OGG 128'

AssertionError: expected 137 to be 130 // Object.is equality - Expected + Received - 130 + 137 ❯ js/api.test.ts:468:54
break;
}

Expand Down
47 changes: 17 additions & 30 deletions js/download-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand Down Expand Up @@ -71,6 +74,12 @@ export async function applyAudioPostProcessing(
signal: AbortSignal | null = null,
trackAudioQuality: string | null = null
): Promise<Blob> {
// 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');

Expand All @@ -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);

Expand All @@ -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<string, string> = { 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();
Expand Down
16 changes: 16 additions & 0 deletions js/ffmpegFormats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,14 @@ export const customFormats: Record<string, CustomFormat> = {
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'],
Expand All @@ -128,6 +136,14 @@ export const customFormats: Record<string, CustomFormat> = {
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
Expand Down
2 changes: 0 additions & 2 deletions js/settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading