Skip to content

Commit c1ba045

Browse files
cale-bradburyclaudefunwithtriangles
authored
Midi Auto-Learn & On+Off Mode (#666)
* add auto-midi-learn * add midi on/off mode * Fix type errors from post-fork store/schema changes - globalOptionNodesConfig entries require nodeType: 'param' (added since fork) - InputNode.optionNodeIds moved under input.childGroups.optionNodeIds * MIDI learn: only update channel/note, not type Learning previously overwrote the type param with whatever message triggered the learn (Note On/Off vs CC), silently discarding a user-chosen type — including the Note On/Off mode toggle. Channel and note are still learned; type is left as the user set it. * MIDI auto learn causes button state to update * MIDI learn: move auto-learn to onNewInput, fix stale node lookup Auto-learn and the boolean-default-to-Note-On/Off logic lived in a React mount effect that inferred "new input" from channel/note still being at their defaults — indistinguishable from a user deliberately choosing those same values later. Moved both into MidiInput.onNewInput, the existing engine-level hook that fires exactly once at creation. That also surfaced a real bug: newInput is captured before addOptionNodes runs its own store update, so newInput.childGroups.optionNodeIds was stale and channel/note/type lookups silently found nothing. Now re-fetches the live node by id after the learn resolves. Type is derived from the actual learned event (Note On/Off for a button, Control Change for a knob) instead of being pre-guessed from valueType. Extracted MidiInput.applyLearnedEvent, shared by onNewInput and the panel's manual "Midi Learn" button, so channel/note/type application only has one implementation. The manual path still passes includeType: false so a re-learn doesn't clobber a deliberately chosen type. MidiManager now exposes isLearning/onLearnStateChange so the panel reflects the real state instead of owning a separate copy — fixes the button not showing "Cancel" while an auto-triggered learn is running. * Fix MIDI override regressions from Copilot review - Restore the -1 sentinel check in getValue() so a negative overrideValue still falls back to the incoming MIDI value instead of feeding -1 into enum/number mapping. - Fix overrideEnabled to read the "override" boolean option node instead of "overrideValue", so the Override Value control's visibility tracks the actual toggle. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * getNodeOptionNodes instead of findNodeWithKeyFromIdList * cleanup * getMidiInputTypeFromEvent as util * ts fix * ts fix --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> Co-authored-by: Alex Kempton <alex@funwithtriangles.net>
1 parent 5ae57b1 commit c1ba045

12 files changed

Lines changed: 340 additions & 177 deletions

File tree

packages/gamepad-input/src/GamepadGlobalPanel.tsx

Lines changed: 9 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,13 @@
11
import React, { useState, useEffect, useCallback } from 'react'
2-
import {
3-
findNodeWithKeyFromIdList,
4-
HedronEngine,
5-
InputNode,
6-
Nodes,
7-
nodesAsArray,
8-
ParamValues,
9-
} from '@hedron-gl/engine'
2+
import { HedronEngine, InputNode, Nodes, nodesAsArray, ParamValues } from '@hedron-gl/engine'
103
import {
114
useEngineStore,
125
Card,
136
CardBody,
147
CardContent,
158
Collapsible,
169
ControlGrid,
10+
getNodeOptionNodes,
1711
NodeContainer,
1812
} from '@hedron-gl/ui-core'
1913
import { GamepadInput } from './GamepadInput'
@@ -167,18 +161,11 @@ const findMatchingInputsForEvent = (
167161
.filter((input) => {
168162
if (input.nodeType !== 'input' || input.inputType !== 'gamepad') return false
169163

170-
const controllerIndexNode = findNodeWithKeyFromIdList(
171-
nodes,
172-
'controllerIndex',
173-
input.childGroups.optionNodeIds,
174-
)
175-
176-
const inputTypeNode = findNodeWithKeyFromIdList(
177-
nodes,
178-
'inputType',
179-
input.childGroups.optionNodeIds,
180-
)
181-
const indexNode = findNodeWithKeyFromIdList(nodes, 'index', input.childGroups.optionNodeIds)
164+
const {
165+
controllerIndex: controllerIndexNode,
166+
inputType: inputTypeNode,
167+
index: indexNode,
168+
} = getNodeOptionNodes({ nodes }, input.id)
182169

183170
if (!controllerIndexNode || !inputTypeNode || !indexNode) return false
184171

@@ -238,11 +225,7 @@ const getInputsForController = (
238225
return nodesAsArray(nodes).filter((node) => {
239226
if (node.nodeType !== 'input' || node.inputType !== 'gamepad') return false
240227

241-
const controllerIndexNode = findNodeWithKeyFromIdList(
242-
nodes,
243-
'controllerIndex',
244-
node.childGroups.optionNodeIds,
245-
)
228+
const { controllerIndex: controllerIndexNode } = getNodeOptionNodes({ nodes }, node.id)
246229

247230
if (!controllerIndexNode) return false
248231

@@ -429,12 +412,7 @@ const ControllerItem: React.FC<ControllerItemProps> = ({
429412
if (!lastEvent) return null
430413

431414
const matchingInput = controllerInputs.find((input) => {
432-
const inputTypeNode = findNodeWithKeyFromIdList(
433-
nodes,
434-
'inputType',
435-
input.childGroups.optionNodeIds,
436-
)
437-
const indexNode = findNodeWithKeyFromIdList(nodes, 'index', input.childGroups.optionNodeIds)
415+
const { inputType: inputTypeNode, index: indexNode } = getNodeOptionNodes({ nodes }, input.id)
438416

439417
if (!inputTypeNode || !indexNode) return false
440418

packages/midi-input/src/MidiGlobalPanel.tsx

Lines changed: 28 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import React, { useState, useEffect } from 'react'
2-
import { findNodeWithKeyFromIdList, HedronEngine, ParamNode, ShotNode } from '@hedron-gl/engine'
3-
import { useEngineStore, ControlGrid, NodeContainer } from '@hedron-gl/ui-core'
2+
import { HedronEngine, ParamNode, ShotNode } from '@hedron-gl/engine'
3+
import { useEngineStore, ControlGrid, NodeContainer, getNodeOptionNodes } from '@hedron-gl/ui-core'
44
import { MIDIEvent, MidiMessageType, midiMessageNames } from '@hedron-gl/midi-manager'
55
import { MidiInput } from './MidiInput'
6+
import { doesMidiEventMatchInput } from './utils'
67
import styles from './MidiGlobalPanel.module.css'
8+
import { NOTE_MODE_ON } from './constants'
79

810
interface MidiGlobalPanelProps {
911
engine: HedronEngine
@@ -69,24 +71,34 @@ export const MidiGlobalPanel: React.FC<MidiGlobalPanelProps> = ({ engine }) => {
6971
Object.values(nodes).forEach((input) => {
7072
if (input?.nodeType !== 'input' || input.inputType !== 'midi') return
7173

72-
const channelNode = findNodeWithKeyFromIdList(
73-
nodes,
74-
'channel',
75-
input.childGroups.optionNodeIds,
76-
)
77-
const noteNode = findNodeWithKeyFromIdList(nodes, 'note', input.childGroups.optionNodeIds)
78-
const typeNode = findNodeWithKeyFromIdList(nodes, 'type', input.childGroups.optionNodeIds)
74+
const {
75+
channel: channelNode,
76+
note: noteNode,
77+
type: typeNode,
78+
noteMode: noteModeNode,
79+
} = getNodeOptionNodes({ nodes }, input.id)
7980

8081
if (!channelNode || !noteNode || !typeNode) return
8182

82-
const channelValue = paramValues[channelNode.id]
83-
const noteValue = paramValues[noteNode.id]
84-
const typeValue = paramValues[typeNode.id]
83+
const channelValue = paramValues[channelNode.id] as number | undefined
84+
const noteValue = paramValues[noteNode.id] as number | undefined
85+
const typeValue = paramValues[typeNode.id] as number | undefined
86+
const noteModeValue = noteModeNode
87+
? ((paramValues[noteModeNode.id] as number | undefined) ?? NOTE_MODE_ON)
88+
: NOTE_MODE_ON
89+
90+
if (channelValue === undefined || noteValue === undefined || typeValue === undefined) {
91+
return
92+
}
8593

8694
if (
87-
channelValue === event.channel &&
88-
noteValue === event.note &&
89-
typeValue === event.type
95+
doesMidiEventMatchInput({
96+
event,
97+
channel: channelValue,
98+
note: noteValue,
99+
type: typeValue,
100+
noteMode: noteModeValue,
101+
})
90102
) {
91103
const targetNode = nodes[input.targetNodeId] as ParamNode | ShotNode
92104
if (targetNode) {
@@ -130,6 +142,7 @@ export const MidiGlobalPanel: React.FC<MidiGlobalPanelProps> = ({ engine }) => {
130142
<div>
131143
<ControlGrid>
132144
<NodeContainer nodeId={`${globalNodeId}-smoothing`} />
145+
<NodeContainer nodeId={`${globalNodeId}-autoMidiLearn`} />
133146
</ControlGrid>
134147

135148
<div className={styles.section}>

packages/midi-input/src/MidiInput.ts

Lines changed: 128 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -10,16 +10,18 @@ import {
1010
ParamEnum,
1111
EngineStateWithActions,
1212
} from '@hedron-gl/engine'
13-
import { MIDIEvent, MidiManager, MidiMessageType } from '@hedron-gl/midi-manager'
14-
15-
const noteLetters = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']
16-
const midiNotes: string[] = new Array(128)
1713

18-
for (let i = 0; i < 128; i++) {
19-
const letter = noteLetters[i % noteLetters.length]
20-
const octave = Math.floor(i / noteLetters.length) - 1
21-
midiNotes[i] = `${i} - ${letter} (${octave})`
22-
}
14+
import { MIDIEvent, MidiManager, MidiMessageType } from '@hedron-gl/midi-manager'
15+
import { getNodeOptionNodes } from '@hedron-gl/ui-core'
16+
import {
17+
MIDI_INPUT_TYPE_CONTROL_CHANGE,
18+
MIDI_INPUT_TYPE_NOTE,
19+
NOTE_MODE_ON,
20+
NOTE_MODE_OFF,
21+
NOTE_MODE_ON_OFF,
22+
MIDI_NOTES,
23+
} from './constants'
24+
import { doesMidiEventMatchInput, getMidiInputTypeFromEvent } from './utils'
2325

2426
type MIDIEventWithValue = Omit<MIDIEvent, 'value'> & { value: number }
2527

@@ -55,6 +57,13 @@ export class MidiInput implements IPlugin {
5557
sliderMin: 0,
5658
sliderMax: 0.99,
5759
},
60+
{
61+
nodeType: 'param',
62+
key: 'autoMidiLearn',
63+
title: 'Auto MIDI Learn',
64+
valueType: 'boolean',
65+
defaultValue: false,
66+
},
5867
] as const satisfies IPlugin['globalOptionNodesConfig']
5968
public readonly optionNodesConfig = [
6069
{
@@ -68,18 +77,29 @@ export class MidiInput implements IPlugin {
6877
nodeType: 'param',
6978
key: 'note',
7079
valueType: 'enum',
71-
options: midiNotes.map((label, i) => ({ value: i, label })),
80+
options: MIDI_NOTES.map((label, i) => ({ value: i, label })),
7281
defaultValue: 1,
7382
},
7483
{
7584
nodeType: 'param',
7685
key: 'type',
7786
valueType: 'enum',
78-
defaultValue: MidiMessageType.ControlChange,
87+
defaultValue: MIDI_INPUT_TYPE_CONTROL_CHANGE,
7988
options: [
80-
{ value: MidiMessageType.NoteOn, label: 'Note On' },
81-
{ value: MidiMessageType.NoteOff, label: 'Note Off' },
82-
{ value: MidiMessageType.ControlChange, label: 'Control Change' },
89+
{ value: MIDI_INPUT_TYPE_NOTE, label: 'Note' },
90+
{ value: MIDI_INPUT_TYPE_CONTROL_CHANGE, label: 'Control Change' },
91+
],
92+
},
93+
{
94+
nodeType: 'param',
95+
key: 'noteMode',
96+
title: 'Note Mode',
97+
valueType: 'enum',
98+
defaultValue: NOTE_MODE_ON,
99+
options: [
100+
{ value: NOTE_MODE_ON, label: 'On' },
101+
{ value: NOTE_MODE_OFF, label: 'Off' },
102+
{ value: NOTE_MODE_ON_OFF, label: 'On/Off' },
83103
],
84104
},
85105
{
@@ -137,6 +157,10 @@ export class MidiInput implements IPlugin {
137157
switch (midiEvent.type) {
138158
case MidiMessageType.NoteOn:
139159
case MidiMessageType.NoteOff:
160+
if (optionNodes.noteMode === NOTE_MODE_ON_OFF) {
161+
// NoteOn → true (pressed), NoteOff → false (released)
162+
return midiEvent.type === MidiMessageType.NoteOn
163+
}
140164
return !targetParamValue
141165
default: {
142166
const value = this.getValue(optionNodes, midiEvent)
@@ -155,8 +179,18 @@ export class MidiInput implements IPlugin {
155179
const sliderMin = (storeState.paramValues[`${input.targetNodeId}-sliderMin`] as number) ?? 0
156180
const sliderMax = (storeState.paramValues[`${input.targetNodeId}-sliderMax`] as number) ?? 1
157181

158-
const targetValue =
159-
(this.getValue(optionNodes, midiEvent) / 127) * (sliderMax - sliderMin) + sliderMin
182+
let targetValue: number
183+
if (
184+
optionNodes.type === MIDI_INPUT_TYPE_NOTE &&
185+
optionNodes.noteMode === NOTE_MODE_ON_OFF &&
186+
midiEvent.type === MidiMessageType.NoteOff
187+
) {
188+
// Release: snap back to the minimum of the slider range
189+
targetValue = sliderMin
190+
} else {
191+
targetValue =
192+
(this.getValue(optionNodes, midiEvent) / 127) * (sliderMax - sliderMin) + sliderMin
193+
}
160194

161195
// Use MidiManager's smoothing system
162196
this.midiManager.setSmoothedValue(
@@ -180,6 +214,41 @@ export class MidiInput implements IPlugin {
180214
return null
181215
}
182216

217+
// Shared by onNewInput and the panel's manual "Midi Learn" button, so both stay in sync.
218+
applyLearnedEvent(engine: HedronEngine, inputId: string, event: MIDIEvent) {
219+
const state = engine.getStore().getState()
220+
// Always re-fetch by id: a passed-in InputNode reference can be stale.
221+
const input = state.nodes[inputId] as InputNode | undefined
222+
if (!input) return
223+
224+
const {
225+
channel: channelNode,
226+
note: noteNode,
227+
type: typeNode,
228+
} = getNodeOptionNodes(state, input.id)
229+
230+
const learnedType = getMidiInputTypeFromEvent(event)
231+
232+
if (channelNode) state.updateParamValue(channelNode.id, event.channel)
233+
if (noteNode) state.updateParamValue(noteNode.id, event.note)
234+
if (typeNode && learnedType !== null) state.updateParamValue(typeNode.id, learnedType)
235+
}
236+
237+
// Runs once at creation, not on every panel mount, unlike a value-inferred "is new" check.
238+
onNewInput = (engine: HedronEngine, newInput: InputNode) => {
239+
const store = engine.getStore()
240+
241+
const autoLearnEnabled = Boolean(
242+
store.getState().paramValues[`${this.id}-global-autoMidiLearn`],
243+
)
244+
if (!autoLearnEnabled) return
245+
246+
this.midiManager.midiLearn().then((event) => {
247+
if (!event) return
248+
this.applyLearnedEvent(engine, newInput.id, event)
249+
})
250+
}
251+
183252
constructor(engine: HedronEngine) {
184253
const store = engine.getStore()
185254

@@ -208,42 +277,52 @@ export class MidiInput implements IPlugin {
208277
storeState,
209278
'midi',
210279
({ input, optionNodes, targetNode, targetParamValue }) => {
211-
if (
212-
event.channel === optionNodes.channel &&
213-
event.note === optionNodes.note &&
214-
event.type === optionNodes.type &&
215-
event.value !== undefined
216-
) {
217-
if (targetNode.nodeType === 'shot') {
218-
this.handleShot({ input, engine, midiEvent: event as MIDIEventWithValue })
280+
const doesEventMatchInput = doesMidiEventMatchInput({
281+
event,
282+
channel: optionNodes.channel,
283+
note: optionNodes.note,
284+
type: optionNodes.type,
285+
noteMode: optionNodes.noteMode,
286+
})
287+
288+
if (!doesEventMatchInput || event.value === undefined) return
289+
const midiEvent = event as MIDIEventWithValue
290+
291+
if (targetNode.nodeType === 'shot') {
292+
if (
293+
optionNodes.type === MIDI_INPUT_TYPE_NOTE &&
294+
optionNodes.noteMode === NOTE_MODE_ON_OFF &&
295+
event.type === MidiMessageType.NoteOff
296+
) {
219297
return
220298
}
221299

222-
const value = {
223-
enum: this.handleEnum,
224-
boolean: this.handleBoolean,
225-
number: this.handleNumber,
226-
string: this.handleUnsupported,
227-
rgb: this.handleUnsupported,
228-
vector2: this.handleUnsupported,
229-
vector3: this.handleUnsupported,
230-
file: this.handleUnsupported,
231-
}[targetNode.valueType]({
232-
midiEvent: event as MIDIEventWithValue,
233-
input,
234-
storeState,
235-
optionNodes,
236-
// @ts-expect-error -- TS isn't smart enough to infer the correct node type
237-
targetNode,
238-
// @ts-expect-error -- TS isn't smart enough to infer the correct node type
239-
targetParamValue,
240-
})
241-
242-
// For numbers, handleNumber returns null and uses smoothing system
243-
// For other types, update directly
244-
if (value !== null) {
245-
storeState.updateParamValue(input.targetNodeId, value)
246-
}
300+
this.handleShot({ input, engine, midiEvent })
301+
return
302+
}
303+
304+
const value = {
305+
enum: this.handleEnum,
306+
boolean: this.handleBoolean,
307+
number: this.handleNumber,
308+
string: this.handleUnsupported,
309+
rgb: this.handleUnsupported,
310+
vector2: this.handleUnsupported,
311+
vector3: this.handleUnsupported,
312+
file: this.handleUnsupported,
313+
}[targetNode.valueType]({
314+
midiEvent,
315+
input,
316+
storeState,
317+
optionNodes,
318+
// @ts-expect-error -- TS isn't smart enough to infer the correct node type
319+
targetNode,
320+
// @ts-expect-error -- TS isn't smart enough to infer the correct node type
321+
targetParamValue,
322+
})
323+
324+
if (value !== null) {
325+
storeState.updateParamValue(input.targetNodeId, value)
247326
}
248327
},
249328
)

0 commit comments

Comments
 (0)