summaryrefslogtreecommitdiff
path: root/apps/web/src/lib/media/audio.ts
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2026-04-20 11:31:17 +0200
committerMaze Winther <mazewinther@gmail.com>2026-04-20 11:31:17 +0200
commit3e89d2998588f8e35c524e523ba2ee853f0f6475 (patch)
tree57221f96c1259b3b07e3ddf8c1adefc502b9d8eb /apps/web/src/lib/media/audio.ts
parent729d10592f4e802f91c555c309ff3ba4b119f843 (diff)
downloadopencut-3e89d2998588f8e35c524e523ba2ee853f0f6475.tar.gz
refactor: restructure files to their domains, new preview overlay system, and dep graph
Diffstat (limited to 'apps/web/src/lib/media/audio.ts')
-rw-r--r--apps/web/src/lib/media/audio.ts868
1 files changed, 0 insertions, 868 deletions
diff --git a/apps/web/src/lib/media/audio.ts b/apps/web/src/lib/media/audio.ts
deleted file mode 100644
index 675cc3a5..00000000
--- a/apps/web/src/lib/media/audio.ts
+++ /dev/null
@@ -1,868 +0,0 @@
-import type {
- AudioElement,
- VideoElement,
- LibraryAudioElement,
- RetimeConfig,
- SceneTracks,
-} from "@/lib/timeline";
-import { shouldMaintainPitch } from "@/lib/retime/rate";
-import type { MediaAsset } from "@/lib/media/types";
-import { applyAudioMasteringToBuffer } from "@/lib/media/audio-mastering";
-import type { AudioCapableElement } from "@/lib/timeline/audio-state";
-import {
- hasAnimatedVolume,
- resolveEffectiveAudioGain,
-} from "@/lib/timeline/audio-state";
-import { doesElementHaveEnabledAudio } from "@/lib/timeline/audio-separation";
-import { canElementHaveAudio, hasMediaId } from "@/lib/timeline/element-utils";
-import { canTrackHaveAudio } from "@/lib/timeline";
-import { mediaSupportsAudio } from "@/lib/media/media-utils";
-import { getSourceTimeAtClipTime, renderRetimedBuffer } from "@/lib/retime";
-import { Input, ALL_FORMATS, BlobSource, AudioBufferSink } from "mediabunny";
-import { TICKS_PER_SECOND } from "@/lib/wasm";
-import {
- computeRmsBuckets,
- type SampleBucket,
-} from "@/lib/media/waveform-summary";
-
-const MAX_AUDIO_CHANNELS = 2;
-const EXPORT_SAMPLE_RATE = 44100;
-
-export interface CollectedAudioElement {
- timelineElement: AudioCapableElement;
- buffer: AudioBuffer;
- startTime: number;
- duration: number;
- trimStart: number;
- trimEnd: number;
- volume: number;
- muted: boolean;
- retime?: RetimeConfig;
-}
-
-export function createAudioContext({
- sampleRate,
-}: {
- sampleRate?: number;
-} = {}): AudioContext {
- const AudioContextConstructor =
- window.AudioContext ||
- (window as typeof window & { webkitAudioContext?: typeof AudioContext })
- .webkitAudioContext;
-
- return new AudioContextConstructor(sampleRate ? { sampleRate } : undefined);
-}
-
-export interface DecodedAudio {
- samples: Float32Array;
- sampleRate: number;
-}
-
-export async function decodeAudioToFloat32({
- audioBlob,
- sampleRate,
-}: {
- audioBlob: Blob;
- sampleRate?: number;
-}): Promise<DecodedAudio> {
- const audioContext = createAudioContext({ sampleRate });
- const arrayBuffer = await audioBlob.arrayBuffer();
- const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
-
- // mix down to mono
- const numChannels = audioBuffer.numberOfChannels;
- const length = audioBuffer.length;
- const samples = new Float32Array(length);
-
- for (let i = 0; i < length; i++) {
- let sum = 0;
- for (let channel = 0; channel < numChannels; channel++) {
- sum += audioBuffer.getChannelData(channel)[i];
- }
- samples[i] = sum / numChannels;
- }
-
- return { samples, sampleRate: audioBuffer.sampleRate };
-}
-
-export interface AudibleElementCandidate {
- element: AudioElement | VideoElement;
- mediaAsset: MediaAsset | null;
-}
-
-export function collectAudibleCandidates({
- tracks,
- mediaAssets,
-}: {
- tracks: SceneTracks;
- mediaAssets: MediaAsset[];
-}): AudibleElementCandidate[] {
- const allTracks = [...tracks.overlay, tracks.main, ...tracks.audio];
- const mediaMap = new Map(mediaAssets.map((a) => [a.id, a]));
- const candidates: AudibleElementCandidate[] = [];
-
- for (const track of allTracks) {
- if (canTrackHaveAudio(track) && track.muted) continue;
-
- for (const element of track.elements) {
- if (!canElementHaveAudio(element)) continue;
- if (element.duration <= 0) continue;
-
- const mediaAsset = hasMediaId(element)
- ? (mediaMap.get(element.mediaId) ?? null)
- : null;
- if (!doesElementHaveEnabledAudio({ element, mediaAsset })) continue;
-
- candidates.push({ element, mediaAsset });
- }
- }
-
- return candidates;
-}
-
-export function timelineHasAudio({
- tracks,
- mediaAssets,
-}: {
- tracks: SceneTracks;
- mediaAssets: MediaAsset[];
-}): boolean {
- return collectAudibleCandidates({ tracks, mediaAssets }).some(
- ({ element }) => element.muted !== true,
- );
-}
-
-export async function collectAudioElements({
- tracks,
- mediaAssets,
- audioContext,
-}: {
- tracks: SceneTracks;
- mediaAssets: MediaAsset[];
- audioContext: AudioContext;
-}): Promise<CollectedAudioElement[]> {
- const candidates = collectAudibleCandidates({ tracks, mediaAssets });
- const mediaMap = new Map<string, MediaAsset>(
- mediaAssets.map((media) => [media.id, media]),
- );
- const pendingElements: Array<Promise<CollectedAudioElement | null>> = [];
-
- for (const { element, mediaAsset } of candidates) {
- if (element.type === "audio") {
- pendingElements.push(
- resolveAudioBufferForElement({
- element,
- mediaMap,
- audioContext,
- }).then((audioBuffer) => {
- if (!audioBuffer) return null;
- return {
- timelineElement: element,
- buffer: audioBuffer,
- startTime: element.startTime / TICKS_PER_SECOND,
- duration: element.duration / TICKS_PER_SECOND,
- trimStart: element.trimStart / TICKS_PER_SECOND,
- trimEnd: element.trimEnd / TICKS_PER_SECOND,
- volume: resolveEffectiveAudioGain({
- element,
- trackMuted: false,
- localTime: 0,
- }),
- muted: element.muted === true,
- retime: element.retime,
- };
- }),
- );
- continue;
- }
-
- if (element.type === "video") {
- if (!mediaAsset || !mediaSupportsAudio({ media: mediaAsset })) continue;
-
- pendingElements.push(
- resolveAudioBufferForAsset({
- asset: mediaAsset,
- audioContext,
- }).then((audioBuffer) => {
- if (!audioBuffer) return null;
- return {
- timelineElement: element,
- buffer: audioBuffer,
- startTime: element.startTime / TICKS_PER_SECOND,
- duration: element.duration / TICKS_PER_SECOND,
- trimStart: element.trimStart / TICKS_PER_SECOND,
- trimEnd: element.trimEnd / TICKS_PER_SECOND,
- volume: resolveEffectiveAudioGain({
- element,
- trackMuted: false,
- localTime: 0,
- }),
- muted: element.muted ?? false,
- retime: element.retime,
- };
- }),
- );
- }
- }
-
- const resolvedElements = await Promise.all(pendingElements);
- const audioElements: CollectedAudioElement[] = [];
- for (const element of resolvedElements) {
- if (element) audioElements.push(element);
- }
- return audioElements;
-}
-
-async function resolveAudioBufferForElement({
- element,
- mediaMap,
- audioContext,
-}: {
- element: AudioElement;
- mediaMap: Map<string, MediaAsset>;
- audioContext: AudioContext;
-}): Promise<AudioBuffer | null> {
- try {
- if (element.sourceType === "upload") {
- const asset = mediaMap.get(element.mediaId);
- if (!asset) return null;
- return await resolveAudioBufferForAsset({ asset, audioContext });
- }
-
- if (element.buffer) return element.buffer;
-
- const response = await fetch(element.sourceUrl);
- if (!response.ok) {
- throw new Error(`Library audio fetch failed: ${response.status}`);
- }
-
- const arrayBuffer = await response.arrayBuffer();
- return await audioContext.decodeAudioData(arrayBuffer.slice(0));
- } catch (error) {
- console.warn("Failed to decode audio:", error);
- return null;
- }
-}
-
-async function resolveAudioBufferForAsset({
- asset,
- audioContext,
-}: {
- asset: MediaAsset;
- audioContext: AudioContext;
-}): Promise<AudioBuffer | null> {
- if (asset.type === "audio") {
- try {
- const arrayBuffer = await asset.file.arrayBuffer();
- return await audioContext.decodeAudioData(arrayBuffer.slice(0));
- } catch (error) {
- console.warn("Failed to decode audio asset:", error);
- return null;
- }
- }
-
- const input = new Input({
- source: new BlobSource(asset.file),
- formats: ALL_FORMATS,
- });
-
- try {
- const audioTrack = await input.getPrimaryAudioTrack();
- if (!audioTrack) return null;
-
- const sink = new AudioBufferSink(audioTrack);
- const targetSampleRate = audioContext.sampleRate;
-
- const chunks: AudioBuffer[] = [];
- let totalSamples = 0;
-
- for await (const { buffer } of sink.buffers(0)) {
- chunks.push(buffer);
- totalSamples += buffer.length;
- }
-
- if (chunks.length === 0) return null;
-
- const nativeSampleRate = chunks[0].sampleRate;
- const numChannels = Math.min(
- MAX_AUDIO_CHANNELS,
- chunks[0].numberOfChannels,
- );
-
- const nativeChannels = Array.from(
- { length: numChannels },
- () => new Float32Array(totalSamples),
- );
- let offset = 0;
- for (const chunk of chunks) {
- for (let channel = 0; channel < numChannels; channel++) {
- const sourceData = chunk.getChannelData(
- Math.min(channel, chunk.numberOfChannels - 1),
- );
- nativeChannels[channel].set(sourceData, offset);
- }
- offset += chunk.length;
- }
-
- // use OfflineAudioContext for high-quality resampling to target rate
- const outputSamples = Math.ceil(
- totalSamples * (targetSampleRate / nativeSampleRate),
- );
- const offlineContext = new OfflineAudioContext(
- numChannels,
- outputSamples,
- targetSampleRate,
- );
-
- const nativeBuffer = audioContext.createBuffer(
- numChannels,
- totalSamples,
- nativeSampleRate,
- );
- for (let ch = 0; ch < numChannels; ch++) {
- nativeBuffer.copyToChannel(nativeChannels[ch], ch);
- }
-
- const sourceNode = offlineContext.createBufferSource();
- sourceNode.buffer = nativeBuffer;
- sourceNode.connect(offlineContext.destination);
- sourceNode.start(0);
-
- return await offlineContext.startRendering();
- } catch (error) {
- console.warn("Failed to decode asset audio:", error);
- return null;
- } finally {
- input.dispose();
- }
-}
-
-interface AudioMixSource {
- timelineElement: AudioCapableElement;
- file: File;
- startTime: number;
- duration: number;
- trimStart: number;
- trimEnd: number;
- volume: number;
- retime?: RetimeConfig;
-}
-
-export interface AudioClipSource {
- timelineElement: AudioCapableElement;
- id: string;
- sourceKey: string;
- file: File;
- startTime: number;
- duration: number;
- trimStart: number;
- trimEnd: number;
- volume: number;
- muted: boolean;
- retime?: RetimeConfig;
-}
-
-async function fetchLibraryAudioSource({
- element,
- volume,
-}: {
- element: LibraryAudioElement;
- volume: number;
-}): Promise<AudioMixSource | null> {
- try {
- const response = await fetch(element.sourceUrl);
- if (!response.ok) {
- throw new Error(`Library audio fetch failed: ${response.status}`);
- }
-
- const blob = await response.blob();
- const file = new File([blob], `${element.name}.mp3`, {
- type: "audio/mpeg",
- });
-
- return {
- timelineElement: element,
- file,
- startTime: element.startTime / TICKS_PER_SECOND,
- duration: element.duration / TICKS_PER_SECOND,
- trimStart: element.trimStart / TICKS_PER_SECOND,
- trimEnd: element.trimEnd / TICKS_PER_SECOND,
- volume,
- retime: element.retime,
- };
- } catch (error) {
- console.warn("Failed to fetch library audio:", error);
- return null;
- }
-}
-
-async function fetchLibraryAudioClip({
- element,
- muted,
- volume,
-}: {
- element: LibraryAudioElement;
- muted: boolean;
- volume: number;
-}): Promise<AudioClipSource | null> {
- try {
- const response = await fetch(element.sourceUrl);
- if (!response.ok) {
- throw new Error(`Library audio fetch failed: ${response.status}`);
- }
-
- const blob = await response.blob();
- const file = new File([blob], `${element.name}.mp3`, {
- type: "audio/mpeg",
- });
-
- return {
- timelineElement: element,
- id: element.id,
- sourceKey: element.id,
- file,
- startTime: element.startTime,
- duration: element.duration,
- trimStart: element.trimStart,
- trimEnd: element.trimEnd,
- volume,
- muted,
- retime: element.retime,
- };
- } catch (error) {
- console.warn("Failed to fetch library audio:", error);
- return null;
- }
-}
-
-function collectMediaAudioSource({
- element,
- mediaAsset,
- volume,
-}: {
- element: AudioCapableElement;
- mediaAsset: MediaAsset;
- volume: number;
-}): AudioMixSource {
- return {
- timelineElement: element,
- file: mediaAsset.file,
- startTime: element.startTime / TICKS_PER_SECOND,
- duration: element.duration / TICKS_PER_SECOND,
- trimStart: element.trimStart / TICKS_PER_SECOND,
- trimEnd: element.trimEnd / TICKS_PER_SECOND,
- volume,
- retime: element.retime,
- };
-}
-
-function collectMediaAudioClip({
- element,
- mediaAsset,
- muted,
- volume,
-}: {
- element: AudioCapableElement;
- mediaAsset: MediaAsset;
- muted: boolean;
- volume: number;
-}): AudioClipSource {
- return {
- timelineElement: element,
- id: element.id,
- sourceKey: mediaAsset.id,
- file: mediaAsset.file,
- startTime: element.startTime / TICKS_PER_SECOND,
- duration: element.duration / TICKS_PER_SECOND,
- trimStart: element.trimStart / TICKS_PER_SECOND,
- trimEnd: element.trimEnd / TICKS_PER_SECOND,
- volume,
- muted,
- retime: element.retime,
- };
-}
-
-export async function collectAudioMixSources({
- tracks,
- mediaAssets,
-}: {
- tracks: SceneTracks;
- mediaAssets: MediaAsset[];
-}): Promise<AudioMixSource[]> {
- const orderedTracks = [...tracks.overlay, tracks.main, ...tracks.audio];
- const audioMixSources: AudioMixSource[] = [];
- const mediaMap = new Map<string, MediaAsset>(
- mediaAssets.map((asset) => [asset.id, asset]),
- );
- const pendingLibrarySources: Array<Promise<AudioMixSource | null>> = [];
-
- for (const track of orderedTracks) {
- if (canTrackHaveAudio(track) && track.muted) continue;
-
- for (const element of track.elements) {
- if (!canElementHaveAudio(element)) continue;
- if (element.muted === true) continue;
- const mediaAsset = hasMediaId(element)
- ? (mediaMap.get(element.mediaId) ?? null)
- : null;
- if (!doesElementHaveEnabledAudio({ element, mediaAsset })) continue;
- const volume = resolveEffectiveAudioGain({
- element,
- localTime: 0,
- });
-
- if (element.type === "audio") {
- if (element.sourceType === "upload") {
- const mediaAsset = mediaMap.get(element.mediaId);
- if (!mediaAsset) continue;
-
- audioMixSources.push(
- collectMediaAudioSource({ element, mediaAsset, volume }),
- );
- } else {
- pendingLibrarySources.push(
- fetchLibraryAudioSource({ element, volume }),
- );
- }
- continue;
- }
-
- if (element.type === "video") {
- if (mediaAsset && mediaSupportsAudio({ media: mediaAsset })) {
- audioMixSources.push(
- collectMediaAudioSource({ element, mediaAsset, volume }),
- );
- }
- }
- }
- }
-
- const resolvedLibrarySources = await Promise.all(pendingLibrarySources);
- for (const source of resolvedLibrarySources) {
- if (source) audioMixSources.push(source);
- }
-
- return audioMixSources;
-}
-
-export async function collectAudioClips({
- tracks,
- mediaAssets,
-}: {
- tracks: SceneTracks;
- mediaAssets: MediaAsset[];
-}): Promise<AudioClipSource[]> {
- const orderedTracks = [...tracks.overlay, tracks.main, ...tracks.audio];
- const clips: AudioClipSource[] = [];
- const mediaMap = new Map<string, MediaAsset>(
- mediaAssets.map((asset) => [asset.id, asset]),
- );
- const pendingLibraryClips: Array<Promise<AudioClipSource | null>> = [];
-
- for (const track of orderedTracks) {
- const isTrackMuted = canTrackHaveAudio(track) && track.muted;
-
- for (const element of track.elements) {
- if (!canElementHaveAudio(element)) continue;
-
- const mediaAsset = hasMediaId(element)
- ? (mediaMap.get(element.mediaId) ?? null)
- : null;
- if (!doesElementHaveEnabledAudio({ element, mediaAsset })) continue;
-
- const isElementMuted =
- "muted" in element ? (element.muted ?? false) : false;
- const muted = isTrackMuted || isElementMuted;
- const volume = resolveEffectiveAudioGain({
- element,
- trackMuted: isTrackMuted,
- localTime: 0,
- });
-
- if (element.type === "audio") {
- if (element.sourceType === "upload") {
- const mediaAsset = mediaMap.get(element.mediaId);
- if (!mediaAsset) continue;
-
- clips.push(
- collectMediaAudioClip({
- element,
- mediaAsset,
- muted,
- volume,
- }),
- );
- } else {
- pendingLibraryClips.push(
- fetchLibraryAudioClip({ element, muted, volume }),
- );
- }
- continue;
- }
-
- if (element.type === "video") {
- if (mediaAsset && mediaSupportsAudio({ media: mediaAsset })) {
- clips.push(
- collectMediaAudioClip({
- element,
- mediaAsset,
- muted,
- volume,
- }),
- );
- }
- }
- }
- }
-
- const resolvedLibraryClips = await Promise.all(pendingLibraryClips);
- for (const clip of resolvedLibraryClips) {
- if (clip) clips.push(clip);
- }
-
- return clips;
-}
-
-export async function createTimelineAudioBuffer({
- tracks,
- mediaAssets,
- duration,
- sampleRate = EXPORT_SAMPLE_RATE,
- audioContext,
-}: {
- tracks: SceneTracks;
- mediaAssets: MediaAsset[];
- duration: number;
- sampleRate?: number;
- audioContext?: AudioContext;
-}): Promise<AudioBuffer | null> {
- const context = audioContext ?? createAudioContext({ sampleRate });
-
- const audioElements = await collectAudioElements({
- tracks,
- mediaAssets,
- audioContext: context,
- });
-
- if (audioElements.length === 0) return null;
-
- const outputChannels = 2;
- const durationSeconds = duration / TICKS_PER_SECOND;
- const outputLength = Math.ceil(durationSeconds * sampleRate);
- const outputBuffer = context.createBuffer(
- outputChannels,
- outputLength,
- sampleRate,
- );
-
- for (const element of audioElements) {
- if (element.muted) continue;
-
- const renderedBuffer = shouldMaintainPitch({
- rate: element.retime?.rate ?? 1,
- maintainPitch: element.retime?.maintainPitch,
- })
- ? await renderRetimedBuffer({
- audioContext: context,
- sourceBuffer: element.buffer,
- trimStart: element.trimStart,
- clipDuration: element.duration,
- retime: element.retime,
- maintainPitch: true,
- })
- : undefined;
-
- mixAudioChannels({
- element,
- buffer: renderedBuffer ?? element.buffer,
- trimStart: renderedBuffer ? 0 : element.trimStart,
- retime: renderedBuffer ? undefined : element.retime,
- outputBuffer,
- outputLength,
- sampleRate,
- });
- }
-
- return await applyAudioMasteringToBuffer({ audioBuffer: outputBuffer });
-}
-
-function collectPeakRange({
- buffer,
- count,
- startSample,
- endSample,
-}: {
- buffer: AudioBuffer;
- count: number;
- startSample: number;
- endSample: number;
-}): Float32Array {
- const channels = buffer.numberOfChannels;
- const peaks = new Float32Array(count);
-
- for (let c = 0; c < channels; c++) {
- const data = buffer.getChannelData(c);
- for (let i = 0; i < count; i++) {
- const { bucketStart: start, bucketEnd: end } = getSampleBucketRange({
- startSample,
- endSample,
- bucketIndex: i,
- bucketCount: count,
- });
- for (let j = start; j < end; j++) {
- const abs = Math.abs(data[j]);
- if (abs > peaks[i]) peaks[i] = abs;
- }
- }
- }
-
- return peaks;
-}
-
-export function extractPeakRange({
- buffer,
- count,
- startSample,
- endSample,
-}: {
- buffer: AudioBuffer;
- count: number;
- startSample: number;
- endSample: number;
-}): number[] {
- return Array.from(
- collectPeakRange({
- buffer,
- count,
- startSample,
- endSample,
- }),
- );
-}
-
-export function getSampleBucketRange({
- startSample,
- endSample,
- bucketIndex,
- bucketCount,
-}: {
- startSample: number;
- endSample: number;
- bucketIndex: number;
- bucketCount: number;
-}): {
- bucketStart: number;
- bucketEnd: number;
-} {
- const rangeLength = Math.max(0, endSample - startSample);
- const bucketStart =
- startSample + Math.floor((bucketIndex * rangeLength) / bucketCount);
- const bucketEnd =
- startSample + Math.floor(((bucketIndex + 1) * rangeLength) / bucketCount);
- return {
- bucketStart,
- bucketEnd: Math.max(bucketStart, bucketEnd),
- };
-}
-
-export function extractRmsBuckets({
- buffer,
- buckets,
-}: {
- buffer: AudioBuffer;
- buckets: SampleBucket[];
-}): number[] {
- return computeRmsBuckets({ buffer, buckets });
-}
-
-/**
- * Computes per-bucket waveform amplitude using the maximum RMS over a short
- * analysis window inside each bucket.
- *
- * A naive mean-RMS over a whole bucket averages silence together with nearby
- * sound, which smears transitions (e.g. the onset of speech) across the
- * bucket and makes the waveform respond late. Taking the max over fixed
- * short windows (~20 ms) preserves the smooth, non-jittery RMS character
- * while making transitions land where they actually happen in the audio.
- *
- * Channels are combined per-window before taking the max, so the measure
- * reflects total energy regardless of stereo layout.
- */
-export function extractRmsRange({
- buffer,
- count,
- startSample,
- endSample,
-}: {
- buffer: AudioBuffer;
- count: number;
- startSample: number;
- endSample: number;
-}): number[] {
- return extractRmsBuckets({
- buffer,
- buckets: Array.from({ length: count }, (_, bucketIndex) =>
- getSampleBucketRange({
- startSample,
- endSample,
- bucketIndex,
- bucketCount: count,
- }),
- ),
- });
-}
-
-function mixAudioChannels({
- element,
- buffer,
- trimStart,
- retime,
- outputBuffer,
- outputLength,
- sampleRate,
-}: {
- element: CollectedAudioElement;
- buffer: AudioBuffer;
- trimStart: number;
- retime?: RetimeConfig;
- outputBuffer: AudioBuffer;
- outputLength: number;
- sampleRate: number;
-}): void {
- const { startTime, duration: elementDuration } = element;
-
- const outputStartSample = Math.floor(startTime * sampleRate);
- const renderedLength = Math.ceil(elementDuration * sampleRate);
-
- const outputChannels = 2;
- for (let channel = 0; channel < outputChannels; channel++) {
- const outputData = outputBuffer.getChannelData(channel);
- const sourceChannel = Math.min(channel, buffer.numberOfChannels - 1);
- const sourceData = buffer.getChannelData(sourceChannel);
-
- for (let i = 0; i < renderedLength; i++) {
- const outputIndex = outputStartSample + i;
- if (outputIndex >= outputLength) break;
-
- const clipTime = i / sampleRate;
- const sourceTime =
- trimStart + getSourceTimeAtClipTime({ clipTime, retime });
- const sourceIndex = sourceTime * buffer.sampleRate;
- if (sourceIndex >= sourceData.length) break;
-
- const lowerIndex = Math.floor(sourceIndex);
- const upperIndex = Math.min(sourceData.length - 1, lowerIndex + 1);
- const fraction = sourceIndex - lowerIndex;
- const gain = hasAnimatedVolume({ element: element.timelineElement })
- ? resolveEffectiveAudioGain({
- element: element.timelineElement,
- localTime: clipTime,
- })
- : element.volume;
- outputData[outputIndex] +=
- (sourceData[lowerIndex] * (1 - fraction) +
- sourceData[upperIndex] * fraction) *
- gain;
- }
- }
-}