preview speed control
This commit is contained in:
+50
-2
@@ -18,6 +18,7 @@
|
|||||||
selectAll,
|
selectAll,
|
||||||
setActiveSwitch,
|
setActiveSwitch,
|
||||||
setFps,
|
setFps,
|
||||||
|
setPreviewSpeed,
|
||||||
setVideoUrl,
|
setVideoUrl,
|
||||||
snapToFrame,
|
snapToFrame,
|
||||||
updateMarker,
|
updateMarker,
|
||||||
@@ -38,7 +39,12 @@
|
|||||||
|
|
||||||
function rescheduleAudio() {
|
function rescheduleAudio() {
|
||||||
if (!videoEl) return;
|
if (!videoEl) return;
|
||||||
audioEngine.reschedule(videoEl.currentTime, app.markers, !videoEl.paused);
|
audioEngine.reschedule(
|
||||||
|
videoEl.currentTime,
|
||||||
|
app.markers,
|
||||||
|
!videoEl.paused,
|
||||||
|
app.previewSpeed,
|
||||||
|
);
|
||||||
lastScheduledTime = videoEl.currentTime;
|
lastScheduledTime = videoEl.currentTime;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,11 +66,18 @@
|
|||||||
const playing = app.isPlaying;
|
const playing = app.isPlaying;
|
||||||
void app.markers;
|
void app.markers;
|
||||||
void app.activeSwitch;
|
void app.activeSwitch;
|
||||||
|
void app.previewSpeed;
|
||||||
if (playing && Math.abs(t - lastScheduledTime) > 0.25) {
|
if (playing && Math.abs(t - lastScheduledTime) > 0.25) {
|
||||||
rescheduleAudio();
|
rescheduleAudio();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const speed = app.previewSpeed;
|
||||||
|
controller?.setPlaybackRate(speed);
|
||||||
|
if (app.isPlaying) rescheduleAudio();
|
||||||
|
});
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
const onKeyDown = (event: KeyboardEvent) => {
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
if (isEditableTarget(event.target)) return;
|
if (isEditableTarget(event.target)) return;
|
||||||
@@ -254,6 +267,22 @@
|
|||||||
</button>
|
</button>
|
||||||
<span class="shortcut-hint">Ctrl+Space</span>
|
<span class="shortcut-hint">Ctrl+Space</span>
|
||||||
|
|
||||||
|
<label class="speed-control">
|
||||||
|
Preview
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="25"
|
||||||
|
max="200"
|
||||||
|
step="5"
|
||||||
|
value={Math.round(app.previewSpeed * 100)}
|
||||||
|
disabled={!app.videoUrl}
|
||||||
|
oninput={(event) => {
|
||||||
|
setPreviewSpeed(Number(event.currentTarget.value) / 100);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span class="speed-value">{Math.round(app.previewSpeed * 100)}%</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
<label class="switch-select">
|
<label class="switch-select">
|
||||||
Switch
|
Switch
|
||||||
<select
|
<select
|
||||||
@@ -378,8 +407,27 @@
|
|||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.switch-select {
|
.speed-control {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.speed-control input[type='range'] {
|
||||||
|
width: 8rem;
|
||||||
|
accent-color: #68d391;
|
||||||
|
}
|
||||||
|
|
||||||
|
.speed-value {
|
||||||
|
min-width: 2.75rem;
|
||||||
|
text-align: right;
|
||||||
|
color: #a0aec0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch-select {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
|
|||||||
@@ -11,7 +11,12 @@ import { app } from '../store.svelte';
|
|||||||
|
|
||||||
export type AudioEngine = {
|
export type AudioEngine = {
|
||||||
init: (video: HTMLVideoElement) => Promise<void>;
|
init: (video: HTMLVideoElement) => Promise<void>;
|
||||||
reschedule: (currentTime: number, markers: Marker[], isPlaying: boolean) => void;
|
reschedule: (
|
||||||
|
currentTime: number,
|
||||||
|
markers: Marker[],
|
||||||
|
isPlaying: boolean,
|
||||||
|
playbackRate: number,
|
||||||
|
) => void;
|
||||||
playKey: (code: string, keyLabel: string, phase: SoundPhase) => void;
|
playKey: (code: string, keyLabel: string, phase: SoundPhase) => void;
|
||||||
preloadActiveSwitch: () => Promise<void>;
|
preloadActiveSwitch: () => Promise<void>;
|
||||||
destroy: () => void;
|
destroy: () => void;
|
||||||
@@ -146,7 +151,7 @@ export function createAudioEngine(): AudioEngine {
|
|||||||
await preloadSwitch(app.activeSwitch);
|
await preloadSwitch(app.activeSwitch);
|
||||||
},
|
},
|
||||||
|
|
||||||
reschedule(time: number, markers: Marker[], playing: boolean) {
|
reschedule(time: number, markers: Marker[], playing: boolean, playbackRate: number) {
|
||||||
if (!ctx || !playing) {
|
if (!ctx || !playing) {
|
||||||
clearScheduled();
|
clearScheduled();
|
||||||
return;
|
return;
|
||||||
@@ -155,6 +160,7 @@ export function createAudioEngine(): AudioEngine {
|
|||||||
clearScheduled();
|
clearScheduled();
|
||||||
const audioCtx = ctx;
|
const audioCtx = ctx;
|
||||||
const startAt = audioCtx.currentTime + 0.02;
|
const startAt = audioCtx.currentTime + 0.02;
|
||||||
|
const rate = playbackRate > 0 ? playbackRate : 1;
|
||||||
const switchType = app.activeSwitch;
|
const switchType = app.activeSwitch;
|
||||||
|
|
||||||
void (async () => {
|
void (async () => {
|
||||||
@@ -172,14 +178,14 @@ export function createAudioEngine(): AudioEngine {
|
|||||||
const releaseTime = markerReleaseTime(marker);
|
const releaseTime = markerReleaseTime(marker);
|
||||||
|
|
||||||
if (marker.time >= time - 0.05) {
|
if (marker.time >= time - 0.05) {
|
||||||
const pressDelay = marker.time - time;
|
const pressDelay = (marker.time - time) / rate;
|
||||||
const pressBuffer = await loadBuffer(switchType, 'press', pressKey);
|
const pressBuffer = await loadBuffer(switchType, 'press', pressKey);
|
||||||
const pressSource = playBuffer(pressBuffer, startAt + Math.max(0, pressDelay));
|
const pressSource = playBuffer(pressBuffer, startAt + Math.max(0, pressDelay));
|
||||||
if (pressSource) scheduledSources.push(pressSource);
|
if (pressSource) scheduledSources.push(pressSource);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (releaseTime >= time - 0.05) {
|
if (releaseTime >= time - 0.05) {
|
||||||
const releaseDelay = releaseTime - time;
|
const releaseDelay = (releaseTime - time) / rate;
|
||||||
const releaseBuffer = await loadBuffer(switchType, 'release', releaseKey);
|
const releaseBuffer = await loadBuffer(switchType, 'release', releaseKey);
|
||||||
const releaseSource = playBuffer(releaseBuffer, startAt + Math.max(0, releaseDelay));
|
const releaseSource = playBuffer(releaseBuffer, startAt + Math.max(0, releaseDelay));
|
||||||
if (releaseSource) scheduledSources.push(releaseSource);
|
if (releaseSource) scheduledSources.push(releaseSource);
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export const app = $state({
|
|||||||
videoUrl: null as string | null,
|
videoUrl: null as string | null,
|
||||||
videoName: null as string | null,
|
videoName: null as string | null,
|
||||||
timelineWidth: 800,
|
timelineWidth: 800,
|
||||||
|
previewSpeed: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
let markerCounter = 0;
|
let markerCounter = 0;
|
||||||
@@ -52,6 +53,10 @@ export function setFps(value: number) {
|
|||||||
app.fps = value;
|
app.fps = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function setPreviewSpeed(value: number) {
|
||||||
|
app.previewSpeed = Math.max(0.25, Math.min(2, value));
|
||||||
|
}
|
||||||
|
|
||||||
export function setPixelsPerSecond(value: number) {
|
export function setPixelsPerSecond(value: number) {
|
||||||
app.pixelsPerSecond = value;
|
app.pixelsPerSecond = value;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export type VideoController = {
|
|||||||
togglePlayPause: () => void;
|
togglePlayPause: () => void;
|
||||||
stepFrame: (delta: number) => void;
|
stepFrame: (delta: number) => void;
|
||||||
seek: (time: number) => void;
|
seek: (time: number) => void;
|
||||||
|
setPlaybackRate: (rate: number) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function attachVideoPlayer(video: HTMLVideoElement): VideoController {
|
export function attachVideoPlayer(video: HTMLVideoElement): VideoController {
|
||||||
@@ -71,6 +72,9 @@ export function attachVideoPlayer(video: HTMLVideoElement): VideoController {
|
|||||||
|
|
||||||
if (video.readyState >= 1) onLoadedMetadata();
|
if (video.readyState >= 1) onLoadedMetadata();
|
||||||
|
|
||||||
|
video.preservesPitch = true;
|
||||||
|
video.playbackRate = app.previewSpeed;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
element: video,
|
element: video,
|
||||||
destroy() {
|
destroy() {
|
||||||
@@ -99,5 +103,8 @@ export function attachVideoPlayer(video: HTMLVideoElement): VideoController {
|
|||||||
video.currentTime = clamped;
|
video.currentTime = clamped;
|
||||||
setCurrentTime(clamped);
|
setCurrentTime(clamped);
|
||||||
},
|
},
|
||||||
|
setPlaybackRate(rate: number) {
|
||||||
|
video.playbackRate = rate;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user