From 3e8bb040b1694aedaaa7879f7b83f7ec9d0d0efb Mon Sep 17 00:00:00 2001 From: Robin Fernandes Date: Fri, 21 Jul 2023 16:12:58 +1000 Subject: [PATCH] Enable playback shortcut keys on timeseries waveform; fix some darkmode issues; small improvements to undo/redo. --- src/App.tsx | 53 ++++---- src/ParseqUI.js | 79 ++++++----- src/components/AudioWaveform.tsx | 74 +++++++---- src/components/Header.tsx | 20 ++- src/components/TimeSeriesUI.tsx | 181 +++++++++++++++----------- src/components/WavesurferWaveform.tsx | 81 +++++++++--- src/theme.ts | 18 +++ src/utils/utils.ts | 5 + 8 files changed, 333 insertions(+), 178 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 90faf90..73154c1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -9,37 +9,40 @@ import Labs from "./Labs"; import Raw from "./Raw"; import Header from "./components/Header"; import { themeFactory } from "./theme"; +import { HotkeysProvider } from 'react-hotkeys-hook'; const App = () => { const theme = extendTheme(themeFactory()); return - - - -
- - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - + + + + +
+ + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + ; }; diff --git a/src/ParseqUI.js b/src/ParseqUI.js index afdb45e..d7adf25 100644 --- a/src/ParseqUI.js +++ b/src/ParseqUI.js @@ -205,31 +205,26 @@ const ParseqUI = (props) => { // in quick succession. useDebouncedEffect(() => { if (autoSaveEnabled && prompts && options && displayedFields && keyframes && managedFields && timeSeries && keyframeLock) { - const savedStatus = saveVersion(activeDocId, getPersistableState()); - savedStatus.then((versionIdIfSaved) => { - if (versionIdIfSaved) { + const versionToSave = getPersistableState(); + if (recoveredFrom + && Object.keys(versionToSave) + .filter((k) => k !== 'meta') // exclude this field because it has a timestamp that is expected to change. + .every((k) => equal(versionToSave[k], recoveredFrom[k]))) { + // If the document is identical to the doc we just reverted from, no need to save yet: + console.log("Not saving, would be identical to recovered version."); + } else { + saveVersion(activeDocId, getPersistableState()).then((versionIdIfSaved) => { + if (!versionIdIfSaved) { + // No save occurred. + return; + } + console.log("Non-reversion detected. No longer in a just-recovered state, redo is no longer possible."); + setRecoveredFrom(undefined); setActiveVersionId(versionIdIfSaved); - setUndoStack((undoStack) => [versionIdIfSaved, ...undoStack]); - // HACK - // We want to reset the undo pointer IFF the user made a non-reversion change. - // We do NOT want to reset the undo pointer if we're only saving after an undo/redo. - // A quick & dirty way to determine this is to store the full state that we last - // recovered from and compare it against the current state... - // This is not memory or CPU efficient, and will have a latency impact when saving - if (recoveredFrom && (!equal(recoveredFrom.reverseRender, reverseRender) - || !equal(recoveredFrom.keyframeLock, keyframeLock) - || !equal(recoveredFrom.options, options) - || !equal(recoveredFrom.managedFields, managedFields) - || !equal(recoveredFrom.displayedFields, displayedFields) - || !equal(recoveredFrom.prompts, prompts) - || !equal(recoveredFrom.keyframes, keyframes) - || !equal(recoveredFrom.timeSeries, timeSeries))) { - console.log("User made a non-reversion change. Reset revision pointer.", [versionIdIfSaved, ...undoStack], 0); - setRecoveredFrom(undefined); - } - } - }); - setLastSaved(Date.now()); + setUndoStack((undoStack) => [versionIdIfSaved, ...undoStack]); + setLastSaved(Date.now()); + }); + }; } }, 200, [prompts, options, displayedFields, keyframes, autoSaveEnabled, managedFields, timeSeries, keyframeLock, reverseRender, undoStack, recoveredFrom]); @@ -1295,6 +1290,29 @@ const ParseqUI = (props) => { // Footer ------------------------ + const debugStatus = useMemo(() => { + if (process.env.NODE_ENV === 'development') { + const undoStackSummary = undoStack.map((v, idx) => { + return v.split("-")[1]; + }); + console.log("undostack", undoStackSummary) + const recoveredIdx = (recoveredFrom) ? undoStack.findIndex((v) => v === recoveredFrom.versionId) : -1; + + return +
    +
  • Current version: {activeVersionId}; recovered from: {recoveredFrom?.versionId || 'None'}
  • +
  • Undo stack size: {undoStack.length}; "recovered from" index: {recoveredIdx}
  • +
  • Undos available: { (recoveredIdx>=0) ? undoStack.length-recoveredIdx : 0}; Redos available: { (recoveredIdx>=0) ? recoveredIdx : 0}
  • +
  • Next undo: { (recoveredIdx>-1 && recoveredIdx; Next redo: { recoveredIdx>0 ? undoStackSummary[recoveredIdx-1] : 'None' }
  • +
+ +
+ } else { + return <>; + } + }, [undoStack, recoveredFrom, activeVersionId]); + + const renderStatus = useMemo(() => { let animated_fields = getAnimatedFields(renderedData); let uses2d = defaultFields.filter(f => f.labels.some(l => l === '2D') && animated_fields.includes(f)); @@ -1378,6 +1396,7 @@ const ParseqUI = (props) => { {renderStatus} + {(process.env.NODE_ENV === 'development') && debugStatus} @@ -1421,7 +1440,7 @@ const ParseqUI = (props) => { - , [renderStatus, initStatus, renderButton, renderedDataJsonString, activeDocId, autoUpload, needsRender, uploadStatus, autoRender, pinFooter]); + , [renderStatus, initStatus, renderButton, renderedDataJsonString, activeDocId, autoUpload, needsRender, uploadStatus, autoRender, pinFooter, debugStatus]); const stickyFooter = useMemo(() => { const idx = (recoveredFrom) ? undoStack.findIndex((v) => v === recoveredFrom.versionId) : 0; if (idx === -1 || idx >= undoStack.length-1) { console.log("Cannot undo any further - try using the revert dialog.") - console.log("undostack", undoStack, idx) return; } const versionToLoad = undoStack[idx+1] loadVersion(activeDocId, versionToLoad).then((loaded) => { setPersistableState(loaded); - console.log("undostack", undoStack) - //HACK - track the full state that we recovered from, to see whether we deviate from it. setRecoveredFrom(_.cloneDeep(loaded)); }); - }, {preventDefault:true}, [loadVersion, setPersistableState, undoStack, activeDocId]) + }, {preventDefault:true, scopes:['main']}, [loadVersion, setPersistableState, undoStack, activeDocId, recoveredFrom]) useHotkeys('shift+mod+z', () => { const idx = (recoveredFrom) ? undoStack.findIndex((v) => v === recoveredFrom.versionId) : 0; if (idx <=0) { console.log("Cannot redo any further - try using the revert dialog.") - console.log("undostack", undoStack, idx) return; } const versionToLoad = undoStack[idx-1] loadVersion(activeDocId, versionToLoad).then((loaded) => { setPersistableState(loaded); - console.log("undostack", undoStack) - //HACK - track the full state that we recovered from, to see whether we deviate from it. setRecoveredFrom(_.cloneDeep(loaded)); }); - }, {preventDefault:true}, [loadVersion, setPersistableState, undoStack, activeDocId]) + }, {preventDefault:true, scopes:['main']}, [loadVersion, setPersistableState, undoStack, activeDocId, recoveredFrom]) ////////////////////////////////////////// // Main layout diff --git a/src/components/AudioWaveform.tsx b/src/components/AudioWaveform.tsx index 9eb6a86..f161d87 100644 --- a/src/components/AudioWaveform.tsx +++ b/src/components/AudioWaveform.tsx @@ -2,7 +2,7 @@ import { Box, Alert, Typography, Button, Stack, TextField, MenuItem, Tab, Tabs, import Fade from '@mui/material/Fade'; import Grid from '@mui/material/Unstable_Grid2'; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { WaveForm, WaveSurfer } from "wavesurfer-react"; +import { WaveForm, WaveSurfer } from "wavesurfer-react"; // TODO: react wrapper isn't that useful, consider removing so we can upgrade to ws7 //@ts-ignore import TimelinePlugin from "wavesurfer.js/dist/plugin/wavesurfer.timeline.min"; //@ts-ignore @@ -150,6 +150,19 @@ export function AudioWaveform(props: AudioWaveformProps) { const debouncedOnCursorMove = useMemo(() => debounce(props.onCursorMove, 100), [props]); + // Update the colours manually on palette change. + // This is necessary because we are not recreating wavesurfer that often + useEffect(() => { + if (wavesurferRef.current) { + // @ts-ignore - type definition is wrong? + wavesurferRef.current.setWaveColor([palette.waveformStart.main, palette.waveformEnd.main]); + // @ts-ignore - type definition is wrong? + wavesurferRef.current.setProgressColor([palette.waveformProgressMaskStart.main, palette.waveformProgressMaskEnd.main]); + wavesurferRef.current.setCursorColor(palette.success.light); + } + }, [palette]); + + const handleDoubleClick = useCallback((event: any) => { const time = wavesurferRef.current?.getCurrentTime(); //@ts-ignore @@ -356,11 +369,11 @@ export function AudioWaveform(props: AudioWaveformProps) { function playPause(from : number = -1, pauseIfPlaying = true) { if (isPlaying && pauseIfPlaying) { wavesurferRef.current?.pause(); - setCapturedPos(wavesurferRef.current?.getCurrentTime() || 0); } else { if (from>=0) { wavesurferRef.current?.setCurrentTime(from); } if (!isPlaying) { + setCapturedPos(wavesurferRef.current?.getCurrentTime() || 0); wavesurferRef.current?.play(); } } @@ -506,9 +519,7 @@ export function AudioWaveform(props: AudioWaveformProps) { primaryColor: palette.graphBorder.dark, secondaryColor: palette.graphBorder.light, primaryFontColor: palette.graphFont.main, - secondaryFontColor: palette.graphFont.light, - fontFamily: 'Arial', - fontSize: 10, + secondaryFontColor: palette.graphFont.light, })); wavesurferRef.current.initPlugin('timeline'); // HACK to force the timeline position to update. @@ -646,28 +657,31 @@ export function AudioWaveform(props: AudioWaveformProps) { props.onAddKeyframes(frames, infoLabel); } - useHotkeys('space', () => { - playPause(); - }, {preventDefault:true}, [playPause]); - - useHotkeys('shift+space', () => { - playPause(0, false); - }, {preventDefault:true}, [playPause]); - - useHotkeys('ctrl+space', () => { - playPause(capturedPos, false); - }, {preventDefault:true}, [playPause, capturedPos]); - - useHotkeys('shift+a', () => { - const time = wavesurferRef.current?.getCurrentTime(); - //@ts-ignore - const newMarkers = [...manualEvents, time].sort((a, b) => a - b) - //@ts-ignore - setManualEvents(newMarkers); - }, {preventDefault:true}, [manualEvents]) - - - + useHotkeys('space', + () => playPause(), + {preventDefault:true, scopes: ['main']}, + [playPause]); + + useHotkeys('shift+space', + () => playPause(0, false), + {preventDefault:true, scopes: ['main']}, + [playPause]); + + useHotkeys('ctrl+space', + () => playPause(capturedPos, false), + {preventDefault:true, scopes: ['main']}, + [playPause, capturedPos]); + + useHotkeys('shift+a', + () => { + const time = wavesurferRef.current?.getCurrentTime(); + //@ts-ignore + const newMarkers = [...manualEvents, time].sort((a, b) => a - b) + //@ts-ignore + setManualEvents(newMarkers); + }, + {preventDefault:true, scopes: ['main']}, + [manualEvents]) return <> @@ -714,7 +728,11 @@ export function AudioWaveform(props: AudioWaveformProps) { autoCenter={false} interact={true} cursorColor={palette.success.light} - cursorWidth={3} + // @ts-ignore - type definition is wrong? + waveColor={[palette.waveformStart.main, palette.waveformEnd.main]} + // @ts-ignore - type definition is wrong? + progressColor={[palette.waveformProgressMaskStart.main, palette.waveformProgressMaskEnd.main]} + cursorWidth={1} />
diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 48104f8..d9ff889 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -35,6 +35,21 @@ export default function Header() { const displayBranch = (!GIT_BRANCH || GIT_BRANCH === 'master') ? '' : `Branch: ${GIT_BRANCH};`; const commitLink = {GIT_COMMIT_SHORTHASH} const changeLogLink = all changes + const environment = (process.env.NODE_ENV === 'development' ? 'dev' : getEnvFromHostname()) ; + + function getEnvFromHostname() { + const hostname = window.location.hostname; + if (hostname.includes('--dev')) { + return 'dev (hosted)'; + } + if (hostname.includes('--staging')) { + return 'staging (hosted)'; + } + if (hostname === 'sd-parseq.web.app') { + return 'production'; + } + return 'unknown'; + } const { colorScheme, setColorScheme } = useColorScheme(); const prefersDarkMode = useMediaQuery('(prefers-color-scheme: dark)'); @@ -67,7 +82,7 @@ export default function Header() {

Parseq v{getVersionNumber()} - [{process.env.NODE_ENV}] {displayBranch} Built {displayDate} ({commitLink} - {changeLogLink}) + [{environment}] {displayBranch} Built {displayDate} ({commitLink} - {changeLogLink})

@@ -80,9 +95,8 @@ export default function Header() { - {/* updateDarkMode(!darkMode)} icon={} label={(darkMode?"Light":"(wip) Dark")+" Mode"}/> */} {/* @ts-ignore */} - } label={(colorScheme === 'dark'?"Light":"(wip) Dark")+" Mode"} + } label={(colorScheme === 'dark'?"Light":"Dark")+" Mode"} onClick={() => { console.log("Setting color scheme"); const newColorScheme = colorScheme === 'dark' ? 'light' : 'dark'; diff --git a/src/components/TimeSeriesUI.tsx b/src/components/TimeSeriesUI.tsx index 52af135..c59495d 100644 --- a/src/components/TimeSeriesUI.tsx +++ b/src/components/TimeSeriesUI.tsx @@ -19,18 +19,21 @@ import { } from '@mui/material'; import TextField from '@mui/material/TextField'; import Grid from '@mui/material/Unstable_Grid2/Grid2'; +import { SupportedColorScheme, experimental_extendTheme as extendTheme, useColorScheme } from "@mui/material/styles"; import { PitchMethod } from 'aubiojs'; import { range } from 'lodash'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Line } from 'react-chartjs-2'; +import { useHotkeysContext } from 'react-hotkeys-hook'; import { Sparklines, SparklinesLine } from 'react-sparklines-typescript-v2'; import { InterpolationType, TimeSeries, TimestampType } from '../parseq-lang/parseq-timeseries'; +import { themeFactory } from "../theme"; import { DECIMATION_THRESHOLD } from '../utils/consts'; import { frameToSec } from '../utils/maths'; -import {createAudioBufferCopy} from '../utils/utils'; -import WavesurferAudioWaveform from './WavesurferWaveform'; -import { TabPanel } from './TabPanel'; +import { channelToRgba, createAudioBufferCopy } from '../utils/utils'; import { SmallTextField } from './SmallTextField'; +import { TabPanel } from './TabPanel'; +import WavesurferAudioWaveform from './WavesurferWaveform'; type TimeSeriesUIProps = { lastFrame: number, @@ -104,17 +107,25 @@ export const TimeSeriesUI = (props: TimeSeriesUIProps) => { const [status, setStatus] = useState(<>); + const theme = extendTheme(themeFactory()); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { colorScheme, setColorScheme } = useColorScheme(); + const palette = theme.colorSchemes[(colorScheme || 'light') as SupportedColorScheme].palette; + + const { disableScope: disableHotkeyScope, enableScope: enableHotkeyScope } = useHotkeysContext(); const handleTimestampTypeChange = (event: any) => { setTimestampType(event.target.value); }; - const handleClose = () => { + const handleClose = useCallback(() => { setOpen(false); - }; + disableHotkeyScope('timeseries'); + enableHotkeyScope('main'); + }, [disableHotkeyScope, enableHotkeyScope]); const handleAddTimeSeries = useCallback(() => { - setOpen(false); + handleClose(); if (processedTimeSeries) { let num = allTimeSeries.length; let alias = 'ts' + num; @@ -128,10 +139,10 @@ export const TimeSeriesUI = (props: TimeSeriesUIProps) => { ts: processedTimeSeries }]; setAllTimeSeries(newTimeSeries); - onChange(newTimeSeries); + onChange(newTimeSeries); } - }, [allTimeSeries, processedTimeSeries, onChange]); + }, [allTimeSeries, processedTimeSeries, onChange, handleClose]); const handleDeleteTimeSeries = useCallback((idx: number) => { const newTimeSeries = allTimeSeries.filter((_, i) => i !== idx); @@ -141,6 +152,8 @@ export const TimeSeriesUI = (props: TimeSeriesUIProps) => { const handleOpen = () => { setOpen(true); + enableHotkeyScope('timeseries'); + disableHotkeyScope('main'); setRawTimeSeries(undefined); setSetProcessedTimeSeries(undefined); setChartData(defaultData); @@ -159,8 +172,8 @@ export const TimeSeriesUI = (props: TimeSeriesUIProps) => { yAxisID: 'raw', pointRadius: showValuesAtFrames ? 2 : 0, pointStyle: 'cross', - pointColor: 'black', - borderColor: 'rgba(100,100,100,0.5)', + pointColor: channelToRgba(palette.primary.mainChannel, 0.45), + borderColor: channelToRgba(palette.primary.mainChannel, 0.45), borderWidth: 1 }); @@ -171,10 +184,10 @@ export const TimeSeriesUI = (props: TimeSeriesUIProps) => { label: 'Processed', data: graphableProcessed, yAxisID: 'processed', - pointRadius: showValuesAtFrames ? 2 : 0, + pointRadius: showValuesAtFrames ? 4 : 0, pointStyle: 'cross', - pointColor: 'red', - borderColor: 'red', + pointColor: palette.warning.dark, + borderColor: palette.warning.dark, borderWidth: 1 }); @@ -182,9 +195,9 @@ export const TimeSeriesUI = (props: TimeSeriesUIProps) => { datasets.push({ label: 'Frame positions', data: range(0, lastFrame).map((x) => ({ - x: (processed.timestampType === TimestampType.Millisecond) ? frameToSec(x, fps) * 1000 : x, - y: processed.getValueAt(x, fps, InterpolationType.Step) - })), + x: (processed.timestampType === TimestampType.Millisecond) ? frameToSec(x, fps) * 1000 : x, + y: processed.getValueAt(x, fps, InterpolationType.Step) + })), yAxisID: 'processed', pointRadius: 2, pointStyle: 'circle', @@ -200,7 +213,7 @@ export const TimeSeriesUI = (props: TimeSeriesUIProps) => { //console.log(datasets); //@ts-ignore setChartData({ datasets }); - }, [lastFrame, showValuesAtFrames, fps]); + }, [lastFrame, showValuesAtFrames, fps, palette]); const handleLoadFromAudio = async (event: any) => { try { @@ -213,6 +226,7 @@ export const TimeSeriesUI = (props: TimeSeriesUIProps) => { const newAudioBuffer = await audioContext.decodeAudioData(arrayBuffer); setUnfilteredAudioBuffer(createAudioBufferCopy(newAudioBuffer)); setAudioBuffer(newAudioBuffer); + event.target.blur(); // Remove focus from the file input so that spacebar doesn't trigger it again (and can be used for immediate playback) } catch (e: any) { console.error(e); } @@ -318,8 +332,8 @@ export const TimeSeriesUI = (props: TimeSeriesUIProps) => { rawTimeSeries, updateChartData]); - /*eslint-disable react-hooks/exhaustive-deps */ - useEffect(() => handleProcess(), [showValuesAtFrames]); + /*eslint-disable react-hooks/exhaustive-deps */ + useEffect(() => handleProcess(), [showValuesAtFrames]); const extractAmplitude = () => { @@ -393,31 +407,36 @@ export const TimeSeriesUI = (props: TimeSeriesUIProps) => { }, [audioBuffer, pitchMethod, selectionEndMs, selectionStartMs, updateChartData]); const timeSeriesList = useMemo(() => allTimeSeries.map(({ ts, alias }, idx) => <> - - - { - allTimeSeries[idx].alias = e.target.value.trim(); - setAllTimeSeries([...allTimeSeries]); - onChange(allTimeSeries); - }} - onBlur={(e: any) => afterBlur(e)} - onFocus={(e: any) => afterFocus(e)} - helperText={allTimeSeries.find((t, i) => i !== idx && t.alias === allTimeSeries[idx].alias) ? "⚠️ duplicate name" : ""} - /> - - - y)} height={25}> - - - - - - - - ), [allTimeSeries, handleDeleteTimeSeries, onChange, afterBlur, afterFocus]); + + { + allTimeSeries[idx].alias = e.target.value.trim(); + setAllTimeSeries([...allTimeSeries]); + onChange(allTimeSeries); + }} + style={{ width: "9em" }} + onBlur={(e: any) => afterBlur(e)} + onFocus={(e: any) => afterFocus(e)} + helperText={allTimeSeries.find((t, i) => i !== idx && t.alias === allTimeSeries[idx].alias) ? "⚠️ duplicate name" : ""} + /> + y)} height={8}> + + + + + ), [allTimeSeries, handleDeleteTimeSeries, onChange, afterBlur, afterFocus, palette]); const waveSuferWaveform = useMemo(() => audioBuffer && { onClick={ //@ts-ignore e => e.target.value = null // Ensures onChange fires even if same file is re-selected. - } + } onChange={handleLoadFromAudio} /> {audioBuffer && <> - - Filter: - + Filter: + { lowpass highpass bandpass - + { - setPitchMethod(e.target.value as PitchMethod)} - select - > - default - schmitt - fcomb - mcomb - specacf - yin - yinfft - + setPitchMethod(e.target.value as PitchMethod)} + select + > + default + schmitt + fcomb + mcomb + specacf + yin + yinfft + @@ -640,6 +659,9 @@ export const TimeSeriesUI = (props: TimeSeriesUIProps) => { spanGaps: true, aspectRatio: 4, responsive: true, + backgroundColor: palette.graphBackground.main, + borderColor: palette.graphBorder.main, + color: palette.graphFont.main, animation: { duration: 175, delay: 0 @@ -663,6 +685,9 @@ export const TimeSeriesUI = (props: TimeSeriesUIProps) => { scales: { x: { type: 'linear', + min: 0, + //@ts-ignore + max: showValuesAtFrames ? lastFrame : (chartData.datasets[0].data.at(-1)?.x) ?? lastFrame, title: { display: true, text: (showValuesAtFrames || (rawTimeSeries?.timestampType === TimestampType.Frame)) ? "frame" : "ms" @@ -670,10 +695,11 @@ export const TimeSeriesUI = (props: TimeSeriesUIProps) => { ticks: { minRotation: 0, maxRotation: 0, + color: palette.graphFont.main, + }, + grid: { + color: palette.graphBorder.main, }, - min: 0, - //@ts-ignore - max: showValuesAtFrames ? lastFrame : (chartData.datasets[0].data.at(-1)?.x) ?? lastFrame, }, raw: { type: 'linear', @@ -685,7 +711,11 @@ export const TimeSeriesUI = (props: TimeSeriesUIProps) => { ticks: { minRotation: 0, maxRotation: 0, + color: palette.graphFont.main, }, + grid: { + color: palette.graphBorder.main, + } }, processed: { type: 'linear', @@ -693,13 +723,16 @@ export const TimeSeriesUI = (props: TimeSeriesUIProps) => { title: { text: 'processed', display: true, - color: 'red', + color: palette.warning.dark, }, ticks: { - color: 'red', + color: palette.warning.dark, minRotation: 0, maxRotation: 0, }, + grid: { + color: 'rgba(255,0,0,0.15)', + } }, }, }} @@ -714,7 +747,7 @@ export const TimeSeriesUI = (props: TimeSeriesUIProps) => { }} size='small' /> } label={Only show values at frame positions} /> - {status} + {status} diff --git a/src/components/WavesurferWaveform.tsx b/src/components/WavesurferWaveform.tsx index a7e2eb9..8d2a113 100644 --- a/src/components/WavesurferWaveform.tsx +++ b/src/components/WavesurferWaveform.tsx @@ -8,6 +8,10 @@ import TimelinePlugin from 'wavesurfer.js/dist/plugin/wavesurfer.timeline.min.js //@ts-ignore import SpectrogramPlugin from "wavesurfer.js/dist/plugin/wavesurfer.spectrogram.min"; import colormap from '../data/hot-colormap.json'; +import { CssVarsPalette, Palette, SupportedColorScheme, experimental_extendTheme as extendTheme, useColorScheme } from "@mui/material/styles"; +import { themeFactory } from "../theme"; +import { channelToRgba } from '../utils/utils'; +import {useHotkeys} from 'react-hotkeys-hook'; interface WavesurferAudioWaveformProps { audioBuffer: AudioBuffer; @@ -23,16 +27,33 @@ const WavesurferAudioWaveform = ({ audioBuffer, initialSelection, onSelectionCha const [playbackStart, setPlaybackStart] = useState(initialSelection.start); const [isPlaying, setIsPlaying] = useState(false); + const theme = extendTheme(themeFactory()); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const {colorScheme, setColorScheme } = useColorScheme(); + const palette = theme.colorSchemes[(colorScheme||'light') as SupportedColorScheme].palette; + const [prevAudioBuffer, setPrevAudioBuffer] = useState(); + const [prevPalette, setPrevPalette] = useState(); useEffect(() => { - if (waveSurferRef.current) { - waveSurferRef.current.destroy(); - waveSurferRef.current = null; + + // Recreate wavesurfer iff the audio buffer or color scheme has changed + if (audioBuffer !== prevAudioBuffer || palette !== prevPalette) { + if (waveSurferRef.current) { + waveSurferRef.current.destroy(); + waveSurferRef.current = null; + } + setPrevAudioBuffer(audioBuffer); + setPrevPalette(palette); } - if (waveformRef.current) { + if (waveformRef.current && !waveSurferRef.current) { const wavesurfer = WaveSurfer.create({ + cursorColor: palette.success.light, + cursorWidth: 3, container: waveformRef.current, + //@ts-ignore - type definition is wrong? + waveColor: [palette.waveformStart.main, palette.waveformEnd.main], + progressColorColor: [palette.waveformProgressMaskStart.main, palette.waveformProgressMaskEnd.main], plugins: [ RegionsPlugin.create({ maxRegions: 1, @@ -42,6 +63,11 @@ const WavesurferAudioWaveform = ({ audioBuffer, initialSelection, onSelectionCha }), TimelinePlugin.create({ container: '#timeline_dialog', + unlabeledNotchColor: palette.graphBorder.main, + primaryColor: palette.graphBorder.dark, + secondaryColor: palette.graphBorder.light, + primaryFontColor: palette.graphFont.main, + secondaryFontColor: palette.graphFont.light, }), SpectrogramPlugin.create({ container: "#spectrogram_dialog", @@ -50,7 +76,8 @@ const WavesurferAudioWaveform = ({ audioBuffer, initialSelection, onSelectionCha colorMap: colormap }), ], - normalize: true + normalize: true, + }); waveSurferRef.current = wavesurfer; @@ -64,7 +91,7 @@ const WavesurferAudioWaveform = ({ audioBuffer, initialSelection, onSelectionCha loop: false, drag: true, resize: false, - color: 'hsla(200, 50%, 70%, 0.2)', + color: channelToRgba(palette.primary.mainChannel, 0.3), }) setIsPlaying(false); } @@ -86,24 +113,48 @@ const WavesurferAudioWaveform = ({ audioBuffer, initialSelection, onSelectionCha }); wavesurfer.loadDecodedBuffer(audioBuffer); + } - }, [audioBuffer, initialSelection, onSelectionChange]); + }, [audioBuffer, initialSelection, onSelectionChange, prevPalette, palette, prevAudioBuffer]); + + + + function playPause(from : number = -1, pauseIfPlaying = true) { + if (isPlaying && pauseIfPlaying) { + waveSurferRef.current?.pause(); + } else { + if (from>=0) { + waveSurferRef.current?.setCurrentTime(from); + } if (!isPlaying) { + waveSurferRef.current?.play(); + } + } + setIsPlaying(waveSurferRef.current?.isPlaying() ?? false ); + } + + useHotkeys('space', + () => playPause(), + {preventDefault:true, scopes:['timeseries']}, + [playPause]); + + useHotkeys('shift+space', + () => playPause(playbackStart??0, false), + {preventDefault:true, scopes:['timeseries']}, + [playPause, playbackStart]); + + useHotkeys('ctrl+space', + () => playPause(0, false), + {preventDefault:true, scopes:['timeseries']}, + [playPause]); return (
-
diff --git a/src/theme.ts b/src/theme.ts index 6e5c0b4..f7e3a61 100644 --- a/src/theme.ts +++ b/src/theme.ts @@ -14,6 +14,10 @@ declare module "@mui/material/styles" { gridColSeparatorMajor: PaletteColor; gridColSeparatorMinor: PaletteColor; codeBackground: PaletteColor; + waveformStart: PaletteColor; + waveformEnd: PaletteColor; + waveformProgressMaskStart: PaletteColor; + waveformProgressMaskEnd: PaletteColor; } interface PaletteOptions { negative: PaletteColor; @@ -28,6 +32,11 @@ declare module "@mui/material/styles" { gridColSeparatorMajor: PaletteColor; gridColSeparatorMinor: PaletteColor; codeBackground: PaletteColor; + waveformStart: PaletteColor; + waveformEnd: PaletteColor; + waveformProgressMaskStart: PaletteColor; + waveformProgressMaskEnd: PaletteColor; + } } @@ -50,6 +59,11 @@ export const themeFactory = (): CssVarsThemeOptions => { gridColSeparatorMajor: palette.augmentColor({ color: { main: '#000' } }), gridColSeparatorMinor: palette.augmentColor({ color: { main: '#ccc' } }), codeBackground: palette.augmentColor({ color: { main: '#ccc' } }), + waveformStart: palette.augmentColor({ color: { main: '#000' } }), + waveformEnd: palette.augmentColor({ color: { main: '#aaa' } }), + waveformProgressMaskStart: palette.augmentColor({ color: { main: '#aaa' } }), + waveformProgressMaskEnd: palette.augmentColor({ color: { main: '#ccc' } }), + } }, dark: { @@ -67,6 +81,10 @@ export const themeFactory = (): CssVarsThemeOptions => { gridColSeparatorMajor: palette.augmentColor({ color: { main: '#fff' } }), gridColSeparatorMinor: palette.augmentColor({ color: { main: '#555' } }), codeBackground: palette.augmentColor({ color: { main: '#666' } }), + waveformStart: palette.augmentColor({ color: { main: '#fff' } }), + waveformEnd: palette.augmentColor({ color: { main: '#aaa' } }), + waveformProgressMaskStart: palette.augmentColor({ color: { main: '#bbb' } }), + waveformProgressMaskEnd: palette.augmentColor({ color: { main: '#555' } }), } } } diff --git a/src/utils/utils.ts b/src/utils/utils.ts index 65d3dd3..b6e3b02 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -154,3 +154,8 @@ export function deleteQsParams(qsParamsToDelete: string[]) { qsParamsToDelete.forEach(p => url.searchParams.delete(p)); window.history.replaceState({}, '', url); } + +export const channelToRgba = (channel: string, alpha: number): string => { + //return `rgba(${channel.replaceAll(' ', ',')}, ${alpha})` + return `rgba(${channel} / ${alpha})` +} \ No newline at end of file