118 lines
4.5 KiB
TypeScript
118 lines
4.5 KiB
TypeScript
import type { AudioToolRequest } from "@/lib/video-canvas/audio-tools";
|
|
import {
|
|
normalizeAudioPlaybackRate,
|
|
normalizeAudioTrimRange,
|
|
} from "@/lib/video-canvas/audio-tools";
|
|
|
|
function getAudioContextConstructor() {
|
|
return window.AudioContext ||
|
|
(window as typeof window & { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
|
|
}
|
|
|
|
export async function decodeAudioArrayBuffer(arrayBuffer: ArrayBuffer) {
|
|
const AudioContextCtor = getAudioContextConstructor();
|
|
if (!AudioContextCtor) throw new Error("当前浏览器不支持音频处理");
|
|
const context = new AudioContextCtor();
|
|
try {
|
|
return await context.decodeAudioData(arrayBuffer.slice(0));
|
|
} finally {
|
|
await context.close().catch(() => undefined);
|
|
}
|
|
}
|
|
|
|
async function decodeAudioSource(mediaUrl: string) {
|
|
const parsedUrl = new URL(mediaUrl, window.location.origin);
|
|
const sourceUrl =
|
|
parsedUrl.origin === window.location.origin
|
|
? parsedUrl.toString()
|
|
: `/api/canvas/audio-source?url=${encodeURIComponent(parsedUrl.toString())}`;
|
|
const response = await fetch(sourceUrl, { credentials: "include" });
|
|
if (!response.ok) throw new Error("音频素材读取失败");
|
|
return decodeAudioArrayBuffer(await response.arrayBuffer());
|
|
}
|
|
|
|
function trimAudioBuffer(source: AudioBuffer, startSeconds: number, endSeconds: number) {
|
|
const range = normalizeAudioTrimRange(source.duration, startSeconds, endSeconds);
|
|
const startFrame = Math.floor(range.startSeconds * source.sampleRate);
|
|
const endFrame = Math.min(source.length, Math.ceil(range.endSeconds * source.sampleRate));
|
|
const frameCount = Math.max(1, endFrame - startFrame);
|
|
const output = new AudioBuffer({
|
|
length: frameCount,
|
|
numberOfChannels: source.numberOfChannels,
|
|
sampleRate: source.sampleRate,
|
|
});
|
|
for (let channel = 0; channel < source.numberOfChannels; channel += 1) {
|
|
output.copyToChannel(source.getChannelData(channel).subarray(startFrame, endFrame), channel);
|
|
}
|
|
return output;
|
|
}
|
|
|
|
async function changeAudioPlaybackRate(source: AudioBuffer, playbackRate: number) {
|
|
const rate = normalizeAudioPlaybackRate(playbackRate);
|
|
const frameCount = Math.max(1, Math.ceil(source.length / rate));
|
|
const offline = new OfflineAudioContext(
|
|
source.numberOfChannels,
|
|
frameCount,
|
|
source.sampleRate,
|
|
);
|
|
const sourceNode = offline.createBufferSource();
|
|
sourceNode.buffer = source;
|
|
sourceNode.playbackRate.value = rate;
|
|
sourceNode.connect(offline.destination);
|
|
sourceNode.start(0);
|
|
return offline.startRendering();
|
|
}
|
|
|
|
function writeAscii(view: DataView, offset: number, value: string) {
|
|
for (let index = 0; index < value.length; index += 1) {
|
|
view.setUint8(offset + index, value.charCodeAt(index));
|
|
}
|
|
}
|
|
|
|
export function encodeAudioBufferAsWav(buffer: AudioBuffer) {
|
|
const channelCount = Math.max(1, Math.min(2, buffer.numberOfChannels));
|
|
const bytesPerSample = 2;
|
|
const dataSize = buffer.length * channelCount * bytesPerSample;
|
|
const arrayBuffer = new ArrayBuffer(44 + dataSize);
|
|
const view = new DataView(arrayBuffer);
|
|
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, channelCount, true);
|
|
view.setUint32(24, buffer.sampleRate, true);
|
|
view.setUint32(28, buffer.sampleRate * channelCount * bytesPerSample, true);
|
|
view.setUint16(32, channelCount * bytesPerSample, true);
|
|
view.setUint16(34, 16, true);
|
|
writeAscii(view, 36, "data");
|
|
view.setUint32(40, dataSize, true);
|
|
|
|
const channels = Array.from({ length: channelCount }, (_, index) =>
|
|
buffer.getChannelData(Math.min(index, buffer.numberOfChannels - 1)),
|
|
);
|
|
let offset = 44;
|
|
for (let frame = 0; frame < buffer.length; frame += 1) {
|
|
for (let channel = 0; channel < channelCount; channel += 1) {
|
|
const sample = Math.max(-1, Math.min(1, channels[channel]![frame] || 0));
|
|
view.setInt16(offset, sample < 0 ? sample * 0x8000 : sample * 0x7fff, true);
|
|
offset += bytesPerSample;
|
|
}
|
|
}
|
|
return new Blob([arrayBuffer], { type: "audio/wav" });
|
|
}
|
|
|
|
export async function processCanvasAudio(mediaUrl: string, request: AudioToolRequest) {
|
|
const source = await decodeAudioSource(mediaUrl);
|
|
const output =
|
|
request.kind === "trim"
|
|
? trimAudioBuffer(source, request.startSeconds, request.endSeconds)
|
|
: await changeAudioPlaybackRate(source, request.playbackRate);
|
|
return {
|
|
blob: encodeAudioBufferAsWav(output),
|
|
durationSeconds: output.duration,
|
|
sampleRate: output.sampleRate,
|
|
};
|
|
}
|