diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index e5c7049..4755035 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -38,7 +38,9 @@ jobs: run: | RUNNER=/usr/lib/qt6/bin/qmltestrunner [ -x "$RUNNER" ] || RUNNER=$(command -v qmltestrunner) - QT_QPA_PLATFORM=offscreen "$RUNNER" -import qml -input tests/ + # As the app's `environment` sets them: handler scripts are files. + QML_XHR_ALLOW_FILE_READ=1 QML_XHR_ALLOW_FILE_WRITE=1 \ + QT_QPA_PLATFORM=offscreen "$RUNNER" -import qml -input tests/ # Informational: qmllint cannot see the ossia/score runtime. - name: qmllint diff --git a/docs/HANDLERS.md b/docs/HANDLERS.md new file mode 100644 index 0000000..f3845e1 --- /dev/null +++ b/docs/HANDLERS.md @@ -0,0 +1,239 @@ +# Protocol handlers + +Every protocol the mapper speaks is a *handler*: a small JavaScript object that +turns incoming OSC into **events**, and events into outgoing OSC. SpatGRIS, +ADM-OSC and SPAT Revolution are bundled handlers +(`qml/spm/handlers/`); your own load from the **SCRIPTS** tab and are written +the same way. + +A handler can have an input side, an output side, or both. Routing (which +input feeds which output, source ranges, offsets) and per-input scaling stay +in the engine; a handler only translates. + +``` + OSC in ──► input handler ──► event ──► scale ──► route filter + offset ──► output handler ──► OSC out +``` + +The quickest start is **SCRIPTS → + New script**: it opens a complete, +commented handler in the built-in editor +([`qml/spm/handlers/Template.js`](../qml/spm/handlers/Template.js)), ready to +apply and adapt. + +## The file + +A handler script assigns `module.exports`: + +```js +module.exports = { + apiVersion: 1, // required + id: "my-protocol", // required, unique; saved with each device + name: "My protocol", // shown in the pickers (defaults to id) + defaultPort: 9000, // for new outputs + matches: "/my/", // address prefix; makes the input side available to "Auto" + params: [ /* per-device settings, see below */ ], + + createInput: function (ctx) { /* returns an address table */ }, + createOutput: function (ctx) { /* returns an encoder */ }, + + // Optional: claim an address space for verbatim passthrough (see below). + native: { prefix: "/my/", source: function (address) { return -1; } } +}; +``` + +Scripts run in strict mode. `spm` is in scope; `Qt`, `XMLHttpRequest` and +`Protocols` are not: handlers never touch the app, its sockets or QML. That +keeps a broken script from reaching into the rest of the app, but **it is not +a sandbox**: only load scripts you trust. + +## Events + +Each input owns one event object. It is reset and refilled for every message. +**Do not keep a reference to it** beyond the call that received it. + +| Field | Meaning | +|---|---| +| `type` | `spm.POSITION`, `spm.CLEAR`, `spm.ALGORITHM` or `spm.NATIVE` | +| `source` | 1-based source index, `-1` when the message has none. Output side: the route offset is already added | +| `offset` | Output side: the route offset that was added | +| `coords` | `spm.SPHERICAL` or `spm.CARTESIAN`: the family the sender wrote | +| `azimuth`, `elevation` | Degrees, SpatGRIS convention: −90 = left, +90 = up | +| `distance` | 1 = the speaker sphere | +| `x`, `y`, `z` | SpatGRIS units: x right, y front, z up, ±`spm.MBAP_EXTENDED_RADIUS` (1.6667) | +| `hspan`, `vspan` | Extents, 0..1 | +| `radians` | Hint: the sender used radians. Fields stay in degrees | +| `value` | `ALGORITHM` payload: `"dome"` or `"cube"` | +| `nativeProtocol`, `nativeAddress`, `nativeArgs` | The message as received, for encoders of the same protocol to re-emit verbatim. Cleared when the engine changes the event (scaling) | + +Only the fields of the event's `coords` family are meaningful. Use +`spm.toCartesian(ev, out)` or `spm.toSpherical(ev, out)` to get the other one. + +## Input side + +`createInput(ctx)` runs once per input device and returns a table of +functions keyed by **address pattern**: + +```js +createInput: function (ctx) { + const sources = []; // per-device state lives here + return { + "/my/{n}/pos": function (n, args, ev, address) { + if (!args || args.length < 3) return false; + ev.type = spm.POSITION; + ev.source = n; + ev.coords = spm.CARTESIAN; + ev.x = args[0]; ev.y = args[1]; ev.z = args[2]; + return true; // route the event + }, + "*": function (n, args, ev, address) { + // anything else under `matches` (n is -1) + return false; // nothing to route + } + }; +} +``` + +- A pattern is literal except for at most one `{n}`, a decimal source index. + It is compiled once. Each distinct address is resolved once and then cached + per input, so a function costs one map lookup per message. +- With a declared protocol, the input takes addresses under `matches` (or every + address, when `matches` is absent). With "Auto", each address goes to the + first handler whose `matches` prefix it starts with: the bundled handlers + first, then scripts in loading order. +- Return `true` to route `ev`. Call `ctx.emit()` to route it immediately, + for instance to produce several events from one message, then return `false`. +- Inputs are decoded even when nothing is routed, so accumulated state stays + current. + +## Output side + +`createOutput(ctx)` runs once per output device and returns an **encoder**. +It has one method per event type it handles; missing methods mean "skip". + +```js +createOutput: function (ctx) { + const pos = spm.addressTemplate("/my/{n}/pos"); // n => cached string + const buf = [0, 0, 0]; // reused for every send + const cart = { x: 0, y: 0, z: 0 }; + return { + position: function (ev) { + spm.toCartesian(ev, cart); + buf[0] = cart.x; buf[1] = cart.y; buf[2] = cart.z; + ctx.send(pos(ev.source), buf); + }, + clear: function (ev) { /* … */ }, + algorithm: function (ev) { /* … */ }, + native: function (ev) { + if (ev.nativeProtocol === "my-protocol") ctx.send(ev.nativeAddress, ev.nativeArgs); + }, + onParamsChanged: function () { /* optional, see params */ }, + destroy: function () { /* optional */ } + }; +} +``` + +`ctx.send(address, args)` sends one OSC message. The arguments are serialized +before it returns, so **reusing the same array for every send is safe**, and is +the intended pattern. + +## Verbatim passthrough (`native`) + +Some messages have no place in the event model: ADM gain, mute, `/adm/lis`, +and so on. A handler can claim an address space: + +```js +native: { prefix: "/adm/", source: function (address) { /* index or -1 */ } } +``` + +When no decoder produced an event for an address under that prefix, on any +input, the engine sends an `spm.NATIVE` event with `nativeProtocol` set to the +claiming handler's id. Only that protocol's encoders forward it, through their +`native()` method. `source()` gives the source index used by the route filter, +or `-1` to bypass it. + +## `ctx` + +| Member | Side | | +|---|---|---| +| `ctx.params` | both | The device's settings. Updated in place on change | +| `ctx.device` | both | `{ id, name }` | +| `ctx.send(address, args)` | output | Send one message | +| `ctx.emit()` | input | Route the current event now | +| `ctx.log(msg)` | both | Console line, prefixed with handler and device | +| `ctx.error(msg)` | both | Show a problem on the device's row | + +## Params + +Per-device settings, edited with the **…** button next to a device's protocol +and saved with the configuration: + +```js +params: [ + { key: "root", label: "Address root", type: "string", default: "/obj" }, + { key: "range", label: "Half-width", type: "real", default: 1, min: 0.001 }, + { key: "count", type: "int", default: 8, min: 1, max: 64 }, + { key: "flip", type: "bool", default: false }, + { key: "mode", type: "enum", options: ["xyz", "aed"] } +] +``` + +Values are coerced to the declared type and clamped to `min`/`max`. When they +change, an instance that defines `onParamsChanged()` is kept and sees the new +values in `ctx.params`. Otherwise it is rebuilt, which also resets any +accumulated state. + +## `spm` + +Constants: `POSITION`, `CLEAR`, `ALGORITHM`, `NATIVE`, `SPHERICAL`, +`CARTESIAN`, `API_VERSION`, `MBAP_EXTENDED_RADIUS`, `DEG` (radians → degrees +factor), `RAD` (degrees → radians). + +Helpers, all writing into an object you pass in, so nothing allocates: + +- `toCartesian(ev, out)`, `toSpherical(ev, out)` +- `sphericalToCartesian(az, el, r, out)`, `cartesianToSpherical(x, y, z, out)` +- `addressTemplate("/a/{n}/b")` returns `n => string`, cached per index +- `indexAt(address, start)`: the decimal number at `start`, or −1 +- `clamp(v, lo, hi)` + +## Performance rules + +Messages are handled on the GUI thread, and garbage collection pauses there +too. In the per-message functions: + +- Don't allocate: no `[]`, `{}`, template strings, regexes, closures, + `concat`/`slice` or destructuring. Allocate in `createInput`/`createOutput`. +- Keep object shapes fixed: add every property of a state object when you + create it. +- Use `spm.addressTemplate` for outgoing addresses and reuse send buffers. + +`tests/tst_handlers.qml` has benchmarks (`benchmark_*`) to measure against. + +## Failures + +A handler that throws loses that one message. The other routes still get it, +the device's row shows the error, and the console logs the first throw and +every thousandth after that. A device whose protocol is not loaded (a missing +or broken script) keeps its protocol, settings and routes, and resumes once +the script loads. + +## Writing and loading scripts + +The **SCRIPTS** tab lists every handler. + +- **+ New script** starts a script from the template. It is stored in the + app's settings, not in a file, so it also works where there is no file + access (the web build). +- **+ Open file** adds a `.js` file. It is saved by path and read again at + startup; **Reload** picks up changes made in another editor. +- **Edit** opens either kind in the built-in editor. **Apply** (Ctrl+S) saves + the script (to the settings, or back to its file) and loads it. Devices on + its protocol get a fresh instance. + +The editor shows line numbers. Tab and Enter keep the indentation. When a +script does not compile, or throws while loading, the failing line is +highlighted. The edit is saved anyway, so nothing is lost, but it is not +applied: the version that was running keeps running. + +Reading and writing files from QML needs `QML_XHR_ALLOW_FILE_READ=1` and +`QML_XHR_ALLOW_FILE_WRITE=1`, which the app's `environment` sets. Without them, +opening or saving a file reports an error after a couple of seconds. diff --git a/environment b/environment index 15c4476..f22267f 100644 --- a/environment +++ b/environment @@ -1,3 +1,5 @@ export SCORE_AUDIO_BACKEND=dummy export SCORE_DISABLE_LIBRARY=1 export SCORE_DISABLE_AUDIO_PLUGINS=1 +export QML_XHR_ALLOW_FILE_READ=1 +export QML_XHR_ALLOW_FILE_WRITE=1 diff --git a/qml/Main.qml b/qml/Main.qml index 324bb9d..4c48b2c 100644 --- a/qml/Main.qml +++ b/qml/Main.qml @@ -37,6 +37,14 @@ ApplicationWindow { property string savedConfiguration: "" property int listenPort: 18032 property string savedOutputDevices: "[]" + + // URLs of user handler scripts (file:// or inline:), as a JSON array. + property string handlerScripts: "[]" + // Sources of the inline scripts written in the editor, by URL. + property string inlineScripts: "{}" + + onLogReceivedMessagesChanged: Engine.updateLogging() + onLogSentMessagesChanged: Engine.updateLogging() } palette { @@ -66,8 +74,14 @@ ApplicationWindow { color: Theme.backgroundColor - readonly property var outputProtocols: ["SpatGRIS", "ADM-OSC", "SPAT Revolution"] - readonly property var inputProtocols: ["Auto", "SpatGRIS", "ADM-OSC", "SPAT Revolution"] + // Protocol pickers list ids and names in parallel: handlers are picked by + // id and shown by name. Rebuilt by refresh(), since scripts come and go. + property var inputProtocolIds: [] + property var inputProtocolNames: [] + property var outputProtocolIds: [] + property var outputProtocolNames: [] + property var builtinOutputChoices: [] + property var scriptedOutputChoices: [] property alias settings: appSettings // ---- Engine state -------------------------------------------------- // @@ -81,6 +95,7 @@ ApplicationWindow { property alias outputListModel: outputListModel property alias routeListModel: routeListModel property alias matrixModel: matrixModel + property alias handlerListModel: handlerListModel ListModel { id: inputListModel } ListModel { id: outputListModel } @@ -88,11 +103,14 @@ ApplicationWindow { ListModel { id: routeListModel } // inputs x outputs, row-major, for the matrix grid. ListModel { id: matrixModel } + // Bundled handlers and user scripts, for the scripts view. + ListModel { id: handlerListModel } property alias messageMonitor: monitorView.messageMonitor // Formatting a log line costs more than the routing, so it is gated. readonly property bool monitorActive: currentViewIndex === monitorViewIndex + onMonitorActiveChanged: Engine.updateLogging() // ---- Current input -------------------------------------------------- // // Bindable mirrors: the engine's state is plain JS with no notifiers. @@ -136,6 +154,15 @@ ApplicationWindow { } function refresh() { + const ins = Engine.protocolOptions("input"); + inputProtocolIds = ins.ids; + inputProtocolNames = ins.names; + const outs = Engine.protocolOptions("output"); + outputProtocolIds = outs.ids; + outputProtocolNames = outs.names; + builtinOutputChoices = Engine.outputChoices(true); + scriptedOutputChoices = Engine.outputChoices(false); + Engine.updateInputList(); Engine.updateOutputList(); Engine.updateRouteList(currentInputId); @@ -206,17 +233,12 @@ ApplicationWindow { // Adds a ready-to-edit output for that protocol, on its usual port. function addOutput(protocol) { - const defaults = { - "SpatGRIS": 18042, - "ADM-OSC": 9000, - "SPAT Revolution": 8088 - }; let n = 1; for (let o of outputs) if (o.protocol === protocol) n++; - const dev = Engine.createOutput(protocol + " " + n, "127.0.0.1", - defaults[protocol] || 8000, protocol); + const dev = Engine.createOutput(Engine.protocolName(protocol) + " " + n, "127.0.0.1", + Engine.defaultPortFor(protocol), protocol); if (currentInputId >= 0) Engine.setRoute(currentInputId, dev.id, { enabled: true }); Engine.saveConfiguration(); @@ -279,16 +301,83 @@ ApplicationWindow { Engine.clearLogs(); } + // ---- Handler settings and scripts ------------------------------------- // + + function deviceOf(kind, id) { + return kind === "input" ? Engine.findInput(id) : Engine.findOutput(id); + } + + // { specs, values } for the params dialog. + function deviceParams(kind, id) { + const dev = deviceOf(kind, id); + if (!dev) return { specs: [], values: {} }; + return { specs: Engine.paramSpecs(dev.protocol), values: Engine.paramValues(dev) }; + } + + function setDeviceParams(kind, id, values) { + const dev = deviceOf(kind, id); + if (!dev) return; + Engine.setDeviceParams(dev, values, kind); + Engine.saveConfiguration(); + refresh(); + } + + function addHandlerScript(url) { + Engine.addScript(url, function () { refresh(); }); + } + + function reloadHandlerScript(url) { + Engine.reloadScript(url); + } + + function reloadHandlerScripts() { + Engine.reloadScripts(); + } + + // A new script stored in the settings, from the template. Returns its URL. + function newHandlerScript() { + const url = Engine.newInlineScript(); + refresh(); + return url; + } + + function handlerScriptName(url) { + return Engine.scriptName(url); + } + + // `done(source, error)`. + function handlerScriptSource(url, done) { + Engine.readSource(url, done); + } + + // Saves and loads the script; `done({ error, line, saved, running })`. + function applyHandlerScript(url, source, done) { + Engine.applyScript(url, source, function (result) { + refresh(); + done(result); + }); + } + + function removeHandlerScript(url) { + Engine.removeScript(url); + refresh(); + } + // ---- Views ----------------------------------------------------------- // readonly property int routingViewIndex: 0 readonly property int matrixViewIndex: 1 readonly property int monitorViewIndex: 2 + readonly property int handlersViewIndex: 3 property int currentViewIndex: appSettings.lastViewIndex onCurrentViewIndexChanged: appSettings.lastViewIndex = currentViewIndex // ---- Lifecycle -------------------------------------------------------- // Component.onCompleted: { + Engine.updateLogging(); Engine.restoreConfiguration(); + // Asynchronous: devices on a scripted protocol pick it up on arrival. + Engine.loadSavedScripts(); + Engine.updateHandlerList(); if (inputs.length > 0) currentInputId = inputs[0].id; refresh(); @@ -307,7 +396,7 @@ ApplicationWindow { appName: "Spatial Protocol Mapper" appDescription: "A tool developed by the Société des Arts Technologiques" appDetails: "Route and translate spatial audio positioning between " - + "SpatGRIS, ADM-OSC and SPAT Revolution over OSC." + + "SpatGRIS, ADM-OSC, SPAT Revolution and scripted protocols over OSC." appWebsite: "https://github.com/sat-mtl/spatial-protocol-mapper" // Relative paths would resolve against AboutDialog.qml in the submodule. logoPath: Qt.resolvedUrl("spm/resources/images/logo.png") @@ -372,6 +461,14 @@ ApplicationWindow { onClicked: window.currentViewIndex = window.monitorViewIndex } + CustomButton { + text: "SCRIPTS" + Layout.fillWidth: true + Layout.topMargin: Theme.spacing + isActive: window.currentViewIndex === window.handlersViewIndex + onClicked: window.currentViewIndex = window.handlersViewIndex + } + Item { Layout.fillHeight: true } } } @@ -384,6 +481,7 @@ ApplicationWindow { RoutingView { controller: window } MatrixView { controller: window } MonitorView { id: monitorView; controller: window } + HandlersView { controller: window } } } } diff --git a/qml/spm/Components/InputRow.qml b/qml/spm/Components/InputRow.qml index 46202a0..bebdc07 100644 --- a/qml/spm/Components/InputRow.qml +++ b/qml/spm/Components/InputRow.qml @@ -14,7 +14,9 @@ Rectangle { property string protocol: "Auto" property bool listening: false property string error: "" - property var protocols: [] + property var protocolIds: [] + property var protocolNames: [] + property bool hasParams: false property real scaleX: 1 property real scaleY: 1 property real scaleZ: 1 @@ -25,6 +27,7 @@ Rectangle { signal nameEdited(string value) signal portEdited(string value) signal protocolEdited(string value) + signal paramsEditRequested signal scaleEditRequested signal removeRequested signal selectRequested @@ -91,11 +94,16 @@ Rectangle { onCommit: value => root.portEdited(value) } - CustomComboBox { + ProtocolCell { Layout.preferredWidth: Columns.protocol - model: root.protocols - currentIndex: Math.max(0, root.protocols.indexOf(root.protocol)) - onActivated: root.protocolEdited(currentText) + protocol: root.protocol + protocolIds: root.protocolIds + protocolNames: root.protocolNames + hasParams: root.hasParams + // The address column already reports it. + error: "" + onProtocolEdited: id => root.protocolEdited(id) + onParamsEditRequested: root.paramsEditRequested() } RowButton { diff --git a/qml/spm/Components/OutputRow.qml b/qml/spm/Components/OutputRow.qml index 37477a5..82e4d7c 100644 --- a/qml/spm/Components/OutputRow.qml +++ b/qml/spm/Components/OutputRow.qml @@ -11,7 +11,11 @@ Rectangle { property string host: "" property int port: 0 property string protocol: "" - property var protocols: [] + property var protocolIds: [] + property var protocolNames: [] + property bool hasParams: false + // Handler trouble: not loaded, or it threw. + property string error: "" property bool routed: true property int sourceOffset: 0 @@ -25,6 +29,7 @@ Rectangle { signal hostEdited(string value) signal portEdited(string value) signal protocolEdited(string value) + signal paramsEditRequested signal routeEditRequested signal removeRequested @@ -72,11 +77,15 @@ Rectangle { onCommit: value => root.portEdited(value) } - CustomComboBox { + ProtocolCell { Layout.preferredWidth: Columns.protocol - model: root.protocols - currentIndex: Math.max(0, root.protocols.indexOf(root.protocol)) - onActivated: root.protocolEdited(currentText) + protocol: root.protocol + protocolIds: root.protocolIds + protocolNames: root.protocolNames + hasParams: root.hasParams + error: root.error + onProtocolEdited: id => root.protocolEdited(id) + onParamsEditRequested: root.paramsEditRequested() } RowButton { diff --git a/qml/spm/Components/ParamsDialog.qml b/qml/spm/Components/ParamsDialog.qml new file mode 100644 index 0000000..e7176b0 --- /dev/null +++ b/qml/spm/Components/ParamsDialog.qml @@ -0,0 +1,152 @@ +import QtQuick +import QtQuick.Controls.Basic +import QtQuick.Layouts +import ca.qc.sat.qmlcomponents +import spm + +// A device's handler settings, built from the params its handler declares: +// [{ key, label?, type: "int"|"real"|"bool"|"string"|"enum", min?, max?, options? }] +Dialog { + id: root + + property string deviceName: "" + property var specs: [] + property var values: ({}) + + signal paramsAccepted(var values) + + title: "Settings" + modal: true + standardButtons: Dialog.Ok | Dialog.Cancel + closePolicy: Popup.CloseOnEscape + + function editParams(name, paramSpecs, current) { + deviceName = name; + values = current; + specs = paramSpecs; + open(); + } + + readonly property bool valid: { + for (let i = 0; i < fields.count; i++) { + const f = fields.itemAt(i); + if (f && !f.acceptable) return false; + } + return true; + } + + onAccepted: { + const out = {}; + for (let i = 0; i < fields.count; i++) { + const f = fields.itemAt(i); + if (f) out[f.spec.key] = f.value(); + } + paramsAccepted(out); + } + + background: Rectangle { + color: Theme.backgroundColorSecondary + border.color: Theme.borderColor + border.width: 1 + radius: Theme.borderRadius + } + + header: CustomLabel { + text: root.deviceName === "" ? root.title : root.title + ": " + root.deviceName + font.bold: true + font.pixelSize: Theme.fontSizeSubtitle + padding: Theme.padding + elide: Text.ElideRight + } + + GridLayout { + columns: 2 + columnSpacing: Theme.spacing + rowSpacing: Theme.spacing + + Repeater { + id: fields + model: root.specs + + // One row: the label and whichever editor the type calls for. + delegate: RowLayout { + id: field + required property var modelData + readonly property var spec: modelData + readonly property var current: root.values[spec.key] + readonly property bool numeric: spec.type === "int" || spec.type === "real" + readonly property bool acceptable: !numeric || text.acceptableInput + + Layout.columnSpan: 2 + spacing: Theme.spacing + + function value() { + switch (spec.type) { + case "int": return parseInt(text.text); + case "real": return parseFloat(text.text); + case "bool": return toggle.checked; + case "enum": return spec.options[choice.currentIndex]; + } + return text.text; + } + + CustomLabel { + Layout.preferredWidth: 160 + text: field.spec.label || field.spec.key + elide: Text.ElideRight + } + + CustomTextField { + id: text + visible: field.spec.type === "int" || field.spec.type === "real" + || field.spec.type === "string" + Layout.preferredWidth: 180 + text: field.current === undefined ? "" : String(field.current) + color: acceptableInput ? Theme.textColor : Theme.errorColor + validator: field.spec.type === "int" ? intValidator + : field.spec.type === "real" ? realValidator : null + + IntValidator { + id: intValidator + bottom: typeof field.spec.min === "number" ? field.spec.min : -2147483647 + top: typeof field.spec.max === "number" ? field.spec.max : 2147483647 + } + DoubleValidator { + id: realValidator + bottom: typeof field.spec.min === "number" ? field.spec.min : -1e12 + top: typeof field.spec.max === "number" ? field.spec.max : 1e12 + notation: DoubleValidator.StandardNotation + } + } + + CustomSwitch { + id: toggle + visible: field.spec.type === "bool" + checked: field.current === true + } + + CustomComboBox { + id: choice + visible: field.spec.type === "enum" + Layout.preferredWidth: 180 + model: field.spec.type === "enum" ? field.spec.options : [] + currentIndex: field.spec.type === "enum" + ? Math.max(0, field.spec.options.indexOf(field.current)) : -1 + } + } + } + + CustomLabel { + Layout.columnSpan: 2 + visible: root.specs.length === 0 + color: Theme.textColorSecondary + text: "This handler has no settings." + } + } + + onOpened: { + const ok = root.standardButton(Dialog.Ok); + if (ok) + ok.enabled = Qt.binding(function () { return root.valid; }); + } +} diff --git a/qml/spm/Components/ProtocolCell.qml b/qml/spm/Components/ProtocolCell.qml new file mode 100644 index 0000000..8ac846b --- /dev/null +++ b/qml/spm/Components/ProtocolCell.qml @@ -0,0 +1,57 @@ +import QtQuick +import QtQuick.Controls.Basic +import QtQuick.Layouts +import ca.qc.sat.qmlcomponents +import spm + +// The protocol column of a device row: the picker, a button for the handler's +// settings when it declares any, and a marker when the handler is in trouble. +// Protocols are picked by id but shown by name, hence the two parallel lists. +RowLayout { + id: root + + property string protocol: "" + property var protocolIds: [] + property var protocolNames: [] + property bool hasParams: false + // A handler problem (not loaded, threw); empty when fine. + property string error: "" + + signal protocolEdited(string id) + signal paramsEditRequested + + spacing: 4 + + CustomComboBox { + Layout.fillWidth: true + Layout.minimumWidth: 60 + model: root.protocolNames + currentIndex: Math.max(0, root.protocolIds.indexOf(root.protocol)) + onActivated: index => root.protocolEdited(root.protocolIds[index]) + } + + CustomLabel { + visible: root.error !== "" + text: "!" + font.bold: true + color: Theme.errorColor + horizontalAlignment: Text.AlignHCenter + Layout.preferredWidth: 12 + + ToolTip.visible: errorHover.hovered + ToolTip.text: root.error + HoverHandler { id: errorHover } + } + + RowButton { + visible: root.hasParams + Layout.preferredWidth: 36 + text: "…" + onClicked: root.paramsEditRequested() + + ToolTip.visible: paramsHover.hovered + ToolTip.delay: 400 + ToolTip.text: "Handler settings" + HoverHandler { id: paramsHover } + } +} diff --git a/qml/spm/Components/ScriptEditor.qml b/qml/spm/Components/ScriptEditor.qml new file mode 100644 index 0000000..ed7ceee --- /dev/null +++ b/qml/spm/Components/ScriptEditor.qml @@ -0,0 +1,247 @@ +import QtQuick +import QtQuick.Controls.Basic +import QtQuick.Layouts +import ca.qc.sat.qmlcomponents +import spm + +// Plain-text editor for a handler script: line numbers, the failing line +// highlighted, Tab and Enter that keep the indentation, Ctrl+S to apply. +Item { + id: root + + property string url: "" + property string title: "" + // What is stored; the text differs from it while there are unsaved edits. + property string savedText: "" + readonly property bool dirty: edit.text !== savedText + + property string status: "" + property bool statusIsError: false + // 1-based line the last error points at, or -1. + property int errorLine: -1 + + // Close asks once before discarding edits. + property bool confirmingClose: false + + readonly property string monoFont: "IBM Plex Mono" + readonly property string indentUnit: " " + + signal applyRequested(string text) + signal closeRequested + + function load(scriptUrl, scriptTitle, text, error) { + url = scriptUrl; + title = scriptTitle; + savedText = text; + edit.text = text; + edit.cursorPosition = 0; + flick.contentY = 0; + confirmingClose = false; + errorLine = -1; + status = error; + statusIsError = error !== ""; + edit.forceActiveFocus(); + } + + // The result of applying `text`: { error, line, saved, running }. + function applied(result, text) { + if (result.saved) savedText = text; + errorLine = result.line; + statusIsError = result.error !== ""; + if (result.error === "") + status = "Applied."; + else if (!result.saved) + status = "Not saved: " + result.error; + else + status = result.error + (result.running ? " — saved, not applied; the previous version keeps running" + : " — saved, not applied"); + if (errorLine > 0) showLine(errorLine); + } + + function showLine(line) { + const y = (line - 1) * lineHeight; + if (y < flick.contentY || y + lineHeight > flick.contentY + flick.height) + flick.contentY = Math.max(0, y - flick.height / 3); + } + + readonly property real lineHeight: edit.lineCount > 0 ? edit.contentHeight / edit.lineCount : 16 + + Shortcut { + sequences: [StandardKey.Save] + enabled: root.visible + onActivated: root.applyRequested(edit.text) + } + + ColumnLayout { + anchors.fill: parent + spacing: Theme.spacing + + RowLayout { + Layout.fillWidth: true + spacing: Theme.spacing + + CustomLabel { + text: root.title + (root.dirty ? " •" : "") + font.bold: true + font.pixelSize: Theme.fontSizeSubtitle + elide: Text.ElideRight + Layout.maximumWidth: 260 + } + + CustomLabel { + Layout.fillWidth: true + text: root.status + color: root.statusIsError ? Theme.errorColor : Theme.textColorSecondary + font.pixelSize: Theme.fontSizeSmall + elide: Text.ElideRight + + ToolTip.visible: root.status !== "" && statusHover.hovered + ToolTip.text: root.status + HoverHandler { id: statusHover } + } + + CustomButton { + Layout.preferredWidth: 90 + height: Theme.buttonHeight + text: "Apply" + onClicked: root.applyRequested(edit.text) + + ToolTip.visible: applyHover.hovered + ToolTip.delay: 400 + ToolTip.text: "Save and load the script (Ctrl+S)" + HoverHandler { id: applyHover } + } + + CustomButton { + Layout.preferredWidth: 90 + height: Theme.buttonHeight + text: "Revert" + enabled: root.dirty + opacity: enabled ? 1.0 : 0.4 + onClicked: { + edit.text = root.savedText; + root.confirmingClose = false; + } + } + + CustomButton { + Layout.preferredWidth: 130 + height: Theme.buttonHeight + text: root.confirmingClose ? "Discard edits?" : "Close" + onClicked: { + if (root.dirty && !root.confirmingClose) { + root.confirmingClose = true; + return; + } + root.confirmingClose = false; + root.closeRequested(); + } + } + } + + Rectangle { + Layout.fillWidth: true + Layout.fillHeight: true + color: Theme.backgroundColorSecondary + border.color: edit.activeFocus ? Theme.primaryColor : Theme.borderColor + border.width: 1 + radius: Theme.borderRadius + clip: true + + Flickable { + id: flick + anchors.fill: parent + anchors.margins: 1 + contentWidth: Math.max(width, gutter.width + edit.contentWidth + 2 * Theme.spacing) + contentHeight: Math.max(height, edit.y + edit.contentHeight + Theme.spacing) + boundsBehavior: Flickable.StopAtBounds + clip: true + + ScrollBar.vertical: ScrollBar {} + ScrollBar.horizontal: ScrollBar {} + + // Keeps the cursor in view while typing. + function ensureVisible(r) { + const x = r.x + edit.x, y = r.y + edit.y; + if (contentX >= x) contentX = Math.max(0, x - gutter.width); + else if (contentX + width <= x + r.width) contentX = x + r.width - width + Theme.spacing; + if (contentY >= y) contentY = y; + else if (contentY + height <= y + r.height) contentY = y + r.height - height + Theme.spacing; + } + + Rectangle { + visible: root.errorLine > 0 && root.errorLine <= edit.lineCount + x: 0 + y: edit.y + (root.errorLine - 1) * root.lineHeight + width: flick.contentWidth + height: root.lineHeight + color: Theme.errorColor + opacity: 0.18 + } + + Text { + id: gutter + x: 0 + y: edit.y + width: Math.max(32, implicitWidth) + Theme.spacing + rightPadding: Theme.spacing + horizontalAlignment: Text.AlignRight + font.family: root.monoFont + font.pixelSize: Theme.fontSizeSmall + color: Theme.textColorSecondary + text: { + let s = ""; + for (let i = 1; i <= edit.lineCount; i++) s += (i > 1 ? "\n" : "") + i; + return s; + } + lineHeight: root.lineHeight + lineHeightMode: Text.FixedHeight + } + + TextEdit { + id: edit + x: gutter.width + Theme.spacing + y: Theme.spacing + width: Math.max(implicitWidth, flick.width - x) + // Fills the view, so a click below the last line lands here. + height: Math.max(implicitHeight, flick.height - y) + font.family: root.monoFont + font.pixelSize: Theme.fontSizeSmall + color: Theme.textColor + selectionColor: Theme.primaryColor + selectedTextColor: Theme.textColorOnAccent + selectByMouse: true + persistentSelection: true + wrapMode: TextEdit.NoWrap + textFormat: TextEdit.PlainText + onCursorRectangleChanged: flick.ensureVisible(cursorRectangle) + onTextChanged: if (root.confirmingClose && !root.dirty) root.confirmingClose = false + + // The indentation of the line the cursor is on. + function currentIndent() { + const start = text.lastIndexOf("\n", cursorPosition - 1) + 1; + let end = start; + while (end < text.length && (text[end] === " " || text[end] === "\t")) end++; + return text.substring(start, Math.min(end, cursorPosition)); + } + + Keys.onTabPressed: event => { + if (selectedText !== "") remove(selectionStart, selectionEnd); + insert(cursorPosition, root.indentUnit); + event.accepted = true; + } + // A new line keeps the indentation, one level deeper after { or [. + function newLine(event) { + if (selectedText !== "") remove(selectionStart, selectionEnd); + const indent = currentIndent(); + const before = cursorPosition > 0 ? text[cursorPosition - 1] : ""; + insert(cursorPosition, "\n" + indent + ((before === "{" || before === "[") ? root.indentUnit : "")); + event.accepted = true; + } + Keys.onReturnPressed: event => newLine(event) + Keys.onEnterPressed: event => newLine(event) + } + } + } + } +} diff --git a/qml/spm/Engine.js b/qml/spm/Engine.js index 325fab2..2a48ea6 100644 --- a/qml/spm/Engine.js +++ b/qml/spm/Engine.js @@ -1,20 +1,33 @@ .import "LogQueue.js" as LogQueue +.import "Handlers.js" as Handlers +.import "handlers/Spm.js" as Spm +.import "handlers/Template.js" as Template // Routing engine. // // Any number of inputs and outputs, with an explicit route per connection. // -// inputs [{ id, name, protocol, port, listening, error, udp, osc, admState }] -// outputs [{ id, name, protocol, host, port, udp }] +// inputs [{ id, name, protocol, port, listening, error, params, udp, osc, rt }] +// outputs [{ id, name, protocol, host, port, params, udp, rt }] // routes [{ inputId, outputId, enabled, sourceOffset, srcMin, srcMax }] // // `srcMin`/`srcMax` are null when the route carries every source. The filter // tests the *incoming* source index, before `sourceOffset` is applied, so the // numbers in the UI match the numbers on the sender. // +// Protocols are handlers (Handlers.js, docs/HANDLERS.md). Each device holds a +// runtime, `rt`, with its handler instance; it is built lazily and dropped +// whenever the protocol, its params or its script change. +// // Free variables (inputs, outputs, routes, appSettings, the list models, // messageMonitor, monitorActive, Protocols) resolve against Main.qml. +// Read once: module property lookups do not belong on the message path. +var POSITION = Spm.POSITION; +var CLEAR = Spm.CLEAR; +var ALGORITHM = Spm.ALGORITHM; +var NATIVE = Spm.NATIVE; + // ----- Identity --------------------------------------------------------- // // Routes reference devices by id, so ids must outlive any array position. var g_nextId = 1; @@ -41,8 +54,21 @@ function findRoute(inputId, outputId) { return null; } +// ----- Logging ---------------------------------------------------------- // +// Formatting a log line costs more than the routing, so the message path only +// tests these. The window calls updateLogging() whenever an input to them +// changes. +var g_logIn = false; +var g_logOut = false; + +function updateLogging() { + g_logIn = !!(appSettings.logReceivedMessages && monitorActive); + g_logOut = !!(appSettings.logSentMessages && monitorActive); +} + // ----- Dispatch index --------------------------------------------------- // -// Rebuilt on every structural change; the message path only reads it. +// Rebuilt on every structural change; the message path only reads it. Each +// link carries everything dispatch needs, so no lookup happens per message. var g_linksByInput = {}; function reindexRoutes() { @@ -51,9 +77,27 @@ function reindexRoutes() { if (!r.enabled) continue; const out = findOutput(r.outputId); if (!out) continue; + + let rt = out.rt; + if (!rt || rt.protocol !== out.protocol) + rt = attachOutput(out); + const enc = rt.enc; + if (!g_linksByInput[r.inputId]) g_linksByInput[r.inputId] = []; - g_linksByInput[r.inputId].push({ route: r, output: out }); + g_linksByInput[r.inputId].push({ + route: r, + output: out, + entry: rt.entry, + enc: enc, + offset: r.sourceOffset || 0, + min: (r.srcMin === null || r.srcMin === undefined) ? -Infinity : r.srcMin, + max: (r.srcMax === null || r.srcMax === undefined) ? Infinity : r.srcMax, + position: !!enc && typeof enc.position === "function", + clear: !!enc && typeof enc.clear === "function", + algorithm: !!enc && typeof enc.algorithm === "function", + native: !!enc && typeof enc.native === "function" + }); } } @@ -69,553 +113,402 @@ function routeAccepts(route, sourceIndex) { } // ----- Message path ----------------------------------------------------- // +// Steady state allocates nothing here: the event is the input's own, the +// address resolves through a per-input cache, and encoders send from buffers +// they allocated once. Score's udp.osc() copies its arguments before +// returning (libossia protocols_sender::send_osc), which is what makes reusing +// those buffers safe. + +var ADDRESS_CACHE_MAX = 4096; function onInputValueReceived(inp, address, value) { - if (appSettings.logReceivedMessages && monitorActive) { + if (g_logIn) LogQueue.pushInput(`IN [${inp.name}] ${address} = ${JSON.stringify(value)}`); - } - // Normalize the incoming message to an internal SpatGRIS-style form: - // command : "pol" | "deg" | "car" | "clr" | "alg" - // sourceIndex: 1-based source index (or -1 if n/a) - // args : command-dependent payload - // pol : [azimuthRad, elevationRad, radius, hspan, vspan] - // deg : [azimuthDeg, elevationDeg, radius, hspan, vspan] - // car : [x, y, z, hspan, vspan] - // clr : [] - // alg : [algorithm] - // Angles and axes use SpatGRIS conventions internally - // (negative azimuth = left). Each output's mapper applies its own flips. - // - // Parsed unconditionally: ADM and SPAT accumulate one parameter per + let rt = inp.rt; + if (rt === undefined || rt === null) + rt = attachInput(inp); + + let e = rt.cache.get(address); + if (e === undefined) + e = resolveAddress(rt, address); + + const ev = rt.ev; + resetEvent(ev); + rt.emitted = false; + + // Decoded unconditionally: ADM and SPAT accumulate one parameter per // message, and that state has to stay current even while nothing is routed. - const parsed = parseInput(inp, address, value); + let decoded = false; + if (e.fn !== null) { + try { + decoded = e.fn(e.n, value, ev, address) === true; + } catch (err) { + handlerThrew(inp, e.decoder.entry, err); + } + } const links = g_linksByInput[inp.id]; - if (!links || links.length === 0) return; - - const norm = applyInputScale(inp, parsed); - - if (norm) { - for (let link of links) { - const out = link.output; - if (!out.udp) continue; - if (!routeAccepts(link.route, norm.sourceIndex)) continue; - - const idx = norm.sourceIndex + (link.route.sourceOffset || 0); - const mapped = mapMessage(norm.command, idx, norm.args, out.protocol, norm); - if (!mapped || mapped.length === 0) continue; - - for (let msg of mapped) { - // outboundUDP.osc() expects a JS array for the arguments — - // wrap scalar values so "/a/b" with value 0 doesn't crash. - const oscArgs = Array.isArray(msg.value) ? msg.value : [msg.value]; - out.udp.osc(msg.address, oscArgs); - if (appSettings.logSentMessages && monitorActive) { - LogQueue.pushOutput(`OUT [${out.name}] ${msg.address} = ${JSON.stringify(msg.value)}`); - } - } - } + if (links === undefined || links.length === 0) return; + + if (decoded && ev.type !== 0) { + routeEvent(inp, links, ev); return; } - - // Messages we couldn't translate that still belong to a protocol - // we have outputs for: pass them through verbatim. This lets - // ADM-specific features (gain, mute, name, /adm/lis, /adm/env, …) - // reach ADM-OSC outputs even though we don't understand them. - if (address.startsWith("/adm/")) { - forwardAdmRaw(inp, links, address, value); + if (rt.emitted) return; + + // Nothing understood it, but a protocol claims its address space: pass it + // through verbatim to that protocol's outputs. This is how ADM gain, mute, + // /adm/lis, /adm/env, … reach ADM-OSC outputs. + if (e.nativeProtocol !== null) { + ev.type = NATIVE; + ev.source = e.nativeSource; + ev.nativeProtocol = e.nativeProtocol; + ev.nativeAddress = address; + ev.nativeArgs = Array.isArray(value) ? value : [value]; + dispatch(links, ev); } } -// An input declares what it speaks; "Auto" keeps the original behaviour of -// deciding from the address. -function parseInput(inp, address, value) { - switch (inp.protocol) { - case "SpatGRIS": - return address.startsWith("/spat/serv") ? parseSpatGRISInput(value) : null; - case "ADM-OSC": - return address.startsWith("/adm/obj/") ? parseADMInput(inp, address, value) : null; - case "SPAT Revolution": - return address.startsWith("/source/") ? parseSPATInput(inp, address, value) : null; - } - if (address.startsWith("/spat/serv")) return parseSpatGRISInput(value); - if (address.startsWith("/adm/obj/")) return parseADMInput(inp, address, value); - if (address.startsWith("/source/")) return parseSPATInput(inp, address, value); - return null; +function resetEvent(ev) { + ev.type = 0; + ev.source = -1; + ev.offset = 0; + ev.coords = Spm.SPHERICAL; + ev.radians = false; + ev.azimuth = 0; ev.elevation = 0; ev.distance = 1; + ev.x = 0; ev.y = 0; ev.z = 0; + ev.hspan = 0; ev.vspan = 0; + ev.value = null; + ev.nativeProtocol = null; + ev.nativeAddress = null; + ev.nativeArgs = null; } -function forwardAdmRaw(inp, links, address, value) { - const oscArgs = Array.isArray(value) ? value : [value]; +// Per-input scaling, then every route. +function routeEvent(inp, links, ev) { + if (ev.type === POSITION) + Spm.scaleEvent(ev, scaleOf(inp, "scaleX"), scaleOf(inp, "scaleY"), scaleOf(inp, "scaleZ")); + dispatch(links, ev); +} - // Only /adm/obj/{n}/… carries a source index; /adm/lis and /adm/env don't. - // Both the range filter and the offset key off that. - const m = address.match(/^\/adm\/obj\/(\d+)(\/.*)$/); - const sourceIndex = m ? parseInt(m[1]) : -1; +function dispatch(links, ev) { + const inSource = ev.source; + const type = ev.type; + for (let i = 0; i < links.length; i++) { + const l = links[i]; + if (!l.output.udp) continue; + if (inSource >= 0 && (inSource < l.min || inSource > l.max)) continue; - for (let link of links) { - const out = link.output; - if (!out.udp) continue; - if (out.protocol !== "ADM-OSC") continue; - if (!routeAccepts(link.route, sourceIndex)) continue; + ev.offset = l.offset; + ev.source = inSource + l.offset; + try { + switch (type) { + case POSITION: if (l.position) l.enc.position(ev); break; + case CLEAR: if (l.clear) l.enc.clear(ev); break; + case ALGORITHM: if (l.algorithm) l.enc.algorithm(ev); break; + case NATIVE: if (l.native) l.enc.native(ev); break; + } + } catch (err) { + handlerThrew(l.output, l.entry, err); + } + } + ev.source = inSource; + ev.offset = 0; +} - let outAddress = address; - const offset = link.route.sourceOffset || 0; - if (offset !== 0 && m) { - outAddress = `/adm/obj/${sourceIndex + offset}${m[2]}`; +// First sight of an address: which decoder function takes it, with which +// source index, and which protocol claims it if that decoder declines. +function resolveAddress(rt, address) { + let fn = null, n = -1, decoder = null; + for (let d of rt.decoders) { + if (!address.startsWith(d.prefix)) continue; + // The first decoder whose prefix matches owns the address, matched or not. + decoder = d; + for (let pt of d.patterns) { + const m = Spm.matchPattern(pt.p, address); + if (m !== -2) { fn = pt.fn; n = m; break; } } + if (fn === null) fn = d.fallback; + break; + } - out.udp.osc(outAddress, oscArgs); - if (appSettings.logSentMessages && monitorActive) { - LogQueue.pushOutput(`OUT [${out.name}] ${outAddress} = ${JSON.stringify(value)}`); + let nativeProtocol = null, nativeSource = -1; + for (let h of Handlers.all()) { + if (h.native === null || !address.startsWith(h.native.prefix)) continue; + nativeProtocol = h.id; + if (typeof h.native.source === "function") { + try { + const s = h.native.source(address); + nativeSource = (typeof s === "number" && s >= 0) ? s : -1; + } catch (err) { + nativeSource = -1; + } } + break; } -} -// ----- Per-input scaling ------------------------------------------------ // -// A negative factor mirrors that axis. Applied to the canonical form before -// any route sees the message. -// -// SpatGRIS convention: azimuth is measured from the front (+y) toward the -// right (+x), elevation from the horizon toward +z. -function sphericalToCartesian(az, el, r) { - const ce = Math.cos(el); - return { x: r * ce * Math.sin(az), y: r * ce * Math.cos(az), z: r * Math.sin(el) }; + const e = { fn: fn, n: n, decoder: decoder, + nativeProtocol: nativeProtocol, nativeSource: nativeSource }; + // A sender spraying unique addresses must not grow this without bound. + if (rt.cache.size >= ADDRESS_CACHE_MAX) + rt.cache.clear(); + rt.cache.set(address, e); + return e; } -function cartesianToSpherical(x, y, z) { - const r = Math.sqrt(x * x + y * y + z * z); - // At the origin there is no direction to report; asin(0/0) would be NaN. - if (r < 1e-12) return { az: 0, el: 0, r: 0 }; - return { az: Math.atan2(x, y), el: Math.asin(z / r), r: r }; +// ----- Handler runtimes ------------------------------------------------- // + +// Inputs set to "Auto" run every decoder that declares an address prefix, in +// registration order; the first prefix that matches an address owns it. +function inputEntriesFor(protocol) { + if (protocol === "Auto") { + const list = []; + for (let h of Handlers.all()) + if (h.hasInput && h.matches !== null) list.push(h); + return list; + } + const h = Handlers.get(protocol); + return (h !== null && h.hasInput) ? [h] : []; } -function scaleOf(inp, axis) { - const v = inp[axis]; - return (v === undefined || v === null) ? 1 : v; +function deviceInfo(dev) { + return { id: dev.id, name: dev.name }; } -function inputHasScale(inp) { - return scaleOf(inp, "scaleX") !== 1 - || scaleOf(inp, "scaleY") !== 1 - || scaleOf(inp, "scaleZ") !== 1; +function makeInputContext(inp, rt, entry) { + return { + device: deviceInfo(inp), + params: Handlers.resolveParams(entry, inp.protocol === "Auto" ? null : inp.params), + // Routes the event as it stands now, for a decoder that produces more + // than one per message (or produces one outside onMessage). + emit: function () { + const links = g_linksByInput[inp.id]; + rt.emitted = true; + if (links === undefined || links.length === 0 || rt.ev.type === 0) return; + routeEvent(inp, links, rt.ev); + }, + log: function (msg) { console.log(`[${entry.id}] ${inp.name}:`, msg); }, + error: function (msg) { setHandlerError(inp, String(msg)); } + }; } -// Returns the message unchanged when the scale is identity. -function applyInputScale(inp, norm) { - if (!norm || !inputHasScale(inp)) return norm; - - const sx = scaleOf(inp, "scaleX"); - const sy = scaleOf(inp, "scaleY"); - const sz = scaleOf(inp, "scaleZ"); - const a = norm.args; - - // `legacyArgs` is not carried over: a SpatGRIS output re-emits it verbatim, - // which would put the unscaled position on the wire. - switch (norm.command) { - case "car": - if (a.length < 3) return norm; - return { - command: "car", - sourceIndex: norm.sourceIndex, - args: [a[0] * sx, a[1] * sy, a[2] * sz].concat(a.slice(3)) - }; - case "pol": - case "deg": { - if (a.length < 3) return norm; - // Polar is scaled through cartesian and back. - const toRad = (norm.command === "deg") ? Math.PI / 180 : 1; - const p = sphericalToCartesian(a[0] * toRad, a[1] * toRad, a[2]); - const q = cartesianToSpherical(p.x * sx, p.y * sy, p.z * sz); - return { - command: norm.command, - sourceIndex: norm.sourceIndex, - args: [q.az / toRad, q.el / toRad, q.r].concat(a.slice(3)) - }; - } - } - // clr, alg and anything forwarded verbatim carry no position. - return norm; -} - -// ----- Input parsing: /spat/serv ---------------------------------------- // -const HALF_PI = Math.PI / 2.; -function parseSpatGRISInput(value) { - if (!value || value.length < 2) return null; - - if (typeof value[0] === "number") { - // Legacy format: [sourceIndex, az, el, hspan, vspan, radius, reserved] - // Polar in radians; rewrite to canonical "pol" shape. - if (value.length < 7) return null; - // Keep the original payload: SpatGRIS routes the legacy form through a - // *cylindrical* LegacyLbapPosition::toPosition() in Cube/MBAP mode, but - // routes "pol" through a *spherical* conversion. Rewriting legacy to - // "pol" therefore moves the source (az=0, colat=pi/4, dist=1 lands at - // (0,0.707,0.707) instead of (0,1,0.5)). Outputs that speak /spat/serv - // natively re-emit `legacyArgs` unchanged; every other output uses the - // normalized "pol" form below, which is correct for them. - return { - command: "pol", - sourceIndex: 1+value[0], - args: [value[1], HALF_PI - value[2], value[5], value[3] / 2., value[4] * 2.], - legacyArgs: value.slice(1) - }; - } +function makeOutputContext(out, entry) { return { - command: value[0], - sourceIndex: (typeof value[1] === "number") ? value[1] : -1, - args: value.slice(2) + device: deviceInfo(out), + params: Handlers.resolveParams(entry, out.params), + send: function (address, args) { + const u = out.udp; + if (!u) return; + if (!Array.isArray(args)) args = [args]; + u.osc(address, args); + if (g_logOut) + LogQueue.pushOutput(`OUT [${out.name}] ${address} = ${JSON.stringify(args.length === 1 ? args[0] : args)}`); + }, + log: function (msg) { console.log(`[${entry.id}] ${out.name}:`, msg); }, + error: function (msg) { setHandlerError(out, String(msg)); } }; } -// ----- Input parsing: /adm/obj/{n}/… ------------------------------------ // -// ADM sends per-parameter messages, not atomic packets, so we maintain -// per-source state and emit a complete SpatGRIS-form command on every -// incoming update. The last coordinate family written (polar vs cartesian) -// determines whether we emit a "deg" or "car" command downstream. -// -// The accumulator belongs to the input device: two ADM senders each address -// their own source 1. -function getAdmSource(inp, n) { - if (!inp.admState) - inp.admState = {}; - if (!inp.admState[n]) { - // Defaults per ADM-OSC spec: - // azim, elev: (unspecified) → 0 - // dist : 1.0 (on reference sphere) - // xyz : 0 - // w : 0 - inp.admState[n] = { - azim: 0, elev: 0, dist: 1.0, - x: 0, y: 0, z: 0, - w: 0, - lastMode: "car" - }; +function attachInput(inp) { + detachInput(inp); + const rt = { + protocol: inp.protocol, + ev: Spm.newEvent(), + cache: new Map(), + decoders: [], + emitted: false + }; + inp.rt = rt; + inp.handlerError = ""; + inp.handlerErrors = 0; + + for (let h of inputEntriesFor(inp.protocol)) { + const ctx = makeInputContext(inp, rt, h); + let table = null; + try { + table = h.descriptor.createInput(ctx); + } catch (err) { + handlerThrew(inp, h, err); + continue; + } + if (!table || typeof table !== "object") continue; + const c = Handlers.compileInputTable(table); + rt.decoders.push({ + entry: h, + prefix: h.matches || "", + patterns: c.patterns, + fallback: c.fallback, + table: table, + ctx: ctx + }); } - return inp.admState[n]; + return rt; } -function parseADMInput(inp, address, value) { - // Match /adm/obj// exactly (no wildcards). - const m = address.match(/^\/adm\/obj\/(\d+)\/(\w+)$/); - if (!m) return null; - - const n = parseInt(m[1]); - const param = m[2]; - const s = getAdmSource(inp, n); +function detachInput(inp) { + const rt = inp.rt; + inp.rt = null; + if (!rt) return; + for (let d of rt.decoders) + callDestroy(inp, d.entry, d.table); +} - if (!value) return null; +function attachOutput(out) { + detachOutput(out); + const entry = Handlers.get(out.protocol); + const rt = { protocol: out.protocol, entry: entry, enc: null, ctx: null }; + out.rt = rt; + out.handlerError = ""; + out.handlerErrors = 0; + if (entry === null || !entry.hasOutput) return rt; - switch (param) { - case "aed": - if (value.length < 3) return null; - s.azim = value[0]; s.elev = value[1]; s.dist = value[2]; - s.lastMode = "pol"; - break; - case "azim": - if (value.length < 1) return null; - s.azim = value[0]; s.lastMode = "pol"; - break; - case "elev": - if (value.length < 1) return null; - s.elev = value[0]; s.lastMode = "pol"; - break; - case "dist": - if (value.length < 1) return null; - s.dist = value[0]; s.lastMode = "pol"; - break; - case "xyz": - if (value.length < 3) return null; - s.x = value[0]; s.y = value[1]; s.z = value[2]; - s.lastMode = "car"; - break; - case "xy": - if (value.length < 2) return null; - s.x = value[0]; s.y = value[1]; - s.lastMode = "car"; - break; - case "x": - if (value.length < 1) return null; - s.x = value[0]; s.lastMode = "car"; - break; - case "y": - if (value.length < 1) return null; - s.y = value[0]; s.lastMode = "car"; - break; - case "z": - if (value.length < 1) return null; - s.z = value[0]; s.lastMode = "car"; - break; - case "w": - if (value.length < 1) return null; - s.w = value[0]; - // Width alone doesn't switch coordinate mode; re-emit with current mode. - break; - default: - // gain/mute/name/dref/dmax — no equivalent in our internal model. - return null; + rt.ctx = makeOutputContext(out, entry); + try { + const enc = entry.descriptor.createOutput(rt.ctx); + rt.enc = (enc && typeof enc === "object") ? enc : null; + } catch (err) { + handlerThrew(out, entry, err); } + return rt; +} - // Emit in SpatGRIS-convention internal form. - // Axes match (x=L/R, y=B/F, z=D/U with same sign), so xyz passes through. - // ADM azimuth: +90° = left. SpatGRIS azimuth: -90° = left. → sign flip. - // ADM has no vertical extent → vspan = 0. - if (s.lastMode === "pol") { - return { - command: "deg", - sourceIndex: n, - args: [-s.azim, s.elev, s.dist, s.w, 0] - }; - } else { - return { - command: "car", - sourceIndex: n, - args: [s.x, s.y, s.z, s.w, 0] - }; - } +function detachOutput(out) { + const rt = out.rt; + out.rt = null; + if (rt && rt.enc) + callDestroy(out, rt.entry, rt.enc); } -// ----- Input parsing: /source/{n}/… (SPAT Revolution) ------------------- // -// The mirror of mapForSPAT: azimuth keeps the SpatGRIS sign, radius arrives as -// a percentage, spread is one figure filling both extents. One parameter per -// message, so the accumulator lives on the input device. -function getSpatSource(inp, n) { - if (!inp.spatState) - inp.spatState = {}; - if (!inp.spatState[n]) { - inp.spatState[n] = { - azim: 0, elev: 0, dist: 1.0, - x: 0, y: 0, z: 0, - spread: 0, - lastMode: "car" - }; +function callDestroy(dev, entry, instance) { + if (!instance || typeof instance.destroy !== "function") return; + try { + instance.destroy(); + } catch (err) { + handlerThrew(dev, entry, err); } - return inp.spatState[n]; } -function parseSPATInput(inp, address, value) { - const m = address.match(/^\/source\/(\d+)\/(\w+)$/); - if (!m) return null; +// Reported once per distinct message; the console gets the first throw and +// then one line per thousand, so a handler failing on every message cannot +// flood it. +var ERROR_LOG_EVERY = 1000; + +function handlerThrew(dev, entry, err) { + dev.handlerErrors = (dev.handlerErrors || 0) + 1; + const text = Handlers.describeError(err); + if (dev.handlerErrors === 1 || dev.handlerErrors % ERROR_LOG_EVERY === 0) + console.log(`Handler ${entry ? entry.id : "?"} on ${dev.name} threw (${dev.handlerErrors}x):`, text); + setHandlerError(dev, "Handler error: " + text); +} - const n = parseInt(m[1]); - const param = m[2]; - const s = getSpatSource(inp, n); +// The lists are rebuilt later, never from inside the message path. +var g_listUpdatePending = false; - if (!value) return null; +function setHandlerError(dev, msg) { + if (dev.handlerError === msg) return; + dev.handlerError = msg; + if (g_listUpdatePending) return; + g_listUpdatePending = true; + Qt.callLater(function () { + g_listUpdatePending = false; + updateInputList(); + updateOutputList(); + }); +} - switch (param) { - case "aed": - if (value.length < 3) return null; - s.azim = value[0]; s.elev = value[1]; s.dist = value[2] / 100.0; - s.lastMode = "pol"; - break; - case "xyz": - if (value.length < 3) return null; - s.x = value[0]; s.y = value[1]; s.z = value[2]; - s.lastMode = "car"; - break; - case "spread": - if (value.length < 1) return null; - // One figure on the wire, both extents internally. - s.spread = value[0] / 100.0; - // Spread alone doesn't switch coordinate mode. - break; - default: - // mode/gain/mute and the rest have no equivalent in our model. - return null; - } +// What the device's row shows when the socket itself is fine. +function handlerStatus(dev, kind) { + if (dev.handlerError) return dev.handlerError; + if (kind === "input" && dev.protocol === "Auto") return ""; + const h = Handlers.get(dev.protocol); + if (h === null) return "Handler not loaded: " + dev.protocol; + if (kind === "input" && !h.hasInput) return h.name + " has no input side"; + if (kind === "output" && !h.hasOutput) return h.name + " has no output side"; + return ""; +} - if (s.lastMode === "pol") { - return { - command: "deg", - sourceIndex: n, - args: [s.azim, s.elev, s.dist, s.spread, s.spread] - }; +// A script was loaded, reloaded or removed. Devices on that protocol get a new +// instance; everyone's address cache goes, since a protocol may have claimed +// or released an address space. +function handlersChanged(id) { + for (let inp of inputs) { + if (inp.protocol === id || inp.protocol === "Auto") + detachInput(inp); + else if (inp.rt) + inp.rt.cache.clear(); } - return { - command: "car", - sourceIndex: n, - args: [s.x, s.y, s.z, s.spread, s.spread] - }; + for (let out of outputs) + if (out.protocol === id) + detachOutput(out); + reindexRoutes(); } -function mapMessage(command, idx, args, outputType, norm) { - switch (outputType) { - case "SpatGRIS": return mapForSpatGRIS(command, idx, args, norm); - case "ADM-OSC": return mapForADM(command, idx, args); - case "SPAT Revolution": return mapForSPAT(command, idx, args); - } - return []; -} - -// ----- SpatGRIS: pass /spat/serv through verbatim ------------------------ // -// The SpatGRIS server speaks /spat/serv natively, so we re-emit the -// command with the exact coordinate system we received. No lossy -// polar<->cartesian round-trip. -function mapForSpatGRIS(command, idx, args, norm) { - // A legacy-form input goes back out in the legacy form, untouched. - // idx is 1-based (+ any per-route offset); the legacy wire form is 0-based. - if (norm && norm.legacyArgs) { - return [{ - address: "/spat/serv", - value: [idx - 1].concat(norm.legacyArgs) - }]; - } - switch (command) { - case "pol": - case "deg": - case "car": - if (args.length < 5) return []; - return [{ - address: "/spat/serv", - value: [command, idx, args[0], args[1], args[2], args[3], args[4]] - }]; - case "clr": - return [{ address: "/spat/serv", value: ["clr", idx] }]; - case "alg": - if (args.length < 1) return []; - // alg idx - return [{ address: "/spat/serv", value: ["alg", idx, args[0]] }]; - } - // SpatGRIS speaks /spat/serv natively and supports commands we do not - // model (e.g. "reset ", sg_OscInput.cpp:186-193). Forward anything we did - // not recognise rather than dropping it on the floor. - if (typeof command === "string" && command.length > 0) { - return [{ address: "/spat/serv", value: [command, idx].concat(args) }]; - } - return []; -} - -// ----- ADM-OSC (v1.0) --------------------------------------------------- // -// Conventions: -// - Azimuth: SpatGRIS uses −90° = left / +90° = right (example in spec: -// "deg 7 -90.0 ... moves source #7 at the extreme left"). -// ADM uses +90° = left / −90° = right. → sign flipped. -// - Elevation: both use 0° = horizon, +90° = above. → no change. -// - Cartesian axes match: x = L/R, y = B/F, z = D/U with same signs. -// Ranges differ: SpatGRIS cartesian is the MBAP extended field -// (±MBAP_EXTENDED_RADIUS = ±1.6666667, sg_constants.hpp:71) while ADM-OSC -// cartesian is normalized ±1. Relying on the receiver to clamp (ADM-OSC §9) -// is NOT position-preserving: every source beyond 60% of full-scale collapses -// onto the wall, silently discarding the outer 40% of the field. We therefore -// scale the axes into ADM's normalized range. Polar radius is left alone: it -// is a distance, not a bounded axis, and ControlGris dome sources sit at ~1. -// ADM has no vertical-extent address — vspan is dropped. -// Packed /aed and /xyz are used per the spec's atomicity recommendation. -function mapForADM(command, idx, args) { - switch (command) { - case "pol": - if (args.length < 5) return []; - return polarToADM(idx, args[0], args[1], args[2], args[3]); - case "deg": - if (args.length < 5) return []; - return polarToADM(idx, - args[0] * Math.PI / 180.0, - args[1] * Math.PI / 180.0, - args[2], args[3]); - case "car": - if (args.length < 5) return []; - return cartesianToADM(idx, args[0], args[1], args[2], args[3]); - case "clr": - // Packed xyz for atomic reset. - return [{ address: `/adm/obj/${idx}/xyz`, value: [0, 0, 0] }]; - case "alg": - // ADM has no algorithm concept. - return []; - } - return []; -} - -function polarToADM(idx, azimuthRad, elevationRad, radius, hspan) { - const messages = [{ - address: `/adm/obj/${idx}/aed`, - value: [ - -azimuthRad * 180.0 / Math.PI, // SpatGRIS → ADM azimuth sign flip - elevationRad * 180.0 / Math.PI, - radius - ] - }]; - if (hspan !== undefined) { - // ADM /w is normalized [0, 1]; SpatGRIS hspan is already [0, 1]. - messages.push({ address: `/adm/obj/${idx}/w`, value: hspan }); - } - return messages; -} - -// SpatGRIS MBAP_EXTENDED_RADIUS (sg_constants.hpp:71) -- the cartesian clamp -// applied by CartesianVector::clampedToFarField (sg_CartesianVector.hpp:182-187). -const MBAP_EXTENDED_RADIUS = 1.6666667; - -function cartesianToADM(idx, x, y, z, hspan) { - const messages = [{ - address: `/adm/obj/${idx}/xyz`, - value: [x / MBAP_EXTENDED_RADIUS, - y / MBAP_EXTENDED_RADIUS, - z / MBAP_EXTENDED_RADIUS] - }]; - if (hspan !== undefined) { - messages.push({ address: `/adm/obj/${idx}/w`, value: hspan }); - } - return messages; -} - -// ----- SPAT Revolution: /source/N/aed or /source/N/xyz ------------------- // -function mapForSPAT(command, idx, args) { - switch (command) { - case "pol": - if (args.length < 5) return []; - return polarToSPAT(idx, args[0], args[1], args[2], args[3], args[4]); - case "deg": - if (args.length < 5) return []; - return polarToSPAT(idx, - args[0] * Math.PI / 180.0, - args[1] * Math.PI / 180.0, - args[2], args[3], args[4]); - case "car": - if (args.length < 5) return []; - return cartesianToSPAT(idx, args[0], args[1], args[2], args[3], args[4]); - case "clr": - return [{ address: `/source/${idx}/xyz`, value: [0, 0, 0] }]; - case "alg": - if (args.length < 1) return []; - return [{ - address: `/source/${idx}/mode`, - value: args[0] === "dome" ? "dome" : "panning" - }]; +// ----- Params ------------------------------------------------------------ // + +function paramSpecs(protocol) { + const h = Handlers.get(protocol); + return h === null ? [] : h.params; +} + +function hasParams(dev) { + return dev.protocol !== "Auto" && paramSpecs(dev.protocol).length > 0; +} + +// The values the device's handler runs with, defaults filled in. +function paramValues(dev) { + return Handlers.resolveParams(Handlers.get(dev.protocol), dev.params); +} + +// Updates the live instance in place when it can take it (onParamsChanged), +// and rebuilds it otherwise. +function setDeviceParams(dev, values, kind) { + const h = Handlers.get(dev.protocol); + dev.params = Handlers.resolveParams(h, values); + + const instances = []; + if (kind === "input" && dev.rt) { + for (let d of dev.rt.decoders) instances.push({ ctx: d.ctx, obj: d.table }); + } else if (kind === "output" && dev.rt && dev.rt.ctx) { + instances.push({ ctx: dev.rt.ctx, obj: dev.rt.enc }); } - return []; -} - -function polarToSPAT(idx, azimuthRad, elevationRad, radius, hspan, vspan) { - const messages = [{ - address: `/source/${idx}/aed`, - value: [ - azimuthRad * 180.0 / Math.PI, - elevationRad * 180.0 / Math.PI, - radius * 100 // SPAT uses percentage (0-100) - ] - }]; - if (hspan !== undefined && vspan !== undefined) { - messages.push({ - address: `/source/${idx}/spread`, - value: (hspan + vspan) / 2 * 100 - }); + + let rebuild = instances.length === 0; + for (let it of instances) { + if (!it.obj || typeof it.obj.onParamsChanged !== "function") { rebuild = true; break; } } - return messages; -} - -function cartesianToSPAT(idx, x, y, z, hspan, vspan) { - const messages = [{ - address: `/source/${idx}/xyz`, - value: [x, y, z] - }]; - if (hspan !== undefined && vspan !== undefined) { - messages.push({ - address: `/source/${idx}/spread`, - value: (hspan + vspan) / 2 * 100 - }); + + if (!rebuild) { + for (let it of instances) { + const p = it.ctx.params; + for (let k in dev.params) p[k] = dev.params[k]; + try { + it.obj.onParamsChanged(); + } catch (err) { + handlerThrew(dev, h, err); + } + } + } else if (kind === "input") { + detachInput(dev); + } else { + attachOutput(dev); + reindexRoutes(); } - return messages; +} + +// ----- Per-input scaling ------------------------------------------------ // +// A negative factor mirrors that axis. Applied to the event before any route +// sees it (Spm.scaleEvent). + +function scaleOf(inp, axis) { + const v = inp[axis]; + return (v === undefined || v === null) ? 1 : v; +} + +function inputHasScale(inp) { + return scaleOf(inp, "scaleX") !== 1 + || scaleOf(inp, "scaleY") !== 1 + || scaleOf(inp, "scaleZ") !== 1; } // ----- Input device lifecycle ------------------------------------------- // @@ -688,10 +581,11 @@ function createInput(name, port, protocol) { enabled: true, // what the user asked for; persisted listening: false, // what the socket managed; runtime only scaleX: 1, scaleY: 1, scaleZ: 1, + params: {}, error: "", udp: null, osc: null, - admState: {} + rt: null }; inputs.push(inp); openInput(inp); @@ -703,6 +597,7 @@ function removeInput(id) { for (let i = 0; i < inputs.length; i++) { if (inputs[i].id !== id) continue; closeInput(inputs[i]); + detachInput(inputs[i]); inputs.splice(i, 1); // Routes referencing it would keep resolving. routes = routes.filter(function (r) { return r.inputId !== id; }); @@ -717,12 +612,17 @@ function updateInput(id, props) { const inp = findInput(id); if (!inp) return; - if (props.name !== undefined) inp.name = props.name; + if (props.name !== undefined) { + inp.name = props.name; + if (inp.rt) + for (let d of inp.rt.decoders) d.ctx.device.name = inp.name; + } if (props.protocol !== undefined && props.protocol !== inp.protocol) { inp.protocol = props.protocol; - // The accumulated coordinates describe the previous reading. - inp.admState = {}; - inp.spatState = {}; + // The accumulated coordinates describe the previous reading, and the + // params belong to the previous handler. + inp.params = {}; + detachInput(inp); } for (let axis of ["scaleX", "scaleY", "scaleZ"]) { if (props[axis] === undefined) continue; @@ -771,9 +671,8 @@ function setInputListening(id, listening) { } // ----- Output device lifecycle ------------------------------------------ // -// Each output device is backed by a raw outbound UDP socket. -// We format OSC entirely in JS (mapForXxx above) and hand the -// result to udp.osc(addr, values). +// Each output device is backed by a raw outbound UDP socket. Its handler +// formats OSC in JS and sends through udp.osc(addr, values). function openOutputSocket(dev) { if (dev.udp) { try { dev.udp.close(); } catch (e) {} @@ -799,7 +698,9 @@ function createOutput(name, host, port, protocol) { host: host, port: port, protocol: protocol, - udp: null + params: {}, + udp: null, + rt: null }; openOutputSocket(dev); outputs.push(dev); @@ -807,14 +708,23 @@ function createOutput(name, host, port, protocol) { return dev; } -// Host and port changes reopen the socket; the rest are labels. +// Host and port changes reopen the socket; a protocol change rebuilds the +// handler; the rest are labels. function updateOutput(id, props) { const out = findOutput(id); if (!out) return; let rebind = false; - if (props.name !== undefined) out.name = props.name; - if (props.protocol !== undefined) out.protocol = props.protocol; + if (props.name !== undefined) { + out.name = props.name; + if (out.rt && out.rt.ctx) out.rt.ctx.device.name = out.name; + } + if (props.protocol !== undefined && props.protocol !== out.protocol) { + out.protocol = props.protocol; + out.params = {}; + detachOutput(out); + reindexRoutes(); + } if (props.host !== undefined && props.host !== out.host) { out.host = props.host; rebind = true; @@ -840,6 +750,7 @@ function removeOutput(id) { try { dev.udp.close(); } catch (e) {} dev.udp = null; } + detachOutput(dev); outputs.splice(i, 1); routes = routes.filter(function (r) { return r.outputId !== id; }); reindexRoutes(); @@ -893,6 +804,46 @@ function updateMatrixList() { syncModel(matrixModel, rows, ["inputId", "outputId"]); } +// ----- Protocol menus ---------------------------------------------------- // + +function protocolName(id) { + if (id === "Auto") return "Auto"; + const h = Handlers.get(id); + return h === null ? id + " (not loaded)" : h.name; +} + +// Parallel id and name lists for a protocol picker. A protocol some device +// uses but no script provides any more stays listed, so the picker can show it. +function protocolOptions(kind) { + const ids = []; + const names = []; + if (kind === "input") { ids.push("Auto"); names.push("Auto"); } + for (let h of Handlers.all()) { + if (kind === "input" ? !h.hasInput : !h.hasOutput) continue; + ids.push(h.id); + names.push(h.name); + } + for (let d of (kind === "input" ? inputs : outputs)) { + if (ids.indexOf(d.protocol) >= 0) continue; + ids.push(d.protocol); + names.push(protocolName(d.protocol)); + } + return { ids: ids, names: names }; +} + +// [{ id, name }] of the protocols an output can be added with. +function outputChoices(builtin) { + const list = []; + for (let h of Handlers.all()) + if (h.hasOutput && h.builtin === builtin) list.push({ id: h.id, name: h.name }); + return list; +} + +function defaultPortFor(protocol) { + const h = Handlers.get(protocol); + return h === null ? 8000 : h.defaultPort; +} + // ----- List models ------------------------------------------------------- // // The engine keeps plain JS arrays; the views bind to these. @@ -935,7 +886,9 @@ function updateInputList() { port: inp.port, enabled: inp.enabled !== false, listening: inp.listening, - error: inp.error, + // A socket problem first: without it nothing arrives at all. + error: inp.error ? inp.error : handlerStatus(inp, "input"), + hasParams: hasParams(inp), scaleX: scaleOf(inp, "scaleX"), scaleY: scaleOf(inp, "scaleY"), scaleZ: scaleOf(inp, "scaleZ") @@ -957,7 +910,10 @@ function updateOutputList() { name: out.name, host: out.host, port: out.port, - protocol: out.protocol + protocol: out.protocol, + protocolName: protocolName(out.protocol), + error: handlerStatus(out, "output"), + hasParams: hasParams(out) }); } syncModel(outputListModel, rows, ["outputId"]); @@ -975,6 +931,8 @@ function updateRouteList(inputId) { host: out.host, port: out.port, protocol: out.protocol, + error: handlerStatus(out, "output"), + hasParams: hasParams(out), routed: r ? r.enabled : false, sourceOffset: r ? (r.sourceOffset || 0) : 0, srcMin: (r && r.srcMin !== null && r.srcMin !== undefined) ? r.srcMin : -1, @@ -984,6 +942,280 @@ function updateRouteList(inputId) { syncModel(routeListModel, rows, ["outputId"]); } +// ----- Handler scripts --------------------------------------------------- // +// Two kinds, both listed by URL in appSettings.handlerScripts: +// - files ("file:///…"), read and written with XMLHttpRequest, which needs +// QML_XHR_ALLOW_FILE_READ=1 and QML_XHR_ALLOW_FILE_WRITE=1 (`environment`); +// - inline scripts ("inline:"), written in the app's editor, whose +// source is kept in appSettings.inlineScripts. These need no file access, +// so they also work where there is none (wasm). +// Loading is asynchronous for files: a device on a protocol that is not loaded +// yet keeps its settings and picks the handler up when it arrives. + +var INLINE = "inline:"; + +function isInline(url) { + return url.startsWith(INLINE); +} + +function savedScriptUrls() { + if (appSettings.handlerScripts === undefined) return []; + try { + const list = JSON.parse(appSettings.handlerScripts || "[]"); + return Array.isArray(list) ? list.filter(function (u) { return typeof u === "string"; }) : []; + } catch (e) { + console.log("Could not parse the saved handler scripts:", e); + return []; + } +} + +function saveScriptUrls() { + if (appSettings.handlerScripts === undefined) return; + appSettings.handlerScripts = JSON.stringify(Handlers.scripts().map(function (s) { return s.url; })); +} + +// url -> source, for inline scripts. +function inlineSources() { + if (appSettings.inlineScripts === undefined) return {}; + try { + const m = JSON.parse(appSettings.inlineScripts || "{}"); + return (m && typeof m === "object" && !Array.isArray(m)) ? m : {}; + } catch (e) { + console.log("Could not parse the saved inline scripts:", e); + return {}; + } +} + +function saveInlineSource(url, source) { + if (appSettings.inlineScripts === undefined) return; + const m = inlineSources(); + if (source === null) delete m[url]; + else m[url] = source; + appSettings.inlineScripts = JSON.stringify(m); +} + +function loadSavedScripts() { + const inline = inlineSources(); + for (let url of savedScriptUrls()) { + if (isInline(url)) + registerSource(url, inline.hasOwnProperty(url) ? inline[url] : "", + inline.hasOwnProperty(url) ? "" : "The script's source is missing"); + else + readScript(url); + } +} + +function scriptRecord(url) { + for (let s of Handlers.scripts()) + if (s.url === url) return s; + return null; +} + +// Compiles `source` for `url` (or records `readError`), then brings the +// devices and lists up to date. Returns the error, or "". +function registerSource(url, source, readError) { + const before = scriptRecord(url); + const previousId = before ? before.id : ""; + + let err = readError; + if (err === "") + err = Handlers.loadScript(url, source); + else + Handlers.scriptFailed(url, err); + if (err !== "") + console.log("Handler script", url, "failed:", err); + + const after = scriptRecord(url); + if (previousId !== "" && previousId !== (after ? after.id : "")) + handlersChanged(previousId); + if (after && after.id !== "") + handlersChanged(after.id); + + updateHandlerList(); + // The window's refresh() also rebuilds the protocol pickers. + if (typeof refresh === "function") { + refresh(); + } else { + updateInputList(); + updateOutputList(); + } + return err; +} + +// file:// requests that Qt does not allow (QML_XHR_ALLOW_FILE_READ/WRITE unset) +// never complete; they only print a warning. Give up on them after this long. +var FILE_TIMEOUT_MS = 2000; + +function after(ms, fn) { + const t = Qt.createQmlObject("import QtQml; Timer { repeat: false }", appSettings); + t.interval = ms; + t.triggered.connect(function () { t.destroy(); fn(); }); + t.start(); +} + +// `done(text, error)`, exactly once. +function fileRequest(method, url, body, done) { + let settled = false; + const finish = function (text, error) { + if (settled) return; + settled = true; + done(text, error); + }; + let xhr; + try { + xhr = new XMLHttpRequest(); + xhr.onreadystatechange = function () { + // Qt throws on `status` before the request is done. + if (xhr.readyState !== XMLHttpRequest.DONE) return; + // file:// reports status 0 on success. + if (xhr.status === 0 || xhr.status === 200) finish(xhr.responseText, ""); + else finish("", "status " + xhr.status); + }; + xhr.open(method, url); + xhr.send(body); + } catch (e) { + finish("", String(e)); + return; + } + after(FILE_TIMEOUT_MS, function () { + if (settled) return; + // Before abort(): it fires DONE with status 0, which reads as success. + finish("", method === "PUT" ? "file writes are not allowed (QML_XHR_ALLOW_FILE_WRITE=1)" + : "file reads are not allowed (QML_XHR_ALLOW_FILE_READ=1)"); + try { xhr.abort(); } catch (e) {} + }); +} + +// `done(source, error)`. +function readSource(url, done) { + if (isInline(url)) { + const m = inlineSources(); + done(m.hasOwnProperty(url) ? m[url] : "", m.hasOwnProperty(url) ? "" : "The script's source is missing"); + return; + } + fileRequest("GET", url, null, function (text, error) { + if (error !== "") done("", "Could not read the file: " + error); + else if (text === "") done("", "Could not read the file: it is missing or empty"); + else done(text, ""); + }); +} + +// `done(error)`. +function writeSource(url, source, done) { + if (isInline(url)) { + saveInlineSource(url, source); + done(""); + return; + } + fileRequest("PUT", url, source, function (text, error) { + done(error === "" ? "" : "Could not write the file: " + error); + }); +} + +// `done(error)` runs once the script is registered or has failed. +function readScript(url, done) { + readSource(url, function (source, readError) { + const err = registerSource(url, source, readError); + if (done) done(err); + }); +} + +function addScript(url, done) { + readScript(url, function (err) { + saveScriptUrls(); + if (done) done(err); + }); +} + +// A new inline script from the template, with an id and name nothing uses. +// Returns its URL. +function newInlineScript() { + const inline = inlineSources(); + let n = 1; + while (Handlers.get("script-" + n) !== null || inline.hasOwnProperty(INLINE + "script-" + n) + || scriptRecord(INLINE + "script-" + n) !== null) + n++; + const url = INLINE + "script-" + n; + const source = Template.make("script-" + n, "Script " + n); + saveInlineSource(url, source); + registerSource(url, source, ""); + saveScriptUrls(); + return url; +} + +// Saves the source where the script lives, then compiles it. The source is +// kept even when it does not compile, so no edit is lost; the handler that was +// running keeps running. `done({ error, line, saved })`. +function applyScript(url, source, done) { + writeSource(url, source, function (writeError) { + if (writeError !== "") { + if (done) done({ error: writeError, line: -1, saved: false, running: false }); + return; + } + const err = registerSource(url, source, ""); + if (scriptRecord(url) !== null && savedScriptUrls().indexOf(url) < 0) + saveScriptUrls(); + const rec = scriptRecord(url); + if (done) done({ error: err, line: rec ? rec.line : -1, saved: true, + // A failed apply leaves the previous version registered. + running: !!rec && rec.id !== "" && Handlers.get(rec.id) !== null }); + }); +} + +// What the scripts view calls a script: its handler's name once it has one. +function scriptName(url) { + const rec = scriptRecord(url); + const h = (rec && rec.id !== "") ? Handlers.get(rec.id) : null; + return h ? h.name : fileName(url); +} + +function reloadScripts() { + for (let s of Handlers.scripts().slice()) + readScript(s.url); +} + +function reloadScript(url) { + readScript(url); +} + +function removeScript(url) { + const id = Handlers.removeScript(url); + if (id !== "") handlersChanged(id); + if (isInline(url)) saveInlineSource(url, null); + saveScriptUrls(); + updateHandlerList(); + updateInputList(); + updateOutputList(); +} + +function updateHandlerList() { + if (typeof handlerListModel === "undefined") return; + const rows = []; + for (let h of Handlers.all()) { + if (!h.builtin) continue; + rows.push({ handlerId: h.id, name: h.name, url: "", builtin: true, inline: false, + directions: directionsOf(h), error: "" }); + } + for (let s of Handlers.scripts()) { + const h = s.id !== "" ? Handlers.get(s.id) : null; + rows.push({ handlerId: s.id, name: h ? h.name : fileName(s.url), url: s.url, + builtin: false, inline: isInline(s.url), + directions: h ? directionsOf(h) : "", error: s.error }); + } + syncModel(handlerListModel, rows, ["url", "handlerId"]); +} + +function directionsOf(h) { + if (h.hasInput && h.hasOutput) return "in / out"; + return h.hasInput ? "in" : "out"; +} + +function fileName(url) { + if (isInline(url)) return url.substring(INLINE.length); + const i = url.lastIndexOf("/"); + return i < 0 ? url : decodeURIComponent(url.substring(i + 1)); +} + // ----- Persistence ------------------------------------------------------- // function saveConfiguration() { @@ -994,11 +1226,13 @@ function saveConfiguration() { port: i.port, enabled: i.enabled !== false, scaleX: scaleOf(i, "scaleX"), scaleY: scaleOf(i, "scaleY"), - scaleZ: scaleOf(i, "scaleZ") }; + scaleZ: scaleOf(i, "scaleZ"), + params: i.params || {} }; }), outputs: outputs.map(function (o) { return { id: o.id, name: o.name, protocol: o.protocol, - host: o.host, port: o.port }; + host: o.host, port: o.port, + params: o.params || {} }; }), routes: routes.map(function (r) { return { inputId: r.inputId, outputId: r.outputId, enabled: r.enabled, @@ -1056,6 +1290,10 @@ function usableDevices(list) { return out; } +function savedParams(p) { + return (p && typeof p === "object" && !Array.isArray(p)) ? p : {}; +} + function restoreV2(cfg) { const savedInputs = usableDevices(cfg.inputs); const savedOutputs = usableDevices(cfg.outputs); @@ -1072,18 +1310,23 @@ function restoreV2(cfg) { scaleX: (si.scaleX === undefined) ? 1 : si.scaleX, scaleY: (si.scaleY === undefined) ? 1 : si.scaleY, scaleZ: (si.scaleZ === undefined) ? 1 : si.scaleZ, + params: savedParams(si.params), listening: false, error: "", - udp: null, osc: null, admState: {} + udp: null, osc: null, rt: null }; inputs.push(inp); if (inp.enabled) openInput(inp); } + // A protocol whose script is missing keeps its id and params, so the + // device comes back whole once the script does. for (let so of savedOutputs) { const dev = { id: so.id, name: so.name, host: so.host, - port: so.port, protocol: so.protocol, udp: null + port: so.port, protocol: so.protocol, + params: savedParams(so.params), + udp: null, rt: null }; openOutputSocket(dev); outputs.push(dev); diff --git a/qml/spm/Handlers.js b/qml/spm/Handlers.js new file mode 100644 index 0000000..b770bfc --- /dev/null +++ b/qml/spm/Handlers.js @@ -0,0 +1,305 @@ +.pragma library +.import "handlers/Spm.js" as Spm +.import "handlers/SpatGRIS.js" as SpatGRISHandler +.import "handlers/AdmOsc.js" as AdmOscHandler +.import "handlers/SpatRevolution.js" as SpatRevolutionHandler + +// Protocol handler registry. +// +// A handler is a descriptor (see docs/HANDLERS.md): +// +// { apiVersion: 1, id, name, defaultPort?, params?, matches?, +// createInput?(ctx), createOutput?(ctx), native?: { prefix, source(address) } } +// +// The bundled ones are registered at load; user scripts are compiled from +// source and registered on top. A `.pragma library`, so the engine, the +// window and the tests share one registry. + +var PARAM_TYPES = { "int": true, "real": true, "bool": true, "string": true, "enum": true }; + +// Registration order is the order "Auto" tries decoders in, and the order the +// protocol menus list them in: bundled first. +var g_list = []; +var g_byId = {}; + +// [{ id, url, error }] for every user script, loaded or not. +var g_scripts = []; + +function get(id) { + return g_byId.hasOwnProperty(id) ? g_byId[id] : null; +} + +function all() { + return g_list; +} + +function isBuiltin(id) { + const d = get(id); + return d !== null && d.builtin === true; +} + +// "" when usable, otherwise why not. +function validate(d) { + if (!d || typeof d !== "object" || Object.keys(d).length === 0) + return "the script does not export a handler object (module.exports = { … })"; + if (d.apiVersion !== Spm.API_VERSION) + return "unsupported apiVersion " + d.apiVersion + " (expected " + Spm.API_VERSION + ")"; + if (typeof d.id !== "string" || d.id.length === 0) return "missing id"; + if (d.id === "Auto") return "\"Auto\" is reserved"; + if (d.name !== undefined && typeof d.name !== "string") return "name must be a string"; + const hasIn = typeof d.createInput === "function"; + const hasOut = typeof d.createOutput === "function"; + if (!hasIn && !hasOut) return "defines neither createInput nor createOutput"; + if (d.createInput !== undefined && !hasIn) return "createInput must be a function"; + if (d.createOutput !== undefined && !hasOut) return "createOutput must be a function"; + if (d.matches !== undefined && (typeof d.matches !== "string" || d.matches[0] !== "/")) + return "matches must be an address prefix starting with /"; + if (d.defaultPort !== undefined + && (typeof d.defaultPort !== "number" || d.defaultPort < 1 || d.defaultPort > 65535)) + return "defaultPort must be between 1 and 65535"; + if (d.native !== undefined) { + if (!d.native || typeof d.native.prefix !== "string" || d.native.prefix[0] !== "/") + return "native.prefix must be an address prefix starting with /"; + if (d.native.source !== undefined && typeof d.native.source !== "function") + return "native.source must be a function"; + } + if (d.params !== undefined) { + if (!Array.isArray(d.params)) return "params must be an array"; + const seen = {}; + for (let p of d.params) { + if (!p || typeof p.key !== "string" || p.key.length === 0) return "a param has no key"; + if (seen[p.key]) return "duplicate param " + p.key; + seen[p.key] = true; + if (!PARAM_TYPES[p.type]) return "param " + p.key + " has unknown type " + p.type; + if (p.type === "enum" && (!Array.isArray(p.options) || p.options.length === 0)) + return "enum param " + p.key + " needs options"; + } + } + return ""; +} + +// Adds or replaces. Bundled handlers cannot be replaced by a script. +function register(d, url) { + const err = validate(d); + if (err !== "") return err; + + const existing = get(d.id); + if (existing !== null && existing.builtin) + return "id \"" + d.id + "\" is a built-in protocol"; + if (existing !== null && existing.url !== url) + return "id \"" + d.id + "\" is already used by " + existing.url; + + const entry = { + id: d.id, + name: d.name || d.id, + builtin: url === null, + url: url, + descriptor: d, + defaultPort: d.defaultPort || 8000, + params: d.params || [], + hasInput: typeof d.createInput === "function", + hasOutput: typeof d.createOutput === "function", + matches: d.matches || null, + native: d.native || null + }; + + if (existing !== null) { + g_list[g_list.indexOf(existing)] = entry; + } else { + g_list.push(entry); + } + g_byId[d.id] = entry; + return ""; +} + +function unregister(id) { + const e = get(id); + if (e === null || e.builtin) return false; + g_list.splice(g_list.indexOf(e), 1); + delete g_byId[id]; + return true; +} + +// ----- User scripts ------------------------------------------------------ // +// A script is a CommonJS-style body: it assigns `module.exports`. It runs with +// `spm` in scope and with `Qt`, `XMLHttpRequest` and `Protocols` shadowed. +// That keeps handlers from reaching into the app by accident -- and keeps them +// movable to another thread later -- but it is not a security boundary. +// +// Compiled with an indirect eval rather than `new Function`: V4 then reports +// syntax and runtime errors at the script's own line numbers ("eval code:N"), +// which the editor points at. The wrapper's header shares line 1 with the +// script, so the numbers need no adjustment. +var g_eval = eval; + +function compile(source) { + const module = { exports: {} }; + let factory; + try { + factory = g_eval("(function (module, exports, spm, Qt, XMLHttpRequest, Protocols) {\"use strict\";" + + source + "\n})"); + } catch (e) { + return { descriptor: null, error: describeError(e), line: scriptLine(e) }; + } + try { + factory(module, module.exports, Spm.api, undefined, undefined, undefined); + } catch (e) { + return { descriptor: null, error: describeError(e), line: scriptLine(e) }; + } + return { descriptor: module.exports, error: "", line: -1 }; +} + +// The line in a user script an error comes from, or -1. +function scriptLine(e) { + if (!e) return -1; + const m = /eval code:(\d+)/.exec(String(e.stack || "")); + if (m) return parseInt(m[1]); + return -1; +} + +function describeError(e) { + const line = scriptLine(e); + return String(e) + (line > 0 ? " (line " + line + ")" : ""); +} + +function scriptFor(url) { + for (let s of g_scripts) + if (s.url === url) return s; + return null; +} + +// Compiles and registers the source of the script at `url`, replacing what it +// registered before. Returns the error, or "". +// +// On failure the version registered before, if any, stays registered: an edit +// that does not compile leaves the running handler alone. +function loadScript(url, source) { + let s = scriptFor(url); + if (s === null) { + s = { id: "", url: url, error: "", line: -1 }; + g_scripts.push(s); + } + + const c = compile(source); + let err = c.error; + if (err === "") err = validate(c.descriptor); + if (err === "" && s.id !== "" && s.id !== c.descriptor.id) + unregister(s.id); + if (err === "") err = register(c.descriptor, url); + + s.error = err; + s.line = c.line; + if (err === "") + s.id = c.descriptor.id; + return err; +} + +function scriptFailed(url, error) { + let s = scriptFor(url); + if (s === null) { + s = { id: "", url: url, error: "", line: -1 }; + g_scripts.push(s); + } + s.error = error; + s.line = -1; +} + +function removeScript(url) { + const s = scriptFor(url); + if (s === null) return ""; + g_scripts.splice(g_scripts.indexOf(s), 1); + if (s.id !== "" && get(s.id) !== null && get(s.id).url === url) { + unregister(s.id); + return s.id; + } + return ""; +} + +function scripts() { + return g_scripts; +} + +// ----- Params ------------------------------------------------------------ // + +function coerceParam(p, v) { + switch (p.type) { + case "int": { + let n = parseInt(v); + if (isNaN(n)) return undefined; + if (typeof p.min === "number") n = Math.max(p.min, n); + if (typeof p.max === "number") n = Math.min(p.max, n); + return n; + } + case "real": { + let n = parseFloat(v); + if (isNaN(n)) return undefined; + if (typeof p.min === "number") n = Math.max(p.min, n); + if (typeof p.max === "number") n = Math.min(p.max, n); + return n; + } + case "bool": + return v === true || v === "true" || v === 1; + case "string": + return (v === null || v === undefined) ? undefined : String(v); + case "enum": + return p.options.indexOf(v) >= 0 ? v : undefined; + } + return undefined; +} + +function defaultOf(p) { + if (p.default !== undefined) { + const v = coerceParam(p, p.default); + if (v !== undefined) return v; + } + switch (p.type) { + case "int": case "real": return typeof p.min === "number" ? p.min : 0; + case "bool": return false; + case "string": return ""; + case "enum": return p.options[0]; + } + return null; +} + +// The values a device runs with: the saved ones that are still valid, and +// defaults for the rest. Keys the handler no longer declares are kept, so a +// script that briefly drops a param does not erase the setting. +function resolveParams(entry, saved) { + const out = {}; + if (saved && typeof saved === "object") + for (let k in saved) out[k] = saved[k]; + if (entry === null) return out; + for (let p of entry.params) { + const v = (saved && saved.hasOwnProperty(p.key)) ? coerceParam(p, saved[p.key]) : undefined; + out[p.key] = (v === undefined) ? defaultOf(p) : v; + } + return out; +} + +// ----- Input tables ------------------------------------------------------ // +// What createInput returns: functions keyed by address pattern, plus an +// optional "*" for anything else under the handler's `matches` prefix. +function compileInputTable(table) { + const patterns = []; + let fallback = null; + for (let key in table) { + const fn = table[key]; + if (typeof fn !== "function") continue; + if (key === "*") fallback = fn; + else if (key[0] === "/") patterns.push({ p: Spm.compilePattern(key), fn: fn }); + } + return { patterns: patterns, fallback: fallback }; +} + +// ----- Bundled ----------------------------------------------------------- // + +function registerBuiltins() { + for (let d of [SpatGRISHandler.module.exports, + AdmOscHandler.module.exports, + SpatRevolutionHandler.module.exports]) { + const err = register(d, null); + if (err !== "") console.log("Bundled handler", d && d.id, "rejected:", err); + } +} + +registerBuiltins(); diff --git a/qml/spm/Views/HandlersView.qml b/qml/spm/Views/HandlersView.qml new file mode 100644 index 0000000..d4c5387 --- /dev/null +++ b/qml/spm/Views/HandlersView.qml @@ -0,0 +1,187 @@ +import QtQuick +import QtQuick.Controls.Basic +import QtQuick.Dialogs +import QtQuick.Layouts +import ca.qc.sat.qmlcomponents +import spm + +// Protocol handlers: the bundled ones, user scripts on disk, and inline +// scripts written in the editor here. See docs/HANDLERS.md for writing one. +Pane { + id: root + + required property var controller + + // The editor replaces the list while a script is open. + property bool editing: false + + function openEditor(url, name) { + root.controller.handlerScriptSource(url, function (source, error) { + editor.load(url, name, source, error); + root.editing = true; + }); + } + + padding: 0 + background: Rectangle { color: Theme.backgroundColor } + + FileDialog { + id: scriptDialog + title: "Add a handler script" + nameFilters: ["JavaScript (*.js)", "All files (*)"] + onAccepted: root.controller.addHandlerScript(selectedFile.toString()) + } + + ScriptEditor { + id: editor + anchors.fill: parent + anchors.margins: Theme.padding + visible: root.editing + + onApplyRequested: text => root.controller.applyHandlerScript( + editor.url, text, function (result) { + editor.applied(result, text); + editor.title = root.controller.handlerScriptName(editor.url); + }) + onCloseRequested: root.editing = false + } + + ColumnLayout { + visible: !root.editing + anchors.fill: parent + anchors.margins: Theme.padding + spacing: Theme.spacing + + SectionHeader { text: "Protocol handlers" } + + Rectangle { + Layout.fillWidth: true + Layout.fillHeight: true + color: Theme.backgroundColor + border.color: Theme.borderColor + border.width: 1 + radius: Theme.borderRadius + clip: true + + ListView { + id: handlerList + anchors.fill: parent + anchors.margins: 1 + clip: true + model: root.controller.handlerListModel + + delegate: Rectangle { + id: row + required property var model + + width: ListView.view ? ListView.view.width : 0 + implicitHeight: 48 + color: Theme.backgroundColorSecondary + + Rectangle { + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + height: 1 + color: Theme.separatorColor + } + + RowLayout { + anchors.fill: parent + anchors.leftMargin: Theme.spacing * 2 + anchors.rightMargin: Theme.spacing * 2 + spacing: Theme.spacing + + CustomLabel { + Layout.preferredWidth: 200 + text: row.model.name + font.bold: true + elide: Text.ElideRight + } + + CustomLabel { + Layout.preferredWidth: 70 + text: row.model.directions + color: Theme.textColorSecondary + font.pixelSize: Theme.fontSizeSmall + } + + CustomLabel { + Layout.fillWidth: true + text: row.model.error !== "" ? row.model.error + : row.model.builtin ? "Built-in" + : row.model.inline ? "Stored in the app" : row.model.url + color: row.model.error !== "" ? Theme.errorColor : Theme.textColorSecondary + font.pixelSize: Theme.fontSizeSmall + elide: row.model.error !== "" ? Text.ElideRight : Text.ElideLeft + + ToolTip.visible: !row.model.builtin && textHover.hovered + ToolTip.text: row.model.error !== "" + ? row.model.url + "\n" + row.model.error : row.model.url + HoverHandler { id: textHover } + } + + RowButton { + Layout.preferredWidth: Columns.action + visible: !row.model.builtin + text: "Edit" + onClicked: root.openEditor(row.model.url, row.model.name) + } + + RowButton { + Layout.preferredWidth: Columns.action + // An inline script has no file to re-read. + visible: !row.model.builtin && !row.model.inline + text: "Reload" + onClicked: root.controller.reloadHandlerScript(row.model.url) + } + + RowButton { + Layout.preferredWidth: Columns.action + visible: !row.model.builtin + text: "Remove" + onClicked: root.controller.removeHandlerScript(row.model.url) + } + } + } + } + } + + RowLayout { + Layout.fillWidth: true + spacing: Theme.spacing + + CustomButton { + Layout.preferredWidth: 130 + height: Theme.buttonHeight + text: "+ New script" + onClicked: { + const url = root.controller.newHandlerScript(); + root.openEditor(url, root.controller.handlerScriptName(url)); + } + } + + CustomButton { + Layout.preferredWidth: 130 + height: Theme.buttonHeight + text: "+ Open file" + onClicked: scriptDialog.open() + } + + CustomButton { + Layout.preferredWidth: 110 + height: Theme.buttonHeight + text: "Reload all" + onClicked: root.controller.reloadHandlerScripts() + } + + CustomLabel { + Layout.fillWidth: true + text: "Devices on a script's protocol pick up changes on reload." + color: Theme.textColorSecondary + font.pixelSize: Theme.fontSizeSmall + elide: Text.ElideRight + } + } + } +} diff --git a/qml/spm/Views/MatrixView.qml b/qml/spm/Views/MatrixView.qml index 0737bc3..6b0c4d6 100644 --- a/qml/spm/Views/MatrixView.qml +++ b/qml/spm/Views/MatrixView.qml @@ -136,7 +136,7 @@ Pane { ToolTip.visible: headerHover.hovered ToolTip.delay: 400 - ToolTip.text: model.name + "\n" + model.protocol + ToolTip.text: model.name + "\n" + model.protocolName + ", " + model.host + ":" + model.port HoverHandler { id: headerHover } diff --git a/qml/spm/Views/RoutingView.qml b/qml/spm/Views/RoutingView.qml index fa76405..3d8340a 100644 --- a/qml/spm/Views/RoutingView.qml +++ b/qml/spm/Views/RoutingView.qml @@ -24,6 +24,23 @@ Pane { inputId, { scaleX: x, scaleY: y, scaleZ: z }) } + ParamsDialog { + id: paramsDialog + parent: Overlay.overlay + anchors.centerIn: parent + + property string kind: "" + property int deviceId: -1 + onParamsAccepted: values => root.controller.setDeviceParams(kind, deviceId, values) + } + + function editParams(kind, id, name) { + paramsDialog.kind = kind; + paramsDialog.deviceId = id; + const p = root.controller.deviceParams(kind, id); + paramsDialog.editParams(name, p.specs, p.values); + } + RouteEditDialog { id: routeDialog parent: Overlay.overlay @@ -88,7 +105,9 @@ Pane { protocol: model.protocol listening: model.enabled error: model.error - protocols: root.controller.inputProtocols + protocolIds: root.controller.inputProtocolIds + protocolNames: root.controller.inputProtocolNames + hasParams: model.hasParams scaleX: model.scaleX scaleY: model.scaleY scaleZ: model.scaleZ @@ -102,6 +121,7 @@ Pane { onPortEdited: value => root.controller.updateInput(model.inputId, { port: value }) onProtocolEdited: value => root.controller.updateInput(model.inputId, { protocol: value }) onRemoveRequested: root.controller.removeInput(model.inputId) + onParamsEditRequested: root.editParams("input", model.inputId, model.name) onScaleEditRequested: { scaleDialog.inputId = model.inputId; scaleDialog.editScale(model.name, model.scaleX, @@ -179,7 +199,10 @@ Pane { host: model.host port: model.port protocol: model.protocol - protocols: root.controller.outputProtocols + protocolIds: root.controller.outputProtocolIds + protocolNames: root.controller.outputProtocolNames + hasParams: model.hasParams + error: model.error routed: model.routed sourceOffset: model.sourceOffset srcMin: model.srcMin @@ -191,6 +214,7 @@ Pane { onPortEdited: value => root.controller.updateOutput(model.outputId, { port: value }) onProtocolEdited: value => root.controller.updateOutput(model.outputId, { protocol: value }) onRemoveRequested: root.controller.removeOutput(model.outputId) + onParamsEditRequested: root.editParams("output", model.outputId, model.name) onRouteEditRequested: { routeDialog.outputId = model.outputId; routeDialog.editRoute( @@ -213,15 +237,37 @@ Pane { Layout.fillWidth: true spacing: Theme.spacing + // Bundled protocols get a button each; scripted ones share a menu. Repeater { - model: root.controller.outputProtocols + model: root.controller.builtinOutputChoices CustomButton { required property var modelData Layout.preferredWidth: 160 height: Theme.buttonHeight - text: "+ " + modelData - onClicked: root.controller.addOutput(modelData) + text: "+ " + modelData.name + onClicked: root.controller.addOutput(modelData.id) + } + } + + CustomButton { + id: scriptedButton + Layout.preferredWidth: 160 + height: Theme.buttonHeight + visible: root.controller.scriptedOutputChoices.length > 0 + text: "+ Scripted…" + onClicked: scriptedMenu.popup(scriptedButton, 0, scriptedButton.height) + + Menu { + id: scriptedMenu + Repeater { + model: root.controller.scriptedOutputChoices + MenuItem { + required property var modelData + text: modelData.name + onTriggered: root.controller.addOutput(modelData.id) + } + } } } diff --git a/qml/spm/handlers/AdmOsc.js b/qml/spm/handlers/AdmOsc.js new file mode 100644 index 0000000..b97e101 --- /dev/null +++ b/qml/spm/handlers/AdmOsc.js @@ -0,0 +1,211 @@ +.pragma library +.import "Spm.js" as Spm + +// ADM-OSC (v1.0): /adm/obj/{n}/…, in both directions. +var spm = Spm.api; +var module = { exports: null }; + +// "/adm/obj/" is 9 characters. +var OBJ = "/adm/obj/"; + +// /adm/obj/{n}/… carries a source index; /adm/lis and /adm/env do not. +// Returns the index and where the "/…" after it starts, or -1. +function objIndexEnd(address) { + if (!address.startsWith(OBJ)) return -1; + let i = OBJ.length; + while (i < address.length) { + const c = address.charCodeAt(i); + if (c < 48 || c > 57) break; + i++; + } + if (i === OBJ.length || i >= address.length || address.charCodeAt(i) !== 47 /* / */) + return -1; + return i; +} + +module.exports = { + apiVersion: 1, + id: "ADM-OSC", + name: "ADM-OSC", + defaultPort: 9000, + matches: OBJ, + + // ADM-specific features (gain, mute, name, /adm/lis, /adm/env, …) have no + // place in the event model. Whatever no decoder turned into an event, on + // any input, still reaches ADM-OSC outputs verbatim. + native: { + prefix: "/adm/", + source: function (address) { + const end = objIndexEnd(address); + return end < 0 ? -1 : spm.indexAt(address, OBJ.length); + } + }, + + // ADM sends per-parameter messages, not atomic packets, so each input keeps + // per-source state and emits a complete position on every update. The last + // coordinate family written decides spherical or cartesian downstream. + createInput: function (ctx) { + const sources = []; + + function get(n) { + let s = sources[n]; + if (s === undefined) { + // Defaults per ADM-OSC spec: azim, elev 0; dist 1 (on the + // reference sphere); xyz 0; w 0. + s = { azim: 0, elev: 0, dist: 1.0, x: 0, y: 0, z: 0, w: 0, polar: false }; + sources[n] = s; + } + return s; + } + + // ADM azimuth is +90 = left, SpatGRIS -90 = left: the sign flips. + // Cartesian axes match (x = L/R, y = B/F, z = D/U), so xyz passes + // through. ADM has no vertical extent, so vspan is 0. + function emit(n, s, ev) { + ev.type = spm.POSITION; + ev.source = n; + ev.hspan = s.w; + ev.vspan = 0; + if (s.polar) { + ev.coords = spm.SPHERICAL; + ev.radians = false; + ev.azimuth = -s.azim; + ev.elevation = s.elev; + ev.distance = s.dist; + } else { + ev.coords = spm.CARTESIAN; + ev.x = s.x; ev.y = s.y; ev.z = s.z; + } + return true; + } + + // A short payload returns false; the message is then forwarded verbatim. + return { + "/adm/obj/{n}/aed": function (n, a, ev) { + if (!a || a.length < 3) return false; + const s = get(n); + s.azim = a[0]; s.elev = a[1]; s.dist = a[2]; s.polar = true; + return emit(n, s, ev); + }, + "/adm/obj/{n}/azim": function (n, a, ev) { + if (!a || a.length < 1) return false; + const s = get(n); + s.azim = a[0]; s.polar = true; + return emit(n, s, ev); + }, + "/adm/obj/{n}/elev": function (n, a, ev) { + if (!a || a.length < 1) return false; + const s = get(n); + s.elev = a[0]; s.polar = true; + return emit(n, s, ev); + }, + "/adm/obj/{n}/dist": function (n, a, ev) { + if (!a || a.length < 1) return false; + const s = get(n); + s.dist = a[0]; s.polar = true; + return emit(n, s, ev); + }, + "/adm/obj/{n}/xyz": function (n, a, ev) { + if (!a || a.length < 3) return false; + const s = get(n); + s.x = a[0]; s.y = a[1]; s.z = a[2]; s.polar = false; + return emit(n, s, ev); + }, + "/adm/obj/{n}/xy": function (n, a, ev) { + if (!a || a.length < 2) return false; + const s = get(n); + s.x = a[0]; s.y = a[1]; s.polar = false; + return emit(n, s, ev); + }, + "/adm/obj/{n}/x": function (n, a, ev) { + if (!a || a.length < 1) return false; + const s = get(n); + s.x = a[0]; s.polar = false; + return emit(n, s, ev); + }, + "/adm/obj/{n}/y": function (n, a, ev) { + if (!a || a.length < 1) return false; + const s = get(n); + s.y = a[0]; s.polar = false; + return emit(n, s, ev); + }, + "/adm/obj/{n}/z": function (n, a, ev) { + if (!a || a.length < 1) return false; + const s = get(n); + s.z = a[0]; s.polar = false; + return emit(n, s, ev); + }, + // Width alone doesn't switch coordinate mode. + "/adm/obj/{n}/w": function (n, a, ev) { + if (!a || a.length < 1) return false; + const s = get(n); + s.w = a[0]; + return emit(n, s, ev); + } + }; + }, + + // Out: + // - Azimuth flips sign (SpatGRIS -90 = left, ADM +90 = left); elevation + // does not. + // - Ranges differ: SpatGRIS cartesian is the MBAP extended field + // (+-MBAP_EXTENDED_RADIUS) while ADM-OSC is normalized +-1. Letting the + // receiver clamp (ADM-OSC §9) is NOT position-preserving: every source + // beyond 60% of full scale would collapse onto the wall. The axes are + // scaled instead. Polar radius is a distance, not a bounded axis, and is + // left alone. + // - ADM has no vertical extent; vspan is dropped. ADM /w is normalized + // [0, 1], as is hspan. + // - Packed /aed and /xyz, per the spec's atomicity recommendation. + createOutput: function (ctx) { + const aed = spm.addressTemplate("/adm/obj/{n}/aed"); + const xyz = spm.addressTemplate("/adm/obj/{n}/xyz"); + const w = spm.addressTemplate("/adm/obj/{n}/w"); + const b3 = [0, 0, 0]; + const b1 = [0]; + const zero = [0, 0, 0]; + const k = 1 / spm.MBAP_EXTENDED_RADIUS; + // offset -> (address -> address with the offset index) + const rewritten = new Map(); + + function shifted(address, offset) { + let m = rewritten.get(offset); + if (m === undefined) { m = new Map(); rewritten.set(offset, m); } + let s = m.get(address); + if (s === undefined) { + const end = objIndexEnd(address); + s = OBJ + (spm.indexAt(address, OBJ.length) + offset) + address.substring(end); + if (m.size > 4096) m.clear(); + m.set(address, s); + } + return s; + } + + return { + position: function (ev) { + const n = ev.source; + if (ev.coords === spm.CARTESIAN) { + b3[0] = ev.x * k; b3[1] = ev.y * k; b3[2] = ev.z * k; + ctx.send(xyz(n), b3); + } else { + b3[0] = -ev.azimuth; b3[1] = ev.elevation; b3[2] = ev.distance; + ctx.send(aed(n), b3); + } + b1[0] = ev.hspan; + ctx.send(w(n), b1); + }, + clear: function (ev) { + ctx.send(xyz(ev.source), zero); + }, + // ADM has no algorithm concept: no algorithm() method. + native: function (ev) { + if (ev.nativeProtocol !== "ADM-OSC") return; + const inSource = ev.source - ev.offset; + const address = (ev.offset !== 0 && inSource >= 0) + ? shifted(ev.nativeAddress, ev.offset) + : ev.nativeAddress; + ctx.send(address, ev.nativeArgs); + } + }; + } +}; diff --git a/qml/spm/handlers/SpatGRIS.js b/qml/spm/handlers/SpatGRIS.js new file mode 100644 index 0000000..16b3d3d --- /dev/null +++ b/qml/spm/handlers/SpatGRIS.js @@ -0,0 +1,159 @@ +.pragma library +.import "Spm.js" as Spm + +// SpatGRIS: /spat/serv, in both directions. +// +// Bundled handlers are written exactly like user scripts; only these two +// lines differ, because a user script receives `module` and `spm` as +// parameters instead. +var spm = Spm.api; +var module = { exports: null }; + +module.exports = { + apiVersion: 1, + id: "SpatGRIS", + name: "SpatGRIS", + defaultPort: 18042, + matches: "/spat/serv", + + createInput: function (ctx) { + const HALF_PI = Math.PI / 2.; + + function decode(n, a, ev, address) { + if (!a || a.length < 2) return false; + + if (typeof a[0] === "number") { + // Legacy format: [sourceIndex, az, el, hspan, vspan, radius, reserved] + // 0-based index, polar in radians, elevation from the zenith. + if (a.length < 7) return false; + ev.type = spm.POSITION; + ev.source = 1 + a[0]; + ev.coords = spm.SPHERICAL; + ev.radians = true; + ev.azimuth = a[1] * spm.DEG; + ev.elevation = (HALF_PI - a[2]) * spm.DEG; + ev.distance = a[5]; + ev.hspan = a[3] / 2.; + ev.vspan = a[4] * 2.; + // SpatGRIS routes the legacy form through a *cylindrical* + // LegacyLbapPosition::toPosition() in Cube/MBAP mode, but "pol" + // through a *spherical* conversion: rewriting legacy to "pol" + // moves the source (az=0, colat=pi/4, dist=1 lands at + // (0,0.707,0.707) instead of (0,1,0.5)). A SpatGRIS output + // re-emits this payload unchanged; every other output uses the + // normalized position above, which is correct for them. + ev.nativeProtocol = "SpatGRIS"; + ev.nativeAddress = address; + ev.nativeArgs = a; + return true; + } + + const idx = (typeof a[1] === "number") ? a[1] : -1; + switch (a[0]) { + case "pol": + case "deg": + if (a.length < 7) return false; + ev.type = spm.POSITION; + ev.source = idx; + ev.coords = spm.SPHERICAL; + ev.radians = (a[0] === "pol"); + ev.azimuth = ev.radians ? a[2] * spm.DEG : a[2]; + ev.elevation = ev.radians ? a[3] * spm.DEG : a[3]; + ev.distance = a[4]; + ev.hspan = a[5]; + ev.vspan = a[6]; + return true; + case "car": + if (a.length < 7) return false; + ev.type = spm.POSITION; + ev.source = idx; + ev.coords = spm.CARTESIAN; + ev.x = a[2]; ev.y = a[3]; ev.z = a[4]; + ev.hspan = a[5]; + ev.vspan = a[6]; + return true; + case "clr": + ev.type = spm.CLEAR; + ev.source = idx; + return true; + case "alg": + ev.type = spm.ALGORITHM; + ev.source = idx; + ev.value = a.length > 2 ? a[2] : null; + return true; + } + // Commands we do not model (e.g. "reset ", sg_OscInput.cpp:186-193) + // still reach SpatGRIS outputs. + ev.type = spm.NATIVE; + ev.source = idx; + ev.nativeProtocol = "SpatGRIS"; + ev.nativeAddress = address; + ev.nativeArgs = a; + return true; + } + + // Everything under the prefix, including the bare address. + return { "*": decode }; + }, + + // The SpatGRIS server speaks /spat/serv natively, so the command goes out + // in the coordinate system it arrived in. No polar<->cartesian round trip. + createOutput: function (ctx) { + const ADDR = "/spat/serv"; + const pos = ["", 0, 0, 0, 0, 0, 0]; + const legacy = []; + const clr = ["clr", 0]; + const alg = ["alg", 0, ""]; + const nat = []; + + return { + position: function (ev) { + // A legacy-form input goes back out in the legacy form, untouched. + // `source` is 1-based (+ any route offset); the wire form is 0-based. + if (ev.nativeProtocol === "SpatGRIS" && typeof ev.nativeArgs[0] === "number") { + const a = ev.nativeArgs; + legacy.length = a.length; + legacy[0] = ev.source - 1; + for (let i = 1; i < a.length; i++) legacy[i] = a[i]; + ctx.send(ADDR, legacy); + return; + } + pos[1] = ev.source; + if (ev.coords === spm.CARTESIAN) { + pos[0] = "car"; + pos[2] = ev.x; pos[3] = ev.y; pos[4] = ev.z; + } else if (ev.radians) { + pos[0] = "pol"; + pos[2] = ev.azimuth * spm.RAD; pos[3] = ev.elevation * spm.RAD; pos[4] = ev.distance; + } else { + pos[0] = "deg"; + pos[2] = ev.azimuth; pos[3] = ev.elevation; pos[4] = ev.distance; + } + pos[5] = ev.hspan; + pos[6] = ev.vspan; + ctx.send(ADDR, pos); + }, + clear: function (ev) { + clr[1] = ev.source; + ctx.send(ADDR, clr); + }, + algorithm: function (ev) { + if (ev.value === null || ev.value === undefined) return; + alg[1] = ev.source; + alg[2] = ev.value; + ctx.send(ADDR, alg); + }, + native: function (ev) { + if (ev.nativeProtocol !== "SpatGRIS") return; + const a = ev.nativeArgs; + if (typeof a[0] !== "string" || a[0].length === 0) return; + // [command, index, ...rest], with the route's index. + nat.length = Math.max(2, a.length); + nat[0] = a[0]; + nat[1] = ev.source; + for (let i = 2; i < a.length; i++) nat[i] = a[i]; + ctx.send(ADDR, nat); + } + }; + } +}; diff --git a/qml/spm/handlers/SpatRevolution.js b/qml/spm/handlers/SpatRevolution.js new file mode 100644 index 0000000..3eb58e2 --- /dev/null +++ b/qml/spm/handlers/SpatRevolution.js @@ -0,0 +1,107 @@ +.pragma library +.import "Spm.js" as Spm + +// SPAT Revolution: /source/{n}/aed, /source/{n}/xyz, /source/{n}/spread. +// +// Azimuth keeps the SpatGRIS sign, radius is a percentage, and spread is one +// figure (a percentage) standing for both extents. +var spm = Spm.api; +var module = { exports: null }; + +module.exports = { + apiVersion: 1, + id: "SPAT Revolution", + name: "SPAT Revolution", + defaultPort: 8088, + matches: "/source/", + + // One parameter per message, so the accumulator lives on the input. + createInput: function (ctx) { + const sources = []; + + function get(n) { + let s = sources[n]; + if (s === undefined) { + s = { azim: 0, elev: 0, dist: 1.0, x: 0, y: 0, z: 0, spread: 0, polar: false }; + sources[n] = s; + } + return s; + } + + function emit(n, s, ev) { + ev.type = spm.POSITION; + ev.source = n; + ev.hspan = s.spread; + ev.vspan = s.spread; + if (s.polar) { + ev.coords = spm.SPHERICAL; + ev.radians = false; + ev.azimuth = s.azim; + ev.elevation = s.elev; + ev.distance = s.dist; + } else { + ev.coords = spm.CARTESIAN; + ev.x = s.x; ev.y = s.y; ev.z = s.z; + } + return true; + } + + // mode/gain/mute and the rest have no equivalent in the event model. + return { + "/source/{n}/aed": function (n, a, ev) { + if (!a || a.length < 3) return false; + const s = get(n); + s.azim = a[0]; s.elev = a[1]; s.dist = a[2] / 100.0; s.polar = true; + return emit(n, s, ev); + }, + "/source/{n}/xyz": function (n, a, ev) { + if (!a || a.length < 3) return false; + const s = get(n); + s.x = a[0]; s.y = a[1]; s.z = a[2]; s.polar = false; + return emit(n, s, ev); + }, + // Spread alone doesn't switch coordinate mode. + "/source/{n}/spread": function (n, a, ev) { + if (!a || a.length < 1) return false; + const s = get(n); + s.spread = a[0] / 100.0; + return emit(n, s, ev); + } + }; + }, + + // Unlike ADM, SPAT cartesian is not rescaled. + createOutput: function (ctx) { + const aed = spm.addressTemplate("/source/{n}/aed"); + const xyz = spm.addressTemplate("/source/{n}/xyz"); + const spread = spm.addressTemplate("/source/{n}/spread"); + const mode = spm.addressTemplate("/source/{n}/mode"); + const b3 = [0, 0, 0]; + const b1 = [0]; + const m1 = [""]; + const zero = [0, 0, 0]; + + return { + position: function (ev) { + const n = ev.source; + if (ev.coords === spm.CARTESIAN) { + b3[0] = ev.x; b3[1] = ev.y; b3[2] = ev.z; + ctx.send(xyz(n), b3); + } else { + b3[0] = ev.azimuth; b3[1] = ev.elevation; b3[2] = ev.distance * 100; + ctx.send(aed(n), b3); + } + b1[0] = (ev.hspan + ev.vspan) / 2 * 100; + ctx.send(spread(n), b1); + }, + clear: function (ev) { + ctx.send(xyz(ev.source), zero); + }, + algorithm: function (ev) { + if (ev.value === null || ev.value === undefined) return; + m1[0] = ev.value === "dome" ? "dome" : "panning"; + ctx.send(mode(ev.source), m1); + } + }; + } +}; diff --git a/qml/spm/handlers/Spm.js b/qml/spm/handlers/Spm.js new file mode 100644 index 0000000..fb8e545 --- /dev/null +++ b/qml/spm/handlers/Spm.js @@ -0,0 +1,210 @@ +.pragma library + +// The `spm` object every protocol handler sees: event constants, coordinate +// helpers and the per-input scaling the engine applies before routing. +// +// The event +// --------- +// One fixed-shape object per input device, allocated once and overwritten by +// every message. Handlers fill it and must not keep a reference to it. +// +// type POSITION | CLEAR | ALGORITHM | NATIVE +// source 1-based source index, or -1 when the message has none +// offset the route's source offset, already added to `source` (encoders) +// coords SPHERICAL | CARTESIAN: the family the sender wrote +// radians the sender wrote its angles in radians (a hint for encoders +// that can re-emit them that way; the fields are always degrees) +// azimuth, elevation degrees, SpatGRIS convention: -90 = left, +90 = up +// distance 1 = the speaker sphere +// x, y, z SpatGRIS units: x right, y front, z up, +-MBAP_EXTENDED_RADIUS +// hspan, vspan extents, 0..1 +// value ALGORITHM payload ("dome" | "cube") +// nativeProtocol, nativeAddress, nativeArgs +// the message as received, for encoders of that same protocol. +// Cleared by the engine whenever it alters the event (scaling). + +var POSITION = 1; +var CLEAR = 2; +var ALGORITHM = 3; +var NATIVE = 4; + +var SPHERICAL = 1; +var CARTESIAN = 2; + +var API_VERSION = 1; + +// SpatGRIS MBAP_EXTENDED_RADIUS (sg_constants.hpp:71) -- the cartesian clamp +// applied by CartesianVector::clampedToFarField (sg_CartesianVector.hpp:182-187). +var MBAP_EXTENDED_RADIUS = 1.6666667; + +var DEG = 180.0 / Math.PI; +var RAD = Math.PI / 180.0; + +function newEvent() { + return { + type: 0, source: -1, offset: 0, + coords: SPHERICAL, radians: false, + azimuth: 0, elevation: 0, distance: 1, + x: 0, y: 0, z: 0, + hspan: 0, vspan: 0, + value: null, + nativeProtocol: null, nativeAddress: null, nativeArgs: null + }; +} + +function clamp(v, lo, hi) { + return v < lo ? lo : (v > hi ? hi : v); +} + +// SpatGRIS convention: azimuth is measured from the front (+y) toward the +// right (+x), elevation from the horizon toward +z. Both write into `out` +// ({x, y, z} / {azimuth, elevation, distance}) so the caller owns the memory. +function sphericalToCartesian(azDeg, elDeg, r, out) { + const az = azDeg * RAD, el = elDeg * RAD; + const ce = Math.cos(el); + out.x = r * ce * Math.sin(az); + out.y = r * ce * Math.cos(az); + out.z = r * Math.sin(el); + return out; +} + +function cartesianToSpherical(x, y, z, out) { + const r = Math.sqrt(x * x + y * y + z * z); + // At the origin there is no direction to report; asin(0/0) would be NaN. + if (r < 1e-12) { + out.azimuth = 0; out.elevation = 0; out.distance = 0; + return out; + } + out.azimuth = Math.atan2(x, y) * DEG; + out.elevation = Math.asin(z / r) * DEG; + out.distance = r; + return out; +} + +// The event in the other family, without touching it. +function toCartesian(ev, out) { + if (ev.coords === CARTESIAN) { + out.x = ev.x; out.y = ev.y; out.z = ev.z; + return out; + } + return sphericalToCartesian(ev.azimuth, ev.elevation, ev.distance, out); +} + +function toSpherical(ev, out) { + if (ev.coords === SPHERICAL) { + out.azimuth = ev.azimuth; out.elevation = ev.elevation; out.distance = ev.distance; + return out; + } + return cartesianToSpherical(ev.x, ev.y, ev.z, out); +} + +// ----- Per-input scaling ------------------------------------------------- // +// A negative factor mirrors that axis. Spherical positions are scaled through +// cartesian and back, so they stay in the family the sender chose. +var g_scratch = { x: 0, y: 0, z: 0, azimuth: 0, elevation: 0, distance: 0 }; + +function isIdentityScale(sx, sy, sz) { + return sx === 1 && sy === 1 && sz === 1; +} + +// In place. Returns false when nothing was changed. +function scaleEvent(ev, sx, sy, sz) { + if (ev.type !== POSITION || isIdentityScale(sx, sy, sz)) + return false; + + if (ev.coords === CARTESIAN) { + ev.x *= sx; ev.y *= sy; ev.z *= sz; + } else { + const p = sphericalToCartesian(ev.azimuth, ev.elevation, ev.distance, g_scratch); + const q = cartesianToSpherical(p.x * sx, p.y * sy, p.z * sz, g_scratch); + ev.azimuth = q.azimuth; ev.elevation = q.elevation; ev.distance = q.distance; + } + // The verbatim form now describes a position the source is no longer at. + ev.nativeProtocol = null; + ev.nativeAddress = null; + ev.nativeArgs = null; + return true; +} + +// ----- Address templates ------------------------------------------------- // +// "/adm/obj/{n}/aed" -> a function n => "/adm/obj/7/aed" that builds each +// string once and then hands back the cached one. +function addressTemplate(pattern) { + const at = pattern.indexOf("{n}"); + if (at < 0) + return function () { return pattern; }; + const head = pattern.substring(0, at); + const tail = pattern.substring(at + 3); + const cache = []; + return function (n) { + if (n >= 0 && n < 65536 && (n | 0) === n) { + let s = cache[n]; + if (s === undefined) { + s = head + n + tail; + cache[n] = s; + } + return s; + } + return head + n + tail; + }; +} + +// ----- Address patterns -------------------------------------------------- // +// Input handlers key their functions by pattern: literal segments plus at most +// one "{n}" segment, a decimal source index. Compiled once per pattern, then +// matched by scanning characters: no regex, no split, no allocation. +function compilePattern(pattern) { + const at = pattern.indexOf("{n}"); + if (at < 0) + return { pattern: pattern, head: pattern, tail: null }; + return { pattern: pattern, head: pattern.substring(0, at), tail: pattern.substring(at + 3) }; +} + +// The source index the address carries for this pattern, -1 when the pattern +// has none, or -2 when the address does not match. +function matchPattern(p, address) { + if (p.tail === null) + return address === p.head ? -1 : -2; + + const headLen = p.head.length; + const tailLen = p.tail.length; + if (address.length <= headLen + tailLen) return -2; + if (!address.startsWith(p.head)) return -2; + if (!address.endsWith(p.tail)) return -2; + + const end = address.length - tailLen; + let n = 0; + for (let i = headLen; i < end; i++) { + const c = address.charCodeAt(i) - 48; + if (c < 0 || c > 9) return -2; + n = n * 10 + c; + } + return n; +} + +// The leading run of digits in `address` from `start`, or -1. +function indexAt(address, start) { + let n = 0, i = start; + for (; i < address.length; i++) { + const c = address.charCodeAt(i) - 48; + if (c < 0 || c > 9) break; + n = n * 10 + c; + } + return i === start ? -1 : n; +} + +// What handlers get as `spm`. Frozen so one script cannot break another. +var api = Object.freeze({ + POSITION: POSITION, CLEAR: CLEAR, ALGORITHM: ALGORITHM, NATIVE: NATIVE, + SPHERICAL: SPHERICAL, CARTESIAN: CARTESIAN, + API_VERSION: API_VERSION, + MBAP_EXTENDED_RADIUS: MBAP_EXTENDED_RADIUS, + DEG: DEG, RAD: RAD, + clamp: clamp, + sphericalToCartesian: sphericalToCartesian, + cartesianToSpherical: cartesianToSpherical, + toCartesian: toCartesian, + toSpherical: toSpherical, + addressTemplate: addressTemplate, + indexAt: indexAt +}); diff --git a/qml/spm/handlers/Template.js b/qml/spm/handlers/Template.js new file mode 100644 index 0000000..7a6f1b0 --- /dev/null +++ b/qml/spm/handlers/Template.js @@ -0,0 +1,95 @@ +.pragma library + +// The script "+ New script" starts from. __ID__ and __NAME__ are filled in +// with a free id and name. It lives in a template literal, so it must not +// contain backticks or dollar-brace sequences (tst_handlers checks it compiles). + +var source = `// __NAME__: a starting point for your own protocol handler. +// +// As written, it speaks //xyz with x y z floats, in both directions. +// Edit it, press Apply (Ctrl+S), then pick "__NAME__" as an input's or +// output's protocol; the "..." button next to the protocol edits the settings +// declared in params below. +// +// See docs/HANDLERS.md for the full API. In short: +// - module.exports is the handler descriptor; +// - spm holds the constants and helpers; +// - nothing on the per-message path should allocate: build buffers and +// address templates up front and reuse them. + +module.exports = { + apiVersion: 1, + id: "__ID__", // unique; devices remember their protocol by it + name: "__NAME__", + defaultPort: 9100, + + // No "matches": the address depends on a setting, so this handler is not + // offered to "Auto" inputs. A fixed prefix ("/source/") would be. + + params: [ + { key: "root", label: "Address root", type: "string", default: "/obj" }, + { key: "range", label: "Field half-width", type: "real", default: 1.0, min: 0.001 }, + { key: "zeroBased", label: "Zero-based indices", type: "bool", default: false } + ], + + createInput: function (ctx) { + const p = ctx.params; + const shift = p.zeroBased ? 1 : 0; + const k = spm.MBAP_EXTENDED_RADIUS / p.range; + + // The table's keys are address patterns; {n} is the source index. + // Settings changes rebuild the instance (no onParamsChanged here), so + // the pattern can depend on them. + const table = {}; + table[p.root + "/{n}/xyz"] = function (n, args, ev) { + if (!args || args.length < 3) return false; + ev.type = spm.POSITION; + ev.source = n + shift; + ev.coords = spm.CARTESIAN; + ev.x = args[0] * k; + ev.y = args[1] * k; + ev.z = args[2] * k; + return true; // route it; false drops the message + }; + return table; + }, + + createOutput: function (ctx) { + const p = ctx.params; + let address, k, shift; + const xyz = [0, 0, 0]; + const cart = { x: 0, y: 0, z: 0 }; + + // Live settings: the instance stays, only what depends on them changes. + function configure() { + address = spm.addressTemplate(p.root + "/{n}/xyz"); + k = p.range / spm.MBAP_EXTENDED_RADIUS; + shift = p.zeroBased ? 1 : 0; + } + configure(); + + return { + onParamsChanged: configure, + + position: function (ev) { + // Whatever family the sender used, this protocol wants cartesian. + spm.toCartesian(ev, cart); + xyz[0] = spm.clamp(cart.x * k, -p.range, p.range); + xyz[1] = spm.clamp(cart.y * k, -p.range, p.range); + xyz[2] = spm.clamp(cart.z * k, -p.range, p.range); + ctx.send(address(ev.source - shift), xyz); + }, + + clear: function (ev) { + xyz[0] = 0; xyz[1] = 0; xyz[2] = 0; + ctx.send(address(ev.source - shift), xyz); + } + // No algorithm() or native(): those events are skipped for this output. + }; + } +}; +`; + +function make(id, name) { + return source.split("__ID__").join(id).split("__NAME__").join(name); +} diff --git a/qml/spm/qmldir b/qml/spm/qmldir index 589b0c2..29cbdf3 100644 --- a/qml/spm/qmldir +++ b/qml/spm/qmldir @@ -4,6 +4,7 @@ module spm RoutingView Views/RoutingView.qml MatrixView Views/MatrixView.qml MonitorView Views/MonitorView.qml +HandlersView Views/HandlersView.qml # Presentation-only building blocks singleton Columns Components/Columns.qml @@ -16,4 +17,7 @@ RowButton Components/RowButton.qml InputRow Components/InputRow.qml RouteEditDialog Components/RouteEditDialog.qml InputScaleDialog Components/InputScaleDialog.qml +ParamsDialog Components/ParamsDialog.qml +ProtocolCell Components/ProtocolCell.qml +ScriptEditor Components/ScriptEditor.qml MatrixCell Components/MatrixCell.qml diff --git a/tests/LegacyApi.js b/tests/LegacyApi.js new file mode 100644 index 0000000..1e35172 --- /dev/null +++ b/tests/LegacyApi.js @@ -0,0 +1,188 @@ +.import "../qml/spm/Engine.js" as Engine +.import "../qml/spm/Handlers.js" as Handlers +.import "../qml/spm/handlers/Spm.js" as Spm + +// The parse/map functions Engine.js had before protocols became handlers, +// rebuilt on top of the handlers and the engine's own address resolution. +// +// tst_engine.qml pinned the wire output of every protocol path through these +// signatures. Keeping them lets the same assertions run, unchanged, against +// the handler implementation: if a handler drifts, those tests fail. +// +// norm: { command: "pol" | "deg" | "car" | "clr" | "alg" | , +// sourceIndex, args, legacyArgs? } + +// ----- Event <-> command form -------------------------------------------- // + +function toLegacy(ev) { + let n = null; + switch (ev.type) { + case Spm.POSITION: + if (ev.coords === Spm.CARTESIAN) { + n = { command: "car", args: [ev.x, ev.y, ev.z, ev.hspan, ev.vspan] }; + } else if (ev.radians) { + n = { command: "pol", args: [ev.azimuth * Spm.RAD, ev.elevation * Spm.RAD, + ev.distance, ev.hspan, ev.vspan] }; + } else { + n = { command: "deg", args: [ev.azimuth, ev.elevation, ev.distance, ev.hspan, ev.vspan] }; + } + if (ev.nativeProtocol === "SpatGRIS" && typeof ev.nativeArgs[0] === "number") + n.legacyArgs = ev.nativeArgs.slice(1); + break; + case Spm.CLEAR: + n = { command: "clr", args: [] }; + break; + case Spm.ALGORITHM: + n = { command: "alg", args: ev.value === null ? [] : [ev.value] }; + break; + case Spm.NATIVE: + // Only SpatGRIS used to parse a command it did not model. + if (ev.nativeProtocol !== "SpatGRIS") return null; + n = { command: ev.nativeArgs[0], args: ev.nativeArgs.slice(2) }; + break; + default: + return null; + } + n.sourceIndex = ev.source; + return n; +} + +function fromLegacy(command, idx, args, norm) { + const ev = Spm.newEvent(); + ev.source = idx; + switch (command) { + case "pol": + case "deg": + if (args.length < 5) return null; + ev.type = Spm.POSITION; + ev.coords = Spm.SPHERICAL; + ev.radians = (command === "pol"); + ev.azimuth = ev.radians ? args[0] * Spm.DEG : args[0]; + ev.elevation = ev.radians ? args[1] * Spm.DEG : args[1]; + ev.distance = args[2]; + ev.hspan = args[3]; + ev.vspan = args[4]; + break; + case "car": + if (args.length < 5) return null; + ev.type = Spm.POSITION; + ev.coords = Spm.CARTESIAN; + ev.x = args[0]; ev.y = args[1]; ev.z = args[2]; + ev.hspan = args[3]; + ev.vspan = args[4]; + break; + case "clr": + ev.type = Spm.CLEAR; + break; + case "alg": + ev.type = Spm.ALGORITHM; + ev.value = args.length > 0 ? args[0] : null; + break; + default: + ev.type = Spm.NATIVE; + ev.nativeProtocol = "SpatGRIS"; + ev.nativeAddress = "/spat/serv"; + ev.nativeArgs = [command, idx].concat(args); + } + if (norm && norm.legacyArgs && ev.type === Spm.POSITION) { + ev.nativeProtocol = "SpatGRIS"; + ev.nativeAddress = "/spat/serv"; + ev.nativeArgs = [norm.sourceIndex - 1].concat(norm.legacyArgs); + } + return ev; +} + +// ----- Parsing ----------------------------------------------------------- // + +// The runtime for one protocol on one test input. The handlers keep their +// accumulators per instance, so a fresh input object means fresh state. +function runtimeFor(inp, protocol) { + if (!inp.__legacy) inp.__legacy = {}; + let shadow = inp.__legacy[protocol]; + if (!shadow) { + shadow = { id: inp.id, name: inp.name, protocol: protocol, params: {}, rt: null }; + inp.__legacy[protocol] = shadow; + } + return shadow.rt || Engine.attachInput(shadow); +} + +function decodeWith(rt, address, value) { + let e = rt.cache.get(address); + if (e === undefined) e = Engine.resolveAddress(rt, address); + Engine.resetEvent(rt.ev); + if (e.fn === null) return null; + return e.fn(e.n, value, rt.ev, address) === true ? toLegacy(rt.ev) : null; +} + +function parseSpatGRISInput(value) { + return decodeWith(runtimeFor({ id: 0, name: "legacy" }, "SpatGRIS"), "/spat/serv", value); +} + +function parseADMInput(inp, address, value) { + return decodeWith(runtimeFor(inp, "ADM-OSC"), address, value); +} + +function parseSPATInput(inp, address, value) { + return decodeWith(runtimeFor(inp, "SPAT Revolution"), address, value); +} + +// The input's own protocol, "Auto" included, resolved exactly as the engine does. +function parseInput(inp, address, value) { + return decodeWith(runtimeFor(inp, inp.protocol || "Auto"), address, value); +} + +// ----- Scaling ----------------------------------------------------------- // + +// Returns the message unchanged when the scale is identity or it carries no +// position. +function applyInputScale(inp, norm) { + if (!norm || !Engine.inputHasScale(inp)) return norm; + if (norm.command !== "pol" && norm.command !== "deg" && norm.command !== "car") return norm; + const ev = fromLegacy(norm.command, norm.sourceIndex, norm.args, norm); + if (ev === null) return norm; + Spm.scaleEvent(ev, Engine.scaleOf(inp, "scaleX"), Engine.scaleOf(inp, "scaleY"), + Engine.scaleOf(inp, "scaleZ")); + return toLegacy(ev); +} + +// ----- Mapping ----------------------------------------------------------- // + +// What an output of that protocol sends for the message, as +// [{ address, value }]. Values are copied: encoders reuse their buffers. +function mapMessage(command, idx, args, outputType, norm) { + const h = Handlers.get(outputType); + if (h === null || !h.hasOutput) return []; + + const sent = []; + const enc = h.descriptor.createOutput({ + device: { id: 0, name: "legacy" }, + params: Handlers.resolveParams(h, null), + send: function (address, values) { + sent.push({ address: address, value: Array.isArray(values) ? values.slice() : [values] }); + }, + log: function () {}, + error: function () {} + }); + + const ev = fromLegacy(command, idx, args, norm); + if (ev === null) return []; + switch (ev.type) { + case Spm.POSITION: if (enc.position) enc.position(ev); break; + case Spm.CLEAR: if (enc.clear) enc.clear(ev); break; + case Spm.ALGORITHM: if (enc.algorithm) enc.algorithm(ev); break; + case Spm.NATIVE: if (enc.native) enc.native(ev); break; + } + return sent; +} + +function mapForSpatGRIS(command, idx, args, norm) { + return mapMessage(command, idx, args, "SpatGRIS", norm); +} + +function mapForADM(command, idx, args) { + return mapMessage(command, idx, args, "ADM-OSC", null); +} + +function mapForSPAT(command, idx, args) { + return mapMessage(command, idx, args, "SPAT Revolution", null); +} diff --git a/tests/tst_engine.qml b/tests/tst_engine.qml index 5a62ca5..a93f43d 100644 --- a/tests/tst_engine.qml +++ b/tests/tst_engine.qml @@ -1,24 +1,25 @@ // SPDX-License-Identifier: GPL-3.0-or-later // © Société des arts technologiques // -// Characterization tests for the protocol conversion table in Engine.js. +// Characterization tests for the protocol conversion table. // -// These pin the *current* wire output of every parse and map path so the -// routing rework (ids, routes, multi-input, source-id ranges) can be proven -// not to have moved a source. Where the existing behaviour is surprising, the +// These pin the wire output of every parse and map path so the routing rework +// (ids, routes, multi-input, source-id ranges) and then the move to protocol +// handlers can be proven not to have moved a source. The parse/map calls go +// through LegacyApi.js, which rebuilds the pre-handler signatures on top of +// the bundled handlers. Where the existing behaviour is surprising, the // test says so rather than "correcting" it — the point is to detect change, // not to assert an opinion. // // Run: // QT_QPA_PLATFORM=offscreen /usr/lib/qt6/bin/qmltestrunner -import qml -input tests/ // -// Only the pure parse/map functions are exercised. Everything below -// `----- Output device lifecycle -----` in Engine.js touches the QML root -// context (Protocols, appSettings, outputDevices) and is covered by the -// visual pass instead. +// Only the pure parse/map paths are exercised here; tst_dispatch and +// tst_handlers drive the engine's message path with stub sockets. import QtQuick import QtTest import "../qml/spm/Engine.js" as Engine +import "LegacyApi.js" as Legacy TestCase { id: tc @@ -59,17 +60,17 @@ TestCase { ]; } function test_spatgris_rejects_empty(row) { - compare(Engine.parseSpatGRISInput(row.value), null); + compare(Legacy.parseSpatGRISInput(row.value), null); } // Legacy wire form: [sourceIndex, az, el, hspan, vspan, radius, reserved], // 0-based index, polar in radians, elevation measured from the zenith. function test_spatgris_legacy_needs_seven_args() { - compare(Engine.parseSpatGRISInput([0, 0.1, 0.2, 0.3, 0.4, 0.5]), null); + compare(Legacy.parseSpatGRISInput([0, 0.1, 0.2, 0.3, 0.4, 0.5]), null); } function test_spatgris_legacy_normalizes_to_pol() { - var n = Engine.parseSpatGRISInput([6, 0.5, 0.25, 0.3, 0.4, 1.5, 0]); + var n = Legacy.parseSpatGRISInput([6, 0.5, 0.25, 0.3, 0.4, 1.5, 0]); compare(n.command, "pol"); // 0-based on the wire, 1-based internally. compare(n.sourceIndex, 7); @@ -84,7 +85,7 @@ TestCase { // verbatim: SpatGRIS routes legacy through a cylindrical conversion and // "pol" through a spherical one, so rewriting would move the source. function test_spatgris_legacy_keeps_raw_payload() { - var n = Engine.parseSpatGRISInput([6, 0.5, 0.25, 0.3, 0.4, 1.5, 0]); + var n = Legacy.parseSpatGRISInput([6, 0.5, 0.25, 0.3, 0.4, 1.5, 0]); compare(n.legacyArgs.length, 6); compare(n.legacyArgs[0], 0.5); compare(n.legacyArgs[4], 1.5); @@ -103,7 +104,7 @@ TestCase { ]; } function test_spatgris_modern_forms(row) { - var n = Engine.parseSpatGRISInput(row.value); + var n = Legacy.parseSpatGRISInput(row.value); compare(n.command, row.command); compare(n.sourceIndex, row.index); compare(n.args.length, row.argc); @@ -113,7 +114,7 @@ TestCase { // A command whose second element is not a number has no source index. // Downstream this reaches mapForSpatGRIS's catch-all with idx === -1. function test_spatgris_sourceless_command_indexes_minus_one() { - var n = Engine.parseSpatGRISInput(["reset", "all"]); + var n = Legacy.parseSpatGRISInput(["reset", "all"]); compare(n.command, "reset"); compare(n.sourceIndex, -1); compare(n.args.length, 0); @@ -133,7 +134,7 @@ TestCase { ]; } function test_adm_rejects_addresses(row) { - compare(Engine.parseADMInput(newInput(), row.address, [1.0]), null); + compare(Legacy.parseADMInput(newInput(), row.address, [1.0]), null); } // gain/mute/name/dref/dmax have no equivalent in the internal model; they @@ -147,13 +148,13 @@ TestCase { ]; } function test_adm_unmodelled_params(row) { - compare(Engine.parseADMInput(newInput(), "/adm/obj/90/" + row.param, [1.0]), null); + compare(Legacy.parseADMInput(newInput(), "/adm/obj/90/" + row.param, [1.0]), null); } // ADM azimuth is +90 = left; SpatGRIS is -90 = left. Sign flips. // ADM has no vertical extent, so vspan is always 0. function test_adm_aed_emits_degrees() { - var n = Engine.parseADMInput(newInput(), "/adm/obj/10/aed", [30.0, 15.0, 0.8]); + var n = Legacy.parseADMInput(newInput(), "/adm/obj/10/aed", [30.0, 15.0, 0.8]); compare(n.command, "deg"); compare(n.sourceIndex, 10); fuzzyCompare(n.args[0], -30.0, eps, "azimuth sign flipped"); @@ -167,24 +168,24 @@ TestCase { // complete command is emitted on every update. function test_adm_accumulates_polar_components() { var inp = newInput(); - var a = Engine.parseADMInput(inp, "/adm/obj/11/azim", [45.0]); + var a = Legacy.parseADMInput(inp, "/adm/obj/11/azim", [45.0]); compare(a.command, "deg"); fuzzyCompare(a.args[0], -45.0, eps); fuzzyCompare(a.args[1], 0.0, eps, "elevation still at default"); fuzzyCompare(a.args[2], 1.0, eps, "distance defaults to the unit sphere"); - var b = Engine.parseADMInput(inp, "/adm/obj/11/elev", [20.0]); + var b = Legacy.parseADMInput(inp, "/adm/obj/11/elev", [20.0]); fuzzyCompare(b.args[0], -45.0, eps, "azimuth retained"); fuzzyCompare(b.args[1], 20.0, eps); - var c = Engine.parseADMInput(inp, "/adm/obj/11/dist", [0.5]); + var c = Legacy.parseADMInput(inp, "/adm/obj/11/dist", [0.5]); fuzzyCompare(c.args[0], -45.0, eps); fuzzyCompare(c.args[1], 20.0, eps); fuzzyCompare(c.args[2], 0.5, eps); } function test_adm_xyz_emits_cartesian() { - var n = Engine.parseADMInput(newInput(), "/adm/obj/12/xyz", [0.1, 0.2, 0.3]); + var n = Legacy.parseADMInput(newInput(), "/adm/obj/12/xyz", [0.1, 0.2, 0.3]); compare(n.command, "car"); compare(n.sourceIndex, 12); fuzzyCompare(n.args[0], 0.1, eps); @@ -195,8 +196,8 @@ TestCase { function test_adm_accumulates_cartesian_components() { var inp = newInput(); - Engine.parseADMInput(inp, "/adm/obj/13/xyz", [0.1, 0.2, 0.3]); - var n = Engine.parseADMInput(inp, "/adm/obj/13/x", [0.9]); + Legacy.parseADMInput(inp, "/adm/obj/13/xyz", [0.1, 0.2, 0.3]); + var n = Legacy.parseADMInput(inp, "/adm/obj/13/x", [0.9]); compare(n.command, "car"); fuzzyCompare(n.args[0], 0.9, eps, "x overwritten"); fuzzyCompare(n.args[1], 0.2, eps, "y retained"); @@ -205,8 +206,8 @@ TestCase { function test_adm_xy_leaves_z_alone() { var inp = newInput(); - Engine.parseADMInput(inp, "/adm/obj/14/xyz", [0.1, 0.2, 0.7]); - var n = Engine.parseADMInput(inp, "/adm/obj/14/xy", [0.4, 0.5]); + Legacy.parseADMInput(inp, "/adm/obj/14/xyz", [0.1, 0.2, 0.7]); + var n = Legacy.parseADMInput(inp, "/adm/obj/14/xy", [0.4, 0.5]); fuzzyCompare(n.args[0], 0.4, eps); fuzzyCompare(n.args[1], 0.5, eps); fuzzyCompare(n.args[2], 0.7, eps, "z untouched by /xy"); @@ -215,16 +216,16 @@ TestCase { // The last coordinate family written decides which command comes out. function test_adm_last_family_written_wins() { var inp = newInput(); - Engine.parseADMInput(inp, "/adm/obj/15/xyz", [0.1, 0.2, 0.3]); - compare(Engine.parseADMInput(inp, "/adm/obj/15/azim", [10.0]).command, "deg"); - compare(Engine.parseADMInput(inp, "/adm/obj/15/y", [0.4]).command, "car"); + Legacy.parseADMInput(inp, "/adm/obj/15/xyz", [0.1, 0.2, 0.3]); + compare(Legacy.parseADMInput(inp, "/adm/obj/15/azim", [10.0]).command, "deg"); + compare(Legacy.parseADMInput(inp, "/adm/obj/15/y", [0.4]).command, "car"); } // Width alone does not switch coordinate mode. function test_adm_width_keeps_current_mode() { var inp = newInput(); - Engine.parseADMInput(inp, "/adm/obj/16/aed", [30.0, 0.0, 1.0]); - var n = Engine.parseADMInput(inp, "/adm/obj/16/w", [0.25]); + Legacy.parseADMInput(inp, "/adm/obj/16/aed", [30.0, 0.0, 1.0]); + var n = Legacy.parseADMInput(inp, "/adm/obj/16/w", [0.25]); compare(n.command, "deg", "still polar"); fuzzyCompare(n.args[3], 0.25, eps, "width carried into the command"); } @@ -238,7 +239,7 @@ TestCase { ]; } function test_adm_short_payloads_rejected(row) { - compare(Engine.parseADMInput(newInput(), row.address, row.value), null); + compare(Legacy.parseADMInput(newInput(), row.address, row.value), null); } // ---------------------------------------------------------------- // @@ -248,16 +249,16 @@ TestCase { // A legacy input goes back out legacy and untouched. idx is 1-based // internally (plus any offset); the legacy wire form is 0-based. function test_map_spatgris_legacy_round_trips() { - var norm = Engine.parseSpatGRISInput([6, 0.5, 0.25, 0.3, 0.4, 1.5, 0]); - var out = Engine.mapForSpatGRIS("pol", norm.sourceIndex, norm.args, norm); + var norm = Legacy.parseSpatGRISInput([6, 0.5, 0.25, 0.3, 0.4, 1.5, 0]); + var out = Legacy.mapForSpatGRIS("pol", norm.sourceIndex, norm.args, norm); compare(out.length, 1); checkMsg(out[0], "/spat/serv", [6, 0.5, 0.25, 0.3, 0.4, 1.5, 0]); } function test_map_spatgris_legacy_applies_offset_zero_based() { - var norm = Engine.parseSpatGRISInput([6, 0.5, 0.25, 0.3, 0.4, 1.5, 0]); + var norm = Legacy.parseSpatGRISInput([6, 0.5, 0.25, 0.3, 0.4, 1.5, 0]); // sourceIndex 7 + an offset of 10 => wire index 16. - var out = Engine.mapForSpatGRIS("pol", 17, norm.args, norm); + var out = Legacy.mapForSpatGRIS("pol", 17, norm.args, norm); compare(out[0].value[0], 16); } @@ -269,30 +270,30 @@ TestCase { ]; } function test_map_spatgris_passes_coordinates_verbatim(row) { - var out = Engine.mapForSpatGRIS(row.command, 3, [1, 2, 3, 4, 5], null); + var out = Legacy.mapForSpatGRIS(row.command, 3, [1, 2, 3, 4, 5], null); compare(out.length, 1); checkMsg(out[0], "/spat/serv", [row.command, 3, 1, 2, 3, 4, 5]); } function test_map_spatgris_short_coordinates_drop() { - compare(Engine.mapForSpatGRIS("pol", 3, [1, 2, 3], null).length, 0); + compare(Legacy.mapForSpatGRIS("pol", 3, [1, 2, 3], null).length, 0); } function test_map_spatgris_clr() { - var out = Engine.mapForSpatGRIS("clr", 5, [], null); + var out = Legacy.mapForSpatGRIS("clr", 5, [], null); checkMsg(out[0], "/spat/serv", ["clr", 5]); } function test_map_spatgris_alg() { - var out = Engine.mapForSpatGRIS("alg", 5, ["dome"], null); + var out = Legacy.mapForSpatGRIS("alg", 5, ["dome"], null); checkMsg(out[0], "/spat/serv", ["alg", 5, "dome"]); - compare(Engine.mapForSpatGRIS("alg", 5, [], null).length, 0); + compare(Legacy.mapForSpatGRIS("alg", 5, [], null).length, 0); } // SpatGRIS speaks /spat/serv natively and supports commands this app does // not model (sg_OscInput.cpp:186-193), so unknown ones are forwarded. function test_map_spatgris_forwards_unknown_commands() { - var out = Engine.mapForSpatGRIS("reset", 2, ["all"], null); + var out = Legacy.mapForSpatGRIS("reset", 2, ["all"], null); checkMsg(out[0], "/spat/serv", ["reset", 2, "all"]); } @@ -304,7 +305,7 @@ TestCase { ]; } function test_map_spatgris_drops_non_string_commands(row) { - compare(Engine.mapForSpatGRIS(row.command, 1, [], null).length, 0); + compare(Legacy.mapForSpatGRIS(row.command, 1, [], null).length, 0); } // ---------------------------------------------------------------- // @@ -314,14 +315,14 @@ TestCase { // Packed /aed per the spec's atomicity recommendation. Azimuth flips sign // on the way out, elevation does not. /w rides along as a scalar. function test_map_adm_polar() { - var out = Engine.mapForADM("pol", 4, [Math.PI / 2, Math.PI / 6, 0.8, 0.25, 0.9]); + var out = Legacy.mapForADM("pol", 4, [Math.PI / 2, Math.PI / 6, 0.8, 0.25, 0.9]); compare(out.length, 2); checkMsg(out[0], "/adm/obj/4/aed", [-90.0, 30.0, 0.8]); checkMsg(out[1], "/adm/obj/4/w", [0.25]); } function test_map_adm_degrees_round_trip() { - var out = Engine.mapForADM("deg", 4, [-90.0, 30.0, 0.8, 0.25, 0.0]); + var out = Legacy.mapForADM("deg", 4, [-90.0, 30.0, 0.8, 0.25, 0.0]); // deg -> rad on the way in, rad -> deg with a sign flip on the way out. checkMsg(out[0], "/adm/obj/4/aed", [90.0, 30.0, 0.8]); } @@ -330,19 +331,19 @@ TestCase { // normalized +-1. Scaling rather than letting the receiver clamp, which // would collapse everything past 60% of full scale onto the wall. function test_map_adm_cartesian_scales_into_normalized_range() { - var out = Engine.mapForADM("car", 9, [mbap, -mbap, mbap / 2, 0.5, 0.0]); + var out = Legacy.mapForADM("car", 9, [mbap, -mbap, mbap / 2, 0.5, 0.0]); compare(out.length, 2); checkMsg(out[0], "/adm/obj/9/xyz", [1.0, -1.0, 0.5]); checkMsg(out[1], "/adm/obj/9/w", [0.5]); } function test_map_adm_clr_is_packed_xyz() { - checkMsg(Engine.mapForADM("clr", 3, [])[0], "/adm/obj/3/xyz", [0, 0, 0]); + checkMsg(Legacy.mapForADM("clr", 3, [])[0], "/adm/obj/3/xyz", [0, 0, 0]); } // ADM has no algorithm concept. function test_map_adm_drops_alg() { - compare(Engine.mapForADM("alg", 3, ["dome"]).length, 0); + compare(Legacy.mapForADM("alg", 3, ["dome"]).length, 0); } function test_map_adm_short_coordinates_drop_data() { @@ -353,7 +354,7 @@ TestCase { ]; } function test_map_adm_short_coordinates_drop(row) { - compare(Engine.mapForADM(row.command, 1, [1, 2, 3]).length, 0); + compare(Legacy.mapForADM(row.command, 1, [1, 2, 3]).length, 0); } // ---------------------------------------------------------------- // @@ -363,26 +364,26 @@ TestCase { // SPAT keeps the SpatGRIS azimuth sign and takes radius as a percentage. // Spread is the mean of the two extents, also a percentage. function test_map_spat_polar() { - var out = Engine.mapForSPAT("pol", 4, [Math.PI / 2, Math.PI / 6, 0.8, 0.2, 0.4]); + var out = Legacy.mapForSPAT("pol", 4, [Math.PI / 2, Math.PI / 6, 0.8, 0.2, 0.4]); compare(out.length, 2); checkMsg(out[0], "/source/4/aed", [90.0, 30.0, 80.0]); checkMsg(out[1], "/source/4/spread", [30.0]); } function test_map_spat_degrees() { - var out = Engine.mapForSPAT("deg", 4, [-90.0, 30.0, 1.0, 0.0, 0.0]); + var out = Legacy.mapForSPAT("deg", 4, [-90.0, 30.0, 1.0, 0.0, 0.0]); checkMsg(out[0], "/source/4/aed", [-90.0, 30.0, 100.0]); } // Unlike ADM, SPAT cartesian is not rescaled. function test_map_spat_cartesian_unscaled() { - var out = Engine.mapForSPAT("car", 9, [1.5, -0.5, 0.25, 0.2, 0.4]); + var out = Legacy.mapForSPAT("car", 9, [1.5, -0.5, 0.25, 0.2, 0.4]); checkMsg(out[0], "/source/9/xyz", [1.5, -0.5, 0.25]); checkMsg(out[1], "/source/9/spread", [30.0]); } function test_map_spat_clr() { - checkMsg(Engine.mapForSPAT("clr", 3, [])[0], "/source/3/xyz", [0, 0, 0]); + checkMsg(Legacy.mapForSPAT("clr", 3, [])[0], "/source/3/xyz", [0, 0, 0]); } function test_map_spat_alg_data() { @@ -393,11 +394,11 @@ TestCase { ]; } function test_map_spat_alg(row) { - checkMsg(Engine.mapForSPAT("alg", 3, [row.arg])[0], "/source/3/mode", [row.mode]); + checkMsg(Legacy.mapForSPAT("alg", 3, [row.arg])[0], "/source/3/mode", [row.mode]); } function test_map_spat_alg_without_argument_drops() { - compare(Engine.mapForSPAT("alg", 3, []).length, 0); + compare(Legacy.mapForSPAT("alg", 3, []).length, 0); } function test_map_spat_short_coordinates_drop_data() { @@ -408,7 +409,7 @@ TestCase { ]; } function test_map_spat_short_coordinates_drop(row) { - compare(Engine.mapForSPAT(row.command, 1, [1, 2, 3]).length, 0); + compare(Legacy.mapForSPAT(row.command, 1, [1, 2, 3]).length, 0); } // ---------------------------------------------------------------- // @@ -423,13 +424,13 @@ TestCase { ]; } function test_dispatch(row) { - var out = Engine.mapMessage("car", 2, [0.1, 0.2, 0.3, 0.4, 0.5], row.type, null); + var out = Legacy.mapMessage("car", 2, [0.1, 0.2, 0.3, 0.4, 0.5], row.type, null); verify(out.length > 0, row.type + " produced no messages"); compare(out[0].address, row.address); } function test_dispatch_unknown_type_drops() { - compare(Engine.mapMessage("car", 2, [1, 2, 3, 4, 5], "Nonexistent", null).length, 0); + compare(Legacy.mapMessage("car", 2, [1, 2, 3, 4, 5], "Nonexistent", null).length, 0); } @@ -443,16 +444,16 @@ TestCase { var a = newInput(); var b = newInput(); - Engine.parseADMInput(a, "/adm/obj/1/xyz", [0.1, 0.2, 0.3]); - Engine.parseADMInput(b, "/adm/obj/1/xyz", [0.7, 0.8, 0.9]); + Legacy.parseADMInput(a, "/adm/obj/1/xyz", [0.1, 0.2, 0.3]); + Legacy.parseADMInput(b, "/adm/obj/1/xyz", [0.7, 0.8, 0.9]); // Touching one axis on A must leave A's other axes, and all of B, alone. - var na = Engine.parseADMInput(a, "/adm/obj/1/x", [0.5]); + var na = Legacy.parseADMInput(a, "/adm/obj/1/x", [0.5]); fuzzyCompare(na.args[0], 0.5, eps); fuzzyCompare(na.args[1], 0.2, eps, "A keeps its own y"); fuzzyCompare(na.args[2], 0.3, eps, "A keeps its own z"); - var nb = Engine.parseADMInput(b, "/adm/obj/1/y", [0.4]); + var nb = Legacy.parseADMInput(b, "/adm/obj/1/y", [0.4]); fuzzyCompare(nb.args[0], 0.7, eps, "B keeps its own x"); fuzzyCompare(nb.args[1], 0.4, eps); fuzzyCompare(nb.args[2], 0.9, eps, "B keeps its own z"); @@ -462,9 +463,9 @@ TestCase { function test_adm_mode_is_per_input() { var a = newInput(); var b = newInput(); - Engine.parseADMInput(a, "/adm/obj/1/xyz", [0, 0, 0]); - compare(Engine.parseADMInput(b, "/adm/obj/1/azim", [10.0]).command, "deg"); - compare(Engine.parseADMInput(a, "/adm/obj/1/x", [0.1]).command, "car", + Legacy.parseADMInput(a, "/adm/obj/1/xyz", [0, 0, 0]); + compare(Legacy.parseADMInput(b, "/adm/obj/1/azim", [10.0]).command, "deg"); + compare(Legacy.parseADMInput(a, "/adm/obj/1/x", [0.1]).command, "car", "A is still cartesian"); } @@ -474,9 +475,9 @@ TestCase { function test_parse_input_auto_sniffs_both_protocols() { var inp = newInput("Auto"); - compare(Engine.parseInput(inp, "/spat/serv", ["clr", 2]).command, "clr"); - compare(Engine.parseInput(inp, "/adm/obj/5/xyz", [0, 0, 0]).command, "car"); - compare(Engine.parseInput(inp, "/other/address", [1]), null); + compare(Legacy.parseInput(inp, "/spat/serv", ["clr", 2]).command, "clr"); + compare(Legacy.parseInput(inp, "/adm/obj/5/xyz", [0, 0, 0]).command, "car"); + compare(Legacy.parseInput(inp, "/other/address", [1]), null); } // A declared protocol ignores anything that is not its own, so two senders @@ -494,7 +495,7 @@ TestCase { ]; } function test_parse_input_declared_protocol_is_exclusive(row) { - var got = Engine.parseInput(newInput(row.protocol), row.address, row.value); + var got = Legacy.parseInput(newInput(row.protocol), row.address, row.value); compare(got !== null, row.parsed); } @@ -577,19 +578,19 @@ TestCase { // An unscaled input must keep today's exact values, including the legacy // payload a SpatGRIS output re-emits verbatim. function test_scale_identity_returns_the_same_object() { - var n = Engine.parseSpatGRISInput([6, 0.5, 0.25, 0.3, 0.4, 1.5, 0]); - var out = Engine.applyInputScale(scaled(1, 1, 1), n); + var n = Legacy.parseSpatGRISInput([6, 0.5, 0.25, 0.3, 0.4, 1.5, 0]); + var out = Legacy.applyInputScale(scaled(1, 1, 1), n); verify(out === n, "identity scaling does not even rebuild the message"); verify(out.legacyArgs !== undefined, "and keeps the legacy payload"); } function test_scale_missing_fields_read_as_identity() { var n = norm("car", [1, 2, 3, 0, 0]); - verify(Engine.applyInputScale(newInput(), n) === n); + verify(Legacy.applyInputScale(newInput(), n) === n); } function test_scale_cartesian_per_axis() { - var out = Engine.applyInputScale(scaled(2, 0.5, -1), norm("car", [1, 2, 3, 0.4, 0.5])); + var out = Legacy.applyInputScale(scaled(2, 0.5, -1), norm("car", [1, 2, 3, 0.4, 0.5])); fuzzyCompare(out.args[0], 2.0, eps); fuzzyCompare(out.args[1], 1.0, eps); fuzzyCompare(out.args[2], -3.0, eps); @@ -600,7 +601,7 @@ TestCase { // A source at the extreme right, mirrored, lands at the extreme left. function test_scale_negative_x_mirrors_azimuth() { - var out = Engine.applyInputScale(scaled(-1, 1, 1), + var out = Legacy.applyInputScale(scaled(-1, 1, 1), norm("deg", [90.0, 0.0, 1.0, 0, 0])); fuzzyCompare(out.args[0], -90.0, 1e-6); fuzzyCompare(out.args[1], 0.0, 1e-6, "elevation is unaffected"); @@ -608,7 +609,7 @@ TestCase { } function test_scale_negative_z_mirrors_elevation() { - var out = Engine.applyInputScale(scaled(1, 1, -1), + var out = Legacy.applyInputScale(scaled(1, 1, -1), norm("deg", [30.0, 45.0, 1.0, 0, 0])); fuzzyCompare(out.args[0], 30.0, 1e-6, "azimuth is unaffected"); fuzzyCompare(out.args[1], -45.0, 1e-6); @@ -616,7 +617,7 @@ TestCase { // Front/back mirroring is an azimuth reflection about the left-right axis. function test_scale_negative_y_mirrors_front_back() { - var out = Engine.applyInputScale(scaled(1, -1, 1), + var out = Legacy.applyInputScale(scaled(1, -1, 1), norm("deg", [30.0, 0.0, 1.0, 0, 0])); fuzzyCompare(out.args[0], 150.0, 1e-6); } @@ -624,7 +625,7 @@ TestCase { // Scaling every axis alike is a distance change and must leave the angles // exactly where they were. function test_scale_uniform_is_a_distance_change() { - var out = Engine.applyInputScale(scaled(0.5, 0.5, 0.5), + var out = Legacy.applyInputScale(scaled(0.5, 0.5, 0.5), norm("deg", [37.0, 21.0, 2.0, 0, 0])); fuzzyCompare(out.args[0], 37.0, 1e-6); fuzzyCompare(out.args[1], 21.0, 1e-6); @@ -633,7 +634,7 @@ TestCase { // The radian form scales the same way, in radians. function test_scale_polar_stays_in_radians() { - var out = Engine.applyInputScale(scaled(-1, 1, 1), + var out = Legacy.applyInputScale(scaled(-1, 1, 1), norm("pol", [halfPi, 0.0, 1.0, 0, 0])); compare(out.command, "pol"); fuzzyCompare(out.args[0], -halfPi, 1e-6); @@ -643,14 +644,14 @@ TestCase { // payload, or a SpatGRIS output would re-emit the unscaled position and // silently ignore the scaling. function test_scale_drops_the_legacy_passthrough() { - var n = Engine.parseSpatGRISInput([6, 0.5, 0.25, 0.3, 0.4, 1.5, 0]); + var n = Legacy.parseSpatGRISInput([6, 0.5, 0.25, 0.3, 0.4, 1.5, 0]); verify(n.legacyArgs !== undefined, "precondition"); - var out = Engine.applyInputScale(scaled(-1, 1, 1), n); + var out = Legacy.applyInputScale(scaled(-1, 1, 1), n); verify(out.legacyArgs === undefined, "a scaled legacy message cannot be passed through unchanged"); // And the mapper therefore builds a normal command for it. - var msgs = Engine.mapForSpatGRIS(out.command, out.sourceIndex, out.args, out); + var msgs = Legacy.mapForSpatGRIS(out.command, out.sourceIndex, out.args, out); compare(msgs[0].value[0], "pol"); } @@ -663,13 +664,13 @@ TestCase { } function test_scale_ignores_positionless_commands(row) { var n = norm(row.command, row.args); - verify(Engine.applyInputScale(scaled(-1, 2, 3), n) === n); + verify(Legacy.applyInputScale(scaled(-1, 2, 3), n) === n); } // Scaling a source sitting at the origin must not produce NaN through // asin(0/0). function test_scale_at_the_origin_is_finite() { - var out = Engine.applyInputScale(scaled(0, 0, 0), norm("deg", [45.0, 10.0, 1.0, 0, 0])); + var out = Legacy.applyInputScale(scaled(0, 0, 0), norm("deg", [45.0, 10.0, 1.0, 0, 0])); fuzzyCompare(out.args[0], 0.0, eps); fuzzyCompare(out.args[1], 0.0, eps); fuzzyCompare(out.args[2], 0.0, eps); @@ -677,17 +678,17 @@ TestCase { // The scale is a property of the input, so two inputs scale independently. function test_scale_is_per_input() { - var a = Engine.applyInputScale(scaled(-1, 1, 1), norm("car", [1, 2, 3, 0, 0])); - var b = Engine.applyInputScale(scaled(1, 1, 1), norm("car", [1, 2, 3, 0, 0])); + var a = Legacy.applyInputScale(scaled(-1, 1, 1), norm("car", [1, 2, 3, 0, 0])); + var b = Legacy.applyInputScale(scaled(1, 1, 1), norm("car", [1, 2, 3, 0, 0])); fuzzyCompare(a.args[0], -1.0, eps); fuzzyCompare(b.args[0], 1.0, eps); } // Round trip: scale then unscale returns the original position. function test_scale_round_trips_through_its_inverse() { - var once = Engine.applyInputScale(scaled(-2, 0.5, -1), + var once = Legacy.applyInputScale(scaled(-2, 0.5, -1), norm("deg", [37.0, 21.0, 1.4, 0, 0])); - var back = Engine.applyInputScale(scaled(-0.5, 2, -1), once); + var back = Legacy.applyInputScale(scaled(-0.5, 2, -1), once); fuzzyCompare(back.args[0], 37.0, 1e-6); fuzzyCompare(back.args[1], 21.0, 1e-6); fuzzyCompare(back.args[2], 1.4, 1e-6); @@ -814,19 +815,19 @@ TestCase { ]; } function test_spat_input_rejects_addresses(row) { - compare(Engine.parseSPATInput(newInput(), row.address, [1.0]), null); + compare(Legacy.parseSPATInput(newInput(), row.address, [1.0]), null); } // mode/gain/mute have no equivalent in the internal model. function test_spat_input_unmodelled_params() { - compare(Engine.parseSPATInput(newInput(), "/source/1/mode", ["dome"]), null); - compare(Engine.parseSPATInput(newInput(), "/source/1/gain", [0.5]), null); + compare(Legacy.parseSPATInput(newInput(), "/source/1/mode", ["dome"]), null); + compare(Legacy.parseSPATInput(newInput(), "/source/1/gain", [0.5]), null); } // SPAT shares the SpatGRIS azimuth sign, so nothing flips; radius arrives // as a percentage. function test_spat_input_aed() { - var n = Engine.parseSPATInput(newInput(), "/source/4/aed", [90.0, 30.0, 80.0]); + var n = Legacy.parseSPATInput(newInput(), "/source/4/aed", [90.0, 30.0, 80.0]); compare(n.command, "deg"); compare(n.sourceIndex, 4); fuzzyCompare(n.args[0], 90.0, eps, "azimuth is not flipped"); @@ -835,7 +836,7 @@ TestCase { } function test_spat_input_xyz_is_unscaled() { - var n = Engine.parseSPATInput(newInput(), "/source/9/xyz", [1.5, -0.5, 0.25]); + var n = Legacy.parseSPATInput(newInput(), "/source/9/xyz", [1.5, -0.5, 0.25]); compare(n.command, "car"); fuzzyCompare(n.args[0], 1.5, eps); fuzzyCompare(n.args[1], -0.5, eps); @@ -846,8 +847,8 @@ TestCase { // coordinate family. function test_spat_input_spread_fills_both_extents() { var inp = newInput(); - Engine.parseSPATInput(inp, "/source/2/aed", [10.0, 0.0, 100.0]); - var n = Engine.parseSPATInput(inp, "/source/2/spread", [30.0]); + Legacy.parseSPATInput(inp, "/source/2/aed", [10.0, 0.0, 100.0]); + var n = Legacy.parseSPATInput(inp, "/source/2/spread", [30.0]); compare(n.command, "deg", "still polar"); fuzzyCompare(n.args[3], 0.3, eps); fuzzyCompare(n.args[4], 0.3, eps); @@ -855,17 +856,17 @@ TestCase { function test_spat_input_last_family_written_wins() { var inp = newInput(); - Engine.parseSPATInput(inp, "/source/3/xyz", [0.1, 0.2, 0.3]); - compare(Engine.parseSPATInput(inp, "/source/3/aed", [0, 0, 100]).command, "deg"); - compare(Engine.parseSPATInput(inp, "/source/3/xyz", [0, 0, 0]).command, "car"); + Legacy.parseSPATInput(inp, "/source/3/xyz", [0.1, 0.2, 0.3]); + compare(Legacy.parseSPATInput(inp, "/source/3/aed", [0, 0, 100]).command, "deg"); + compare(Legacy.parseSPATInput(inp, "/source/3/xyz", [0, 0, 0]).command, "car"); } function test_spat_input_state_is_per_input() { var a = newInput(); var b = newInput(); - Engine.parseSPATInput(a, "/source/1/xyz", [0.1, 0.2, 0.3]); - Engine.parseSPATInput(b, "/source/1/xyz", [0.7, 0.8, 0.9]); - var na = Engine.parseSPATInput(a, "/source/1/spread", [0.0]); + Legacy.parseSPATInput(a, "/source/1/xyz", [0.1, 0.2, 0.3]); + Legacy.parseSPATInput(b, "/source/1/xyz", [0.7, 0.8, 0.9]); + var na = Legacy.parseSPATInput(a, "/source/1/spread", [0.0]); fuzzyCompare(na.args[0], 0.1, eps, "A keeps its own coordinates"); } @@ -879,22 +880,22 @@ TestCase { ]; } function test_spat_round_trips_through_its_own_output(row) { - var norm = Engine.parseSPATInput(newInput(), row.address, row.value); - var out = Engine.mapMessage(norm.command, norm.sourceIndex, norm.args, + var norm = Legacy.parseSPATInput(newInput(), row.address, row.value); + var out = Legacy.mapMessage(norm.command, norm.sourceIndex, norm.args, "SPAT Revolution", norm); checkMsg(out[0], row.outAddress, row.expected); } function test_parse_input_auto_sniffs_spat() { var inp = newInput("Auto"); - compare(Engine.parseInput(inp, "/source/1/xyz", [0, 0, 0]).command, "car"); + compare(Legacy.parseInput(inp, "/source/1/xyz", [0, 0, 0]).command, "car"); } function test_parse_input_spat_protocol_is_exclusive() { var inp = newInput("SPAT Revolution"); - verify(Engine.parseInput(inp, "/source/1/xyz", [0, 0, 0]) !== null); - compare(Engine.parseInput(inp, "/spat/serv", ["clr", 2]), null); - compare(Engine.parseInput(inp, "/adm/obj/1/xyz", [0, 0, 0]), null); + verify(Legacy.parseInput(inp, "/source/1/xyz", [0, 0, 0]) !== null); + compare(Legacy.parseInput(inp, "/spat/serv", ["clr", 2]), null); + compare(Legacy.parseInput(inp, "/adm/obj/1/xyz", [0, 0, 0]), null); } // ---------------------------------------------------------------- // @@ -913,8 +914,8 @@ TestCase { ]; } function test_end_to_end_controlgris_left(row) { - var norm = Engine.parseSpatGRISInput(["deg", 7, -90.0, 0.0, 1.0, 0.0, 0.0]); - var out = Engine.mapMessage(norm.command, norm.sourceIndex, norm.args, + var norm = Legacy.parseSpatGRISInput(["deg", 7, -90.0, 0.0, 1.0, 0.0, 0.0]); + var out = Legacy.mapMessage(norm.command, norm.sourceIndex, norm.args, row.type, norm); compare(out[0].address, row.address); if (typeof row.first === "number") @@ -926,16 +927,16 @@ TestCase { // An ADM input feeding a SpatGRIS output: sign flips on the way in and // stays flipped on the way out. function test_end_to_end_adm_into_spatgris() { - var norm = Engine.parseADMInput(newInput(), "/adm/obj/20/aed", [90.0, 0.0, 1.0]); - var out = Engine.mapMessage(norm.command, norm.sourceIndex, norm.args, + var norm = Legacy.parseADMInput(newInput(), "/adm/obj/20/aed", [90.0, 0.0, 1.0]); + var out = Legacy.mapMessage(norm.command, norm.sourceIndex, norm.args, "SpatGRIS", norm); checkMsg(out[0], "/spat/serv", ["deg", 20, -90.0, 0.0, 1.0, 0.0, 0.0]); } // The per-output source index offset is applied before mapping. function test_end_to_end_offset_shifts_index() { - var norm = Engine.parseSpatGRISInput(["car", 1, 0.1, 0.2, 0.3, 0.0, 0.0]); - var out = Engine.mapMessage(norm.command, norm.sourceIndex + 16, norm.args, + var norm = Legacy.parseSpatGRISInput(["car", 1, 0.1, 0.2, 0.3, 0.0, 0.0]); + var out = Legacy.mapMessage(norm.command, norm.sourceIndex + 16, norm.args, "ADM-OSC", norm); compare(out[0].address, "/adm/obj/17/xyz"); } diff --git a/tests/tst_handlers.qml b/tests/tst_handlers.qml new file mode 100644 index 0000000..5a35038 --- /dev/null +++ b/tests/tst_handlers.qml @@ -0,0 +1,747 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// © Société des arts technologiques +// +// Protocol handlers: the registry, user scripts, and the engine's message path +// around them (address resolution, verbatim passthrough, params, failures). +import QtCore +import QtQuick +import QtTest +import "../qml/spm/Engine.js" as Engine +import "../qml/spm/Handlers.js" as Handlers +import "../qml/spm/handlers/Spm.js" as Spm +import "../qml/spm/handlers/Template.js" as Template + +TestCase { + id: tc + name: "handlers" + + // ---- the world Engine.js expects ---------------------------------- // + property var inputs: [] + property var outputs: [] + property var routes: [] + property bool monitorActive: false + + property QtObject appSettings: QtObject { + property bool logReceivedMessages: false + property bool logSentMessages: false + property int monitorMaxRate: 500 + property int listenPort: 18032 + property string savedConfiguration: "" + property string savedOutputDevices: "[]" + property string handlerScripts: "[]" + property string inlineScripts: "{}" + } + + function initTestCase() { + var g = (function () { return this; })(); // Qt 6.4 has no globalThis + g.Protocols = { + osc: function () { return { processMessage: function () {} }; }, + inboundUDP: function (cfg) { return { close: function () {} }; }, + outboundUDP: function () { return { osc: function () {}, close: function () {} }; } + }; + } + + function syncCurrentInput() {} + ListModel { id: inputListModel } + ListModel { id: outputListModel } + ListModel { id: routeListModel } + ListModel { id: matrixModel } + + // Scripts registered by a test, removed again afterwards: the registry is + // a library, shared with every other test file. + property var loadedUrls: [] + + function cleanup() { + for (let u of loadedUrls) { + const id = Handlers.removeScript(u); + if (id !== "") Engine.handlersChanged(id); + } + loadedUrls = []; + for (let s of Handlers.scripts().slice()) + if (s.url.startsWith("inline:")) Engine.removeScript(s.url); + appSettings.handlerScripts = "[]"; + appSettings.inlineScripts = "{}"; + tc.inputs = []; + tc.outputs = []; + tc.routes = []; + Engine.reindexRoutes(); + } + + function load(url, source) { + loadedUrls.push(url); + const err = Handlers.loadScript(url, source); + Engine.handlersChanged(Handlers.scripts().filter(function (s) { return s.url === url; })[0].id); + return err; + } + + // Records what it was sent. Encoders reuse their buffers, so a sink that + // keeps values must copy them -- score's udp.osc() does, by serializing. + function sink(id, protocol, params) { + const out = { id: id, name: "out" + id, protocol: protocol, host: "h", port: 1, + params: params || {}, sent: [], udp: null, rt: null }; + out.udp = { osc: function (a, v) { out.sent.push({ address: a, value: v.slice() }); } }; + return out; + } + + function input(id, protocol, params) { + return { id: id, name: "in" + id, protocol: protocol, port: 1, enabled: true, + listening: true, error: "", params: params || {}, udp: null, osc: null, rt: null }; + } + + function route(inputId, outputId, offset, min, max) { + return { inputId: inputId, outputId: outputId, enabled: true, + sourceOffset: offset || 0, + srcMin: min === undefined ? null : min, + srcMax: max === undefined ? null : max }; + } + + function world(ins, outs, rts) { + tc.inputs = ins; + tc.outputs = outs; + tc.routes = rts; + Engine.reindexRoutes(); + } + + function addresses(out) { + return out.sent.map(function (m) { return m.address; }); + } + + // ---------------------------------------------------------------- // + // Registry // + // ---------------------------------------------------------------- // + + function test_builtins_are_registered_in_auto_order() { + const ids = Handlers.all().filter(function (h) { return h.builtin; }) + .map(function (h) { return h.id; }); + compare(ids, ["SpatGRIS", "ADM-OSC", "SPAT Revolution"]); + for (let id of ids) { + verify(Handlers.get(id).hasInput, id + " decodes"); + verify(Handlers.get(id).hasOutput, id + " encodes"); + } + } + + function test_invalid_descriptors_are_rejected_data() { + const ok = "createOutput: function () { return {}; }"; + return [ + { tag: "no export", src: "", error: "does not export" }, + { tag: "wrong api", src: "module.exports = { apiVersion: 2, id: 'x', " + ok + " };", error: "apiVersion" }, + { tag: "no id", src: "module.exports = { apiVersion: 1, " + ok + " };", error: "missing id" }, + { tag: "reserved id", src: "module.exports = { apiVersion: 1, id: 'Auto', " + ok + " };", error: "reserved" }, + { tag: "no sides", src: "module.exports = { apiVersion: 1, id: 'x' };", error: "neither" }, + { tag: "bad matches", src: "module.exports = { apiVersion: 1, id: 'x', matches: 'adm', " + ok + " };", error: "matches" }, + { tag: "bad port", src: "module.exports = { apiVersion: 1, id: 'x', defaultPort: 70000, " + ok + " };", error: "defaultPort" }, + { tag: "bad param type", src: "module.exports = { apiVersion: 1, id: 'x', params: [{ key: 'a', type: 'float' }], " + ok + " };", error: "unknown type" }, + { tag: "enum, no options", src: "module.exports = { apiVersion: 1, id: 'x', params: [{ key: 'a', type: 'enum' }], " + ok + " };", error: "options" }, + { tag: "dup param", src: "module.exports = { apiVersion: 1, id: 'x', params: [{ key: 'a', type: 'int' }, { key: 'a', type: 'int' }], " + ok + " };", error: "duplicate" }, + { tag: "syntax error", src: "module.exports = {", error: "SyntaxError" }, + { tag: "throws", src: "throw new Error('boom');", error: "boom" }, + { tag: "builtin id", src: "module.exports = { apiVersion: 1, id: 'ADM-OSC', " + ok + " };", error: "built-in" } + ]; + } + function test_invalid_descriptors_are_rejected(row) { + const url = "test://invalid/" + row.tag; + loadedUrls.push(url); + const err = Handlers.loadScript(url, row.src); + verify(err.indexOf(row.error) >= 0, "got: " + err); + const s = Handlers.scripts().filter(function (x) { return x.url === url; })[0]; + compare(s.error, err, "the failure is kept for the scripts view"); + compare(Handlers.get("x"), null); + verify(Handlers.get("ADM-OSC").builtin, "the bundled handler is untouched"); + } + + // Scripts cannot reach the app by accident, and do not leak globals. + function test_scripts_run_strict_with_app_globals_shadowed() { + const err = load("test://shadow", ` + module.exports = { + apiVersion: 1, id: "shadow-probe", + seen: [typeof Qt, typeof XMLHttpRequest, typeof Protocols, typeof spm], + createOutput: function () { return {}; } + }; + try { leakedGlobal = 1; } catch (e) { module.exports.strict = true; } + `); + compare(err, ""); + const d = Handlers.get("shadow-probe").descriptor; + compare(d.seen, ["undefined", "undefined", "undefined", "object"]); + verify(d.strict, "assigning an undeclared name throws"); + } + + function test_spm_is_frozen() { + verify(Object.isFrozen(Spm.api)); + } + + function test_reloading_a_script_replaces_its_handler() { + const v1 = "module.exports = { apiVersion: 1, id: 'reloadable', name: 'One', createOutput: function () { return {}; } };"; + const v2 = "module.exports = { apiVersion: 1, id: 'reloadable', name: 'Two', createOutput: function () { return {}; } };"; + compare(load("test://reload", v1), ""); + compare(Handlers.get("reloadable").name, "One"); + compare(Handlers.loadScript("test://reload", v2), ""); + compare(Handlers.get("reloadable").name, "Two"); + compare(Handlers.all().filter(function (h) { return h.id === "reloadable"; }).length, 1); + } + + function test_a_script_that_changes_id_releases_the_old_one() { + load("test://rename", "module.exports = { apiVersion: 1, id: 'old-id', createOutput: function () { return {}; } };"); + Handlers.loadScript("test://rename", "module.exports = { apiVersion: 1, id: 'new-id', createOutput: function () { return {}; } };"); + compare(Handlers.get("old-id"), null); + verify(Handlers.get("new-id") !== null); + } + + function test_two_scripts_cannot_share_an_id() { + load("test://first", "module.exports = { apiVersion: 1, id: 'shared', createOutput: function () { return {}; } };"); + loadedUrls.push("test://second"); + const err = Handlers.loadScript("test://second", + "module.exports = { apiVersion: 1, id: 'shared', createOutput: function () { return {}; } };"); + verify(err.indexOf("already used") >= 0, err); + compare(Handlers.get("shared").url, "test://first"); + } + + // ---------------------------------------------------------------- // + // Address patterns and templates // + // ---------------------------------------------------------------- // + + function test_pattern_matching_data() { + return [ + { tag: "index", pattern: "/adm/obj/{n}/xyz", address: "/adm/obj/12/xyz", n: 12 }, + { tag: "leading zeros", pattern: "/adm/obj/{n}/xyz", address: "/adm/obj/007/xyz", n: 7 }, + { tag: "no index", pattern: "/adm/obj/{n}/xyz", address: "/adm/obj//xyz", n: -2 }, + { tag: "not digits", pattern: "/adm/obj/{n}/xyz", address: "/adm/obj/a/xyz", n: -2 }, + { tag: "deeper", pattern: "/adm/obj/{n}/x", address: "/adm/obj/1/y/x", n: -2 }, + { tag: "suffix only", pattern: "/adm/obj/{n}/x", address: "/adm/obj/1/xyz", n: -2 }, + { tag: "wildcard", pattern: "/adm/obj/{n}/x", address: "/adm/obj/*/x", n: -2 }, + { tag: "literal", pattern: "/spat/serv", address: "/spat/serv", n: -1 }, + { tag: "literal miss", pattern: "/spat/serv", address: "/spat/servx", n: -2 }, + { tag: "trailing index", pattern: "/track/{n}", address: "/track/3", n: 3 } + ]; + } + function test_pattern_matching(row) { + compare(Spm.matchPattern(Spm.compilePattern(row.pattern), row.address), row.n); + } + + function test_address_templates() { + const t = Spm.addressTemplate("/source/{n}/aed"); + compare(t(3), "/source/3/aed"); + compare(t(3), "/source/3/aed", "cached"); + compare(t(-1), "/source/-1/aed", "out of the cache range still formats"); + compare(Spm.addressTemplate("/fixed")(9), "/fixed"); + } + + // ---------------------------------------------------------------- // + // User handlers on the message path // + // ---------------------------------------------------------------- // + + // A scripted input feeding every bundled output, and a bundled input + // feeding a scripted output. + readonly property string probeSource: ` + module.exports = { + apiVersion: 1, id: "probe", name: "Probe", defaultPort: 9999, + matches: "/probe/", + params: [{ key: "gain", type: "real", default: 2 }], + createInput: function (ctx) { + return { + "/probe/{n}/aed": function (n, a, ev) { + ev.type = spm.POSITION; ev.source = n; ev.coords = spm.SPHERICAL; + ev.azimuth = a[0]; ev.elevation = a[1]; ev.distance = a[2]; + return true; + } + }; + }, + createOutput: function (ctx) { + const b = [0, 0, 0]; + const cart = { x: 0, y: 0, z: 0 }; + return { + position: function (ev) { + spm.toCartesian(ev, cart); + b[0] = cart.x * ctx.params.gain; b[1] = cart.y * ctx.params.gain; b[2] = cart.z * ctx.params.gain; + ctx.send("/probe/" + ev.source + "/xyz", b); + } + }; + } + };` + + function test_a_scripted_input_feeds_bundled_outputs() { + compare(load("test://probe", probeSource), ""); + const inp = input(1, "probe"); + const sg = sink(10, "SpatGRIS"), adm = sink(11, "ADM-OSC"); + world([inp], [sg, adm], [route(1, 10), route(1, 11, 4)]); + + Engine.onInputValueReceived(inp, "/probe/2/aed", [30, 10, 1]); + + compare(sg.sent.length, 1); + compare(sg.sent[0].value, ["deg", 2, 30, 10, 1, 0, 0]); + compare(addresses(adm), ["/adm/obj/6/aed", "/adm/obj/6/w"], "the route offset applies"); + fuzzyCompare(adm.sent[0].value[0], -30, 1e-9, "ADM azimuth flips"); + } + + function test_a_bundled_input_feeds_a_scripted_output() { + load("test://probe", probeSource); + const inp = input(1, "ADM-OSC"); + const out = sink(10, "probe"); + world([inp], [out], [route(1, 10)]); + + Engine.onInputValueReceived(inp, "/adm/obj/3/xyz", [0.1, 0.2, 0.3]); + + compare(addresses(out), ["/probe/3/xyz"]); + fuzzyCompare(out.sent[0].value[0], 0.2, 1e-9, "the default gain of 2"); + fuzzyCompare(out.sent[0].value[2], 0.6, 1e-9); + } + + // A handler that declares `matches` joins Auto, after the bundled ones. + function test_auto_inputs_try_scripted_decoders_too() { + load("test://probe", probeSource); + const inp = input(1, "Auto"); + const out = sink(10, "SpatGRIS"); + world([inp], [out], [route(1, 10)]); + + Engine.onInputValueReceived(inp, "/probe/5/aed", [0, 0, 1]); + Engine.onInputValueReceived(inp, "/adm/obj/6/xyz", [0, 0, 0]); + compare(out.sent.map(function (m) { return m.value[1]; }), [5, 6]); + } + + function test_a_handler_without_matches_stays_out_of_auto() { + load("test://nomatch", `module.exports = { apiVersion: 1, id: "nomatch", + createInput: function () { return { "*": function (n, a, ev) { + ev.type = spm.CLEAR; ev.source = 1; return true; } }; } };`); + const inp = input(1, "Auto"); + const out = sink(10, "SpatGRIS"); + world([inp], [out], [route(1, 10)]); + Engine.onInputValueReceived(inp, "/anything", [1]); + compare(out.sent.length, 0); + + // Declared, it takes every address. + const own = input(2, "nomatch"); + world([own], [out], [route(2, 10)]); + Engine.onInputValueReceived(own, "/anything", [1]); + compare(out.sent[0].value, ["clr", 1]); + } + + // emit() routes more than one event from a single message. + function test_a_decoder_can_emit_several_events() { + load("test://multi", `module.exports = { apiVersion: 1, id: "multi", + createInput: function (ctx) { return { "/both/{n}": function (n, a, ev) { + ev.type = spm.CLEAR; ev.source = n; ctx.emit(); + ev.source = n + 1; ctx.emit(); + return false; } }; } };`); + const inp = input(1, "multi"); + const out = sink(10, "SpatGRIS"); + world([inp], [out], [route(1, 10)]); + Engine.onInputValueReceived(inp, "/both/3", []); + compare(out.sent.map(function (m) { return m.value[1]; }), [3, 4]); + } + + // ---------------------------------------------------------------- // + // Verbatim passthrough // + // ---------------------------------------------------------------- // + + // ADM features with no place in the event model reach ADM outputs from + // any input, with the route offset applied where there is an index. + function test_unmodelled_adm_is_forwarded_verbatim_data() { + return [ + { tag: "gain", address: "/adm/obj/3/gain", expected: "/adm/obj/19/gain" }, + { tag: "short", address: "/adm/obj/3/aed", expected: "/adm/obj/19/aed" }, + { tag: "listener", address: "/adm/lis/azim", expected: "/adm/lis/azim" }, + { tag: "wildcard", address: "/adm/obj/*/azim", expected: "/adm/obj/*/azim" } + ]; + } + function test_unmodelled_adm_is_forwarded_verbatim(row) { + const inp = input(1, "SpatGRIS"); + const adm = sink(10, "ADM-OSC"), sg = sink(11, "SpatGRIS"); + world([inp], [adm, sg], [route(1, 10, 16), route(1, 11)]); + + Engine.onInputValueReceived(inp, row.address, [0.5]); + + compare(addresses(adm), [row.expected]); + compare(adm.sent[0].value, [0.5]); + compare(sg.sent.length, 0, "other protocols do not take it"); + } + + function test_verbatim_forwarding_respects_the_range_filter() { + const inp = input(1, "ADM-OSC"); + const adm = sink(10, "ADM-OSC"); + world([inp], [adm], [route(1, 10, 0, 1, 4)]); + Engine.onInputValueReceived(inp, "/adm/obj/9/gain", [1]); + Engine.onInputValueReceived(inp, "/adm/obj/2/gain", [1]); + Engine.onInputValueReceived(inp, "/adm/env/size", [1]); + compare(addresses(adm), ["/adm/obj/2/gain", "/adm/env/size"]); + } + + // ---------------------------------------------------------------- // + // Address cache // + // ---------------------------------------------------------------- // + + function test_the_address_cache_is_bounded() { + const inp = input(1, "ADM-OSC"); + world([inp], [sink(10, "ADM-OSC")], [route(1, 10)]); + for (let i = 0; i < Engine.ADDRESS_CACHE_MAX + 50; i++) + Engine.onInputValueReceived(inp, "/adm/obj/" + i + "/x", [0.1]); + verify(inp.rt.cache.size <= Engine.ADDRESS_CACHE_MAX); + verify(inp.rt.cache.size > 0); + } + + // A protocol change rebuilds the decoder: no accumulated state survives. + function test_a_protocol_change_resets_the_accumulators() { + const inp = input(1, "ADM-OSC"); + const out = sink(10, "SpatGRIS"); + world([inp], [out], [route(1, 10)]); + tc.inputs = [inp]; + Engine.onInputValueReceived(inp, "/adm/obj/1/xyz", [0.5, 0.5, 0.5]); + + Engine.updateInput(1, { protocol: "Auto" }); + Engine.onInputValueReceived(inp, "/adm/obj/1/x", [0.9]); + compare(out.sent[1].value, ["car", 1, 0.9, 0, 0, 0, 0], "y and z back at defaults"); + } + + // ---------------------------------------------------------------- // + // Failures // + // ---------------------------------------------------------------- // + + function test_a_throwing_encoder_does_not_stop_the_others() { + load("test://broken", `module.exports = { apiVersion: 1, id: "broken", + createOutput: function () { return { position: function () { throw new Error("nope"); } }; } };`); + const inp = input(1, "ADM-OSC"); + const bad = sink(10, "broken"), good = sink(11, "SpatGRIS"); + world([inp], [bad, good], [route(1, 10), route(1, 11)]); + + Engine.onInputValueReceived(inp, "/adm/obj/1/xyz", [0, 0, 0]); + Engine.onInputValueReceived(inp, "/adm/obj/1/xyz", [0, 0, 0]); + + compare(good.sent.length, 2, "the other output kept receiving"); + verify(bad.handlerError.indexOf("nope") >= 0); + compare(bad.handlerErrors, 2); + verify(Engine.handlerStatus(bad, "output").indexOf("nope") >= 0, "and the row shows it"); + } + + function test_a_throwing_decoder_drops_only_that_message() { + load("test://brokenin", `module.exports = { apiVersion: 1, id: "brokenin", + createInput: function () { return { "/x/{n}": function (n) { + if (n === 1) throw new Error("bad"); return false; } }; } };`); + const inp = input(1, "brokenin"); + world([inp], [sink(10, "SpatGRIS")], [route(1, 10)]); + Engine.onInputValueReceived(inp, "/x/1", []); + Engine.onInputValueReceived(inp, "/x/2", []); + compare(inp.handlerErrors, 1); + } + + function test_a_factory_that_throws_leaves_the_device_inert() { + load("test://badfactory", `module.exports = { apiVersion: 1, id: "badfactory", + createOutput: function () { throw new Error("no instance"); } };`); + const inp = input(1, "ADM-OSC"); + const out = sink(10, "badfactory"); + world([inp], [out], [route(1, 10)]); + Engine.onInputValueReceived(inp, "/adm/obj/1/xyz", [0, 0, 0]); + compare(out.sent.length, 0); + verify(out.handlerError.indexOf("no instance") >= 0); + } + + // ---------------------------------------------------------------- // + // Missing handlers and persistence // + // ---------------------------------------------------------------- // + + // A device on a protocol whose script is absent keeps its protocol and + // params through a save, and works once the script arrives. + function test_a_missing_handler_keeps_the_device() { + appSettings.savedConfiguration = JSON.stringify({ + version: 2, + inputs: [{ id: 1, name: "in", protocol: "ADM-OSC", port: 18032, enabled: false }], + outputs: [{ id: 2, name: "later", protocol: "probe", host: "h", port: 1, + params: { gain: 10 } }], + routes: [{ inputId: 1, outputId: 2, enabled: true, sourceOffset: 0 }] + }); + Engine.restoreConfiguration(); + compare(tc.outputs.length, 1); + compare(tc.routes.length, 1, "the route survives too"); + compare(Engine.handlerStatus(tc.outputs[0], "output"), "Handler not loaded: probe"); + verify(Engine.protocolOptions("output").ids.indexOf("probe") >= 0, "the picker can show it"); + + Engine.saveConfiguration(); + const saved = JSON.parse(appSettings.savedConfiguration); + compare(saved.outputs[0].protocol, "probe"); + compare(saved.outputs[0].params.gain, 10); + + // The script arrives. + const out = tc.outputs[0]; + const sent = []; + out.udp = { osc: function (a, v) { sent.push(v.slice()); } }; + load("test://probe", probeSource); + compare(Engine.handlerStatus(out, "output"), ""); + Engine.onInputValueReceived(tc.inputs[0], "/adm/obj/1/xyz", [0.1, 0, 0]); + fuzzyCompare(sent[0][0], 1.0, 1e-9, "with its saved gain"); + } + + function test_params_round_trip_through_the_configuration() { + load("test://probe", probeSource); + // A configuration with no input is treated as unusable. + tc.inputs = [input(1, "ADM-OSC")]; + tc.inputs[0].enabled = false; + const out = Engine.createOutput("p", "127.0.0.1", 9999, "probe"); + Engine.setDeviceParams(out, { gain: "3.5", stray: 1 }, "output"); + compare(out.params.gain, 3.5, "coerced to the declared type"); + compare(out.params.stray, 1, "undeclared keys are kept"); + Engine.saveConfiguration(); + + const raw = appSettings.savedConfiguration; + tc.outputs = []; + tc.inputs = []; + tc.routes = []; + appSettings.savedConfiguration = raw; + Engine.restoreConfiguration(); + const back = tc.outputs.filter(function (o) { return o.protocol === "probe"; })[0]; + compare(Engine.paramValues(back).gain, 3.5); + } + + // With onParamsChanged the instance stays and sees the new values; + // without it, it is rebuilt. + function test_params_update_live_or_rebuild() { + load("test://live", `module.exports = { apiVersion: 1, id: "live", + params: [{ key: "k", type: "int", default: 1 }], + createOutput: function (ctx) { + const built = ++module.exports.builds; + return { + onParamsChanged: function () { module.exports.seen = ctx.params.k; }, + position: function (ev) { ctx.send("/k", [ctx.params.k, built]); } + }; + }, + builds: 0 };`); + load("test://rebuilt", `module.exports = { apiVersion: 1, id: "rebuilt", + params: [{ key: "k", type: "int", default: 1 }], + createOutput: function (ctx) { + const built = ++module.exports.builds; + return { position: function (ev) { ctx.send("/k", [ctx.params.k, built]); } }; + }, + builds: 0 };`); + + const inp = input(1, "ADM-OSC"); + const live = sink(10, "live"), rebuilt = sink(11, "rebuilt"); + world([inp], [live, rebuilt], [route(1, 10), route(1, 11)]); + tc.outputs = [live, rebuilt]; + + Engine.setDeviceParams(live, { k: 5 }, "output"); + Engine.setDeviceParams(rebuilt, { k: 5 }, "output"); + Engine.onInputValueReceived(inp, "/adm/obj/1/xyz", [0, 0, 0]); + + compare(live.sent[0].value, [5, 1], "same instance, new value"); + compare(Handlers.get("live").descriptor.seen, 5); + compare(rebuilt.sent[0].value, [5, 2], "a second instance"); + } + + function test_param_coercion_data() { + return [ + { tag: "int clamps", spec: { key: "a", type: "int", min: 1, max: 4 }, value: "9", expected: 4 }, + { tag: "int junk", spec: { key: "a", type: "int", default: 2 }, value: "x", expected: 2 }, + { tag: "real", spec: { key: "a", type: "real" }, value: "0.25", expected: 0.25 }, + { tag: "bool", spec: { key: "a", type: "bool" }, value: "true", expected: true }, + { tag: "enum valid", spec: { key: "a", type: "enum", options: ["p", "q"] }, value: "q", expected: "q" }, + { tag: "enum junk", spec: { key: "a", type: "enum", options: ["p", "q"] }, value: "z", expected: "p" }, + { tag: "string", spec: { key: "a", type: "string", default: "d" }, value: undefined, expected: "d" } + ]; + } + function test_param_coercion(row) { + const entry = { params: [row.spec] }; + const saved = row.value === undefined ? {} : { a: row.value }; + compare(Handlers.resolveParams(entry, saved).a, row.expected); + } + + // ---------------------------------------------------------------- // + // The template and error lines // + // ---------------------------------------------------------------- // + + function test_the_template_compiles_and_runs() { + const src = Template.make("tpl-test", "Template test"); + verify(src.indexOf("__ID__") < 0 && src.indexOf("__NAME__") < 0, "placeholders filled"); + compare(load("test://tpl", src), ""); + const h = Handlers.get("tpl-test"); + compare(h.name, "Template test"); + verify(h.hasInput && h.hasOutput); + + // Its defaults: /obj//xyz, half-width 1, one-based. + const inp = input(1, "tpl-test"); + const out = sink(10, "tpl-test"); + world([inp], [out], [route(1, 10)]); + Engine.onInputValueReceived(inp, "/obj/4/xyz", [0.5, -0.25, 0]); + compare(addresses(out), ["/obj/4/xyz"]); + fuzzyCompare(out.sent[0].value[0], 0.5, 1e-6, "round trip through SpatGRIS units"); + fuzzyCompare(out.sent[0].value[1], -0.25, 1e-6); + } + + function test_errors_point_at_the_script_line_data() { + return [ + { tag: "syntax", src: "// one\n// two\nlet x = ;\n", line: 3 }, + { tag: "at load", src: "\n\nnull.boom;\n", line: 3 }, + { tag: "line one", src: "module.exports = {", line: 2 } + ]; + } + function test_errors_point_at_the_script_line(row) { + loadedUrls.push("test://lines"); + const err = Handlers.loadScript("test://lines", row.src); + verify(err !== ""); + const rec = Handlers.scripts().filter(function (s) { return s.url === "test://lines"; })[0]; + compare(rec.line, row.line, err); + verify(err.indexOf("(line " + row.line + ")") >= 0, err); + } + + function test_runtime_errors_name_the_line() { + load("test://rtline", "module.exports = { apiVersion: 1, id: 'rtline',\n" + + " createOutput: function () { return {\n" + + " position: function () { null.boom; } }; } };"); + const inp = input(1, "ADM-OSC"); + const out = sink(10, "rtline"); + world([inp], [out], [route(1, 10)]); + Engine.onInputValueReceived(inp, "/adm/obj/1/xyz", [0, 0, 0]); + verify(out.handlerError.indexOf("(line 3)") >= 0, out.handlerError); + } + + // ---------------------------------------------------------------- // + // Inline scripts (the editor) // + // ---------------------------------------------------------------- // + + function test_a_new_inline_script_starts_from_the_template() { + const url = Engine.newInlineScript(); + compare(url, "inline:script-1"); + compare(Handlers.get("script-1").name, "Script 1"); + compare(JSON.parse(appSettings.handlerScripts), [url], "listed for the next start"); + compare(JSON.parse(appSettings.inlineScripts)[url], Template.make("script-1", "Script 1")); + + const second = Engine.newInlineScript(); + compare(second, "inline:script-2", "the next one gets a free id"); + } + + function test_applying_an_edit_replaces_the_handler() { + const url = Engine.newInlineScript(); + const edited = Template.make("script-1", "Renamed"); + let result = null; + Engine.applyScript(url, edited, function (r) { result = r; }); + compare(result.error, ""); + verify(result.saved); + compare(Handlers.get("script-1").name, "Renamed"); + compare(JSON.parse(appSettings.inlineScripts)[url], edited); + } + + // A broken edit is kept, so no work is lost, and the running version + // keeps running. + function test_a_broken_edit_is_saved_but_not_applied() { + const url = Engine.newInlineScript(); + const out = sink(10, "script-1"); + const inp = input(1, "ADM-OSC"); + world([inp], [out], [route(1, 10)]); + + let result = null; + Engine.applyScript(url, "module.exports = {\n apiVersion: 1,\n id: oops oops\n};", function (r) { result = r; }); + verify(result.error !== ""); + compare(result.line, 3); + verify(result.saved); + verify(JSON.parse(appSettings.inlineScripts)[url].indexOf("oops") >= 0); + + verify(Handlers.get("script-1") !== null, "the previous version is still registered"); + Engine.onInputValueReceived(inp, "/adm/obj/2/xyz", [0, 0, 0]); + compare(addresses(out), ["/obj/2/xyz"], "and still routes"); + } + + function test_inline_scripts_come_back_on_restart() { + const url = Engine.newInlineScript(); + Engine.applyScript(url, Template.make("script-1", "Kept"), function () {}); + const urls = appSettings.handlerScripts, sources = appSettings.inlineScripts; + + // A fresh start: nothing registered, the settings as saved. + Handlers.removeScript(url); + compare(Handlers.get("script-1"), null); + appSettings.handlerScripts = urls; + appSettings.inlineScripts = sources; + Engine.loadSavedScripts(); + compare(Handlers.get("script-1").name, "Kept"); + } + + function test_removing_an_inline_script_deletes_its_source() { + const url = Engine.newInlineScript(); + Engine.removeScript(url); + compare(Handlers.get("script-1"), null); + compare(JSON.parse(appSettings.inlineScripts), {}); + compare(JSON.parse(appSettings.handlerScripts), []); + } + + function test_reading_an_inline_source() { + const url = Engine.newInlineScript(); + let got = null, error = null; + Engine.readSource(url, function (s, e) { got = s; error = e; }); + compare(error, ""); + compare(got, Template.make("script-1", "Script 1")); + Engine.readSource("inline:nothing", function (s, e) { error = e; }); + verify(error !== ""); + } + + // Editing a script that lives in a file writes the file. Needs + // QML_XHR_ALLOW_FILE_READ=1 and QML_XHR_ALLOW_FILE_WRITE=1, as the app sets. + function test_applying_a_file_script_writes_the_file() { + const dir = StandardPaths.writableLocation(StandardPaths.TempLocation); + // One fixed name, overwritten each run: XMLHttpRequest cannot delete files. + const url = dir.toString() + "/spm-handler-test.js"; + const src = Template.make("file-test", "File test"); + + let result = null; + Engine.applyScript(url, src, function (r) { result = r; }); + tryVerify(function () { return result !== null; }, Engine.FILE_TIMEOUT_MS + 3000, + "a write Qt refuses still reports back"); + if (!result.saved) + skip("file writes are not allowed in this environment: " + result.error); + loadedUrls.push(url); + compare(result.error, ""); + compare(Handlers.get("file-test").name, "File test"); + + let back = null; + Engine.readSource(url, function (s) { back = s; }); + tryVerify(function () { return back !== null; }, Engine.FILE_TIMEOUT_MS + 3000); + compare(back, src, "the file holds what was applied"); + // Leave nothing behind. + const x = new XMLHttpRequest(); + x.open("PUT", url); + x.send(""); + } + + // ---------------------------------------------------------------- // + // Logging // + // ---------------------------------------------------------------- // + + function test_logging_follows_the_settings_and_the_monitor() { + appSettings.logSentMessages = true; + tc.monitorActive = false; + Engine.updateLogging(); + verify(!Engine.g_logOut, "nothing is formatted while the monitor is hidden"); + tc.monitorActive = true; + Engine.updateLogging(); + verify(Engine.g_logOut); + verify(!Engine.g_logIn); + appSettings.logSentMessages = false; + tc.monitorActive = false; + Engine.updateLogging(); + } + + // ---------------------------------------------------------------- // + // Benchmarks // + // ---------------------------------------------------------------- // + + function benchWorld(protocol) { + const inp = input(1, protocol); + const outs = [sink(10, "SpatGRIS"), sink(11, "ADM-OSC"), sink(12, "SPAT Revolution")]; + // A socket that discards, like a real one, without recording. + for (let o of outs) o.udp = { osc: function () {} }; + world([inp], outs, [route(1, 10), route(1, 11, 16), route(1, 12)]); + return inp; + } + + // 1000 ADM position updates over 64 sources, fanned out to three outputs. + function benchmark_adm_to_three_outputs() { + const inp = benchWorld("ADM-OSC"); + const addrs = []; + for (let i = 0; i < 64; i++) addrs.push("/adm/obj/" + (i + 1) + "/xyz"); + const v = [0.1, 0.2, 0.3]; + for (let i = 0; i < 1000; i++) + Engine.onInputValueReceived(inp, addrs[i & 63], v); + } + + function benchmark_spatgris_to_three_outputs() { + const inp = benchWorld("SpatGRIS"); + const v = ["deg", 1, 30, 10, 1, 0, 0]; + for (let i = 0; i < 1000; i++) { + v[1] = (i & 63) + 1; + Engine.onInputValueReceived(inp, "/spat/serv", v); + } + } +}