Files
suaveplan/packages/ui/react/react-midi/src/midi-contract/reducers.ts
2026-05-27 23:47:53 +01:00

174 lines
5.3 KiB
TypeScript

import type {
CC14Change,
Channel,
ControlChangeMessage,
NRPNChange,
RPNChange,
} from "./midi-message.types.js";
const CC_NRPN_HIGH = 99;
const CC_NRPN_LOW = 98;
const CC_RPN_HIGH = 101;
const CC_RPN_LOW = 100;
const CC_DATA_HIGH = 6;
const CC_DATA_LOW = 38;
interface PartialState {
paramHigh: number | null;
paramLow: number | null;
valueHigh: number | null;
valueLow: number | null;
}
function emptyState(): PartialState {
return {
paramHigh: null,
paramLow: null,
valueHigh: null,
valueLow: null,
};
}
/**
* NRPN four-CC reducer. Feed channel-voice CC messages into `feed`;
* when CC 99/98/6/38 have all arrived on the same channel, the
* reducer emits a single `NRPNChange` event and resets that channel's
* partial state. Incomplete dances (e.g. missing LSB) remain pending
* until completion or are dropped when a fresh paramHigh arrives.
*/
export class NRPNReducer {
private readonly partials = new Map<Channel, PartialState>();
feed(msg: ControlChangeMessage): NRPNChange | null {
const state = this.partials.get(msg.channel) ?? emptyState();
switch (msg.controller) {
case CC_NRPN_HIGH:
state.paramHigh = msg.value;
state.paramLow = null;
state.valueHigh = null;
state.valueLow = null;
break;
case CC_NRPN_LOW:
state.paramLow = msg.value;
break;
case CC_DATA_HIGH:
state.valueHigh = msg.value;
break;
case CC_DATA_LOW:
state.valueLow = msg.value;
break;
default:
this.partials.set(msg.channel, state);
return null;
}
this.partials.set(msg.channel, state);
if (
state.paramHigh !== null &&
state.paramLow !== null &&
state.valueHigh !== null &&
state.valueLow !== null
) {
const out: NRPNChange = {
type: "nrpnChange",
channel: msg.channel,
paramHigh: state.paramHigh,
paramLow: state.paramLow,
value: (state.valueHigh << 7) | state.valueLow,
};
this.partials.set(msg.channel, emptyState());
return out;
}
return null;
}
}
/**
* RPN four-CC reducer. Identical control flow as `NRPNReducer` but
* uses CC 101/100 for the parameter MSB/LSB selectors.
*/
export class RPNReducer {
private readonly partials = new Map<Channel, PartialState>();
feed(msg: ControlChangeMessage): RPNChange | null {
const state = this.partials.get(msg.channel) ?? emptyState();
switch (msg.controller) {
case CC_RPN_HIGH:
state.paramHigh = msg.value;
state.paramLow = null;
state.valueHigh = null;
state.valueLow = null;
break;
case CC_RPN_LOW:
state.paramLow = msg.value;
break;
case CC_DATA_HIGH:
state.valueHigh = msg.value;
break;
case CC_DATA_LOW:
state.valueLow = msg.value;
break;
default:
this.partials.set(msg.channel, state);
return null;
}
this.partials.set(msg.channel, state);
if (
state.paramHigh !== null &&
state.paramLow !== null &&
state.valueHigh !== null &&
state.valueLow !== null
) {
const out: RPNChange = {
type: "rpnChange",
channel: msg.channel,
paramHigh: state.paramHigh,
paramLow: state.paramLow,
value: (state.valueHigh << 7) | state.valueLow,
};
this.partials.set(msg.channel, emptyState());
return out;
}
return null;
}
}
/**
* 14-bit CC reducer. Pairs CCs 0-31 with CCs 32-63 (MSB+LSB pair on
* the same channel). On reception of either half, the reducer
* remembers the partial value and, once both halves are observed,
* emits a single `CC14Change` event with the combined 14-bit value.
*/
export class CC14Reducer {
private readonly msbs = new Map<string, number>();
private readonly lsbs = new Map<string, number>();
feed(msg: ControlChangeMessage): CC14Change | null {
const isMsb = msg.controller < 32;
const isLsb = msg.controller >= 32 && msg.controller < 64;
if (!isMsb && !isLsb) {
return null;
}
const baseController = isMsb ? msg.controller : msg.controller - 32;
const key = `${msg.channel}:${baseController}`;
if (isMsb) {
this.msbs.set(key, msg.value);
} else {
this.lsbs.set(key, msg.value);
}
const msb = this.msbs.get(key);
const lsb = this.lsbs.get(key);
if (msb !== undefined && lsb !== undefined) {
const out: CC14Change = {
type: "cc14Change",
channel: msg.channel,
controller: baseController,
value: (msb << 7) | lsb,
};
this.msbs.delete(key);
this.lsbs.delete(key);
return out;
}
return null;
}
}