From 503756bd28e9608d264e98285e43463a51495c5e Mon Sep 17 00:00:00 2001 From: Alex Sanders Date: Thu, 24 Oct 2024 17:18:47 +0100 Subject: [PATCH] add a waveform to the `AudioPlayer` component (#12670) --- .../components/AudioPlayer/AudioPlayer.tsx | 1 + .../AudioPlayer/components/ProgressBar.tsx | 50 ++--- .../AudioPlayer/components/WaveForm.tsx | 180 ++++++++++++++++++ 3 files changed, 206 insertions(+), 25 deletions(-) create mode 100644 dotcom-rendering/src/components/AudioPlayer/components/WaveForm.tsx diff --git a/dotcom-rendering/src/components/AudioPlayer/AudioPlayer.tsx b/dotcom-rendering/src/components/AudioPlayer/AudioPlayer.tsx index 0e0127a45c..e0f989f1cf 100644 --- a/dotcom-rendering/src/components/AudioPlayer/AudioPlayer.tsx +++ b/dotcom-rendering/src/components/AudioPlayer/AudioPlayer.tsx @@ -301,6 +301,7 @@ export const AudioPlayer = ({ canJumpToPoint={Boolean(audioRef.current?.duration)} buffer={buffer} progress={progress} + src={src} onMouseDown={jumpToPoint} onMouseUp={stopScrubbing} onMouseMove={scrub} diff --git a/dotcom-rendering/src/components/AudioPlayer/components/ProgressBar.tsx b/dotcom-rendering/src/components/AudioPlayer/components/ProgressBar.tsx index a820de6053..e545b46b59 100644 --- a/dotcom-rendering/src/components/AudioPlayer/components/ProgressBar.tsx +++ b/dotcom-rendering/src/components/AudioPlayer/components/ProgressBar.tsx @@ -1,5 +1,6 @@ import { css } from '@emotion/react'; import { from, palette } from '@guardian/source/foundations'; +import { WaveForm } from './WaveForm'; const cursorWidth = '4px'; @@ -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 @@ -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 @@ -43,26 +44,7 @@ const Cursor = ({ } `} style={{ - transform: `translateX(clamp(-100% + ${cursorWidth}, ${ - -100 + progress - }%, 0%))`, - }} - > -); - -const Buffer = ({ buffer = 0 }: { buffer: number }) => ( -
); @@ -70,12 +52,14 @@ const Buffer = ({ buffer = 0 }: { buffer: number }) => ( export const ProgressBar = ({ progress, buffer, + src, isScrubbing, canJumpToPoint, ...props }: React.ComponentPropsWithoutRef<'div'> & { isScrubbing: boolean; canJumpToPoint: boolean; + src: string; buffer: number; progress: number; }) => { @@ -100,7 +84,23 @@ export const ProgressBar = ({ `} {...props} > - + + 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; + +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 ( + + {/* the base bars we'll use to create the variants we need below */} + + + {barHeights.map((barHeight, index) => { + const x = index * (barWidth + gap); + return ( + + ); + })} + + + + + + + + + + + + {/* default wave colours */} + + + {/* buffer wave */} + + + {/* progress wave */} + + + ); +};