Skip to content
This repository has been archived by the owner on Oct 18, 2021. It is now read-only.

Commit

Permalink
Use SessionShowContext everywhere, sustainInverted
Browse files Browse the repository at this point in the history
  • Loading branch information
corytheboyd committed Mar 18, 2021
1 parent b945f2b commit c81651c
Show file tree
Hide file tree
Showing 11 changed files with 236 additions and 103 deletions.
70 changes: 70 additions & 0 deletions projects/client/src/lib/createMidiInputHandler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/**
* Processes MIDI data received from local MIDI input device. Plays the local
* peer keyboard and sends data to the remote peer.
*
* @see https://newt.phys.unsw.edu.au/jw/notes.html
* @see https://www.midi.org/specifications-old/item/table-1-summary-of-midi-message
* */
import {
InputEventControlchange,
InputEventNoteoff,
InputEventNoteon,
InputEvents,
} from "webmidi";
import { store } from "./store";
import { PeerConnection } from "./rtc/PeerConnection";
import { playKeyboard } from "./playKeyboard";
import { Session } from "@midishare/common";
import {
buildSessionShowContext,
ISessionShowContext,
} from "../views/pages/Sessions/Show/SessionShowContext";
import { queryClient } from "./queryClient";
import { queryKey as currentUserQueryKey } from "./queries/getCurrentUser";
import { queryKey as sessionQueryKey } from "./queries/getSession";

// Restrict allowable event types to prevent registering something new that
// we don't yet know how to handle. Let's goooo strong types!
export type AllowedInputEventTypes = "noteon" | "noteoff" | "controlchange";

export function createMidiInputHandler(
context: ISessionShowContext
): (
event: InputEventNoteon | InputEventNoteoff | InputEventControlchange
) => void {
if (!context.session) {
throw new Error("Cannot create MIDI input handler without session");
}

const sessionId = context.session.id;

return (event) => {
// Ignore events from inactive MIDI devices, but don't unregister
// the listeners.
if (store.getState().activeMidiInputDeviceId !== event.target.id) {
return;
}

const eventType: Extract<keyof InputEvents, AllowedInputEventTypes> =
event.type;
const timestamp = event.timestamp;
const data = event.data;

const runtime = store.getState().runtime?.localKeyboardRuntime;
if (!runtime) {
throw new Error("Runtime not initialized");
}
playKeyboard(
"local",
eventType,
timestamp,
data,
// TODO this is a tight coupling, but it might be fine tbh
buildSessionShowContext({
currentUser: queryClient.getQueryData(currentUserQueryKey()),
session: queryClient.getQueryData(sessionQueryKey(sessionId)),
})
);
PeerConnection.sendMidiData(data, timestamp);
};
}
15 changes: 4 additions & 11 deletions projects/client/src/lib/getMidiAccess.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,12 @@ import WebMidi, {
InputEventControlchange,
InputEventNoteoff,
InputEventNoteon,
InputEvents,
} from "webmidi";
import { store } from "./store";
import { handleMidiInput } from "./handleMidiInput";
import { createMidiInputHandler } from "./createMidiInputHandler";
import { ISessionShowContext } from "../views/pages/Sessions/Show/SessionShowContext";

export function getMidiAccess(): void {
export function getMidiAccess(context: ISessionShowContext): void {
if (store.getState().midiAccessGranted !== null) {
return;
}
Expand All @@ -25,14 +25,7 @@ export function getMidiAccess(): void {
}
store.getState().addMidiInputDevice(event.port);

const eventListenerCallback = (
event: InputEventNoteon | InputEventNoteoff | InputEventControlchange
) => {
if (store.getState().activeMidiInputDeviceId !== event.target.id) {
return;
}
handleMidiInput(event.type, event.timestamp, event.data);
};
const eventListenerCallback = createMidiInputHandler(context);

event.port.addListener("noteon", "all", eventListenerCallback);
event.port.addListener("noteoff", "all", eventListenerCallback);
Expand Down
28 changes: 0 additions & 28 deletions projects/client/src/lib/handleMidiInput.ts

This file was deleted.

40 changes: 31 additions & 9 deletions projects/client/src/lib/playKeyboard.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,36 @@
import { InputEvents } from "webmidi";
import { AllowedInputEventTypes } from "./handleMidiInput";
import { getKeyNameFromIndex, Runtime } from "@midishare/keyboard";
import { store } from "./store";
import { AllowedInputEventTypes } from "./createMidiInputHandler";
import { getKeyNameFromIndex } from "@midishare/keyboard";
import { ISessionShowContext } from "../views/pages/Sessions/Show/SessionShowContext";

const SUSTAIN_PEDAL_CONTROL_CODE = 64;

export function playKeyboard(
targetRuntime: "local" | "remote",
eventType: Extract<keyof InputEvents, AllowedInputEventTypes>,
timestamp: number,
data: Uint8Array,
runtime: Runtime
context: ISessionShowContext
): void {
// Play the local keyboard
const runtime =
targetRuntime === "local" ? context.localRuntime : context.remoteRuntime;

const runtimeOptions =
targetRuntime === "local"
? context.localRuntimeOptions
: context.remoteRuntimeOptions;

if (!runtime) {
throw new Error("Failed to play keyboard: target runtime does not exist");
}

if (!runtimeOptions) {
throw new Error(
"Failed to play keyboard: target runtimeOptions does not exist"
);
}

// Play notes
if (eventType === "noteon" || eventType === "noteoff") {
const [, note, velocity] = data;
const keyName = getKeyNameFromIndex(note - 21);
Expand All @@ -21,11 +42,12 @@ export function playKeyboard(
}
}

// Sustain pedal
if (eventType === "controlchange") {
// Sustain pedal
if (data[1] === 64) {
const isInverted = store.getState().sustainInverted;
const isPressed = isInverted ? data[2] > 0 : data[2] === 0;
if (data[1] === SUSTAIN_PEDAL_CONTROL_CODE) {
const isPressed = runtimeOptions.sustainInverted
? data[2] > 0
: data[2] === 0;

if (isPressed) {
runtime.sustainOn();
Expand Down
10 changes: 9 additions & 1 deletion projects/client/src/lib/rtc/PeerConnection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,16 @@ export class PeerConnection {
this.polite = value;
}

public onMidiData(cb: (data: number[]) => void): void {
public onMidiData(cb: (data: number[]) => void): () => void {
this.onMidiDataCallbacks.push(cb);
const index = this.onMidiDataCallbacks.length - 1;
return () => {
// Yeah, you could use splice. I just try to stay away from impure
// functions for the sake of it.
this.onMidiDataCallbacks = this.onMidiDataCallbacks.filter(
(cb, i) => i !== index
);
};
}

/**
Expand Down
2 changes: 1 addition & 1 deletion projects/client/src/lib/rtc/handleMidiData.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { AllowedInputEventTypes } from "../handleMidiInput";
import { AllowedInputEventTypes } from "../createMidiInputHandler";
import { getKeyNameFromIndex } from "@midishare/keyboard";
import { store } from "../store";

Expand Down
10 changes: 0 additions & 10 deletions projects/client/src/lib/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,16 +37,12 @@ export type State = {
remoteKeyboardRuntime: Runtime;
};
initializeRuntime: () => void;

sustainInverted: boolean;
setSustainInverted: (value: boolean) => void;
};

export const store = create<State>((set, get) => ({
midiAccessGranted: null,
midiInputDevices: [],
activeMidiInputDeviceId: null,
sustainInverted: false,
setMidiAccessGranted: (value) =>
set(
produce(get(), (state) => {
Expand Down Expand Up @@ -93,12 +89,6 @@ export const store = create<State>((set, get) => ({
}
})
),
setSustainInverted: (value) =>
set(
produce(get(), (state) => {
state.sustainInverted = value;
})
),
}));

export const useStore = createReactHook(store);
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
import React, { ChangeEvent, useState } from "react";
import React, { ChangeEvent, useContext } from "react";
import { ExclamationCircle } from "../../../../common/icons/sm/ExclamationCircle";
import { BaseButton } from "../../../../common/buttons/BaseButton";
import { Plus } from "../../../../common/icons/sm/Plus";
import { getMidiAccess } from "../../../../../lib/getMidiAccess";
import { useStore } from "../../../../../lib/store";
import { SessionShowContext } from "../SessionShowContext";

const GetAccessButton: React.FC = () => {
const context = useContext(SessionShowContext);

const handleAttachMidiKeyboard = () => {
getMidiAccess();
getMidiAccess(context);
};

return (
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,12 @@
import React, { useCallback } from "react";
import React, { useCallback, useContext } from "react";
import classnames from "classnames";
import { PeerLaneProps } from "./index";
import { useStore } from "../../../../../lib/store";
import { useMutation } from "react-query";
import { setSustainInverted } from "../../../../../lib/mutations/setSustainInverted";
import { queryClient } from "../../../../../lib/queryClient";
import { queryKey as getSessionQueryKey } from "../../../../../lib/queries/getSession";
import { queryKey as getCurrentUserQueryKey } from "../../../../../lib/queries/getCurrentUser";
import { Session, UserProfile } from "@midishare/common";
import { useParams } from "react-router-dom";
import { Session } from "@midishare/common";
import { SessionShowContext } from "../SessionShowContext";

const pedImageUrl = (() => {
const url = new URL(process.env.STATIC_CDN_URL as string);
Expand All @@ -17,13 +15,10 @@ const pedImageUrl = (() => {
})();

export const KeyboardInfoWell: React.FC<PeerLaneProps> = (props) => {
// TODO this is technically a bit of a coupling concern-- what if the URL
// structure changes? it's weird to have hierarchically deep components
// depend on that. however, this project is tiny so I am letting it slide.
const urlParams = useParams<{ id: string }>();
const context = useContext(SessionShowContext);

// Canonical isPressed, directly from MIDI signal
const isPressed = props.runtime.useStore((state) => state.sustain);
const isSustainInverted = useStore((state) => state.sustainInverted);

const setSustainInvertedMutation = useMutation<
Session,
Expand All @@ -36,23 +31,11 @@ export const KeyboardInfoWell: React.FC<PeerLaneProps> = (props) => {
});

const handleInvertSustain = useCallback(() => {
// TODO I have written this logic TOO MANY TIMES, something is off about
// either/all of the Session schema, missing helper functions, etc.
const session = queryClient.getQueryData(
getSessionQueryKey(urlParams.id)
) as Session;
const currentUser = queryClient.getQueryData(
getCurrentUserQueryKey()
) as UserProfile;
const isCurrentUserHost = currentUser.sub === session.participants.host;

setSustainInvertedMutation.mutate({
id: urlParams.id,
value: isCurrentUserHost
? !session.runtimeOptions.host.sustainInverted
: !session.runtimeOptions.guest.sustainInverted,
id: context.session!.id,
value: !context.localRuntimeOptions!.sustainInverted,
});
}, [setSustainInverted, isSustainInverted]);
}, [context.session]);

return (
<div
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { createContext } from "react";
import { Session, SessionRuntimeOptions, UserProfile } from "@midishare/common";
import { Runtime } from "@midishare/keyboard";
import { store } from "../../../../lib/store";

export type ISessionShowContext = {
currentUser?: UserProfile | null;
session?: Session | null;
isHost?: boolean;
localRuntime?: Runtime;
remoteRuntime?: Runtime;
localRuntimeOptions?: SessionRuntimeOptions;
remoteRuntimeOptions?: SessionRuntimeOptions;
};

export const SessionShowContext = createContext<ISessionShowContext>({});

/**
* @note Calling this without the currentUser and/or session keys will still
* create a context, one
*
* @note Do not use React hooks in this function for now, as it is also used
* outside of the React context. Maybe that is a huge smell anyway, might
* revisit this approach.
* */
export function buildSessionShowContext(
options: Pick<ISessionShowContext, "currentUser" | "session">
): ISessionShowContext {
const isHost = (() => {
// If we know user is not logged in, they are always the guest
if (options.currentUser === null) {
return false;
}

// Waiting for currentUser and/or session to resolve, indeterminate
if (!options.currentUser || !options.session) {
return undefined;
}

return options.currentUser.sub === options.session.participants.host;
})();

const localRuntime = store.getState().runtime?.localKeyboardRuntime;
const remoteRuntime = store.getState().runtime?.remoteKeyboardRuntime;

const localRuntimeOptions = (() => {
if (!options.session || isHost === undefined) {
return undefined;
}
return options.session.runtimeOptions[isHost ? "host" : "guest"];
})();

const remoteRuntimeOptions = (() => {
if (!options.session || isHost === undefined) {
return undefined;
}
return options.session.runtimeOptions[isHost ? "guest" : "host"];
})();

return {
currentUser: options.currentUser,
session: options.session,
localRuntime,
remoteRuntime,
localRuntimeOptions,
remoteRuntimeOptions,
isHost, // computed
};
}
Loading

0 comments on commit c81651c

Please sign in to comment.