From c1d027bc18b82bb7ca9af9f1f63bc7966dbe7e4b Mon Sep 17 00:00:00 2001 From: Marks Polakovs Date: Fri, 20 Mar 2020 14:21:33 +0000 Subject: [PATCH] Microphone! It speech-jams you! --- src/App.css | 9 +++- src/mixer/state.ts | 95 +++++++++++++++++++++++++++++++++++++-- src/showplanner/index.tsx | 59 ++++++++++++++++++------ 3 files changed, 146 insertions(+), 17 deletions(-) diff --git a/src/App.css b/src/App.css index 004e52f..6b3599b 100644 --- a/src/App.css +++ b/src/App.css @@ -159,4 +159,11 @@ button{ .player{ height: 20%; -} \ No newline at end of file +} + +.sp-alert { + display: block; + color: white; + background-color: #e6463e; + padding: 0.3em; +} diff --git a/src/mixer/state.ts b/src/mixer/state.ts index 5e9317f..fa56190 100644 --- a/src/mixer/state.ts +++ b/src/mixer/state.ts @@ -22,6 +22,12 @@ const playerGainTweens: Array<{ target: VolumePresetEnum; tweens: Between[]; }> = []; + +let micMedia: MediaStream | null = null; +let micSource: MediaStreamAudioSourceNode | null = null; +let micGain: GainNode | null = null; +let micCompressor: DynamicsCompressorNode | null = null; + // TODO // const destination = audioContext.createWebcastSource(4096, 2); const destination = audioContext.createDynamicsCompressor(); @@ -29,6 +35,8 @@ destination.connect(audioContext.destination); type PlayerStateEnum = "playing" | "paused" | "stopped"; type VolumePresetEnum = "off" | "bed" | "full"; +type MicVolumePresetEnum = "off" | "full"; +type MicErrorEnum = "NO_PERMISSION" | "NOT_SECURE_CONTEXT" | "UNKNOWN"; interface PlayerState { loadedItem: PlanItem | Track | null; @@ -38,8 +46,16 @@ interface PlayerState { gain: number; } +interface MicState { + open: boolean; + openError: null | MicErrorEnum; + volume: number; + gain: number; +} + interface MixerState { players: PlayerState[]; + mic: MicState; } const mixerState = createSlice({ @@ -67,7 +83,13 @@ const mixerState = createSlice({ volume: 1, gain: 1 } - ] + ], + mic: { + open: false, + volume: 1, + gain: 1, + openError: null + } } as MixerState, reducers: { loadItem( @@ -104,6 +126,16 @@ const mixerState = createSlice({ }> ) { state.players[action.payload.player].gain = action.payload.gain; + }, + setMicError(state, action: PayloadAction) { + state.mic.openError = action.payload; + }, + micOpen(state) { + state.mic.open = true; + }, + setMicLevels(state, action: PayloadAction<{volume: number, gain: number}>) { + state.mic.volume = action.payload.volume; + state.mic.gain = action.payload.gain; } } }); @@ -152,7 +184,7 @@ export const load = (player: number, item: PlanItem | Track): AppThunk => ( console.log("loading"); const sauce = audioContext.createMediaElementSource(el); const gain = audioContext.createGain(); - gain.gain.value = getState().mixer.players[player].volume; + gain.gain.value = getState().mixer.players[player].gain; sauce.connect(gain); gain.connect(destination); console.log("Connected to", destination); @@ -225,7 +257,7 @@ export const setVolume = ( uiLevel = 0; break; case "bed": - volume = 0.125; + volume = 0.185; uiLevel = 0.5; break; case "full": @@ -286,6 +318,55 @@ export const setVolume = ( }; }; +export const openMicrophone = (): AppThunk => async (dispatch, getState) => { + if (getState().mixer.mic.open) { + return; + } + 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 { + micMedia = await navigator.mediaDevices.getUserMedia({ + audio: { + echoCancellation: false, + autoGainControl: false, + noiseSuppression: false, + latency: 0.01 + } + }); + } 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; + } + // Okay, we have a mic stream, time to do some audio nonsense + micSource = audioContext.createMediaStreamSource(micMedia) + micGain = audioContext.createGain(); + micCompressor = audioContext.createDynamicsCompressor(); + // TODO: for testing we're connecting mic output to main out + // When streaming works we don't want to do this, because the latency is high enough to speech-jam + micSource.connect(micGain).connect(micCompressor).connect(destination); + dispatch(mixerState.actions.micOpen()); +}; + +export const setMicVolume = (level: MicVolumePresetEnum): AppThunk => dispatch => { + // no tween fuckery here, just cut the level + const levelVal = level === "full" ? 1 : 0; + dispatch(mixerState.actions.setMicLevels({ volume: levelVal, gain: levelVal })); +}; + export const mixerMiddleware: Middleware< {}, RootState, @@ -301,6 +382,9 @@ export const mixerMiddleware: Middleware< } } }); + if (newState.mic.gain !== oldState.mic.gain && micGain !== null) { + micGain.gain.value = newState.mic.gain; + } return result; }; @@ -365,5 +449,10 @@ export const mixerKeyboardShortcutsMiddleware: Middleware< store.dispatch(setVolume(2, "full")); }); + Keys("x", () => { + const state = store.getState().mixer.mic; + store.dispatch(setMicVolume(state.volume === 1 ? "off" : "full")); + }); + return next => action => next(action); }; diff --git a/src/showplanner/index.tsx b/src/showplanner/index.tsx index 7244bae..2f4f995 100644 --- a/src/showplanner/index.tsx +++ b/src/showplanner/index.tsx @@ -31,7 +31,7 @@ import { removeItem } from "./state"; -import * as PlayerState from "../mixer/state"; +import * as MixerState from "../mixer/state"; import playLogo from "../assets/icons/play.svg"; import pauseLogo from "../assets/icons/pause.svg"; @@ -61,7 +61,7 @@ const Item = memo(function Item({ function triggerClick() { if (column > -1) { - dispatch(PlayerState.load(column, x)); + dispatch(MixerState.load(column, x)); } } @@ -112,23 +112,23 @@ function Player({ id }: { id: number }) { {playerState.loadedItem !== null && playerState.loading == false ? playerState.loadedItem.title : "No Media Selected"}{" "} - {playerState.loading && LOADING} + {playerState.loading && LOADING}
- - - @@ -251,11 +251,44 @@ function LibraryColumn() { ); } -function MixingInterface() { - const [sauce, setSauce] = useState("None"); +function MicControl() { + const state = useSelector((state: RootState) => state.mixer.mic); + const dispatch = useDispatch(); return (
-

Mixing Interface

+

Microphone

+ + {state.openError !== null && ( +
+ {state.openError === "NO_PERMISSION" + ? "Please grant this page permission to use your microphone and try again." + : state.openError === "NOT_SECURE_CONTEXT" + ? "We can't open the microphone. Please make sure the address bar has a https:// at the start and try again." + : "An error occurred when opening the microphone. Please try again."} +
+ )} +
+
+ + +
); } @@ -351,7 +384,7 @@ const Showplanner: React.FC<{ timeslotId: number }> = function({ timeslotId }) {
- +