Skip to content

Commit acb3d2b

Browse files
committed
add wavform to the AudioPlayer component
1 parent 40168c4 commit acb3d2b

File tree

3 files changed

+206
-25
lines changed

3 files changed

+206
-25
lines changed

dotcom-rendering/src/components/AudioPlayer/AudioPlayer.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,6 +301,7 @@ export const AudioPlayer = ({
301301
canJumpToPoint={Boolean(audioRef.current?.duration)}
302302
buffer={buffer}
303303
progress={progress}
304+
src={src}
304305
onMouseDown={jumpToPoint}
305306
onMouseUp={stopScrubbing}
306307
onMouseMove={scrub}

dotcom-rendering/src/components/AudioPlayer/components/ProgressBar.tsx

Lines changed: 25 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { css } from '@emotion/react';
22
import { from, palette } from '@guardian/source/foundations';
3+
import { WaveForm } from './WaveForm';
34

45
const cursorWidth = '4px';
56

@@ -16,7 +17,6 @@ const Cursor = ({
1617
css={css`
1718
width: 100%;
1819
height: 100%;
19-
background-color: ${palette.neutral[60]};
2020
transition: transform 10ms ease-out;
2121
position: relative;
2222
cursor: ${isScrubbing
@@ -26,13 +26,14 @@ const Cursor = ({
2626
: 'default'};
2727
2828
/* this is the yellow '|' cursor */
29-
border-right: ${cursorWidth} solid ${palette.brandAlt[400]};
29+
border-left: ${cursorWidth} solid ${palette.brandAlt[400]};
3030
31-
::after {
31+
/* a wider 'grabbable' area */
32+
::before {
3233
content: '';
3334
position: absolute;
3435
top: 0;
35-
right: -8px;
36+
left: -8px;
3637
width: 12px;
3738
height: 100%;
3839
cursor: ${isScrubbing
@@ -43,39 +44,22 @@ const Cursor = ({
4344
}
4445
`}
4546
style={{
46-
transform: `translateX(clamp(-100% + ${cursorWidth}, ${
47-
-100 + progress
48-
}%, 0%))`,
49-
}}
50-
></div>
51-
);
52-
53-
const Buffer = ({ buffer = 0 }: { buffer: number }) => (
54-
<div
55-
css={css`
56-
position: absolute;
57-
top: 0;
58-
left: 4px;
59-
width: 100%;
60-
height: 100%;
61-
background-color: ${palette.neutral[46]};
62-
transition: transform 300ms ease-in;
63-
`}
64-
style={{
65-
transform: `translateX(${-100 + buffer}%)`,
47+
transform: `translateX(clamp(0%, ${progress}%, 100% - ${cursorWidth}))`,
6648
}}
6749
></div>
6850
);
6951

7052
export const ProgressBar = ({
7153
progress,
7254
buffer,
55+
src,
7356
isScrubbing,
7457
canJumpToPoint,
7558
...props
7659
}: React.ComponentPropsWithoutRef<'div'> & {
7760
isScrubbing: boolean;
7861
canJumpToPoint: boolean;
62+
src: string;
7963
buffer: number;
8064
progress: number;
8165
}) => {
@@ -100,7 +84,23 @@ export const ProgressBar = ({
10084
`}
10185
{...props}
10286
>
103-
<Buffer buffer={buffer} />
87+
<WaveForm
88+
bars={175}
89+
src={src}
90+
progress={progress}
91+
buffer={buffer}
92+
theme={{
93+
progress: palette.neutral[100],
94+
buffer: palette.neutral[60],
95+
wave: palette.neutral[46],
96+
}}
97+
css={css`
98+
position: absolute;
99+
height: 100%;
100+
width: 100%;
101+
`}
102+
/>
103+
104104
<Cursor
105105
isScrubbing={isScrubbing}
106106
canJumpToPoint={canJumpToPoint}
Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
import { useId, useMemo } from 'react';
2+
3+
const sumArray = (array: number[]) => array.reduce((a, b) => a + b, 0);
4+
5+
/**
6+
* Pseudo random number generator generator ([linear congruential
7+
* generator](https://en.wikipedia.org/wiki/Linear_congruential_generator)).
8+
*
9+
* I'll be honest, I don't fully understand it, but it creates a pseudo random
10+
* number generator based on a seed, in this case an array of numbers.
11+
*
12+
* It's deterministic, so calls to the function it returns will always return
13+
* the same results.
14+
*
15+
* Copilot helped me with it...
16+
*/
17+
const getSeededRandomNumberGenerator = (array: number[]) => {
18+
const modulus = 2147483648;
19+
const seed = sumArray(array) % modulus;
20+
const multiplier = 1103515245;
21+
const increment = 12345;
22+
23+
let state = seed;
24+
25+
return function () {
26+
state = (multiplier * state + increment) % modulus;
27+
return state / modulus;
28+
};
29+
};
30+
31+
function shuffle(array: number[]) {
32+
// Create a random number generator that's seeded with array.
33+
const getSeededRandomNumber = getSeededRandomNumberGenerator(array);
34+
35+
// Sort the array using the seeded random number generator. This means that
36+
// the same array will always be sorted in the same (pseudo random) way.
37+
return array.sort(() => getSeededRandomNumber() - getSeededRandomNumber());
38+
}
39+
40+
// normalize the amplitude of the fake audio data
41+
const normalizeAmplitude = (data: number[]) => {
42+
const multiplier = Math.pow(Math.max(...data), -1);
43+
return data.map((n) => n * multiplier * 100);
44+
};
45+
46+
/** Returns a string of the specified length, repeating the input string as necessary. */
47+
function padString(str: string, length: number) {
48+
// Repeat the string until it is longer than the desired length
49+
const result = str.repeat(Math.ceil(length / str.length));
50+
51+
// Return the truncated result to the specified length
52+
return result.slice(0, length);
53+
}
54+
55+
// Generate an array of fake audio peaks based on the URL
56+
function generateWaveform(url: string, bars: number) {
57+
// convert the URL to a base64 string
58+
const base64 = btoa(url);
59+
60+
// Pad the base64 string to the number of bars we want
61+
const stringOfBarLength = padString(base64, bars);
62+
63+
// Convert the string to an array of char codes (fake audio data)
64+
const valuesFromString = Array.from(stringOfBarLength).map((_, i) =>
65+
stringOfBarLength.charCodeAt(i),
66+
);
67+
68+
// Shuffle (sort) the fake audio data using a deterministic algorithm. This
69+
// means the same URL will always produce the same waveform, but the
70+
// waveforms of two similar URLs (e.g. guardian podcast URLs) won't _look_
71+
// all that similar.
72+
const shuffled = shuffle(valuesFromString);
73+
74+
// Normalize the amplitude of the fake audio data
75+
const normalized = normalizeAmplitude(shuffled);
76+
77+
// Return the normalized the amplitude of the fake audio data
78+
return normalized;
79+
}
80+
81+
type Theme = {
82+
progress?: string;
83+
buffer?: string;
84+
wave?: string;
85+
};
86+
87+
const defaultTheme: Theme = {
88+
progress: 'green',
89+
buffer: 'orange',
90+
wave: 'grey',
91+
};
92+
93+
type Props = {
94+
src: string;
95+
progress: number;
96+
buffer: number;
97+
theme?: Theme;
98+
gap?: number;
99+
bars?: number;
100+
barWidth?: number;
101+
} & React.SVGProps<SVGSVGElement>;
102+
103+
export const WaveForm = ({
104+
src,
105+
progress,
106+
buffer,
107+
theme: userTheme,
108+
gap = 1,
109+
bars = 150,
110+
barWidth = 4,
111+
...props
112+
}: Props) => {
113+
// memoise the waveform data so they aren't recalculated on every render
114+
const barHeights = useMemo(() => generateWaveform(src, bars), [src, bars]);
115+
const totalWidth = useMemo(
116+
() => bars * (barWidth + gap) - gap,
117+
[bars, barWidth, gap],
118+
);
119+
const theme = useMemo(
120+
() => ({ ...defaultTheme, ...userTheme }),
121+
[userTheme],
122+
);
123+
124+
// needed in case we have multiple waveforms on the same page
125+
const id = useId();
126+
127+
return (
128+
<svg
129+
viewBox={`0 0 ${totalWidth} 100`}
130+
preserveAspectRatio="none"
131+
width={totalWidth}
132+
height={100}
133+
xmlns="http://www.w3.org/2000/svg"
134+
{...props}
135+
>
136+
{/* the base bars we'll use to create the variants we need below */}
137+
<defs>
138+
<g id={`bars-${id}`}>
139+
{barHeights.map((barHeight, index) => {
140+
const x = index * (barWidth + gap);
141+
return (
142+
<rect
143+
key={x}
144+
x={x}
145+
y={100 - barHeight} // place it on the bottom
146+
width={barWidth}
147+
height={barHeight}
148+
/>
149+
);
150+
})}
151+
</g>
152+
153+
<clipPath id="buffer-clip-path">
154+
<rect height="100" width={(buffer / 100) * totalWidth} />
155+
</clipPath>
156+
157+
<clipPath id="progress-clip-path">
158+
<rect height="100" width={(progress / 100) * totalWidth} />
159+
</clipPath>
160+
</defs>
161+
162+
{/* default wave colours */}
163+
<use href={`#bars-${id}`} fill={theme.wave} />
164+
165+
{/* buffer wave */}
166+
<use
167+
href={`#bars-${id}`}
168+
clipPath="url(#buffer-clip-path)"
169+
fill={theme.buffer}
170+
/>
171+
172+
{/* progress wave */}
173+
<use
174+
href={`#bars-${id}`}
175+
clipPath="url(#progress-clip-path)"
176+
fill={theme.progress}
177+
/>
178+
</svg>
179+
);
180+
};

0 commit comments

Comments
 (0)