Microphone! It speech-jams you!

This commit is contained in:
Marks Polakovs 2020-03-20 14:21:33 +00:00
parent b8529210f1
commit c1d027bc18
3 changed files with 146 additions and 17 deletions

View file

@ -160,3 +160,10 @@ button{
.player{
height: 20%;
}
.sp-alert {
display: block;
color: white;
background-color: #e6463e;
padding: 0.3em;
}

View file

@ -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<null | MicErrorEnum>) {
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);
};

View file

@ -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));
}
}
@ -116,19 +116,19 @@ function Player({ id }: { id: number }) {
</div>
<div className="mediaButtons">
<button
onClick={() => dispatch(PlayerState.play(id))}
onClick={() => dispatch(MixerState.play(id))}
className={playerState.state === "playing" ? "sp-state-playing" : ""}
>
<img src={playLogo} className="sp-player-button" />
</button>
<button
onClick={() => dispatch(PlayerState.pause(id))}
onClick={() => dispatch(MixerState.pause(id))}
className={playerState.state === "paused" ? "sp-state-paused" : ""}
>
<img src={pauseLogo} className="sp-player-button" />
</button>
<button
onClick={() => dispatch(PlayerState.stop(id))}
onClick={() => dispatch(MixerState.stop(id))}
className={playerState.state === "stopped" ? "sp-state-stopped" : ""}
>
<img src={stopLogo} className="sp-player-button" />
@ -144,13 +144,13 @@ function Player({ id }: { id: number }) {
"%"
}}
></div>
<button onClick={() => dispatch(PlayerState.setVolume(id, "off"))}>
<button onClick={() => dispatch(MixerState.setVolume(id, "off"))}>
Off
</button>
<button onClick={() => dispatch(PlayerState.setVolume(id, "bed"))}>
<button onClick={() => dispatch(MixerState.setVolume(id, "bed"))}>
Bed
</button>
<button onClick={() => dispatch(PlayerState.setVolume(id, "full"))}>
<button onClick={() => dispatch(MixerState.setVolume(id, "full"))}>
Full
</button>
</div>
@ -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 (
<div className="sp-col" style={{ height: "48%", overflowY: "visible" }}>
<h1>Mixing Interface</h1>
<h2>Microphone</h2>
<button
disabled={state.open}
onClick={() => dispatch(MixerState.openMicrophone())}
>
Open
</button>
{state.openError !== null && (
<div className="sp-alert">
{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."}
</div>
)}
<div className="sp-mixer-buttons">
<div
className="sp-mixer-buttons-backdrop"
style={{
width:
(USE_REAL_GAIN_VALUE ? state.gain : state.volume) *
100 +
"%"
}}
></div>
<button onClick={() => dispatch(MixerState.setMicVolume("off"))}>
Off
</button>
<button onClick={() => dispatch(MixerState.setMicVolume("full"))}>
Full
</button>
</div>
</div>
);
}
@ -351,7 +384,7 @@ const Showplanner: React.FC<{ timeslotId: number }> = function({ timeslotId }) {
<Column id={2} data={showplan} />
<div className="sp-main-col" style={{ marginRight: ".2%" }}>
<LibraryColumn />
<MixingInterface />
<MicControl />
</div>
</DragDropContext>
</div>