From 21d3a9a85d90bc910636ca8af352bb7fad39f965 Mon Sep 17 00:00:00 2001 From: Artur Finger Date: Mon, 18 Sep 2023 17:38:58 +0300 Subject: [PATCH] Add VU meter component. --- CHANGELOG.md | 4 + src/components/VuMeter.tsx | 33 ++ src/index.ts | 1 + src/services/volumeMeterService.ts | 341 +++++++++++++++++++ story/stories/components/VuMeter.mdx | 15 + story/stories/components/VuMeter.stories.tsx | 81 +++++ 6 files changed, 475 insertions(+) create mode 100644 src/components/VuMeter.tsx create mode 100644 src/services/volumeMeterService.ts create mode 100644 story/stories/components/VuMeter.mdx create mode 100644 story/stories/components/VuMeter.stories.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index a3bde36..87731c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,10 @@ (or `PrestoContext`), in which case it is not necessary to pass the `player` argument anymore. * `HoverContainer` no longer accepts props `listenToHover` and `notTrackFullWidth`. +## New Features + +* VU Meter component. + ## Fixes * Fixes to `BaseThemeOverlay`: diff --git a/src/components/VuMeter.tsx b/src/components/VuMeter.tsx new file mode 100644 index 0000000..ebeb777 --- /dev/null +++ b/src/components/VuMeter.tsx @@ -0,0 +1,33 @@ +import React, { useCallback, useContext, useEffect, useRef } from 'react' + +import { PrestoContext } from '../context/PrestoContext' +import { VolumeMeterService, VuMeterConfig } from '../services/volumeMeterService' + +import type { BaseComponentProps } from './types' + +export type Props = BaseComponentProps & { + config?: VuMeterConfig + width: number + height: number +} + +/** + * Volume Unit Meter + */ +export const VuMeter = (props: Props) => { + const ctx = useContext(PrestoContext) + const serviceRef = useRef(new VolumeMeterService(ctx.presto)) + + const onRef = useCallback((canvas: HTMLCanvasElement) => { + serviceRef.current.configure(canvas, props.config ?? {}) + serviceRef.current.mount() + }, []) + + useEffect(() => { + return () => { + serviceRef.current.unmount() + } + }, []) + + return +} diff --git a/src/index.ts b/src/index.ts index 517354b..e49d26a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -38,5 +38,6 @@ export * from './components/TrackSelectionList' export * from './components/VerticalBar' export * from './components/VolumeBar' export * from './components/PlayPauseIndicator' +export * from './components/VuMeter' export * from './context/PrestoContext' diff --git a/src/services/volumeMeterService.ts b/src/services/volumeMeterService.ts new file mode 100644 index 0000000..deee41c --- /dev/null +++ b/src/services/volumeMeterService.ts @@ -0,0 +1,341 @@ +import { clpp } from '@castlabs/prestoplay' + +type GradientColorStop = { + stop: number + color: string +} + +type AnalyzerContext = { + analyser: AnalyserNode + dataArray: Uint8Array +} + +export type ConfigInternal = { + fontSize: number + fontColor: string + fontFamily: string + backgroundColor: string + barGradient: GradientColorStop[] + barWidth?: number + barLeftOffset?: number + barSpacing: number + smoothingTimeConstant: number + frequencyBinCount: number + levelLabel: (level: number) => string + minDecibels: number + maxDecibels: number +} + +export type VuMeterConfig = Partial + +const DEFAULT_CONFIG: ConfigInternal = { + fontSize: 12, + fontColor: 'rgba(255, 255, 255, 0.7)', + fontFamily: 'sans-serif', + backgroundColor: 'rgba(0,0,0,1)', + barGradient: [ + { stop: 0, color: 'green' }, + { stop: 0.8, color: 'yellow' }, + { stop: 1, color: 'red' }, + ], + barSpacing: 4, + smoothingTimeConstant: 0.4, + frequencyBinCount: 1, + levelLabel: (level => `${level} dB`), + minDecibels: -100, + maxDecibels: -30, +} + +const dimensions = (element: HTMLCanvasElement) => { + return { + width: element.width, + height: element.height, + } +} + +/** + * Volume Meter Service. + * It can analyze volume and animate the results on a canvas. + */ +export class VolumeMeterService { + private animationRequestID: number | null = null + private attachedVideoElement: HTMLMediaElement | null = null + private audioCtx: AudioContext | null = null + private audioSource: AudioNode | null = null + private canvas: HTMLCanvasElement | null = null + private config: ConfigInternal = DEFAULT_CONFIG + private disposers: (() => void)[] = [] + private enabled = false + private mediaElementToSourceNodeMap: Map = new Map() + private log = new clpp.log.Logger('clpp.services.VuMeter') + + constructor (private player: clpp.Player) {} + + /** + * Configure. + */ + configure (canvas: HTMLCanvasElement, config?: VuMeterConfig) { + this.canvas = canvas + this.config = { + ...DEFAULT_CONFIG, + ...config, + } + + this.log.info('VU meter configured', config) + } + + /** + * Mount the VU meter to the canvas element. + */ + mount () { + if (this.canvas) { + this.drawEmpty(this.canvas) + this.log.info('VU meter mounted') + } + + // Handle start / re-start of content playback + const onPlaying = () => { + this.attachToMainVideo() + } + this.player.on('playing', onPlaying) + this.disposers.push(() => this.player.off('playing', onPlaying)) + + // Handle time update (if mounted after playback started) + const onTimeupdate = () => { + this.attachToMainVideo() + } + this.player.on('timeupdate', onTimeupdate) + this.disposers.push(() => this.player.off('timeupdate', onTimeupdate)) + + // Handle pause + const onPaused = () => { + this.disable() + } + this.player.on('paused', onPaused) + this.disposers.push(() => this.player.off('paused', onPaused)) + + // Handle end + const onEnded = () => { + this.disable() + } + this.player.on('ended', onEnded) + this.disposers.push(() => this.player.off('ended', onEnded)) + } + + /** + * Un-Mount the VU meter from the canvas element. + */ + unmount () { + this.disable() + if (this.canvas) { + this.clear(this.canvas) + this.canvas = null + } + this.disposers.forEach(dispose => dispose()) + this.disposers = [] + this.log.info('VU meter un-mounted') + } + + /** + * Enable the VU meter - start measuring the audio volume. + */ + enable () { + if (this.enabled || !this.attachedVideoElement || !this.canvas) { + return + } + + this.log.info('VU meter enabled') + + this.enabled = true + + const audioContext = this.createAudioContext() + this.audioSource = this.getAudioSource(audioContext, this.attachedVideoElement) + const splitter = audioContext.createChannelSplitter(2) + this.audioSource.connect(splitter) + + const leftAudioCtx = this.createAnalyserContext(audioContext, splitter, 0) + const rightAudioCtx = this.createAnalyserContext(audioContext, splitter, 1) + + const paintRecursively = () => { + this.animationRequestID = requestAnimationFrame(() => { + if (!this.canvas) {return} + this.draw(this.canvas, leftAudioCtx, rightAudioCtx) + paintRecursively() + }) + } + + paintRecursively() + } + + /** + * Disable the VU meter - stop measuring the audio volume. + */ + disable () { + if (!this.enabled) { + return + } + + if (this.animationRequestID != null) { + cancelAnimationFrame(this.animationRequestID) + this.animationRequestID = null + } + if (this.canvas) { + this.drawEmpty(this.canvas) + } + + this.enabled = false + } + + /** + * Attach to volume of the main video element. + */ + private attachToMainVideo () { + const media = this.player.getSurface()?.getMedia() + + if (media === this.attachedVideoElement) { + if (!this.enabled) { + this.enable() + } + return + } + + this.disable() + + if (!media) { + this.log.warn('Failed to get Main video element.') + return + } + + this.setVideoElement(media) + this.enable() + this.log.info('Attached to main video.') + } + + private setVideoElement (element: HTMLMediaElement) { + this.log.info(`VU meter set video element ID: ${element.id},` + + ` classes: ${element.className}`) + + this.attachedVideoElement = element + } + + private getAudioSource(audioContext: AudioContext, element: HTMLMediaElement): AudioNode { + let audioSource = this.mediaElementToSourceNodeMap.get(element) + if (audioSource) { + return audioSource + } + audioSource = audioContext.createMediaElementSource(element) + // `createMediaElementSource` disconnected audio from output, + // so connect it back + audioSource.connect(audioContext.destination) + this.mediaElementToSourceNodeMap.set(element, audioSource) + return audioSource + } + + private clear (canvas: HTMLCanvasElement) { + if (!canvas) {return} + const canvasCtx = canvas.getContext('2d') + if (!canvasCtx) {return} + + const { width, height } = dimensions(canvas) + canvasCtx.clearRect(0, 0, width, height) + } + + private drawEmpty (canvas: HTMLCanvasElement) { + const canvasCtx = canvas.getContext('2d') + if (!canvasCtx) { + return + } + + const { width, height } = dimensions(canvas) + canvasCtx.clearRect(0, 0, width, height) + canvasCtx.fillStyle = this.config.backgroundColor + canvasCtx.fillRect(0, 0, width, height) + + // Render numbering + canvasCtx.fillStyle = this.config.fontColor + canvasCtx.font = `${this.config.fontSize}px ${this.config.fontFamily}` + const steps = Math.round(( + this.config.maxDecibels - this.config.minDecibels) / 10) + const startStep = Math.round(- this.config.maxDecibels / 10) + const heightStep = height / steps + for (let i = 1; i < steps; i++) { + const label = this.config.levelLabel(-10 * (i + startStep)) + canvasCtx.fillText(label, 3, i * heightStep) + } + } + + private draw (canvas: HTMLCanvasElement, leftAudioCtx: AnalyzerContext, rightAudioCtx: AnalyzerContext) { + this.drawEmpty(canvas) + + const canvasCtx = canvas.getContext('2d') + if (!canvasCtx) {return} + + const { width, height } = dimensions(canvas) + const leftBarHeight = this.computeBarHeight(leftAudioCtx, height) + const rightBarHeight = this.computeBarHeight(rightAudioCtx, height) + const barWidth = this.config.barWidth || (width / 6) + const barLeftOffset = this.config.barLeftOffset || (3.4 * barWidth) + const barSpacing = this.config.barSpacing + + const gradient = canvasCtx.createLinearGradient(0, height, 0, 0) + this.config.barGradient.forEach(({ stop, color }) => { + gradient.addColorStop(stop, color) + }) + + // Render volume bar + canvasCtx.fillStyle = gradient + canvasCtx.fillRect(barLeftOffset, height - leftBarHeight, + barWidth, leftBarHeight) + canvasCtx.fillRect(barLeftOffset + barWidth + barSpacing, + height - rightBarHeight, barWidth, rightBarHeight) + } + + private computeBarHeight (analyzerCtx: AnalyzerContext, maxHeight: number): number { + const { analyser, dataArray } = analyzerCtx + const MAX_VALUE = 255 + + analyser.getByteFrequencyData(dataArray) + + const groupCount = this.config.frequencyBinCount + const groupSize = dataArray.length / groupCount + + // Calculate the averages + const averages: number[] = [] + for (let i = 0; i < groupCount; i++) { + const start = i * groupSize + const end = (i + 1) * groupSize + const array = dataArray.slice(start, end) + const average = array.reduce((a, b) => a + b, 0) / groupSize + averages.push(average) + } + // clpp.log.info('AAA audio FFT averages', averages); + const average = Math.max(...averages) + const averagePercent = average / MAX_VALUE + + // const levelText = Math.round(averagePercent * 100 - 100); + const barHeight = maxHeight * averagePercent + return barHeight + } + + private createAnalyserContext (audioContext: AudioContext, audioNode: AudioNode, channel: number): AnalyzerContext { + const analyser = audioContext.createAnalyser() + analyser.smoothingTimeConstant = this.config.smoothingTimeConstant + analyser.fftSize = 256 + const bufferLength = analyser.frequencyBinCount + const dataArray = new Uint8Array(bufferLength) + audioNode.connect(analyser, channel) + analyser.minDecibels = this.config.minDecibels + analyser.maxDecibels = this.config.maxDecibels + return { analyser, dataArray } + } + + private createAudioContext(): AudioContext { + if (!this.audioCtx) { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + this.audioCtx = new (window.AudioContext || window.webkitAudioContext)() + } + return this.audioCtx + } +} diff --git a/story/stories/components/VuMeter.mdx b/story/stories/components/VuMeter.mdx new file mode 100644 index 0000000..a1c38bf --- /dev/null +++ b/story/stories/components/VuMeter.mdx @@ -0,0 +1,15 @@ +import { Canvas, Meta, Primary, Controls, Story, ArgTypes } from '@storybook/blocks'; +import * as VuMeterStories from './VuMeter.stories'; + + + + + +# VU Meter + +It is a volume meter. + +### Props + + + diff --git a/story/stories/components/VuMeter.stories.tsx b/story/stories/components/VuMeter.stories.tsx new file mode 100644 index 0000000..761ec57 --- /dev/null +++ b/story/stories/components/VuMeter.stories.tsx @@ -0,0 +1,81 @@ +import React, { useMemo, useState } from 'react' +import '@castlabs/prestoplay/clpp.styles.css' +import '../../../src/themes/pp-ui-base-theme.css' + +import { clpp } from '@castlabs/prestoplay' +import '@castlabs/prestoplay/cl.mse' +import '@castlabs/prestoplay/cl.dash' +import '@castlabs/prestoplay/cl.hls' +import '@castlabs/prestoplay/cl.htmlcue' +import '@castlabs/prestoplay/cl.ttml' +import '@castlabs/prestoplay/cl.vtt' + +import { BaseThemeOverlay, Player, PlayerSurface, PrestoContext, PrestoContextType, VuMeter } from '../../../src' +import { TEST_ASSETS } from '../Asset' + +import type { Meta, StoryObj } from '@storybook/react' + + +const Component = () => { + const [context, setContext] = useState(null) + + const player = useMemo(() => { + return new Player(pp => { + pp.use(clpp.dash.DashComponent) + pp.use(clpp.hls.HlsComponent) + pp.use(clpp.htmlcue.HtmlCueComponent) + pp.use(clpp.ttml.TtmlComponent) + pp.use(clpp.vtt.VttComponent) + }) + }, []) + + const playerConfig = { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + source: TEST_ASSETS[0].config.source ?? '', + } + + return ( +
+ + + + {context && ( + + + + )} +
+ ) +} + +const meta: Meta = { + title: 'components/VU Meter', + component: Component, + parameters: { + docs: { + source: { + code: ` +import { VuMeter } from '@castlabs/prestoplay-react-components' + +return ( + +) + `, + }, + }, + }, +} + +export default meta + +type Story = StoryObj + +export const Primary: Story = {}