Skip to content

Commit

Permalink
add a waveform to the AudioPlayer component (#12670)
Browse files Browse the repository at this point in the history
  • Loading branch information
sndrs authored Oct 24, 2024
1 parent 45aa8ab commit 503756b
Show file tree
Hide file tree
Showing 3 changed files with 206 additions and 25 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,7 @@ export const AudioPlayer = ({
canJumpToPoint={Boolean(audioRef.current?.duration)}
buffer={buffer}
progress={progress}
src={src}
onMouseDown={jumpToPoint}
onMouseUp={stopScrubbing}
onMouseMove={scrub}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { css } from '@emotion/react';
import { from, palette } from '@guardian/source/foundations';
import { WaveForm } from './WaveForm';

const cursorWidth = '4px';

Expand All @@ -16,7 +17,6 @@ const Cursor = ({
css={css`
width: 100%;
height: 100%;
background-color: ${palette.neutral[60]};
transition: transform 10ms ease-out;
position: relative;
cursor: ${isScrubbing
Expand All @@ -26,13 +26,14 @@ const Cursor = ({
: 'default'};
/* this is the yellow '|' cursor */
border-right: ${cursorWidth} solid ${palette.brandAlt[400]};
border-left: ${cursorWidth} solid ${palette.brandAlt[400]};
::after {
/* a wider 'grabbable' area */
::before {
content: '';
position: absolute;
top: 0;
right: -8px;
left: -8px;
width: 12px;
height: 100%;
cursor: ${isScrubbing
Expand All @@ -43,39 +44,22 @@ const Cursor = ({
}
`}
style={{
transform: `translateX(clamp(-100% + ${cursorWidth}, ${
-100 + progress
}%, 0%))`,
}}
></div>
);

const Buffer = ({ buffer = 0 }: { buffer: number }) => (
<div
css={css`
position: absolute;
top: 0;
left: 4px;
width: 100%;
height: 100%;
background-color: ${palette.neutral[46]};
transition: transform 300ms ease-in;
`}
style={{
transform: `translateX(${-100 + buffer}%)`,
transform: `translateX(clamp(0%, ${progress}%, 100% - ${cursorWidth}))`,
}}
></div>
);

export const ProgressBar = ({
progress,
buffer,
src,
isScrubbing,
canJumpToPoint,
...props
}: React.ComponentPropsWithoutRef<'div'> & {
isScrubbing: boolean;
canJumpToPoint: boolean;
src: string;
buffer: number;
progress: number;
}) => {
Expand All @@ -100,7 +84,23 @@ export const ProgressBar = ({
`}
{...props}
>
<Buffer buffer={buffer} />
<WaveForm
bars={175}
src={src}
progress={progress}
buffer={buffer}
theme={{
progress: palette.neutral[100],
buffer: palette.neutral[60],
wave: palette.neutral[46],
}}
css={css`
position: absolute;
height: 100%;
width: 100%;
`}
/>

<Cursor
isScrubbing={isScrubbing}
canJumpToPoint={canJumpToPoint}
Expand Down
180 changes: 180 additions & 0 deletions dotcom-rendering/src/components/AudioPlayer/components/WaveForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
import { useId, useMemo } from 'react';

const sumArray = (array: number[]) => array.reduce((a, b) => a + b, 0);

/**
* Pseudo random number generator generator ([linear congruential
* generator](https://en.wikipedia.org/wiki/Linear_congruential_generator)).
*
* I'll be honest, I don't fully understand it, but it creates a pseudo random
* number generator based on a seed, in this case an array of numbers.
*
* It's deterministic, so calls to the function it returns will always return
* the same results.
*
* Copilot helped me with it...
*/
const getSeededRandomNumberGenerator = (array: number[]) => {
const modulus = 2147483648;
const seed = sumArray(array) % modulus;
const multiplier = 1103515245;
const increment = 12345;

let state = seed;

return function () {
state = (multiplier * state + increment) % modulus;
return state / modulus;
};
};

function shuffle(array: number[]) {
// Create a random number generator that's seeded with array.
const getSeededRandomNumber = getSeededRandomNumberGenerator(array);

// Sort the array using the seeded random number generator. This means that
// the same array will always be sorted in the same (pseudo random) way.
return array.sort(() => getSeededRandomNumber() - getSeededRandomNumber());
}

// normalize the amplitude of the fake audio data
const normalizeAmplitude = (data: number[]) => {
const multiplier = Math.pow(Math.max(...data), -1);
return data.map((n) => n * multiplier * 100);
};

/** Returns a string of the specified length, repeating the input string as necessary. */
function padString(str: string, length: number) {
// Repeat the string until it is longer than the desired length
const result = str.repeat(Math.ceil(length / str.length));

// Return the truncated result to the specified length
return result.slice(0, length);
}

// Generate an array of fake audio peaks based on the URL
function generateWaveform(url: string, bars: number) {
// convert the URL to a base64 string
const base64 = btoa(url);

// Pad the base64 string to the number of bars we want
const stringOfBarLength = padString(base64, bars);

// Convert the string to an array of char codes (fake audio data)
const valuesFromString = Array.from(stringOfBarLength).map((_, i) =>
stringOfBarLength.charCodeAt(i),
);

// Shuffle (sort) the fake audio data using a deterministic algorithm. This
// means the same URL will always produce the same waveform, but the
// waveforms of two similar URLs (e.g. guardian podcast URLs) won't _look_
// all that similar.
const shuffled = shuffle(valuesFromString);

// Normalize the amplitude of the fake audio data
const normalized = normalizeAmplitude(shuffled);

// Return the normalized the amplitude of the fake audio data
return normalized;
}

type Theme = {
progress?: string;
buffer?: string;
wave?: string;
};

const defaultTheme: Theme = {
progress: 'green',
buffer: 'orange',
wave: 'grey',
};

type Props = {
src: string;
progress: number;
buffer: number;
theme?: Theme;
gap?: number;
bars?: number;
barWidth?: number;
} & React.SVGProps<SVGSVGElement>;

export const WaveForm = ({
src,
progress,
buffer,
theme: userTheme,
gap = 1,
bars = 150,
barWidth = 4,
...props
}: Props) => {
// memoise the waveform data so they aren't recalculated on every render
const barHeights = useMemo(() => generateWaveform(src, bars), [src, bars]);
const totalWidth = useMemo(
() => bars * (barWidth + gap) - gap,
[bars, barWidth, gap],
);
const theme = useMemo(
() => ({ ...defaultTheme, ...userTheme }),
[userTheme],
);

// needed in case we have multiple waveforms on the same page
const id = useId();

return (
<svg
viewBox={`0 0 ${totalWidth} 100`}
preserveAspectRatio="none"
width={totalWidth}
height={100}
xmlns="http://www.w3.org/2000/svg"
{...props}
>
{/* the base bars we'll use to create the variants we need below */}
<defs>
<g id={`bars-${id}`}>
{barHeights.map((barHeight, index) => {
const x = index * (barWidth + gap);
return (
<rect
key={x}
x={x}
y={100 - barHeight} // place it on the bottom
width={barWidth}
height={barHeight}
/>
);
})}
</g>

<clipPath id="buffer-clip-path">
<rect height="100" width={(buffer / 100) * totalWidth} />
</clipPath>

<clipPath id="progress-clip-path">
<rect height="100" width={(progress / 100) * totalWidth} />
</clipPath>
</defs>

{/* default wave colours */}
<use href={`#bars-${id}`} fill={theme.wave} />

{/* buffer wave */}
<use
href={`#bars-${id}`}
clipPath="url(#buffer-clip-path)"
fill={theme.buffer}
/>

{/* progress wave */}
<use
href={`#bars-${id}`}
clipPath="url(#progress-clip-path)"
fill={theme.progress}
/>
</svg>
);
};

0 comments on commit 503756b

Please sign in to comment.