Skip to content

Commit

Permalink
Refactor MixerState (#94)
Browse files Browse the repository at this point in the history
* Refactor MixerState

This extracts the actual audio handling out from MixerState into a separate module with two classes, Player and AudioEngine, because separation of concerns good and 1,000 line file bad.

* Remove unnecessary module

* Fix mic calibration

* rename engine to audioEngine

* Fix a butchered merge

* remove a bunch of unused imports
  • Loading branch information
markspolakovs authored Apr 23, 2020
1 parent df20df4 commit c5da634
Show file tree
Hide file tree
Showing 11 changed files with 454 additions and 414 deletions.
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
"eslint-plugin-jsx-a11y": "6.2.3",
"eslint-plugin-react": "7.14.3",
"eslint-plugin-react-hooks": "^1.6.1",
"eventemitter3": "^4.0.0",
"fetch-progress": "github:UniversityRadioYork/fetch-progress",
"file-loader": "3.0.1",
"fs-extra": "7.0.1",
Expand Down Expand Up @@ -91,6 +92,7 @@
"sass-loader": "7.2.0",
"sdp-transform": "^2.14.0",
"semver": "6.3.0",
"strict-event-emitter-types": "^2.0.0",
"style-loader": "1.0.0",
"terser-webpack-plugin": "1.4.1",
"ts-pnp": "1.1.4",
Expand Down
6 changes: 3 additions & 3 deletions src/broadcast/rtc_streamer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@ import SdpTransform from "sdp-transform";
import * as later from "later";

import * as BroadcastState from "./state";
import * as MixerState from "../mixer/state";

import { Streamer, ConnectionStateEnum } from "./streamer";
import { Dispatch } from "redux";
import { broadcastApiRequest } from "../api";
import { audioEngine } from "../mixer/audio";

type StreamerState = "HELLO" | "OFFER" | "ANSWER" | "CONNECTED";

Expand Down Expand Up @@ -112,7 +112,7 @@ export class WebRTCStreamer extends Streamer {
if (now.getSeconds() < 45) {
later.setTimeout(
async () => {
await MixerState.playNewsIntro();
await audioEngine.playNewsIntro();
},
later.parse
.recur()
Expand All @@ -125,7 +125,7 @@ export class WebRTCStreamer extends Streamer {
if (now.getMinutes() <= 1 && now.getSeconds() < 55) {
later.setTimeout(
async () => {
await MixerState.playNewsEnd();
await audioEngine.playNewsEnd();
},
later.parse
.recur()
Expand Down
8 changes: 6 additions & 2 deletions src/broadcast/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import * as MixerState from "../mixer/state";
import * as NavbarState from "../navbar/state";
import { ConnectionStateEnum } from "./streamer";
import { RecordingStreamer } from "./recording_streamer";
import { audioEngine } from "../mixer/audio";

export let streamer: WebRTCStreamer | null = null;

Expand Down Expand Up @@ -302,7 +303,10 @@ export const goOnAir = (): AppThunk => async (dispatch, getState) => {
return;
}
console.log("starting streamer.");
streamer = new WebRTCStreamer(MixerState.destination.stream, dispatch);
streamer = new WebRTCStreamer(
audioEngine.streamingDestination.stream,
dispatch
);
streamer.addConnectionStateListener((state) => {
dispatch(broadcastState.actions.setConnectionState(state));
if (state === "CONNECTION_LOST") {
Expand All @@ -328,7 +332,7 @@ export const stopStreaming = (): AppThunk => async (dispatch) => {
let recorder: RecordingStreamer;

export const startRecording = (): AppThunk => async (dispatch) => {
recorder = new RecordingStreamer(MixerState.destination.stream);
recorder = new RecordingStreamer(audioEngine.streamingDestination.stream);
recorder.addConnectionStateListener((state) => {
dispatch(broadcastState.actions.setRecordingState(state));
});
Expand Down
277 changes: 277 additions & 0 deletions src/mixer/audio.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,277 @@
import EventEmitter from "eventemitter3";
import StrictEmitter from "strict-event-emitter-types";

import WaveSurfer from "wavesurfer.js";
import CursorPlugin from "wavesurfer.js/dist/plugin/wavesurfer.cursor.min.js";
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";

interface PlayerEvents {
loadComplete: (duration: number) => void;
timeChange: (time: number) => void;
play: () => void;
pause: () => void;
finish: () => void;
}

const PlayerEmitter: StrictEmitter<
EventEmitter,
PlayerEvents
> = EventEmitter as any;

class Player extends ((PlayerEmitter as unknown) as { new (): EventEmitter }) {
private constructor(
private readonly engine: AudioEngine,
private wavesurfer: WaveSurfer,
private readonly waveform: HTMLElement
) {
super();
}

get isPlaying() {
return this.wavesurfer.isPlaying();
}

get currentTime() {
return this.wavesurfer.getCurrentTime();
}

play() {
return this.wavesurfer.play();
}

pause() {
return this.wavesurfer.pause();
}

stop() {
return this.wavesurfer.stop();
}

redraw() {
this.wavesurfer.drawBuffer();
}

setIntro(duration: number) {
this.wavesurfer.addRegion({
id: "intro",
resize: false,
start: 0,
end: duration,
color: "rgba(125,0,255, 0.12)",
});
}

setVolume(val: number) {
this.wavesurfer.setVolume(val);
}

public static create(engine: AudioEngine, player: number, url: string) {
let waveform = document.getElementById("waveform-" + player.toString());
if (waveform == null) {
throw new Error();
}
waveform.innerHTML = "";
const wavesurfer = WaveSurfer.create({
audioContext: engine.audioContext,
container: "#waveform-" + player.toString(),
waveColor: "#CCCCFF",
progressColor: "#9999FF",
backend: "MediaElementWebAudio",
responsive: true,
xhr: {
credentials: "include",
} as any,
plugins: [
CursorPlugin.create({
showTime: true,
opacity: 1,
customShowTimeStyle: {
"background-color": "#000",
color: "#fff",
padding: "2px",
"font-size": "10px",
},
}),
RegionsPlugin.create({}),
],
});

const instance = new this(engine, wavesurfer, waveform);

wavesurfer.on("ready", () => {
console.log("ready");
instance.emit("loadComplete", wavesurfer.getDuration());
});
wavesurfer.on("play", () => {
instance.emit("play");
});
wavesurfer.on("pause", () => {
instance.emit("pause");
});
wavesurfer.on("seek", () => {
instance.emit("timeChange", wavesurfer.getCurrentTime());
});
wavesurfer.on("finish", () => {
instance.emit("finish");
});
wavesurfer.on("audioprocess", () => {
instance.emit("timeChange", wavesurfer.getCurrentTime());
});

(wavesurfer as any).backend.gainNode.disconnect();
(wavesurfer as any).backend.gainNode.connect(engine.finalCompressor);
(wavesurfer as any).backend.gainNode.connect(
engine.audioContext.destination
);

wavesurfer.load(url);

return instance;
}
}

interface EngineEvents {
micOpen: () => void;
}

const EngineEmitter: StrictEmitter<
EventEmitter,
EngineEvents
> = EventEmitter as any;

export class AudioEngine extends ((EngineEmitter as unknown) as {
new (): EventEmitter;
}) {
public audioContext: AudioContext;
public players: (Player | undefined)[] = [];

micMedia: MediaStream | null = null;
micSource: MediaStreamAudioSourceNode | null = null;
micCalibrationGain: GainNode;
micAnalyser: AnalyserNode;
micCompressor: DynamicsCompressorNode;
micMixGain: GainNode;

finalCompressor: DynamicsCompressorNode;
streamingDestination: MediaStreamAudioDestinationNode;

newsStartCountdownEl: HTMLAudioElement;
newsStartCountdownNode: MediaElementAudioSourceNode;

newsEndCountdownEl: HTMLAudioElement;
newsEndCountdownNode: MediaElementAudioSourceNode;

analysisBuffer: Float32Array;

constructor() {
super();
this.audioContext = new AudioContext({
sampleRate: 44100,
latencyHint: "interactive",
});

this.finalCompressor = this.audioContext.createDynamicsCompressor();
this.finalCompressor.ratio.value = 20; //brickwall destination comressor
this.finalCompressor.threshold.value = -0.5;
this.finalCompressor.attack.value = 0;
this.finalCompressor.release.value = 0.2;
this.finalCompressor.connect(this.audioContext.destination);

this.streamingDestination = this.audioContext.createMediaStreamDestination();
this.finalCompressor.connect(this.streamingDestination);

this.micCalibrationGain = this.audioContext.createGain();

this.micAnalyser = this.audioContext.createAnalyser();
this.micAnalyser.fftSize = 8192;

this.analysisBuffer = new Float32Array(this.micAnalyser.fftSize);

this.micCompressor = this.audioContext.createDynamicsCompressor();
this.micCompressor.ratio.value = 3; // mic compressor - fairly gentle, can be upped
this.micCompressor.threshold.value = -18;
this.micCompressor.attack.value = 0.01;
this.micCompressor.release.value = 0.1;

this.micMixGain = this.audioContext.createGain();
this.micMixGain.gain.value = 1;

this.micCalibrationGain
.connect(this.micAnalyser)
.connect(this.micCompressor)
.connect(this.micMixGain)
.connect(this.streamingDestination);

this.newsEndCountdownEl = new Audio(NewsEndCountdown);
this.newsEndCountdownEl.preload = "auto";
this.newsEndCountdownEl.volume = 0.5;
this.newsEndCountdownNode = this.audioContext.createMediaElementSource(
this.newsEndCountdownEl
);
this.newsEndCountdownNode.connect(this.audioContext.destination);

this.newsStartCountdownEl = new Audio(NewsIntro);
this.newsStartCountdownEl.preload = "auto";
this.newsStartCountdownEl.volume = 0.5;
this.newsStartCountdownNode = this.audioContext.createMediaElementSource(
this.newsStartCountdownEl
);
this.newsStartCountdownNode.connect(this.audioContext.destination);
}

public createPlayer(number: number, url: string) {
const player = Player.create(this, number, url);
this.players[number] = player;
return player;
}

async openMic(deviceId: string) {
console.log("opening mic", deviceId);
this.micMedia = await navigator.mediaDevices.getUserMedia({
audio: {
deviceId: { exact: deviceId },
echoCancellation: false,
autoGainControl: false,
noiseSuppression: false,
latency: 0.01,
},
});

this.micSource = this.audioContext.createMediaStreamSource(this.micMedia);

this.micSource.connect(this.micCalibrationGain);

this.emit("micOpen");
}

setMicCalibrationGain(value: number) {
this.micCalibrationGain.gain.value = value;
}

setMicVolume(value: number) {
this.micMixGain.gain.value = value;
}

getMicLevel() {
this.micAnalyser.getFloatTimeDomainData(this.analysisBuffer);
let peak = 0;
for (let i = 0; i < this.analysisBuffer.length; i++) {
peak = Math.max(peak, this.analysisBuffer[i] ** 2);
}
return 10 * Math.log10(peak);
}

async playNewsEnd() {
this.newsEndCountdownEl.currentTime = 0;
await this.newsEndCountdownEl.play();
}

async playNewsIntro() {
this.newsStartCountdownEl.currentTime = 0;
await this.newsStartCountdownEl.play();
}
}

export const audioEngine = new AudioEngine();
Loading

0 comments on commit c5da634

Please sign in to comment.