Skip to content

Commit

Permalink
Added: Make the Histogram component display the actual audio instea…
Browse files Browse the repository at this point in the history
…d of showing random bar heights (#1297)

* feat: Make `Histogram` display the actual audio instead of showing random bar heights

* fix(test): Fix & update Histogram tests after making it dependent on actual audio frequency data

* fix: Set histogram end state to array of zeros

* feat: Refactor Playback component and update stories to TypeScript and use correct props

* chore: Add (creative commons) music audio file to test the histogram component

* refactor: Remove the lower end of the histogram frequency data to avoid it overcrowding the histogram

* fix: Fix test through improved fake timer configuration

* chore: Update testing library dependencies in package.json

* chore: Update vitest coverage dependencies to latest versions

* chore: Remove babel-plugin-named-exports-order dependency from project
  • Loading branch information
drikusroor authored Nov 12, 2024
1 parent c843f94 commit e5c886c
Show file tree
Hide file tree
Showing 10 changed files with 812 additions and 1,059 deletions.
793 changes: 302 additions & 491 deletions frontend/.pnp.cjs

Large diffs are not rendered by default.

7 changes: 3 additions & 4 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -86,10 +86,9 @@
"@types/react-dom": "^18.3.0",
"@types/react-helmet": "^6",
"@types/react-router-dom": "^5.3.3",
"@vitest/coverage-istanbul": "^1.4.0",
"@vitest/coverage-v8": "^1.4.0",
"@vitest/coverage-istanbul": "^2.1.4",
"@vitest/coverage-v8": "^2.1.4",
"axios-mock-adapter": "^1.22.0",
"babel-plugin-named-exports-order": "0.0.2",
"coverage-badges-cli": "^1.2.5",
"eslint": "^8.54.0",
"eslint-config-react-app": "^7.0.1",
Expand All @@ -99,6 +98,6 @@
"history": "^5.3.0",
"prop-types": "15.8.1",
"storybook": "^8.4.2",
"vitest": "^1.4.0"
"vitest": "^2.1.4"
}
}
78 changes: 65 additions & 13 deletions frontend/src/components/Histogram/Histogram.test.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,38 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, act, waitFor } from '@testing-library/react';
import Histogram from './Histogram';

// Mock requestAnimationFrame and cancelAnimationFrame
vi.mock('global', () => ({
requestAnimationFrame: (callback: FrameRequestCallback): number => {
return setTimeout(callback, 0);
},
cancelAnimationFrame: (handle: number): void => {
clearTimeout(handle);
}
}));

describe('Histogram', () => {

let mockAnalyser: {
getByteFrequencyData: vi.Mock;
};

beforeEach(() => {
vi.useFakeTimers();
vi.useFakeTimers({ toFake: ['requestAnimationFrame'] });

// Mock the Web Audio API
mockAnalyser = {
getByteFrequencyData: vi.fn(),
};

(global as any).window.audioContext = {};
(global as any).window.analyzer = mockAnalyser;
});

afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});

it('renders the correct number of bars', () => {
Expand Down Expand Up @@ -69,37 +97,61 @@ describe('Histogram', () => {
expect(histogram.classList.contains('active')).toBe(false);
});

it('updates bar heights when running', async () => {
const { container, rerender } = render(<Histogram running={true} />);
it('updates bar heights based on frequency data when running', async () => {
const bars = 5;
mockAnalyser.getByteFrequencyData.mockImplementation((array) => {
for (let i = 0; i < array.length; i++) {
array[i] = Math.floor(Math.random() * 256);
}
});

const { container, rerender } = render(<Histogram running={true} bars={bars} />);

const getHeights = () => Array.from(container.querySelectorAll('.aha__histogram > div')).map(
(bar) => bar.style.height
);

const initialHeights = getHeights();

// Advance timer and force re-render
vi.advanceTimersByTime(100);
rerender(<Histogram running={true} />);
// Advance timers and trigger animation frame
await act(async () => {
vi.advanceTimersByTime(100);
});

rerender(<Histogram running={true} bars={bars} />);

const updatedHeights = getHeights();

expect(initialHeights).not.to.deep.equal(updatedHeights);
expect(mockAnalyser.getByteFrequencyData).toHaveBeenCalled();
});

it('does not update bar heights when not running', () => {
const { container, rerender } = render(<Histogram running={false} />);
it('does not update bar heights when not running', async () => {
const bars = 5;
mockAnalyser.getByteFrequencyData.mockImplementation((array) => {
for (let i = 0; i < array.length; i++) {
array[i] = Math.floor(Math.random() * 256);
}
});

const { container, rerender } = render(<Histogram running={false} bars={bars} />);

const getHeights = () => Array.from(container.querySelectorAll('.aha__histogram > div')).map(
(bar) => bar.style.height
);

const initialHeights = getHeights();

// Advance timer and force re-render
vi.advanceTimersByTime(100);
rerender(<Histogram running={false} />);
// Advance timers and trigger animation frame
await waitFor(async () => {
vi.advanceTimersToNextFrame();
});

rerender(<Histogram running={false} bars={bars} />);

const updatedHeights = getHeights();

expect(initialHeights).to.deep.equal(updatedHeights);
expect(mockAnalyser.getByteFrequencyData).not.toHaveBeenCalled();
});
});
89 changes: 59 additions & 30 deletions frontend/src/components/Histogram/Histogram.tsx
Original file line number Diff line number Diff line change
@@ -1,59 +1,88 @@
import { useEffect, useState } from "react";
import classNames from "classnames";
import React, { useEffect, useRef, useState } from 'react';
import classNames from 'classnames';

interface HistogramProps {
bars?: number;
spacing?: number;
interval?: number;
running?: boolean;
marginLeft?: number;
marginTop?: number;
backgroundColor?: string;
borderRadius?: string;
}

/** Histogram with random bar movement for decoration */
const Histogram = ({
bars = 7,
spacing = 6,
interval = 100,
const Histogram: React.FC<HistogramProps> = ({
bars = 8,
spacing = 4,
running = true,
marginLeft = 0,
marginTop = 0,
backgroundColor = undefined,
borderRadius = '0.15rem',
}: HistogramProps) => {
const [pulse, setPulse] = useState(true);
}) => {
const [frequencyData, setFrequencyData] = useState<Uint8Array>(new Uint8Array(bars));

const requestRef = useRef<number>();

useEffect(() => {
const id = setTimeout(() => {
setPulse(!pulse);
}, interval);
if (!running) {
if (requestRef.current) {
const emptyHistogram = new Uint8Array(bars);
setFrequencyData(emptyHistogram);
cancelAnimationFrame(requestRef.current);
}
return;
}

const updateFrequencyData = () => {
if (window.audioContext && window.analyzer) {
const data = new Uint8Array(bars + 3);
window.analyzer.getByteFrequencyData(data);
// Remove the lower end of the frequency data
const dataWithoutExtremes = data.slice(3, bars + 3);
setFrequencyData(dataWithoutExtremes);
}
requestRef.current = requestAnimationFrame(updateFrequencyData);
};

requestRef.current = requestAnimationFrame(updateFrequencyData);

return () => {
clearTimeout(id);
if (requestRef.current) {
cancelAnimationFrame(requestRef.current);
}
};
});
}, [running, bars]);

const _bars = Array.from(Array(bars)).map((_, index) => (
<div
key={index}
style={{
width: '80%',
height: running
? `${Math.random() * 100}%`
: '10%',
marginRight: index < bars - 1 ? spacing : 0,
}}
/>
));
const barWidth = `calc((100% - ${(bars - 1) * spacing}px) / ${bars})`;

return (
<div
className={classNames("aha__histogram", { active: running })}
style={{ height: '100%', marginLeft, marginTop, backgroundColor, width: '100%', borderRadius: borderRadius, border: backgroundColor ? `10px solid ${backgroundColor}` : undefined }}
className={classNames('aha__histogram', { active: running })}
style={{
height: '100%',
marginLeft,
marginTop,
backgroundColor,
width: '100%',
borderRadius,
border: backgroundColor ? `10px solid ${backgroundColor}` : undefined,
display: 'flex',
alignItems: 'flex-start',
}}
>
{_bars}
{Array.from({ length: bars }, (_, index) => (
<div
key={index}
style={{
width: barWidth,
height: `${(frequencyData[index] / 255) * 100}%`,
backgroundColor: 'currentColor',
marginRight: index < bars - 1 ? spacing : 0,
transition: 'height 0.05s ease',
}}
/>
))}
</div>
);
};
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/Playback/Playback.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import MatchingPairs from "../MatchingPairs/MatchingPairs";
import Preload from "../Preload/Preload";
import { AUTOPLAY, BUTTON, IMAGE, MATCHINGPAIRS, MULTIPLAYER, PRELOAD, PlaybackArgs, PlaybackView } from "@/types/Playback";

interface PlaybackProps {
export interface PlaybackProps {
playbackArgs: PlaybackArgs;
onPreloadReady: () => void;
autoAdvance: boolean;
Expand Down
44 changes: 0 additions & 44 deletions frontend/src/stories/Playback.stories.jsx

This file was deleted.

66 changes: 66 additions & 0 deletions frontend/src/stories/Playback.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { useState } from "react";
import Playback, { PlaybackProps } from "../components/Playback/Playback";
import { AUTOPLAY } from "../types/Playback";

import audio from "./assets/music.ogg";

export default {
title: "Playback",
component: Playback,
parameters: {
layout: "fullscreen",
},
};

export const Button = {
args: {
playbackArgs: {
view: AUTOPLAY,
play_method: "BUFFER",
show_animation: true,
preload_message: "Loading audio...",
instruction: "Click the button to play the audio.",
sections: [
{
id: 0,
url: audio,
}
],
play_from: 0.0,
resume_play: false,
},
onPreloadReady: () => { },
autoAdvance: false,
responseTime: 10,
submitResult: () => { },
finishedPlaying: () => { },
} as PlaybackProps,
decorators: [
(Story) => {

const [initialized, setInitialized] = useState(false);


if (!initialized) {
return (
<>
<button onClick={() => {
setInitialized(true);
}
}>
Initialize WebAudio
</button>
</>
)
}

return (
<div
style={{ width: "100%", height: "100%", backgroundColor: "#ddd", padding: "1rem" }}
>
<Story />
</div>
)
}
],
};
Binary file added frontend/src/stories/assets/music.ogg
Binary file not shown.
Loading

0 comments on commit e5c886c

Please sign in to comment.