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
This commit is contained in:
@@ -0,0 +1,149 @@
|
||||
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" });
|
||||
}
|
||||
Reference in New Issue
Block a user