Zoom to overview selection

This commit is contained in:
Rob Watson 2021-09-13 12:16:23 +02:00
parent 712fbd3142
commit 193073015d
3 changed files with 154 additions and 32 deletions

View File

@ -1,18 +1,28 @@
import { useEffect, useState, useRef, MouseEvent } from 'react'; import { useEffect, useState, useRef, MouseEvent } from 'react';
import { Waveform as WaveformOverview } from './Waveform/Overview'; import { Waveform as WaveformOverview } from './Waveform/Overview';
import { Canvas as WaveformCanvas } from './Waveform/Canvas'; import { Canvas as WaveformCanvas } from './Waveform/Canvas';
import {
secsToCanvasX,
canvasXToFrame,
mouseEventToCanvasX,
} from './Waveform/Helpers';
type Props = { type Props = {
audioContext: AudioContext; audioContext: AudioContext;
}; };
type AudioFile = { export type AudioFile = {
bytes: number; bytes: number;
channels: number; channels: number;
frames: number; frames: number;
sampleRate: number; sampleRate: number;
}; };
export type Selection = {
x1: number;
x2: number;
};
type ZoomSettings = { type ZoomSettings = {
startFrame: number; startFrame: number;
endFrame: number; endFrame: number;
@ -36,31 +46,6 @@ export const Waveform: React.FC<Props> = ({ audioContext }: Props) => {
// TODO: error handling // TODO: error handling
const videoID = new URLSearchParams(window.location.search).get('video_id'); const videoID = new URLSearchParams(window.location.search).get('video_id');
// helpers
const mouseEventToCanvasX = (evt: MouseEvent<HTMLCanvasElement>): number => {
// TODO: use offsetX/offsetY?
const rect = evt.currentTarget.getBoundingClientRect();
const elementX = evt.clientX - rect.left;
const canvas = evt.target as HTMLCanvasElement;
return (elementX * canvas.width) / rect.width;
};
const canvasXToFrame = (x: number): number => {
if (audioFile == null) {
return 0;
}
return Math.floor((x / CanvasLogicalWidth) * audioFile.frames);
};
const secsToCanvasX = (canvasWidth: number, secs: number): number => {
if (audioFile == null) {
return 0;
}
const duration = audioFile.frames / audioFile.sampleRate;
return Math.floor(canvasWidth * (secs / duration));
};
// effects // effects
// setup player on page load: // setup player on page load:
@ -149,7 +134,11 @@ export const Waveform: React.FC<Props> = ({ audioContext }: Props) => {
return; return;
} }
const x = secsToCanvasX(canvas.width, currentTime); const x = secsToCanvasX(
currentTime,
audioFile.sampleRate,
audioFile.frames
);
ctx.strokeStyle = 'red'; ctx.strokeStyle = 'red';
ctx.beginPath(); ctx.beginPath();
@ -162,8 +151,16 @@ export const Waveform: React.FC<Props> = ({ audioContext }: Props) => {
// callbacks // callbacks
const handleMouseMove = (evt: MouseEvent<HTMLCanvasElement>) => { const handleMouseMove = (evt: MouseEvent<HTMLCanvasElement>) => {
if (audioFile == null) {
return;
}
const canvasX = mouseEventToCanvasX(evt); const canvasX = mouseEventToCanvasX(evt);
console.log('mousemove, x =', canvasX, 'frame =', canvasXToFrame(canvasX)); console.log(
'mousemove, x =',
canvasX,
'frame =',
canvasXToFrame(canvasX, numFrames)
);
}; };
const handleMouseDown = () => { const handleMouseDown = () => {
@ -212,6 +209,17 @@ export const Waveform: React.FC<Props> = ({ audioContext }: Props) => {
setZoomSettings(settings); setZoomSettings(settings);
}; };
const handleSelectionChange = (selection: Selection) => {
if (audioFile == null) {
return;
}
const settings: ZoomSettings = {
startFrame: canvasXToFrame(selection.x1, audioFile.frames),
endFrame: canvasXToFrame(selection.x2, audioFile.frames),
};
setZoomSettings(settings);
};
// render component: // render component:
const wrapperProps = { const wrapperProps = {
@ -249,6 +257,11 @@ export const Waveform: React.FC<Props> = ({ audioContext }: Props) => {
const controlPanelStyles = { margin: '1em' } as React.CSSProperties; const controlPanelStyles = { margin: '1em' } as React.CSSProperties;
const clockTextAreaProps = { color: '#999', width: '400px' }; const clockTextAreaProps = { color: '#999', width: '400px' };
let numFrames = 0;
if (audioFile != null) {
numFrames = audioFile.frames;
}
return ( return (
<> <>
<h1>clipper</h1> <h1>clipper</h1>
@ -271,7 +284,9 @@ export const Waveform: React.FC<Props> = ({ audioContext }: Props) => {
</div> </div>
<WaveformOverview <WaveformOverview
peaks={overviewPeaks} peaks={overviewPeaks}
numFrames={numFrames}
style={overviewStyles} style={overviewStyles}
onSelectionChange={handleSelectionChange}
></WaveformOverview> ></WaveformOverview>
<div style={controlPanelStyles}> <div style={controlPanelStyles}>
<button onClick={handlePlay}>Play</button> <button onClick={handlePlay}>Play</button>

View File

@ -0,0 +1,27 @@
import { CanvasLogicalWidth } from '../Waveform';
import { MouseEvent } from 'react';
// TODO: add tests
export const mouseEventToCanvasX = (
evt: MouseEvent<HTMLCanvasElement>
): number => {
// TODO: use offsetX/offsetY?
const rect = evt.currentTarget.getBoundingClientRect();
const elementX = evt.clientX - rect.left;
return (elementX * CanvasLogicalWidth) / rect.width;
};
// TODO: add tests
export const canvasXToFrame = (x: number, numFrames: number): number => {
return Math.floor((x / CanvasLogicalWidth) * numFrames);
};
// TODO: add tests
export const secsToCanvasX = (
secs: number,
sampleRate: number,
numFrames: number
): number => {
const duration = numFrames / sampleRate;
return Math.floor(CanvasLogicalWidth * (secs / duration));
};

View File

@ -1,26 +1,104 @@
import { useEffect, useRef } from 'react'; import { useEffect, useState, useRef, MouseEvent } from 'react';
import { Canvas as WaveformCanvas } from './Canvas'; import { Canvas as WaveformCanvas } from './Canvas';
import { CanvasLogicalWidth, CanvasLogicalHeight } from '../Waveform'; import {
CanvasLogicalWidth,
CanvasLogicalHeight,
Selection,
} from '../Waveform';
import { mouseEventToCanvasX } from './Helpers';
type Props = { type Props = {
peaks: number[][] | null; peaks: number[][] | null;
numFrames: number;
style: React.CSSProperties; style: React.CSSProperties;
onSelectionChange: (selection: Selection) => void;
}; };
enum Mode {
Normal,
Selecting,
}
export const Waveform: React.FC<Props> = (props: Props) => { export const Waveform: React.FC<Props> = (props: Props) => {
const hudCanvasRef = useRef<HTMLCanvasElement>(null); const hudCanvasRef = useRef<HTMLCanvasElement>(null);
const [mode, setMode] = useState(Mode.Normal);
const defaultSelection: Selection = { x1: 0, x2: 0 };
const [selection, setSelection] = useState(defaultSelection);
// TODO: is it needed?
const [newStartSelection, setNewStartSelection] = useState(0);
// effects
useEffect(() => {
(async function () {
const canvas = hudCanvasRef.current;
if (canvas == null) {
console.error('no hud canvas ref available');
return;
}
const ctx = canvas.getContext('2d');
if (ctx == null) {
console.error('no hud 2d context available');
return;
}
ctx.clearRect(0, 0, canvas.width, canvas.height);
if (selection.x1 >= selection.x2) {
return;
}
ctx.beginPath();
ctx.strokeStyle = 'red';
ctx.lineWidth = 2;
ctx.fillStyle = 'rgba(255, 255, 255, 0.3)';
ctx.rect(selection.x1, 2, selection.x2 - selection.x1, canvas.height - 8);
ctx.fill();
ctx.stroke();
})();
});
// handlers // handlers
const handleMouseDown = () => { const handleMouseDown = (evt: MouseEvent<HTMLCanvasElement>) => {
console.log('mousedown'); console.log('mousedown');
if (mode != Mode.Normal) {
return;
}
setMode(Mode.Selecting);
setNewStartSelection(mouseEventToCanvasX(evt));
};
const handleMouseMove = (evt: MouseEvent<HTMLCanvasElement>) => {
console.log('mousemove');
if (mode != Mode.Selecting) {
return;
}
const selection: Selection = {
x1: newStartSelection,
x2: mouseEventToCanvasX(evt),
};
setSelection(selection);
};
const handleMouseUp = () => {
console.log('mouseup');
if (mode != Mode.Selecting) {
return;
}
setMode(Mode.Normal);
props.onSelectionChange(selection);
}; };
// render component // render component
const canvasStyles = { const canvasStyles = {
width: '100%', width: '100%',
height: '100px', height: '100%',
margin: '0 auto', margin: '0 auto',
display: 'block', display: 'block',
} as React.CSSProperties; } as React.CSSProperties;
@ -51,6 +129,8 @@ export const Waveform: React.FC<Props> = (props: Props) => {
width={CanvasLogicalWidth} width={CanvasLogicalWidth}
height={CanvasLogicalHeight} height={CanvasLogicalHeight}
onMouseDown={handleMouseDown} onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
></canvas> ></canvas>
</div> </div>
</> </>