diff options
Diffstat (limited to 'apps/web/src/lib/media')
| -rw-r--r-- | apps/web/src/lib/media/audio-mastering.ts | 103 | ||||
| -rw-r--r-- | apps/web/src/lib/media/audio.ts | 868 | ||||
| -rw-r--r-- | apps/web/src/lib/media/media-utils.ts | 32 | ||||
| -rw-r--r-- | apps/web/src/lib/media/mediabunny.ts | 168 | ||||
| -rw-r--r-- | apps/web/src/lib/media/processing.ts | 296 | ||||
| -rw-r--r-- | apps/web/src/lib/media/types.ts | 9 | ||||
| -rw-r--r-- | apps/web/src/lib/media/upload-toast.ts | 51 | ||||
| -rw-r--r-- | apps/web/src/lib/media/waveform-summary.ts | 233 |
8 files changed, 0 insertions, 1760 deletions
diff --git a/apps/web/src/lib/media/audio-mastering.ts b/apps/web/src/lib/media/audio-mastering.ts deleted file mode 100644 index f901c3bf..00000000 --- a/apps/web/src/lib/media/audio-mastering.ts +++ /dev/null @@ -1,103 +0,0 @@ -const MASTER_LIMITER_THRESHOLD_DB = -1;
-const MASTER_LIMITER_KNEE_DB = 0;
-const MASTER_LIMITER_RATIO = 20;
-const MASTER_LIMITER_ATTACK_SECONDS = 0.001;
-const MASTER_LIMITER_RELEASE_SECONDS = 0.12;
-const MASTER_OUTPUT_HEADROOM = 0.98;
-
-export function getAudioBufferPeak({
- audioBuffer,
-}: {
- audioBuffer: AudioBuffer;
-}): number {
- let peak = 0;
-
- for (let channel = 0; channel < audioBuffer.numberOfChannels; channel++) {
- const channelData = audioBuffer.getChannelData(channel);
- for (let index = 0; index < channelData.length; index++) {
- const magnitude = Math.abs(channelData[index]);
- if (magnitude > peak) {
- peak = magnitude;
- }
- }
- }
-
- return peak;
-}
-
-export function createAudioMasteringChain({
- audioContext,
- destination,
-}: {
- audioContext: AudioContext | OfflineAudioContext;
- destination: AudioNode;
-}): {
- input: GainNode;
-} {
- const input = audioContext.createGain();
- const limiter = audioContext.createDynamicsCompressor();
- const outputGain = audioContext.createGain();
-
- limiter.threshold.value = MASTER_LIMITER_THRESHOLD_DB;
- limiter.knee.value = MASTER_LIMITER_KNEE_DB;
- limiter.ratio.value = MASTER_LIMITER_RATIO;
- limiter.attack.value = MASTER_LIMITER_ATTACK_SECONDS;
- limiter.release.value = MASTER_LIMITER_RELEASE_SECONDS;
- outputGain.gain.value = MASTER_OUTPUT_HEADROOM;
-
- input.connect(limiter);
- limiter.connect(outputGain);
- outputGain.connect(destination);
-
- return { input };
-}
-
-export async function applyAudioMasteringToBuffer({
- audioBuffer,
-}: {
- audioBuffer: AudioBuffer;
-}): Promise<AudioBuffer> {
- if (getAudioBufferPeak({ audioBuffer }) <= MASTER_OUTPUT_HEADROOM) {
- return audioBuffer;
- }
-
- const offlineContext = new OfflineAudioContext(
- audioBuffer.numberOfChannels,
- Math.max(1, audioBuffer.length),
- audioBuffer.sampleRate,
- );
- const source = offlineContext.createBufferSource();
- source.buffer = audioBuffer;
-
- const { input } = createAudioMasteringChain({
- audioContext: offlineContext,
- destination: offlineContext.destination,
- });
- source.connect(input);
- source.start(0);
-
- const renderedBuffer = await offlineContext.startRendering();
- clampAudioBufferPeak({
- audioBuffer: renderedBuffer,
- maxPeak: MASTER_OUTPUT_HEADROOM,
- });
- return renderedBuffer;
-}
-
-function clampAudioBufferPeak({
- audioBuffer,
- maxPeak,
-}: {
- audioBuffer: AudioBuffer;
- maxPeak: number;
-}): void {
- for (let channel = 0; channel < audioBuffer.numberOfChannels; channel++) {
- const channelData = audioBuffer.getChannelData(channel);
- for (let index = 0; index < channelData.length; index++) {
- channelData[index] = Math.max(
- -maxPeak,
- Math.min(maxPeak, channelData[index]),
- );
- }
- }
-}
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; - } - } -} diff --git a/apps/web/src/lib/media/media-utils.ts b/apps/web/src/lib/media/media-utils.ts deleted file mode 100644 index dc73da23..00000000 --- a/apps/web/src/lib/media/media-utils.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type { MediaAsset, MediaType } from "@/lib/media/types";
-
-export const SUPPORTS_AUDIO: readonly MediaType[] = ["audio", "video"];
-
-export function mediaSupportsAudio({
- media,
-}: {
- media: MediaAsset | null | undefined;
-}): boolean {
- if (!media) return false;
- return SUPPORTS_AUDIO.includes(media.type);
-}
-
-export const getMediaTypeFromFile = ({
- file,
-}: {
- file: File;
-}): MediaType | null => {
- const { type } = file;
-
- if (type.startsWith("image/")) {
- return "image";
- }
- if (type.startsWith("video/")) {
- return "video";
- }
- if (type.startsWith("audio/")) {
- return "audio";
- }
-
- return null;
-};
diff --git a/apps/web/src/lib/media/mediabunny.ts b/apps/web/src/lib/media/mediabunny.ts deleted file mode 100644 index 1e97fc42..00000000 --- a/apps/web/src/lib/media/mediabunny.ts +++ /dev/null @@ -1,168 +0,0 @@ -import { Input, ALL_FORMATS, BlobSource } from "mediabunny"; -import { createTimelineAudioBuffer } from "@/lib/media/audio"; -import type { SceneTracks } from "@/lib/timeline"; -import type { MediaAsset } from "@/lib/media/types"; -import { TICKS_PER_SECOND } from "@/lib/wasm"; - -export async function getVideoInfo({ - videoFile, -}: { - videoFile: File; -}): Promise<{ - duration: number; - width: number; - height: number; - fps: number; - hasAudio: boolean; -}> { - const input = new Input({ - source: new BlobSource(videoFile), - formats: ALL_FORMATS, - }); - - const duration = await input.computeDuration(); - const videoTrack = await input.getPrimaryVideoTrack(); - - if (!videoTrack) { - throw new Error("No video track found in the file"); - } - - const packetStats = await videoTrack.computePacketStats(100); - const fps = packetStats.averagePacketRate; - const audioTrack = await input.getPrimaryAudioTrack(); - - return { - duration, - width: videoTrack.displayWidth, - height: videoTrack.displayHeight, - fps, - hasAudio: audioTrack !== null, - }; -} - -const SAMPLE_RATE = 44100; -const NUM_CHANNELS = 2; -const EMPTY_TIMELINE_SILENT_DURATION_SECONDS = 0.1; -const MIN_SILENT_DURATION_SECONDS = 0.001; - -export const extractTimelineAudio = async ({ - tracks, - mediaAssets, - totalDuration, - onProgress, -}: { - tracks: SceneTracks; - mediaAssets: MediaAsset[]; - totalDuration: number; - onProgress?: (progress: number) => void; -}): Promise<Blob> => { - if (totalDuration === 0) { - return createWavBlob({ - samples: new Float32Array( - SAMPLE_RATE * EMPTY_TIMELINE_SILENT_DURATION_SECONDS, - ), - }); - } - - onProgress?.(10); - - const audioBuffer = await createTimelineAudioBuffer({ - tracks, - mediaAssets, - duration: totalDuration, - sampleRate: SAMPLE_RATE, - }); - - if (!audioBuffer) { - const silentDurationSeconds = Math.max( - MIN_SILENT_DURATION_SECONDS, - totalDuration / TICKS_PER_SECOND, - ); - const silentSamples = new Float32Array( - Math.ceil(silentDurationSeconds * SAMPLE_RATE) * NUM_CHANNELS, - ); - return createWavBlob({ samples: silentSamples }); - } - - onProgress?.(90); - - const interleavedSamples = interleaveAudioBuffer({ audioBuffer }); - onProgress?.(100); - - return createWavBlob({ samples: interleavedSamples }); -}; - -function interleaveAudioBuffer({ - audioBuffer, -}: { - audioBuffer: AudioBuffer; -}): Float32Array { - const numChannels = Math.min(NUM_CHANNELS, audioBuffer.numberOfChannels); - const interleavedSamples = new Float32Array( - audioBuffer.length * NUM_CHANNELS, - ); - - for (let sampleIndex = 0; sampleIndex < audioBuffer.length; sampleIndex++) { - for (let channel = 0; channel < NUM_CHANNELS; channel++) { - const sourceChannel = Math.min(channel, Math.max(0, numChannels - 1)); - interleavedSamples[sampleIndex * NUM_CHANNELS + channel] = - audioBuffer.getChannelData(sourceChannel)[sampleIndex] ?? 0; - } - } - - return interleavedSamples; -} - -function createWavBlob({ samples }: { samples: Float32Array }): Blob { - const numChannels = NUM_CHANNELS; - const bitsPerSample = 16; - const bytesPerSample = bitsPerSample / 8; - const numSamples = samples.length / numChannels; - const dataSize = numSamples * numChannels * bytesPerSample; - const buffer = new ArrayBuffer(44 + dataSize); - const view = new DataView(buffer); - - // riff header - writeString({ view, offset: 0, str: "RIFF" }); - view.setUint32(4, 36 + dataSize, true); - writeString({ view, offset: 8, str: "WAVE" }); - - // fmt chunk - writeString({ view, offset: 12, str: "fmt " }); - view.setUint32(16, 16, true); - view.setUint16(20, 1, true); - view.setUint16(22, numChannels, true); - view.setUint32(24, SAMPLE_RATE, true); - view.setUint32(28, SAMPLE_RATE * numChannels * bytesPerSample, true); - view.setUint16(32, numChannels * bytesPerSample, true); - view.setUint16(34, bitsPerSample, true); - - // data chunk - writeString({ view, offset: 36, str: "data" }); - view.setUint32(40, dataSize, true); - - // convert float32 to int16 and write - let offset = 44; - for (let i = 0; i < samples.length; i++) { - const sample = Math.max(-1, Math.min(1, samples[i])); - const int16 = sample < 0 ? sample * 0x8000 : sample * 0x7fff; - view.setInt16(offset, int16, true); - offset += 2; - } - - return new Blob([buffer], { type: "audio/wav" }); -} - -function writeString({ - view, - offset, - str, -}: { - view: DataView; - offset: number; - str: string; -}): void { - for (let i = 0; i < str.length; i++) { - view.setUint8(offset + i, str.charCodeAt(i)); - } -} diff --git a/apps/web/src/lib/media/processing.ts b/apps/web/src/lib/media/processing.ts deleted file mode 100644 index 547e7ed8..00000000 --- a/apps/web/src/lib/media/processing.ts +++ /dev/null @@ -1,296 +0,0 @@ -import { Input, ALL_FORMATS, BlobSource, VideoSampleSink } from "mediabunny"; -import { toast } from "sonner"; -import { getMediaTypeFromFile } from "@/lib/media/media-utils"; -import { formatStorageBytes } from "@/services/storage/quota"; -import { storageService } from "@/services/storage/service"; -import type { MediaAsset } from "@/lib/media/types"; -import { getVideoInfo } from "./mediabunny"; - -export interface ProcessedMediaAsset extends Omit<MediaAsset, "id"> {} - -const THUMBNAIL_MAX_WIDTH = 1280; -const THUMBNAIL_MAX_HEIGHT = 720; - -const getStorageLimitDescription = ({ - fileSize, - availableBytes, -}: { - fileSize: number; - availableBytes: number | null; -}): string => { - const fileSizeLabel = formatStorageBytes({ bytes: fileSize }); - - if (availableBytes === null) { - return `File size is ${fileSizeLabel}.`; - } - - return `File size is ${fileSizeLabel}, but only ${formatStorageBytes({ - bytes: availableBytes, - })} is safely available in browser storage.`; -}; - -const getThumbnailSize = ({ - width, - height, -}: { - width: number; - height: number; -}): { width: number; height: number } => { - const aspectRatio = width / height; - let targetWidth = width; - let targetHeight = height; - - if (targetWidth > THUMBNAIL_MAX_WIDTH) { - targetWidth = THUMBNAIL_MAX_WIDTH; - targetHeight = Math.round(targetWidth / aspectRatio); - } - if (targetHeight > THUMBNAIL_MAX_HEIGHT) { - targetHeight = THUMBNAIL_MAX_HEIGHT; - targetWidth = Math.round(targetHeight * aspectRatio); - } - - return { width: targetWidth, height: targetHeight }; -}; - -const renderToThumbnailDataUrl = ({ - width, - height, - draw, -}: { - width: number; - height: number; - draw: ({ - context, - width, - height, - }: { - context: CanvasRenderingContext2D; - width: number; - height: number; - }) => void; -}): string => { - const size = getThumbnailSize({ width, height }); - const canvas = document.createElement("canvas"); - canvas.width = size.width; - canvas.height = size.height; - const context = canvas.getContext("2d"); - - if (!context) { - throw new Error("Could not get canvas context"); - } - - draw({ context, width: size.width, height: size.height }); - return canvas.toDataURL("image/jpeg", 0.8); -}; - -async function generateThumbnail({ - videoFile, - timeInSeconds, -}: { - videoFile: File; - timeInSeconds: number; -}): Promise<string> { - const input = new Input({ - source: new BlobSource(videoFile), - formats: ALL_FORMATS, - }); - - const videoTrack = await input.getPrimaryVideoTrack(); - if (!videoTrack) { - throw new Error("No video track found in the file"); - } - - const canDecode = await videoTrack.canDecode(); - if (!canDecode) { - throw new Error("Video codec not supported for decoding"); - } - - const sink = new VideoSampleSink(videoTrack); - - const frame = await sink.getSample(timeInSeconds); - - if (!frame) { - throw new Error("Could not get frame at specified time"); - } - - try { - return renderToThumbnailDataUrl({ - width: videoTrack.displayWidth, - height: videoTrack.displayHeight, - draw: ({ context, width, height }) => { - frame.draw(context, 0, 0, width, height); - }, - }); - } finally { - frame.close(); - } -} - -async function generateImageThumbnail({ - imageFile, -}: { - imageFile: File; -}): Promise<{ thumbnailUrl: string; width: number; height: number }> { - return new Promise((resolve, reject) => { - const image = new window.Image(); - const objectUrl = URL.createObjectURL(imageFile); - - image.addEventListener("load", () => { - try { - const thumbnailUrl = renderToThumbnailDataUrl({ - width: image.naturalWidth, - height: image.naturalHeight, - draw: ({ context, width, height }) => { - context.drawImage(image, 0, 0, width, height); - }, - }); - resolve({ - thumbnailUrl, - width: image.naturalWidth, - height: image.naturalHeight, - }); - } catch (error) { - reject( - error instanceof Error ? error : new Error("Could not render image"), - ); - } finally { - URL.revokeObjectURL(objectUrl); - image.remove(); - } - }); - - image.addEventListener("error", () => { - URL.revokeObjectURL(objectUrl); - image.remove(); - reject(new Error("Could not load image")); - }); - - image.src = objectUrl; - }); -} - -export async function processMediaAssets({ - files, - onProgress, -}: { - files: FileList | File[]; - onProgress?: ({ progress }: { progress: number }) => void; -}): Promise<ProcessedMediaAsset[]> { - const fileArray = Array.from(files); - const processedAssets: ProcessedMediaAsset[] = []; - - const total = fileArray.length; - let completed = 0; - - for (const file of fileArray) { - const fileType = getMediaTypeFromFile({ file }); - - if (!fileType) { - toast.error(`Unsupported file type: ${file.name}`); - continue; - } - - const storageCheck = await storageService.canStoreFile({ - size: file.size, - }); - - if (!storageCheck.canStore) { - toast.error(`Not enough browser storage for ${file.name}`, { - description: getStorageLimitDescription({ - fileSize: file.size, - availableBytes: storageCheck.availableBytes, - }), - }); - continue; - } - - const url = URL.createObjectURL(file); - let thumbnailUrl: string | undefined; - let duration: number | undefined; - let width: number | undefined; - let height: number | undefined; - let fps: number | undefined; - let hasAudio: boolean | undefined; - - try { - if (fileType === "image") { - const result = await generateImageThumbnail({ imageFile: file }); - thumbnailUrl = result.thumbnailUrl; - width = result.width; - height = result.height; - } else if (fileType === "video") { - try { - const videoInfo = await getVideoInfo({ videoFile: file }); - duration = videoInfo.duration; - width = videoInfo.width; - height = videoInfo.height; - fps = Number.isFinite(videoInfo.fps) - ? Math.round(videoInfo.fps) - : undefined; - hasAudio = videoInfo.hasAudio; - - thumbnailUrl = await generateThumbnail({ - videoFile: file, - timeInSeconds: 1, - }); - } catch (error) { - console.warn("Video processing failed", error); - } - } else if (fileType === "audio") { - // For audio, we don't set width/height/fps (they'll be undefined) - duration = await getMediaDuration({ file }); - } - - processedAssets.push({ - name: file.name, - type: fileType, - file, - url, - thumbnailUrl, - duration, - width, - height, - fps, - hasAudio, - }); - - await new Promise((resolve) => setTimeout(resolve, 0)); - - completed += 1; - if (onProgress) { - const percent = Math.round((completed / total) * 100); - onProgress({ progress: percent }); - } - } catch (error) { - console.error("Error processing file:", file.name, error); - toast.error(`Failed to process ${file.name}`); - URL.revokeObjectURL(url); // Clean up on error - } - } - - return processedAssets; -} - -const getMediaDuration = ({ file }: { file: File }): Promise<number> => { - return new Promise((resolve, reject) => { - const element = document.createElement( - file.type.startsWith("video/") ? "video" : "audio", - ) as HTMLVideoElement; - const objectUrl = URL.createObjectURL(file); - - element.addEventListener("loadedmetadata", () => { - resolve(element.duration); - URL.revokeObjectURL(objectUrl); - element.remove(); - }); - - element.addEventListener("error", () => { - reject(new Error("Could not load media")); - URL.revokeObjectURL(objectUrl); - element.remove(); - }); - - element.src = objectUrl; - element.load(); - }); -}; diff --git a/apps/web/src/lib/media/types.ts b/apps/web/src/lib/media/types.ts deleted file mode 100644 index 4d3e4106..00000000 --- a/apps/web/src/lib/media/types.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { MediaAssetData } from "@/services/storage/types";
-
-export type MediaType = "image" | "video" | "audio";
-
-export interface MediaAsset
- extends Omit<MediaAssetData, "size" | "lastModified"> {
- file: File;
- url?: string;
-}
diff --git a/apps/web/src/lib/media/upload-toast.ts b/apps/web/src/lib/media/upload-toast.ts deleted file mode 100644 index 45846206..00000000 --- a/apps/web/src/lib/media/upload-toast.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { toast } from "sonner";
-
-export interface MediaUploadToastResult {
- uploadedCount: number;
- assetNames?: string[];
-}
-
-function getAssetLabel({ count }: { count: number }): string {
- return count === 1 ? "media asset" : "media assets";
-}
-
-function waitForNextPaint(): Promise<void> {
- return new Promise((resolve) => {
- requestAnimationFrame(() => {
- requestAnimationFrame(() => resolve());
- });
- });
-}
-
-export async function showMediaUploadToast<T extends MediaUploadToastResult>({
- filesCount,
- promise,
-}: {
- filesCount: number;
- promise: Promise<T> | (() => Promise<T>);
-}) {
- const run = typeof promise === "function" ? promise : () => promise;
- const toastPromise = toast.promise(async () => {
- await waitForNextPaint();
- return run();
- }, {
- loading: `Uploading ${getAssetLabel({ count: filesCount })}...`,
- success: ({ uploadedCount, assetNames }) => {
- if (uploadedCount === 1) {
- const assetName = assetNames?.[0];
- return assetName
- ? `${assetName} has been uploaded`
- : "1 media asset has been uploaded";
- }
-
- if (uploadedCount > 1) {
- return `${uploadedCount} media assets have been uploaded`;
- }
-
- return "No media assets were uploaded";
- },
- error: `Failed to upload ${getAssetLabel({ count: filesCount })}`,
- });
-
- return toastPromise.unwrap();
-}
diff --git a/apps/web/src/lib/media/waveform-summary.ts b/apps/web/src/lib/media/waveform-summary.ts deleted file mode 100644 index 522d6e04..00000000 --- a/apps/web/src/lib/media/waveform-summary.ts +++ /dev/null @@ -1,233 +0,0 @@ -"use client"; - -import { getSourceTimeAtClipTime } from "@/lib/retime"; -import type { RetimeConfig } from "@/lib/timeline"; - -const RMS_ANALYSIS_WINDOW_SECONDS = 0.02; -const DEFAULT_SOURCE_WAVEFORM_BUCKET_SIZE = 128; - -function computePeakBuckets({ - buffer, - buckets, -}: { - buffer: AudioBuffer; - buckets: SampleBucket[]; -}): number[] { - const channels = buffer.numberOfChannels; - const channelData: Float32Array[] = Array.from({ length: channels }, (_, c) => - buffer.getChannelData(c), - ); - - return buckets.map(({ bucketStart, bucketEnd }) => { - let peak = 0; - for (let c = 0; c < channels; c++) { - const data = channelData[c]; - for (let j = bucketStart; j < bucketEnd; j++) { - const abs = Math.abs(data[j] ?? 0); - if (abs > peak) { - peak = abs; - } - } - } - return peak; - }); -} - -export interface SampleBucket { - bucketStart: number; - bucketEnd: number; -} - -export interface SourceWaveformSummary { - sourceKey: string; - sampleRate: number; - totalSamples: number; - bucketSize: number; - amplitudes: Float32Array; -} - -export function buildWaveformSourceKey({ - kind, - id, -}: { - kind: "media" | "library"; - id: string; -}): string { - return `${kind}:${id}`; -} - -export function buildSourceWaveformSummary({ - sourceKey, - buffer, - bucketSize = DEFAULT_SOURCE_WAVEFORM_BUCKET_SIZE, -}: { - sourceKey: string; - buffer: AudioBuffer; - bucketSize?: number; -}): SourceWaveformSummary { - const safeBucketSize = Math.max(1, Math.floor(bucketSize)); - const bucketCount = Math.max(1, Math.ceil(buffer.length / safeBucketSize)); - const amplitudes = computePeakBuckets({ - buffer, - buckets: Array.from({ length: bucketCount }, (_, bucketIndex) => { - const bucketStart = bucketIndex * safeBucketSize; - const bucketEnd = Math.min(buffer.length, bucketStart + safeBucketSize); - return { bucketStart, bucketEnd }; - }), - }); - - return { - sourceKey, - sampleRate: buffer.sampleRate, - totalSamples: buffer.length, - bucketSize: safeBucketSize, - amplitudes: Float32Array.from(amplitudes), - }; -} - -export function buildWaveformSampleBuckets({ - clipLeftPx, - clipRightPx, - barCount, - pixelsPerSecond, - clipDurationSec, - sourceStartSec, - retime, - sampleRate, - maxSampleExclusive, - barStepPx, -}: { - clipLeftPx: number; - clipRightPx: number; - barCount: number; - pixelsPerSecond: number; - clipDurationSec: number; - sourceStartSec: number; - retime?: RetimeConfig; - sampleRate: number; - maxSampleExclusive: number; - barStepPx: number; -}): SampleBucket[] { - return Array.from({ length: barCount }, (_, index) => { - const bucketLeftPx = clipLeftPx + index * barStepPx; - const bucketRightPx = Math.min(clipRightPx, bucketLeftPx + barStepPx); - const clipStartSec = Math.max( - 0, - Math.min(clipDurationSec, bucketLeftPx / pixelsPerSecond), - ); - const clipEndSec = Math.max( - clipStartSec, - Math.min(clipDurationSec, bucketRightPx / pixelsPerSecond), - ); - const sourceBucketStartSec = - sourceStartSec + - getSourceTimeAtClipTime({ - clipTime: clipStartSec, - retime, - }); - const sourceBucketEndSec = - sourceStartSec + - getSourceTimeAtClipTime({ - clipTime: clipEndSec, - retime, - }); - - return { - bucketStart: Math.max(0, Math.floor(sourceBucketStartSec * sampleRate)), - bucketEnd: Math.min( - maxSampleExclusive, - Math.max(0, Math.ceil(sourceBucketEndSec * sampleRate)), - ), - }; - }); -} - -export function sampleSourceWaveformSummary({ - summary, - buckets, -}: { - summary: SourceWaveformSummary; - buckets: SampleBucket[]; -}): number[] { - return buckets.map(({ bucketStart, bucketEnd }) => { - if (bucketEnd <= bucketStart) { - return 0; - } - - const startIndex = Math.max( - 0, - Math.floor(bucketStart / summary.bucketSize), - ); - const endIndex = Math.min( - summary.amplitudes.length, - Math.max(startIndex + 1, Math.ceil(bucketEnd / summary.bucketSize)), - ); - - let maxAmplitude = 0; - for (let i = startIndex; i < endIndex; i++) { - const amplitude = summary.amplitudes[i] ?? 0; - if (amplitude > maxAmplitude) { - maxAmplitude = amplitude; - } - } - - return maxAmplitude; - }); -} - -export function computeRmsBuckets({ - buffer, - buckets, -}: { - buffer: AudioBuffer; - buckets: SampleBucket[]; -}): number[] { - const channels = buffer.numberOfChannels; - const maxWindowLength = Math.max( - 1, - Math.floor(buffer.sampleRate * RMS_ANALYSIS_WINDOW_SECONDS), - ); - - const channelData: Float32Array[] = new Array(channels); - for (let c = 0; c < channels; c++) { - channelData[c] = buffer.getChannelData(c); - } - - const result = new Array<number>(buckets.length); - - for (let i = 0; i < buckets.length; i++) { - const { bucketStart, bucketEnd } = buckets[i]; - const bucketLength = bucketEnd - bucketStart; - if (bucketLength <= 0) { - result[i] = 0; - continue; - } - - const windowLength = Math.max(1, Math.min(bucketLength, maxWindowLength)); - let maxMeanSquare = 0; - - for (let winStart = bucketStart; winStart < bucketEnd; ) { - const winEnd = Math.min(winStart + windowLength, bucketEnd); - const n = winEnd - winStart; - if (n > 0) { - let sum = 0; - for (let c = 0; c < channels; c++) { - const data = channelData[c]; - for (let j = winStart; j < winEnd; j++) { - const v = data[j]; - sum += v * v; - } - } - const meanSquare = sum / (n * channels); - if (meanSquare > maxMeanSquare) { - maxMeanSquare = meanSquare; - } - } - winStart = winEnd; - } - - result[i] = Math.sqrt(maxMeanSquare); - } - - return result; -} |
