Skip to content

Commit dbe8ae1

Browse files
committed
Refactor waveform rendering in ClipTrack
1 parent 5d95f24 commit dbe8ae1

1 file changed

Lines changed: 112 additions & 103 deletions

File tree

apps/desktop/src/routes/editor/Timeline/ClipTrack.tsx

Lines changed: 112 additions & 103 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,69 @@ import {
3030
useSegmentWidth,
3131
} from "./Track";
3232

33+
const CANVAS_HEIGHT = 52;
34+
const WAVEFORM_MIN_DB = -60;
35+
const WAVEFORM_SAMPLE_STEP = 0.1;
36+
const WAVEFORM_CONTROL_STEP = 0.05;
37+
const WAVEFORM_PADDING_SECONDS = 0.3;
38+
39+
function gainToScale(gain?: number) {
40+
if (!Number.isFinite(gain)) return 1;
41+
const value = gain as number;
42+
if (value <= WAVEFORM_MIN_DB) return 0;
43+
return Math.max(0, 1 + value / -WAVEFORM_MIN_DB);
44+
}
45+
46+
function createWaveformPath(
47+
segment: { start: number; end: number },
48+
waveform?: number[],
49+
) {
50+
if (typeof Path2D === "undefined") return;
51+
if (!waveform || waveform.length === 0) return;
52+
53+
const duration = Math.max(segment.end - segment.start, WAVEFORM_SAMPLE_STEP);
54+
if (!Number.isFinite(duration) || duration <= 0) return;
55+
56+
const path = new Path2D();
57+
path.moveTo(0, 1);
58+
59+
const amplitudeAt = (index: number) => {
60+
const sample = waveform[index];
61+
const db =
62+
typeof sample === "number" && Number.isFinite(sample)
63+
? sample
64+
: WAVEFORM_MIN_DB;
65+
const clamped = Math.max(db, WAVEFORM_MIN_DB);
66+
const amplitude = 1 + clamped / -WAVEFORM_MIN_DB;
67+
return Math.min(Math.max(amplitude, 0), 1);
68+
};
69+
70+
const controlStep = Math.min(WAVEFORM_CONTROL_STEP / duration, 0.25);
71+
72+
for (
73+
let time = segment.start;
74+
time <= segment.end + WAVEFORM_SAMPLE_STEP;
75+
time += WAVEFORM_SAMPLE_STEP
76+
) {
77+
const index = Math.floor(time * 10);
78+
const normalizedX = (index / 10 - segment.start) / duration;
79+
const prevX =
80+
(index / 10 - WAVEFORM_SAMPLE_STEP - segment.start) / duration;
81+
const y = 1 - amplitudeAt(index);
82+
const prevY = 1 - amplitudeAt(index - 1);
83+
const cpX1 = prevX + controlStep / 2;
84+
const cpX2 = normalizedX - controlStep / 2;
85+
path.bezierCurveTo(cpX1, prevY, cpX2, y, normalizedX, y);
86+
}
87+
88+
const closingX =
89+
(segment.end + WAVEFORM_PADDING_SECONDS - segment.start) / duration;
90+
path.lineTo(closingX, 1);
91+
path.closePath();
92+
93+
return path;
94+
}
95+
3396
function formatTime(totalSeconds: number): string {
3497
const hours = Math.floor(totalSeconds / 3600);
3598
const minutes = Math.floor((totalSeconds % 3600) / 60);
@@ -48,115 +111,61 @@ function WaveformCanvas(props: {
48111
systemWaveform?: number[];
49112
micWaveform?: number[];
50113
segment: { start: number; end: number };
51-
secsPerPixel: number;
52114
}) {
53115
const { project } = useEditorContext();
54-
55-
let canvas: HTMLCanvasElement | undefined;
56116
const { width } = useSegmentContext();
57-
const { secsPerPixel } = useTimelineContext();
58-
59-
const render = (
60-
ctx: CanvasRenderingContext2D,
61-
h: number,
62-
waveform: number[],
63-
color: string,
64-
gain = 0,
65-
) => {
66-
const maxAmplitude = h;
67-
68-
// yellow please
69-
ctx.fillStyle = color;
70-
ctx.beginPath();
71-
72-
const step = 0.05 / secsPerPixel();
73-
74-
ctx.moveTo(0, h);
75-
76-
const norm = (w: number) => {
77-
const ww = Number.isFinite(w) ? w : -60;
78-
return 1.0 - Math.max(ww + gain, -60) / -60;
79-
};
80-
81-
for (
82-
let segmentTime = props.segment.start;
83-
segmentTime <= props.segment.end + 0.1;
84-
segmentTime += 0.1
85-
) {
86-
const index = Math.floor(segmentTime * 10);
87-
const xTime = index / 10;
88-
89-
const currentDb =
90-
typeof waveform[index] === "number" ? waveform[index] : -60;
91-
const amplitude = norm(currentDb) * maxAmplitude;
92-
93-
const x = (xTime - props.segment.start) / secsPerPixel();
94-
const y = h - amplitude;
95-
96-
const prevX = (xTime - 0.1 - props.segment.start) / secsPerPixel();
97-
const prevDb =
98-
typeof waveform[index - 1] === "number" ? waveform[index - 1] : -60;
99-
const prevAmplitude = norm(prevDb) * maxAmplitude;
100-
const prevY = h - prevAmplitude;
101-
102-
const cpX1 = prevX + step / 2;
103-
const cpX2 = x - step / 2;
104-
105-
ctx.bezierCurveTo(cpX1, prevY, cpX2, y, x, y);
106-
}
107-
108-
ctx.lineTo(
109-
(props.segment.end + 0.3 - props.segment.start) / secsPerPixel(),
110-
h,
111-
);
117+
const segmentRange = createMemo(() => ({
118+
start: props.segment.start,
119+
end: props.segment.end,
120+
}));
121+
const micPath = createMemo(() =>
122+
createWaveformPath(segmentRange(), props.micWaveform),
123+
);
124+
const systemPath = createMemo(() =>
125+
createWaveformPath(segmentRange(), props.systemWaveform),
126+
);
112127

113-
ctx.closePath();
114-
ctx.fill();
115-
};
128+
let canvas: HTMLCanvasElement | undefined;
116129

117-
function renderWaveforms() {
130+
createEffect(() => {
118131
if (!canvas) return;
119132
const ctx = canvas.getContext("2d");
120133
if (!ctx) return;
121134

122-
const w = width();
123-
if (w <= 0) return;
124-
125-
const h = canvas.height;
126-
canvas.width = w;
127-
ctx.clearRect(0, 0, w, h);
128-
129-
if (props.micWaveform)
130-
render(
131-
ctx,
132-
h,
133-
props.micWaveform,
134-
"rgba(255,255,255,0.4)",
135-
project.audio.micVolumeDb,
136-
);
137-
138-
if (props.systemWaveform)
139-
render(
140-
ctx,
141-
h,
142-
props.systemWaveform,
143-
"rgba(255,150,0,0.5)",
144-
project.audio.systemVolumeDb,
145-
);
146-
}
135+
const canvasWidth = Math.max(width(), 1);
136+
canvas.width = canvasWidth;
137+
const canvasHeight = canvas.height;
138+
ctx.clearRect(0, 0, canvasWidth, canvasHeight);
139+
140+
const drawPath = (
141+
path: Path2D | undefined,
142+
color: string,
143+
gain?: number,
144+
) => {
145+
if (!path) return;
146+
const scale = gainToScale(gain);
147+
if (scale <= 0) return;
148+
ctx.save();
149+
ctx.translate(0, -1);
150+
ctx.scale(1, scale);
151+
ctx.translate(0, 1);
152+
ctx.scale(canvasWidth, canvasHeight);
153+
ctx.fillStyle = color;
154+
ctx.fill(path);
155+
ctx.restore();
156+
};
147157

148-
createEffect(() => {
149-
renderWaveforms();
158+
drawPath(micPath(), "rgba(255,255,255,0.4)", project.audio.micVolumeDb);
159+
drawPath(systemPath(), "rgba(255,150,0,0.5)", project.audio.systemVolumeDb);
150160
});
151161

152162
return (
153163
<canvas
154164
ref={(el) => {
155165
canvas = el;
156-
renderWaveforms();
157166
}}
158167
class="absolute inset-0 w-full h-full pointer-events-none"
159-
height={52}
168+
height={CANVAS_HEIGHT}
160169
/>
161170
);
162171
}
@@ -184,6 +193,17 @@ export function ClipTrack(
184193
const segments = (): Array<TimelineSegment> =>
185194
project.timeline?.segments ?? [{ start: 0, end: duration(), timescale: 1 }];
186195

196+
const segmentOffsets = createMemo(() => {
197+
const segs = segments();
198+
const offsets: number[] = new Array(segs.length);
199+
let sum = 0;
200+
for (let idx = 0; idx < segs.length; idx++) {
201+
offsets[idx] = sum;
202+
sum += (segs[idx].end - segs[idx].start) / segs[idx].timescale;
203+
}
204+
return offsets;
205+
});
206+
187207
function onHandleReleased() {
188208
const { transform } = editorState.timeline;
189209

@@ -210,17 +230,7 @@ export function ClipTrack(
210230
initialStart: number;
211231
}>(null);
212232

213-
const prefixOffsets = createMemo(() => {
214-
const segs = segments();
215-
const out: number[] = new Array(segs.length);
216-
let sum = 0;
217-
for (let k = 0; k < segs.length; k++) {
218-
out[k] = sum;
219-
sum += (segs[k].end - segs[k].start) / segs[k].timescale;
220-
}
221-
return out;
222-
});
223-
const prevDuration = createMemo(() => prefixOffsets()[i()] ?? 0);
233+
const prevDuration = createMemo(() => segmentOffsets()[i()] ?? 0);
224234

225235
const relativeSegment = createMemo(() => {
226236
const ds = startHandleDrag();
@@ -481,7 +491,6 @@ export function ClipTrack(
481491
micWaveform={micWaveform()}
482492
systemWaveform={systemAudioWaveform()}
483493
segment={segment}
484-
secsPerPixel={secsPerPixel()}
485494
/>
486495
)}
487496

0 commit comments

Comments
 (0)