From 4d127a038bf768eded68aae3cfa5c1b12241bdac Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Fri, 27 Mar 2026 00:55:15 +0100 Subject: chore: normalize line endings --- apps/web/src/lib/retime/__tests__/resolve.test.ts | 122 ++++---- apps/web/src/lib/retime/__tests__/split.test.ts | 62 ++-- apps/web/src/lib/retime/audio-stretch.ts | 364 +++++++++++----------- apps/web/src/lib/retime/index.ts | 8 +- apps/web/src/lib/retime/presets.ts | 24 +- apps/web/src/lib/retime/resolve.ts | 96 +++--- apps/web/src/lib/retime/split.ts | 68 ++-- 7 files changed, 372 insertions(+), 372 deletions(-) (limited to 'apps/web/src/lib/retime') diff --git a/apps/web/src/lib/retime/__tests__/resolve.test.ts b/apps/web/src/lib/retime/__tests__/resolve.test.ts index 4fcf6249..2fbd6675 100644 --- a/apps/web/src/lib/retime/__tests__/resolve.test.ts +++ b/apps/web/src/lib/retime/__tests__/resolve.test.ts @@ -1,61 +1,61 @@ -import { describe, expect, test } from "bun:test"; -import { - getClipTimeAtSourceTime, - getEffectiveRateAt, - getSourceTimeAtClipTime, - getTimelineDurationForSourceSpan, -} from "@/lib/retime"; -import type { RetimeConfig } from "@/lib/timeline"; - -const twoX: RetimeConfig = { rate: 2 }; -const halfX: RetimeConfig = { rate: 0.5 }; - -describe("retime resolve", () => { - test("maps clip time to source time at 2x speed", () => { - expect(getSourceTimeAtClipTime({ clipTime: 5, retime: twoX })).toBe(10); - }); - - test("maps clip time to source time at 0.5x speed", () => { - expect(getSourceTimeAtClipTime({ clipTime: 4, retime: halfX })).toBe(2); - }); - - test("returns clip time unchanged when no retime", () => { - expect(getSourceTimeAtClipTime({ clipTime: 7 })).toBe(7); - }); - - test("inverts source time back to clip time at 2x speed", () => { - expect(getClipTimeAtSourceTime({ sourceTime: 10, retime: twoX })).toBe(5); - }); - - test("returns effective rate", () => { - expect(getEffectiveRateAt({ retime: twoX })).toBe(2); - expect(getEffectiveRateAt({})).toBe(1); - }); - - test("derives timeline duration for a visible source span", () => { - expect( - getTimelineDurationForSourceSpan({ sourceSpan: 10, retime: twoX }), - ).toBe(5); - expect( - getTimelineDurationForSourceSpan({ sourceSpan: 10, retime: halfX }), - ).toBe(20); - }); - - test("clamps invalid rates to 1", () => { - expect(getSourceTimeAtClipTime({ clipTime: 5, retime: { rate: 0 } })).toBe( - 5, - ); - expect( - getSourceTimeAtClipTime({ clipTime: 5, retime: { rate: -1 } }), - ).toBe(5); - }); - - test("caps retime rates above 5x", () => { - expect(getSourceTimeAtClipTime({ clipTime: 5, retime: { rate: 100 } })).toBe( - 25, - ); - expect( - getTimelineDurationForSourceSpan({ sourceSpan: 10, retime: { rate: 100 } }), - ).toBe(2); - }); -}); +import { describe, expect, test } from "bun:test"; +import { + getClipTimeAtSourceTime, + getEffectiveRateAt, + getSourceTimeAtClipTime, + getTimelineDurationForSourceSpan, +} from "@/lib/retime"; +import type { RetimeConfig } from "@/lib/timeline"; + +const twoX: RetimeConfig = { rate: 2 }; +const halfX: RetimeConfig = { rate: 0.5 }; + +describe("retime resolve", () => { + test("maps clip time to source time at 2x speed", () => { + expect(getSourceTimeAtClipTime({ clipTime: 5, retime: twoX })).toBe(10); + }); + + test("maps clip time to source time at 0.5x speed", () => { + expect(getSourceTimeAtClipTime({ clipTime: 4, retime: halfX })).toBe(2); + }); + + test("returns clip time unchanged when no retime", () => { + expect(getSourceTimeAtClipTime({ clipTime: 7 })).toBe(7); + }); + + test("inverts source time back to clip time at 2x speed", () => { + expect(getClipTimeAtSourceTime({ sourceTime: 10, retime: twoX })).toBe(5); + }); + + test("returns effective rate", () => { + expect(getEffectiveRateAt({ retime: twoX })).toBe(2); + expect(getEffectiveRateAt({})).toBe(1); + }); + + test("derives timeline duration for a visible source span", () => { + expect( + getTimelineDurationForSourceSpan({ sourceSpan: 10, retime: twoX }), + ).toBe(5); + expect( + getTimelineDurationForSourceSpan({ sourceSpan: 10, retime: halfX }), + ).toBe(20); + }); + + test("clamps invalid rates to 1", () => { + expect(getSourceTimeAtClipTime({ clipTime: 5, retime: { rate: 0 } })).toBe( + 5, + ); + expect( + getSourceTimeAtClipTime({ clipTime: 5, retime: { rate: -1 } }), + ).toBe(5); + }); + + test("caps retime rates above 5x", () => { + expect(getSourceTimeAtClipTime({ clipTime: 5, retime: { rate: 100 } })).toBe( + 25, + ); + expect( + getTimelineDurationForSourceSpan({ sourceSpan: 10, retime: { rate: 100 } }), + ).toBe(2); + }); +}); diff --git a/apps/web/src/lib/retime/__tests__/split.test.ts b/apps/web/src/lib/retime/__tests__/split.test.ts index 0ce532a1..a00eadac 100644 --- a/apps/web/src/lib/retime/__tests__/split.test.ts +++ b/apps/web/src/lib/retime/__tests__/split.test.ts @@ -1,31 +1,31 @@ -import { describe, expect, test } from "bun:test"; -import { - getSourceSpanAtClipTime, - splitRetimeAtClipTime, -} from "@/lib/retime"; -import type { RetimeConfig } from "@/lib/timeline"; - -describe("retime split", () => { - test("measures source span at a clip time", () => { - const retime: RetimeConfig = { rate: 2 }; - expect(getSourceSpanAtClipTime({ clipTime: 5, retime })).toBe(10); - }); - - test("returns zero for non-positive clip time", () => { - expect(getSourceSpanAtClipTime({ clipTime: 0 })).toBe(0); - expect(getSourceSpanAtClipTime({ clipTime: -1 })).toBe(0); - }); - - test("passes the same retime to both halves when splitting", () => { - const retime: RetimeConfig = { rate: 1.5 }; - const result = splitRetimeAtClipTime({ retime, splitClipTime: 3 }); - expect(result.left).toBe(retime); - expect(result.right).toBe(retime); - }); - - test("returns undefined on both sides when no retime", () => { - const result = splitRetimeAtClipTime({ splitClipTime: 3 }); - expect(result.left).toBeUndefined(); - expect(result.right).toBeUndefined(); - }); -}); +import { describe, expect, test } from "bun:test"; +import { + getSourceSpanAtClipTime, + splitRetimeAtClipTime, +} from "@/lib/retime"; +import type { RetimeConfig } from "@/lib/timeline"; + +describe("retime split", () => { + test("measures source span at a clip time", () => { + const retime: RetimeConfig = { rate: 2 }; + expect(getSourceSpanAtClipTime({ clipTime: 5, retime })).toBe(10); + }); + + test("returns zero for non-positive clip time", () => { + expect(getSourceSpanAtClipTime({ clipTime: 0 })).toBe(0); + expect(getSourceSpanAtClipTime({ clipTime: -1 })).toBe(0); + }); + + test("passes the same retime to both halves when splitting", () => { + const retime: RetimeConfig = { rate: 1.5 }; + const result = splitRetimeAtClipTime({ retime, splitClipTime: 3 }); + expect(result.left).toBe(retime); + expect(result.right).toBe(retime); + }); + + test("returns undefined on both sides when no retime", () => { + const result = splitRetimeAtClipTime({ splitClipTime: 3 }); + expect(result.left).toBeUndefined(); + expect(result.right).toBeUndefined(); + }); +}); diff --git a/apps/web/src/lib/retime/audio-stretch.ts b/apps/web/src/lib/retime/audio-stretch.ts index 51ef38d0..f8dd297c 100644 --- a/apps/web/src/lib/retime/audio-stretch.ts +++ b/apps/web/src/lib/retime/audio-stretch.ts @@ -1,182 +1,182 @@ -import { PitchShifter } from "soundtouchjs"; -import { clampRetimeRate, shouldMaintainPitch } from "@/constants/retime-constants"; -import type { RetimeConfig } from "@/lib/timeline"; -import { getSourceTimeAtClipTime } from "./resolve"; - -const RATE_EPSILON = 1e-6; - -function sampleLinear({ - channelData, - position, -}: { - channelData: Float32Array; - position: number; -}): number { - if (position <= 0) { - return channelData[0] ?? 0; - } - const lower = Math.floor(position); - const upper = Math.min(channelData.length - 1, lower + 1); - if (lower >= channelData.length) { - return 0; - } - const fraction = position - lower; - return channelData[lower] * (1 - fraction) + channelData[upper] * fraction; -} - -function buildResampledBuffer({ - audioContext, - sourceBuffer, - trimStart, - clipDuration, - targetSampleRate, - retime, -}: { - audioContext: BaseAudioContext; - sourceBuffer: AudioBuffer; - trimStart: number; - clipDuration: number; - targetSampleRate: number; - retime?: RetimeConfig; -}): AudioBuffer { - const outputLength = Math.max(1, Math.ceil(clipDuration * targetSampleRate)); - const numChannels = Math.max(1, Math.min(2, sourceBuffer.numberOfChannels)); - const outputBuffer = audioContext.createBuffer( - numChannels, - outputLength, - targetSampleRate, - ); - - for (let channel = 0; channel < numChannels; channel++) { - const sourceData = sourceBuffer.getChannelData( - Math.min(channel, sourceBuffer.numberOfChannels - 1), - ); - const outputData = outputBuffer.getChannelData(channel); - - for (let i = 0; i < outputLength; i++) { - const clipTime = i / targetSampleRate; - const sourceTime = - trimStart + getSourceTimeAtClipTime({ clipTime, retime }); - outputData[i] = sampleLinear({ - channelData: sourceData, - position: sourceTime * sourceBuffer.sampleRate, - }); - } - } - - return outputBuffer; -} - -async function buildPitchPreservedBuffer({ - sourceBuffer, - trimStart, - clipDuration, - rate, - targetSampleRate, -}: { - sourceBuffer: AudioBuffer; - trimStart: number; - clipDuration: number; - rate: number; - targetSampleRate: number; -}): Promise { - const nativeSampleRate = sourceBuffer.sampleRate; - const sourceDuration = clipDuration * rate; - const startSample = Math.max(0, Math.floor(trimStart * nativeSampleRate)); - const numSourceSamples = Math.max( - 1, - Math.ceil(sourceDuration * nativeSampleRate), - ); - const available = Math.max(0, sourceBuffer.length - startSample); - const actualSamples = Math.max(1, Math.min(numSourceSamples, available)); - const numChannels = Math.max(1, Math.min(2, sourceBuffer.numberOfChannels)); - - // Resample to targetSampleRate first — soundtouchjs reads raw channel data - // and does not respect the source buffer's native sample rate. - const resampledLength = Math.max( - 1, - Math.ceil(sourceDuration * targetSampleRate), - ); - const resampleCtx = new OfflineAudioContext( - numChannels, - resampledLength, - targetSampleRate, - ); - const nativeBuffer = resampleCtx.createBuffer( - numChannels, - actualSamples, - nativeSampleRate, - ); - - for (let ch = 0; ch < numChannels; ch++) { - const src = sourceBuffer.getChannelData( - Math.min(ch, sourceBuffer.numberOfChannels - 1), - ); - nativeBuffer.copyToChannel( - src.subarray(startSample, startSample + actualSamples), - ch, - ); - } - - const resampleSourceNode = resampleCtx.createBufferSource(); - resampleSourceNode.buffer = nativeBuffer; - resampleSourceNode.connect(resampleCtx.destination); - resampleSourceNode.start(0); - const resampledBuffer = await resampleCtx.startRendering(); - - const outputSamples = Math.max( - 1, - Math.ceil(clipDuration * targetSampleRate), - ); - const stretchCtx = new OfflineAudioContext( - numChannels, - outputSamples, - targetSampleRate, - ); - const shifter = new PitchShifter(stretchCtx, resampledBuffer, 4096); - shifter.tempo = rate; - shifter.pitch = 1; - shifter.connect(stretchCtx.destination); - return stretchCtx.startRendering(); -} - -export async function renderRetimedBuffer({ - audioContext, - sourceBuffer, - trimStart, - clipDuration, - retime, - maintainPitch = false, -}: { - audioContext: BaseAudioContext; - sourceBuffer: AudioBuffer; - trimStart: number; - clipDuration: number; - retime?: RetimeConfig; - maintainPitch?: boolean; -}): Promise { - const targetSampleRate = audioContext.sampleRate; - const rate = clampRetimeRate({ rate: retime?.rate ?? 1 }); - const usePitchPreservation = - shouldMaintainPitch({ rate, maintainPitch }) && - Math.abs(rate - 1) > RATE_EPSILON; - - if (usePitchPreservation) { - return buildPitchPreservedBuffer({ - sourceBuffer, - trimStart, - clipDuration, - rate, - targetSampleRate, - }); - } - - return buildResampledBuffer({ - audioContext, - sourceBuffer, - trimStart, - clipDuration, - targetSampleRate, - retime, - }); -} +import { PitchShifter } from "soundtouchjs"; +import { clampRetimeRate, shouldMaintainPitch } from "@/constants/retime-constants"; +import type { RetimeConfig } from "@/lib/timeline"; +import { getSourceTimeAtClipTime } from "./resolve"; + +const RATE_EPSILON = 1e-6; + +function sampleLinear({ + channelData, + position, +}: { + channelData: Float32Array; + position: number; +}): number { + if (position <= 0) { + return channelData[0] ?? 0; + } + const lower = Math.floor(position); + const upper = Math.min(channelData.length - 1, lower + 1); + if (lower >= channelData.length) { + return 0; + } + const fraction = position - lower; + return channelData[lower] * (1 - fraction) + channelData[upper] * fraction; +} + +function buildResampledBuffer({ + audioContext, + sourceBuffer, + trimStart, + clipDuration, + targetSampleRate, + retime, +}: { + audioContext: BaseAudioContext; + sourceBuffer: AudioBuffer; + trimStart: number; + clipDuration: number; + targetSampleRate: number; + retime?: RetimeConfig; +}): AudioBuffer { + const outputLength = Math.max(1, Math.ceil(clipDuration * targetSampleRate)); + const numChannels = Math.max(1, Math.min(2, sourceBuffer.numberOfChannels)); + const outputBuffer = audioContext.createBuffer( + numChannels, + outputLength, + targetSampleRate, + ); + + for (let channel = 0; channel < numChannels; channel++) { + const sourceData = sourceBuffer.getChannelData( + Math.min(channel, sourceBuffer.numberOfChannels - 1), + ); + const outputData = outputBuffer.getChannelData(channel); + + for (let i = 0; i < outputLength; i++) { + const clipTime = i / targetSampleRate; + const sourceTime = + trimStart + getSourceTimeAtClipTime({ clipTime, retime }); + outputData[i] = sampleLinear({ + channelData: sourceData, + position: sourceTime * sourceBuffer.sampleRate, + }); + } + } + + return outputBuffer; +} + +async function buildPitchPreservedBuffer({ + sourceBuffer, + trimStart, + clipDuration, + rate, + targetSampleRate, +}: { + sourceBuffer: AudioBuffer; + trimStart: number; + clipDuration: number; + rate: number; + targetSampleRate: number; +}): Promise { + const nativeSampleRate = sourceBuffer.sampleRate; + const sourceDuration = clipDuration * rate; + const startSample = Math.max(0, Math.floor(trimStart * nativeSampleRate)); + const numSourceSamples = Math.max( + 1, + Math.ceil(sourceDuration * nativeSampleRate), + ); + const available = Math.max(0, sourceBuffer.length - startSample); + const actualSamples = Math.max(1, Math.min(numSourceSamples, available)); + const numChannels = Math.max(1, Math.min(2, sourceBuffer.numberOfChannels)); + + // Resample to targetSampleRate first — soundtouchjs reads raw channel data + // and does not respect the source buffer's native sample rate. + const resampledLength = Math.max( + 1, + Math.ceil(sourceDuration * targetSampleRate), + ); + const resampleCtx = new OfflineAudioContext( + numChannels, + resampledLength, + targetSampleRate, + ); + const nativeBuffer = resampleCtx.createBuffer( + numChannels, + actualSamples, + nativeSampleRate, + ); + + for (let ch = 0; ch < numChannels; ch++) { + const src = sourceBuffer.getChannelData( + Math.min(ch, sourceBuffer.numberOfChannels - 1), + ); + nativeBuffer.copyToChannel( + src.subarray(startSample, startSample + actualSamples), + ch, + ); + } + + const resampleSourceNode = resampleCtx.createBufferSource(); + resampleSourceNode.buffer = nativeBuffer; + resampleSourceNode.connect(resampleCtx.destination); + resampleSourceNode.start(0); + const resampledBuffer = await resampleCtx.startRendering(); + + const outputSamples = Math.max( + 1, + Math.ceil(clipDuration * targetSampleRate), + ); + const stretchCtx = new OfflineAudioContext( + numChannels, + outputSamples, + targetSampleRate, + ); + const shifter = new PitchShifter(stretchCtx, resampledBuffer, 4096); + shifter.tempo = rate; + shifter.pitch = 1; + shifter.connect(stretchCtx.destination); + return stretchCtx.startRendering(); +} + +export async function renderRetimedBuffer({ + audioContext, + sourceBuffer, + trimStart, + clipDuration, + retime, + maintainPitch = false, +}: { + audioContext: BaseAudioContext; + sourceBuffer: AudioBuffer; + trimStart: number; + clipDuration: number; + retime?: RetimeConfig; + maintainPitch?: boolean; +}): Promise { + const targetSampleRate = audioContext.sampleRate; + const rate = clampRetimeRate({ rate: retime?.rate ?? 1 }); + const usePitchPreservation = + shouldMaintainPitch({ rate, maintainPitch }) && + Math.abs(rate - 1) > RATE_EPSILON; + + if (usePitchPreservation) { + return buildPitchPreservedBuffer({ + sourceBuffer, + trimStart, + clipDuration, + rate, + targetSampleRate, + }); + } + + return buildResampledBuffer({ + audioContext, + sourceBuffer, + trimStart, + clipDuration, + targetSampleRate, + retime, + }); +} diff --git a/apps/web/src/lib/retime/index.ts b/apps/web/src/lib/retime/index.ts index 4915c203..ca6725dd 100644 --- a/apps/web/src/lib/retime/index.ts +++ b/apps/web/src/lib/retime/index.ts @@ -1,4 +1,4 @@ -export * from "./audio-stretch"; -export * from "./presets"; -export * from "./resolve"; -export * from "./split"; +export * from "./audio-stretch"; +export * from "./presets"; +export * from "./resolve"; +export * from "./split"; diff --git a/apps/web/src/lib/retime/presets.ts b/apps/web/src/lib/retime/presets.ts index 382320b0..777c0219 100644 --- a/apps/web/src/lib/retime/presets.ts +++ b/apps/web/src/lib/retime/presets.ts @@ -1,12 +1,12 @@ -import type { RetimeConfig } from "@/lib/timeline"; -import { clampRetimeRate } from "@/constants/retime-constants"; - -export function buildConstantRetime({ - rate, - maintainPitch = false, -}: { - rate: number; - maintainPitch?: boolean; -}): RetimeConfig { - return { rate: clampRetimeRate({ rate }), maintainPitch }; -} +import type { RetimeConfig } from "@/lib/timeline"; +import { clampRetimeRate } from "@/constants/retime-constants"; + +export function buildConstantRetime({ + rate, + maintainPitch = false, +}: { + rate: number; + maintainPitch?: boolean; +}): RetimeConfig { + return { rate: clampRetimeRate({ rate }), maintainPitch }; +} diff --git a/apps/web/src/lib/retime/resolve.ts b/apps/web/src/lib/retime/resolve.ts index cfcfb6a2..31a562be 100644 --- a/apps/web/src/lib/retime/resolve.ts +++ b/apps/web/src/lib/retime/resolve.ts @@ -1,48 +1,48 @@ -import type { RetimeConfig } from "@/lib/timeline"; -import { clampRetimeRate } from "@/constants/retime-constants"; - -function getSafeRate({ rate }: { rate: number }): number { - return clampRetimeRate({ rate }); -} - -export function getSourceTimeAtClipTime({ - clipTime, - retime, -}: { - clipTime: number; - retime?: RetimeConfig; -}): number { - return clipTime * getSafeRate({ rate: retime?.rate ?? 1 }); -} - -export function getClipTimeAtSourceTime({ - sourceTime, - retime, -}: { - sourceTime: number; - retime?: RetimeConfig; -}): number { - return sourceTime / getSafeRate({ rate: retime?.rate ?? 1 }); -} - -export function getEffectiveRateAt({ - retime, -}: { - clipTime?: number; - retime?: RetimeConfig; -}): number { - return getSafeRate({ rate: retime?.rate ?? 1 }); -} - -export function getTimelineDurationForSourceSpan({ - sourceSpan, - retime, -}: { - sourceSpan: number; - retime?: RetimeConfig; -}): number { - if (sourceSpan <= 0) { - return 0; - } - return sourceSpan / getSafeRate({ rate: retime?.rate ?? 1 }); -} +import type { RetimeConfig } from "@/lib/timeline"; +import { clampRetimeRate } from "@/constants/retime-constants"; + +function getSafeRate({ rate }: { rate: number }): number { + return clampRetimeRate({ rate }); +} + +export function getSourceTimeAtClipTime({ + clipTime, + retime, +}: { + clipTime: number; + retime?: RetimeConfig; +}): number { + return clipTime * getSafeRate({ rate: retime?.rate ?? 1 }); +} + +export function getClipTimeAtSourceTime({ + sourceTime, + retime, +}: { + sourceTime: number; + retime?: RetimeConfig; +}): number { + return sourceTime / getSafeRate({ rate: retime?.rate ?? 1 }); +} + +export function getEffectiveRateAt({ + retime, +}: { + clipTime?: number; + retime?: RetimeConfig; +}): number { + return getSafeRate({ rate: retime?.rate ?? 1 }); +} + +export function getTimelineDurationForSourceSpan({ + sourceSpan, + retime, +}: { + sourceSpan: number; + retime?: RetimeConfig; +}): number { + if (sourceSpan <= 0) { + return 0; + } + return sourceSpan / getSafeRate({ rate: retime?.rate ?? 1 }); +} diff --git a/apps/web/src/lib/retime/split.ts b/apps/web/src/lib/retime/split.ts index 12982292..2635590e 100644 --- a/apps/web/src/lib/retime/split.ts +++ b/apps/web/src/lib/retime/split.ts @@ -1,34 +1,34 @@ -import type { RetimeConfig } from "@/lib/timeline"; -import { getSourceTimeAtClipTime } from "./resolve"; - -export function getSourceSpanAtClipTime({ - clipTime, - retime, -}: { - clipTime: number; - retime?: RetimeConfig; -}): number { - return Math.max(0, getSourceTimeAtClipTime({ clipTime, retime })); -} - -export function splitRetimeAtClipTime({ - retime, -}: { - retime?: RetimeConfig; - splitClipTime: number; -}): { - left: RetimeConfig | undefined; - right: RetimeConfig | undefined; -} { - return { left: retime, right: retime }; -} - -export function adjustRetimeForTrimChange({ - retime, -}: { - retime?: RetimeConfig; - clipTrimTime: number; - side: "start" | "end"; -}): RetimeConfig | undefined { - return retime; -} +import type { RetimeConfig } from "@/lib/timeline"; +import { getSourceTimeAtClipTime } from "./resolve"; + +export function getSourceSpanAtClipTime({ + clipTime, + retime, +}: { + clipTime: number; + retime?: RetimeConfig; +}): number { + return Math.max(0, getSourceTimeAtClipTime({ clipTime, retime })); +} + +export function splitRetimeAtClipTime({ + retime, +}: { + retime?: RetimeConfig; + splitClipTime: number; +}): { + left: RetimeConfig | undefined; + right: RetimeConfig | undefined; +} { + return { left: retime, right: retime }; +} + +export function adjustRetimeForTrimChange({ + retime, +}: { + retime?: RetimeConfig; + clipTrimTime: number; + side: "start" | "end"; +}): RetimeConfig | undefined { + return retime; +} -- cgit v1.3.1