-
Notifications
You must be signed in to change notification settings - Fork 8
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
7 changed files
with
396 additions
and
6 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,5 +1,6 @@ | ||
# Changes | ||
|
||
## February 17, 2025 | ||
- Added WAV file plugin with audio playback and waveform visualization | ||
- Updated URL query parameter mapping between v1 to v2 | ||
- Adjusted home page layout |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
109 changes: 109 additions & 0 deletions
109
src/pages/common/DatasetWorkspace/plugins/wav/AudioPlayer.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,109 @@ | ||
import { useTimeseriesSelection } from "@shared/context-timeseries-selection-2"; | ||
import { FunctionComponent, useEffect, useRef, useState } from "react"; | ||
import { IconButton } from "@mui/material"; | ||
import PlayArrowIcon from "@mui/icons-material/PlayArrow"; | ||
import PauseIcon from "@mui/icons-material/Pause"; | ||
import SkipPreviousIcon from "@mui/icons-material/SkipPrevious"; | ||
|
||
interface AudioPlayerProps { | ||
audioUrl: string; | ||
duration: number; | ||
height: number; | ||
} | ||
|
||
const AudioPlayer: FunctionComponent<AudioPlayerProps> = ({ | ||
audioUrl, | ||
duration, | ||
height, | ||
}) => { | ||
const { currentTime, setCurrentTime } = useTimeseriesSelection(); | ||
const [isPlaying, setIsPlaying] = useState(false); | ||
const audioRef = useRef<HTMLAudioElement>(null); | ||
|
||
// Sync audio current time when timeseriesSelection changes | ||
useEffect(() => { | ||
if (!audioRef.current || isPlaying || currentTime === undefined) return; | ||
if (Math.abs(audioRef.current.currentTime - currentTime) > 0.01) { | ||
audioRef.current.currentTime = currentTime; | ||
} | ||
}, [currentTime, isPlaying]); | ||
|
||
// Update timeseriesSelection as audio plays | ||
useEffect(() => { | ||
const audio = audioRef.current; | ||
if (!audio) return; | ||
|
||
const handleTimeUpdate = () => { | ||
if (isPlaying) { | ||
setCurrentTime(audio.currentTime); | ||
} | ||
}; | ||
|
||
const handleEnded = () => { | ||
setIsPlaying(false); | ||
}; | ||
|
||
audio.addEventListener("timeupdate", handleTimeUpdate); | ||
audio.addEventListener("ended", handleEnded); | ||
|
||
return () => { | ||
audio.removeEventListener("timeupdate", handleTimeUpdate); | ||
audio.removeEventListener("ended", handleEnded); | ||
}; | ||
}, [isPlaying, setCurrentTime]); | ||
|
||
const handlePlayPause = () => { | ||
if (!audioRef.current) return; | ||
|
||
if (isPlaying) { | ||
audioRef.current.pause(); | ||
} else { | ||
// Set current time before playing if timeline position is defined | ||
if (currentTime !== undefined) { | ||
audioRef.current.currentTime = currentTime; | ||
} | ||
audioRef.current.play(); | ||
} | ||
setIsPlaying(!isPlaying); | ||
}; | ||
|
||
const handleReset = () => { | ||
if (!audioRef.current) return; | ||
audioRef.current.currentTime = 0; | ||
setCurrentTime(0); | ||
if (isPlaying) { | ||
audioRef.current.pause(); | ||
setIsPlaying(false); | ||
} | ||
}; | ||
|
||
return ( | ||
<div | ||
style={{ | ||
height, | ||
display: "flex", | ||
alignItems: "center", | ||
padding: "0 20px", | ||
gap: 10, | ||
backgroundColor: "#f5f5f5", | ||
borderBottom: "1px solid #ddd", | ||
}} | ||
> | ||
<audio ref={audioRef} src={audioUrl} /> | ||
<IconButton onClick={handlePlayPause} size="small" sx={{ color: "#666" }}> | ||
{isPlaying ? <PauseIcon /> : <PlayArrowIcon />} | ||
</IconButton> | ||
<IconButton onClick={handleReset} size="small" sx={{ color: "#666" }}> | ||
<SkipPreviousIcon /> | ||
</IconButton> | ||
<span style={{ fontSize: "12px", color: "#666" }}> | ||
{currentTime !== undefined | ||
? `${Math.floor(currentTime * 10) / 10}s` | ||
: "0.0s"}{" "} | ||
/ {Math.floor(duration * 10) / 10}s | ||
</span> | ||
</div> | ||
); | ||
}; | ||
|
||
export default AudioPlayer; |
176 changes: 176 additions & 0 deletions
176
src/pages/common/DatasetWorkspace/plugins/wav/WavFileView.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,176 @@ | ||
import React, { FunctionComponent, useEffect, useState } from "react"; | ||
import { DatasetPluginProps } from "../pluginInterface"; | ||
import { | ||
useTimeRange, | ||
useTimeseriesSelection, | ||
} from "@shared/context-timeseries-selection-2"; | ||
import TimeScrollView2, { | ||
useTimeScrollView2, | ||
} from "@shared/component-time-scroll-view-2/TimeScrollView2"; | ||
import AudioPlayer from "./AudioPlayer"; | ||
import WaveformCanvas from "./WaveformCanvas"; | ||
|
||
type WaveformData = { | ||
data: Float32Array; | ||
sampleRate: number; | ||
duration: number; | ||
channels: number; | ||
minValue: number; | ||
maxValue: number; | ||
}; | ||
|
||
const WavFileView: FunctionComponent<DatasetPluginProps> = ({ | ||
file, | ||
width = 800, | ||
height = 600, | ||
}) => { | ||
const [waveformData, setWaveformData] = useState<WaveformData | undefined>(); | ||
const [isLoading, setIsLoading] = useState(true); | ||
const [error, setError] = useState<string | undefined>(); | ||
const { setVisibleTimeRange } = useTimeRange(); | ||
const { setCurrentTime } = useTimeseriesSelection(); | ||
const [audioContext] = useState(() => new AudioContext()); | ||
|
||
useEffect(() => { | ||
const loadWaveform = async () => { | ||
try { | ||
setIsLoading(true); | ||
setError(undefined); | ||
|
||
const response = await fetch(file.urls[0]); | ||
const arrayBuffer = await response.arrayBuffer(); | ||
const audioBuffer = await audioContext.decodeAudioData(arrayBuffer); | ||
|
||
// Get the first channel's data | ||
const channelData = audioBuffer.getChannelData(0); | ||
|
||
// Calculate min and max values | ||
let minVal = channelData[0]; | ||
let maxVal = channelData[0]; | ||
for (let i = 1; i < channelData.length; i++) { | ||
if (channelData[i] < minVal) minVal = channelData[i]; | ||
if (channelData[i] > maxVal) maxVal = channelData[i]; | ||
} | ||
|
||
setWaveformData({ | ||
data: channelData, | ||
sampleRate: audioBuffer.sampleRate, | ||
duration: audioBuffer.duration, | ||
channels: audioBuffer.numberOfChannels, | ||
minValue: minVal, | ||
maxValue: maxVal, | ||
}); | ||
|
||
// Set initial visible range to full duration | ||
setVisibleTimeRange(0, audioBuffer.duration); | ||
setCurrentTime(0); | ||
} catch (err) { | ||
setError( | ||
err instanceof Error ? err.message : "Error loading audio file", | ||
); | ||
} finally { | ||
setIsLoading(false); | ||
} | ||
}; | ||
|
||
loadWaveform(); | ||
|
||
return () => { | ||
// Cleanup when component unmounts | ||
if (audioContext.state !== "closed") { | ||
audioContext.close(); | ||
} | ||
}; | ||
}, [file.urls, audioContext, setVisibleTimeRange, setCurrentTime]); | ||
|
||
const hideToolbar = true; | ||
const leftMargin = 100; | ||
|
||
const { canvasWidth, canvasHeight, margins } = useTimeScrollView2({ | ||
width, | ||
height: height - 50, | ||
hideToolbar, | ||
leftMargin, | ||
}); | ||
|
||
const { | ||
initializeTimeseriesSelection, | ||
visibleStartTimeSec, | ||
visibleEndTimeSec, | ||
} = useTimeseriesSelection(); | ||
useEffect(() => { | ||
initializeTimeseriesSelection({ | ||
startTimeSec: 0, | ||
endTimeSec: waveformData?.duration || 1, | ||
initialVisibleStartTimeSec: 0, | ||
initialVisibleEndTimeSec: waveformData?.duration || 1, | ||
}); | ||
}, [waveformData, initializeTimeseriesSelection]); | ||
|
||
const [context, setContext] = useState<CanvasRenderingContext2D | null>(null); | ||
useEffect(() => { | ||
if (!context || !waveformData) return; | ||
// Clear canvas | ||
context.clearRect(0, 0, canvasWidth, canvasHeight); | ||
|
||
// Render waveform | ||
if (visibleStartTimeSec === undefined || visibleEndTimeSec === undefined) | ||
return; | ||
WaveformCanvas({ | ||
context, | ||
waveformData, | ||
width: canvasWidth, | ||
height: canvasHeight, | ||
margins, | ||
visibleStartTimeSec, | ||
visibleEndTimeSec, | ||
}); | ||
}, [ | ||
context, | ||
waveformData, | ||
canvasWidth, | ||
canvasHeight, | ||
margins, | ||
visibleStartTimeSec, | ||
visibleEndTimeSec, | ||
]); | ||
|
||
if (isLoading) { | ||
return <div>Loading audio file...</div>; | ||
} | ||
|
||
if (error || !waveformData) { | ||
return <div>Error: {error || "Failed to load audio file"}</div>; | ||
} | ||
|
||
return ( | ||
<div style={{ width, height, display: "flex", flexDirection: "column" }}> | ||
<AudioPlayer | ||
audioUrl={file.urls[0]} | ||
duration={waveformData.duration} | ||
height={50} | ||
/> | ||
<div style={{ flex: 1 }}> | ||
<TimeScrollView2 | ||
width={width} | ||
height={height - 50} | ||
onCanvasElement={(canvas) => { | ||
if (!canvas) return; | ||
const context = canvas.getContext("2d"); | ||
setContext(context); | ||
}} | ||
hideToolbar={hideToolbar} | ||
leftMargin={leftMargin} | ||
yAxisInfo={{ | ||
showTicks: true, | ||
yMin: waveformData.minValue, | ||
yMax: waveformData.maxValue, | ||
yLabel: "Amplitude", | ||
}} | ||
/> | ||
</div> | ||
</div> | ||
); | ||
}; | ||
|
||
export default WavFileView; |
Oops, something went wrong.