diff options
Diffstat (limited to 'apps/web/src/lib')
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, }, }; } |
