Files
Aurora-SpotiRip/helpers/audioProcessing.tsx
T
R3ap3r 3178b8f4f7 Aurora SpotiRip desktop and web app
- Spotify playlist, library, album and artist browsing with in-app playback
- Song, playlist and album downloads via spotdl with YouTube and Bandcamp fallbacks
- Electron desktop app (castLabs Widevine) with a permanent sidebar layout
- Linux and Windows packaging scripts
2026-09-25 23:20:57 -05:00

149 lines
4.6 KiB
TypeScript

import {
ALL_FORMATS,
BlobSource,
BufferTarget,
canEncodeAudio,
Conversion,
Input,
Mp3OutputFormat,
Output,
Quality,
} from "mediabunny";
import { registerMp3Encoder } from "@mediabunny/mp3-encoder";
type DecodedAudio = {
sampleRate: number;
channelCount: number;
channelData: Float32Array[];
};
export async function decodeAudio(file: File): Promise<AudioBuffer> {
const AudioContextCtor =
window.AudioContext ||
(window as typeof window & { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
if (!AudioContextCtor) throw new Error("This browser does not support local audio decoding.");
const context = new AudioContextCtor();
try {
const data = await file.arrayBuffer();
return await context.decodeAudioData(data.slice(0));
} finally {
await context.close();
}
}
function clamp16(value: number) {
const sample = Math.max(-1, Math.min(1, value));
return sample < 0 ? Math.round(sample * 0x8000) : Math.round(sample * 0x7fff);
}
function writeAscii(view: DataView, offset: number, value: string) {
for (let index = 0; index < value.length; index += 1) {
view.setUint8(offset + index, value.charCodeAt(index));
}
}
function makeWavBlob(audio: DecodedAudio) {
const channels = Math.min(audio.channelCount, 2);
const frameCount = audio.channelData[0]?.length || 0;
const bytesPerSample = 2;
const blockAlign = channels * bytesPerSample;
const dataSize = frameCount * blockAlign;
const buffer = new ArrayBuffer(44 + dataSize);
const view = new DataView(buffer);
writeAscii(view, 0, "RIFF");
view.setUint32(4, 36 + dataSize, true);
writeAscii(view, 8, "WAVE");
writeAscii(view, 12, "fmt ");
view.setUint32(16, 16, true);
view.setUint16(20, 1, true);
view.setUint16(22, channels, true);
view.setUint32(24, audio.sampleRate, true);
view.setUint32(28, audio.sampleRate * blockAlign, true);
view.setUint16(32, blockAlign, true);
view.setUint16(34, 16, true);
writeAscii(view, 36, "data");
view.setUint32(40, dataSize, true);
let offset = 44;
for (let frame = 0; frame < frameCount; frame += 1) {
for (let channel = 0; channel < channels; channel += 1) {
view.setInt16(offset, clamp16(audio.channelData[channel][frame] || 0), true);
offset += 2;
}
}
return new Blob([buffer], { type: "audio/wav" });
}
export async function createRecognitionSnippet(file: File) {
const decoded = await decodeAudio(file);
const duration = decoded.duration;
const snippetSeconds = Math.min(12, Math.max(4, duration));
const preferredStart = duration > 35 ? 20 : duration > 20 ? 8 : 0;
const startSeconds = Math.min(preferredStart, Math.max(0, duration - snippetSeconds));
const startFrame = Math.floor(startSeconds * decoded.sampleRate);
const snippetFrames = Math.min(
Math.floor(snippetSeconds * decoded.sampleRate),
Math.max(0, decoded.length - startFrame),
);
const channels = Math.min(decoded.numberOfChannels, 2);
const channelData: Float32Array[] = [];
for (let channel = 0; channel < channels; channel += 1) {
const source = decoded.getChannelData(channel);
channelData.push(source.slice(startFrame, startFrame + snippetFrames));
}
const blob = makeWavBlob({
sampleRate: decoded.sampleRate,
channelCount: channels,
channelData,
});
return new File([blob], "recognition-snippet.wav", {
type: "audio/wav",
lastModified: Date.now(),
});
}
export async function convertAudioFileToMp3(file: File, bitrateKbps = 192) {
if (file.type === "audio/mpeg" || file.name.toLowerCase().endsWith(".mp3")) {
return new Blob([await file.arrayBuffer()], { type: "audio/mpeg" });
}
if (!(await canEncodeAudio("mp3"))) {
registerMp3Encoder();
}
const input = new Input({
source: new BlobSource(file),
formats: ALL_FORMATS,
});
const target = new BufferTarget();
const output = new Output({
format: new Mp3OutputFormat(),
target,
});
const conversion = await Conversion.init({
input,
output,
tracks: "primary",
video: { discard: true },
audio: {
codec: "mp3",
quality: new Quality({ bitrate: bitrateKbps * 1000 }),
forceTranscode: true,
},
});
if (!conversion.isValid) {
const reasons = conversion.discardedTracks.map((entry) => entry.reason).join(", ");
throw new Error(
reasons
? `This audio file cannot be converted in this browser (${reasons}).`
: "This audio file cannot be converted in this browser.",
);
}
await conversion.execute();
if (!target.buffer) throw new Error("MP3 conversion produced no output.");
return new Blob([target.buffer], { type: "audio/mpeg" });
}