mirror of
https://github.com/litruv/AudioSort.git
synced 2026-07-24 02:36:01 +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
|
*.tmp
|
||||||
*.temp
|
*.temp
|
||||||
.cache/
|
.cache/
|
||||||
|
AudioSort.code-workspace
|
||||||
|
|||||||
@@ -23,7 +23,8 @@
|
|||||||
"csv-parse": "^5.5.5",
|
"csv-parse": "^5.5.5",
|
||||||
"fast-glob": "^3.3.2",
|
"fast-glob": "^3.3.2",
|
||||||
"fuse.js": "^6.6.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": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"wavefile": "^11.0.0"
|
"wavefile": "^11.0.0"
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export class LibraryService {
|
|||||||
private readonly organization: OrganizationService;
|
private readonly organization: OrganizationService;
|
||||||
private metadataSuggestionCache: { authors: Set<string> } | null = null;
|
private metadataSuggestionCache: { authors: Set<string> } | null = null;
|
||||||
private readonly waveformPreviewCache = new Map<number, { modifiedAt: number; pointCount: number; samples: number[]; rms: number }>();
|
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(
|
public constructor(
|
||||||
private readonly database: DatabaseService,
|
private readonly database: DatabaseService,
|
||||||
private readonly settings: SettingsService,
|
private readonly settings: SettingsService,
|
||||||
@@ -83,7 +84,7 @@ export class LibraryService {
|
|||||||
this.resetMetadataSuggestionsCache();
|
this.resetMetadataSuggestionsCache();
|
||||||
this.waveformPreviewCache.clear();
|
this.waveformPreviewCache.clear();
|
||||||
|
|
||||||
const cleanedTempFiles = await this.cleanupTempFiles();
|
await this.cleanupTempFiles();
|
||||||
const libraryRoot = this.settings.ensureLibraryPath();
|
const libraryRoot = this.settings.ensureLibraryPath();
|
||||||
const existing = this.database.listFiles();
|
const existing = this.database.listFiles();
|
||||||
const existingByPath = new Map(existing.map((file) => [file.absolutePath, file] as const));
|
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 buffer = await fs.readFile(record.absolutePath);
|
||||||
const wave = new WaveFile(buffer);
|
const wave = new WaveFile(buffer);
|
||||||
const sampleBlock = wave.getSamples(false, Float64Array) as Float64Array | Float64Array[];
|
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];
|
const firstChannel = channels[0];
|
||||||
|
|
||||||
if (!firstChannel || firstChannel.length === 0) {
|
if (!firstChannel || firstChannel.length === 0) {
|
||||||
@@ -751,7 +752,9 @@ export class LibraryService {
|
|||||||
startMs,
|
startMs,
|
||||||
endMs: safeEnd,
|
endMs: safeEnd,
|
||||||
label: segment.label?.trim() ?? undefined,
|
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)
|
.filter((segment) => segment.endMs - segment.startMs >= 5)
|
||||||
@@ -780,7 +783,14 @@ export class LibraryService {
|
|||||||
continue;
|
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();
|
const nextWave = new WaveFile();
|
||||||
nextWave.fromScratch(channels.length, sampleRate, bitDepthText, segmentChannels.length === 1 ? segmentChannels[0] : segmentChannels);
|
nextWave.fromScratch(channels.length, sampleRate, bitDepthText, segmentChannels.length === 1 ? segmentChannels[0] : segmentChannels);
|
||||||
(nextWave as WaveFile & { container?: string }).container = container;
|
(nextWave as WaveFile & { container?: string }).container = container;
|
||||||
@@ -902,6 +912,152 @@ export class LibraryService {
|
|||||||
return created;
|
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.
|
* 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 playbackLengthMs = absoluteEndMs - playbackStartMs;
|
||||||
const source = context.createBufferSource();
|
const source = context.createBufferSource();
|
||||||
source.buffer = buffer;
|
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);
|
source.start(0, playbackStartMs / 1000, playbackLengthMs / 1000);
|
||||||
|
|
||||||
playbackInfoRef.current = { ...request };
|
playbackInfoRef.current = { ...request };
|
||||||
@@ -230,7 +262,7 @@ export function EditModePanel({ file, categories, onClose, onSplitComplete }: Ed
|
|||||||
stopPlaybackCursorTracking();
|
stopPlaybackCursorTracking();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[ensureAudioContext, loadAudioBuffer, stopActiveSource, schedulePlaybackCursorUpdate, stopPlaybackCursorTracking]
|
[ensureAudioContext, loadAudioBuffer, stopActiveSource, schedulePlaybackCursorUpdate, stopPlaybackCursorTracking, segments]
|
||||||
);
|
);
|
||||||
|
|
||||||
const pausePlayback = useCallback(() => {
|
const pausePlayback = useCallback(() => {
|
||||||
@@ -403,7 +435,9 @@ export function EditModePanel({ file, categories, onClose, onSplitComplete }: Ed
|
|||||||
endMs: end,
|
endMs: end,
|
||||||
label: `Segment ${segments.length + 1}`,
|
label: `Segment ${segments.length + 1}`,
|
||||||
metadata: buildDefaultMetadata(file, baseMetadata),
|
metadata: buildDefaultMetadata(file, baseMetadata),
|
||||||
color: generateSegmentColor(segments.length)
|
color: generateSegmentColor(segments.length),
|
||||||
|
fadeInMs: 0,
|
||||||
|
fadeOutMs: 0
|
||||||
};
|
};
|
||||||
setSegments((current) => {
|
setSegments((current) => {
|
||||||
const next = [...current, newSegment].sort((a, b) => a.startMs - b.startMs);
|
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) => {
|
const handleRemoveSegment = useCallback((segmentId: string) => {
|
||||||
setSegments((current) => current.filter((segment) => segment.id !== segmentId));
|
setSegments((current) => current.filter((segment) => segment.id !== segmentId));
|
||||||
setSelectedSegmentId((current) => (current === segmentId ? null : current));
|
setSelectedSegmentId((current) => (current === segmentId ? null : current));
|
||||||
@@ -570,6 +614,7 @@ export function EditModePanel({ file, categories, onClose, onSplitComplete }: Ed
|
|||||||
onResizeSegment={handleResizeSegment}
|
onResizeSegment={handleResizeSegment}
|
||||||
onPlayFromCursor={playFromCursor}
|
onPlayFromCursor={playFromCursor}
|
||||||
playbackCursorMs={playbackCursorMs}
|
playbackCursorMs={playbackCursorMs}
|
||||||
|
onUpdateFade={handleUpdateFade}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ export interface WaveformEditorCanvasProps {
|
|||||||
onPlayFromCursor(startMs: number): void;
|
onPlayFromCursor(startMs: number): void;
|
||||||
/** Location of the active playback cursor relative to the source audio, if playing. */
|
/** Location of the active playback cursor relative to the source audio, if playing. */
|
||||||
playbackCursorMs: number | null;
|
playbackCursorMs: number | null;
|
||||||
|
/** Invoked when fade in/out values change. */
|
||||||
|
onUpdateFade(segmentId: string, fadeInMs: number, fadeOutMs: number): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DraftSelection {
|
interface DraftSelection {
|
||||||
@@ -32,7 +34,8 @@ type DragState =
|
|||||||
| { type: 'none' }
|
| { type: 'none' }
|
||||||
| { type: 'creating'; anchorMs: number; pointerMs: number }
|
| { type: 'creating'; anchorMs: number; pointerMs: number }
|
||||||
| { type: 'resizing'; segmentId: string; handle: 'start' | 'end' }
|
| { 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_SEGMENT_MS = 50;
|
||||||
const MIN_VIEWPORT_MS = 200;
|
const MIN_VIEWPORT_MS = 200;
|
||||||
@@ -53,7 +56,8 @@ export function WaveformEditorCanvas({
|
|||||||
onCreateSegment,
|
onCreateSegment,
|
||||||
onResizeSegment,
|
onResizeSegment,
|
||||||
onPlayFromCursor,
|
onPlayFromCursor,
|
||||||
playbackCursorMs
|
playbackCursorMs,
|
||||||
|
onUpdateFade
|
||||||
}: WaveformEditorCanvasProps): JSX.Element {
|
}: WaveformEditorCanvasProps): JSX.Element {
|
||||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||||
const dragStateRef = useRef<DragState>({ type: 'none' });
|
const dragStateRef = useRef<DragState>({ type: 'none' });
|
||||||
@@ -179,13 +183,121 @@ export function WaveformEditorCanvas({
|
|||||||
}
|
}
|
||||||
context.fillRect(startX, 0, endX - startX, height);
|
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
|
// Use segment color for handles
|
||||||
context.fillStyle = isSelected ? '#ffcc00' : color;
|
context.fillStyle = isSelected ? '#ffcc00' : color;
|
||||||
context.fillRect(startX - HANDLE_WIDTH_PX / 2, 0, HANDLE_WIDTH_PX, height);
|
context.fillRect(startX - HANDLE_WIDTH_PX / 2, 0, HANDLE_WIDTH_PX, height);
|
||||||
context.fillRect(endX - 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 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) ||
|
const shouldShowLabels = (dragState.type === 'resizing' && dragState.segmentId === segment.id) ||
|
||||||
(hoveredSegmentId === segment.id && dragState.type === 'none');
|
(hoveredSegmentId === segment.id && dragState.type === 'none');
|
||||||
|
|
||||||
@@ -449,6 +561,32 @@ export function WaveformEditorCanvas({
|
|||||||
|
|
||||||
const viewport = viewportRef.current;
|
const viewport = viewportRef.current;
|
||||||
const toleranceMs = viewport.durationMs * (HANDLE_WIDTH_PX / Math.max(canvas?.clientWidth ?? 1, 1));
|
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);
|
const hit = findSegmentHandle(pointerMs, segments, toleranceMs);
|
||||||
|
|
||||||
if (hit) {
|
if (hit) {
|
||||||
@@ -591,6 +729,24 @@ export function WaveformEditorCanvas({
|
|||||||
const nextEnd = clamp(pointerMs, Math.max(minEnd, segment.startMs + MIN_SEGMENT_MS), durationMs);
|
const nextEnd = clamp(pointerMs, Math.max(minEnd, segment.startMs + MIN_SEGMENT_MS), durationMs);
|
||||||
onResizeSegment(segment.id, segment.startMs, nextEnd);
|
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;
|
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.
|
* 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.
|
* Prefers the segment whose midpoint is closest to the pointer when multiple segments overlap.
|
||||||
|
|||||||
@@ -32,6 +32,10 @@ export interface SegmentDraft {
|
|||||||
metadata: SegmentMetadataDraft;
|
metadata: SegmentMetadataDraft;
|
||||||
/** Color used for visual identification in UI and waveform. */
|
/** Color used for visual identification in UI and waveform. */
|
||||||
color: string;
|
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,
|
rating: segment.metadata.rating ?? undefined,
|
||||||
tags: segment.metadata.tags,
|
tags: segment.metadata.tags,
|
||||||
categories: segment.metadata.categories
|
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;
|
label?: string;
|
||||||
/** Metadata overrides to apply to the generated file. */
|
/** Metadata overrides to apply to the generated file. */
|
||||||
metadata?: SegmentMetadataInput;
|
metadata?: SegmentMetadataInput;
|
||||||
|
/** Fade in duration in milliseconds. */
|
||||||
|
fadeInMs?: number;
|
||||||
|
/** Fade out duration in milliseconds. */
|
||||||
|
fadeOutMs?: number;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user