Skip to content

Commit

Permalink
add .wav plugin
Browse files Browse the repository at this point in the history
  • Loading branch information
magland committed Feb 17, 2025
1 parent fc61d63 commit 8bda4d9
Show file tree
Hide file tree
Showing 7 changed files with 396 additions and 6 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
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
15 changes: 9 additions & 6 deletions src/pages/common/DatasetWorkspace/DatasetWorkspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
} from "./datasetWorkspaceTabsReducer";
import DatasetMainTab from "./DatasetMainTab";
import TabToolbar, { TOOLBAR_HEIGHT } from "./TabToolbar";
import { ProvideTimeseriesSelection } from "@shared/context-timeseries-selection-2";

// Initialize plugins
initializePlugins();
Expand Down Expand Up @@ -148,12 +149,14 @@ const DatasetWorkspace: FunctionComponent<DatasetWorkspaceProps> = ({
height={pluginContentHeight}
top={toolbarHeight}
>
{/* The plugin may or may not use the width/height */}
<Plugin
file={tab.file}
width={width - 20}
height={pluginContentHeight}
/>
<ProvideTimeseriesSelection>
{/* The plugin may or may not use the width/height */}
<Plugin
file={tab.file}
width={width - 20}
height={pluginContentHeight}
/>
</ProvideTimeseriesSelection>
</ScrollY>
</div>
);
Expand Down
2 changes: 2 additions & 0 deletions src/pages/common/DatasetWorkspace/plugins/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import textPlugin from "./text";
import edfPlugin from "./edf";
import niftiPlugin from "./nifti";
import tsvPlugin from "./tsv";
import wavPlugin from "./wav";

// Register plugins in order of priority
export const initializePlugins = () => {
Expand All @@ -13,5 +14,6 @@ export const initializePlugins = () => {
registerPlugin(jsonPlugin);
registerPlugin(edfPlugin);
registerPlugin(niftiPlugin);
registerPlugin(wavPlugin); // Add WAV plugin before default
registerPlugin(defaultPlugin);
};
109 changes: 109 additions & 0 deletions src/pages/common/DatasetWorkspace/plugins/wav/AudioPlayer.tsx
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 src/pages/common/DatasetWorkspace/plugins/wav/WavFileView.tsx
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;
Loading

0 comments on commit 8bda4d9

Please sign in to comment.