summaryrefslogtreecommitdiff
path: root/apps/web/src/lib
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2026-04-18 01:40:18 +0200
committerMaze Winther <mazewinther@gmail.com>2026-04-18 01:40:18 +0200
commit011f51051c9750706978d6c89ce84dd15560c335 (patch)
tree2e7b23ebebb798934d6cb6aeace855f31699b633 /apps/web/src/lib
parentc46d28891fee62ca2d3132da876af6411a41d9da (diff)
downloadopencut-011f51051c9750706978d6c89ce84dd15560c335.tar.gz
feat: clip volume line and timeline waveform improvements
Diffstat (limited to 'apps/web/src/lib')
-rw-r--r--apps/web/src/lib/changelog/entries/0.4.0.md8
-rw-r--r--apps/web/src/lib/commands/media/remove-media-asset.ts8
-rw-r--r--apps/web/src/lib/commands/timeline/element/insert-element.ts1
-rw-r--r--apps/web/src/lib/commands/timeline/element/split-elements.ts122
-rw-r--r--apps/web/src/lib/commands/timeline/element/toggle-source-audio-separation.ts48
-rw-r--r--apps/web/src/lib/commands/timeline/track/add-track.ts5
-rw-r--r--apps/web/src/lib/media/audio.ts162
-rw-r--r--apps/web/src/lib/media/waveform-summary.ts233
-rw-r--r--apps/web/src/lib/timeline/audio-display.ts64
-rw-r--r--apps/web/src/lib/timeline/group-resize/compute-resize.ts97
10 files changed, 553 insertions, 195 deletions
diff --git a/apps/web/src/lib/changelog/entries/0.4.0.md b/apps/web/src/lib/changelog/entries/0.4.0.md
index e20c2785..141df133 100644
--- a/apps/web/src/lib/changelog/entries/0.4.0.md
+++ b/apps/web/src/lib/changelog/entries/0.4.0.md
@@ -10,4 +10,10 @@ changes:
- type: new
text: "Two new mask types: text and custom (draw any shape with the pen tool using bezier curves). Both support feather, invert, and stroke."
- type: fixed
- text: "Preview panning now keeps working while the Masks tab is open." \ No newline at end of file
+ text: "Preview panning now keeps working while the Masks tab is open."
+- type: new
+ text: "Audio clips have a horizontal volume line you can drag up or down to change volume. The line position reflects the current volume setting and is consistent across clips: same dB always means the same position."
+- type: improved
+ text: "Audio waveforms now show the actual shape of the audio. Quiet parts are visible, loud parts are tall, and any bar that peaks above 0 dBFS gets a colored tip at the top."
+- type: fixed
+ text: "Trimming or cutting an audio clip no longer causes the waveform to compress or show the wrong section of audio." \ No newline at end of file
diff --git a/apps/web/src/lib/commands/media/remove-media-asset.ts b/apps/web/src/lib/commands/media/remove-media-asset.ts
index 5601a7e3..85d187d9 100644
--- a/apps/web/src/lib/commands/media/remove-media-asset.ts
+++ b/apps/web/src/lib/commands/media/remove-media-asset.ts
@@ -1,8 +1,10 @@
import { Command, type CommandResult } from "@/lib/commands/base-command";
import { EditorCore } from "@/core";
import type { MediaAsset } from "@/lib/media/types";
+import { buildWaveformSourceKey } from "@/lib/media/waveform-summary";
import { storageService } from "@/services/storage/service";
import { videoCache } from "@/services/video-cache/service";
+import { waveformCache } from "@/services/waveform-cache/service";
import { hasMediaId } from "@/lib/timeline/element-utils";
import type { SceneTracks } from "@/lib/timeline";
@@ -41,6 +43,12 @@ export class RemoveMediaAssetCommand extends Command {
}
videoCache.clearVideo({ mediaId: this.assetId });
+ waveformCache.clearSource({
+ sourceKey: buildWaveformSourceKey({
+ kind: "media",
+ id: this.assetId,
+ }),
+ });
editor.media.setAssets({
assets: assets.filter((media) => media.id !== this.assetId),
diff --git a/apps/web/src/lib/commands/timeline/element/insert-element.ts b/apps/web/src/lib/commands/timeline/element/insert-element.ts
index 0e806d6e..ed05b8c6 100644
--- a/apps/web/src/lib/commands/timeline/element/insert-element.ts
+++ b/apps/web/src/lib/commands/timeline/element/insert-element.ts
@@ -3,7 +3,6 @@ import { EditorCore } from "@/core";
import type {
CreateTimelineElement,
SceneTracks,
- TimelineTrack,
TimelineElement,
TrackType,
} from "@/lib/timeline";
diff --git a/apps/web/src/lib/commands/timeline/element/split-elements.ts b/apps/web/src/lib/commands/timeline/element/split-elements.ts
index 9799f7e8..0f2495f3 100644
--- a/apps/web/src/lib/commands/timeline/element/split-elements.ts
+++ b/apps/web/src/lib/commands/timeline/element/split-elements.ts
@@ -37,7 +37,9 @@ export class SplitElementsCommand extends Command {
this.savedState = editor.scenes.getActiveScene().tracks;
this.rightSideElements = [];
- const splitTrack = <TTrack extends { id: string; elements: TimelineElement[] }>(
+ const splitTrack = <
+ TTrack extends { id: string; elements: TimelineElement[] },
+ >(
track: TTrack,
): TTrack => {
const elementsToSplit = this.elements.filter(
@@ -48,7 +50,7 @@ export class SplitElementsCommand extends Command {
return track;
}
- let elements = track.elements.flatMap((element) => {
+ const elements = track.elements.flatMap((element) => {
const shouldSplit = elementsToSplit.some(
(target) => target.elementId === element.id,
);
@@ -87,67 +89,67 @@ export class SplitElementsCommand extends Command {
splitTime: relativeTime,
shouldIncludeSplitBoundary: true,
});
+ let splitResult: TimelineElement[];
if (this.retainSide === "left") {
- return [
- {
- ...element,
- duration: leftVisibleDuration,
- trimEnd: element.trimEnd + rightSourceSpan,
- name: `${element.name} (left)`,
- animations: leftAnimations,
- ...(retimeRef !== undefined ? { retime: retimeRef } : {}),
- },
- ];
- }
-
- if (this.retainSide === "right") {
- const newId = generateUUID();
- this.rightSideElements.push({
- trackId: track.id,
- elementId: newId,
- });
- return [
- {
- ...element,
- id: newId,
- startTime: this.splitTime,
- duration: rightVisibleDuration,
- trimStart: element.trimStart + leftSourceSpan,
- name: `${element.name} (right)`,
- animations: rightAnimations,
- ...(retimeRef !== undefined ? { retime: retimeRef } : {}),
- },
- ];
- }
-
- // "both" - split into two pieces
- const secondElementId = generateUUID();
- this.rightSideElements.push({
- trackId: track.id,
- elementId: secondElementId,
- });
+ splitResult = [
+ {
+ ...element,
+ duration: leftVisibleDuration,
+ trimEnd: element.trimEnd + rightSourceSpan,
+ name: `${element.name} (left)`,
+ animations: leftAnimations,
+ ...(retimeRef !== undefined ? { retime: retimeRef } : {}),
+ },
+ ];
+ } else if (this.retainSide === "right") {
+ const newId = generateUUID();
+ this.rightSideElements.push({
+ trackId: track.id,
+ elementId: newId,
+ });
+ splitResult = [
+ {
+ ...element,
+ id: newId,
+ startTime: this.splitTime,
+ duration: rightVisibleDuration,
+ trimStart: element.trimStart + leftSourceSpan,
+ name: `${element.name} (right)`,
+ animations: rightAnimations,
+ ...(retimeRef !== undefined ? { retime: retimeRef } : {}),
+ },
+ ];
+ } else {
+ // "both" - split into two pieces
+ const secondElementId = generateUUID();
+ this.rightSideElements.push({
+ trackId: track.id,
+ elementId: secondElementId,
+ });
+ splitResult = [
+ {
+ ...element,
+ duration: leftVisibleDuration,
+ trimEnd: element.trimEnd + rightSourceSpan,
+ name: `${element.name} (left)`,
+ animations: leftAnimations,
+ ...(retimeRef !== undefined ? { retime: retimeRef } : {}),
+ },
+ {
+ ...element,
+ id: secondElementId,
+ startTime: this.splitTime,
+ duration: rightVisibleDuration,
+ trimStart: element.trimStart + leftSourceSpan,
+ name: `${element.name} (right)`,
+ animations: rightAnimations,
+ ...(retimeRef !== undefined ? { retime: retimeRef } : {}),
+ },
+ ];
+ }
- return [
- {
- ...element,
- duration: leftVisibleDuration,
- trimEnd: element.trimEnd + rightSourceSpan,
- name: `${element.name} (left)`,
- animations: leftAnimations,
- ...(retimeRef !== undefined ? { retime: retimeRef } : {}),
- },
- {
- ...element,
- id: secondElementId,
- startTime: this.splitTime,
- duration: rightVisibleDuration,
- trimStart: element.trimStart + leftSourceSpan,
- name: `${element.name} (right)`,
- animations: rightAnimations,
- ...(retimeRef !== undefined ? { retime: retimeRef } : {}),
- },
- ];
+ return splitResult;
});
return { ...track, elements } as TTrack;
diff --git a/apps/web/src/lib/commands/timeline/element/toggle-source-audio-separation.ts b/apps/web/src/lib/commands/timeline/element/toggle-source-audio-separation.ts
index 36b3b7a7..7300f540 100644
--- a/apps/web/src/lib/commands/timeline/element/toggle-source-audio-separation.ts
+++ b/apps/web/src/lib/commands/timeline/element/toggle-source-audio-separation.ts
@@ -5,10 +5,12 @@ import {
canExtractSourceAudio,
isSourceAudioSeparated,
} from "@/lib/timeline/audio-separation";
-import { buildEmptyTrack } from "@/lib/timeline/placement";
+import {
+ applyPlacement,
+ resolveTrackPlacement,
+} from "@/lib/timeline/placement";
import { updateElementInSceneTracks } from "@/lib/timeline/track-element-update";
import type {
- AudioTrack,
SceneTracks,
TimelineElement,
VideoElement,
@@ -35,9 +37,7 @@ export class ToggleSourceAudioSeparationCommand extends Command {
...this.savedState.overlay,
this.savedState.main,
...this.savedState.audio,
- ].find(
- (track) => track.id === this.params.trackId,
- );
+ ].find((track) => track.id === this.params.trackId);
if (!sourceTrack) {
return;
}
@@ -61,8 +61,7 @@ export class ToggleSourceAudioSeparationCommand extends Command {
return;
}
- const mediaAsset = editor
- .media
+ const mediaAsset = editor.media
.getAssets()
.find((asset) => asset.id === videoElement.mediaId);
if (!canExtractSourceAudio(videoElement, mediaAsset)) {
@@ -78,20 +77,32 @@ export class ToggleSourceAudioSeparationCommand extends Command {
}),
id: generateUUID(),
};
- const newAudioTrack = {
- ...buildEmptyTrack({
- id: generateUUID(),
- type: "audio",
- }),
+ const placementResult = resolveTrackPlacement({
+ tracks: this.savedState,
+ trackType: "audio",
+ timeSpans: [
+ {
+ startTime: separatedAudioElement.startTime,
+ duration: separatedAudioElement.duration,
+ },
+ ],
+ strategy: { type: "firstAvailable" },
+ });
+ if (!placementResult) {
+ return;
+ }
+ const appliedPlacement = applyPlacement({
+ tracks: this.savedState,
+ placementResult,
elements: [separatedAudioElement],
- } as AudioTrack;
+ });
+ if (!appliedPlacement) {
+ return;
+ }
editor.timeline.updateTracks(
updateSourceAudioEnabled({
- tracks: {
- ...this.savedState,
- audio: [...this.savedState.audio, newAudioTrack],
- },
+ tracks: appliedPlacement.updatedTracks,
trackId: this.params.trackId,
elementId: this.params.elementId,
isSourceAudioEnabled: false,
@@ -124,7 +135,8 @@ function updateSourceAudioEnabled({
tracks,
trackId,
elementId,
- elementPredicate: (element): element is VideoElement => element.type === "video",
+ elementPredicate: (element): element is VideoElement =>
+ element.type === "video",
update: (element) => ({
...element,
isSourceAudioEnabled,
diff --git a/apps/web/src/lib/commands/timeline/track/add-track.ts b/apps/web/src/lib/commands/timeline/track/add-track.ts
index 66b9c348..a577c693 100644
--- a/apps/web/src/lib/commands/timeline/track/add-track.ts
+++ b/apps/web/src/lib/commands/timeline/track/add-track.ts
@@ -69,10 +69,7 @@ function buildAudioTrackState({
insertIndex: number;
trackId: string;
}): SceneTracks {
- const audioInsertIndex = Math.max(
- 0,
- insertIndex - tracks.overlay.length - 1,
- );
+ const audioInsertIndex = Math.max(0, insertIndex - tracks.overlay.length - 1);
const newTrack = buildEmptyTrack({
id: trackId,
type: "audio",
diff --git a/apps/web/src/lib/media/audio.ts b/apps/web/src/lib/media/audio.ts
index 04ea580f..675cc3a5 100644
--- a/apps/web/src/lib/media/audio.ts
+++ b/apps/web/src/lib/media/audio.ts
@@ -20,10 +20,13 @@ 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;
-const COARSE_SAMPLE_COUNT = 2048;
export interface CollectedAudioElement {
timelineElement: AudioCapableElement;
@@ -177,8 +180,8 @@ export async function collectAudioElements({
if (!mediaAsset || !mediaSupportsAudio({ media: mediaAsset })) continue;
pendingElements.push(
- resolveAudioBufferForVideoElement({
- mediaAsset,
+ resolveAudioBufferForAsset({
+ asset: mediaAsset,
audioContext,
}).then((audioBuffer) => {
if (!audioBuffer) return null;
@@ -222,10 +225,8 @@ async function resolveAudioBufferForElement({
try {
if (element.sourceType === "upload") {
const asset = mediaMap.get(element.mediaId);
- if (!asset || asset.type !== "audio") return null;
-
- const arrayBuffer = await asset.file.arrayBuffer();
- return await audioContext.decodeAudioData(arrayBuffer.slice(0));
+ if (!asset) return null;
+ return await resolveAudioBufferForAsset({ asset, audioContext });
}
if (element.buffer) return element.buffer;
@@ -243,15 +244,25 @@ async function resolveAudioBufferForElement({
}
}
-async function resolveAudioBufferForVideoElement({
- mediaAsset,
+async function resolveAudioBufferForAsset({
+ asset,
audioContext,
}: {
- mediaAsset: MediaAsset;
+ 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(mediaAsset.file),
+ source: new BlobSource(asset.file),
formats: ALL_FORMATS,
});
@@ -319,7 +330,7 @@ async function resolveAudioBufferForVideoElement({
return await offlineContext.startRendering();
} catch (error) {
- console.warn("Failed to decode video audio:", error);
+ console.warn("Failed to decode asset audio:", error);
return null;
} finally {
input.dispose();
@@ -675,63 +686,130 @@ export async function createTimelineAudioBuffer({
return await applyAudioMasteringToBuffer({ audioBuffer: outputBuffer });
}
-export function computeGlobalMaxRms({
+function collectPeakRange({
buffer,
+ count,
+ startSample,
+ endSample,
}: {
buffer: AudioBuffer;
-}): number {
+ count: number;
+ startSample: number;
+ endSample: number;
+}): Float32Array {
const channels = buffer.numberOfChannels;
- const step = Math.max(1, Math.floor(buffer.length / COARSE_SAMPLE_COUNT));
- let globalMax = 0;
+ const peaks = new Float32Array(count);
for (let c = 0; c < channels; c++) {
const data = buffer.getChannelData(c);
- for (let i = 0; i + step <= buffer.length; i += step) {
- for (let j = i; j < i + step; j++) {
+ 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 > globalMax) globalMax = abs;
+ if (abs > peaks[i]) peaks[i] = abs;
}
}
}
- return globalMax || 1;
+ return peaks;
}
-export function extractRmsRange({
+export function extractPeakRange({
buffer,
count,
startSample,
endSample,
- globalMax,
}: {
buffer: AudioBuffer;
count: number;
startSample: number;
endSample: number;
- globalMax: number;
}): number[] {
- const channels = buffer.numberOfChannels;
- const rangeLength = endSample - startSample;
- const step = Math.max(1, Math.floor(rangeLength / count));
- const peaks = new Float32Array(count);
+ return Array.from(
+ collectPeakRange({
+ buffer,
+ count,
+ startSample,
+ endSample,
+ }),
+ );
+}
- for (let c = 0; c < channels; c++) {
- const data = buffer.getChannelData(c);
- for (let i = 0; i < count; i++) {
- const start = startSample + i * step;
- const end = Math.min(start + step, endSample);
- for (let j = start; j < end; j++) {
- const abs = Math.abs(data[j]);
- if (abs > peaks[i]) peaks[i] = abs;
- }
- }
- }
+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),
+ };
+}
- const norm = 1 / globalMax;
- const result = new Array<number>(count);
- for (let i = 0; i < count; i++) result[i] = Math.min(1, peaks[i] * norm);
+export function extractRmsBuckets({
+ buffer,
+ buckets,
+}: {
+ buffer: AudioBuffer;
+ buckets: SampleBucket[];
+}): number[] {
+ return computeRmsBuckets({ buffer, buckets });
+}
- return result;
+/**
+ * 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({
diff --git a/apps/web/src/lib/media/waveform-summary.ts b/apps/web/src/lib/media/waveform-summary.ts
new file mode 100644
index 00000000..522d6e04
--- /dev/null
+++ b/apps/web/src/lib/media/waveform-summary.ts
@@ -0,0 +1,233 @@
+"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;
+}
diff --git a/apps/web/src/lib/timeline/audio-display.ts b/apps/web/src/lib/timeline/audio-display.ts
new file mode 100644
index 00000000..9958c472
--- /dev/null
+++ b/apps/web/src/lib/timeline/audio-display.ts
@@ -0,0 +1,64 @@
+import { clampDb } from "./audio-state";
+import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "./audio-constants";
+
+const SLIDER_CURVE_EXPONENT = 2;
+const MIN_DISPLAY_DB = -40;
+const WAVEFORM_BAR_EXPONENT = 1.5;
+const MIN_LINEAR_GAIN = 10 ** (VOLUME_DB_MIN / 20);
+const MAX_LINEAR_GAIN = 10 ** (VOLUME_DB_MAX / 20);
+const LINEAR_GAIN_RANGE = MAX_LINEAR_GAIN - MIN_LINEAR_GAIN;
+
+function getNormalizedGainFromDb({ db }: { db: number }): number {
+ const clampedDb = clampDb(db);
+ const linearGain = 10 ** (clampedDb / 20);
+ return (linearGain - MIN_LINEAR_GAIN) / LINEAR_GAIN_RANGE;
+}
+
+/**
+ * Maps the clip's volume setting to the line position. The curve is defined in
+ * linear gain space so dragging near 0 dB is precise while mute compresses into
+ * the bottom of the clip.
+ */
+export function getLinePosFromDb({ db }: { db: number }): number {
+ const normalizedGain = Math.max(
+ 0,
+ Math.min(1, getNormalizedGainFromDb({ db })),
+ );
+ const progress = normalizedGain ** (1 / SLIDER_CURVE_EXPONENT);
+ return (1 - progress) * 100;
+}
+
+/**
+ * Inverse of getLinePosFromDb. Converts a drag position back into the clip's
+ * volume setting without depending on the underlying audio content.
+ */
+export function getDbFromLinePos({ percent }: { percent: number }): number {
+ const clampedPercent = Math.max(0, Math.min(100, percent));
+ const progress = 1 - clampedPercent / 100;
+ const normalizedGain = progress ** SLIDER_CURVE_EXPONENT;
+ const linearGain = MIN_LINEAR_GAIN + normalizedGain * LINEAR_GAIN_RANGE;
+ return clampDb(20 * Math.log10(linearGain));
+}
+
+/**
+ * Maps an output amplitude (raw sample amplitude × gain) to a visible waveform
+ * height fraction using a dB scale. Agnostic to whether the input is peak or
+ * RMS — the caller decides which measure feeds this function; the mapping
+ * curve is the same either way.
+ *
+ * The log scale keeps quiet content visible while reserving the top of the
+ * element for amplitudes that approach 0 dBFS.
+ */
+export function getBarFractionFromOutputAmplitude({
+ outputAmplitude,
+}: {
+ outputAmplitude: number;
+}): number {
+ if (outputAmplitude <= 0) return 0;
+ const db = 20 * Math.log10(outputAmplitude);
+ if (db <= MIN_DISPLAY_DB) return 0;
+ return Math.min(
+ 1,
+ ((db - MIN_DISPLAY_DB) / -MIN_DISPLAY_DB) ** WAVEFORM_BAR_EXPONENT,
+ );
+}
diff --git a/apps/web/src/lib/timeline/group-resize/compute-resize.ts b/apps/web/src/lib/timeline/group-resize/compute-resize.ts
index 8d7d9b2d..5730b117 100644
--- a/apps/web/src/lib/timeline/group-resize/compute-resize.ts
+++ b/apps/web/src/lib/timeline/group-resize/compute-resize.ts
@@ -44,14 +44,34 @@ export function computeGroupResize({
? minimumDeltaTime
: Math.min(maximumDeltaTime, Math.max(minimumDeltaTime, deltaTime));
+ // Snap the drag delta to a frame exactly once, then derive every patch
+ // field from that single snapped value. This keeps the invariant
+ // `trimStart + duration*rate + trimEnd == sourceDuration` exact: the same
+ // delta is added on one side of the element and removed from the other,
+ // so the rounding cancels by construction. Per-field rounding (the old
+ // approach) couldn't preserve this because the individual rounds don't
+ // compose when `sourceDuration` isn't frame-aligned.
+ const snappedDeltaTime =
+ roundToFrame({ time: clampedDeltaTime, rate: fps }) ?? clampedDeltaTime;
+ // Re-clamp after rounding. Bounds derived from other elements are
+ // frame-aligned, so this is normally a no-op; at the source-extent limit
+ // the bound may not be frame-aligned, and honouring the bound takes
+ // precedence over frame alignment (you can't extend past real content).
+ const finalDeltaTime =
+ minimumDeltaTime > maximumDeltaTime
+ ? minimumDeltaTime
+ : Math.min(
+ maximumDeltaTime,
+ Math.max(minimumDeltaTime, snappedDeltaTime),
+ );
+
return {
- deltaTime: Object.is(clampedDeltaTime, -0) ? 0 : clampedDeltaTime,
+ deltaTime: Object.is(finalDeltaTime, -0) ? 0 : finalDeltaTime,
updates: members.map((member) =>
buildResizeUpdate({
member,
side,
- deltaTime: clampedDeltaTime,
- fps,
+ deltaTime: finalDeltaTime,
}),
),
};
@@ -61,98 +81,37 @@ function buildResizeUpdate({
member,
side,
deltaTime,
- fps,
}: {
member: GroupResizeMember;
side: ResizeSide;
deltaTime: number;
- fps: ComputeGroupResizeArgs["fps"];
}): GroupResizeUpdate {
- const totalSourceDuration = getSourceDuration({ member });
const sourceDelta = getSourceDeltaForClipDelta({
member,
clipDelta: deltaTime,
});
- const visibleSourceSpan = getVisibleSourceSpanForDuration({
- member,
- duration: member.duration,
- });
if (side === "left") {
- if (deltaTime < 0 && member.sourceDuration == null) {
- const rawStartTime = member.startTime + deltaTime;
- const rawDuration = member.duration - deltaTime;
- return {
- trackId: member.trackId,
- elementId: member.elementId,
- patch: {
- trimStart: Math.max(0, member.trimStart + sourceDelta),
- trimEnd: member.trimEnd,
- startTime:
- roundToFrame({ time: rawStartTime, rate: fps }) ?? rawStartTime,
- duration:
- roundToFrame({ time: rawDuration, rate: fps }) ?? rawDuration,
- },
- };
- }
-
- const nextTrimStart = Math.max(0, member.trimStart + sourceDelta);
- const nextVisibleSourceSpan = Math.max(
- 0,
- totalSourceDuration - nextTrimStart - member.trimEnd,
- );
- const rawDuration = getDurationForVisibleSourceSpan({
- member,
- sourceSpan: nextVisibleSourceSpan,
- });
- const nextDuration =
- roundToFrame({ time: rawDuration, rate: fps }) ?? rawDuration;
- const rawStartTime = member.startTime + (member.duration - nextDuration);
return {
trackId: member.trackId,
elementId: member.elementId,
patch: {
- trimStart:
- roundToFrame({ time: nextTrimStart, rate: fps }) ?? nextTrimStart,
+ trimStart: Math.max(0, member.trimStart + sourceDelta),
trimEnd: member.trimEnd,
- startTime:
- roundToFrame({ time: rawStartTime, rate: fps }) ?? rawStartTime,
- duration: nextDuration,
+ startTime: member.startTime + deltaTime,
+ duration: member.duration - deltaTime,
},
};
}
- const nextVisibleSourceSpan = Math.max(0, visibleSourceSpan + sourceDelta);
- if (deltaTime > 0 && member.sourceDuration == null) {
- const rawDuration = member.duration + deltaTime;
- return {
- trackId: member.trackId,
- elementId: member.elementId,
- patch: {
- trimStart: member.trimStart,
- trimEnd: Math.max(0, member.trimEnd - sourceDelta),
- startTime: member.startTime,
- duration: roundToFrame({ time: rawDuration, rate: fps }) ?? rawDuration,
- },
- };
- }
-
- const nextTrimEnd = Math.max(
- 0,
- totalSourceDuration - member.trimStart - nextVisibleSourceSpan,
- );
- const rawDuration = getDurationForVisibleSourceSpan({
- member,
- sourceSpan: nextVisibleSourceSpan,
- });
return {
trackId: member.trackId,
elementId: member.elementId,
patch: {
trimStart: member.trimStart,
- trimEnd: roundToFrame({ time: nextTrimEnd, rate: fps }) ?? nextTrimEnd,
+ trimEnd: Math.max(0, member.trimEnd - sourceDelta),
startTime: member.startTime,
- duration: roundToFrame({ time: rawDuration, rate: fps }) ?? rawDuration,
+ duration: member.duration + deltaTime,
},
};
}