Microphone! It speech-jams you!
This commit is contained in:
parent
b8529210f1
commit
c1d027bc18
3 changed files with 146 additions and 17 deletions
|
@ -160,3 +160,10 @@ button{
|
|||
.player{
|
||||
height: 20%;
|
||||
}
|
||||
|
||||
.sp-alert {
|
||||
display: block;
|
||||
color: white;
|
||||
background-color: #e6463e;
|
||||
padding: 0.3em;
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
|
|
Loading…
Reference in a new issue