diff --git a/src/actions/patchers.ts b/src/actions/patchers.ts index a0f7c54..db61a53 100644 --- a/src/actions/patchers.ts +++ b/src/actions/patchers.ts @@ -1,6 +1,6 @@ import Router from "next/router"; import { ActionBase, AppThunk } from "../lib/store"; -import { OSCQueryRNBOInstance, OSCQueryRNBOInstancePresetEntries, OSCQueryRNBOPatchersState, OSCValue } from "../lib/types"; +import { MIDIMetaMapping, OSCQueryRNBOInstance, OSCQueryRNBOInstancePresetEntries, OSCQueryRNBOPatchersState, OSCValue, ParameterMetaJsonMap } from "../lib/types"; import { PatcherInstanceRecord } from "../models/instance"; import { getPatcherInstanceByIndex, getPatcherInstance, getPatcherInstanceParametersByInstanceIndex, getPatcherInstanceParameter, getPatcherInstanceMessageInportsByInstanceIndex, getPatcherInstanceMesssageOutportsByInstanceIndex, getPatcherInstanceMessageInportByPath, getPatcherInstanceMessageOutportByPath, getPatcherInstanceMesssageOutportsByInstanceIndexAndTag, getPatcherInstanceParameterByPath, getPatcherInstanceParametersByInstanceIndexAndName, getPatcherInstanceMessageInportsByInstanceIndexAndTag } from "../selectors/patchers"; import { getAppSetting } from "../selectors/settings"; @@ -16,6 +16,8 @@ import { AppSetting } from "../models/settings"; import { DataRefRecord } from "../models/dataref"; import { DataFileRecord } from "../models/datafile"; import { PatcherExportRecord } from "../models/patcher"; +import { cloneJSON, InvalidMIDIFormatError, parseMIDIMappingDisplayValue, UnknownMIDIFormatError } from "../lib/util"; +import { MIDIMetaMappingType } from "../lib/constants"; export enum PatcherActionType { INIT_PATCHERS = "INIT_PATCHERS", @@ -552,7 +554,7 @@ export const activateParameterMIDIMappingFocus = (instance: PatcherInstanceRecor )); }; -export const clearParameterMidiMappingOnRemote = (id: PatcherInstanceRecord["id"], paramId: ParameterRecord["id"]): AppThunk => +export const clearParameterMIDIMappingOnRemote = (id: PatcherInstanceRecord["id"], paramId: ParameterRecord["id"]): AppThunk => (_dispatch, getState) => { const state = getState(); const instance = getPatcherInstance(state, id); @@ -561,8 +563,9 @@ export const clearParameterMidiMappingOnRemote = (id: PatcherInstanceRecord["id" const param = getPatcherInstanceParameter(state, paramId); if (!param) return; - const meta = param.getParsedMetaObject(); + const meta = cloneJSON(param.meta); delete meta.midi; + const message = { address: `${param.path}/meta`, args: [ @@ -573,6 +576,59 @@ export const clearParameterMidiMappingOnRemote = (id: PatcherInstanceRecord["id" oscQueryBridge.sendPacket(writePacket(message)); }; +export const setParameterMIDIMappingOnRemote = (id: PatcherInstanceRecord["id"], paramId: ParameterRecord["id"], type: MIDIMetaMappingType, mapping: MIDIMetaMapping): AppThunk => + (_dispatch, getState) => { + const state = getState(); + const instance = getPatcherInstance(state, id); + if (!instance) return; + + const param = getPatcherInstanceParameter(state, paramId); + if (!param) return; + + const meta: ParameterMetaJsonMap = cloneJSON(param.meta); + meta.midi = { ...mapping }; + + const message = { + address: `${param.path}/meta`, + args: [ + { type: "s", value: JSON.stringify(meta) } + ] + }; + + oscQueryBridge.sendPacket(writePacket(message)); + }; + +export const setParameterMIDIMappingOnRemoteFromDisplayValue = (id: PatcherInstanceRecord["id"], paramId: ParameterRecord["id"], value: string): AppThunk => + (dispatch) => { + try { + const parsed = parseMIDIMappingDisplayValue(value); + dispatch(setParameterMIDIMappingOnRemote(id, paramId, parsed.type, parsed.mapping)); + } catch (err: unknown) { + let notification: { level: NotificationLevel; message: string; title: string }; + if (err instanceof InvalidMIDIFormatError) { + notification = { + title: err.message, + message: `"${value}" is not a valid MIDI mapping value`, + level: NotificationLevel.error + }; + } else if (err instanceof UnknownMIDIFormatError) { + notification = { + title: err.message, + message: `"${value}" is an unknown MIDI mapping format. Please use the parameter meta editor to set this mapping.`, + level: NotificationLevel.warn + }; + } else { + notification = { + title: "Unexpected Error", + message: `Encountered an unexpected error while trying to set "${value}" as the MIDI mapping`, + level: NotificationLevel.error + }; + console.error(err); + } + return void dispatch(showNotification(notification)); + } + }; + export const setInstanceMessagePortMetaOnRemote = (_instance: PatcherInstanceRecord, port: MessagePortRecord, value: string): AppThunk => () => { const message = { @@ -848,7 +904,7 @@ export const updateInstanceMIDILastValue = (index: number, value: string): AppTh const parameters: ParameterRecord[] = []; getPatcherInstanceParametersByInstanceIndex(state, instance.index).forEach(param => { if (param.waitingForMidiMapping) { - const meta = param.getParsedMetaObject(); + const meta = cloneJSON(param.meta); meta.midi = midiMeta; const message = { diff --git a/src/components/editor/patcherNode.tsx b/src/components/editor/patcherNode.tsx index bc911c6..6cd6442 100644 --- a/src/components/editor/patcherNode.tsx +++ b/src/components/editor/patcherNode.tsx @@ -26,9 +26,7 @@ const EditorPatcherNode: FunctionComponent = memo(function Wrap return (
-
- { (node as GraphPatcherNodeRecord).index }: { (node as GraphPatcherNodeRecord).patcher } -
+
{ (node as GraphPatcherNodeRecord).displayName }
void; + prefix?: string; + value: number; +}; + +export const EditableTableNumberCell: FC = memo(function WrappedEditableNumberField({ + className = "", + min, + max, + name, + onUpdate, + prefix, + value +}) { + const [isEditing, setIsEditing] = useState(false); + const [currentValue, setCurrentValue] = useState(value); + + const onTriggerEdit = useCallback(() => { + if (isEditing) return; + setIsEditing(true); + setCurrentValue(value); + }, [isEditing, setIsEditing, setCurrentValue, value]); + + const onChange = useCallback((val: number) => { + setCurrentValue(val); + }, [setCurrentValue]); + + const onBlur = useCallback(() => { + setIsEditing(false); + if (currentValue === value) return; + onUpdate(currentValue); + }, [setIsEditing, value, currentValue, onUpdate]); + + const onKeyDown = useCallback((e: KeyboardEvent): void => { + if (e.key === "Escape") { + setIsEditing(false); + setCurrentValue(value); + return void e.preventDefault(); + } else if (e.key === "Enter") { + setIsEditing(false); + if (currentValue === value) return; + onUpdate(currentValue); + return void e.preventDefault(); + } + }, [setIsEditing, setCurrentValue, value, currentValue, onUpdate]); + + useEffect(() => { + setCurrentValue(value); + }, [value, setCurrentValue]); + + return ( + + { + isEditing ? ( + + ) : `${prefix || ""}${value}` + } + + + ); +}); + +export type EditableTableTextCellProps = { + className?: string; + name: string; + onUpdate: (val: string) => void; + value: string; +}; + +export const EditableTableTextCell: FC = memo(function WrappedEditableTextField({ + className = "", + name, + onUpdate, + value +}) { + const [isEditing, setIsEditing] = useState(false); + const [currentValue, setCurrentValue] = useState(value); + + const onTriggerEdit = useCallback(() => { + if (isEditing) return; + setIsEditing(true); + setCurrentValue(value); + }, [isEditing, setIsEditing, setCurrentValue, value]); + + const onChange = useCallback((e: ChangeEvent) => { + setCurrentValue(e.target.value); + }, [setCurrentValue]); + + const onBlur = useCallback(() => { + setIsEditing(false); + if (currentValue === value) return; + onUpdate(currentValue); + }, [setIsEditing, value, currentValue, onUpdate]); + + const onKeyDown = useCallback((e: KeyboardEvent): void => { + if (e.key === "Escape") { + setIsEditing(false); + setCurrentValue(value); + return void e.preventDefault(); + } else if (e.key === "Enter") { + setIsEditing(false); + if (currentValue === value) return; + onUpdate(currentValue); + return void e.preventDefault(); + } + }, [setIsEditing, setCurrentValue, value, currentValue, onUpdate]); + + useEffect(() => { + setCurrentValue(value); + }, [value, setCurrentValue]); + + return ( + + { + isEditing ? ( + + ) : value + } + + + ); +}); diff --git a/src/components/elements/elements.module.css b/src/components/elements/elements.module.css index d2c0ece..2d14314 100644 --- a/src/components/elements/elements.module.css +++ b/src/components/elements/elements.module.css @@ -1,3 +1,7 @@ .icon { width: 1.3em; } + +.editableTableCellInput { + border-bottom: 1px solid var(--mantine-primary-color-filled); +} diff --git a/src/components/elements/tableHeaderCell.tsx b/src/components/elements/tableHeaderCell.tsx new file mode 100644 index 0000000..ac346d5 --- /dev/null +++ b/src/components/elements/tableHeaderCell.tsx @@ -0,0 +1,57 @@ +import { Group, MantineFontSize, Table, Text, UnstyledButton } from "@mantine/core"; +import { FC, PropsWithChildren, useCallback } from "react"; +import { SortOrder } from "../../lib/constants"; +import { IconElement } from "./icon"; +import { mdiChevronDown, mdiChevronUp, mdiUnfoldMoreHorizontal } from "@mdi/js"; + +export type TableHeaderCellProps = PropsWithChildren<{ + className?: string; + fz?: MantineFontSize; + + onSort?: (sortKey: string) => void; + sorted?: boolean; + sortKey?: string; + sortOrder?: SortOrder; +}>; + +export const TableHeaderCell: FC = ({ + children, + className, + fz = "sm", + + onSort, + sorted = false, + sortKey, + sortOrder = SortOrder.Asc + +}) => { + + const onTriggerSort = useCallback(() => { + onSort?.(sortKey); + }, [onSort, sortKey]); + + return ( + + { + onSort ? ( + + + + { children } + + { + sorted + ? + : + } + + + ) : ( + + { children } + + ) + } + + ); +}; diff --git a/src/components/instance/paramTab.tsx b/src/components/instance/paramTab.tsx index 893939c..cd1bc53 100644 --- a/src/components/instance/paramTab.tsx +++ b/src/components/instance/paramTab.tsx @@ -9,7 +9,7 @@ import { PatcherInstanceRecord } from "../../models/instance"; import { restoreDefaultParameterMetaOnRemote, setInstanceParameterMetaOnRemote, setInstanceParameterValueNormalizedOnRemote, - setInstanceWaitingForMidiMappingOnRemote, clearParameterMidiMappingOnRemote, + setInstanceWaitingForMidiMappingOnRemote, clearParameterMIDIMappingOnRemote, activateParameterMIDIMappingFocus } from "../../actions/patchers"; import { OrderedSet as ImmuOrderedSet, Map as ImmuMap } from "immutable"; @@ -159,7 +159,7 @@ const InstanceParameterTab: FunctionComponent = memo( }, [dispatch, instance]); const onClearParameterMidiMapping = useCallback((param: ParameterRecord) => { - dispatch(clearParameterMidiMappingOnRemote(instance.id, param.id)); + dispatch(clearParameterMIDIMappingOnRemote(instance.id, param.id)); }, [dispatch, instance]); const onSearch = useDebouncedCallback((query: string) => { diff --git a/src/components/messages/inport.tsx b/src/components/messages/inport.tsx index 10926d1..e37eff9 100644 --- a/src/components/messages/inport.tsx +++ b/src/components/messages/inport.tsx @@ -49,7 +49,7 @@ const MessageInportEntry: FunctionComponent = memo(func onClose={ closeMetaEditor } onRestore={ onRestoreMeta } onSaveMeta={ onSaveMeta } - meta={ port.meta } + meta={ port.metaString } name={ port.name } scope={ MetadataScope.Inport } /> diff --git a/src/components/messages/outport.tsx b/src/components/messages/outport.tsx index e6b2f4f..ce6c562 100644 --- a/src/components/messages/outport.tsx +++ b/src/components/messages/outport.tsx @@ -42,7 +42,7 @@ const MessageOutportEntry: FunctionComponent = memo(fu onClose={ closeMetaEditor } onRestore={ onRestoreMeta } onSaveMeta={ onSaveMeta } - meta={ port.meta } + meta={ port.metaString } name={ port.name } scope={ MetadataScope.Outport } /> diff --git a/src/components/meta/metaEditorModal.tsx b/src/components/meta/metaEditorModal.tsx index 15e10b8..94adfb6 100644 --- a/src/components/meta/metaEditorModal.tsx +++ b/src/components/meta/metaEditorModal.tsx @@ -4,7 +4,7 @@ import { useIsMobileDevice } from "../../hooks/useIsMobileDevice"; import { modals } from "@mantine/modals"; import { JsonMap } from "../../lib/types"; import { MetadataScope } from "../../lib/constants"; -import { parseParamMetaJSONString } from "../../lib/util"; +import { parseMetaJSONString } from "../../lib/util"; import classes from "./metaEditorModal.module.css"; import { IconElement } from "../elements/icon"; import { mdiClose, mdiCodeBraces } from "@mdi/js"; @@ -129,7 +129,7 @@ export const MetaEditorModal: FC = memo(function WrappedPa setHasChanges(false); try { - if (meta) parseParamMetaJSONString(meta); // ensure valid + if (meta) parseMetaJSONString(meta); // ensure valid setError(undefined); } catch (err: unknown) { setError(err instanceof Error ? err : new Error("Invalid JSON format.")); @@ -140,7 +140,7 @@ export const MetaEditorModal: FC = memo(function WrappedPa setValue(meta); setHasChanges(false); try { - parseParamMetaJSONString(meta); // ensure valid + parseMetaJSONString(meta); // ensure valid setError(undefined); } catch (err: unknown) { setError(err instanceof Error ? err : new Error("Invalid JSON format.")); @@ -152,7 +152,7 @@ export const MetaEditorModal: FC = memo(function WrappedPa if (error) { try { const v = e.currentTarget.value; - if (v) parseParamMetaJSONString(v); // ensure valid + if (v) parseMetaJSONString(v); // ensure valid setError(undefined); } catch (err: unknown) { setError(err instanceof Error ? err : new Error("Invalid JSON format.")); @@ -165,7 +165,7 @@ export const MetaEditorModal: FC = memo(function WrappedPa const onInputBlur = useCallback(() => { try { if (value) { - const j: JsonMap = parseParamMetaJSONString(value); // ensure valid + const j: JsonMap = parseMetaJSONString(value); // ensure valid setValue(JSON.stringify(j, null, 2)); } setError(undefined); @@ -177,7 +177,7 @@ export const MetaEditorModal: FC = memo(function WrappedPa const onSaveValue = useCallback((e: FormEvent) => { e.preventDefault(); try { - if (value) parseParamMetaJSONString(value); // ensure valid + if (value) parseMetaJSONString(value); // ensure valid setHasChanges(false); onSaveMeta(value); } catch (err: unknown) { diff --git a/src/components/midi/mappedParameterItem.tsx b/src/components/midi/mappedParameterItem.tsx new file mode 100644 index 0000000..26eb362 --- /dev/null +++ b/src/components/midi/mappedParameterItem.tsx @@ -0,0 +1,118 @@ +import { FC, memo, useCallback } from "react"; +import { PatcherInstanceRecord } from "../../models/instance"; +import { ParameterRecord } from "../../models/parameter"; +import { ActionIcon, Group, Menu, Table, Text, Tooltip } from "@mantine/core"; +import { mdiDotsVertical, mdiEraser, mdiVectorSquare } from "@mdi/js"; +import { IconElement } from "../elements/icon"; +import { modals } from "@mantine/modals"; +import Link from "next/link"; +import { useRouter } from "next/router"; +import classes from "./midi.module.css"; +import { formatMIDIMappingToDisplay, formatParamValueForDisplay } from "../../lib/util"; +import { EditableTableTextCell } from "../elements/editableTableCell"; +import { MIDIMetaMappingType } from "../../lib/constants"; +import { MIDIMetaMapping } from "../../lib/types"; + +export type MIDISourceProps = { + mappingType: MIDIMetaMappingType; + midiMapping: MIDIMetaMapping; + onUpdateMapping: (value: string) => void; +}; + + +const MIDISource: FC = memo(function WrappedMIDISource({ + mappingType, + midiMapping, + onUpdateMapping +}) { + + return ( + + ); +}); + +export type MIDIMappedParamProps = { + instance: PatcherInstanceRecord; + param: ParameterRecord; + onClearMIDIMapping: (instance: PatcherInstanceRecord, param: ParameterRecord) => void; + onUpdateMIDIMapping: (instance: PatcherInstanceRecord, param: ParameterRecord, value: string) => void; +}; + + +const MIDIMappedParameter: FC = memo(function WrappedMIDIMappedParam({ + instance, + param, + onClearMIDIMapping, + onUpdateMIDIMapping +}) { + + const { query: restQuery } = useRouter(); + + const onClearMapping = useCallback(() => { + modals.openConfirmModal({ + title: "Clear Parameter MIDI Mapping", + centered: true, + children: ( + + Are you sure you want to remove the active MIDI mapping for { `"${param.name}"` } on patcher instance { `"${instance.displayName}"` }? + + ), + labels: { confirm: "Remove", cancel: "Cancel" }, + confirmProps: { color: "red" }, + onConfirm: () => onClearMIDIMapping(instance, param) + }); + }, [param, instance, onClearMIDIMapping]); + + const onUpdateMapping = useCallback((value: string) => { + onUpdateMIDIMapping(instance, param, value); + }, [instance, param, onUpdateMIDIMapping]); + + return ( + + + { param.name } + + { instance.index } + : {instance.name} + + { formatParamValueForDisplay(param.value) } + + + + + + + + + + + + MIDI Mapping Actions + } + component={ Link } + href={{ pathname: "/instances/[index]", query: { ...restQuery, index: instance.index } }} + > + Show Instance + + } onClick={ onClearMapping } > + Remove MIDI Mapping + + + + + + + ); +}); + +export default MIDIMappedParameter; diff --git a/src/components/midi/mappedParameterList.tsx b/src/components/midi/mappedParameterList.tsx new file mode 100644 index 0000000..68c52c9 --- /dev/null +++ b/src/components/midi/mappedParameterList.tsx @@ -0,0 +1,92 @@ +import { Map as ImmuMap, Set as ImmuOrderedSet } from "immutable"; +import { Table } from "@mantine/core"; +import { FC, memo } from "react"; +import classes from "./midi.module.css"; +import { PatcherInstanceRecord } from "../../models/instance"; +import { ParameterRecord } from "../../models/parameter"; +import MIDIMappedParameter from "./mappedParameterItem"; +import { TableHeaderCell } from "../elements/tableHeaderCell"; +import { MIDIMappedParameterSortAttr, SortOrder } from "../../lib/constants"; + +export type MIDIMappedParameterListProps = { + parameters: ImmuOrderedSet; + patcherInstances: ImmuMap; + onClearParameterMIDIMapping: (instance: PatcherInstanceRecord, param: ParameterRecord) => void; + onUpdateParameterMIDIMapping: (instance: PatcherInstanceRecord, param: ParameterRecord, value: string) => void; + onSort: (sortAttr: MIDIMappedParameterSortAttr) => void; + sortAttr: MIDIMappedParameterSortAttr; + sortOrder: SortOrder; +}; + +const MIDIMappedParameterList: FC = memo(function WrappedMIDIMappedParameterList({ + patcherInstances, + parameters, + onClearParameterMIDIMapping, + onUpdateParameterMIDIMapping, + onSort, + sortAttr, + sortOrder +}) { + + return ( + + + + + Source + + + Parameter + + + Instance + + + Current Value + + + + + + { + parameters.map(p => { + const pInstance = patcherInstances.get(p.instanceIndex); + if (!pInstance) return null; + return ( + + ); + }) + } + +
+ ); +}); + +export default MIDIMappedParameterList; diff --git a/src/components/midi/midi.module.css b/src/components/midi/midi.module.css new file mode 100644 index 0000000..8d1a5dc --- /dev/null +++ b/src/components/midi/midi.module.css @@ -0,0 +1,64 @@ +.midiSourceColumnHeader { + width: 100px; +} + +.midiSourceColumn { + cursor: text; + font-size: var(--mantine-font-size-xs); +} + +.parameterValueColumnHeader, +.parameterValueColumn { + cursor: default; + display: none; + font-size: var(--mantine-font-size-xs); + user-select: none; + + @media (min-width: $mantine-breakpoint-md) { + display: table-cell; + width: 200px; + } +} + +.patcherInstanceColumnHeader { + width: 100px; + + @media (min-width: $mantine-breakpoint-md) { + width: initial; + } +} + +.patcherInstanceColumn { + cursor: default; + font-size: var(--mantine-font-size-xs); + overflow: hidden; + text-overflow: ellipsis; + user-select: none; + white-space: nowrap; + + .patcherInstanceName { + display: none; + + @media (min-width: $mantine-breakpoint-md) { + display: inline-block; + } + } +} + +.parameterNameColumnHeader { + width: 150px; +} + +.parameterNameColumn { + cursor: default; + font-size: var(--mantine-font-size-xs); + overflow: hidden; + text-overflow: ellipsis; + user-select: none; + white-space: nowrap; +} + +.actionColumnHeader { + width: 10px; +} +.actionColumn {} diff --git a/src/components/nav/index.tsx b/src/components/nav/index.tsx index 67195ae..5d4ee23 100644 --- a/src/components/nav/index.tsx +++ b/src/components/nav/index.tsx @@ -9,7 +9,7 @@ import { getShowSettingsModal } from "../../selectors/settings"; import { ExternalNavLink, NavLink } from "./link"; import { useRouter } from "next/router"; import { getFirstPatcherNodeIndex } from "../../selectors/graph"; -import { mdiChartSankeyVariant, mdiCog, mdiFileMusic, mdiHelpCircle, mdiVectorSquare } from "@mdi/js"; +import { mdiChartSankeyVariant, mdiCog, mdiFileMusic, mdiHelpCircle, mdiMidiPort, mdiVectorSquare } from "@mdi/js"; const AppNav: FunctionComponent = memo(function WrappedNav() { @@ -51,6 +51,12 @@ const AppNav: FunctionComponent = memo(function WrappedNav() { href={{ pathname: "/files", query: restQuery }} isActive={ pathname === "/files" } /> + { - if (typeof value === "number") return Number.isInteger(value) ? value : value.toFixed(2); - return value; -}; interface ParameterProps { instanceIsMIDIMapping: boolean; @@ -92,7 +89,7 @@ const Parameter = memo(function WrappedParameter({ onClose={ closeMetaEditor } onRestore={ onRestoreMeta } onSaveMeta={ onSaveMeta } - meta={ param.meta } + meta={ param.metaString } name={ param.name } scope={ MetadataScope.Parameter } /> @@ -145,7 +142,7 @@ const Parameter = memo(function WrappedParameter({ } onClick={ toggleMetaEditor }> Edit Metadata - } onClick={ onClearMidiMap } disabled={ !param.isMidiMapped } > + } onClick={ onClearMidiMap } disabled={ !param.isMidiMapped } > Clear MIDI Mapping diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 570ed45..1788f33 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -71,6 +71,21 @@ export enum SortOrder { Desc = "desc" } +export enum MIDIMappedParameterSortAttr { + MIDISource = "midi_source", + InstanceIndex = "instance_index", + ParameterName = "param_name" +} + +export enum MIDIMetaMappingType { + ChannelPressure = "channel_pressure", + ControlChange = "control_change", + KeyPressure = "key_pressure", + Note = "note", + PitchBend = "pitch_bend", + ProgramChange = "progam_change" +} + export enum ParameterSortAttr { Index = "displayorder", Name = "name" diff --git a/src/lib/types.ts b/src/lib/types.ts index c35cda6..50a34f3 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -9,6 +9,42 @@ export type AnyJson = export interface JsonMap { [key: string]: AnyJson } +export type MIDIControlChangeMetaMapping = { + chan: number; + ctrl: number; +}; + +export type MIDINoteMetaMapping = { + chan: number; + note: number; +}; + +export type MIDIKeypressMetaMapping = { + chan: number; + keypress: number; +}; + +export type MIDIPitchBendMetaMapping = { + bend: number; +}; + +export type MIDIProgramChangeMetaMapping = { + prgchg: number; +}; + +export type MIDIChannelPressureMetaMapping = { + chanpress: number; +}; + +export type MIDIIndividualScopedMetaMapping = MIDIControlChangeMetaMapping | MIDINoteMetaMapping | MIDIKeypressMetaMapping; +export type MIDIChannelScopedMetaMapping = MIDIPitchBendMetaMapping | MIDIProgramChangeMetaMapping | MIDIChannelPressureMetaMapping; + +export type MIDIMetaMapping = MIDIIndividualScopedMetaMapping | MIDIChannelScopedMetaMapping; + +export type ParameterMetaJsonMap = JsonMap & { + midi?: MIDIMetaMapping; +}; + export type OSCValue = string | number | null; export enum OSCAccess { diff --git a/src/lib/util.ts b/src/lib/util.ts index 0510e1f..f5ac8ad 100644 --- a/src/lib/util.ts +++ b/src/lib/util.ts @@ -1,5 +1,6 @@ import { KeyboardEvent } from "react"; -import { AnyJson, JsonMap, OSCQueryStringValueRange, OSCQueryValueRange } from "./types"; +import { AnyJson, JsonMap, MIDIChannelPressureMetaMapping, MIDIControlChangeMetaMapping, MIDIKeypressMetaMapping, MIDIMetaMapping, MIDINoteMetaMapping, MIDIPitchBendMetaMapping, MIDIProgramChangeMetaMapping, OSCQueryStringValueRange, OSCQueryValueRange } from "./types"; +import { MIDIMetaMappingType } from "./constants"; export const sleep = (t: number): Promise => new Promise(resolve => setTimeout(resolve, t)); @@ -37,7 +38,7 @@ export const formatFileSize = (size: number): string => { return (size / Math.pow(1000, exp)).toFixed(exp >= 2 ? 2 : 0) + " " + fileSizeUnits[exp]; }; -export const parseParamMetaJSONString = (v: string): JsonMap => { +export const parseMetaJSONString = (v: string): JsonMap => { if (!v?.length) return {}; let parsed: AnyJson; @@ -51,11 +52,150 @@ export const parseParamMetaJSONString = (v: string): JsonMap => { return parsed; }; -export const validateParamMetaJSONString = (v: string): boolean => { +export const validateMetaJSONString = (v: string): boolean => { try { - parseParamMetaJSONString(v); + parseMetaJSONString(v); return true; } catch (err) { return false; } }; + +export const formatParamValueForDisplay = (value: number | string) => { + if (typeof value === "number") return Number.isInteger(value) ? value : value.toFixed(2); + return value; +}; + +export const cloneJSON = (value: JsonMap): JsonMap => JSON.parse(JSON.stringify(value)); + +export const formatMIDIMappingToDisplay = (type: MIDIMetaMappingType, mapping: MIDIMetaMapping): string => { + switch (type) { + case MIDIMetaMappingType.ChannelPressure: { + return `CPRESS/${(mapping as MIDIChannelPressureMetaMapping).chanpress}`; + } + case MIDIMetaMappingType.ControlChange: { + return `CC#${(mapping as MIDIControlChangeMetaMapping).ctrl}/${(mapping as MIDIControlChangeMetaMapping).chan}`; + } + case MIDIMetaMappingType.KeyPressure: { + return `KPRESS#${(mapping as MIDIKeypressMetaMapping).keypress}/${(mapping as MIDIKeypressMetaMapping).chan}`; + } + case MIDIMetaMappingType.Note: { + return `NOTE#${(mapping as MIDINoteMetaMapping).note}/${(mapping as MIDINoteMetaMapping).chan}`; + } + case MIDIMetaMappingType.PitchBend: { + return `BEND/${(mapping as MIDIPitchBendMetaMapping).bend}`; + } + case MIDIMetaMappingType.ProgramChange: { + return `PRGCHG/${(mapping as MIDIProgramChangeMetaMapping).prgchg}`; + } + default: { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const _exhaustive: never = type; + return "Unknown"; + } + } +}; + +const midiMetaRegexp: Record = { + [MIDIMetaMappingType.ChannelPressure]: /^CPRESS\/(?[0-9]{1,2})$/, + [MIDIMetaMappingType.ControlChange]: /^CC#(?[0-9]{1,3})\/(?[0-9]{1,2})$/, + [MIDIMetaMappingType.KeyPressure]: /^KPRESS#(?[0-9]{1,3})\/(?[0-9]{1,2})$/, + [MIDIMetaMappingType.Note]: /^NOTE#(?[0-9]{1,3})\/(?[0-9]{1,2})$/, + [MIDIMetaMappingType.PitchBend]: /^BEND\/(?[0-9]{1,2})$/, + [MIDIMetaMappingType.ProgramChange]: /^PRGCHG\/(?[0-9]{1,2})$/ +}; + +const parseMIDIByte = (val: string, min: number, max: number): number | null => { + if (val === undefined) return null; + const n = parseInt(val, 10); + if (isNaN(n) || n < min || n > max) return null; + return n; +}; + +export class InvalidMIDIFormatError extends Error { + constructor() { + super("Invalid MIDI mapping"); + } +} + +export class UnknownMIDIFormatError extends Error { + constructor() { + super("Unknown MIDI mapping format"); + } +} + +export const parseMIDIMappingDisplayValue = (value: string): { type: MIDIMetaMappingType, mapping: MIDIMetaMapping } => { + for (const [mappingType, reg] of Object.entries(midiMetaRegexp) as Array<[MIDIMetaMappingType, RegExp]>) { + const match = value.match(reg); + if (!match) continue; + + switch (mappingType) { + case MIDIMetaMappingType.ChannelPressure: { + const chanpress = parseMIDIByte(match.groups?.chanpress, 1, 16); + if (chanpress === null) throw new Error(`"${value}" is not a valid MIDI mapping format`); + return { + type: MIDIMetaMappingType.ChannelPressure, + mapping: { chanpress } as MIDIChannelPressureMetaMapping + }; + } + case MIDIMetaMappingType.ControlChange: { + const chan = parseMIDIByte(match.groups?.chan, 1, 16); + const ctrl = parseMIDIByte(match.groups?.ctrl, 0, 127); + if (chan === null || ctrl === null) throw new InvalidMIDIFormatError(); + + return { + type: MIDIMetaMappingType.ControlChange, + mapping: { chan, ctrl } as MIDIControlChangeMetaMapping + }; + } + case MIDIMetaMappingType.KeyPressure: { + const chan = parseMIDIByte(match.groups?.chan, 1, 16); + const keypress = parseMIDIByte(match.groups?.keypress, 0, 127); + if (chan === null || keypress === null) throw new InvalidMIDIFormatError(); + + return { + type: MIDIMetaMappingType.KeyPressure, + mapping: { chan, keypress } as MIDIKeypressMetaMapping + }; + + } + case MIDIMetaMappingType.Note: { + const chan = parseMIDIByte(match.groups?.chan, 1, 16); + const note = parseMIDIByte(match.groups?.note, 0, 127); + if (chan === null || note === null) throw new InvalidMIDIFormatError(); + + return { + type: MIDIMetaMappingType.Note, + mapping: { chan, note } as MIDINoteMetaMapping + }; + + } + case MIDIMetaMappingType.PitchBend: { + const bend = parseMIDIByte(match.groups?.bend, 1, 16); + if (bend === null) throw new InvalidMIDIFormatError(); + + return { + type: MIDIMetaMappingType.PitchBend, + mapping: { bend } as MIDIPitchBendMetaMapping + }; + + } + case MIDIMetaMappingType.ProgramChange: { + const prgchg = parseMIDIByte(match.groups?.prgchg, 1, 16); + if (prgchg === null) throw new InvalidMIDIFormatError(); + + return { + type: MIDIMetaMappingType.ProgramChange, + mapping: { prgchg } as MIDIProgramChangeMetaMapping + }; + } + + default: { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const _exhaustive: never = mappingType; + } + } + } + + throw new UnknownMIDIFormatError(); +}; diff --git a/src/models/graph.ts b/src/models/graph.ts index 6012192..ec9980f 100644 --- a/src/models/graph.ts +++ b/src/models/graph.ts @@ -112,6 +112,10 @@ export class GraphPatcherNodeRecord extends ImmuRecord({ return this.ports.get(id); } + public get displayName(): string { + return `${this.index}: ${this.patcher}`; + } + public get id(): string { return this.jackName; } diff --git a/src/models/instance.ts b/src/models/instance.ts index af5fe77..610a023 100644 --- a/src/models/instance.ts +++ b/src/models/instance.ts @@ -45,6 +45,10 @@ export class PatcherInstanceRecord extends ImmuRecord({ }) { + public get displayName(): string { + return `${this.index}: ${this.name}`; + } + public get id(): string { return this.name; } diff --git a/src/models/messageport.ts b/src/models/messageport.ts index 62f0fa0..8b5a691 100644 --- a/src/models/messageport.ts +++ b/src/models/messageport.ts @@ -1,11 +1,13 @@ import { Record as ImmuRecord } from "immutable"; -import { OSCQueryRNBOInstanceMessageInfo, OSCQueryRNBOInstanceMessages, OSCQueryRNBOInstanceMessageValue } from "../lib/types"; +import { JsonMap, OSCQueryRNBOInstanceMessageInfo, OSCQueryRNBOInstanceMessages, OSCQueryRNBOInstanceMessageValue } from "../lib/types"; import { PatcherInstanceRecord } from "./instance"; +import { parseMetaJSONString } from "../lib/util"; export type MessagePortRecordProps = { instanceIndex: number; tag: string; - meta: string; + meta: JsonMap; + metaString: string; value: string; path: string; }; @@ -14,7 +16,8 @@ export type MessagePortRecordProps = { export class MessagePortRecord extends ImmuRecord({ instanceIndex: 0, tag: "", - meta: "", + meta: {}, + metaString: "", value: "", path: "" }) { @@ -25,9 +28,8 @@ export class MessagePortRecord extends ImmuRecord({ new MessagePortRecord({ instanceIndex, tag: name, - path: (desc as OSCQueryRNBOInstanceMessageValue).FULL_PATH, - meta: (desc as OSCQueryRNBOInstanceMessageValue).CONTENTS?.meta?.VALUE || "" - }) + path: (desc as OSCQueryRNBOInstanceMessageValue).FULL_PATH + }).setMeta((desc as OSCQueryRNBOInstanceMessageValue).CONTENTS?.meta?.VALUE || "") ]; } @@ -56,7 +58,19 @@ export class MessagePortRecord extends ImmuRecord({ } public setMeta(value: string): MessagePortRecord { - return this.set("meta", value); + // detect midi mapping + let parsed: JsonMap = {}; + try { + // detection simply looks for a 'midi' entry in the meta + parsed = parseMetaJSONString(value); + } catch { + // ignore + } + return this.withMutations(p => { + return p + .set("meta", parsed) + .set("metaString", value); + }); } public setValue(value: string): MessagePortRecord { diff --git a/src/models/parameter.ts b/src/models/parameter.ts index 13be879..317da9a 100644 --- a/src/models/parameter.ts +++ b/src/models/parameter.ts @@ -1,6 +1,7 @@ import { Record as ImmuRecord } from "immutable"; -import { AnyJson, JsonMap, OSCQueryRNBOInstance, OSCQueryRNBOInstanceParameterInfo, OSCQueryRNBOInstanceParameterValue } from "../lib/types"; -import { parseParamMetaJSONString } from "../lib/util"; +import { OSCQueryRNBOInstance, OSCQueryRNBOInstanceParameterInfo, OSCQueryRNBOInstanceParameterValue, ParameterMetaJsonMap } from "../lib/types"; +import { parseMetaJSONString } from "../lib/util"; +import { MIDIMetaMappingType } from "../lib/constants"; export type ParameterRecordProps = { @@ -9,13 +10,15 @@ export type ParameterRecordProps = { instanceIndex: number; min: number; max: number; - meta: string; + meta: ParameterMetaJsonMap; + metaString: string; name: string; normalizedValue: number; path: string; type: string; value: string | number; waitingForMidiMapping: boolean; + midiMappingType: false | MIDIMetaMappingType; isMidiMapped: boolean; } export class ParameterRecord extends ImmuRecord({ @@ -25,14 +28,16 @@ export class ParameterRecord extends ImmuRecord({ instanceIndex: 0, min: 0, max: 1, - meta: "", + meta: {}, + metaString: "", name: "name", normalizedValue: 0, path: "", type: "f", value: 0, waitingForMidiMapping: false, - isMidiMapped: false + isMidiMapped: false, + midiMappingType: false }) { private static arrayFromDescription( @@ -101,38 +106,43 @@ export class ParameterRecord extends ImmuRecord({ return this.name.toLowerCase().includes(query); } - public getParsedMeta(): AnyJson { - let meta: AnyJson = {}; - try { - meta = JSON.parse(this.meta); - } catch { - // ignore - } - return meta; - } - - // get parsed meta but if it isn't a map, return an empty map - public getParsedMetaObject(): JsonMap { - try { - return parseParamMetaJSONString(this.meta); // ensure valid - } catch (err) { - return {}; - } - } - public setMeta(value: string): ParameterRecord { // detect midi mapping - let isMidiMapped = false; - let j: JsonMap = {}; + let parsed: ParameterMetaJsonMap = {}; try { // detection simply looks for a 'midi' entry in the meta - j = parseParamMetaJSONString(value); + parsed = parseMetaJSONString(value); } catch { // ignore } - isMidiMapped = typeof j.midi === "object"; - return this.set("meta", value).set("isMidiMapped", isMidiMapped); + const isMidiMapped = typeof parsed.midi === "object"; + let midiMappingType: false | MIDIMetaMappingType; + if (!isMidiMapped) { + midiMappingType = false; + } else if (Object.hasOwn(parsed.midi, "bend")) { + midiMappingType = MIDIMetaMappingType.PitchBend; + } else if (Object.hasOwn(parsed.midi, "chanpress")) { + midiMappingType = MIDIMetaMappingType.ChannelPressure; + } else if (Object.hasOwn(parsed.midi, "ctrl")) { + midiMappingType = MIDIMetaMappingType.ControlChange; + } else if (Object.hasOwn(parsed.midi, "keypress")) { + midiMappingType = MIDIMetaMappingType.KeyPressure; + } else if (Object.hasOwn(parsed.midi, "note")) { + midiMappingType = MIDIMetaMappingType.Note; + } else if (Object.hasOwn(parsed.midi, "prgchg")) { + midiMappingType = MIDIMetaMappingType.ProgramChange; + } else { + midiMappingType = false; + } + + return this.withMutations(p => { + return p + .set("metaString", value) + .set("meta", parsed) + .set("isMidiMapped", isMidiMapped) + .set("midiMappingType", midiMappingType); + }); } public setWaitingForMidiMapping(value: boolean): ParameterRecord { diff --git a/src/pages/files.tsx b/src/pages/files.tsx index 22c30b1..67b36f4 100644 --- a/src/pages/files.tsx +++ b/src/pages/files.tsx @@ -1,4 +1,4 @@ -import { Button, Group, Stack, Table, Text, UnstyledButton } from "@mantine/core"; +import { Button, Group, Stack, Table, Text } from "@mantine/core"; import { DataFileListItem } from "../components/datafile/item"; import { useAppDispatch, useAppSelector } from "../hooks/useAppDispatch"; import { RootStateType } from "../lib/store"; @@ -14,7 +14,8 @@ import { modals } from "@mantine/modals"; import { NotificationLevel } from "../models/notification"; import { showNotification } from "../actions/notifications"; import { IconElement } from "../components/elements/icon"; -import { mdiSortAlphabeticalAscending, mdiSortAlphabeticalDescending, mdiUpload } from "@mdi/js"; +import { mdiUpload } from "@mdi/js"; +import { TableHeaderCell } from "../components/elements/tableHeaderCell"; const SampleDependencies = () => { @@ -61,14 +62,9 @@ const SampleDependencies = () => { - - - - Filename - - - - + + Filename + diff --git a/src/pages/instances/[index].tsx b/src/pages/instances/[index].tsx index c409605..1fb82e3 100644 --- a/src/pages/instances/[index].tsx +++ b/src/pages/instances/[index].tsx @@ -131,7 +131,7 @@ export default function Instance() {
n.index).toArray().map(d => ({ value: `${d.index}`, label: `${d.index}: ${d.patcher}` })) } + data={ instances.valueSeq().sortBy(n => n.index).toArray().map(d => ({ value: `${d.index}`, label: d.displayName })) } leftSection={ } onChange={ onChangeInstance } value={ currentInstance.index } diff --git a/src/pages/midimappings.tsx b/src/pages/midimappings.tsx new file mode 100644 index 0000000..b377179 --- /dev/null +++ b/src/pages/midimappings.tsx @@ -0,0 +1,112 @@ +import { OrderedSet as ImmuOrderedSet, Map as ImmuMap } from "immutable"; +import { Stack } from "@mantine/core"; +import { useAppDispatch, useAppSelector } from "../hooks/useAppDispatch"; +import { RootStateType } from "../lib/store"; +import classes from "../components/midi/midi.module.css"; +import { MIDIMappedParameterSortAttr, MIDIMetaMappingType, SortOrder } from "../lib/constants"; +import { useCallback, useEffect, useState } from "react"; +import { getPatcherInstanceParametersWithMIDIMapping, getPatcherInstancesByIndex } from "../selectors/patchers"; +import MIDIMappedParameterList from "../components/midi/mappedParameterList"; +import { ParameterRecord } from "../models/parameter"; +import { clearParameterMIDIMappingOnRemote, setParameterMIDIMappingOnRemoteFromDisplayValue } from "../actions/patchers"; +import { PatcherInstanceRecord } from "../models/instance"; +import { formatMIDIMappingToDisplay } from "../lib/util"; + +const collator = new Intl.Collator("en-US", { numeric: true }); +const parameterComparators: Record number>> = { + [MIDIMappedParameterSortAttr.MIDISource]: { + [SortOrder.Asc]: (a: ParameterRecord, b: ParameterRecord) => { + const aDisplay = formatMIDIMappingToDisplay(a.midiMappingType as MIDIMetaMappingType, a.meta.midi); + const bDisplay = formatMIDIMappingToDisplay(b.midiMappingType as MIDIMetaMappingType, b.meta.midi); + return collator.compare(aDisplay, bDisplay); + }, + [SortOrder.Desc]: (a: ParameterRecord, b: ParameterRecord) => { + const aDisplay = formatMIDIMappingToDisplay(a.midiMappingType as MIDIMetaMappingType, a.meta.midi); + const bDisplay = formatMIDIMappingToDisplay(b.midiMappingType as MIDIMetaMappingType, b.meta.midi); + return collator.compare(aDisplay, bDisplay) * -1; + } + }, + [MIDIMappedParameterSortAttr.InstanceIndex]: { + [SortOrder.Asc]: (a: ParameterRecord, b: ParameterRecord) => { + if (a.instanceIndex < b.instanceIndex) return -1; + if (a.instanceIndex > b.instanceIndex) return 1; + return collator.compare(a.name.toLowerCase(), b.name.toLowerCase()); + }, + [SortOrder.Desc]: (a: ParameterRecord, b: ParameterRecord) => { + if (a.instanceIndex > b.instanceIndex) return -1; + if (a.instanceIndex < b.instanceIndex) return 1; + return collator.compare(a.name.toLowerCase(), b.name.toLowerCase()) * -1; + } + }, + [MIDIMappedParameterSortAttr.ParameterName]: { + [SortOrder.Asc]: (a: ParameterRecord, b: ParameterRecord) => { + return collator.compare(a.name.toLowerCase(), b.name.toLowerCase()); + }, + [SortOrder.Desc]: (a: ParameterRecord, b: ParameterRecord) => { + return collator.compare(a.name.toLowerCase(), b.name.toLowerCase()) * -1; + } + } +}; + +const getSortedParameterIds = (params: ImmuMap, attr: MIDIMappedParameterSortAttr, order: SortOrder): ImmuOrderedSet => { + return ImmuOrderedSet(params.valueSeq().sort(parameterComparators[attr][order]).map(p => p.id)); +}; + +const MIDIMappings = () => { + + const [sortOrder, setSortOrder] = useState(SortOrder.Asc); + const [sortAttr, setSortAttr] = useState(MIDIMappedParameterSortAttr.MIDISource); + const [sortedParameterIds, setSortedParameterIds] = useState>(ImmuOrderedSet()); + + const dispatch = useAppDispatch(); + const [ + patcherInstances, + parameters + ] = useAppSelector((state: RootStateType) => [ + getPatcherInstancesByIndex(state), + getPatcherInstanceParametersWithMIDIMapping(state) + ]); + + const onClearParameterMIDIMapping = useCallback((instance: PatcherInstanceRecord, param: ParameterRecord) => { + dispatch(clearParameterMIDIMappingOnRemote(instance.id, param.id)); + }, [dispatch]); + + const onUpdateParameterMIDIMapping = useCallback((instance: PatcherInstanceRecord, param: ParameterRecord, value: string) => { + dispatch(setParameterMIDIMappingOnRemoteFromDisplayValue(instance.id, param.id, value)); + }, [dispatch]); + + const onSort = useCallback((attr: MIDIMappedParameterSortAttr): void => { + if (attr === sortAttr) return void setSortOrder(sortOrder === SortOrder.Asc ? SortOrder.Desc : SortOrder.Asc); + + setSortAttr(attr); + setSortOrder(SortOrder.Asc); + + }, [sortOrder, sortAttr, setSortOrder, setSortAttr]); + + useEffect(() => { + setSortedParameterIds(getSortedParameterIds(parameters, sortAttr, sortOrder)); + }, [patcherInstances, parameters.size, sortAttr, sortOrder]); + + const displayParameters = ImmuOrderedSet().withMutations(set => { + sortedParameterIds.forEach(id => { + const p = parameters.get(id); + if (p) set.add(p); + }); + }); + + return ( + + + + ); +}; + +export default MIDIMappings; diff --git a/src/selectors/patchers.ts b/src/selectors/patchers.ts index a0b4577..4b066fe 100644 --- a/src/selectors/patchers.ts +++ b/src/selectors/patchers.ts @@ -71,6 +71,15 @@ export const getPatcherInstancesByIndex = createSelector( export const getPatcherInstanceParameters = (state: RootStateType): ImmuMap => state.patchers.instanceParameters; +export const getPatcherInstanceParametersWithMIDIMapping = createSelector( + [ + getPatcherInstanceParameters + ], + (parameters): ImmuMap => { + return parameters.filter(p => p.isMidiMapped); + } +); + export const getPatcherInstanceParameter = createSelector( [ getPatcherInstanceParameters, @@ -143,7 +152,6 @@ export const getPatcherInstanceMessageInportsByInstanceIndex = createSelector( (state: RootStateType, instanceIndex: PatcherInstanceRecord["index"]): PatcherInstanceRecord["index"] => instanceIndex ], (ports, instanceIndex): ImmuMap => { - console.log(ports.valueSeq().toArray().map(p => p.toJSON())); return ports.filter(p => { return p.instanceIndex === instanceIndex; });