Hey, I heard you like Stereo VU Meters.
This commit is contained in:
parent
1132862d9a
commit
a45aa7a17e
8 changed files with 90 additions and 40 deletions
|
@ -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",
|
||||||
|
|
|
@ -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() {
|
||||||
|
|
|
@ -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>
|
||||||
</>
|
</>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
@ -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"}`}>
|
||||||
|
|
|
@ -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"
|
||||||
|
|
Loading…
Reference in a new issue