import { losslessContainerSettings } from './storage'; import { getExtensionFromBlob } from './utils'; import { rebuildFlacWithoutMetadata } from './metadata.flac.js'; import { type ProgressEvent, isCustomFormat, getCustomFormat, transcodeWithCustomFormat, getContainerFormat, transcodeWithContainerFormat, } from './ffmpegFormats'; import { ffmpegNewContainer } from './ffmpeg'; /** * Triggers a browser file download for the given blob. */ export function triggerDownload(blob: Blob, filename: string): void { const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } /** * Apply post-processing to an audio Blob according to the requested quality. * * This function: * - 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. * - 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. * - If a lossless output is requested (quality ends with "LOSSLESS"): * - Retrieves the configured lossless container and its format handler. * - If the source is not lossless, logs a warning and returns the original Blob. * - Otherwise: * - If containerFmt.needsTranscode(blob) is true, transcodes via transcodeWithContainerFormat(...). * - Else if the source is FLAC, calls rebuildFlacWithoutMetadata to strip/rebuild metadata safely. * - Else remuxes into the desired container via ffmpegNewContainer (maps m4a -> mp4 where appropriate). * - Any non-abort errors during lossless container conversion are caught and logged (conversion is best-effort). * * Progress and cancellation: * - onProgress, if provided, will be called with progress/update/error events from the underlying encoding/transcode helpers. * - An AbortSignal may be provided to cancel long-running transcode operations; abort-related errors (AbortError) * are propagated. * * @param blob - The source audio Blob to process. * @param quality - Requested output quality identifier (may indicate custom lossy format or lossless output). * @param onProgress - Optional callback invoked with progress/update events (or error notifications). * @param signal - Optional AbortSignal used to cancel asynchronous transcode operations. * @param trackAudioQuality - Optional track audio quality information from the API (e.g. "LOSSLESS", "HI_RES_LOSSLESS") * used to determine whether an m4a source should be treated as lossless. * @returns A Promise that resolves to the resulting audio Blob (may be the original blob if no processing was needed * or if processing was skipped due to source/quality constraints). * @throws Throws underlying encoding/transcoding errors (including AbortError when aborted). Encoding errors during * custom-format transcode are rethrown after reporting via onProgress. Non-abort errors during lossless * container conversion are logged and do not necessarily propagate. */ export async function applyAudioPostProcessing( blob: Blob, quality: string, onProgress: ((progress: ProgressEvent) => void) | null = null, signal: AbortSignal | null = null, trackAudioQuality: string | null = null ): Promise { const extension = await getExtensionFromBlob(blob); // Determine whether the downloaded source is lossless. // FLAC is always lossless. m4a is lossless only when the track's // audio quality from the API is LOSSLESS or HI_RES_LOSSLESS; otherwise // it is AAC (lossy). const sourceIsLossless = extension === 'flac' || (extension === 'm4a' && (trackAudioQuality === 'LOSSLESS' || trackAudioQuality === 'HI_RES_LOSSLESS')); // 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) { try { blob = await transcodeWithCustomFormat(blob, format, onProgress, signal); } catch (encodingError) { if (onProgress) { onProgress({ stage: 'error', message: `Encoding failed: ${(encodingError as Error).message}`, }); } throw encodingError; } } } if (quality.endsWith('LOSSLESS')) { try { const containerName = losslessContainerSettings.getContainer(); const containerFmt = getContainerFormat(containerName); if (!sourceIsLossless) { console.warn( `Requested lossless output but source is not lossless (quality: ${quality}, trackAudioQuality: ${trackAudioQuality}, extension: ${extension}).` ); return blob; } if (await containerFmt?.needsTranscode(blob)) { blob = await transcodeWithContainerFormat(blob, containerFmt, onProgress, signal); } else if (extension === 'flac') { blob = await rebuildFlacWithoutMetadata(blob); } else { blob = await ffmpegNewContainer( blob, extension == 'm4a' ? 'mp4' : extension, blob.type, onProgress, signal ); } } catch (error) { if ((error as Error)?.name === 'AbortError' || signal?.aborted) { throw error; } console.error('Lossless container conversion failed:', error); } } return blob; }