149 lines
4.6 KiB
TypeScript
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" });
|
||
|
|
}
|