reaper-osc
Version:
Controls Cockos Reaper using OSC
405 lines • 17.3 kB
JavaScript
;
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
Object.defineProperty(o, k2, { enumerable: true, get: function() { return m[k]; } });
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
__setModuleDefault(result, mod);
return result;
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.ReaperOscClient = exports.parseMessage = void 0;
/**
* Stateless OSC client for communicating with Reaper
* @module
*/
const events_1 = require("events");
const osc = __importStar(require("osc"));
const Messages_1 = require("../Messages");
const Events_1 = require("./Events");
const Commands_1 = require("./Commands");
const Config_1 = require("../Config");
// --- Value extractors ---
function boolFrom(msg) {
var _a;
return ((_a = msg.args[0]) === null || _a === void 0 ? void 0 : _a.value) === 1;
}
function floatFrom(msg) {
var _a;
return (_a = msg.args[0]) === null || _a === void 0 ? void 0 : _a.value;
}
function intFrom(msg) {
var _a;
return (_a = msg.args[0]) === null || _a === void 0 ? void 0 : _a.value;
}
function stringFrom(msg) {
var _a;
return (_a = msg.args[0]) === null || _a === void 0 ? void 0 : _a.value;
}
// --- Exact address parsers (global + transport) ---
const EXACT_PARSERS = new Map([
// Global
['/click', msg => Events_1.MetronomeEvent(boolFrom(msg))],
['/autorecarm', msg => Events_1.AutoRecordArmEvent(boolFrom(msg))],
['/anysolo', msg => Events_1.AnySoloEvent(boolFrom(msg))],
// Transport
['/play', msg => Events_1.PlayEvent(boolFrom(msg))],
['/stop', msg => Events_1.StopEvent(boolFrom(msg))],
['/pause', msg => Events_1.PauseEvent(boolFrom(msg))],
['/record', msg => Events_1.RecordEvent(boolFrom(msg))],
['/rewind', msg => Events_1.RewindEvent(boolFrom(msg))],
['/forward', msg => Events_1.FastForwardEvent(boolFrom(msg))],
['/repeat', msg => Events_1.RepeatEvent(boolFrom(msg))],
['/time', msg => Events_1.TimeChanged(floatFrom(msg))],
['/beat/str', msg => Events_1.BeatChanged(stringFrom(msg))],
['/frames/str', msg => Events_1.FramesChanged(stringFrom(msg))],
['/loop/start/time', msg => Events_1.LoopStartChanged(floatFrom(msg))],
['/loop/end/time', msg => Events_1.LoopEndChanged(floatFrom(msg))],
['/bymarker', msg => Events_1.RewindByMarkerEvent(boolFrom(msg))],
['/editloop', msg => Events_1.SetLoopEvent(boolFrom(msg))],
// Last marker
['/lastmarker/name', msg => Events_1.LastMarkerNameChanged(stringFrom(msg))],
['/lastmarker/number/str', msg => Events_1.LastMarkerNumberChanged(stringFrom(msg))],
['/lastmarker/time', msg => Events_1.LastMarkerTimeChanged(floatFrom(msg))],
// Last region
['/lastregion/name', msg => Events_1.LastRegionNameChanged(stringFrom(msg))],
['/lastregion/number/str', msg => Events_1.LastRegionNumberChanged(stringFrom(msg))],
['/lastregion/time', msg => Events_1.LastRegionTimeChanged(floatFrom(msg))],
['/lastregion/length', msg => Events_1.LastRegionLengthChanged(floatFrom(msg))],
]);
const TRACK_PARSERS = new Map([
['mute', (n, m) => Events_1.TrackMuteEvent(n, boolFrom(m))],
['solo', (n, m) => Events_1.TrackSoloEvent(n, boolFrom(m))],
['recarm', (n, m) => Events_1.TrackRecordArmEvent(n, boolFrom(m))],
['select', (n, m) => Events_1.TrackSelectEvent(n, boolFrom(m))],
['name', (n, m) => Events_1.TrackNameChanged(n, stringFrom(m))],
['pan', (n, m) => Events_1.TrackPanChanged(n, floatFrom(m))],
['pan2', (n, m) => Events_1.TrackPan2Changed(n, floatFrom(m))],
['panmode', (n, m) => Events_1.TrackPanModeChanged(n, stringFrom(m))],
['volume', (n, m) => Events_1.TrackVolumeChanged(n, floatFrom(m))],
['volume/db', (n, m) => Events_1.TrackVolumeDbChanged(n, floatFrom(m))],
['vu', (n, m) => Events_1.TrackVuChanged(n, floatFrom(m))],
['vu/L', (n, m) => Events_1.TrackVuLeftChanged(n, floatFrom(m))],
['vu/R', (n, m) => Events_1.TrackVuRightChanged(n, floatFrom(m))],
['monitor', (n, m) => Events_1.TrackMonitoringModeChanged(n, intFrom(m))],
]);
const TRACK_FX_PARSERS = new Map([
// bypass is inverted: Reaper sends 0 when bypassed
['name', (t, f, m) => Events_1.TrackFxNameChanged(t, f, stringFrom(m))],
['bypass', (t, f, m) => Events_1.TrackFxBypassEvent(t, f, !boolFrom(m))],
['openui', (t, f, m) => Events_1.TrackFxOpenUiEvent(t, f, boolFrom(m))],
['preset', (t, f, m) => Events_1.TrackFxPresetChanged(t, f, stringFrom(m))],
]);
const TRACK_SEND_PARSERS = new Map([
['name', (t, s, m) => Events_1.TrackSendNameChanged(t, s, stringFrom(m))],
['volume', (t, s, m) => Events_1.TrackSendVolumeChanged(t, s, floatFrom(m))],
['volume/str', (t, s, m) => Events_1.TrackSendVolumeStrChanged(t, s, stringFrom(m))],
['pan', (t, s, m) => Events_1.TrackSendPanChanged(t, s, floatFrom(m))],
['pan/str', (t, s, m) => Events_1.TrackSendPanStrChanged(t, s, stringFrom(m))],
]);
// --- Track receive suffix parsers (for /track/N/recv/M/suffix) ---
const TRACK_RECV_PARSERS = new Map([
['name', (t, r, m) => Events_1.TrackReceiveNameChanged(t, r, stringFrom(m))],
['volume', (t, r, m) => Events_1.TrackReceiveVolumeChanged(t, r, floatFrom(m))],
['volume/str', (t, r, m) => Events_1.TrackReceiveVolumeStrChanged(t, r, stringFrom(m))],
['pan', (t, r, m) => Events_1.TrackReceivePanChanged(t, r, floatFrom(m))],
['pan/str', (t, r, m) => Events_1.TrackReceivePanStrChanged(t, r, stringFrom(m))],
]);
const SELECTED_TRACK_SEND_PARSERS = new Map([
['name', (s, m) => Events_1.SelectedTrackSendNameChanged(s, stringFrom(m))],
['volume', (s, m) => Events_1.SelectedTrackSendVolumeChanged(s, floatFrom(m))],
['volume/str', (s, m) => Events_1.SelectedTrackSendVolumeStrChanged(s, stringFrom(m))],
['pan', (s, m) => Events_1.SelectedTrackSendPanChanged(s, floatFrom(m))],
['pan/str', (s, m) => Events_1.SelectedTrackSendPanStrChanged(s, stringFrom(m))],
]);
// --- Selected track receive suffix parsers (for /track/recv/M/suffix) ---
const SELECTED_TRACK_RECV_PARSERS = new Map([
['name', (r, m) => Events_1.SelectedTrackReceiveNameChanged(r, stringFrom(m))],
['volume', (r, m) => Events_1.SelectedTrackReceiveVolumeChanged(r, floatFrom(m))],
['volume/str', (r, m) => Events_1.SelectedTrackReceiveVolumeStrChanged(r, stringFrom(m))],
['pan', (r, m) => Events_1.SelectedTrackReceivePanChanged(r, floatFrom(m))],
['pan/str', (r, m) => Events_1.SelectedTrackReceivePanStrChanged(r, stringFrom(m))],
]);
const SELECTED_TRACK_PARSERS = new Map([
['mute', m => Events_1.SelectedTrackMuteEvent(boolFrom(m))],
['solo', m => Events_1.SelectedTrackSoloEvent(boolFrom(m))],
['recarm', m => Events_1.SelectedTrackRecordArmEvent(boolFrom(m))],
['select', m => Events_1.SelectedTrackSelectEvent(boolFrom(m))],
['name', m => Events_1.SelectedTrackNameChanged(stringFrom(m))],
['pan', m => Events_1.SelectedTrackPanChanged(floatFrom(m))],
['pan2', m => Events_1.SelectedTrackPan2Changed(floatFrom(m))],
['panmode', m => Events_1.SelectedTrackPanModeChanged(stringFrom(m))],
['volume', m => Events_1.SelectedTrackVolumeChanged(floatFrom(m))],
['volume/db', m => Events_1.SelectedTrackVolumeDbChanged(floatFrom(m))],
['vu', m => Events_1.SelectedTrackVuChanged(floatFrom(m))],
['vu/L', m => Events_1.SelectedTrackVuLeftChanged(floatFrom(m))],
['vu/R', m => Events_1.SelectedTrackVuRightChanged(floatFrom(m))],
['monitor', m => Events_1.SelectedTrackMonitoringModeChanged(intFrom(m))],
]);
const SELECTED_TRACK_FX_PARSERS = new Map([
['name', (f, m) => Events_1.SelectedTrackFxNameChanged(f, stringFrom(m))],
['bypass', (f, m) => Events_1.SelectedTrackFxBypassEvent(f, !boolFrom(m))],
['openui', (f, m) => Events_1.SelectedTrackFxOpenUiEvent(f, boolFrom(m))],
['preset', (f, m) => Events_1.SelectedTrackFxPresetChanged(f, stringFrom(m))],
]);
const SELECTED_FX_PARSERS = new Map([
['name', m => Events_1.SelectedFxNameChanged(stringFrom(m))],
['bypass', m => Events_1.SelectedFxBypassEvent(!boolFrom(m))],
['openui', m => Events_1.SelectedFxOpenUiEvent(boolFrom(m))],
['preset', m => Events_1.SelectedFxPresetChanged(stringFrom(m))],
]);
const MARKER_PARSERS = new Map([
['name', (n, m) => Events_1.MarkerNameChanged(n, stringFrom(m))],
['number/str', (n, m) => Events_1.MarkerNumberChanged(n, stringFrom(m))],
['time', (n, m) => Events_1.MarkerTimeChanged(n, floatFrom(m))],
]);
const REGION_PARSERS = new Map([
['name', (n, m) => Events_1.RegionNameChanged(n, stringFrom(m))],
['number/str', (n, m) => Events_1.RegionNumberChanged(n, stringFrom(m))],
['time', (n, m) => Events_1.RegionTimeChanged(n, floatFrom(m))],
['length', (n, m) => Events_1.RegionLengthChanged(n, floatFrom(m))],
]);
// --- Message parsing ---
function parseMarkerMessage(msg) {
const parts = msg.addressParts;
const slotIndex = parseInt(parts[1]);
if (isNaN(slotIndex))
return null;
const suffix = parts.slice(2).join('/');
const parser = MARKER_PARSERS.get(suffix);
return parser ? parser(slotIndex, msg) : null;
}
function parseRegionMessage(msg) {
const parts = msg.addressParts;
const slotIndex = parseInt(parts[1]);
if (isNaN(slotIndex))
return null;
const suffix = parts.slice(2).join('/');
const parser = REGION_PARSERS.get(suffix);
return parser ? parser(slotIndex, msg) : null;
}
function parseTrackMessage(msg) {
const parts = msg.addressParts;
const trackNumber = parseInt(parts[1]);
if (isNaN(trackNumber)) {
// Selected track sends/receives: /track/send/N/suffix, /track/recv/N/suffix
if (parts[1] === 'send' || parts[1] === 'recv') {
const sendNumber = parseInt(parts[2]);
if (isNaN(sendNumber))
return null;
const suffix = parts.slice(3).join('/');
const parsers = parts[1] === 'send' ? SELECTED_TRACK_SEND_PARSERS : SELECTED_TRACK_RECV_PARSERS;
const parser = parsers.get(suffix);
return parser ? parser(sendNumber, msg) : null;
}
// Selected track property: /track/mute, /track/name, etc.
const suffix = parts.slice(1).join('/');
const parser = SELECTED_TRACK_PARSERS.get(suffix);
return parser ? parser(msg) : null;
}
// Track FX: /track/N/fx/M/property
if (parts[2] === 'fx') {
const fxNumber = parseInt(parts[3]);
if (isNaN(fxNumber))
return null;
const suffix = parts.slice(4).join('/');
const parser = TRACK_FX_PARSERS.get(suffix);
return parser ? parser(trackNumber, fxNumber, msg) : null;
}
// Track sends/receives: /track/N/send/M/suffix, /track/N/recv/M/suffix
if (parts[2] === 'send' || parts[2] === 'recv') {
const sendNumber = parseInt(parts[3]);
if (isNaN(sendNumber))
return null;
const suffix = parts.slice(4).join('/');
const parsers = parts[2] === 'send' ? TRACK_SEND_PARSERS : TRACK_RECV_PARSERS;
const parser = parsers.get(suffix);
return parser ? parser(trackNumber, sendNumber, msg) : null;
}
// Track property: /track/N/property
const suffix = parts.slice(2).join('/');
const parser = TRACK_PARSERS.get(suffix);
return parser ? parser(trackNumber, msg) : null;
}
function parseFxMessage(msg) {
const parts = msg.addressParts;
const fxNumber = parseInt(parts[1]);
if (isNaN(fxNumber)) {
// Selected FX: /fx/name, /fx/bypass, etc.
const suffix = parts.slice(1).join('/');
const parser = SELECTED_FX_PARSERS.get(suffix);
return parser ? parser(msg) : null;
}
// Indexed FX on selected track: /fx/M/name, /fx/M/bypass, etc.
const suffix = parts.slice(2).join('/');
const parser = SELECTED_TRACK_FX_PARSERS.get(suffix);
return parser ? parser(fxNumber, msg) : null;
}
function parseMessage(msg) {
// O(1) exact match for global + transport
const exact = EXACT_PARSERS.get(msg.address);
if (exact)
return exact(msg);
// Prefix-based routing
const firstPart = msg.addressParts[0];
if (firstPart === 'track') {
const event = parseTrackMessage(msg);
if (event)
return event;
}
if (firstPart === 'fx') {
const event = parseFxMessage(msg);
if (event)
return event;
}
if (firstPart === 'marker') {
const event = parseMarkerMessage(msg);
if (event)
return event;
}
if (firstPart === 'region') {
const event = parseRegionMessage(msg);
if (event)
return event;
}
return { type: 'unknown', message: msg };
}
exports.parseMessage = parseMessage;
/**
* A stateless OSC client for communicating with Reaper.
*
* Translates incoming OSC messages into strongly typed events, and accepts
* strongly typed commands for sending to Reaper. Does not maintain any state
* about track properties, transport, or FX — use {@link Reaper} for that.
*
* @example
* ```typescript
* const client = new ReaperOscClient();
* await client.start();
*
* // Receive typed events
* client.on('message', event => {
* switch (event.type) {
* case 'track:mute':
* console.log(`Track ${event.trackNumber} muted: ${event.muted}`);
* break;
* case 'transport:play':
* console.log(`Playing: ${event.playing}`);
* break;
* }
* });
*
* // Send typed commands
* client.send({ type: 'transport:play' });
* client.send({ type: 'track:mute', trackNumber: 3, muted: true });
* ```
*/
class ReaperOscClient extends events_1.EventEmitter {
constructor(config = new Config_1.ReaperConfiguration()) {
super();
this._isReady = false;
this._log = config.log;
this._osc = new osc.UDPPort({
localAddress: config.localAddress,
localPort: config.localPort,
remoteAddress: config.remoteAddress,
remotePort: config.remotePort,
broadcast: true,
metadata: true,
});
this.initOsc();
}
/** Indicates whether OSC is ready to send and receive messages */
get isReady() {
return this._isReady;
}
/**
* Send a typed command to Reaper.
* @param command The command to send
*/
send(command) {
this.sendRaw(Commands_1.commandToOscMessage(command));
}
/**
* Send a raw OSC message to Reaper.
* @param message The OSC message to send
*/
sendRaw(message) {
if (!this._isReady) {
throw new Error("Can't send while OSC is not ready");
}
this._osc.send(message);
this._log('debug', 'OSC Message sent', message);
}
/** Open the OSC port and start listening for messages */
async start() {
if (this._isReady) {
return;
}
let errorCallback;
let readyCallback;
const promise = new Promise((resolve, reject) => {
errorCallback = (err) => {
this._log('debug', 'Error opening OSC connection', err);
reject(err);
};
readyCallback = () => {
this._log('debug', 'OSC listener ready');
this._isReady = true;
resolve();
};
this._osc.once('ready', readyCallback);
this._osc.once('error', errorCallback);
});
const removeListeners = () => {
this._osc.removeListener('ready', readyCallback);
this._osc.removeListener('error', errorCallback);
};
this._osc.open();
try {
await promise;
}
finally {
removeListeners();
}
}
/** Stop listening for OSC messages */
async stop() {
if (!this._isReady) {
return;
}
const promise = new Promise(resolve => {
this._osc.once('close', () => {
this._log('debug', 'OSC listener stopped');
resolve();
});
});
this._isReady = false;
this._osc.close();
return promise;
}
initOsc() {
this._osc.on('error', (err) => {
this._log('error', 'OSC error received', err);
});
this._osc.on('message', (rawMessage) => {
const message = new Messages_1.OscMessage(rawMessage.address, rawMessage.args);
const event = parseMessage(message);
this.emit('message', event);
this.emit('rawMessage', message);
});
}
}
exports.ReaperOscClient = ReaperOscClient;
//# sourceMappingURL=Client.js.map