From f497cc3d5c1a0e8e5cadc04cd168e4c212273188 Mon Sep 17 00:00:00 2001 From: Florian Demmer Date: Mon, 16 Dec 2024 19:02:02 +0000 Subject: [PATCH 01/17] elements: added TableHeader element with sort support --- src/components/elements/elements.module.css | 4 ++ src/components/elements/tableHeaderCell.tsx | 58 +++++++++++++++++++++ 2 files changed, 62 insertions(+) create mode 100644 src/components/elements/tableHeaderCell.tsx diff --git a/src/components/elements/elements.module.css b/src/components/elements/elements.module.css index d2c0ece..af655aa 100644 --- a/src/components/elements/elements.module.css +++ b/src/components/elements/elements.module.css @@ -1,3 +1,7 @@ .icon { width: 1.3em; } + +.tableHeaderButton { + width: 100%; +} diff --git a/src/components/elements/tableHeaderCell.tsx b/src/components/elements/tableHeaderCell.tsx new file mode 100644 index 0000000..49efe9c --- /dev/null +++ b/src/components/elements/tableHeaderCell.tsx @@ -0,0 +1,58 @@ +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"; +import classes from "./elements.module.css"; + +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 } + + ) + } + + ); +}; From e0ce4a8f426f2449ce3f89dadb68dac5b087a37a Mon Sep 17 00:00:00 2001 From: Florian Demmer Date: Mon, 16 Dec 2024 19:05:11 +0000 Subject: [PATCH 02/17] reuse tableheader in files table --- src/components/elements/elements.module.css | 4 ---- src/components/elements/tableHeaderCell.tsx | 3 +-- src/pages/files.tsx | 16 ++++++---------- 3 files changed, 7 insertions(+), 16 deletions(-) diff --git a/src/components/elements/elements.module.css b/src/components/elements/elements.module.css index af655aa..d2c0ece 100644 --- a/src/components/elements/elements.module.css +++ b/src/components/elements/elements.module.css @@ -1,7 +1,3 @@ .icon { width: 1.3em; } - -.tableHeaderButton { - width: 100%; -} diff --git a/src/components/elements/tableHeaderCell.tsx b/src/components/elements/tableHeaderCell.tsx index 49efe9c..ac346d5 100644 --- a/src/components/elements/tableHeaderCell.tsx +++ b/src/components/elements/tableHeaderCell.tsx @@ -3,7 +3,6 @@ import { FC, PropsWithChildren, useCallback } from "react"; import { SortOrder } from "../../lib/constants"; import { IconElement } from "./icon"; import { mdiChevronDown, mdiChevronUp, mdiUnfoldMoreHorizontal } from "@mdi/js"; -import classes from "./elements.module.css"; export type TableHeaderCellProps = PropsWithChildren<{ className?: string; @@ -35,7 +34,7 @@ export const TableHeaderCell: FC = ({ { onSort ? ( - + { children } 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 + From e50eb0e09be2b91040a9e33ba997d2cd160787e7 Mon Sep 17 00:00:00 2001 From: Florian Demmer Date: Mon, 16 Dec 2024 19:09:25 +0000 Subject: [PATCH 03/17] handle parsed JSON and string values for Parameter and MessagePorts --- src/actions/patchers.ts | 4 +-- src/components/messages/inport.tsx | 2 +- src/components/messages/outport.tsx | 2 +- src/components/meta/metaEditorModal.tsx | 12 +++---- src/components/parameter/item.tsx | 2 +- src/lib/types.ts | 7 +++++ src/lib/util.ts | 6 ++-- src/models/messageport.ts | 28 ++++++++++++----- src/models/parameter.ts | 42 +++++++++---------------- 9 files changed, 56 insertions(+), 49 deletions(-) diff --git a/src/actions/patchers.ts b/src/actions/patchers.ts index a0f7c54..eb3734f 100644 --- a/src/actions/patchers.ts +++ b/src/actions/patchers.ts @@ -561,7 +561,7 @@ export const clearParameterMidiMappingOnRemote = (id: PatcherInstanceRecord["id" const param = getPatcherInstanceParameter(state, paramId); if (!param) return; - const meta = param.getParsedMetaObject(); + const meta = { ...param.meta }; delete meta.midi; const message = { address: `${param.path}/meta`, @@ -848,7 +848,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 = { ...param.meta }; meta.midi = midiMeta; const message = { 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/parameter/item.tsx b/src/components/parameter/item.tsx index 8490628..cdedbc2 100644 --- a/src/components/parameter/item.tsx +++ b/src/components/parameter/item.tsx @@ -92,7 +92,7 @@ const Parameter = memo(function WrappedParameter({ onClose={ closeMetaEditor } onRestore={ onRestoreMeta } onSaveMeta={ onSaveMeta } - meta={ param.meta } + meta={ param.metaString } name={ param.name } scope={ MetadataScope.Parameter } /> diff --git a/src/lib/types.ts b/src/lib/types.ts index c35cda6..91de006 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -9,6 +9,13 @@ export type AnyJson = export interface JsonMap { [key: string]: AnyJson } +export type ParameterMetaJsonMap = JsonMap & { + midi?: { + chan?: number; + ctrl?: number; + } +}; + export type OSCValue = string | number | null; export enum OSCAccess { diff --git a/src/lib/util.ts b/src/lib/util.ts index 0510e1f..4dafd13 100644 --- a/src/lib/util.ts +++ b/src/lib/util.ts @@ -37,7 +37,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,9 +51,9 @@ 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; 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..2d9bcd6 100644 --- a/src/models/parameter.ts +++ b/src/models/parameter.ts @@ -1,6 +1,6 @@ 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"; export type ParameterRecordProps = { @@ -9,7 +9,8 @@ export type ParameterRecordProps = { instanceIndex: number; min: number; max: number; - meta: string; + meta: ParameterMetaJsonMap; + metaString: string; name: string; normalizedValue: number; path: string; @@ -25,7 +26,8 @@ export class ParameterRecord extends ImmuRecord({ instanceIndex: 0, min: 0, max: 1, - meta: "", + meta: {}, + metaString: "", name: "name", normalizedValue: 0, path: "", @@ -101,38 +103,22 @@ 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); + return this.withMutations(p => { + return p + .set("metaString", value) + .set("meta", parsed) + .set("isMidiMapped", typeof parsed.midi === "object"); + }); } public setWaitingForMidiMapping(value: boolean): ParameterRecord { From 8bb602e317a3d8336c3c81169a487a3cc96a7f3f Mon Sep 17 00:00:00 2001 From: Florian Demmer Date: Mon, 16 Dec 2024 19:09:37 +0000 Subject: [PATCH 04/17] parameter action menu: red clear entry --- src/components/parameter/item.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/parameter/item.tsx b/src/components/parameter/item.tsx index cdedbc2..3a4b1e9 100644 --- a/src/components/parameter/item.tsx +++ b/src/components/parameter/item.tsx @@ -145,7 +145,7 @@ const Parameter = memo(function WrappedParameter({ } onClick={ toggleMetaEditor }> Edit Metadata - } onClick={ onClearMidiMap } disabled={ !param.isMidiMapped } > + } onClick={ onClearMidiMap } disabled={ !param.isMidiMapped } > Clear MIDI Mapping From 9037dcb81fa6ee9ea63326dcea65d72810967af6 Mon Sep 17 00:00:00 2001 From: Florian Demmer Date: Mon, 16 Dec 2024 19:13:26 +0000 Subject: [PATCH 05/17] moved parameter display value formatting to utils for being able to reuse it --- src/components/parameter/item.tsx | 5 +---- src/lib/util.ts | 5 +++++ 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/components/parameter/item.tsx b/src/components/parameter/item.tsx index 3a4b1e9..6042cab 100644 --- a/src/components/parameter/item.tsx +++ b/src/components/parameter/item.tsx @@ -8,12 +8,9 @@ import { MetadataScope } from "../../lib/constants"; import { IconElement } from "../elements/icon"; import { mdiCodeBraces, mdiDotsVertical, mdiEraser } from "@mdi/js"; import { modals } from "@mantine/modals"; +import { formatParamValueForDisplay } from "../../lib/util"; export const parameterBoxHeight = 87 + 6; // 87px + 6px margin -const formatParamValueForDisplay = (value: number | string) => { - if (typeof value === "number") return Number.isInteger(value) ? value : value.toFixed(2); - return value; -}; interface ParameterProps { instanceIsMIDIMapping: boolean; diff --git a/src/lib/util.ts b/src/lib/util.ts index 4dafd13..e9273bf 100644 --- a/src/lib/util.ts +++ b/src/lib/util.ts @@ -59,3 +59,8 @@ export const validateMetaJSONString = (v: string): boolean => { return false; } }; + +export const formatParamValueForDisplay = (value: number | string) => { + if (typeof value === "number") return Number.isInteger(value) ? value : value.toFixed(2); + return value; +}; From 88958d7276bc12aafc20ee897f5a17e28cd45c27 Mon Sep 17 00:00:00 2001 From: Florian Demmer Date: Mon, 16 Dec 2024 19:13:55 +0000 Subject: [PATCH 06/17] GraphPatcherNodes and PatcherInstances have a displayName --- src/components/editor/patcherNode.tsx | 4 +--- src/models/graph.ts | 4 ++++ src/models/instance.ts | 4 ++++ src/pages/instances/[index].tsx | 2 +- 4 files changed, 10 insertions(+), 4 deletions(-) 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 }
({ 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/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 } From 145459bd16ac94922e3a43e68bdf48abc9bc3b79 Mon Sep 17 00:00:00 2001 From: Florian Demmer Date: Mon, 16 Dec 2024 19:14:32 +0000 Subject: [PATCH 07/17] added global MIDI Mapping table view --- src/components/midi/mappedParameterItem.tsx | 82 +++++++++++++ src/components/midi/mappedParameterList.tsx | 98 +++++++++++++++ src/components/midi/midi.module.css | 56 +++++++++ src/components/nav/index.tsx | 8 +- src/components/nav/nav.module.css | 5 +- src/lib/constants.ts | 7 ++ src/pages/midimappings.tsx | 128 ++++++++++++++++++++ src/selectors/patchers.ts | 9 ++ 8 files changed, 390 insertions(+), 3 deletions(-) create mode 100644 src/components/midi/mappedParameterItem.tsx create mode 100644 src/components/midi/mappedParameterList.tsx create mode 100644 src/components/midi/midi.module.css create mode 100644 src/pages/midimappings.tsx diff --git a/src/components/midi/mappedParameterItem.tsx b/src/components/midi/mappedParameterItem.tsx new file mode 100644 index 0000000..f4626b6 --- /dev/null +++ b/src/components/midi/mappedParameterItem.tsx @@ -0,0 +1,82 @@ +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 { formatParamValueForDisplay } from "../../lib/util"; + +export type MIDIMappedParamProps = { + instance: PatcherInstanceRecord; + param: ParameterRecord; + onClearMIDIMapping: (instance: PatcherInstanceRecord, param: ParameterRecord) => void; +}; + +const MIDIMappedParameter: FC = memo(function WrappedMIDIMappedParam({ + instance, + param, + onClearMIDIMapping +}) { + + 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]); + + return ( + + { param.meta.midi?.chan || ""} + { param.meta.midi?.ctrl || ""} + { 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..0ecc277 --- /dev/null +++ b/src/components/midi/mappedParameterList.tsx @@ -0,0 +1,98 @@ +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; + onSort: (sortAttr: MIDIMappedParameterSortAttr) => void; + sortAttr: MIDIMappedParameterSortAttr; + sortOrder: SortOrder; +}; + +const MIDIMappedParameterList: FC = memo(function WrappedMIDIMappedParameterList({ + patcherInstances, + parameters, + onClearParameterMidiMapping, + onSort, + sortAttr, + sortOrder +}) { + + return ( +
+ + + + Channel + + + Control + + + Parameter + + + Instance + + + Current Value + + + + + + { + parameters.map(p => { + const pInstance = patcherInstances.get(p.instanceIndex); + return pInstance + ? + : null; + }) + } + +
+ ); +}) + +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..65b7cbe --- /dev/null +++ b/src/components/midi/midi.module.css @@ -0,0 +1,56 @@ +.midiChannelColumnHeader, +.midiControlColumnHeader { + width: 100px; +} + +.midiChannelColumn, +.midiControlColumn { + font-size: var(--mantine-font-size-xs); +} + +.parameterValueColumnHeader, +.parameterValueColumn { + display: none; + font-size: var(--mantine-font-size-xs); + + @media (min-width: $mantine-breakpoint-md) { + display: table-cell; + width: 200px; + } +} + +.patcherInstanceColumnHeader { + width: 80px; + + @media (min-width: $mantine-breakpoint-md) { + width: initial; + } +} + +.patcherInstanceColumn { + font-size: var(--mantine-font-size-xs); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + + .patcherInstanceName { + display: none; + + @media (min-width: $mantine-breakpoint-md) { + display: inline-block; + } + } +} + +.parameterNameColumnHeader {} +.parameterNameColumn { + font-size: var(--mantine-font-size-xs); + overflow: hidden; + text-overflow: ellipsis; + 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" } /> + number>> = { + [MIDIMappedParameterSortAttr.MIDIChannel]: { + [SortOrder.Asc]: (a: ParameterRecord, b: ParameterRecord) => { + if (a.meta.midi?.chan < b.meta.midi?.chan) return -1; + if (a.meta.midi?.chan > b.meta.midi?.chan) return 1; + if (a.meta.midi?.ctrl < b.meta.midi?.ctrl) return -1; + if (a.meta.midi?.ctrl > b.meta.midi?.ctrl) return 1; + return collator.compare(a.name.toLowerCase(), b.name.toLowerCase()); + }, + [SortOrder.Desc]: (a: ParameterRecord, b: ParameterRecord) => { + if (a.meta.midi?.chan > b.meta.midi?.chan) return -1; + if (a.meta.midi?.chan < b.meta.midi?.chan) return 1; + if (a.meta.midi?.ctrl > b.meta.midi?.ctrl) return -1; + if (a.meta.midi?.ctrl < b.meta.midi?.ctrl) return 1; + + return collator.compare(a.name.toLowerCase(), b.name.toLowerCase()) * -1; + } + }, + [MIDIMappedParameterSortAttr.MIDIControl]: { + [SortOrder.Asc]: (a: ParameterRecord, b: ParameterRecord) => { + if (a.meta.midi?.ctrl < b.meta.midi?.ctrl) return -1; + if (a.meta.midi?.ctrl > b.meta.midi?.ctrl) return 1; + if (a.meta.midi?.chan < b.meta.midi?.chan) return -1; + if (a.meta.midi?.chan > b.meta.midi?.chan) return 1; + return collator.compare(a.name.toLowerCase(), b.name.toLowerCase()); + }, + [SortOrder.Desc]: (a: ParameterRecord, b: ParameterRecord) => { + if (a.meta.midi?.ctrl > b.meta.midi?.ctrl) return -1; + if (a.meta.midi?.ctrl < b.meta.midi?.ctrl) return 1; + if (a.meta.midi?.chan > b.meta.midi?.chan) return -1; + if (a.meta.midi?.chan < b.meta.midi?.chan) return 1; + + return collator.compare(a.name.toLowerCase(), b.name.toLowerCase()) * -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.MIDIChannel); + 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 onSort = useCallback((attr: MIDIMappedParameterSortAttr) => { + if (attr === sortAttr) return 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..24394c6 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, From 3f940c511a0d6308f750ed04f5a692fa39777ab0 Mon Sep 17 00:00:00 2001 From: Florian Demmer Date: Mon, 16 Dec 2024 19:14:39 +0000 Subject: [PATCH 08/17] removed noisy console.log --- src/selectors/patchers.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/selectors/patchers.ts b/src/selectors/patchers.ts index 24394c6..4b066fe 100644 --- a/src/selectors/patchers.ts +++ b/src/selectors/patchers.ts @@ -152,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; }); From edc3d239ec7c233343683c83bb123b87f9899938 Mon Sep 17 00:00:00 2001 From: Florian Demmer Date: Mon, 16 Dec 2024 19:17:32 +0000 Subject: [PATCH 09/17] fixed linter issues --- src/components/midi/mappedParameterItem.tsx | 2 +- src/components/midi/mappedParameterList.tsx | 19 ++++++++++--------- src/pages/midimappings.tsx | 2 +- 3 files changed, 12 insertions(+), 11 deletions(-) diff --git a/src/components/midi/mappedParameterItem.tsx b/src/components/midi/mappedParameterItem.tsx index f4626b6..c535556 100644 --- a/src/components/midi/mappedParameterItem.tsx +++ b/src/components/midi/mappedParameterItem.tsx @@ -37,7 +37,7 @@ const MIDIMappedParameter: FC = memo(function WrappedMIDIM confirmProps: { color: "red" }, onConfirm: () => onClearMIDIMapping(instance, param) }); - }, [param, instance]); + }, [param, instance, onClearMIDIMapping]); return ( diff --git a/src/components/midi/mappedParameterList.tsx b/src/components/midi/mappedParameterList.tsx index 0ecc277..e4188a5 100644 --- a/src/components/midi/mappedParameterList.tsx +++ b/src/components/midi/mappedParameterList.tsx @@ -80,19 +80,20 @@ const MIDIMappedParameterList: FC = memo(function { parameters.map(p => { const pInstance = patcherInstances.get(p.instanceIndex); - return pInstance - ? - : null; + if (!pInstance) return null; + return ( + + ); }) } ); -}) +}); export default MIDIMappedParameterList; diff --git a/src/pages/midimappings.tsx b/src/pages/midimappings.tsx index a60721f..b8f4560 100644 --- a/src/pages/midimappings.tsx +++ b/src/pages/midimappings.tsx @@ -93,7 +93,7 @@ const MIDIMappings = () => { }, [dispatch]); const onSort = useCallback((attr: MIDIMappedParameterSortAttr) => { - if (attr === sortAttr) return setSortOrder(sortOrder === SortOrder.Asc ? SortOrder.Desc : SortOrder.Asc); + if (attr === sortAttr) return void setSortOrder(sortOrder === SortOrder.Asc ? SortOrder.Desc : SortOrder.Asc); setSortAttr(attr); setSortOrder(SortOrder.Asc); From c289ca656f9cdcb36e8c0b332abe1e195927807c Mon Sep 17 00:00:00 2001 From: Florian Demmer Date: Mon, 16 Dec 2024 20:10:28 +0000 Subject: [PATCH 10/17] elements: inline-editable number table cell --- src/components/elements/editableTableCell.tsx | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 src/components/elements/editableTableCell.tsx diff --git a/src/components/elements/editableTableCell.tsx b/src/components/elements/editableTableCell.tsx new file mode 100644 index 0000000..966ee6e --- /dev/null +++ b/src/components/elements/editableTableCell.tsx @@ -0,0 +1,64 @@ +import { Group, NumberInput, Table } from "@mantine/core"; +import { FC, memo, useCallback, useEffect, useState } from "react" + +export type EditableTableNumberCellProps = { + className?: string; + min: number; + max: number; + name: string; + onUpdate: (val: number) => void; + value: number; +} + +export const EditableTableNumberCell: FC = memo(function WrappedEditableMIDIField({ + className = "", + min, + max, + 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((val: number) => { + setCurrentValue(val); + }, [setCurrentValue]); + + const onBlur = useCallback(() => { + setIsEditing(false); + if (currentValue === value) return; + onUpdate(currentValue); + }, [setIsEditing, value, currentValue, onUpdate]); + + useEffect(() => { + setCurrentValue(value); + }, [value, setCurrentValue]); + + return ( + + { + isEditing ? ( + + ) : value + } + + + ) +}) From d700ef3e4d13133bb2bd89ad660588ebaed4ccae Mon Sep 17 00:00:00 2001 From: Florian Demmer Date: Mon, 16 Dec 2024 20:10:39 +0000 Subject: [PATCH 11/17] added JSON cloning util --- src/lib/util.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/lib/util.ts b/src/lib/util.ts index e9273bf..504fe25 100644 --- a/src/lib/util.ts +++ b/src/lib/util.ts @@ -64,3 +64,5 @@ 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)); From 4fda7355bdaf7552cd9acdc12c1fd34face6e822 Mon Sep 17 00:00:00 2001 From: Florian Demmer Date: Mon, 16 Dec 2024 20:13:05 +0000 Subject: [PATCH 12/17] use cloneJSON when updating parameter meta --- src/actions/patchers.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/actions/patchers.ts b/src/actions/patchers.ts index eb3734f..028024f 100644 --- a/src/actions/patchers.ts +++ b/src/actions/patchers.ts @@ -16,6 +16,7 @@ import { AppSetting } from "../models/settings"; import { DataRefRecord } from "../models/dataref"; import { DataFileRecord } from "../models/datafile"; import { PatcherExportRecord } from "../models/patcher"; +import { cloneJSON } from "../lib/util"; export enum PatcherActionType { INIT_PATCHERS = "INIT_PATCHERS", @@ -561,7 +562,7 @@ export const clearParameterMidiMappingOnRemote = (id: PatcherInstanceRecord["id" const param = getPatcherInstanceParameter(state, paramId); if (!param) return; - const meta = { ...param.meta }; + const meta = cloneJSON(param.meta); delete meta.midi; const message = { address: `${param.path}/meta`, @@ -848,7 +849,7 @@ export const updateInstanceMIDILastValue = (index: number, value: string): AppTh const parameters: ParameterRecord[] = []; getPatcherInstanceParametersByInstanceIndex(state, instance.index).forEach(param => { if (param.waitingForMidiMapping) { - const meta = { ...param.meta }; + const meta = cloneJSON(param.meta); meta.midi = midiMeta; const message = { From f7fda8507fcf9e1dd5ea555e4ffd947b363a15d9 Mon Sep 17 00:00:00 2001 From: Florian Demmer Date: Mon, 16 Dec 2024 20:14:00 +0000 Subject: [PATCH 13/17] added inline Parameter MIDIMapping updates for channel and control --- src/actions/patchers.ts | 51 ++++++++++++++++++++- src/components/instance/paramTab.tsx | 4 +- src/components/midi/mappedParameterItem.tsx | 27 +++++++++-- src/components/midi/mappedParameterList.tsx | 12 +++-- src/components/midi/midi.module.css | 3 ++ src/pages/midimappings.tsx | 20 ++++++-- 6 files changed, 102 insertions(+), 15 deletions(-) diff --git a/src/actions/patchers.ts b/src/actions/patchers.ts index 028024f..0877786 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 { 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"; @@ -553,7 +553,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); @@ -564,6 +564,53 @@ export const clearParameterMidiMappingOnRemote = (id: PatcherInstanceRecord["id" const meta = cloneJSON(param.meta); delete meta.midi; + + const message = { + address: `${param.path}/meta`, + args: [ + { type: "s", value: JSON.stringify(meta) } + ] + }; + + oscQueryBridge.sendPacket(writePacket(message)); + }; + +export const setParameterMIDIChannelOnRemote = (id: PatcherInstanceRecord["id"], paramId: ParameterRecord["id"], channel: number): 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 = (meta.midi || {}); + meta.midi.chan = channel; + + const message = { + address: `${param.path}/meta`, + args: [ + { type: "s", value: JSON.stringify(meta) } + ] + }; + + oscQueryBridge.sendPacket(writePacket(message)); + }; + +export const setParameterMIDIControlOnRemote = (id: PatcherInstanceRecord["id"], paramId: ParameterRecord["id"], control: number): 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 = (meta.midi || {}); + meta.midi.ctrl = control; + const message = { address: `${param.path}/meta`, args: [ 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/midi/mappedParameterItem.tsx b/src/components/midi/mappedParameterItem.tsx index c535556..473dc6f 100644 --- a/src/components/midi/mappedParameterItem.tsx +++ b/src/components/midi/mappedParameterItem.tsx @@ -9,17 +9,22 @@ import Link from "next/link"; import { useRouter } from "next/router"; import classes from "./midi.module.css"; import { formatParamValueForDisplay } from "../../lib/util"; +import { EditableTableNumberCell } from "../elements/editableTableCell"; export type MIDIMappedParamProps = { instance: PatcherInstanceRecord; param: ParameterRecord; onClearMIDIMapping: (instance: PatcherInstanceRecord, param: ParameterRecord) => void; + onUpdateMIDIChannel: (instance: PatcherInstanceRecord, param: ParameterRecord, channel: number) => void; + onUpdateMIDIControl: (instance: PatcherInstanceRecord, param: ParameterRecord, control: number) => void; }; const MIDIMappedParameter: FC = memo(function WrappedMIDIMappedParam({ instance, param, - onClearMIDIMapping + onClearMIDIMapping, + onUpdateMIDIChannel, + onUpdateMIDIControl }) { const { query: restQuery } = useRouter(); @@ -39,10 +44,26 @@ const MIDIMappedParameter: FC = memo(function WrappedMIDIM }); }, [param, instance, onClearMIDIMapping]); + const onUpdateChannel = useCallback((channel: number) => { + onUpdateMIDIChannel(instance, param, channel); + }, [onUpdateMIDIChannel, instance, param]); + + const onUpdateControl = useCallback((control: number) => { + onUpdateMIDIControl(instance, param, control); + }, [onUpdateMIDIControl, instance, param]); + return ( - { param.meta.midi?.chan || ""} - { param.meta.midi?.ctrl || ""} + { + param.meta.midi?.chan === undefined + ? + : + } + { + param.meta.midi?.ctrl === undefined + ? + : + } { param.name } { instance.index } diff --git a/src/components/midi/mappedParameterList.tsx b/src/components/midi/mappedParameterList.tsx index e4188a5..9a595d5 100644 --- a/src/components/midi/mappedParameterList.tsx +++ b/src/components/midi/mappedParameterList.tsx @@ -11,7 +11,9 @@ import { MIDIMappedParameterSortAttr, SortOrder } from "../../lib/constants"; export type MIDIMappedParameterListProps = { parameters: ImmuOrderedSet; patcherInstances: ImmuMap; - onClearParameterMidiMapping: (instance: PatcherInstanceRecord, param: ParameterRecord) => void; + onClearParameterMIDIMapping: (instance: PatcherInstanceRecord, param: ParameterRecord) => void; + onUpdateParameterMIDIChannel: (instance: PatcherInstanceRecord, param: ParameterRecord, channel: number) => void; + onUpdateParameterMIDIControl: (instance: PatcherInstanceRecord, param: ParameterRecord, control: number) => void; onSort: (sortAttr: MIDIMappedParameterSortAttr) => void; sortAttr: MIDIMappedParameterSortAttr; sortOrder: SortOrder; @@ -20,7 +22,9 @@ export type MIDIMappedParameterListProps = { const MIDIMappedParameterList: FC = memo(function WrappedMIDIMappedParameterList({ patcherInstances, parameters, - onClearParameterMidiMapping, + onClearParameterMIDIMapping, + onUpdateParameterMIDIChannel, + onUpdateParameterMIDIControl, onSort, sortAttr, sortOrder @@ -86,7 +90,9 @@ const MIDIMappedParameterList: FC = memo(function key={ p.id } instance={ pInstance } param={ p } - onClearMIDIMapping={ onClearParameterMidiMapping } + onClearMIDIMapping={ onClearParameterMIDIMapping } + onUpdateMIDIChannel={ onUpdateParameterMIDIChannel } + onUpdateMIDIControl={ onUpdateParameterMIDIControl } /> ); }) diff --git a/src/components/midi/midi.module.css b/src/components/midi/midi.module.css index 65b7cbe..e6ee10b 100644 --- a/src/components/midi/midi.module.css +++ b/src/components/midi/midi.module.css @@ -10,6 +10,7 @@ .parameterValueColumnHeader, .parameterValueColumn { + cursor: default; display: none; font-size: var(--mantine-font-size-xs); @@ -28,6 +29,7 @@ } .patcherInstanceColumn { + cursor: default; font-size: var(--mantine-font-size-xs); overflow: hidden; text-overflow: ellipsis; @@ -44,6 +46,7 @@ .parameterNameColumnHeader {} .parameterNameColumn { + cursor: default; font-size: var(--mantine-font-size-xs); overflow: hidden; text-overflow: ellipsis; diff --git a/src/pages/midimappings.tsx b/src/pages/midimappings.tsx index b8f4560..995ac36 100644 --- a/src/pages/midimappings.tsx +++ b/src/pages/midimappings.tsx @@ -8,7 +8,7 @@ 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 } from "../actions/patchers"; +import { clearParameterMIDIMappingOnRemote, setParameterMIDIChannelOnRemote, setParameterMIDIControlOnRemote } from "../actions/patchers"; import { PatcherInstanceRecord } from "../models/instance"; const collator = new Intl.Collator("en-US"); @@ -88,11 +88,19 @@ const MIDIMappings = () => { getPatcherInstanceParametersWithMIDIMapping(state) ]); - const onClearParameterMidiMapping = useCallback((instance: PatcherInstanceRecord, param: ParameterRecord) => { - dispatch(clearParameterMidiMappingOnRemote(instance.id, param.id)); + const onClearParameterMIDIMapping = useCallback((instance: PatcherInstanceRecord, param: ParameterRecord) => { + dispatch(clearParameterMIDIMappingOnRemote(instance.id, param.id)); }, [dispatch]); - const onSort = useCallback((attr: MIDIMappedParameterSortAttr) => { + const onUpdateParamterMIDIChannel = useCallback((instance: PatcherInstanceRecord, param: ParameterRecord, channel: number) => { + dispatch(setParameterMIDIChannelOnRemote(instance.id, param.id, channel)); + }, [dispatch]); + + const onUpdateParamterMIDIControl = useCallback((instance: PatcherInstanceRecord, param: ParameterRecord, control: number) => { + dispatch(setParameterMIDIControlOnRemote(instance.id, param.id, control)); + }, [dispatch]); + + const onSort = useCallback((attr: MIDIMappedParameterSortAttr): void => { if (attr === sortAttr) return void setSortOrder(sortOrder === SortOrder.Asc ? SortOrder.Desc : SortOrder.Asc); setSortAttr(attr); @@ -116,7 +124,9 @@ const MIDIMappings = () => { Date: Mon, 16 Dec 2024 20:16:43 +0000 Subject: [PATCH 14/17] fixed linter errors --- src/actions/patchers.ts | 34 +++++++++---------- src/components/elements/editableTableCell.tsx | 10 +++--- 2 files changed, 22 insertions(+), 22 deletions(-) diff --git a/src/actions/patchers.ts b/src/actions/patchers.ts index 0877786..f4a373c 100644 --- a/src/actions/patchers.ts +++ b/src/actions/patchers.ts @@ -576,28 +576,28 @@ export const clearParameterMIDIMappingOnRemote = (id: PatcherInstanceRecord["id" }; export const setParameterMIDIChannelOnRemote = (id: PatcherInstanceRecord["id"], paramId: ParameterRecord["id"], channel: number): AppThunk => - (_dispatch, getState) => { - const state = getState(); - const instance = getPatcherInstance(state, id); - if (!instance) return; - - const param = getPatcherInstanceParameter(state, paramId); - if (!param) return; + (_dispatch, getState) => { + const state = getState(); + const instance = getPatcherInstance(state, id); + if (!instance) return; - const meta: ParameterMetaJsonMap = cloneJSON(param.meta); - meta.midi = (meta.midi || {}); - meta.midi.chan = channel; + const param = getPatcherInstanceParameter(state, paramId); + if (!param) return; - const message = { - address: `${param.path}/meta`, - args: [ - { type: "s", value: JSON.stringify(meta) } - ] - }; + const meta: ParameterMetaJsonMap = cloneJSON(param.meta); + meta.midi = (meta.midi || {}); + meta.midi.chan = channel; - oscQueryBridge.sendPacket(writePacket(message)); + const message = { + address: `${param.path}/meta`, + args: [ + { type: "s", value: JSON.stringify(meta) } + ] }; + oscQueryBridge.sendPacket(writePacket(message)); + }; + export const setParameterMIDIControlOnRemote = (id: PatcherInstanceRecord["id"], paramId: ParameterRecord["id"], control: number): AppThunk => (_dispatch, getState) => { const state = getState(); diff --git a/src/components/elements/editableTableCell.tsx b/src/components/elements/editableTableCell.tsx index 966ee6e..addc60b 100644 --- a/src/components/elements/editableTableCell.tsx +++ b/src/components/elements/editableTableCell.tsx @@ -1,5 +1,5 @@ -import { Group, NumberInput, Table } from "@mantine/core"; -import { FC, memo, useCallback, useEffect, useState } from "react" +import { NumberInput, Table } from "@mantine/core"; +import { FC, memo, useCallback, useEffect, useState } from "react"; export type EditableTableNumberCellProps = { className?: string; @@ -8,7 +8,7 @@ export type EditableTableNumberCellProps = { name: string; onUpdate: (val: number) => void; value: number; -} +}; export const EditableTableNumberCell: FC = memo(function WrappedEditableMIDIField({ className = "", @@ -60,5 +60,5 @@ export const EditableTableNumberCell: FC = memo(fu } - ) -}) + ); +}); From e47f83ba003308890cb9f2d0b2a4c5a3a1bcff3a Mon Sep 17 00:00:00 2001 From: Florian Demmer Date: Tue, 17 Dec 2024 13:17:30 +0000 Subject: [PATCH 15/17] enhanced support for MIDIMapping display and editing using Max-like format / display --- src/actions/patchers.ts | 37 ++---- src/components/elements/editableTableCell.tsx | 98 +++++++++++++- src/components/elements/elements.module.css | 4 + src/components/midi/mappedParameterItem.tsx | 61 +++++---- src/components/midi/mappedParameterList.tsx | 27 +--- src/components/midi/midi.module.css | 17 ++- src/lib/constants.ts | 12 +- src/lib/types.ts | 37 +++++- src/lib/util.ts | 121 +++++++++++++++++- src/models/parameter.ts | 26 +++- src/pages/midimappings.tsx | 56 +++----- 11 files changed, 368 insertions(+), 128 deletions(-) diff --git a/src/actions/patchers.ts b/src/actions/patchers.ts index f4a373c..04322f3 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, ParameterMetaJsonMap } 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,7 +16,8 @@ import { AppSetting } from "../models/settings"; import { DataRefRecord } from "../models/dataref"; import { DataFileRecord } from "../models/datafile"; import { PatcherExportRecord } from "../models/patcher"; -import { cloneJSON } from "../lib/util"; +import { cloneJSON, parseMIDIMappingDisplayValue } from "../lib/util"; +import { MIDIMetaMappingType } from "../lib/constants"; export enum PatcherActionType { INIT_PATCHERS = "INIT_PATCHERS", @@ -575,7 +576,7 @@ export const clearParameterMIDIMappingOnRemote = (id: PatcherInstanceRecord["id" oscQueryBridge.sendPacket(writePacket(message)); }; -export const setParameterMIDIChannelOnRemote = (id: PatcherInstanceRecord["id"], paramId: ParameterRecord["id"], channel: number): AppThunk => +export const setParameterMIDIMappingOnRemote = (id: PatcherInstanceRecord["id"], paramId: ParameterRecord["id"], type: MIDIMetaMappingType, mapping: MIDIMetaMapping): AppThunk => (_dispatch, getState) => { const state = getState(); const instance = getPatcherInstance(state, id); @@ -585,8 +586,7 @@ export const setParameterMIDIChannelOnRemote = (id: PatcherInstanceRecord["id"], if (!param) return; const meta: ParameterMetaJsonMap = cloneJSON(param.meta); - meta.midi = (meta.midi || {}); - meta.midi.chan = channel; + meta.midi = { ...mapping }; const message = { address: `${param.path}/meta`, @@ -598,27 +598,14 @@ export const setParameterMIDIChannelOnRemote = (id: PatcherInstanceRecord["id"], oscQueryBridge.sendPacket(writePacket(message)); }; -export const setParameterMIDIControlOnRemote = (id: PatcherInstanceRecord["id"], paramId: ParameterRecord["id"], control: number): 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 = (meta.midi || {}); - meta.midi.ctrl = control; - - const message = { - address: `${param.path}/meta`, - args: [ - { type: "s", value: JSON.stringify(meta) } - ] - }; +export const setParameterMIDIMappingOnRemoteFromDisplayValue = (id: PatcherInstanceRecord["id"], paramId: ParameterRecord["id"], value: string): AppThunk => + (dispatch) => { + const parsed = parseMIDIMappingDisplayValue(value); + if (!parsed) { + return void dispatch(showNotification({ title: "Invalid MIDI Mapping", message: `"${value}" is not a valid MIDI mapping format`, level: NotificationLevel.error })); + } - oscQueryBridge.sendPacket(writePacket(message)); + dispatch(setParameterMIDIMappingOnRemote(id, paramId, parsed.type, parsed.mapping)); }; export const setInstanceMessagePortMetaOnRemote = (_instance: PatcherInstanceRecord, port: MessagePortRecord, value: string): AppThunk => diff --git a/src/components/elements/editableTableCell.tsx b/src/components/elements/editableTableCell.tsx index addc60b..64e7b67 100644 --- a/src/components/elements/editableTableCell.tsx +++ b/src/components/elements/editableTableCell.tsx @@ -1,5 +1,6 @@ -import { NumberInput, Table } from "@mantine/core"; -import { FC, memo, useCallback, useEffect, useState } from "react"; +import { NumberInput, Table, TextInput } from "@mantine/core"; +import { ChangeEvent, FC, KeyboardEvent, memo, useCallback, useEffect, useState } from "react"; +import classes from "./elements.module.css"; export type EditableTableNumberCellProps = { className?: string; @@ -7,15 +8,17 @@ export type EditableTableNumberCellProps = { max: number; name: string; onUpdate: (val: number) => void; + prefix?: string; value: number; }; -export const EditableTableNumberCell: FC = memo(function WrappedEditableMIDIField({ +export const EditableTableNumberCell: FC = memo(function WrappedEditableNumberField({ className = "", min, max, name, onUpdate, + prefix, value }) { const [isEditing, setIsEditing] = useState(false); @@ -37,6 +40,19 @@ export const EditableTableNumberCell: FC = memo(fu 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]); @@ -47,16 +63,90 @@ export const EditableTableNumberCell: FC = memo(fu 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 + ) : 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/midi/mappedParameterItem.tsx b/src/components/midi/mappedParameterItem.tsx index 473dc6f..26eb362 100644 --- a/src/components/midi/mappedParameterItem.tsx +++ b/src/components/midi/mappedParameterItem.tsx @@ -8,23 +8,47 @@ import { modals } from "@mantine/modals"; import Link from "next/link"; import { useRouter } from "next/router"; import classes from "./midi.module.css"; -import { formatParamValueForDisplay } from "../../lib/util"; -import { EditableTableNumberCell } from "../elements/editableTableCell"; +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; - onUpdateMIDIChannel: (instance: PatcherInstanceRecord, param: ParameterRecord, channel: number) => void; - onUpdateMIDIControl: (instance: PatcherInstanceRecord, param: ParameterRecord, control: number) => void; + onUpdateMIDIMapping: (instance: PatcherInstanceRecord, param: ParameterRecord, value: string) => void; }; + const MIDIMappedParameter: FC = memo(function WrappedMIDIMappedParam({ instance, param, onClearMIDIMapping, - onUpdateMIDIChannel, - onUpdateMIDIControl + onUpdateMIDIMapping }) { const { query: restQuery } = useRouter(); @@ -44,26 +68,17 @@ const MIDIMappedParameter: FC = memo(function WrappedMIDIM }); }, [param, instance, onClearMIDIMapping]); - const onUpdateChannel = useCallback((channel: number) => { - onUpdateMIDIChannel(instance, param, channel); - }, [onUpdateMIDIChannel, instance, param]); - - const onUpdateControl = useCallback((control: number) => { - onUpdateMIDIControl(instance, param, control); - }, [onUpdateMIDIControl, instance, param]); + const onUpdateMapping = useCallback((value: string) => { + onUpdateMIDIMapping(instance, param, value); + }, [instance, param, onUpdateMIDIMapping]); return ( - { - param.meta.midi?.chan === undefined - ? - : - } - { - param.meta.midi?.ctrl === undefined - ? - : - } + { param.name } { instance.index } diff --git a/src/components/midi/mappedParameterList.tsx b/src/components/midi/mappedParameterList.tsx index 9a595d5..68c52c9 100644 --- a/src/components/midi/mappedParameterList.tsx +++ b/src/components/midi/mappedParameterList.tsx @@ -12,8 +12,7 @@ export type MIDIMappedParameterListProps = { parameters: ImmuOrderedSet; patcherInstances: ImmuMap; onClearParameterMIDIMapping: (instance: PatcherInstanceRecord, param: ParameterRecord) => void; - onUpdateParameterMIDIChannel: (instance: PatcherInstanceRecord, param: ParameterRecord, channel: number) => void; - onUpdateParameterMIDIControl: (instance: PatcherInstanceRecord, param: ParameterRecord, control: number) => void; + onUpdateParameterMIDIMapping: (instance: PatcherInstanceRecord, param: ParameterRecord, value: string) => void; onSort: (sortAttr: MIDIMappedParameterSortAttr) => void; sortAttr: MIDIMappedParameterSortAttr; sortOrder: SortOrder; @@ -23,8 +22,7 @@ const MIDIMappedParameterList: FC = memo(function patcherInstances, parameters, onClearParameterMIDIMapping, - onUpdateParameterMIDIChannel, - onUpdateParameterMIDIControl, + onUpdateParameterMIDIMapping, onSort, sortAttr, sortOrder @@ -35,24 +33,14 @@ const MIDIMappedParameterList: FC = memo(function - Channel - - - Control + Source = memo(function instance={ pInstance } param={ p } onClearMIDIMapping={ onClearParameterMIDIMapping } - onUpdateMIDIChannel={ onUpdateParameterMIDIChannel } - onUpdateMIDIControl={ onUpdateParameterMIDIControl } + onUpdateMIDIMapping={ onUpdateParameterMIDIMapping } /> ); }) diff --git a/src/components/midi/midi.module.css b/src/components/midi/midi.module.css index e6ee10b..8d1a5dc 100644 --- a/src/components/midi/midi.module.css +++ b/src/components/midi/midi.module.css @@ -1,10 +1,9 @@ -.midiChannelColumnHeader, -.midiControlColumnHeader { +.midiSourceColumnHeader { width: 100px; } -.midiChannelColumn, -.midiControlColumn { +.midiSourceColumn { + cursor: text; font-size: var(--mantine-font-size-xs); } @@ -13,6 +12,7 @@ cursor: default; display: none; font-size: var(--mantine-font-size-xs); + user-select: none; @media (min-width: $mantine-breakpoint-md) { display: table-cell; @@ -21,7 +21,7 @@ } .patcherInstanceColumnHeader { - width: 80px; + width: 100px; @media (min-width: $mantine-breakpoint-md) { width: initial; @@ -33,6 +33,7 @@ font-size: var(--mantine-font-size-xs); overflow: hidden; text-overflow: ellipsis; + user-select: none; white-space: nowrap; .patcherInstanceName { @@ -44,12 +45,16 @@ } } -.parameterNameColumnHeader {} +.parameterNameColumnHeader { + width: 150px; +} + .parameterNameColumn { cursor: default; font-size: var(--mantine-font-size-xs); overflow: hidden; text-overflow: ellipsis; + user-select: none; white-space: nowrap; } diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 08aa6e0..1788f33 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -72,12 +72,20 @@ export enum SortOrder { } export enum MIDIMappedParameterSortAttr { - MIDIChannel = "midi_channel", - MIDIControl = "midi_control", + 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 91de006..50a34f3 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -9,11 +9,40 @@ 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?: { - chan?: number; - ctrl?: number; - } + midi?: MIDIMetaMapping; }; export type OSCValue = string | number | null; diff --git a/src/lib/util.ts b/src/lib/util.ts index 504fe25..842893d 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)); @@ -66,3 +67,121 @@ export const formatParamValueForDisplay = (value: number | string) => { }; 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; + throw new Error(`Unknown MIDIMappingType "${type}"`); + } + } +}; + +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})$/ +}; + +export const parseMIDIMappingDisplayValue = (value: string): false | { 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 = parseInt(match.groups.chanpress, 10); + if (isNaN(chanpress) || chanpress < 1 || chanpress > 16) return false; + return { + type: MIDIMetaMappingType.ChannelPressure, + mapping: { chanpress } as MIDIChannelPressureMetaMapping + }; + } + case MIDIMetaMappingType.ControlChange: { + const chan = parseInt(match.groups.chan, 10); + if (isNaN(chan) || chan < 1 || chan > 16) return false; + + const ctrl = parseInt(match.groups.ctrl, 10); + if (isNaN(ctrl) || ctrl < 0 || ctrl > 127) return false; + + return { + type: MIDIMetaMappingType.ControlChange, + mapping: { chan, ctrl } as MIDIControlChangeMetaMapping + }; + } + case MIDIMetaMappingType.KeyPressure: { + const chan = parseInt(match.groups.chan, 10); + if (isNaN(chan) || chan < 1 || chan > 16) return false; + + const keypress = parseInt(match.groups.keypress, 10); + if (isNaN(keypress) || keypress < 0 || keypress > 127) return false; + + return { + type: MIDIMetaMappingType.KeyPressure, + mapping: { chan, keypress } as MIDIKeypressMetaMapping + }; + + } + case MIDIMetaMappingType.Note: { + const chan = parseInt(match.groups.chan, 10); + if (isNaN(chan) || chan < 1 || chan > 16) return false; + + const note = parseInt(match.groups.note, 10); + if (isNaN(note) || note < 0 || note > 127) return false; + + return { + type: MIDIMetaMappingType.Note, + mapping: { chan, note } as MIDINoteMetaMapping + }; + + } + case MIDIMetaMappingType.PitchBend: { + const bend = parseInt(match.groups.bend, 10); + if (isNaN(bend) || bend < 1 || bend > 16) return false; + return { + type: MIDIMetaMappingType.PitchBend, + mapping: { bend } as MIDIPitchBendMetaMapping + }; + + } + case MIDIMetaMappingType.ProgramChange: { + const prgchg = parseInt(match.groups.prgchg, 10); + if (isNaN(prgchg) || prgchg < 1 || prgchg > 16) return false; + return { + type: MIDIMetaMappingType.ProgramChange, + mapping: { prgchg } as MIDIProgramChangeMetaMapping + }; + } + + default: { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const _exhaustive: never = mappingType; + throw new Error(`Unknown MIDIMappingType "${mappingType}"`); + } + } + } + + return false; +}; diff --git a/src/models/parameter.ts b/src/models/parameter.ts index 2d9bcd6..7ca392d 100644 --- a/src/models/parameter.ts +++ b/src/models/parameter.ts @@ -1,6 +1,7 @@ import { Record as ImmuRecord } from "immutable"; import { OSCQueryRNBOInstance, OSCQueryRNBOInstanceParameterInfo, OSCQueryRNBOInstanceParameterValue, ParameterMetaJsonMap } from "../lib/types"; import { parseMetaJSONString } from "../lib/util"; +import { MIDIMetaMappingType } from "../lib/constants"; export type ParameterRecordProps = { @@ -17,6 +18,7 @@ export type ParameterRecordProps = { type: string; value: string | number; waitingForMidiMapping: boolean; + midiMappingType: false | MIDIMetaMappingType; isMidiMapped: boolean; } export class ParameterRecord extends ImmuRecord({ @@ -34,7 +36,8 @@ export class ParameterRecord extends ImmuRecord({ type: "f", value: 0, waitingForMidiMapping: false, - isMidiMapped: false + isMidiMapped: false, + midiMappingType: false }) { private static arrayFromDescription( @@ -113,11 +116,30 @@ export class ParameterRecord extends ImmuRecord({ // ignore } + 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; + } + return this.withMutations(p => { return p .set("metaString", value) .set("meta", parsed) - .set("isMidiMapped", typeof parsed.midi === "object"); + .set("isMidiMapped", isMidiMapped) + .set("midiMappingType", midiMappingType); }); } diff --git a/src/pages/midimappings.tsx b/src/pages/midimappings.tsx index 995ac36..b377179 100644 --- a/src/pages/midimappings.tsx +++ b/src/pages/midimappings.tsx @@ -3,48 +3,27 @@ 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, SortOrder } from "../lib/constants"; +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, setParameterMIDIChannelOnRemote, setParameterMIDIControlOnRemote } from "../actions/patchers"; +import { clearParameterMIDIMappingOnRemote, setParameterMIDIMappingOnRemoteFromDisplayValue } from "../actions/patchers"; import { PatcherInstanceRecord } from "../models/instance"; +import { formatMIDIMappingToDisplay } from "../lib/util"; -const collator = new Intl.Collator("en-US"); +const collator = new Intl.Collator("en-US", { numeric: true }); const parameterComparators: Record number>> = { - [MIDIMappedParameterSortAttr.MIDIChannel]: { + [MIDIMappedParameterSortAttr.MIDISource]: { [SortOrder.Asc]: (a: ParameterRecord, b: ParameterRecord) => { - if (a.meta.midi?.chan < b.meta.midi?.chan) return -1; - if (a.meta.midi?.chan > b.meta.midi?.chan) return 1; - if (a.meta.midi?.ctrl < b.meta.midi?.ctrl) return -1; - if (a.meta.midi?.ctrl > b.meta.midi?.ctrl) return 1; - return collator.compare(a.name.toLowerCase(), b.name.toLowerCase()); + 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) => { - if (a.meta.midi?.chan > b.meta.midi?.chan) return -1; - if (a.meta.midi?.chan < b.meta.midi?.chan) return 1; - if (a.meta.midi?.ctrl > b.meta.midi?.ctrl) return -1; - if (a.meta.midi?.ctrl < b.meta.midi?.ctrl) return 1; - - return collator.compare(a.name.toLowerCase(), b.name.toLowerCase()) * -1; - } - }, - [MIDIMappedParameterSortAttr.MIDIControl]: { - [SortOrder.Asc]: (a: ParameterRecord, b: ParameterRecord) => { - if (a.meta.midi?.ctrl < b.meta.midi?.ctrl) return -1; - if (a.meta.midi?.ctrl > b.meta.midi?.ctrl) return 1; - if (a.meta.midi?.chan < b.meta.midi?.chan) return -1; - if (a.meta.midi?.chan > b.meta.midi?.chan) return 1; - return collator.compare(a.name.toLowerCase(), b.name.toLowerCase()); - }, - [SortOrder.Desc]: (a: ParameterRecord, b: ParameterRecord) => { - if (a.meta.midi?.ctrl > b.meta.midi?.ctrl) return -1; - if (a.meta.midi?.ctrl < b.meta.midi?.ctrl) return 1; - if (a.meta.midi?.chan > b.meta.midi?.chan) return -1; - if (a.meta.midi?.chan < b.meta.midi?.chan) return 1; - - return collator.compare(a.name.toLowerCase(), b.name.toLowerCase()) * -1; + 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]: { @@ -76,7 +55,7 @@ const getSortedParameterIds = (params: ImmuMap { const [sortOrder, setSortOrder] = useState(SortOrder.Asc); - const [sortAttr, setSortAttr] = useState(MIDIMappedParameterSortAttr.MIDIChannel); + const [sortAttr, setSortAttr] = useState(MIDIMappedParameterSortAttr.MIDISource); const [sortedParameterIds, setSortedParameterIds] = useState>(ImmuOrderedSet()); const dispatch = useAppDispatch(); @@ -92,12 +71,8 @@ const MIDIMappings = () => { dispatch(clearParameterMIDIMappingOnRemote(instance.id, param.id)); }, [dispatch]); - const onUpdateParamterMIDIChannel = useCallback((instance: PatcherInstanceRecord, param: ParameterRecord, channel: number) => { - dispatch(setParameterMIDIChannelOnRemote(instance.id, param.id, channel)); - }, [dispatch]); - - const onUpdateParamterMIDIControl = useCallback((instance: PatcherInstanceRecord, param: ParameterRecord, control: number) => { - dispatch(setParameterMIDIControlOnRemote(instance.id, param.id, control)); + const onUpdateParameterMIDIMapping = useCallback((instance: PatcherInstanceRecord, param: ParameterRecord, value: string) => { + dispatch(setParameterMIDIMappingOnRemoteFromDisplayValue(instance.id, param.id, value)); }, [dispatch]); const onSort = useCallback((attr: MIDIMappedParameterSortAttr): void => { @@ -125,8 +100,7 @@ const MIDIMappings = () => { patcherInstances={ patcherInstances } parameters={ displayParameters } onClearParameterMIDIMapping={ onClearParameterMIDIMapping } - onUpdateParameterMIDIChannel={ onUpdateParamterMIDIChannel } - onUpdateParameterMIDIControl={ onUpdateParamterMIDIControl } + onUpdateParameterMIDIMapping={ onUpdateParameterMIDIMapping } onSort={ onSort } sortAttr={ sortAttr } sortOrder={ sortOrder } From acb00372bbafaa31b547bd4f639ce813eedb56b1 Mon Sep 17 00:00:00 2001 From: Florian Demmer Date: Tue, 17 Dec 2024 14:34:07 +0000 Subject: [PATCH 16/17] improved MIDI Mapping parsing and display logic --- src/actions/patchers.ts | 33 +++++++++++++++++---- src/lib/util.ts | 64 +++++++++++++++++++++++++---------------- src/models/parameter.ts | 2 ++ 3 files changed, 68 insertions(+), 31 deletions(-) diff --git a/src/actions/patchers.ts b/src/actions/patchers.ts index 04322f3..db61a53 100644 --- a/src/actions/patchers.ts +++ b/src/actions/patchers.ts @@ -16,7 +16,7 @@ import { AppSetting } from "../models/settings"; import { DataRefRecord } from "../models/dataref"; import { DataFileRecord } from "../models/datafile"; import { PatcherExportRecord } from "../models/patcher"; -import { cloneJSON, parseMIDIMappingDisplayValue } from "../lib/util"; +import { cloneJSON, InvalidMIDIFormatError, parseMIDIMappingDisplayValue, UnknownMIDIFormatError } from "../lib/util"; import { MIDIMetaMappingType } from "../lib/constants"; export enum PatcherActionType { @@ -600,12 +600,33 @@ export const setParameterMIDIMappingOnRemote = (id: PatcherInstanceRecord["id"], export const setParameterMIDIMappingOnRemoteFromDisplayValue = (id: PatcherInstanceRecord["id"], paramId: ParameterRecord["id"], value: string): AppThunk => (dispatch) => { - const parsed = parseMIDIMappingDisplayValue(value); - if (!parsed) { - return void dispatch(showNotification({ title: "Invalid MIDI Mapping", message: `"${value}" is not a valid MIDI mapping format`, level: NotificationLevel.error })); + 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)); } - - dispatch(setParameterMIDIMappingOnRemote(id, paramId, parsed.type, parsed.mapping)); }; export const setInstanceMessagePortMetaOnRemote = (_instance: PatcherInstanceRecord, port: MessagePortRecord, value: string): AppThunk => diff --git a/src/lib/util.ts b/src/lib/util.ts index 842893d..9c3e075 100644 --- a/src/lib/util.ts +++ b/src/lib/util.ts @@ -91,7 +91,7 @@ export const formatMIDIMappingToDisplay = (type: MIDIMetaMappingType, mapping: M default: { // eslint-disable-next-line @typescript-eslint/no-unused-vars const _exhaustive: never = type; - throw new Error(`Unknown MIDIMappingType "${type}"`); + return "Unknown"; } } }; @@ -105,26 +105,43 @@ const midiMetaRegexp: Record = { [MIDIMetaMappingType.ProgramChange]: /^PRGCHG\/(?[0-9]{1,2})$/ }; -export const parseMIDIMappingDisplayValue = (value: string): false | { type: MIDIMetaMappingType, mapping: MIDIMetaMapping } => { +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 = parseInt(match.groups.chanpress, 10); - if (isNaN(chanpress) || chanpress < 1 || chanpress > 16) return false; + 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 = parseInt(match.groups.chan, 10); - if (isNaN(chan) || chan < 1 || chan > 16) return false; - - const ctrl = parseInt(match.groups.ctrl, 10); - if (isNaN(ctrl) || ctrl < 0 || ctrl > 127) return false; + 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, @@ -132,11 +149,9 @@ export const parseMIDIMappingDisplayValue = (value: string): false | { type: MID }; } case MIDIMetaMappingType.KeyPressure: { - const chan = parseInt(match.groups.chan, 10); - if (isNaN(chan) || chan < 1 || chan > 16) return false; - - const keypress = parseInt(match.groups.keypress, 10); - if (isNaN(keypress) || keypress < 0 || keypress > 127) return false; + 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, @@ -145,11 +160,9 @@ export const parseMIDIMappingDisplayValue = (value: string): false | { type: MID } case MIDIMetaMappingType.Note: { - const chan = parseInt(match.groups.chan, 10); - if (isNaN(chan) || chan < 1 || chan > 16) return false; - - const note = parseInt(match.groups.note, 10); - if (isNaN(note) || note < 0 || note > 127) return false; + 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, @@ -158,8 +171,9 @@ export const parseMIDIMappingDisplayValue = (value: string): false | { type: MID } case MIDIMetaMappingType.PitchBend: { - const bend = parseInt(match.groups.bend, 10); - if (isNaN(bend) || bend < 1 || bend > 16) return false; + const bend = parseMIDIByte(match.groups?.bend, 1, 16); + if (bend === null) throw new InvalidMIDIFormatError(); + return { type: MIDIMetaMappingType.PitchBend, mapping: { bend } as MIDIPitchBendMetaMapping @@ -167,8 +181,9 @@ export const parseMIDIMappingDisplayValue = (value: string): false | { type: MID } case MIDIMetaMappingType.ProgramChange: { - const prgchg = parseInt(match.groups.prgchg, 10); - if (isNaN(prgchg) || prgchg < 1 || prgchg > 16) return false; + const prgchg = parseMIDIByte(match.groups?.prgchg, 1, 16); + if (prgchg === null) throw new InvalidMIDIFormatError(); + return { type: MIDIMetaMappingType.ProgramChange, mapping: { prgchg } as MIDIProgramChangeMetaMapping @@ -178,10 +193,9 @@ export const parseMIDIMappingDisplayValue = (value: string): false | { type: MID default: { // eslint-disable-next-line @typescript-eslint/no-unused-vars const _exhaustive: never = mappingType; - throw new Error(`Unknown MIDIMappingType "${mappingType}"`); } } } - return false; + throw new UnknownMIDIFormatError(); }; diff --git a/src/models/parameter.ts b/src/models/parameter.ts index 7ca392d..317da9a 100644 --- a/src/models/parameter.ts +++ b/src/models/parameter.ts @@ -132,6 +132,8 @@ export class ParameterRecord extends ImmuRecord({ midiMappingType = MIDIMetaMappingType.Note; } else if (Object.hasOwn(parsed.midi, "prgchg")) { midiMappingType = MIDIMetaMappingType.ProgramChange; + } else { + midiMappingType = false; } return this.withMutations(p => { From b854dc4d1954758e7d6ef742f364c70c4c165f9f Mon Sep 17 00:00:00 2001 From: Florian Demmer Date: Tue, 17 Dec 2024 14:37:13 +0000 Subject: [PATCH 17/17] fixed linting indent issue --- src/lib/util.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/lib/util.ts b/src/lib/util.ts index 9c3e075..f5ac8ad 100644 --- a/src/lib/util.ts +++ b/src/lib/util.ts @@ -107,9 +107,9 @@ const midiMetaRegexp: Record = { 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; + const n = parseInt(val, 10); + if (isNaN(n) || n < min || n > max) return null; + return n; }; export class InvalidMIDIFormatError extends Error {