diff --git a/src/broadcast/state.ts b/src/broadcast/state.ts index ed86b3c2..83391997 100644 --- a/src/broadcast/state.ts +++ b/src/broadcast/state.ts @@ -1,4 +1,4 @@ -import { createSlice, PayloadAction } from "@reduxjs/toolkit"; +import { createSlice, Middleware, PayloadAction } from "@reduxjs/toolkit"; import { AppThunk } from "../store"; import { myradioApiRequest, broadcastApiRequest, ApiException } from "../api"; import { WebRTCStreamer } from "./rtc_streamer"; @@ -7,6 +7,8 @@ import * as NavbarState from "../navbar/state"; import { ConnectionStateEnum } from "./streamer"; import { RecordingStreamer } from "./recording_streamer"; import { audioEngine } from "../mixer/audio"; +import * as audioActions from "../mixer/audio/actions"; +import { RootState } from "../rootReducer"; export let streamer: WebRTCStreamer | null = null; @@ -24,6 +26,7 @@ interface BroadcastState { autoNewsMiddle: boolean; autoNewsEnd: boolean; liveForThePurposesOfTracklisting: boolean; + tracklistItemId: number; connectionState: ConnectionStateEnum; recordingState: ConnectionStateEnum; } @@ -45,6 +48,7 @@ const broadcastState = createSlice({ autoNewsMiddle: true, autoNewsEnd: true, liveForThePurposesOfTracklisting: false, + tracklistItemId: -1, connectionState: "NOT_CONNECTED", recordingState: "NOT_CONNECTED", } as BroadcastState, @@ -346,3 +350,82 @@ export const stopRecording = (): AppThunk => async (dispatch) => { console.warn("stopRecording called with no recorder!"); } }; + +export const tracklistMiddleware: Middleware<{}, RootState> = (store) => { + let lastTracklistedTrackId = -1; + let lastAudioLogId = -1; + function considerTracklisting(playerIdx: number) { + /* + Checks before tracklisting something: + * we're actually playing + * it's a song + * it's faded up + * it's not the last thing we tracklisted + */ + const playerState = store.getState().mixer.players[playerIdx]; + const loadedItem = playerState.loadedItem; + if (playerState.state === "playing") { + if (loadedItem) { + if ("recordid" in loadedItem) { + if (playerState.volume > 0) { + if (loadedItem.trackid !== lastTracklistedTrackId) { + lastTracklistedTrackId = loadedItem.trackid; + sendTracklistStart(loadedItem.trackid) + .then((item) => { + lastAudioLogId = item.audiologid; + // Check if the item was stopped in the meantime, and immediately end the tracklist if so. + const newPlayerState = store.getState().mixer.players[ + playerIdx + ]; + if (newPlayerState.state === "stopped") { + return myradioApiRequest( + "/tracklistItem/" + lastAudioLogId + "/endtime", + "PUT", + {} + ).then(() => { + lastAudioLogId = -1; + lastTracklistedTrackId = -1; + }); + } + }) + .catch(console.error); + } + } + } + } + } + } + function considerEndingTracklist(playerIdx: number) { + const playerState = store.getState().mixer.players[playerIdx]; + const loadedItem = playerState.loadedItem; + if (loadedItem && "recordid" in loadedItem) { + if (lastTracklistedTrackId === loadedItem.trackid) { + if (lastAudioLogId > -1) { + // race condition handled above + myradioApiRequest( + "/tracklistItem/" + lastAudioLogId + "/endtime", + "PUT", + {} + ) + .then(() => { + lastAudioLogId = -1; + lastTracklistedTrackId = -1; + }) + .catch(console.error); + } + } + } + } + return (next) => (action) => { + if ( + MixerState.play.match(action) || + MixerState.setPlayerVolume.match(action) + ) { + considerTracklisting(action.payload.player); + } + if (MixerState.stop.match(action) || audioActions.finished.match(action)) { + considerEndingTracklist(action.payload.player); + } + return next(action); + }; +}; diff --git a/src/mixer/audio/actions.ts b/src/mixer/audio/actions.ts new file mode 100644 index 00000000..9dbec569 --- /dev/null +++ b/src/mixer/audio/actions.ts @@ -0,0 +1,20 @@ +import { createAction } from "@reduxjs/toolkit"; +import { MicErrorEnum } from "./types"; + +export const itemLoadComplete = createAction<{ + player: number; + duration: number; +}>("Audio/itemLoadComplete"); + +export const timeChange = createAction<{ + player: number; + currentTime: number; +}>("Audio/timeChange"); + +export const finished = createAction<{ + player: number; +}>("Audio/finished"); + +export const micOpenError = createAction<{ + code: null | MicErrorEnum; +}>("Audio/micOpenError"); diff --git a/src/mixer/audio.ts b/src/mixer/audio/index.ts similarity index 64% rename from src/mixer/audio.ts rename to src/mixer/audio/index.ts index b62d0142..b81ca5ee 100644 --- a/src/mixer/audio.ts +++ b/src/mixer/audio/index.ts @@ -1,38 +1,56 @@ import EventEmitter from "eventemitter3"; import StrictEmitter from "strict-event-emitter-types"; +import { Action, Dispatch, Middleware } from "@reduxjs/toolkit"; 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"; import StereoAnalyserNode from "stereo-analyser-node"; +import { RootState } from "../../rootReducer"; +import * as AudioActions from "./actions"; -interface PlayerEvents { - loadComplete: (duration: number) => void; - timeChange: (time: number) => void; - play: () => void; - pause: () => void; - finish: () => void; +import NewsEndCountdown from "../assets/audio/NewsEndCountdown.wav"; +import NewsIntro from "../assets/audio/NewsIntro.wav"; +import { MicErrorEnum as MicOpenErrorEnum, PlayerStateEnum } from "./types"; + +// I'd really quite like to do this, and TypeScript understands it, +// but Prettier doesn't! Argh! +// export * as actions from "./actions"; +// export * as types from "./types"; + +interface PlayerState { + /** + * This should only be null when the player hasn't had anything loaded into it. + * If you set this null *after* initialising a Player, bad things will happen! + */ + loadedUrl: string | null; + state: PlayerStateEnum; + volume: number; + trim: number; + timeCurrent: number; + /** + * If we only had timeCurrent, the player would seek every time + * its position changed. Instead, it only seeks when this flag is set. + */ + timeCurrentSeek: boolean; + intro?: number; + cue?: number; + outro?: number; + sinkID: string | null; } -const PlayerEmitter: StrictEmitter< - EventEmitter, - PlayerEvents -> = EventEmitter as any; - -class Player extends ((PlayerEmitter as unknown) as { new (): EventEmitter }) { +class Player { private volume = 0; private trim = 0; + loadedUrl!: string; private constructor( private readonly engine: AudioEngine, + private readonly idx: number, private wavesurfer: WaveSurfer, private readonly waveform: HTMLElement, private readonly customOutput: boolean - ) { - super(); - } + ) {} get isPlaying() { return this.wavesurfer.isPlaying(); @@ -42,15 +60,15 @@ class Player extends ((PlayerEmitter as unknown) as { new (): EventEmitter }) { return this.wavesurfer.getCurrentTime(); } - play() { + private play() { return this.wavesurfer.play(); } - pause() { + private pause() { return this.wavesurfer.pause(); } - stop() { + private stop() { return this.wavesurfer.stop(); } @@ -58,11 +76,11 @@ class Player extends ((PlayerEmitter as unknown) as { new (): EventEmitter }) { this.wavesurfer.drawBuffer(); } - setCurrentTime(secs: number) { + private setCurrentTime(secs: number) { this.wavesurfer.setCurrentTime(secs); } - setIntro(duration: number) { + private setIntro(duration: number) { if ("intro" in this.wavesurfer.regions.list) { this.wavesurfer.regions.list.intro.end = duration; this.redraw(); @@ -79,7 +97,7 @@ class Player extends ((PlayerEmitter as unknown) as { new (): EventEmitter }) { }); } - setCue(startTime: number) { + private setCue(startTime: number) { const duration = this.wavesurfer.getDuration(); const cueWidth = 0.01 * duration; // Cue region marker to be 1% of track length if ("cue" in this.wavesurfer.regions.list) { @@ -99,7 +117,7 @@ class Player extends ((PlayerEmitter as unknown) as { new (): EventEmitter }) { }); } - setOutro(startTime: number) { + private setOutro(startTime: number) { if ("outro" in this.wavesurfer.regions.list) { // If the outro is set to 0, we assume that's no outro. if (startTime === 0) { @@ -130,17 +148,23 @@ class Player extends ((PlayerEmitter as unknown) as { new (): EventEmitter }) { return this.volume; } - setVolume(val: number) { - this.volume = val; - this._applyVolume(); - } - - setTrim(val: number) { - this.trim = val; - this._applyVolume(); + private _applyVolume() { + const level = this.volume + this.trim; + const linear = Math.pow(10, level / 20); + if (linear < 1) { + this.wavesurfer.setVolume(linear); + if (!this.customOutput) { + (this.wavesurfer as any).backend.gainNode.gain.value = 1; + } + } else { + this.wavesurfer.setVolume(1); + if (!this.customOutput) { + (this.wavesurfer as any).backend.gainNode.gain.value = linear; + } + } } - setOutputDevice(sinkId: string) { + private setOutputDevice(sinkId: string) { if (!this.customOutput) { throw Error( "Can't set sinkId when player is not in customOutput mode. Please reinit player." @@ -153,30 +177,68 @@ class Player extends ((PlayerEmitter as unknown) as { new (): EventEmitter }) { } } - _applyVolume() { - const level = this.volume + this.trim; - const linear = Math.pow(10, level / 20); - if (linear < 1) { - this.wavesurfer.setVolume(linear); - if (!this.customOutput) { - (this.wavesurfer as any).backend.gainNode.gain.value = 1; - } - } else { - this.wavesurfer.setVolume(1); - if (!this.customOutput) { - (this.wavesurfer as any).backend.gainNode.gain.value = linear; - } + public onStateChange(state: PlayerState) { + if (state.loadedUrl !== this.loadedUrl) { + throw new Error( + "PlayerState.loadedUrl changed. This can't be done via onStateChanged, please recreate the player!" + ); + } + switch (state.state) { + case "stopped": + if (this.isPlaying) { + this.stop(); + } + break; + case "paused": + if (this.isPlaying) { + this.pause(); + } + break; + case "playing": + if (!this.isPlaying) { + this.play(); + } + break; + } + + this.volume = state.volume; + this.trim = state.trim; + this._applyVolume(); + + if (state.timeCurrentSeek) { + this.setCurrentTime(state.timeCurrent); + } + + if (state.intro) { + this.setIntro(state.intro); + } + + if (state.cue) { + this.setCue(state.cue); + } + + if (state.outro) { + this.setOutro(state.outro); + } + + if (state.sinkID) { + this.setOutputDevice(state.sinkID); } } public static create( engine: AudioEngine, player: number, - outputId: string, - url: string + state: PlayerState ) { + if (state.loadedUrl === null) { + throw new Error( + "Tried to create a player with PlayerState.loadedUrl null" + ); + } + // If we want to output to a custom audio device, we're gonna need to do things differently. - const customOutput = outputId !== "internal"; + const customOutput = state.sinkID !== null && state.sinkID !== "internal"; let waveform = document.getElementById("waveform-" + player.toString()); if (waveform == null) { @@ -212,33 +274,49 @@ class Player extends ((PlayerEmitter as unknown) as { new (): EventEmitter }) { ], }); - const instance = new this(engine, wavesurfer, waveform, customOutput); + const instance = new this( + engine, + player, + wavesurfer, + waveform, + customOutput + ); + instance.loadedUrl = state.loadedUrl; wavesurfer.on("ready", () => { console.log("ready"); - instance.emit("loadComplete", wavesurfer.getDuration()); - }); - wavesurfer.on("play", () => { - instance.emit("play"); - }); - wavesurfer.on("pause", () => { - instance.emit("pause"); + engine.dispatch( + AudioActions.itemLoadComplete({ + player, + duration: wavesurfer.getDuration(), + }) + ); }); wavesurfer.on("seek", () => { - instance.emit("timeChange", wavesurfer.getCurrentTime()); + engine.dispatch( + AudioActions.timeChange({ + player, + currentTime: wavesurfer.getCurrentTime(), + }) + ); }); wavesurfer.on("finish", () => { - instance.emit("finish"); + engine.dispatch(AudioActions.finished({ player })); }); wavesurfer.on("audioprocess", () => { - instance.emit("timeChange", wavesurfer.getCurrentTime()); + engine.dispatch( + AudioActions.timeChange({ + player, + currentTime: wavesurfer.getCurrentTime(), + }) + ); }); - wavesurfer.load(url); + wavesurfer.load(state.loadedUrl); if (customOutput) { try { - instance.setOutputDevice(outputId); + instance.setOutputDevice(state.sinkID!); } catch (e) { console.error("Failed to set channel " + player + " output. " + e); } @@ -282,6 +360,54 @@ export type ChannelMapping = // Must be a power of 2. const ANALYSIS_FFT_SIZE = 2048; +interface AudioEngineState { + micDeviceId: string | null; + micChannelMapping: ChannelMapping; + micCalibrationGain: number; + micVolume: number; + micProcessingEnabled: boolean; + players: PlayerState[]; +} + +function rootStateToAudioEngineState(state: RootState): AudioEngineState { + return { + micDeviceId: state.mixer.mic.id, + micChannelMapping: "mono-both", // TODO + micCalibrationGain: state.mixer.mic.baseGain, + micVolume: state.mixer.mic.volume, + micProcessingEnabled: state.mixer.mic.processing, + players: state.mixer.players.map((p, idx) => { + const result: PlayerState = { + loadedUrl: p.loadedItemUrl, + state: p.state, + timeCurrent: p.timeCurrent, + timeCurrentSeek: p.shouldSeekOnTimeCurrentChange, + volume: p.volume, + trim: p.trim, + sinkID: state.settings.channelOutputIds[idx], + intro: undefined, + cue: undefined, + outro: undefined, + }; + + const loadedItem = p.loadedItem; + if (loadedItem) { + if ("intro" in loadedItem) { + result.intro = loadedItem.intro; + } + if ("outro" in loadedItem) { + result.outro = loadedItem.outro; + } + if ("cue" in loadedItem) { + result.cue = loadedItem.cue; + } + } + + return result; + }), + }; +} + interface EngineEvents { micOpen: () => void; } @@ -294,6 +420,11 @@ const EngineEmitter: StrictEmitter< export class AudioEngine extends ((EngineEmitter as unknown) as { new (): EventEmitter; }) { + // The ! is to avoid a chicken-and-egg problem - the middleware needs the engine, while the engine + // needs dispatch from the middleware. + // Don't mess with this. + dispatch!: Dispatch; + // Multipurpose Bits public audioContext: AudioContext; analysisBuffer: Float32Array; @@ -301,12 +432,22 @@ export class AudioEngine extends ((EngineEmitter as unknown) as { // Mic Input + micDeviceId: string | null = null; + micChannelMapping: ChannelMapping | null = null; + micMedia: MediaStream | null = null; micSource: MediaStreamAudioSourceNode | null = null; + + micCalibrationGainValDb: number; micCalibrationGain: GainNode; + + micProcessingEnabled: boolean = true; micPrecompAnalyser: typeof StereoAnalyserNode; micCompressor: DynamicsCompressorNode; + + micMixGainValLinear: number; micMixGain: GainNode; + micFinalAnalyser: typeof StereoAnalyserNode; // Player Inputs @@ -342,6 +483,7 @@ export class AudioEngine extends ((EngineEmitter as unknown) as { // Mic Input this.micCalibrationGain = this.audioContext.createGain(); + this.micCalibrationGainValDb = 0; this.micPrecompAnalyser = new StereoAnalyserNode(this.audioContext); this.micPrecompAnalyser.fftSize = ANALYSIS_FFT_SIZE; @@ -356,6 +498,7 @@ export class AudioEngine extends ((EngineEmitter as unknown) as { this.micMixGain = this.audioContext.createGain(); this.micMixGain.gain.value = 1; + this.micMixGainValLinear = 1; this.micFinalAnalyser = new StereoAnalyserNode(this.audioContext); this.micFinalAnalyser.fftSize = ANALYSIS_FFT_SIZE; @@ -429,8 +572,8 @@ export class AudioEngine extends ((EngineEmitter as unknown) as { this.newsEndCountdownNode.connect(this.audioContext.destination); } - public createPlayer(number: number, outputId: string, url: string) { - const player = Player.create(this, number, outputId, url); + private createPlayer(number: number, state: PlayerState) { + const player = Player.create(this, number, state); this.players[number] = player; return player; } @@ -443,7 +586,7 @@ export class AudioEngine extends ((EngineEmitter as unknown) as { } // Wavesurfer needs cleanup to remove the old audio mediaelements. Memory leak! - public destroyPlayerIfExists(number: number) { + private destroyPlayerIfExists(number: number) { const existingPlayer = this.players[number]; if (existingPlayer !== undefined) { // already a player setup. Clean it. @@ -452,23 +595,55 @@ export class AudioEngine extends ((EngineEmitter as unknown) as { this.players[number] = undefined; } - async openMic(deviceId: string, channelMapping: ChannelMapping) { + private async openMic(deviceId: string, channelMapping: ChannelMapping) { if (this.micSource !== null && this.micMedia !== null) { this.micMedia.getAudioTracks()[0].stop(); this.micSource.disconnect(); this.micSource = null; this.micMedia = null; } - console.log("opening mic", deviceId); - this.micMedia = await navigator.mediaDevices.getUserMedia({ - audio: { - deviceId: { exact: deviceId }, - echoCancellation: false, - autoGainControl: false, - noiseSuppression: false, - latency: 0.01, - }, - }); + if (this.audioContext.state !== "running") { + console.log("Resuming AudioContext because Chrome bad"); + await this.audioContext.resume(); + } + console.log("opening mic", deviceId, channelMapping); + this.micDeviceId = deviceId; + this.micChannelMapping = channelMapping; + + if (!("mediaDevices" in navigator)) { + // mediaDevices is not there - we're probably not in a secure context + this.dispatch(AudioActions.micOpenError({ code: "NOT_SECURE_CONTEXT" })); + return; + } + + try { + this.micMedia = await navigator.mediaDevices.getUserMedia({ + audio: { + deviceId: { exact: deviceId }, + echoCancellation: false, + autoGainControl: false, + noiseSuppression: false, + latency: 0.01, + }, + }); + } catch (e) { + let error: MicOpenErrorEnum; + if (e instanceof DOMException) { + switch (e.message) { + case "Permission denied": + error = "NO_PERMISSION"; + break; + default: + error = "UNKNOWN"; + } + } else { + error = "UNKNOWN"; + } + this.dispatch(AudioActions.micOpenError({ code: error })); + return; + } + + this.dispatch(AudioActions.micOpenError({ code: null })); this.micSource = this.audioContext.createMediaStreamSource(this.micMedia); @@ -505,16 +680,18 @@ export class AudioEngine extends ((EngineEmitter as unknown) as { this.emit("micOpen"); } - setMicCalibrationGain(value: number) { + private setMicCalibrationGain(value: number) { this.micCalibrationGain.gain.value = value === 0 ? 1 : Math.pow(10, value / 20); + this.micCalibrationGainValDb = value; } - setMicVolume(value: number) { + private setMicVolume(value: number) { this.micMixGain.gain.value = value; + this.micMixGainValLinear = value; } - setMicProcessingEnabled(value: boolean) { + private setMicProcessingEnabled(value: boolean) { /* * Disconnect whatever was connected before. * It's either connected to micCompressor or micMixGain @@ -532,6 +709,49 @@ export class AudioEngine extends ((EngineEmitter as unknown) as { } } + onStateChange(state: AudioEngineState) { + if (state.micCalibrationGain !== this.micCalibrationGainValDb) { + this.setMicCalibrationGain(state.micCalibrationGain); + } + + if (state.micVolume != this.micMixGainValLinear) { + this.setMicVolume(state.micVolume); + } + + if ( + state.micDeviceId != this.micDeviceId || + state.micChannelMapping != this.micChannelMapping + ) { + if (state.micDeviceId !== null) { + this.openMic(state.micDeviceId, state.micChannelMapping); + } + } + + if (state.micProcessingEnabled != this.micProcessingEnabled) { + this.setMicProcessingEnabled(state.micProcessingEnabled); + } + + state.players.forEach((playerState, idx) => { + const player = this.players[idx]; + if (!player) { + console.warn( + `Got a state update for player ${idx} that doesn't exist!` + ); + return; + } + // If we've loaded in a different item, recreate the player + if (player.loadedUrl != playerState.loadedUrl) { + this.destroyPlayerIfExists(idx); + if (playerState.loadedUrl !== null) { + this.createPlayer(idx, playerState); + } + } else { + // If it's the same thing, updating its state will suffice. + player.onStateChange(playerState); + } + }); + } + getLevels(source: LevelsSource, stereo: boolean): [number, number] { switch (source) { case "mic-precomp": @@ -601,5 +821,20 @@ export class AudioEngine extends ((EngineEmitter as unknown) as { } } +function createAudioEngineMiddleware( + engine: AudioEngine +): Middleware<{}, RootState> { + return (store) => { + engine.dispatch = store.dispatch; + return (next) => (action) => { + const nextState = next(action); + const aeState = rootStateToAudioEngineState(nextState); + engine.onStateChange(aeState); + return nextState; + }; + }; +} + export const audioEngine = new AudioEngine(); (window as any).AE = audioEngine; +export const audioEngineMiddleware = createAudioEngineMiddleware(audioEngine); diff --git a/src/mixer/audio/types.ts b/src/mixer/audio/types.ts new file mode 100644 index 00000000..7d4878bb --- /dev/null +++ b/src/mixer/audio/types.ts @@ -0,0 +1,5 @@ +export type PlayerStateEnum = "playing" | "paused" | "stopped"; +export type PlayerRepeatEnum = "none" | "one" | "all"; +export type VolumePresetEnum = "off" | "bed" | "full"; +export type MicVolumePresetEnum = "off" | "full"; +export type MicErrorEnum = "NO_PERMISSION" | "NOT_SECURE_CONTEXT" | "UNKNOWN"; diff --git a/src/mixer/state.ts b/src/mixer/state.ts index 6c1847c5..33241f65 100644 --- a/src/mixer/state.ts +++ b/src/mixer/state.ts @@ -13,43 +13,41 @@ import { Track, MYRADIO_NON_API_BASE, AuxItem } from "../api"; import { AppThunk } from "../store"; import { RootState } from "../rootReducer"; import { audioEngine, ChannelMapping } from "./audio"; +import * as audioActions from "./audio/actions"; +import * as audioTypes from "./audio/types"; import * as TheNews from "./the_news"; const playerGainTweens: Array<{ - target: VolumePresetEnum; + target: audioTypes.VolumePresetEnum; tweens: Between[]; }> = []; const loadAbortControllers: AbortController[] = []; const lastObjectURLs: string[] = []; -type PlayerStateEnum = "playing" | "paused" | "stopped"; -type PlayerRepeatEnum = "none" | "one" | "all"; -type VolumePresetEnum = "off" | "bed" | "full"; -type MicVolumePresetEnum = "off" | "full"; -export type MicErrorEnum = "NO_PERMISSION" | "NOT_SECURE_CONTEXT" | "UNKNOWN"; - const defaultTrimDB = -6; // The default trim applied to channel players. interface PlayerState { loadedItem: PlanItem | Track | AuxItem | null; + loadedItemUrl: string | null; loading: number; loadError: boolean; - state: PlayerStateEnum; + state: audioTypes.PlayerStateEnum; volume: number; gain: number; trim: number; timeCurrent: number; timeRemaining: number; timeLength: number; - playOnLoad: Boolean; - autoAdvance: Boolean; - repeat: PlayerRepeatEnum; + shouldSeekOnTimeCurrentChange: boolean; + playOnLoad: boolean; + autoAdvance: boolean; + repeat: audioTypes.PlayerRepeatEnum; tracklistItemID: number; } interface MicState { open: boolean; - openError: null | MicErrorEnum; + openError: null | audioTypes.MicErrorEnum; volume: 1 | 0; baseGain: number; id: string | null; @@ -63,6 +61,7 @@ interface MixerState { const BasePlayerState: PlayerState = { loadedItem: null, + loadedItemUrl: null, loading: -1, state: "stopped", volume: 1, @@ -71,6 +70,7 @@ const BasePlayerState: PlayerState = { timeCurrent: 0, timeRemaining: 0, timeLength: 0, + shouldSeekOnTimeCurrentChange: false, playOnLoad: false, autoAdvance: true, repeat: "none", @@ -127,6 +127,12 @@ const mixerState = createSlice({ ) { state.players[action.payload.player].loading = action.payload.percent; }, + itemDownloaded( + state, + action: PayloadAction<{ player: number; url: string }> + ) { + state.players[action.payload.player].loadedItemUrl = action.payload.url; + }, itemLoadComplete(state, action: PayloadAction<{ player: number }>) { state.players[action.payload.player].loading = -1; }, @@ -134,11 +140,73 @@ const mixerState = createSlice({ state.players[action.payload.player].loading = -1; state.players[action.payload.player].loadError = true; }, - setPlayerState( - state, - action: PayloadAction<{ player: number; state: PlayerStateEnum }> - ) { - state.players[action.payload.player].state = action.payload.state; + // setPlayerState( + // state, + // action: PayloadAction<{ player: number; state: PlayerStateEnum }> + // ) { + // state.players[action.payload.player].state = action.payload.state; + // }, + play(state, action: PayloadAction<{ player: number }>) { + const playerState = state.players[action.payload.player]; + if (playerState.loadedItemUrl === null) { + console.log("nothing loaded"); + return; + } + if (playerState.loading !== -1) { + console.log("not ready"); + return; + } + + state.players[action.payload.player].state = "playing"; + + // TODO: this needs to move to showplan state as an extraAction + /* + dispatch( + setItemPlayed({ itemId: itemId(state.loadedItem), played: true }) + ); + */ + }, + pause(state, action: PayloadAction<{ player: number }>) { + const playerState = state.players[action.payload.player]; + if (playerState.loadedItemUrl === null) { + console.log("nothing loaded"); + return; + } + if (playerState.loading !== -1) { + console.log("not ready"); + return; + } + + state.players[action.payload.player].state = + playerState.timeCurrent === 0 ? "stopped" : "paused"; + }, + stop(state, action: PayloadAction<{ player: number }>) { + const player = action.payload.player; + const playerState = state.players[player]; + if (playerState.loadedItemUrl === null) { + console.log("nothing loaded"); + return; + } + if (playerState.loading !== -1) { + console.log("not ready"); + return; + } + + state.players[player].state = "stopped"; + + let cueTime = 0; + + if ( + playerState.loadedItem && + "cue" in playerState.loadedItem && + Math.round(playerState.timeCurrent) !== + Math.round(playerState.loadedItem.cue) + ) { + cueTime = playerState.loadedItem.cue; + } + + state.players[player].timeCurrent = cueTime; + state.players[player].shouldSeekOnTimeCurrentChange = true; }, setPlayerVolume( state, @@ -203,9 +271,6 @@ const mixerState = createSlice({ loadedItem.outro = action.payload.secs; } }, - setMicError(state, action: PayloadAction) { - state.mic.openError = action.payload; - }, micOpen(state, action) { state.mic.open = true; state.mic.id = action.payload; @@ -231,15 +296,6 @@ const mixerState = createSlice({ state.players[action.payload.player].timeLength - action.payload.time; state.players[action.payload.player].timeRemaining = timeRemaining; }, - setTimeLength( - state, - action: PayloadAction<{ - player: number; - time: number; - }> - ) { - state.players[action.payload.player].timeLength = action.payload.time; - }, toggleAutoAdvance( state, action: PayloadAction<{ @@ -290,44 +346,98 @@ const mixerState = createSlice({ state.players[action.payload.player].tracklistItemID = action.payload.id; }, }, + extraReducers: (builder) => + builder + .addCase(audioActions.itemLoadComplete, (state, action) => { + const player = action.payload.player; + const loadedItem = state.players[player].loadedItem; + state.players[player].loading = -1; + state.players[player].timeLength = action.payload.duration; + if ( + loadedItem !== null && + "cue" in loadedItem && + loadedItem.cue !== 0 + ) { + state.players[player].timeCurrent = loadedItem.cue; + } else { + state.players[player].timeCurrent = 0; + } + state.players[player].timeRemaining = + action.payload.duration - state.players[player].timeCurrent; + if (state.players[player].playOnLoad) { + state.players[player].state = "playing"; + } else { + state.players[player].state = "stopped"; + } + }) + .addCase(audioActions.timeChange, (state, action) => { + const player = action.payload.player; + state.players[player].timeCurrent = action.payload.currentTime; + state.players[player].shouldSeekOnTimeCurrentChange = false; + }) + .addCase(audioActions.finished, (state, action) => { + const player = action.payload.player; + const playerState = state.players[player]; + if (playerState.repeat === "one") { + // Right back round you go! + state.players[player].timeCurrent = 0; + state.players[player].shouldSeekOnTimeCurrentChange = true; + } + // TODO: this needs to move to showplan state as an extraAction + /* + if (state.repeat === "all") { + if ("channel" in item) { + // it's not in the CML/libraries "column" + const itsChannel = getState() + .showplan.plan!.filter((x) => x.channel === item.channel) + .sort((x, y) => x.weight - y.weight); + const itsIndex = itsChannel.indexOf(item); + if (itsIndex === itsChannel.length - 1) { + dispatch(load(player, itsChannel[0])); + } + } + } else if (state.autoAdvance) { + if ("channel" in item) { + // it's not in the CML/libraries "column" + const itsChannel = getState() + .showplan.plan!.filter((x) => x.channel === item.channel) + .sort((x, y) => x.weight - y.weight); + // Sadly, we can't just do .indexOf() item directly, + // since the player's idea of an item may be changed over it's lifecycle (setting played,intro/cue/outro etc.) + // Therefore we'll find the updated item from the plan and match that. + const itsIndex = itsChannel.findIndex( + (x) => itemId(x) === itemId(item) + ); + if (itsIndex > -1 && itsIndex !== itsChannel.length - 1) { + dispatch(load(player, itsChannel[itsIndex + 1])); + } + } + } + }); + */ + }) + .addCase(audioActions.micOpenError, (state, action) => { + state.mic.openError = action.payload.code; + }), }); export default mixerState.reducer; -export const { setMicBaseGain } = mixerState.actions; - -export const setLoadedItemIntro = ( - player: number, - secs: number -): AppThunk => async (dispatch) => { - dispatch(mixerState.actions.setLoadedItemIntro({ player, secs })); - const playerInstance = audioEngine.getPlayer(player); - if (playerInstance) { - playerInstance.setIntro(secs); - } -}; - -export const setLoadedItemCue = ( - player: number, - secs: number -): AppThunk => async (dispatch) => { - dispatch(mixerState.actions.setLoadedItemCue({ player, secs })); - const playerInstance = audioEngine.getPlayer(player); - if (playerInstance) { - playerInstance.setCue(secs); - } -}; - -export const setLoadedItemOutro = ( - player: number, - secs: number -): AppThunk => async (dispatch) => { - dispatch(mixerState.actions.setLoadedItemOutro({ player, secs })); - const playerInstance = audioEngine.getPlayer(player); - if (playerInstance) { - playerInstance.setOutro(secs); - } -}; +export const { + setMicBaseGain, + setLoadedItemIntro, + setLoadedItemCue, + setLoadedItemOutro, + setPlayerTrim, + toggleAutoAdvance, + togglePlayOnLoad, + toggleRepeat, + setMicProcessingEnabled, + play, + pause, + stop, + setPlayerVolume, +} = mixerState.actions; export const load = ( player: number, @@ -396,7 +506,6 @@ export const load = ( if (waveform == null) { throw new Error(); } - audioEngine.destroyPlayerIfExists(player); // clear previous (ghost) wavesurfer and it's media elements. // wavesurfer also sets the background white, remove for progress bar to work. waveform.style.removeProperty("background"); @@ -418,130 +527,24 @@ export const load = ( }, }) ); - const rawData = await result.arrayBuffer(); - const blob = new Blob([rawData]); - const objectUrl = URL.createObjectURL(blob); - - const channelOutputId = getState().settings.channelOutputIds[player]; - - const playerInstance = await audioEngine.createPlayer( - player, - channelOutputId, - objectUrl - ); - // Clear the last one out from memory if (typeof lastObjectURLs[player] === "string") { URL.revokeObjectURL(lastObjectURLs[player]); } - lastObjectURLs[player] = objectUrl; - - playerInstance.on("loadComplete", (duration) => { - console.log("loadComplete"); - dispatch(mixerState.actions.itemLoadComplete({ player })); - dispatch( - mixerState.actions.setTimeLength({ - player, - time: duration, - }) - ); - dispatch( - mixerState.actions.setTimeCurrent({ - player, - time: 0, - }) - ); - const state = getState().mixer.players[player]; - if (state.playOnLoad) { - playerInstance.play(); - } - if (state.loadedItem && "intro" in state.loadedItem) { - playerInstance.setIntro(state.loadedItem.intro); - } - if (state.loadedItem && "cue" in state.loadedItem) { - playerInstance.setCue(state.loadedItem.cue); - playerInstance.setCurrentTime(state.loadedItem.cue); - } - if (state.loadedItem && "outro" in state.loadedItem) { - playerInstance.setOutro(state.loadedItem.outro); - } - }); - - playerInstance.on("play", () => { - dispatch(mixerState.actions.setPlayerState({ player, state: "playing" })); - - const state = getState().mixer.players[player]; - if (state.loadedItem != null) { - dispatch( - setItemPlayed({ itemId: itemId(state.loadedItem), played: true }) - ); - } - }); - playerInstance.on("pause", () => { - dispatch( - mixerState.actions.setPlayerState({ - player, - state: playerInstance.currentTime === 0 ? "stopped" : "paused", - }) - ); - }); - playerInstance.on("timeChange", (time) => { - if (Math.abs(time - getState().mixer.players[player].timeCurrent) > 0.5) { - dispatch( - mixerState.actions.setTimeCurrent({ - player, - time, - }) - ); - } - }); - playerInstance.on("finish", () => { - dispatch(mixerState.actions.setPlayerState({ player, state: "stopped" })); - const state = getState().mixer.players[player]; - if (state.tracklistItemID !== -1) { - dispatch(BroadcastState.tracklistEnd(state.tracklistItemID)); - } - if (state.repeat === "one") { - playerInstance.play(); - } else if (state.repeat === "all") { - if ("channel" in item) { - // it's not in the CML/libraries "column" - const itsChannel = getState() - .showplan.plan!.filter((x) => x.channel === item.channel) - .sort((x, y) => x.weight - y.weight); - const itsIndex = itsChannel.indexOf(item); - if (itsIndex === itsChannel.length - 1) { - dispatch(load(player, itsChannel[0])); - } - } - } else if (state.autoAdvance) { - if ("channel" in item) { - // it's not in the CML/libraries "column" - const itsChannel = getState() - .showplan.plan!.filter((x) => x.channel === item.channel) - .sort((x, y) => x.weight - y.weight); - // Sadly, we can't just do .indexOf() item directly, - // since the player's idea of an item may be changed over it's lifecycle (setting played,intro/cue/outro etc.) - // Therefore we'll find the updated item from the plan and match that. - const itsIndex = itsChannel.findIndex( - (x) => itemId(x) === itemId(item) - ); - if (itsIndex > -1 && itsIndex !== itsChannel.length - 1) { - dispatch(load(player, itsChannel[itsIndex + 1])); - } - } - } - }); // Double-check we haven't been aborted since if (signal.aborted) { // noinspection ExceptionCaughtLocallyJS throw new DOMException("abort load", "AbortError"); } - - playerInstance.setVolume(getState().mixer.players[player].gain); - playerInstance.setTrim(getState().mixer.players[player].trim); delete loadAbortControllers[player]; + + const rawData = await result.arrayBuffer(); + const blob = new Blob([rawData]); + const objectUrl = URL.createObjectURL(blob); + + dispatch(mixerState.actions.itemDownloaded({ player, url: objectUrl })); + lastObjectURLs[player] = objectUrl; } catch (e) { if ("name" in e && e.name === "AbortError") { // load was aborted, ignore the error @@ -552,93 +555,6 @@ export const load = ( } }; -export const play = (player: number): AppThunk => async ( - dispatch, - getState -) => { - if (typeof audioEngine.players[player] === "undefined") { - console.log("nothing loaded"); - return; - } - if (audioEngine.audioContext.state !== "running") { - console.log("Resuming AudioContext because Chrome bad"); - await audioEngine.audioContext.resume(); - } - const state = getState().mixer.players[player]; - if (state.loading !== -1) { - console.log("not ready"); - return; - } - audioEngine.players[player]?.play(); - - if (state.loadedItem && "album" in state.loadedItem) { - //track - console.log("potentially tracklisting", state.loadedItem); - if (getState().mixer.players[player].tracklistItemID === -1) { - dispatch(BroadcastState.tracklistStart(player, state.loadedItem.trackid)); - } else { - console.log("not tracklisting because already tracklisted"); - } - } -}; - -export const pause = (player: number): AppThunk => (dispatch, getState) => { - if (typeof audioEngine.players[player] === "undefined") { - console.log("nothing loaded"); - return; - } - if (getState().mixer.players[player].loading !== -1) { - console.log("not ready"); - return; - } - if (audioEngine.players[player]?.isPlaying) { - audioEngine.players[player]?.pause(); - } else { - audioEngine.players[player]?.play(); - } -}; - -export const stop = (player: number): AppThunk => (dispatch, getState) => { - const playerInstance = audioEngine.players[player]; - if (typeof playerInstance === "undefined") { - console.log("nothing loaded"); - return; - } - var state = getState().mixer.players[player]; - if (state.loading !== -1) { - console.log("not ready"); - return; - } - - let cueTime = 0; - - if ( - state.loadedItem && - "cue" in state.loadedItem && - Math.round(playerInstance.currentTime) !== Math.round(state.loadedItem.cue) - ) { - cueTime = state.loadedItem.cue; - } - - playerInstance.stop(); - - dispatch(mixerState.actions.setTimeCurrent({ player, time: cueTime })); - playerInstance.setCurrentTime(cueTime); - - // Incase wavesurver wasn't playing, it won't 'finish', so just make sure the UI is stopped. - dispatch(mixerState.actions.setPlayerState({ player, state: "stopped" })); - - if (state.tracklistItemID !== -1) { - dispatch(BroadcastState.tracklistEnd(state.tracklistItemID)); - } -}; - -export const { - toggleAutoAdvance, - togglePlayOnLoad, - toggleRepeat, -} = mixerState.actions; - export const redrawWavesurfers = (): AppThunk => () => { audioEngine.players.forEach(function(item) { item?.redraw(); @@ -650,7 +566,7 @@ export const { setTracklistItemID } = mixerState.actions; const FADE_TIME_SECONDS = 1; export const setVolume = ( player: number, - level: VolumePresetEnum + level: audioTypes.VolumePresetEnum ): AppThunk => (dispatch, getState) => { let volume: number; let uiLevel: number; @@ -676,9 +592,10 @@ export const setVolume = ( if (typeof playerGainTweens[player] !== "undefined") { // We've interrupted a previous fade. - // If we've just hit the button/key to go to the same value as that fade, + // If we've just hit the button/key to go to the same value as that fade + // (read: double-tapped it), // stop it and immediately cut to the target value. - // Otherwise, stop id and start a new fade. + // Otherwise, stop it and start a new fade. playerGainTweens[player].tweens.forEach((tween) => tween.pause()); if (playerGainTweens[player].target === level) { delete playerGainTweens[player]; @@ -707,9 +624,7 @@ export const setVolume = ( const gainTween = new Between(currentGain, volume) .time(FADE_TIME_SECONDS * 1000) .on("update", (val: number) => { - if (typeof audioEngine.players[player] !== "undefined") { - audioEngine.players[player]?.setVolume(val); - } + dispatch(mixerState.actions.setPlayerGain({ player, gain: val })); }) .on("complete", () => { dispatch(mixerState.actions.setPlayerGain({ player, gain: volume })); @@ -723,62 +638,9 @@ export const setVolume = ( }; }; -export const setChannelTrim = (player: number, val: number): AppThunk => async ( - dispatch -) => { - dispatch(mixerState.actions.setPlayerTrim({ player, trim: val })); - audioEngine.players[player]?.setTrim(val); -}; - -export const openMicrophone = ( - micID: string, - micMapping: ChannelMapping -): AppThunk => async (dispatch, getState) => { - if (audioEngine.audioContext.state !== "running") { - console.log("Resuming AudioContext because Chrome bad"); - await audioEngine.audioContext.resume(); - } - dispatch(mixerState.actions.setMicError(null)); - if (!("mediaDevices" in navigator)) { - // mediaDevices is not there - we're probably not in a secure context - dispatch(mixerState.actions.setMicError("NOT_SECURE_CONTEXT")); - return; - } - try { - await audioEngine.openMic(micID, micMapping); - } catch (e) { - if (e instanceof DOMException) { - switch (e.message) { - case "Permission denied": - dispatch(mixerState.actions.setMicError("NO_PERMISSION")); - break; - default: - dispatch(mixerState.actions.setMicError("UNKNOWN")); - } - } else { - dispatch(mixerState.actions.setMicError("UNKNOWN")); - } - return; - } - - const state = getState().mixer.mic; - audioEngine.setMicCalibrationGain(state.baseGain); - audioEngine.setMicVolume(state.volume); - // Now to patch in the Mic to the Compressor, or Bypass it. - audioEngine.setMicProcessingEnabled(state.processing); - dispatch(mixerState.actions.micOpen(micID)); -}; - -export const setMicProcessingEnabled = (enabled: boolean): AppThunk => async ( - dispatch -) => { - dispatch(mixerState.actions.setMicProcessingEnabled(enabled)); - audioEngine.setMicProcessingEnabled(enabled); -}; - -export const setMicVolume = (level: MicVolumePresetEnum): AppThunk => ( - dispatch -) => { +export const setMicVolume = ( + level: audioTypes.MicVolumePresetEnum +): AppThunk => (dispatch) => { // no tween fuckery here, just cut the level const levelVal = level === "full" ? 1 : 0; // actually, that's a lie - if we're turning it off we delay it a little to compensate for @@ -797,33 +659,15 @@ export const startNewsTimer = (): AppThunk => (_, getState) => { TheNews.butNowItsTimeFor(getState); }; -export const mixerMiddleware: Middleware<{}, RootState, Dispatch> = ( - store -) => (next) => (action) => { - const oldState = store.getState().mixer; - const result = next(action); - const newState = store.getState().mixer; - - newState.players.forEach((state, index) => { - if (oldState.players[index].gain !== newState.players[index].gain) { - audioEngine.players[index]?.setVolume(state.gain); - } - }); - - if (newState.mic.baseGain !== oldState.mic.baseGain) { - audioEngine.setMicCalibrationGain(newState.mic.baseGain); - } - if (newState.mic.volume !== oldState.mic.volume) { - audioEngine.setMicVolume(newState.mic.volume); - } - return result; -}; - export const mixerKeyboardShortcutsMiddleware: Middleware< {}, RootState, Dispatch > = (store) => { + const play = (player: number) => mixerState.actions.play({ player }); + const pause = (player: number) => mixerState.actions.pause({ player }); + const stop = (player: number) => mixerState.actions.stop({ player }); + Keys("q", () => { store.dispatch(play(0)); }); diff --git a/src/store.ts b/src/store.ts index 517b1899..1846aff8 100644 --- a/src/store.ts +++ b/src/store.ts @@ -8,10 +8,10 @@ import { import rootReducer, { RootState } from "./rootReducer"; import { ThunkAction } from "redux-thunk"; import { - mixerMiddleware, mixerKeyboardShortcutsMiddleware, startNewsTimer, } from "./mixer/state"; +import { audioEngineMiddleware } from "./mixer/audio"; import { persistStore, FLUSH, @@ -21,6 +21,7 @@ import { PURGE, REGISTER, } from "redux-persist"; +import { tracklistMiddleware } from "./broadcast/state"; const ACTION_HISTORY_MAX_SIZE = 20; @@ -47,7 +48,8 @@ export function getActionHistory() { const store = configureStore({ reducer: rootReducer, middleware: [ - mixerMiddleware, + tracklistMiddleware, + audioEngineMiddleware, mixerKeyboardShortcutsMiddleware, actionHistoryMiddleware, ...getDefaultMiddleware({