From a45aa7a17e4304971177298fea4cc8e8a67807f0 Mon Sep 17 00:00:00 2001 From: Matthew Stratford Date: Sun, 20 Sep 2020 16:16:34 +0100 Subject: [PATCH] Hey, I heard you like Stereo VU Meters. --- package.json | 4 +- src/mixer/audio.ts | 72 ++++++++++++++++++----------- src/navbar/index.tsx | 2 +- src/optionsMenu/MicTab.tsx | 1 + src/optionsMenu/helpers/VUMeter.tsx | 44 +++++++++++++----- src/showplanner/Player.tsx | 1 + src/showplanner/index.tsx | 1 + yarn.lock | 5 ++ 8 files changed, 90 insertions(+), 40 deletions(-) diff --git a/package.json b/package.json index 57d198f..6f26bd6 100644 --- a/package.json +++ b/package.json @@ -93,7 +93,9 @@ "resolve-url-loader": "3.1.0", "sass-loader": "7.2.0", "sdp-transform": "^2.14.0", - "semver": "6.3.0", + "semver": "7.3.2", + "serialize-javascript": "^5.0.1", + "stereo-analyser-node": "^1.0.0", "strict-event-emitter-types": "^2.0.0", "style-loader": "1.0.0", "terser-webpack-plugin": "1.4.1", diff --git a/src/mixer/audio.ts b/src/mixer/audio.ts index 31f3929..dfe3dc8 100644 --- a/src/mixer/audio.ts +++ b/src/mixer/audio.ts @@ -7,6 +7,9 @@ import RegionsPlugin from "wavesurfer.js/dist/plugin/wavesurfer.regions.min.js"; import NewsEndCountdown from "../assets/audio/NewsEndCountdown.wav"; import NewsIntro from "../assets/audio/NewsIntro.wav"; +import StereoAnalyserNode from "stereo-analyser-node"; + + interface PlayerEvents { loadComplete: (duration: number) => void; timeChange: (time: number) => void; @@ -183,20 +186,20 @@ export class AudioEngine extends ((EngineEmitter as unknown) as { micMedia: MediaStream | null = null; micSource: MediaStreamAudioSourceNode | null = null; micCalibrationGain: GainNode; - micPrecompAnalyser: AnalyserNode; + micPrecompAnalyser: StereoAnalyserNode; micCompressor: DynamicsCompressorNode; micMixGain: GainNode; - micFinalAnalyser: AnalyserNode; + micFinalAnalyser: StereoAnalyserNode; finalCompressor: DynamicsCompressorNode; streamingDestination: MediaStreamAudioDestinationNode; - player0Analyser: AnalyserNode; - player1Analyser: AnalyserNode; - player2Analyser: AnalyserNode; - playerAnalysers: AnalyserNode[]; + player0Analyser: StereoAnalyserNode; + player1Analyser: StereoAnalyserNode; + player2Analyser: StereoAnalyserNode; + playerAnalysers: StereoAnalyserNode[]; - streamingAnalyser: AnalyserNode; + streamingAnalyser: StereoAnalyserNode; newsStartCountdownEl: HTMLAudioElement; newsStartCountdownNode: MediaElementAudioSourceNode; @@ -204,7 +207,6 @@ export class AudioEngine extends ((EngineEmitter as unknown) as { newsEndCountdownEl: HTMLAudioElement; newsEndCountdownNode: MediaElementAudioSourceNode; - analysisBuffer: Float32Array; constructor() { super(); @@ -220,16 +222,21 @@ export class AudioEngine extends ((EngineEmitter as unknown) as { this.finalCompressor.release.value = 0.2; this.finalCompressor.knee.value = 0; - this.player0Analyser = this.audioContext.createAnalyser(); + this.player0Analyser = new StereoAnalyserNode(this.audioContext); this.player0Analyser.fftSize = ANALYSIS_FFT_SIZE; - this.player1Analyser = this.audioContext.createAnalyser(); + this.player1Analyser = new StereoAnalyserNode(this.audioContext); this.player1Analyser.fftSize = ANALYSIS_FFT_SIZE; - this.player2Analyser = this.audioContext.createAnalyser(); + this.player2Analyser = new StereoAnalyserNode(this.audioContext); this.player2Analyser.fftSize = ANALYSIS_FFT_SIZE; + + + + this.playerAnalysers = [this.player0Analyser, this.player1Analyser, this.player2Analyser]; - this.streamingAnalyser = this.audioContext.createAnalyser(); + this.streamingAnalyser = new StereoAnalyserNode(this.audioContext); this.streamingAnalyser.fftSize = ANALYSIS_FFT_SIZE; + // this.streamingAnalyser.maxDecibels = 0; this.streamingDestination = this.audioContext.createMediaStreamDestination(); @@ -242,16 +249,14 @@ export class AudioEngine extends ((EngineEmitter as unknown) as { this.micCalibrationGain = this.audioContext.createGain(); - this.micPrecompAnalyser = this.audioContext.createAnalyser(); + this.micPrecompAnalyser = new StereoAnalyserNode(this.audioContext); this.micPrecompAnalyser.fftSize = ANALYSIS_FFT_SIZE; this.micPrecompAnalyser.maxDecibels = 0; - this.micFinalAnalyser = this.audioContext.createAnalyser(); + this.micFinalAnalyser = new StereoAnalyserNode(this.audioContext); this.micFinalAnalyser.fftSize = ANALYSIS_FFT_SIZE; this.micFinalAnalyser.maxDecibels = 0; - this.analysisBuffer = new Float32Array(ANALYSIS_FFT_SIZE); - this.micCompressor = this.audioContext.createDynamicsCompressor(); this.micCompressor.ratio.value = 3; // mic compressor - fairly gentle, can be upped this.micCompressor.threshold.value = -18; @@ -264,6 +269,7 @@ export class AudioEngine extends ((EngineEmitter as unknown) as { this.micCalibrationGain .connect(this.micPrecompAnalyser) + this.micCalibrationGain .connect(this.micCompressor) .connect(this.micMixGain) .connect(this.micFinalAnalyser) @@ -337,34 +343,46 @@ export class AudioEngine extends ((EngineEmitter as unknown) as { this.micMixGain.gain.value = value; } - getLevel(source: LevelsSource) { + getLevels(source: LevelsSource, stereo: boolean) { + let analysisBuffer = new Float32Array(ANALYSIS_FFT_SIZE); + let analysisBuffer2 = new Float32Array(ANALYSIS_FFT_SIZE); switch (source) { case "mic-precomp": - this.micPrecompAnalyser.getFloatTimeDomainData(this.analysisBuffer); + this.micPrecompAnalyser.getFloatTimeDomainData(analysisBuffer, analysisBuffer2); break; case "mic-final": - this.micFinalAnalyser.getFloatTimeDomainData(this.analysisBuffer); + this.micFinalAnalyser.getFloatTimeDomainData(analysisBuffer, analysisBuffer2); break; case "master": - this.streamingAnalyser.getFloatTimeDomainData(this.analysisBuffer); + this.streamingAnalyser.getFloatTimeDomainData(analysisBuffer, analysisBuffer2); break; case "player-0": - this.player0Analyser.getFloatTimeDomainData(this.analysisBuffer); + this.player0Analyser.getFloatTimeDomainData(analysisBuffer, analysisBuffer2); break; case "player-1": - this.player1Analyser.getFloatTimeDomainData(this.analysisBuffer); + this.player1Analyser.getFloatTimeDomainData(analysisBuffer, analysisBuffer2); break; case "player-2": - this.player2Analyser.getFloatTimeDomainData(this.analysisBuffer); + this.player2Analyser.getFloatTimeDomainData(analysisBuffer, analysisBuffer2); break; default: throw new Error("can't getLevel " + source); } - let peak = 0; - for (let i = 0; i < this.analysisBuffer.length; i++) { - peak = Math.max(peak, Math.abs(this.analysisBuffer[i])); + let peakL = 0; + for (let i = 0; i < analysisBuffer.length; i++) { + peakL = Math.max(peakL, Math.abs(analysisBuffer[i])); } - return 20 * Math.log10(peak); + peakL = 20 * Math.log10(peakL); + + if (stereo) { + let peakR = 0; + for (let i = 0; i < analysisBuffer2.length; i++) { + peakR = Math.max(peakR, Math.abs(analysisBuffer2[i])); + } + peakR = 20 * Math.log10(peakR); + return [peakL, peakR] + } + return [peakL]; } async playNewsEnd() { diff --git a/src/navbar/index.tsx b/src/navbar/index.tsx index 2811212..d5c9b29 100644 --- a/src/navbar/index.tsx +++ b/src/navbar/index.tsx @@ -269,7 +269,7 @@ export function NavBarWebStudio() { )}
  • - +
  • diff --git a/src/optionsMenu/MicTab.tsx b/src/optionsMenu/MicTab.tsx index 65ba338..0d2040f 100644 --- a/src/optionsMenu/MicTab.tsx +++ b/src/optionsMenu/MicTab.tsx @@ -125,6 +125,7 @@ export function MicTab() { source="mic-precomp" range={[-70, 0]} greenRange={[-14, -10]} + stereo={true} />
    diff --git a/src/optionsMenu/helpers/VUMeter.tsx b/src/optionsMenu/helpers/VUMeter.tsx index 5e94074..a8963cd 100644 --- a/src/optionsMenu/helpers/VUMeter.tsx +++ b/src/optionsMenu/helpers/VUMeter.tsx @@ -14,6 +14,7 @@ interface VUMeterProps extends HTMLProps { range: [number, number]; greenRange?: [number, number]; source: LevelsSource; + stereo: boolean; } export function VUMeter(props: VUMeterProps) { @@ -22,15 +23,19 @@ export function VUMeter(props: VUMeterProps) { const isMicOpen = useSelector((state: RootState) => state.mixer.mic.open); const rafRef = useRef(null); - const [peak, setPeak] = useState(-Infinity); + const [peakL, setPeakL] = useState(-Infinity); + const [peakR, setPeakR] = useState(-Infinity); const isMic = props.source.substr(0, 3) === "mic"; useEffect(() => { const animate = () => { if (!isMic || isMicOpen) { - const result = audioEngine.getLevel(props.source); - setPeak(result); + const result = audioEngine.getLevels(props.source, props.stereo); + setPeakL(result[0]); + if (props.stereo) { + setPeakR(result[1]); + } rafRef.current = requestAnimationFrame(animate); } }; @@ -43,7 +48,7 @@ export function VUMeter(props: VUMeterProps) { rafRef.current = null; } }; - }, [isMicOpen, isMic, props.source]); + }, [isMicOpen, isMic, props.source, props.stereo]); useLayoutEffect(() => { if (canvasRef.current) { @@ -76,9 +81,15 @@ export function VUMeter(props: VUMeterProps) { height ); - if (peak >= props.greenRange[0] && peak <= props.greenRange[1]) { + if ( + (peakL >= props.greenRange[0] && peakL <= props.greenRange[1]) + || (props.stereo && peakR >= props.greenRange[0] && peakR <= props.greenRange[1]) + ) { ctx.fillStyle = "#00ff00"; - } else if (peak < props.greenRange[0]) { + } else if ( + (peakL < props.greenRange[0]) + || (props.stereo && peakR < props.greenRange[0]) + ) { ctx.fillStyle = "#e8d120"; } else { ctx.fillStyle = "#ff0000"; @@ -87,17 +98,28 @@ export function VUMeter(props: VUMeterProps) { ctx.fillStyle = "#e8d120"; } - const valueOffset = - (Math.max(peak, props.range[0]) - props.range[0]) / valueRange; - ctx.fillRect(0, 0, valueOffset * width, height - 10); + const valueOffsetL = + (Math.max(peakL, props.range[0]) - props.range[0]) / valueRange; + + let valueOffsetR = 0; + if (props.stereo) { + valueOffsetR = + (Math.max(peakR, props.range[0]) - props.range[0]) / valueRange; + } else { + valueOffsetR = valueOffsetL; + } + + + ctx.fillRect(0, 0, valueOffsetL * width, height/2 - 7); + ctx.fillRect(0, height/2 - 6, valueOffsetR * width, height / 2 - 7); ctx.fillStyle = "#fff"; for (let i = 0; i < 10; i++) { const value = (props.range[0] + valueRange * (i / 10)).toFixed(0); - ctx.fillText(value, width * (i / 10), height - 7); + ctx.fillText(value, width * (i / 10), height - 2); } - }, [peak, props.range, props.greenRange]); + }, [peakL, peakR, props.range, props.greenRange, props.stereo]); const { value, range, greenRange, source, ...rest } = props; diff --git a/src/showplanner/Player.tsx b/src/showplanner/Player.tsx index 340ade4..c79f756 100644 --- a/src/showplanner/Player.tsx +++ b/src/showplanner/Player.tsx @@ -259,6 +259,7 @@ export function Player({ id }: { id: number }) { height={40} source={VUsource(id)} range={[-40, 0]} + stereo={true} />}
    ); diff --git a/src/showplanner/index.tsx b/src/showplanner/index.tsx index 8c4df71..9817406 100644 --- a/src/showplanner/index.tsx +++ b/src/showplanner/index.tsx @@ -172,6 +172,7 @@ function MicControl() { source="mic-final" range={[-40, 3]} greenRange={[-10, -5]} + stereo={true} />
    diff --git a/yarn.lock b/yarn.lock index 6c20ca0..a905e71 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10288,6 +10288,11 @@ stealthy-require@^1.1.1: resolved "https://registry.yarnpkg.com/stealthy-require/-/stealthy-require-1.1.1.tgz#35b09875b4ff49f26a777e509b3090a3226bf24b" integrity sha1-NbCYdbT/SfJqd35QmzCQoyJr8ks= +stereo-analyser-node@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/stereo-analyser-node/-/stereo-analyser-node-1.0.0.tgz#349f503ece474651e1e6adf299024ba9a18be8c3" + integrity sha1-NJ9QPs5HRlHh5q3ymQJLqaGL6MM= + stream-browserify@^2.0.1: version "2.0.2" resolved "https://registry.yarnpkg.com/stream-browserify/-/stream-browserify-2.0.2.tgz#87521d38a44aa7ee91ce1cd2a47df0cb49dd660b"