diff --git a/.gitignore b/.gitignore index 173c5de..b06648d 100644 --- a/.gitignore +++ b/.gitignore @@ -64,3 +64,4 @@ test-data/ *.tmp *.temp .cache/ +AudioSort.code-workspace diff --git a/package.json b/package.json index 630fc93..9313c5e 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,8 @@ "csv-parse": "^5.5.5", "fast-glob": "^3.3.2", "fuse.js": "^6.6.2", - "music-metadata": "^10.5.0", + "music-metadata": "^10.5.0", + "standardized-audio-context": "^25.3.12", "react": "^18.3.1", "react-dom": "^18.3.1", "wavefile": "^11.0.0" diff --git a/src/main/services/LibraryService.ts b/src/main/services/LibraryService.ts index 18e45ea..581fd8f 100644 --- a/src/main/services/LibraryService.ts +++ b/src/main/services/LibraryService.ts @@ -31,6 +31,7 @@ export class LibraryService { private readonly organization: OrganizationService; private metadataSuggestionCache: { authors: Set } | null = null; private readonly waveformPreviewCache = new Map(); + 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 { + 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. */ diff --git a/src/main/types/standardized-audio-context.d.ts b/src/main/types/standardized-audio-context.d.ts new file mode 100644 index 0000000..ec19920 --- /dev/null +++ b/src/main/types/standardized-audio-context.d.ts @@ -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; + readonly destination: any; + } +} diff --git a/src/renderer/src/components/edit/EditModePanel.tsx b/src/renderer/src/components/edit/EditModePanel.tsx index 053e0ca..89557a8 100644 --- a/src/renderer/src/components/edit/EditModePanel.tsx +++ b/src/renderer/src/components/edit/EditModePanel.tsx @@ -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} /> )} diff --git a/src/renderer/src/components/edit/WaveformEditorCanvas.tsx b/src/renderer/src/components/edit/WaveformEditorCanvas.tsx index 4e42aff..19c258a 100644 --- a/src/renderer/src/components/edit/WaveformEditorCanvas.tsx +++ b/src/renderer/src/components/edit/WaveformEditorCanvas.tsx @@ -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(null); const dragStateRef = useRef({ 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 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 dragState = dragStateRef.current; 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. diff --git a/src/renderer/src/components/edit/types.ts b/src/renderer/src/components/edit/types.ts index 92fc558..194c958 100644 --- a/src/renderer/src/components/edit/types.ts +++ b/src/renderer/src/components/edit/types.ts @@ -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 }; } diff --git a/src/shared/models.ts b/src/shared/models.ts index 1b82614..3525590 100644 --- a/src/shared/models.ts +++ b/src/shared/models.ts @@ -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; }