Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 13 additions & 11 deletions src/audio/Scheduler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { FadeType } from '../../types/Fade';
import type { ID, Seconds, Fade, ScheduledEntry, SfxClip, ActiveNode } from './types';
import type { BufferCache } from './BufferCache';

const EQUAL_POWER_CURVE_LENGTH = 128;

/** how often the scheduling loop fires (ms) */
const LOOKAHEAD_INTERVAL_MS = 25;

Expand All @@ -11,8 +13,6 @@ const SCHEDULE_AHEAD_S: Seconds = 0.2;
/** grace period, nodes within this window of ctx.currentTime are considered "audible" */
const AUDIBLE_GRACE_S = 0.05;

const EQUAL_POWER_CURVE_LENGTH = 128;

// fade-out: cos(t * pi/2), 1 -> 0
const EQUAL_POWER_FADE_OUT = new Float32Array(EQUAL_POWER_CURVE_LENGTH).map((_, i) =>
Math.cos((i / (EQUAL_POWER_CURVE_LENGTH - 1)) * (Math.PI / 2)),
Expand Down Expand Up @@ -394,17 +394,19 @@ export class Scheduler {

gainNode.gain.setValueAtTime(Math.max(startGain, 0.0001), fadeStartCtx);

if (fade.type === FadeType.LINEAR) {
if (fade.type === FadeType.EQUAL_POWER) {
const duration = fadeEndCtx - fadeStartCtx;
if (duration > 0) {
const isFadeIn = fade.endGain > fade.startGain;
const fullCurve = isFadeIn ? EQUAL_POWER_FADE_IN : EQUAL_POWER_FADE_OUT;
// slice from the fraction we've already passed (mid-fade seek)
const startIdx = Math.round(fadeFraction * (EQUAL_POWER_CURVE_LENGTH - 1));
const curve = startIdx > 0 ? fullCurve.slice(startIdx) : fullCurve;
gainNode.gain.setValueCurveAtTime(curve, fadeStartCtx, duration);
}
} else if (fade.type === FadeType.LINEAR) {
gainNode.gain.linearRampToValueAtTime(Math.max(fade.endGain, 0.0001), fadeEndCtx);
} else if (fade.type === FadeType.EQUAL_POWER) {
const isFadeIn = fade.endGain > fade.startGain;
const fullCurve = isFadeIn ? EQUAL_POWER_FADE_IN : EQUAL_POWER_FADE_OUT;
// slice from the fraction we've already passed (mid-fade seek)
const startIdx = Math.round(fadeFraction * (EQUAL_POWER_CURVE_LENGTH - 1));
const curve = startIdx > 0 ? fullCurve.slice(startIdx) : fullCurve;
gainNode.gain.setValueCurveAtTime(curve, fadeStartCtx, fadeEndCtx - fadeStartCtx);
} else {
// exponentialRamp can't reach 0, so we use an epsilon
gainNode.gain.exponentialRampToValueAtTime(Math.max(fade.endGain, 0.0001), fadeEndCtx);
}
}
Expand Down
136 changes: 136 additions & 0 deletions src/components/Timeline/Timeline.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import { useRef, useEffect, useCallback, useState } from 'react';
import { FadeType } from '../../../types/Fade';
import type { FadeType as FadeTypeValue } from '../../../types/Fade';
import { useAudioEngine } from '../../audio/UseAudioEngine';
import type { SfxClip } from '../../audio/types';
import TimelineTicks from './TimelineTicks';
import TimelineClip from './TimelineClip';
import TransitionModal from '../TransitionModal';
import '../../styles/timeline.css';

// Zoom limits (px per second)
Expand Down Expand Up @@ -125,6 +128,15 @@ export default function Timeline({ sfxClips, onSfxChange }: TimelineProps) {
// Music dragging
const dragRef = useRef<DragState | null>(null);

// Transition modal state
const [transitionTarget, setTransitionTarget] = useState<{
fromEntryId: string;
fromTitle: string;
toEntryId: string;
toTitle: string;
maxDuration: number;
} | null>(null);

const onMusicClipMouseDown = useCallback(
(e: React.MouseEvent, entryId: string) => {
e.preventDefault();
Expand Down Expand Up @@ -293,6 +305,103 @@ export default function Timeline({ sfxClips, onSfxChange }: TimelineProps) {
};
}, [commitMusicDrop, commitSfxDrop]);

// Right-clicking a music clip opens the TransitionModal for the nearest boundary.
const onClipContextMenu = useCallback(
(e: React.MouseEvent, entryId: string) => {
e.preventDefault();
e.stopPropagation();

const idx = timeline.findIndex((en) => en.entryId === entryId);
if (idx === -1 || timeline.length < 2) return;

const entry = timeline[idx];
const scrollLeft = scrollRef.current?.scrollLeft ?? 0;
const rectLeft = scrollRef.current?.getBoundingClientRect().left ?? 0;
const clickCanvasPx = e.clientX - rectLeft + scrollLeft;
const clipLeftPx = entry.absoluteStart * pxPerSecRef.current;
const clipWidthPx = (entry.absoluteEnd - entry.absoluteStart) * pxPerSecRef.current;
const clickedLeftHalf = clickCanvasPx - clipLeftPx < clipWidthPx / 2;

let fromIdx: number;
let toIdx: number;

if (clickedLeftHalf && idx > 0) {
fromIdx = idx - 1;
toIdx = idx;
} else if (!clickedLeftHalf && idx < timeline.length - 1) {
fromIdx = idx;
toIdx = idx + 1;
} else if (idx > 0) {
fromIdx = idx - 1;
toIdx = idx;
} else {
fromIdx = idx;
toIdx = idx + 1;
}

const fromEntry = timeline[fromIdx];
const toEntry = timeline[toIdx];
const maxDur = Math.min(
fromEntry.absoluteEnd - fromEntry.absoluteStart,
toEntry.absoluteEnd - toEntry.absoluteStart,
);

setTransitionTarget({
fromEntryId: fromEntry.entryId,
fromTitle: fromEntry.title,
toEntryId: toEntry.entryId,
toTitle: toEntry.title,
maxDuration: maxDur,
});
},
[timeline],
);

// Clicking the crossfade overlap indicator opens the TransitionModal directly.
const onOverlapClick = useCallback(
(entryId: string) => {
const idx = timeline.findIndex((en) => en.entryId === entryId);
if (idx <= 0) return;

const fromEntry = timeline[idx - 1];
const toEntry = timeline[idx];
const maxDur = Math.min(
fromEntry.absoluteEnd - fromEntry.absoluteStart,
toEntry.absoluteEnd - toEntry.absoluteStart,
);

setTransitionTarget({
fromEntryId: fromEntry.entryId,
fromTitle: fromEntry.title,
toEntryId: toEntry.entryId,
toTitle: toEntry.title,
maxDuration: maxDur,
});
},
[timeline],
);

const handleTransitionApply = useCallback(
(duration: number, fadeOutType: FadeTypeValue, fadeInType: FadeTypeValue) => {
if (!transitionTarget) return;
engine.playlist.setTransition(
transitionTarget.fromEntryId,
transitionTarget.toEntryId,
duration,
fadeOutType,
fadeInType,
);
setTransitionTarget(null);
},
[engine, transitionTarget],
);

const handleTransitionRemove = useCallback(() => {
if (!transitionTarget) return;
engine.playlist.removeTransition(transitionTarget.fromEntryId, transitionTarget.toEntryId);
setTransitionTarget(null);
}, [engine, transitionTarget]);

// The playhead will seek to where the user clicks on the ticks
const handleTicksClick = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
Expand Down Expand Up @@ -415,6 +524,8 @@ export default function Timeline({ sfxClips, onSfxChange }: TimelineProps) {
overlapWidthPx={overlapWidthPx}
variant="music"
onMouseDown={onMusicClipMouseDown}
onContextMenu={onClipContextMenu}
onOverlapClick={onOverlapClick}
/>
);
})}
Expand Down Expand Up @@ -455,6 +566,31 @@ export default function Timeline({ sfxClips, onSfxChange }: TimelineProps) {
</div>
</div>
</div>

{transitionTarget &&
(() => {
const existing = engine.playlist
.getTransitions()
.find(
(t) =>
t.fromEntryId === transitionTarget.fromEntryId &&
t.toEntryId === transitionTarget.toEntryId,
);
return (
<TransitionModal
fromTitle={transitionTarget.fromTitle}
toTitle={transitionTarget.toTitle}
currentDuration={existing?.duration ?? 3}
currentFadeOutType={existing?.fadeOutType ?? FadeType.LINEAR}
currentFadeInType={existing?.fadeInType ?? FadeType.LINEAR}
maxDuration={transitionTarget.maxDuration}
hasExistingTransition={!!existing}
onApply={handleTransitionApply}
onRemove={handleTransitionRemove}
onClose={() => setTransitionTarget(null)}
/>
);
})()}
</div>
);
}
13 changes: 13 additions & 0 deletions src/components/Timeline/TimelineClip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ interface TimelineClipProps {
overlapWidthPx: number;
variant: 'music' | 'sfx';
onMouseDown: (e: React.MouseEvent, entryId: string) => void;
onContextMenu?: (e: React.MouseEvent, entryId: string) => void;
onOverlapClick?: (entryId: string) => void;
}

export default function TimelineClip({
Expand All @@ -29,6 +31,8 @@ export default function TimelineClip({
overlapWidthPx,
variant,
onMouseDown,
onContextMenu,
onOverlapClick,
}: TimelineClipProps) {
const clipDuration = widthPx / pxPerSecond;

Expand All @@ -42,6 +46,7 @@ export default function TimelineClip({
<div
className={baseClass}
onMouseDown={(e) => onMouseDown(e, entryId)}
onContextMenu={onContextMenu ? (e) => onContextMenu(e, entryId) : undefined}
style={{
left: leftPx,
top: clipTop,
Expand Down Expand Up @@ -69,6 +74,14 @@ export default function TimelineClip({
cursor: isDragging ? 'grabbing' : 'pointer',
}}
title={`Crossfade: ${formatDuration(overlapWidthPx / pxPerSecond)}`}
onClick={
onOverlapClick
? (e) => {
e.stopPropagation();
onOverlapClick(entryId);
}
: undefined
}
/>
)}
</>
Expand Down
Loading
Loading