Web MIDI API 提供的 MIDIMessageEvent 通常只把原始字节放在 data 属性里,类型是 Uint8Array | undefined。对合成器、MIDI 控制器页面或硬件音序器来说,控制器变更消息(Control Change,简称 CC)是最常处理的消息之一。一个 CC 消息由三个字节构成:状态字节、控制器编号、控制器值。若回调里直接操作 data[1] 和 data[2],代码里会出现大量 1、2 这样的魔法下标,也无法在编译期发现通道判断错误。本文从类型建模的角度,讨论如何为乐器接口中的控制器变更消息设计可复用、可收窄的 TypeScript 类型。

接着看字节级结构。MIDI 状态字节的高四位决定消息类型,控制器变更的状态字节高位是 0xB,低四位是通道号,所以合法状态范围是 0xB0 到 0xBF。后两个数据字节分别表示控制器编号和控制器值,取值范围都是 0 到 127。实际事件中 data 可能包含更长的系统专用消息或混合数据,因此不能只写一个笼统的 number[],而应让类型系统帮助区分“这确实是一条 CC 消息”。
一、先看清三个字节:状态、控制器编号、控制器值
控制器变更消息的状态字节占据 data[0],它的高四位固定为 1011,十六进制写作 0xB;低四位表示通道号,因此从 0xB0 到 0xBF 正好对应 16 个 MIDI 通道。很多开发者习惯把通道处理成 1 到 16,但底层字节里它是 0 到 15。解析时如果用 status & 0x0F 得到的就是 0 基通道,如果要展示给用户,再加 1 即可。这个差别需要在类型和注释里明确,否则后续做多通道乐器接口时很容易把通道 10 映射到错误的消息。
第二个字节 data[1] 是控制器编号,范围 0 到 127。常见控制号如 1 是调制轮、64 是延音踏板、74 是亮度/截止频率。第三个字节 data[2] 是控制器值,通常也是 0 到 127,但部分开关型控制器只区分 0 与 127。虽然 MIDI 协议本身不要求所有设备都完全遵守这些上限,但 Web MIDI API 收到的信息仍会落在标准范围内。为类型加上边界限制,可以避免把异常数据、系统消息或未定义值当成 CC 来消费。
如果只依赖 Uint8Array,类型层面看到的始终只是“一个字节数组”,没有控制器编号、值、通道这些概念。后续代码可能写成 const cc = data[2] 或 data[0] === 0xB0,这种写法在小项目里不算问题,但一旦涉及多设备、多通道或 MIDI learn 功能,就会散落大量重复判断和魔法数字。更合适的做法是先定义出一组只描述 CC 消息形状的类型,再通过运行时守卫把真实数据收窄到该类型。
二、用元组和字面量类型固定消息骨架
TypeScript 里最容易想到的形状是元组。CC 消息固定为三个字节,因此可以定义一个只读元组,分别标注状态、控制器编号和值。先把合法的 CC 状态字节列成字面量联合,再把元组元素绑定到这些字面量上,就能让错误的状态字节在编译期被拦截。下面这个类型把 0xB0 到 0xBF 全部枚举出来:
type ControlChangeStatus = | 0xB0 | 0xB1 | 0xB2 | 0xB3 | 0xB4 | 0xB5 | 0xB6 | 0xB7 | 0xB8 | 0xB9 | 0xBA | 0xBB | 0xBC | 0xBD | 0xBE | 0xBF; type ControlChangeBytes = readonly [ status: ControlChangeStatus, controller: number, value: number ];
这里的 ControlChangeBytes 是一个带标签的元组类型,调用方看到 data[1] 时仍然不直观,但元组本身已经限定了长度必须是 3。用 readonly 是因为 Web MIDI API 的 data 虽然可变,但从纯消息解析角度看,我们不希望解析函数意外修改原始字节。元组标签 status、controller、value 只在编辑器提示中起作用,运行时并不会生成这些属性。
手动枚举 16 个状态字节很直观,但也可以改成“常量数组推导”的方式减少重复:先写 const CC_STATUSES = [0xB0, 0xB1, ... 0xBF] as const; 再用 typeof CC_STATUSES[number] 推导出联合类型。二者在最终类型上等价。需要注意的是,元组类型只是声明“我想让数据长成这样”,并不会自动把 Uint8Array 收窄成元组。浏览器返回的 event.data 终究是 Uint8Array | undefined,所以下一步必须加入运行时检查,否则直接类型断言只会把风险藏起来。
三、类型守卫与可辨识联合:从原始字节到结构化消息
从 Uint8Array 到 ControlChangeBytes 不是免费转换。浏览器不会因为 TypeScript 声明了元组就改变运行时传入的对象,所以需要一个类型守卫函数。这个函数里除了检查长度为 3,还必须检查状态字节落在 CC 范围、控制器编号和值都位于 0 到 127。下面把类型谓词写成 Uint8Array & ControlChangeBytes,因为事件对象中的 data 本质是 Uint8Array,守卫通过后却可以按照 CC 元组来访问:
function isControlChangeBytes(
data: Uint8Array | undefined
): data is Uint8Array & ControlChangeBytes {
if (!data || data.length !== 3) {
return false;
}
const status = data[0];
const controller = data[1];
const value = data[2];
return (
status >= 0xB0 &&
status <= 0xBF &&
controller >= 0 &&
controller <= 127 &&
value >= 0 &&
value <= 127
);
}
这个守卫只解决“这组字节像不像 CC”。调用方仍然需要记忆 data[0] 是状态、data[1] 是控制器编号、data[2] 是值。为了让业务代码彻底摆脱下标,可以再写一个解析函数,返回带有语义字段的结构化对象。这样事件监听代码里只会出现 message.controller 和 message.value,不会有魔法数字。
interface ControlChangeMessage {
type: 'controlchange';
channel: number;
controller: number;
value: number;
}
function parseControlChange(
data: Uint8Array
): ControlChangeMessage | null {
if (!isControlChangeBytes(data)) {
return null;
}
return {
type: 'controlchange',
channel: data[0] & 0x0F,
controller: data[1],
value: data[2]
};
}
给解析结果增加 type: 'controlchange' 字段,相当于建立可辨识联合的判别键。后面如果还要处理 Note On、Note Off、Pitch Bend 等消息,可以扩展联合类型,每个成员都有各自的 type 字面量。TypeScript 在 switch (message.type) 中会自动收窄到对应成员,这对大型 MIDI 应用尤其重要。相比到处写 status & 0xF0 判断,可辨识联合把意图表达得更清楚。
四、接入 MIDIInput 事件并设计可维护的乐器接口
Web MIDI API 的输入对象 MIDIInput 通过 midimessage 事件通知新数据。事件回调收到的 MIDIMessageEvent 并不会根据消息类型自动变成 CC 专用事件,所以要在回调内部先调用 parseControlChange。借助前面的守卫,如果返回 null 就说明不是 CC 消息,直接忽略即可。这样可以安全地把原始事件转换成业务层类型。
function bindControlChange(
input: MIDIInput,
handler: (message: ControlChangeMessage) => void
): void {
input.addEventListener('midimessage', (event: MIDIMessageEvent) => {
const message = parseControlChange(event.data);
if (message) {
handler(message);
}
});
}
如果当前 TypeScript 版本的 lib.dom 尚未声明 MIDIInput 或 MIDIMessageEvent,可以补充全局声明或引入 @types/webmidi。实际项目中,建议把这类类型守卫和解析函数专门放在一个 midi-types.ts 模块里,避免每个组件都复制一份状态字节判断。
乐器接口通常需要把某个控制器编号映射到一个处理器,例如音量、声像、滤波截止频率或自定义参数。用 Map 可以建立从控制器编号到处理函数的绑定。下面给出一个基础实现:learn 方法负责学习当前控制器,handleMessage 在收到 CC 消息时查找并调用处理器。
type ControlChangeHandler = (message: ControlChangeMessage) => void;
interface InstrumentSurface {
controls: Map<number, ControlChangeHandler>;
learn(controller: number, handler: ControlChangeHandler): void;
handleMessage(message: ControlChangeMessage): void;
}
class Surface implements InstrumentSurface {
readonly controls = new Map<number, ControlChangeHandler>();
learn(controller: number, handler: ControlChangeHandler): void {
this.controls.set(controller, handler);
}
handleMessage(message: ControlChangeMessage): void {
const handler = this.controls.get(message.controller);
if (handler) {
handler(message);
}
}
}
这样的类型设计至少带来三个好处。第一,控制器编号和值始终以 number 出现在明确字段中,调用方不需要写 message.data[1];第二,CC 判断逻辑集中在一个守卫中,后续新增其他消息类型不会破坏现有解析;第三,乐器接口的单元测试可以直接构造 ControlChangeMessage 对象,而不必依赖真实 MIDI 设备。对于需要持久化映射的 MIDI learn 功能,还可以把 Map 替换为带序列化的结构,并由 handleMessage 统一派发,类型层变化很小。

