ai-chatbot/lib/video-canvas/audio-processing.ts

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,
};
}