mirror of
https://github.com/litruv/AudioSort.git
synced 2026-07-23 18:28:46 +10:00
feat: add fade in/out functionality for audio segments with UI controls
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -64,3 +64,4 @@ test-data/
|
||||
*.tmp
|
||||
*.temp
|
||||
.cache/
|
||||
AudioSort.code-workspace
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
10
src/main/types/standardized-audio-context.d.ts
vendored
Normal file
10
src/main/types/standardized-audio-context.d.ts
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 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.
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user