feat: add fade in/out functionality for audio segments with UI controls

This commit is contained in:
2025-11-11 23:46:26 +11:00
parent e2017fc6ea
commit 5b2090c102
8 changed files with 458 additions and 12 deletions

1
.gitignore vendored
View File

@@ -64,3 +64,4 @@ test-data/
*.tmp
*.temp
.cache/
AudioSort.code-workspace

View File

@@ -24,6 +24,7 @@
"fast-glob": "^3.3.2",
"fuse.js": "^6.6.2",
"music-metadata": "^10.5.0",
"standardized-audio-context": "^25.3.12",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"wavefile": "^11.0.0"

View File

@@ -31,6 +31,7 @@ export class LibraryService {
private readonly organization: OrganizationService;
private metadataSuggestionCache: { authors: Set<string> } | null = null;
private readonly waveformPreviewCache = new Map<number, { modifiedAt: number; pointCount: number; samples: number[]; rms: number }>();
private offlineAudioContextCtor: (new (channelCount: number, length: number, sampleRate: number) => any) | null = null;
public constructor(
private readonly database: DatabaseService,
private readonly settings: SettingsService,
@@ -83,7 +84,7 @@ export class LibraryService {
this.resetMetadataSuggestionsCache();
this.waveformPreviewCache.clear();
const cleanedTempFiles = await this.cleanupTempFiles();
await this.cleanupTempFiles();
const libraryRoot = this.settings.ensureLibraryPath();
const existing = this.database.listFiles();
const existingByPath = new Map(existing.map((file) => [file.absolutePath, file] as const));
@@ -344,7 +345,7 @@ export class LibraryService {
const buffer = await fs.readFile(record.absolutePath);
const wave = new WaveFile(buffer);
const sampleBlock = wave.getSamples(false, Float64Array) as Float64Array | Float64Array[];
const channels = Array.isArray(sampleBlock) ? sampleBlock : [sampleBlock];
const channels = (Array.isArray(sampleBlock) ? sampleBlock : [sampleBlock]) as Float64Array[];
const firstChannel = channels[0];
if (!firstChannel || firstChannel.length === 0) {
@@ -751,7 +752,9 @@ export class LibraryService {
startMs,
endMs: safeEnd,
label: segment.label?.trim() ?? undefined,
metadata: segment.metadata
metadata: segment.metadata,
fadeInMs: Number.isFinite(segment.fadeInMs) ? Math.max(0, Math.floor(segment.fadeInMs!)) : 0,
fadeOutMs: Number.isFinite(segment.fadeOutMs) ? Math.max(0, Math.floor(segment.fadeOutMs!)) : 0
};
})
.filter((segment) => segment.endMs - segment.startMs >= 5)
@@ -780,7 +783,14 @@ export class LibraryService {
continue;
}
const segmentChannels = channels.map((channel) => channel.slice(startSample, endSample));
let segmentChannels: Float64Array[] = channels.map((channel) => channel.slice(startSample, endSample));
const fadeInMs = segment.fadeInMs ?? 0;
const fadeOutMs = segment.fadeOutMs ?? 0;
if (fadeInMs > 0 || fadeOutMs > 0) {
segmentChannels = (await this.applyFadeEnvelopeToSegment(segmentChannels, sampleRate, fadeInMs, fadeOutMs)) as Float64Array[];
}
const nextWave = new WaveFile();
nextWave.fromScratch(channels.length, sampleRate, bitDepthText, segmentChannels.length === 1 ? segmentChannels[0] : segmentChannels);
(nextWave as WaveFile & { container?: string }).container = container;
@@ -902,6 +912,152 @@ export class LibraryService {
return created;
}
/**
* Applies the configured fade envelope to the provided channel data. Tries to render using
* `standardized-audio-context` for consistency with the editor preview and falls back to
* a manual smooth-step implementation if the dependency is unavailable at runtime.
*/
private async applyFadeEnvelopeToSegment(
source: Float64Array[],
sampleRate: number,
fadeInMs: number,
fadeOutMs: number
): Promise<Float64Array[]> {
if (source.length === 0 || source[0]?.length === 0) {
return source.map((channel) => channel.slice());
}
const totalSamples = source[0].length;
const fadeInSamples = Math.min(totalSamples, Math.max(0, Math.floor((fadeInMs / 1000) * sampleRate)));
const fadeOutSamples = Math.min(totalSamples, Math.max(0, Math.floor((fadeOutMs / 1000) * sampleRate)));
if (fadeInSamples === 0 && fadeOutSamples === 0) {
return source.map((channel) => channel.slice());
}
const applyManual = (): Float64Array[] => {
return source.map((channel) => {
const copy = channel.slice();
const sampleCount = copy.length;
const fadeInDenominator = Math.max(1, fadeInSamples - 1);
const fadeOutDenominator = Math.max(1, fadeOutSamples - 1);
for (let index = 0; index < sampleCount; index += 1) {
let gain = 1;
if (fadeInSamples > 0 && index < fadeInSamples) {
const t = fadeInDenominator > 0 ? index / fadeInDenominator : 0;
const eased = this.smoothStep(t);
gain = Math.min(gain, eased);
}
if (fadeOutSamples > 0) {
const fromEnd = sampleCount - 1 - index;
if (fromEnd < fadeOutSamples) {
const t = fadeOutDenominator > 0 ? fromEnd / fadeOutDenominator : 0;
const eased = this.smoothStep(t);
gain = Math.min(gain, eased);
}
}
copy[index] = copy[index] * gain;
}
return copy;
});
};
try {
let OfflineContextCtor = this.offlineAudioContextCtor;
if (!OfflineContextCtor) {
const audioModule = (await import('standardized-audio-context')) as {
OfflineAudioContext?: new (channelCount: number, length: number, sampleRate: number) => any;
default?: { OfflineAudioContext?: new (channelCount: number, length: number, sampleRate: number) => any };
};
const resolvedCtor = audioModule.OfflineAudioContext ?? audioModule.default?.OfflineAudioContext ?? null;
if (typeof resolvedCtor !== 'function') {
return applyManual();
}
this.offlineAudioContextCtor = resolvedCtor;
OfflineContextCtor = resolvedCtor;
}
const channelCount = source.length;
const offlineContext = new OfflineContextCtor(channelCount, totalSamples, sampleRate);
const buffer = offlineContext.createBuffer(channelCount, totalSamples, sampleRate);
for (let channelIndex = 0; channelIndex < channelCount; channelIndex += 1) {
const channelData = buffer.getChannelData(channelIndex);
const sourceChannel = source[channelIndex];
for (let sampleIndex = 0; sampleIndex < totalSamples; sampleIndex += 1) {
channelData[sampleIndex] = sourceChannel?.[sampleIndex] ?? 0;
}
}
const bufferSource = offlineContext.createBufferSource();
bufferSource.buffer = buffer;
const gainNode = offlineContext.createGain();
bufferSource.connect(gainNode);
gainNode.connect(offlineContext.destination);
const totalDurationSeconds = totalSamples / sampleRate;
gainNode.gain.setValueAtTime(1, 0);
if (fadeInSamples > 0) {
const fadeInCurve = this.generateFadeCurve(Math.max(fadeInSamples, 2), 'in');
gainNode.gain.setValueAtTime(fadeInCurve[0], 0);
gainNode.gain.setValueCurveAtTime(fadeInCurve, 0, fadeInSamples / sampleRate);
gainNode.gain.setValueAtTime(1, fadeInSamples / sampleRate);
}
if (fadeOutSamples > 0) {
const fadeOutCurve = this.generateFadeCurve(Math.max(fadeOutSamples, 2), 'out');
const fadeOutStart = Math.max(0, totalDurationSeconds - fadeOutSamples / sampleRate);
gainNode.gain.setValueAtTime(1, fadeOutStart);
gainNode.gain.setValueCurveAtTime(fadeOutCurve, fadeOutStart, fadeOutSamples / sampleRate);
gainNode.gain.setValueAtTime(fadeOutCurve[fadeOutCurve.length - 1], totalDurationSeconds);
}
bufferSource.start(0);
const renderedBuffer = await offlineContext.startRendering();
const processed: Float64Array[] = [];
for (let channelIndex = 0; channelIndex < channelCount; channelIndex += 1) {
const renderedChannel = renderedBuffer.getChannelData(channelIndex);
const clone = new Float64Array(renderedChannel.length);
clone.set(renderedChannel);
processed.push(clone);
}
return processed;
} catch (error) {
console.warn('Falling back to manual fade processing', error);
return applyManual();
}
}
/**
* Generates a smooth fade curve using a cubic smooth-step easing function.
*/
private generateFadeCurve(sampleCount: number, direction: 'in' | 'out'): Float32Array {
const totalSamples = Math.max(sampleCount, 2);
const curve = new Float32Array(totalSamples);
const lastIndex = totalSamples - 1;
for (let index = 0; index < totalSamples; index += 1) {
const t = lastIndex === 0 ? 1 : index / lastIndex;
const eased = this.smoothStep(Math.min(Math.max(t, 0), 1));
curve[index] = direction === 'in' ? eased : 1 - eased;
}
if (direction === 'in') {
curve[0] = 0;
curve[lastIndex] = 1;
} else {
curve[0] = 1;
curve[lastIndex] = 0;
}
return curve;
}
/**
* Computes the cubic smooth-step easing value for the provided normalised progress.
*/
private smoothStep(t: number): number {
const clamped = Math.min(Math.max(t, 0), 1);
return clamped * clamped * (3 - 2 * clamped);
}
/**
* Returns the previously parsed UCS category catalog.
*/

View File

@@ -0,0 +1,10 @@
declare module 'standardized-audio-context' {
export class OfflineAudioContext {
constructor(channelCount: number, length: number, sampleRate: number);
createBuffer(channelCount: number, length: number, sampleRate: number): any;
createBufferSource(): any;
createGain(): any;
startRendering(): Promise<any>;
readonly destination: any;
}
}

View File

@@ -189,7 +189,39 @@ export function EditModePanel({ file, categories, onClose, onSplitComplete }: Ed
const playbackLengthMs = absoluteEndMs - playbackStartMs;
const source = context.createBufferSource();
source.buffer = buffer;
source.connect(context.destination);
// Create a gain node for fade control
const gainNode = context.createGain();
source.connect(gainNode);
gainNode.connect(context.destination);
// Apply fade in/out if playing a segment
if (request.mode === 'segment' && request.segmentId) {
const segment = segments.find((s) => s.id === request.segmentId);
if (segment) {
const fadeInMs = Math.min(segment.fadeInMs, playbackLengthMs / 2);
const fadeOutMs = Math.min(segment.fadeOutMs, playbackLengthMs / 2);
const currentTime = context.currentTime;
// Set initial gain
gainNode.gain.setValueAtTime(fadeInMs > 0 ? 0 : 1, currentTime);
// Apply fade in
if (fadeInMs > 0) {
const fadeInEndTime = currentTime + (fadeInMs / 1000);
gainNode.gain.linearRampToValueAtTime(1, fadeInEndTime);
}
// Apply fade out
if (fadeOutMs > 0) {
const fadeOutStartTime = currentTime + ((playbackLengthMs - fadeOutMs) / 1000);
const fadeOutEndTime = currentTime + (playbackLengthMs / 1000);
gainNode.gain.setValueAtTime(1, fadeOutStartTime);
gainNode.gain.linearRampToValueAtTime(0, fadeOutEndTime);
}
}
}
source.start(0, playbackStartMs / 1000, playbackLengthMs / 1000);
playbackInfoRef.current = { ...request };
@@ -230,7 +262,7 @@ export function EditModePanel({ file, categories, onClose, onSplitComplete }: Ed
stopPlaybackCursorTracking();
}
},
[ensureAudioContext, loadAudioBuffer, stopActiveSource, schedulePlaybackCursorUpdate, stopPlaybackCursorTracking]
[ensureAudioContext, loadAudioBuffer, stopActiveSource, schedulePlaybackCursorUpdate, stopPlaybackCursorTracking, segments]
);
const pausePlayback = useCallback(() => {
@@ -403,7 +435,9 @@ export function EditModePanel({ file, categories, onClose, onSplitComplete }: Ed
endMs: end,
label: `Segment ${segments.length + 1}`,
metadata: buildDefaultMetadata(file, baseMetadata),
color: generateSegmentColor(segments.length)
color: generateSegmentColor(segments.length),
fadeInMs: 0,
fadeOutMs: 0
};
setSegments((current) => {
const next = [...current, newSegment].sort((a, b) => a.startMs - b.startMs);
@@ -463,6 +497,16 @@ export function EditModePanel({ file, categories, onClose, onSplitComplete }: Ed
);
};
const handleUpdateFade = useCallback((segmentId: string, fadeInMs: number, fadeOutMs: number) => {
setSegments((current) =>
current.map((segment) =>
segment.id === segmentId
? { ...segment, fadeInMs, fadeOutMs }
: segment
)
);
}, []);
const handleRemoveSegment = useCallback((segmentId: string) => {
setSegments((current) => current.filter((segment) => segment.id !== segmentId));
setSelectedSegmentId((current) => (current === segmentId ? null : current));
@@ -570,6 +614,7 @@ export function EditModePanel({ file, categories, onClose, onSplitComplete }: Ed
onResizeSegment={handleResizeSegment}
onPlayFromCursor={playFromCursor}
playbackCursorMs={playbackCursorMs}
onUpdateFade={handleUpdateFade}
/>
)}
</div>

View File

@@ -21,6 +21,8 @@ export interface WaveformEditorCanvasProps {
onPlayFromCursor(startMs: number): void;
/** Location of the active playback cursor relative to the source audio, if playing. */
playbackCursorMs: number | null;
/** Invoked when fade in/out values change. */
onUpdateFade(segmentId: string, fadeInMs: number, fadeOutMs: number): void;
}
interface DraftSelection {
@@ -32,7 +34,8 @@ type DragState =
| { type: 'none' }
| { type: 'creating'; anchorMs: number; pointerMs: number }
| { type: 'resizing'; segmentId: string; handle: 'start' | 'end' }
| { type: 'panning'; startViewportMs: number; pointerStartX: number };
| { type: 'panning'; startViewportMs: number; pointerStartX: number }
| { type: 'fade'; segmentId: string; handle: 'fadeIn' | 'fadeOut'; initialMs: number };
const MIN_SEGMENT_MS = 50;
const MIN_VIEWPORT_MS = 200;
@@ -53,7 +56,8 @@ export function WaveformEditorCanvas({
onCreateSegment,
onResizeSegment,
onPlayFromCursor,
playbackCursorMs
playbackCursorMs,
onUpdateFade
}: WaveformEditorCanvasProps): JSX.Element {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const dragStateRef = useRef<DragState>({ type: 'none' });
@@ -179,13 +183,121 @@ export function WaveformEditorCanvas({
}
context.fillRect(startX, 0, endX - startX, height);
// Draw fade curves as bezier splines (volume envelope visualization)
const segmentDurationMs = segment.endMs - segment.startMs;
const fadeInMs = Math.min(segment.fadeInMs, segmentDurationMs / 2);
const fadeOutMs = Math.min(segment.fadeOutMs, segmentDurationMs / 2);
const fadeLineColor = isSelected ? '#ffcc00' : (rgbMatch ? `rgb(${parseInt(rgbMatch[1], 16)}, ${parseInt(rgbMatch[2], 16)}, ${parseInt(rgbMatch[3], 16)})` : '#ffffff');
// Draw fade in curve (volume ramp from 0 to 1)
if (fadeInMs > 0) {
const fadeInRatio = fadeInMs / viewportDuration;
const fadeInWidth = fadeInRatio * width;
const fadeInEndX = startX + fadeInWidth;
if (fadeInEndX > startX && fadeInEndX <= endX) {
context.strokeStyle = fadeLineColor;
context.lineWidth = 2.5;
context.beginPath();
// Start from bottom (silence) and curve up to full volume
context.moveTo(startX, height);
// Smooth S-curve using bezier - starts slow, accelerates, then eases in
const cp1X = startX + fadeInWidth * 0.3;
const cp1Y = height * 0.8;
const cp2X = startX + fadeInWidth * 0.7;
const cp2Y = height * 0.2;
context.bezierCurveTo(
cp1X, cp1Y,
cp2X, cp2Y,
fadeInEndX, 0
);
context.stroke();
// Add a subtle fill under the curve
context.globalAlpha = 0.1;
context.fillStyle = fadeLineColor;
context.lineTo(fadeInEndX, height);
context.lineTo(startX, height);
context.closePath();
context.fill();
context.globalAlpha = 1.0;
}
}
// Draw fade out curve (volume ramp from 1 to 0)
if (fadeOutMs > 0) {
const fadeOutRatio = fadeOutMs / viewportDuration;
const fadeOutWidth = fadeOutRatio * width;
const fadeOutStartX = endX - fadeOutWidth;
if (fadeOutStartX >= startX && fadeOutStartX < endX) {
context.strokeStyle = fadeLineColor;
context.lineWidth = 2.5;
context.beginPath();
// Start from top (full volume) and curve down to silence
context.moveTo(fadeOutStartX, 0);
// Smooth S-curve using bezier
const cp1X = fadeOutStartX + fadeOutWidth * 0.3;
const cp1Y = height * 0.2;
const cp2X = fadeOutStartX + fadeOutWidth * 0.7;
const cp2Y = height * 0.8;
context.bezierCurveTo(
cp1X, cp1Y,
cp2X, cp2Y,
endX, height
);
context.stroke();
// Add a subtle fill under the curve
context.globalAlpha = 0.1;
context.fillStyle = fadeLineColor;
context.lineTo(endX, height);
context.lineTo(fadeOutStartX, height);
context.closePath();
context.fill();
context.globalAlpha = 1.0;
}
}
// Use segment color for handles
context.fillStyle = isSelected ? '#ffcc00' : color;
context.fillRect(startX - HANDLE_WIDTH_PX / 2, 0, HANDLE_WIDTH_PX, height);
context.fillRect(endX - HANDLE_WIDTH_PX / 2, 0, HANDLE_WIDTH_PX, height);
// Draw segment labels when being resized or hovered
// Draw corner handles for fade control (DaVinci Resolve style)
const dragState = dragStateRef.current;
const isFadeDragging = dragState.type === 'fade' && dragState.segmentId === segment.id;
const showFadeHandles = isSelected || isFadeDragging;
const cornerHandleSize = 12;
if (showFadeHandles) {
// Top-left corner handle for fade in
context.fillStyle = fadeInMs > 0 ? fadeLineColor : 'rgba(255, 255, 255, 0.3)';
context.beginPath();
context.moveTo(startX, 0);
context.lineTo(startX + cornerHandleSize, 0);
context.lineTo(startX, cornerHandleSize);
context.closePath();
context.fill();
// Top-right corner handle for fade out
context.fillStyle = fadeOutMs > 0 ? fadeLineColor : 'rgba(255, 255, 255, 0.3)';
context.beginPath();
context.moveTo(endX, 0);
context.lineTo(endX - cornerHandleSize, 0);
context.lineTo(endX, cornerHandleSize);
context.closePath();
context.fill();
}
// Draw segment labels when being resized or hovered
const shouldShowLabels = (dragState.type === 'resizing' && dragState.segmentId === segment.id) ||
(hoveredSegmentId === segment.id && dragState.type === 'none');
@@ -449,6 +561,32 @@ export function WaveformEditorCanvas({
const viewport = viewportRef.current;
const toleranceMs = viewport.durationMs * (HANDLE_WIDTH_PX / Math.max(canvas?.clientWidth ?? 1, 1));
// Check for fade handles first (higher priority)
const rect = canvas?.getBoundingClientRect();
const pointerY = rect ? event.clientY - rect.top : 0;
const canvasWidth = canvas?.clientWidth ?? 1;
const canvasHeight = canvas?.clientHeight ?? 1;
const fadeHit = findFadeHandle(
pointerMs,
pointerY,
segments,
viewport.startMs,
viewport.durationMs,
canvasWidth,
canvasHeight
);
if (fadeHit) {
onSelectSegment(fadeHit.segmentId);
const segment = segments.find((s) => s.id === fadeHit.segmentId);
const initialMs = fadeHit.handle === 'fadeIn'
? (segment?.fadeInMs ?? 0)
: (segment?.fadeOutMs ?? 0);
dragStateRef.current = { type: 'fade', segmentId: fadeHit.segmentId, handle: fadeHit.handle, initialMs };
clickContextRef.current = null;
return;
}
const hit = findSegmentHandle(pointerMs, segments, toleranceMs);
if (hit) {
@@ -591,6 +729,24 @@ export function WaveformEditorCanvas({
const nextEnd = clamp(pointerMs, Math.max(minEnd, segment.startMs + MIN_SEGMENT_MS), durationMs);
onResizeSegment(segment.id, segment.startMs, nextEnd);
}
return;
}
if (state.type === 'fade') {
const segment = segments.find((entry) => entry.id === state.segmentId);
if (!segment) {
return;
}
const segmentDurationMs = segment.endMs - segment.startMs;
const maxFade = segmentDurationMs / 2;
if (state.handle === 'fadeIn') {
const fadeInMs = clamp(pointerMs - segment.startMs, 0, maxFade);
onUpdateFade(segment.id, fadeInMs, segment.fadeOutMs);
} else {
const fadeOutMs = clamp(segment.endMs - pointerMs, 0, maxFade);
onUpdateFade(segment.id, segment.fadeInMs, fadeOutMs);
}
}
};
@@ -854,6 +1010,73 @@ function findSegmentHandle(
return null;
}
/**
* Detects if the pointer is over a corner fade handle for a segment.
* Corner handles are triangular regions at the top-left and top-right of segments.
*
* @param pointerMs - Absolute timestamp within the audio file to test.
* @param pointerY - Y coordinate of the pointer in canvas pixels.
* @param segments - Segment collection to evaluate.
* @param viewportStartMs - Start of the current viewport.
* @param viewportDurationMs - Duration of the current viewport.
* @param canvasWidth - Width of the canvas in pixels.
* @param canvasHeight - Height of the canvas in pixels.
* @returns Fade handle information or null if no corner handle is under the pointer.
*/
function findFadeHandle(
pointerMs: number,
pointerY: number,
segments: SegmentDraft[],
viewportStartMs: number,
viewportDurationMs: number,
canvasWidth: number,
canvasHeight: number
): { segmentId: string; handle: 'fadeIn' | 'fadeOut' } | null {
const cornerHandleSize = 12;
const maxCornerY = 25; // Extended hit area
for (const segment of segments) {
const startRatio = (segment.startMs - viewportStartMs) / viewportDurationMs;
const endRatio = (segment.endMs - viewportStartMs) / viewportDurationMs;
const startX = Math.max(0, Math.min(canvasWidth, startRatio * canvasWidth));
const endX = Math.max(0, Math.min(canvasWidth, endRatio * canvasWidth));
if (endX <= startX) {
continue;
}
// Check if pointer is in the segment vertically
if (pointerMs < segment.startMs || pointerMs > segment.endMs) {
continue;
}
// Convert pointerMs to X coordinate
const pointerRatio = (pointerMs - viewportStartMs) / viewportDurationMs;
const pointerX = pointerRatio * canvasWidth;
// Check top-left corner (fade in)
if (pointerY <= maxCornerY && pointerX >= startX && pointerX <= startX + cornerHandleSize * 2) {
// Triangle hit test: point is in triangle if it's above the diagonal line
const relX = pointerX - startX;
const relY = pointerY;
if (relX + relY <= cornerHandleSize * 1.5) {
return { segmentId: segment.id, handle: 'fadeIn' };
}
}
// Check top-right corner (fade out)
if (pointerY <= maxCornerY && pointerX <= endX && pointerX >= endX - cornerHandleSize * 2) {
// Triangle hit test: point is in triangle if it's above the diagonal line
const relX = endX - pointerX;
const relY = pointerY;
if (relX + relY <= cornerHandleSize * 1.5) {
return { segmentId: segment.id, handle: 'fadeOut' };
}
}
}
return null;
}
/**
* Determine which segment should be chosen for interaction at the supplied timestamp.
* Prefers the segment whose midpoint is closest to the pointer when multiple segments overlap.

View File

@@ -32,6 +32,10 @@ export interface SegmentDraft {
metadata: SegmentMetadataDraft;
/** Color used for visual identification in UI and waveform. */
color: string;
/** Fade in duration in milliseconds. */
fadeInMs: number;
/** Fade out duration in milliseconds. */
fadeOutMs: number;
}
/**
@@ -57,6 +61,8 @@ export function toSplitRequest(segment: SegmentDraft): SplitSegmentRequest {
rating: segment.metadata.rating ?? undefined,
tags: segment.metadata.tags,
categories: segment.metadata.categories
}
},
fadeInMs: segment.fadeInMs > 0 ? Math.round(segment.fadeInMs) : undefined,
fadeOutMs: segment.fadeOutMs > 0 ? Math.round(segment.fadeOutMs) : undefined
};
}

View File

@@ -138,4 +138,8 @@ export interface SplitSegmentRequest {
label?: string;
/** Metadata overrides to apply to the generated file. */
metadata?: SegmentMetadataInput;
/** Fade in duration in milliseconds. */
fadeInMs?: number;
/** Fade out duration in milliseconds. */
fadeOutMs?: number;
}