Hey, I heard you like Stereo VU Meters.

This commit is contained in:
Matthew Stratford 2020-09-20 16:16:34 +01:00
parent 1132862d9a
commit a45aa7a17e
8 changed files with 90 additions and 40 deletions

View file

@ -93,7 +93,9 @@
"resolve-url-loader": "3.1.0", "resolve-url-loader": "3.1.0",
"sass-loader": "7.2.0", "sass-loader": "7.2.0",
"sdp-transform": "^2.14.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", "strict-event-emitter-types": "^2.0.0",
"style-loader": "1.0.0", "style-loader": "1.0.0",
"terser-webpack-plugin": "1.4.1", "terser-webpack-plugin": "1.4.1",

View file

@ -7,6 +7,9 @@ import RegionsPlugin from "wavesurfer.js/dist/plugin/wavesurfer.regions.min.js";
import NewsEndCountdown from "../assets/audio/NewsEndCountdown.wav"; import NewsEndCountdown from "../assets/audio/NewsEndCountdown.wav";
import NewsIntro from "../assets/audio/NewsIntro.wav"; import NewsIntro from "../assets/audio/NewsIntro.wav";
import StereoAnalyserNode from "stereo-analyser-node";
interface PlayerEvents { interface PlayerEvents {
loadComplete: (duration: number) => void; loadComplete: (duration: number) => void;
timeChange: (time: number) => void; timeChange: (time: number) => void;
@ -183,20 +186,20 @@ export class AudioEngine extends ((EngineEmitter as unknown) as {
micMedia: MediaStream | null = null; micMedia: MediaStream | null = null;
micSource: MediaStreamAudioSourceNode | null = null; micSource: MediaStreamAudioSourceNode | null = null;
micCalibrationGain: GainNode; micCalibrationGain: GainNode;
micPrecompAnalyser: AnalyserNode; micPrecompAnalyser: StereoAnalyserNode;
micCompressor: DynamicsCompressorNode; micCompressor: DynamicsCompressorNode;
micMixGain: GainNode; micMixGain: GainNode;
micFinalAnalyser: AnalyserNode; micFinalAnalyser: StereoAnalyserNode;
finalCompressor: DynamicsCompressorNode; finalCompressor: DynamicsCompressorNode;
streamingDestination: MediaStreamAudioDestinationNode; streamingDestination: MediaStreamAudioDestinationNode;
player0Analyser: AnalyserNode; player0Analyser: StereoAnalyserNode;
player1Analyser: AnalyserNode; player1Analyser: StereoAnalyserNode;
player2Analyser: AnalyserNode; player2Analyser: StereoAnalyserNode;
playerAnalysers: AnalyserNode[]; playerAnalysers: StereoAnalyserNode[];
streamingAnalyser: AnalyserNode; streamingAnalyser: StereoAnalyserNode;
newsStartCountdownEl: HTMLAudioElement; newsStartCountdownEl: HTMLAudioElement;
newsStartCountdownNode: MediaElementAudioSourceNode; newsStartCountdownNode: MediaElementAudioSourceNode;
@ -204,7 +207,6 @@ export class AudioEngine extends ((EngineEmitter as unknown) as {
newsEndCountdownEl: HTMLAudioElement; newsEndCountdownEl: HTMLAudioElement;
newsEndCountdownNode: MediaElementAudioSourceNode; newsEndCountdownNode: MediaElementAudioSourceNode;
analysisBuffer: Float32Array;
constructor() { constructor() {
super(); super();
@ -220,16 +222,21 @@ export class AudioEngine extends ((EngineEmitter as unknown) as {
this.finalCompressor.release.value = 0.2; this.finalCompressor.release.value = 0.2;
this.finalCompressor.knee.value = 0; this.finalCompressor.knee.value = 0;
this.player0Analyser = this.audioContext.createAnalyser(); this.player0Analyser = new StereoAnalyserNode(this.audioContext);
this.player0Analyser.fftSize = ANALYSIS_FFT_SIZE; this.player0Analyser.fftSize = ANALYSIS_FFT_SIZE;
this.player1Analyser = this.audioContext.createAnalyser(); this.player1Analyser = new StereoAnalyserNode(this.audioContext);
this.player1Analyser.fftSize = ANALYSIS_FFT_SIZE; this.player1Analyser.fftSize = ANALYSIS_FFT_SIZE;
this.player2Analyser = this.audioContext.createAnalyser(); this.player2Analyser = new StereoAnalyserNode(this.audioContext);
this.player2Analyser.fftSize = ANALYSIS_FFT_SIZE; this.player2Analyser.fftSize = ANALYSIS_FFT_SIZE;
this.playerAnalysers = [this.player0Analyser, this.player1Analyser, this.player2Analyser]; 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.fftSize = ANALYSIS_FFT_SIZE;
// this.streamingAnalyser.maxDecibels = 0; // this.streamingAnalyser.maxDecibels = 0;
this.streamingDestination = this.audioContext.createMediaStreamDestination(); this.streamingDestination = this.audioContext.createMediaStreamDestination();
@ -242,16 +249,14 @@ export class AudioEngine extends ((EngineEmitter as unknown) as {
this.micCalibrationGain = this.audioContext.createGain(); this.micCalibrationGain = this.audioContext.createGain();
this.micPrecompAnalyser = this.audioContext.createAnalyser(); this.micPrecompAnalyser = new StereoAnalyserNode(this.audioContext);
this.micPrecompAnalyser.fftSize = ANALYSIS_FFT_SIZE; this.micPrecompAnalyser.fftSize = ANALYSIS_FFT_SIZE;
this.micPrecompAnalyser.maxDecibels = 0; this.micPrecompAnalyser.maxDecibels = 0;
this.micFinalAnalyser = this.audioContext.createAnalyser(); this.micFinalAnalyser = new StereoAnalyserNode(this.audioContext);
this.micFinalAnalyser.fftSize = ANALYSIS_FFT_SIZE; this.micFinalAnalyser.fftSize = ANALYSIS_FFT_SIZE;
this.micFinalAnalyser.maxDecibels = 0; this.micFinalAnalyser.maxDecibels = 0;
this.analysisBuffer = new Float32Array(ANALYSIS_FFT_SIZE);
this.micCompressor = this.audioContext.createDynamicsCompressor(); this.micCompressor = this.audioContext.createDynamicsCompressor();
this.micCompressor.ratio.value = 3; // mic compressor - fairly gentle, can be upped this.micCompressor.ratio.value = 3; // mic compressor - fairly gentle, can be upped
this.micCompressor.threshold.value = -18; this.micCompressor.threshold.value = -18;
@ -264,6 +269,7 @@ export class AudioEngine extends ((EngineEmitter as unknown) as {
this.micCalibrationGain this.micCalibrationGain
.connect(this.micPrecompAnalyser) .connect(this.micPrecompAnalyser)
this.micCalibrationGain
.connect(this.micCompressor) .connect(this.micCompressor)
.connect(this.micMixGain) .connect(this.micMixGain)
.connect(this.micFinalAnalyser) .connect(this.micFinalAnalyser)
@ -337,34 +343,46 @@ export class AudioEngine extends ((EngineEmitter as unknown) as {
this.micMixGain.gain.value = value; 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) { switch (source) {
case "mic-precomp": case "mic-precomp":
this.micPrecompAnalyser.getFloatTimeDomainData(this.analysisBuffer); this.micPrecompAnalyser.getFloatTimeDomainData(analysisBuffer, analysisBuffer2);
break; break;
case "mic-final": case "mic-final":
this.micFinalAnalyser.getFloatTimeDomainData(this.analysisBuffer); this.micFinalAnalyser.getFloatTimeDomainData(analysisBuffer, analysisBuffer2);
break; break;
case "master": case "master":
this.streamingAnalyser.getFloatTimeDomainData(this.analysisBuffer); this.streamingAnalyser.getFloatTimeDomainData(analysisBuffer, analysisBuffer2);
break; break;
case "player-0": case "player-0":
this.player0Analyser.getFloatTimeDomainData(this.analysisBuffer); this.player0Analyser.getFloatTimeDomainData(analysisBuffer, analysisBuffer2);
break; break;
case "player-1": case "player-1":
this.player1Analyser.getFloatTimeDomainData(this.analysisBuffer); this.player1Analyser.getFloatTimeDomainData(analysisBuffer, analysisBuffer2);
break; break;
case "player-2": case "player-2":
this.player2Analyser.getFloatTimeDomainData(this.analysisBuffer); this.player2Analyser.getFloatTimeDomainData(analysisBuffer, analysisBuffer2);
break; break;
default: default:
throw new Error("can't getLevel " + source); throw new Error("can't getLevel " + source);
} }
let peak = 0; let peakL = 0;
for (let i = 0; i < this.analysisBuffer.length; i++) { for (let i = 0; i < analysisBuffer.length; i++) {
peak = Math.max(peak, Math.abs(this.analysisBuffer[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() { async playNewsEnd() {

View file

@ -269,7 +269,7 @@ export function NavBarWebStudio() {
</li> </li>
)} )}
<li className="nav-item px-2 nav-vu"> <li className="nav-item px-2 nav-vu">
<VUMeter width={235} height={40} source="master" range={[-40, 3]} /> <VUMeter width={235} height={40} source="master" range={[-40, 3]} stereo={true} />
</li> </li>
</ul> </ul>
</> </>

View file

@ -125,6 +125,7 @@ export function MicTab() {
source="mic-precomp" source="mic-precomp"
range={[-70, 0]} range={[-70, 0]}
greenRange={[-14, -10]} greenRange={[-14, -10]}
stereo={true}
/> />
</div> </div>
<div> <div>

View file

@ -14,6 +14,7 @@ interface VUMeterProps extends HTMLProps<HTMLCanvasElement> {
range: [number, number]; range: [number, number];
greenRange?: [number, number]; greenRange?: [number, number];
source: LevelsSource; source: LevelsSource;
stereo: boolean;
} }
export function VUMeter(props: VUMeterProps) { export function VUMeter(props: VUMeterProps) {
@ -22,15 +23,19 @@ export function VUMeter(props: VUMeterProps) {
const isMicOpen = useSelector((state: RootState) => state.mixer.mic.open); const isMicOpen = useSelector((state: RootState) => state.mixer.mic.open);
const rafRef = useRef<number | null>(null); const rafRef = useRef<number | null>(null);
const [peak, setPeak] = useState(-Infinity); const [peakL, setPeakL] = useState(-Infinity);
const [peakR, setPeakR] = useState(-Infinity);
const isMic = props.source.substr(0, 3) === "mic"; const isMic = props.source.substr(0, 3) === "mic";
useEffect(() => { useEffect(() => {
const animate = () => { const animate = () => {
if (!isMic || isMicOpen) { if (!isMic || isMicOpen) {
const result = audioEngine.getLevel(props.source); const result = audioEngine.getLevels(props.source, props.stereo);
setPeak(result); setPeakL(result[0]);
if (props.stereo) {
setPeakR(result[1]);
}
rafRef.current = requestAnimationFrame(animate); rafRef.current = requestAnimationFrame(animate);
} }
}; };
@ -43,7 +48,7 @@ export function VUMeter(props: VUMeterProps) {
rafRef.current = null; rafRef.current = null;
} }
}; };
}, [isMicOpen, isMic, props.source]); }, [isMicOpen, isMic, props.source, props.stereo]);
useLayoutEffect(() => { useLayoutEffect(() => {
if (canvasRef.current) { if (canvasRef.current) {
@ -76,9 +81,15 @@ export function VUMeter(props: VUMeterProps) {
height 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"; ctx.fillStyle = "#00ff00";
} else if (peak < props.greenRange[0]) { } else if (
(peakL < props.greenRange[0])
|| (props.stereo && peakR < props.greenRange[0])
) {
ctx.fillStyle = "#e8d120"; ctx.fillStyle = "#e8d120";
} else { } else {
ctx.fillStyle = "#ff0000"; ctx.fillStyle = "#ff0000";
@ -87,17 +98,28 @@ export function VUMeter(props: VUMeterProps) {
ctx.fillStyle = "#e8d120"; 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"; ctx.fillStyle = "#fff";
for (let i = 0; i < 10; i++) { for (let i = 0; i < 10; i++) {
const value = (props.range[0] + valueRange * (i / 10)).toFixed(0); 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; const { value, range, greenRange, source, ...rest } = props;

View file

@ -259,6 +259,7 @@ export function Player({ id }: { id: number }) {
height={40} height={40}
source={VUsource(id)} source={VUsource(id)}
range={[-40, 0]} range={[-40, 0]}
stereo={true}
/>} />}
</div> </div>
); );

View file

@ -172,6 +172,7 @@ function MicControl() {
source="mic-final" source="mic-final"
range={[-40, 3]} range={[-40, 3]}
greenRange={[-10, -5]} greenRange={[-10, -5]}
stereo={true}
/> />
</div> </div>
<div className={`mixer-buttons ${!state.open && "disabled"}`}> <div className={`mixer-buttons ${!state.open && "disabled"}`}>

View file

@ -10288,6 +10288,11 @@ stealthy-require@^1.1.1:
resolved "https://registry.yarnpkg.com/stealthy-require/-/stealthy-require-1.1.1.tgz#35b09875b4ff49f26a777e509b3090a3226bf24b" resolved "https://registry.yarnpkg.com/stealthy-require/-/stealthy-require-1.1.1.tgz#35b09875b4ff49f26a777e509b3090a3226bf24b"
integrity sha1-NbCYdbT/SfJqd35QmzCQoyJr8ks= 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: stream-browserify@^2.0.1:
version "2.0.2" version "2.0.2"
resolved "https://registry.yarnpkg.com/stream-browserify/-/stream-browserify-2.0.2.tgz#87521d38a44aa7ee91ce1cd2a47df0cb49dd660b" resolved "https://registry.yarnpkg.com/stream-browserify/-/stream-browserify-2.0.2.tgz#87521d38a44aa7ee91ce1cd2a47df0cb49dd660b"