@djodjonx/x32-simulator 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.commitlintrc.json +3 -0
- package/.github/workflows/publish.yml +38 -0
- package/.husky/commit-msg +1 -0
- package/.husky/pre-commit +1 -0
- package/.oxlintrc.json +56 -0
- package/CHANGELOG.md +11 -0
- package/INSTALL.md +107 -0
- package/LICENSE +21 -0
- package/README.md +141 -0
- package/dist/UdpNetworkGateway-BrroQ6-Q.mjs +1189 -0
- package/dist/UdpNetworkGateway-Ccdd7Us5.cjs +1265 -0
- package/dist/index.cjs +7 -0
- package/dist/index.d.cts +207 -0
- package/dist/index.d.mts +207 -0
- package/dist/index.mjs +3 -0
- package/dist/server.cjs +1060 -0
- package/dist/server.d.cts +10 -0
- package/dist/server.d.mts +10 -0
- package/dist/server.mjs +1055 -0
- package/docs/OSC-Communication.md +184 -0
- package/docs/X32-INTERNAL.md +262 -0
- package/docs/X32-OSC.pdf +0 -0
- package/docs/behringer-x32-x32-osc-remote-protocol-en-44463.pdf +0 -0
- package/package.json +68 -0
- package/src/application/use-cases/BroadcastUpdatesUseCase.ts +120 -0
- package/src/application/use-cases/ManageSessionsUseCase.ts +9 -0
- package/src/application/use-cases/ProcessPacketUseCase.ts +26 -0
- package/src/application/use-cases/SimulationService.ts +122 -0
- package/src/domain/entities/SubscriptionManager.ts +126 -0
- package/src/domain/entities/X32State.ts +78 -0
- package/src/domain/models/MeterConfig.ts +22 -0
- package/src/domain/models/MeterData.ts +59 -0
- package/src/domain/models/OscMessage.ts +93 -0
- package/src/domain/models/X32Address.ts +78 -0
- package/src/domain/models/X32Node.ts +43 -0
- package/src/domain/models/types.ts +96 -0
- package/src/domain/ports/ILogger.ts +27 -0
- package/src/domain/ports/INetworkGateway.ts +8 -0
- package/src/domain/ports/IStateRepository.ts +16 -0
- package/src/domain/services/MeterService.ts +46 -0
- package/src/domain/services/OscMessageHandler.ts +88 -0
- package/src/domain/services/SchemaFactory.ts +308 -0
- package/src/domain/services/SchemaRegistry.ts +67 -0
- package/src/domain/services/StaticResponseService.ts +52 -0
- package/src/domain/services/strategies/BatchStrategy.ts +74 -0
- package/src/domain/services/strategies/MeterStrategy.ts +45 -0
- package/src/domain/services/strategies/NodeDiscoveryStrategy.ts +36 -0
- package/src/domain/services/strategies/OscCommandStrategy.ts +22 -0
- package/src/domain/services/strategies/StateAccessStrategy.ts +71 -0
- package/src/domain/services/strategies/StaticResponseStrategy.ts +42 -0
- package/src/domain/services/strategies/SubscriptionStrategy.ts +56 -0
- package/src/infrastructure/mappers/OscCodec.ts +54 -0
- package/src/infrastructure/repositories/InMemoryStateRepository.ts +21 -0
- package/src/infrastructure/services/ConsoleLogger.ts +177 -0
- package/src/infrastructure/services/UdpNetworkGateway.ts +71 -0
- package/src/presentation/cli/server.ts +194 -0
- package/src/presentation/library/library.ts +9 -0
- package/tests/application/use-cases/BroadcastUpdatesUseCase.test.ts +104 -0
- package/tests/application/use-cases/ManageSessionsUseCase.test.ts +12 -0
- package/tests/application/use-cases/ProcessPacketUseCase.test.ts +49 -0
- package/tests/application/use-cases/SimulationService.test.ts +77 -0
- package/tests/domain/entities/SubscriptionManager.test.ts +50 -0
- package/tests/domain/entities/X32State.test.ts +52 -0
- package/tests/domain/models/MeterData.test.ts +23 -0
- package/tests/domain/models/OscMessage.test.ts +38 -0
- package/tests/domain/models/X32Address.test.ts +30 -0
- package/tests/domain/models/X32Node.test.ts +30 -0
- package/tests/domain/services/MeterService.test.ts +27 -0
- package/tests/domain/services/OscMessageHandler.test.ts +51 -0
- package/tests/domain/services/SchemaRegistry.test.ts +47 -0
- package/tests/domain/services/StaticResponseService.test.ts +15 -0
- package/tests/domain/services/strategies/BatchStrategy.test.ts +41 -0
- package/tests/domain/services/strategies/MeterStrategy.test.ts +19 -0
- package/tests/domain/services/strategies/NodeDiscoveryStrategy.test.ts +22 -0
- package/tests/domain/services/strategies/StateAccessStrategy.test.ts +49 -0
- package/tests/domain/services/strategies/StaticResponseStrategy.test.ts +15 -0
- package/tests/domain/services/strategies/SubscriptionStrategy.test.ts +45 -0
- package/tests/infrastructure/mappers/OscCodec.test.ts +41 -0
- package/tests/infrastructure/repositories/InMemoryStateRepository.test.ts +29 -0
- package/tests/infrastructure/services/ConsoleLogger.test.ts +74 -0
- package/tests/infrastructure/services/UdpNetworkGateway.test.ts +61 -0
- package/tests/presentation/cli/server.test.ts +178 -0
- package/tests/presentation/library/library.test.ts +13 -0
- package/tsconfig.json +21 -0
- package/tsdown.config.ts +15 -0
- package/vitest.config.ts +9 -0
|
@@ -0,0 +1,1189 @@
|
|
|
1
|
+
import * as os from "os";
|
|
2
|
+
import { EventEmitter } from "node:events";
|
|
3
|
+
import * as dgram from "node:dgram";
|
|
4
|
+
|
|
5
|
+
//#region src/application/use-cases/ProcessPacketUseCase.ts
|
|
6
|
+
var ProcessPacketUseCase = class {
|
|
7
|
+
constructor(messageHandler, gateway) {
|
|
8
|
+
this.messageHandler = messageHandler;
|
|
9
|
+
this.gateway = gateway;
|
|
10
|
+
}
|
|
11
|
+
execute(packet, rinfo) {
|
|
12
|
+
if (packet.oscType === "bundle") packet.elements?.forEach((el) => this.execute(el, rinfo));
|
|
13
|
+
else if (packet.oscType === "message") {
|
|
14
|
+
const args = packet.args.map((arg) => {
|
|
15
|
+
if (typeof arg === "object" && arg !== null && "value" in arg) return arg.value;
|
|
16
|
+
return arg;
|
|
17
|
+
});
|
|
18
|
+
this.messageHandler.handle({
|
|
19
|
+
address: packet.address,
|
|
20
|
+
args
|
|
21
|
+
}, rinfo).forEach((reply) => {
|
|
22
|
+
this.gateway.send(rinfo, reply.address, reply.args);
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
//#endregion
|
|
29
|
+
//#region src/domain/ports/ILogger.ts
|
|
30
|
+
/**
|
|
31
|
+
* Standard log categories for the domain.
|
|
32
|
+
*/
|
|
33
|
+
let LogCategory = /* @__PURE__ */ function(LogCategory$1) {
|
|
34
|
+
LogCategory$1["SYSTEM"] = "SYSTEM";
|
|
35
|
+
LogCategory$1["OSC_IN"] = "OSC_IN";
|
|
36
|
+
LogCategory$1["OSC_OUT"] = "OSC_OUT";
|
|
37
|
+
LogCategory$1["DISPATCH"] = "DISPATCH";
|
|
38
|
+
LogCategory$1["STATE"] = "STATE";
|
|
39
|
+
LogCategory$1["SUB"] = "SUB";
|
|
40
|
+
LogCategory$1["METER"] = "METER";
|
|
41
|
+
return LogCategory$1;
|
|
42
|
+
}({});
|
|
43
|
+
|
|
44
|
+
//#endregion
|
|
45
|
+
//#region src/application/use-cases/BroadcastUpdatesUseCase.ts
|
|
46
|
+
var BroadcastUpdatesUseCase = class {
|
|
47
|
+
constructor(subscriptionManager, state, gateway, logger, meterService, schemaRegistry) {
|
|
48
|
+
this.subscriptionManager = subscriptionManager;
|
|
49
|
+
this.state = state;
|
|
50
|
+
this.gateway = gateway;
|
|
51
|
+
this.logger = logger;
|
|
52
|
+
this.meterService = meterService;
|
|
53
|
+
this.schemaRegistry = schemaRegistry;
|
|
54
|
+
}
|
|
55
|
+
execute() {
|
|
56
|
+
this.subscriptionManager.getSubscribers().forEach((sub) => {
|
|
57
|
+
if (sub.type === "path" || !sub.alias) {
|
|
58
|
+
if (sub.type === "meter" && sub.meterPath) {
|
|
59
|
+
const meterData = this.meterService.generateMeterData(sub.meterPath, this.state);
|
|
60
|
+
this.gateway.send({
|
|
61
|
+
address: sub.address,
|
|
62
|
+
port: sub.port
|
|
63
|
+
}, sub.meterPath, [meterData.toBlob()]);
|
|
64
|
+
}
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
const rinfo = {
|
|
68
|
+
address: sub.address,
|
|
69
|
+
port: sub.port
|
|
70
|
+
};
|
|
71
|
+
if (sub.type === "batch" && sub.paths) {
|
|
72
|
+
if (sub.paths.length === 1 && sub.paths[0].startsWith("/meters")) {
|
|
73
|
+
const meterData = this.meterService.generateMeterData(sub.paths[0], this.state);
|
|
74
|
+
this.gateway.send(rinfo, sub.alias, [meterData.toBlob()]);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
let stride;
|
|
78
|
+
const dataSize = sub.paths.length * 4;
|
|
79
|
+
const factor = sub.factor || 0;
|
|
80
|
+
const count = sub.count || 0;
|
|
81
|
+
if (factor > 0) stride = Math.max(factor * 4, dataSize);
|
|
82
|
+
else stride = sub.paths.length === 3 ? 16 : dataSize;
|
|
83
|
+
const blobSize = count * stride;
|
|
84
|
+
if (isNaN(blobSize) || blobSize < 0) {
|
|
85
|
+
this.logger.error(LogCategory.SYSTEM, `Invalid blob size`, {
|
|
86
|
+
alias: sub.alias,
|
|
87
|
+
blobSize,
|
|
88
|
+
count,
|
|
89
|
+
stride,
|
|
90
|
+
factor,
|
|
91
|
+
dataSize
|
|
92
|
+
});
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
const blob = Buffer.alloc(blobSize);
|
|
96
|
+
for (let i = 0; i < count; i++) {
|
|
97
|
+
const root = this.schemaRegistry.getRootFromIndex(sub.start + i);
|
|
98
|
+
let currentOffset = i * stride;
|
|
99
|
+
if (!root) continue;
|
|
100
|
+
sub.paths.forEach((s, pIdx) => {
|
|
101
|
+
if (pIdx * 4 >= stride) return;
|
|
102
|
+
if (s.startsWith("/meters")) blob.writeFloatLE(0, currentOffset);
|
|
103
|
+
else {
|
|
104
|
+
const target = `${root}${s}`;
|
|
105
|
+
const node = this.schemaRegistry.getNode(target);
|
|
106
|
+
let val = this.state.get(target);
|
|
107
|
+
if (val === void 0) val = node ? node.default : 0;
|
|
108
|
+
if (typeof val === "number") if (node && node.type === "f") blob.writeFloatLE(val, currentOffset);
|
|
109
|
+
else blob.writeInt32LE(val, currentOffset);
|
|
110
|
+
else blob.writeInt32LE(0, currentOffset);
|
|
111
|
+
}
|
|
112
|
+
currentOffset += 4;
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
this.gateway.send(rinfo, sub.alias, [blob]);
|
|
116
|
+
} else if (sub.type === "format" && sub.pattern) {
|
|
117
|
+
const stride = sub.factor ? sub.factor * 4 : 4;
|
|
118
|
+
const start = sub.start || 0;
|
|
119
|
+
const count = sub.count || 0;
|
|
120
|
+
const totalSize = (start + count) * stride;
|
|
121
|
+
if (isNaN(totalSize) || totalSize < 0) return;
|
|
122
|
+
const blob = Buffer.alloc(totalSize);
|
|
123
|
+
let offset = start * stride;
|
|
124
|
+
for (let i = start; i < start + count; i++) {
|
|
125
|
+
const id = i.toString().padStart(2, "0");
|
|
126
|
+
const target = sub.pattern.replace(/\*\*?/, id);
|
|
127
|
+
let val = this.state.get(target);
|
|
128
|
+
if (val === void 0) val = 0;
|
|
129
|
+
if (typeof val === "number") {
|
|
130
|
+
const node = this.schemaRegistry.getNode(target);
|
|
131
|
+
if (node && node.type === "f") blob.writeFloatLE(val, offset);
|
|
132
|
+
else blob.writeInt32LE(val, offset);
|
|
133
|
+
} else blob.writeInt32LE(0, offset);
|
|
134
|
+
offset += stride;
|
|
135
|
+
}
|
|
136
|
+
this.gateway.send(rinfo, sub.alias, [blob]);
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
broadcastSingleChange(path, value) {
|
|
141
|
+
this.subscriptionManager.getSubscribers().forEach((sub) => {
|
|
142
|
+
if (path === sub.path || sub.path === "/xremote" || sub.path && sub.path.includes("*") && path.startsWith(sub.path.split("*")[0])) this.gateway.send({
|
|
143
|
+
address: sub.address,
|
|
144
|
+
port: sub.port
|
|
145
|
+
}, path, [value]);
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
//#endregion
|
|
151
|
+
//#region src/application/use-cases/ManageSessionsUseCase.ts
|
|
152
|
+
var ManageSessionsUseCase = class {
|
|
153
|
+
constructor(subscriptionManager) {
|
|
154
|
+
this.subscriptionManager = subscriptionManager;
|
|
155
|
+
}
|
|
156
|
+
cleanup() {
|
|
157
|
+
this.subscriptionManager.cleanup();
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
//#endregion
|
|
162
|
+
//#region src/domain/entities/SubscriptionManager.ts
|
|
163
|
+
/**
|
|
164
|
+
* Manages OSC client subscriptions and their lifecycle.
|
|
165
|
+
*/
|
|
166
|
+
var SubscriptionManager = class {
|
|
167
|
+
logger;
|
|
168
|
+
subscribers = [];
|
|
169
|
+
constructor(logger) {
|
|
170
|
+
this.logger = logger;
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Cleans up expired subscriptions.
|
|
174
|
+
* Standard X32 subscriptions last 10 seconds.
|
|
175
|
+
*/
|
|
176
|
+
cleanup() {
|
|
177
|
+
const now = Date.now();
|
|
178
|
+
const initialCount = this.subscribers.length;
|
|
179
|
+
this.subscribers = this.subscribers.filter((s) => {
|
|
180
|
+
const active = s.expires > now;
|
|
181
|
+
if (!active) this.logger.debug(LogCategory.SUB, `Expired subscriber`, {
|
|
182
|
+
type: s.type,
|
|
183
|
+
ip: s.address,
|
|
184
|
+
port: s.port,
|
|
185
|
+
path: "path" in s ? s.path : s.alias
|
|
186
|
+
});
|
|
187
|
+
return active;
|
|
188
|
+
});
|
|
189
|
+
if (this.subscribers.length !== initialCount) this.logger.info(LogCategory.SUB, `Cleanup finished`, {
|
|
190
|
+
removed: initialCount - this.subscribers.length,
|
|
191
|
+
remaining: this.subscribers.length
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Gets all active subscribers.
|
|
196
|
+
* @returns Array of subscribers.
|
|
197
|
+
*/
|
|
198
|
+
getSubscribers() {
|
|
199
|
+
return this.subscribers;
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Adds or renews a path-based subscription (e.g. /subscribe or /xremote).
|
|
203
|
+
* @param rinfo - Client remote info.
|
|
204
|
+
* @param path - Subscription path.
|
|
205
|
+
*/
|
|
206
|
+
addPathSubscriber(rinfo, path) {
|
|
207
|
+
const key = `${rinfo.address}:${rinfo.port}:${path}`;
|
|
208
|
+
const expires = Date.now() + 1e4;
|
|
209
|
+
const existing = this.subscribers.find((s) => s.type === "path" && `${s.address}:${s.port}:${s.path}` === key);
|
|
210
|
+
if (existing) {
|
|
211
|
+
existing.expires = expires;
|
|
212
|
+
this.logger.debug(LogCategory.SUB, `Renewed subscription`, {
|
|
213
|
+
ip: rinfo.address,
|
|
214
|
+
path
|
|
215
|
+
});
|
|
216
|
+
} else {
|
|
217
|
+
this.subscribers.push({
|
|
218
|
+
type: "path",
|
|
219
|
+
address: rinfo.address,
|
|
220
|
+
port: rinfo.port,
|
|
221
|
+
path,
|
|
222
|
+
expires
|
|
223
|
+
});
|
|
224
|
+
this.logger.info(LogCategory.SUB, `New subscription`, {
|
|
225
|
+
ip: rinfo.address,
|
|
226
|
+
path
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* Adds or renews a batch subscription.
|
|
232
|
+
* @param rinfo - Client remote info.
|
|
233
|
+
* @param alias - Response alias.
|
|
234
|
+
* @param paths - Target paths.
|
|
235
|
+
* @param start - Start index.
|
|
236
|
+
* @param count - Count.
|
|
237
|
+
* @param factor - Frequency factor.
|
|
238
|
+
* @param args - Command arguments.
|
|
239
|
+
*/
|
|
240
|
+
addBatchSubscriber(rinfo, alias, paths, start, count, factor, args) {
|
|
241
|
+
const expires = Date.now() + 1e4;
|
|
242
|
+
this.subscribers = this.subscribers.filter((s) => !(s.type === "batch" && s.alias === alias && s.address === rinfo.address));
|
|
243
|
+
this.subscribers.push({
|
|
244
|
+
type: "batch",
|
|
245
|
+
address: rinfo.address,
|
|
246
|
+
port: rinfo.port,
|
|
247
|
+
alias,
|
|
248
|
+
paths,
|
|
249
|
+
start,
|
|
250
|
+
count,
|
|
251
|
+
factor,
|
|
252
|
+
expires,
|
|
253
|
+
args
|
|
254
|
+
});
|
|
255
|
+
this.logger.info(LogCategory.SUB, `Batch subscription`, {
|
|
256
|
+
alias,
|
|
257
|
+
count: paths.length,
|
|
258
|
+
factor,
|
|
259
|
+
ip: rinfo.address,
|
|
260
|
+
args
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Adds or renews a format subscription.
|
|
265
|
+
* @param rinfo - Client remote info.
|
|
266
|
+
* @param alias - Response alias.
|
|
267
|
+
* @param pattern - Path pattern.
|
|
268
|
+
* @param start - Start index.
|
|
269
|
+
* @param count - Count.
|
|
270
|
+
* @param factor - Frequency factor.
|
|
271
|
+
*/
|
|
272
|
+
addFormatSubscriber(rinfo, alias, pattern, start, count, factor) {
|
|
273
|
+
const expires = Date.now() + 1e4;
|
|
274
|
+
this.subscribers = this.subscribers.filter((s) => !(s.type === "format" && s.alias === alias && s.address === rinfo.address));
|
|
275
|
+
this.subscribers.push({
|
|
276
|
+
type: "format",
|
|
277
|
+
address: rinfo.address,
|
|
278
|
+
port: rinfo.port,
|
|
279
|
+
alias,
|
|
280
|
+
pattern,
|
|
281
|
+
start,
|
|
282
|
+
count,
|
|
283
|
+
factor,
|
|
284
|
+
expires
|
|
285
|
+
});
|
|
286
|
+
this.logger.info(LogCategory.SUB, `Format subscription`, {
|
|
287
|
+
alias,
|
|
288
|
+
pattern,
|
|
289
|
+
factor,
|
|
290
|
+
ip: rinfo.address
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* Adds or renews a high-frequency meter subscription.
|
|
295
|
+
* @param rinfo - Client remote info.
|
|
296
|
+
* @param meterPath - Target meter path.
|
|
297
|
+
*/
|
|
298
|
+
addMeterSubscriber(rinfo, meterPath) {
|
|
299
|
+
const expires = Date.now() + 1e4;
|
|
300
|
+
const existing = this.subscribers.find((s) => s.type === "meter" && s.meterPath === meterPath && s.address === rinfo.address);
|
|
301
|
+
this.subscribers = this.subscribers.filter((s) => !(s.type === "meter" && s.meterPath === meterPath && s.address === rinfo.address));
|
|
302
|
+
this.subscribers.push({
|
|
303
|
+
type: "meter",
|
|
304
|
+
address: rinfo.address,
|
|
305
|
+
port: rinfo.port,
|
|
306
|
+
meterPath,
|
|
307
|
+
expires
|
|
308
|
+
});
|
|
309
|
+
if (!existing) this.logger.info(LogCategory.SUB, `Meter subscription`, {
|
|
310
|
+
path: meterPath,
|
|
311
|
+
ip: rinfo.address
|
|
312
|
+
});
|
|
313
|
+
else this.logger.debug(LogCategory.SUB, `Meter renewal`, {
|
|
314
|
+
path: meterPath,
|
|
315
|
+
ip: rinfo.address
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
/**
|
|
319
|
+
* Removes a subscription.
|
|
320
|
+
* @param rinfo - Client remote info.
|
|
321
|
+
* @param path - Subscription path.
|
|
322
|
+
*/
|
|
323
|
+
removeSubscriber(rinfo, path) {
|
|
324
|
+
const initial = this.subscribers.length;
|
|
325
|
+
this.subscribers = this.subscribers.filter((s) => !(s.address === rinfo.address && s.port === rinfo.port && s.path === path));
|
|
326
|
+
if (this.subscribers.length < initial) this.logger.info(LogCategory.SUB, `Unsubscribed`, {
|
|
327
|
+
path,
|
|
328
|
+
ip: rinfo.address
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
//#endregion
|
|
334
|
+
//#region src/domain/services/strategies/NodeDiscoveryStrategy.ts
|
|
335
|
+
/**
|
|
336
|
+
* Handles the /node command for tree traversal/discovery.
|
|
337
|
+
* Returns a list of child nodes for a given path.
|
|
338
|
+
*/
|
|
339
|
+
var NodeDiscoveryStrategy = class {
|
|
340
|
+
constructor(schemaRegistry) {
|
|
341
|
+
this.schemaRegistry = schemaRegistry;
|
|
342
|
+
}
|
|
343
|
+
canHandle(address) {
|
|
344
|
+
return address === "/node";
|
|
345
|
+
}
|
|
346
|
+
execute(msg, _source) {
|
|
347
|
+
const queryPath = msg.args[0];
|
|
348
|
+
if (!queryPath) return [];
|
|
349
|
+
const children = /* @__PURE__ */ new Set();
|
|
350
|
+
const prefix = queryPath.endsWith("/") ? queryPath : `${queryPath}/`;
|
|
351
|
+
for (const key of this.schemaRegistry.getAllPaths()) if (key.startsWith(prefix)) {
|
|
352
|
+
const segment = key.slice(prefix.length).split("/")[0];
|
|
353
|
+
if (segment) children.add(segment);
|
|
354
|
+
}
|
|
355
|
+
return [{
|
|
356
|
+
address: "/node",
|
|
357
|
+
args: [queryPath, ...children]
|
|
358
|
+
}];
|
|
359
|
+
}
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
//#endregion
|
|
363
|
+
//#region src/domain/services/strategies/StaticResponseStrategy.ts
|
|
364
|
+
/**
|
|
365
|
+
* Handles discovery and static information queries.
|
|
366
|
+
* e.g., /status, /xinfo, /-prefs/...
|
|
367
|
+
*/
|
|
368
|
+
var StaticResponseStrategy = class {
|
|
369
|
+
constructor(serverIp, serverName, serverModel, staticResponseService) {
|
|
370
|
+
this.serverIp = serverIp;
|
|
371
|
+
this.serverName = serverName;
|
|
372
|
+
this.serverModel = serverModel;
|
|
373
|
+
this.staticResponseService = staticResponseService;
|
|
374
|
+
}
|
|
375
|
+
canHandle(address) {
|
|
376
|
+
return !!this.staticResponseService.getResponse(address);
|
|
377
|
+
}
|
|
378
|
+
execute(msg, _source) {
|
|
379
|
+
const rawResponse = this.staticResponseService.getResponse(msg.address);
|
|
380
|
+
if (!rawResponse) return [];
|
|
381
|
+
const args = rawResponse.map((arg) => {
|
|
382
|
+
if (typeof arg === "string") return arg.replace("{{ip}}", this.serverIp).replace("{{name}}", this.serverName).replace("{{model}}", this.serverModel);
|
|
383
|
+
return arg;
|
|
384
|
+
});
|
|
385
|
+
return [{
|
|
386
|
+
address: msg.address,
|
|
387
|
+
args
|
|
388
|
+
}];
|
|
389
|
+
}
|
|
390
|
+
};
|
|
391
|
+
|
|
392
|
+
//#endregion
|
|
393
|
+
//#region src/domain/services/strategies/SubscriptionStrategy.ts
|
|
394
|
+
var SubscriptionStrategy = class {
|
|
395
|
+
constructor(subscriptionManager, state, logger) {
|
|
396
|
+
this.subscriptionManager = subscriptionManager;
|
|
397
|
+
this.state = state;
|
|
398
|
+
this.logger = logger;
|
|
399
|
+
}
|
|
400
|
+
canHandle(address) {
|
|
401
|
+
return [
|
|
402
|
+
"/subscribe",
|
|
403
|
+
"/renew",
|
|
404
|
+
"/unsubscribe",
|
|
405
|
+
"/xremote"
|
|
406
|
+
].includes(address);
|
|
407
|
+
}
|
|
408
|
+
/**
|
|
409
|
+
* Handles Session and Subscription lifecycle.
|
|
410
|
+
*
|
|
411
|
+
* ROUTES:
|
|
412
|
+
* - /xremote: Firehose subscription (requests all console updates for 10s).
|
|
413
|
+
* - /subscribe [path]: Requests updates for a specific node path for 10s.
|
|
414
|
+
* - /renew [path]: Resets the 10s watchdog timer for a path.
|
|
415
|
+
* - /unsubscribe [path]: Stops updates for a path.
|
|
416
|
+
* @param msg - Parsed OSC message.
|
|
417
|
+
* @param source - Source address and port of the packet.
|
|
418
|
+
* @returns Returns the current value of the path upon subscription.
|
|
419
|
+
*/
|
|
420
|
+
execute(msg, source) {
|
|
421
|
+
const addr = msg.address;
|
|
422
|
+
if (addr === "/xremote") {
|
|
423
|
+
this.subscriptionManager.addPathSubscriber(source, "/xremote");
|
|
424
|
+
return [{
|
|
425
|
+
address: "/xremote",
|
|
426
|
+
args: []
|
|
427
|
+
}];
|
|
428
|
+
}
|
|
429
|
+
if (addr === "/unsubscribe") {
|
|
430
|
+
this.subscriptionManager.removeSubscriber(source, msg.args[0]);
|
|
431
|
+
this.logger.debug(LogCategory.SUB, `[UNSUB] ${msg.args[0]}`);
|
|
432
|
+
return [];
|
|
433
|
+
}
|
|
434
|
+
const target = msg.args[0];
|
|
435
|
+
this.subscriptionManager.addPathSubscriber(source, target);
|
|
436
|
+
const val = this.state.get(target);
|
|
437
|
+
if (val !== void 0) {
|
|
438
|
+
this.logger.debug(LogCategory.SUB, `[SUB] ${target} -> ${val}`);
|
|
439
|
+
return [{
|
|
440
|
+
address: target,
|
|
441
|
+
args: [val]
|
|
442
|
+
}];
|
|
443
|
+
}
|
|
444
|
+
return [];
|
|
445
|
+
}
|
|
446
|
+
};
|
|
447
|
+
|
|
448
|
+
//#endregion
|
|
449
|
+
//#region src/domain/services/strategies/BatchStrategy.ts
|
|
450
|
+
/**
|
|
451
|
+
* Handles bulk data subscription requests from advanced clients like X32-Edit or Mixing Station.
|
|
452
|
+
*
|
|
453
|
+
* SUPPORTED COMMANDS:
|
|
454
|
+
* - /formatsubscribe: Requests a range of numbered nodes (e.g. /ch/01-32/mix/fader).
|
|
455
|
+
* - /batchsubscribe: Aggregates multiple parameters into a single binary blob response.
|
|
456
|
+
*/
|
|
457
|
+
var BatchStrategy = class {
|
|
458
|
+
/**
|
|
459
|
+
* Initializes the strategy.
|
|
460
|
+
* @param subscriptionManager - Session manager.
|
|
461
|
+
* @param logger - Logger instance.
|
|
462
|
+
*/
|
|
463
|
+
constructor(subscriptionManager, logger) {
|
|
464
|
+
this.subscriptionManager = subscriptionManager;
|
|
465
|
+
this.logger = logger;
|
|
466
|
+
}
|
|
467
|
+
/** @inheritdoc */
|
|
468
|
+
canHandle(address) {
|
|
469
|
+
return ["/batchsubscribe", "/formatsubscribe"].includes(address);
|
|
470
|
+
}
|
|
471
|
+
/** @inheritdoc */
|
|
472
|
+
execute(msg, source) {
|
|
473
|
+
const addr = msg.address;
|
|
474
|
+
if (addr === "/formatsubscribe") {
|
|
475
|
+
const alias = msg.args[0];
|
|
476
|
+
const pattern = msg.args[1];
|
|
477
|
+
const start = msg.args[2];
|
|
478
|
+
const end = msg.args[3];
|
|
479
|
+
const factor = msg.args[4];
|
|
480
|
+
const count = end - start + 1;
|
|
481
|
+
this.subscriptionManager.addFormatSubscriber(source, alias, pattern, start, count, factor);
|
|
482
|
+
this.logger.debug(LogCategory.SUB, `[FORMAT] ${alias} factor: ${factor}`);
|
|
483
|
+
return [];
|
|
484
|
+
}
|
|
485
|
+
if (addr === "/batchsubscribe") {
|
|
486
|
+
const alias = msg.args[0];
|
|
487
|
+
const firstIntIndex = msg.args.findIndex((a) => typeof a === "number");
|
|
488
|
+
if (firstIntIndex === -1) return [];
|
|
489
|
+
const paths = msg.args.slice(1, firstIntIndex);
|
|
490
|
+
const intArgs = msg.args.slice(firstIntIndex);
|
|
491
|
+
const factor = intArgs.pop();
|
|
492
|
+
const cmdArgs = intArgs;
|
|
493
|
+
this.subscriptionManager.addBatchSubscriber(source, alias, paths, 0, 1, factor, cmdArgs);
|
|
494
|
+
this.logger.debug(LogCategory.SUB, `[BATCH] ${alias} paths: ${JSON.stringify(paths)} args: ${JSON.stringify(cmdArgs)} factor: ${factor}`);
|
|
495
|
+
return [];
|
|
496
|
+
}
|
|
497
|
+
return [];
|
|
498
|
+
}
|
|
499
|
+
};
|
|
500
|
+
|
|
501
|
+
//#endregion
|
|
502
|
+
//#region src/domain/services/strategies/MeterStrategy.ts
|
|
503
|
+
/**
|
|
504
|
+
* Handles high-frequency metering data requests.
|
|
505
|
+
* Meters are sent as binary blobs to optimize network bandwidth.
|
|
506
|
+
* Supports both direct address requests (/meters/1) and argument-based requests (/meters ,s "/meters/1").
|
|
507
|
+
*/
|
|
508
|
+
var MeterStrategy = class {
|
|
509
|
+
/**
|
|
510
|
+
* Initializes the strategy.
|
|
511
|
+
* @param subscriptionManager - Session manager.
|
|
512
|
+
* @param state - Current mixer state.
|
|
513
|
+
* @param meterService - Service to generate meter data.
|
|
514
|
+
*/
|
|
515
|
+
constructor(subscriptionManager, state, meterService) {
|
|
516
|
+
this.subscriptionManager = subscriptionManager;
|
|
517
|
+
this.state = state;
|
|
518
|
+
this.meterService = meterService;
|
|
519
|
+
}
|
|
520
|
+
/** @inheritdoc */
|
|
521
|
+
canHandle(address) {
|
|
522
|
+
return address.startsWith("/meters");
|
|
523
|
+
}
|
|
524
|
+
/** @inheritdoc */
|
|
525
|
+
execute(msg, source) {
|
|
526
|
+
let meterPath = msg.address;
|
|
527
|
+
if (msg.address === "/meters" && typeof msg.args[0] === "string" && msg.args[0].startsWith("/meters")) meterPath = msg.args[0];
|
|
528
|
+
this.subscriptionManager.addMeterSubscriber(source, meterPath);
|
|
529
|
+
const blob = this.meterService.generateMeterData(meterPath, this.state).toBlob();
|
|
530
|
+
return [{
|
|
531
|
+
address: meterPath,
|
|
532
|
+
args: [blob]
|
|
533
|
+
}];
|
|
534
|
+
}
|
|
535
|
+
};
|
|
536
|
+
|
|
537
|
+
//#endregion
|
|
538
|
+
//#region src/domain/services/strategies/StateAccessStrategy.ts
|
|
539
|
+
/**
|
|
540
|
+
* General-purpose strategy for reading and writing console parameters.
|
|
541
|
+
* acts as the fallback for any command that matches the schema.
|
|
542
|
+
*
|
|
543
|
+
* LOGIC:
|
|
544
|
+
* - If no arguments: Treats as a QUERY (GET) and returns the current value.
|
|
545
|
+
* - If arguments provided: Treats as an UPDATE (SET) and stores the new value.
|
|
546
|
+
*/
|
|
547
|
+
var StateAccessStrategy = class {
|
|
548
|
+
/**
|
|
549
|
+
* Initializes the strategy.
|
|
550
|
+
* @param state - Current mixer state.
|
|
551
|
+
* @param logger - Logger instance.
|
|
552
|
+
* @param schemaRegistry - Registry to validate addresses.
|
|
553
|
+
*/
|
|
554
|
+
constructor(state, logger, schemaRegistry) {
|
|
555
|
+
this.state = state;
|
|
556
|
+
this.logger = logger;
|
|
557
|
+
this.schemaRegistry = schemaRegistry;
|
|
558
|
+
}
|
|
559
|
+
/** @inheritdoc */
|
|
560
|
+
canHandle(address) {
|
|
561
|
+
return this.schemaRegistry.has(address);
|
|
562
|
+
}
|
|
563
|
+
/** @inheritdoc */
|
|
564
|
+
execute(msg, _source) {
|
|
565
|
+
const addr = msg.address;
|
|
566
|
+
const node = this.schemaRegistry.getNode(addr);
|
|
567
|
+
if (msg.args.length === 0) {
|
|
568
|
+
const val$1 = this.state.get(addr);
|
|
569
|
+
if (val$1 !== void 0) return [{
|
|
570
|
+
address: addr,
|
|
571
|
+
args: [val$1]
|
|
572
|
+
}];
|
|
573
|
+
return [];
|
|
574
|
+
}
|
|
575
|
+
const val = msg.args[0];
|
|
576
|
+
if (node) {
|
|
577
|
+
if (!node.validate(val)) {
|
|
578
|
+
this.logger.warn(LogCategory.DISPATCH, `[TYPE ERR] ${addr} expected ${node.type}, got ${typeof val}`);
|
|
579
|
+
return [];
|
|
580
|
+
}
|
|
581
|
+
this.state.set(addr, val);
|
|
582
|
+
this.logger.debug(LogCategory.STATE, `[SET] ${addr} -> ${val}`);
|
|
583
|
+
if (addr.startsWith("/config/mute/")) {
|
|
584
|
+
const groupIdx = parseInt(addr.split("/").pop(), 10);
|
|
585
|
+
if (!isNaN(groupIdx)) this.state.handleMuteGroupChange(groupIdx, val);
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
return [];
|
|
589
|
+
}
|
|
590
|
+
};
|
|
591
|
+
|
|
592
|
+
//#endregion
|
|
593
|
+
//#region src/domain/services/OscMessageHandler.ts
|
|
594
|
+
/**
|
|
595
|
+
* The central dispatcher for incoming OSC messages.
|
|
596
|
+
* Uses a Chain of Responsibility (Strategy pattern) to delegate handling.
|
|
597
|
+
* Order of strategies is critical for correct prioritization.
|
|
598
|
+
*/
|
|
599
|
+
var OscMessageHandler = class {
|
|
600
|
+
/** List of active command strategies. */
|
|
601
|
+
strategies;
|
|
602
|
+
/**
|
|
603
|
+
* Initializes the handler with all available strategies.
|
|
604
|
+
* @param state - Global mixer state.
|
|
605
|
+
* @param subscriptionManager - Active client sessions.
|
|
606
|
+
* @param logger - Logger instance.
|
|
607
|
+
* @param serverIp - Host IP reported in handshakes.
|
|
608
|
+
* @param serverName - Console name reported in handshakes.
|
|
609
|
+
* @param serverModel - Console model reported in handshakes.
|
|
610
|
+
* @param meterService - Service for metering.
|
|
611
|
+
* @param schemaRegistry - Service for schema validation.
|
|
612
|
+
* @param staticResponseService - Service for static responses.
|
|
613
|
+
*/
|
|
614
|
+
constructor(state, subscriptionManager, logger, serverIp, serverName, serverModel, meterService, schemaRegistry, staticResponseService) {
|
|
615
|
+
this.logger = logger;
|
|
616
|
+
this.meterService = meterService;
|
|
617
|
+
this.schemaRegistry = schemaRegistry;
|
|
618
|
+
this.staticResponseService = staticResponseService;
|
|
619
|
+
this.strategies = [
|
|
620
|
+
new NodeDiscoveryStrategy(this.schemaRegistry),
|
|
621
|
+
new StaticResponseStrategy(serverIp, serverName, serverModel, this.staticResponseService),
|
|
622
|
+
new SubscriptionStrategy(subscriptionManager, state, logger),
|
|
623
|
+
new BatchStrategy(subscriptionManager, logger),
|
|
624
|
+
new MeterStrategy(subscriptionManager, state, this.meterService),
|
|
625
|
+
new StateAccessStrategy(state, logger, this.schemaRegistry)
|
|
626
|
+
];
|
|
627
|
+
}
|
|
628
|
+
/**
|
|
629
|
+
* Dispatches an incoming message to the first matching strategy.
|
|
630
|
+
* @param msg - Parsed OSC message.
|
|
631
|
+
* @param source - Source address and port of the packet.
|
|
632
|
+
* @returns Array of replies generated by the strategy.
|
|
633
|
+
*/
|
|
634
|
+
handle(msg, source) {
|
|
635
|
+
const addr = msg.address;
|
|
636
|
+
if (!addr.startsWith("/meters")) this.logger.debug(LogCategory.DISPATCH, `Handling`, {
|
|
637
|
+
addr,
|
|
638
|
+
args: msg.args
|
|
639
|
+
});
|
|
640
|
+
for (const strategy of this.strategies) if (strategy.canHandle(addr)) {
|
|
641
|
+
const replies = strategy.execute(msg, source);
|
|
642
|
+
if (replies.length > 0) this.logger.debug(LogCategory.DISPATCH, `Strategy ${strategy.constructor.name} replied`, { count: replies.length });
|
|
643
|
+
return replies;
|
|
644
|
+
}
|
|
645
|
+
this.logger.warn(LogCategory.DISPATCH, `Unknown Command`, {
|
|
646
|
+
addr,
|
|
647
|
+
args: msg.args,
|
|
648
|
+
ip: source.address
|
|
649
|
+
});
|
|
650
|
+
return [];
|
|
651
|
+
}
|
|
652
|
+
};
|
|
653
|
+
|
|
654
|
+
//#endregion
|
|
655
|
+
//#region src/domain/models/MeterData.ts
|
|
656
|
+
/**
|
|
657
|
+
* Represents a set of meter values for a specific meter path.
|
|
658
|
+
* Handles the storage of float values and serialization to X32-specific binary blobs.
|
|
659
|
+
*/
|
|
660
|
+
var MeterData = class {
|
|
661
|
+
_path;
|
|
662
|
+
_values;
|
|
663
|
+
/**
|
|
664
|
+
* Creates a new MeterData instance.
|
|
665
|
+
* @param path - The meter OSC path (e.g., "/meters/1").
|
|
666
|
+
* @param values - The array of float values (0.0 - 1.0 or similar).
|
|
667
|
+
*/
|
|
668
|
+
constructor(path, values) {
|
|
669
|
+
this._path = path;
|
|
670
|
+
this._values = values;
|
|
671
|
+
}
|
|
672
|
+
/**
|
|
673
|
+
* Gets the meter path.
|
|
674
|
+
*/
|
|
675
|
+
get path() {
|
|
676
|
+
return this._path;
|
|
677
|
+
}
|
|
678
|
+
/**
|
|
679
|
+
* Gets the values.
|
|
680
|
+
*/
|
|
681
|
+
get values() {
|
|
682
|
+
return [...this._values];
|
|
683
|
+
}
|
|
684
|
+
/**
|
|
685
|
+
* Generates an optimized binary blob for X32 meters (mixed endianness).
|
|
686
|
+
* Structure: [Size (Int32BE)] [Count (Int32LE)] [Float1 (LE)] [Float2 (LE)] ...
|
|
687
|
+
* @returns Buffer containing the OSC blob.
|
|
688
|
+
*/
|
|
689
|
+
toBlob() {
|
|
690
|
+
const count = this._values.length;
|
|
691
|
+
const totalSize = 8 + count * 4;
|
|
692
|
+
const blob = Buffer.alloc(totalSize);
|
|
693
|
+
blob.writeInt32BE(totalSize, 0);
|
|
694
|
+
blob.writeInt32LE(count, 4);
|
|
695
|
+
for (let i = 0; i < count; i++) blob.writeFloatLE(this._values[i], 8 + i * 4);
|
|
696
|
+
return blob;
|
|
697
|
+
}
|
|
698
|
+
};
|
|
699
|
+
|
|
700
|
+
//#endregion
|
|
701
|
+
//#region src/domain/models/MeterConfig.ts
|
|
702
|
+
/**
|
|
703
|
+
* Configuration for meter counts per path.
|
|
704
|
+
*/
|
|
705
|
+
const METER_COUNTS = {
|
|
706
|
+
"/meters/0": 70,
|
|
707
|
+
"/meters/1": 96,
|
|
708
|
+
"/meters/2": 49,
|
|
709
|
+
"/meters/3": 22,
|
|
710
|
+
"/meters/4": 82,
|
|
711
|
+
"/meters/5": 27,
|
|
712
|
+
"/meters/6": 4,
|
|
713
|
+
"/meters/7": 16,
|
|
714
|
+
"/meters/8": 6,
|
|
715
|
+
"/meters/9": 32,
|
|
716
|
+
"/meters/10": 32,
|
|
717
|
+
"/meters/11": 5,
|
|
718
|
+
"/meters/12": 4,
|
|
719
|
+
"/meters/13": 48,
|
|
720
|
+
"/meters/14": 80,
|
|
721
|
+
"/meters/15": 50,
|
|
722
|
+
"/meters/16": 48
|
|
723
|
+
};
|
|
724
|
+
|
|
725
|
+
//#endregion
|
|
726
|
+
//#region src/domain/services/MeterService.ts
|
|
727
|
+
/**
|
|
728
|
+
* Service responsible for generating meter values and data.
|
|
729
|
+
* Mimics the physics/audio engine of the X32.
|
|
730
|
+
*/
|
|
731
|
+
var MeterService = class {
|
|
732
|
+
/**
|
|
733
|
+
* Generates a MeterData object for a given path.
|
|
734
|
+
* @param path - The meter OSC path (e.g., "/meters/1").
|
|
735
|
+
* @param state - The current X32 state (for correlating faders to meters).
|
|
736
|
+
* @returns MeterData object containing the values.
|
|
737
|
+
*/
|
|
738
|
+
generateMeterData(path, state) {
|
|
739
|
+
const count = METER_COUNTS[path];
|
|
740
|
+
if (count === void 0) return new MeterData(path, []);
|
|
741
|
+
const values = [];
|
|
742
|
+
for (let i = 0; i < count; i++) {
|
|
743
|
+
let val = Math.random() * .05;
|
|
744
|
+
if (state && path === "/meters/1") {
|
|
745
|
+
if (i < 32) {
|
|
746
|
+
const faderPath = `/ch/${(i + 1).toString().padStart(2, "0")}/mix/fader`;
|
|
747
|
+
const fader = state.get(faderPath);
|
|
748
|
+
if (typeof fader === "number") val = fader > .01 ? fader * (.9 + Math.random() * .1) : 0;
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
values.push(val);
|
|
752
|
+
}
|
|
753
|
+
return new MeterData(path, values);
|
|
754
|
+
}
|
|
755
|
+
};
|
|
756
|
+
|
|
757
|
+
//#endregion
|
|
758
|
+
//#region src/domain/services/StaticResponseService.ts
|
|
759
|
+
/**
|
|
760
|
+
* Static OSC responses for X32 Discovery and System status.
|
|
761
|
+
*/
|
|
762
|
+
const STATIC_RESPONSES_DATA = {
|
|
763
|
+
"/xinfo": [
|
|
764
|
+
"{{ip}}",
|
|
765
|
+
"{{name}}",
|
|
766
|
+
"{{model}}",
|
|
767
|
+
"4.06"
|
|
768
|
+
],
|
|
769
|
+
"/info": [
|
|
770
|
+
"V2.05",
|
|
771
|
+
"{{name}}",
|
|
772
|
+
"{{model}}",
|
|
773
|
+
"4.06"
|
|
774
|
+
],
|
|
775
|
+
"/status": [
|
|
776
|
+
"active",
|
|
777
|
+
"{{ip}}",
|
|
778
|
+
"{{name}}"
|
|
779
|
+
],
|
|
780
|
+
"/-prefs/invertmutes": [0],
|
|
781
|
+
"/-prefs/fine": [0],
|
|
782
|
+
"/-prefs/bright": [50],
|
|
783
|
+
"/-prefs/contrast": [50],
|
|
784
|
+
"/-prefs/led_bright": [50],
|
|
785
|
+
"/-prefs/lcd_bright": [50],
|
|
786
|
+
"/-stat/userpar/1/value": [0],
|
|
787
|
+
"/-stat/userpar/2/value": [0],
|
|
788
|
+
"/-stat/tape/state": [0],
|
|
789
|
+
"/-stat/aes50/A": [0],
|
|
790
|
+
"/-stat/aes50/B": [0],
|
|
791
|
+
"/-stat/solo": [0],
|
|
792
|
+
"/-stat/rtasource": [0],
|
|
793
|
+
"/-urec/errorcode": [0],
|
|
794
|
+
"/-prefs/rta/gain": [0],
|
|
795
|
+
"/-prefs/rta/autogain": [0],
|
|
796
|
+
"/-prefs/hardmute": [0],
|
|
797
|
+
"/-prefs/dcamute": [0],
|
|
798
|
+
"/config/mono/mode": [0],
|
|
799
|
+
"/config/amixenable/X": [0],
|
|
800
|
+
"/config/amixenable/Y": [0],
|
|
801
|
+
"/-show/prepos/current": [0],
|
|
802
|
+
"/-show/showfile/current": [""],
|
|
803
|
+
"/-action/setrtasrc": [0],
|
|
804
|
+
"/-action/playtrack": [0],
|
|
805
|
+
"/-action/goscene": [0],
|
|
806
|
+
"/-action/setscene": [0]
|
|
807
|
+
};
|
|
808
|
+
/**
|
|
809
|
+
* Service providing static system responses.
|
|
810
|
+
* Mimics the fixed firmware responses of the X32.
|
|
811
|
+
*/
|
|
812
|
+
var StaticResponseService = class {
|
|
813
|
+
responses = STATIC_RESPONSES_DATA;
|
|
814
|
+
/**
|
|
815
|
+
* Retrieves a static response for a given path.
|
|
816
|
+
* @param path - The OSC path.
|
|
817
|
+
* @returns The response arguments or undefined.
|
|
818
|
+
*/
|
|
819
|
+
getResponse(path) {
|
|
820
|
+
return this.responses[path];
|
|
821
|
+
}
|
|
822
|
+
};
|
|
823
|
+
|
|
824
|
+
//#endregion
|
|
825
|
+
//#region src/application/use-cases/SimulationService.ts
|
|
826
|
+
/**
|
|
827
|
+
* Gets the first non-internal IPv4 address found on this machine.
|
|
828
|
+
* @returns Detected IP or localhost.
|
|
829
|
+
*/
|
|
830
|
+
function getLocalIp() {
|
|
831
|
+
const interfaces = os.networkInterfaces();
|
|
832
|
+
for (const name of Object.keys(interfaces)) for (const iface of interfaces[name]) if (iface.family === "IPv4" && !iface.internal) return iface.address;
|
|
833
|
+
return "127.0.0.1";
|
|
834
|
+
}
|
|
835
|
+
var SimulationService = class {
|
|
836
|
+
subscriptionManager;
|
|
837
|
+
messageHandler;
|
|
838
|
+
processPacket;
|
|
839
|
+
broadcastUpdates;
|
|
840
|
+
manageSessions;
|
|
841
|
+
meterService;
|
|
842
|
+
staticResponseService;
|
|
843
|
+
updateInterval = null;
|
|
844
|
+
cleanupInterval = null;
|
|
845
|
+
constructor(gateway, logger, stateRepo, schemaRegistry, port = 10023, ip = "0.0.0.0", name = "osc-server", model = "X32") {
|
|
846
|
+
this.gateway = gateway;
|
|
847
|
+
this.logger = logger;
|
|
848
|
+
this.stateRepo = stateRepo;
|
|
849
|
+
this.schemaRegistry = schemaRegistry;
|
|
850
|
+
this.port = port;
|
|
851
|
+
this.ip = ip;
|
|
852
|
+
const state = this.stateRepo.getState();
|
|
853
|
+
this.subscriptionManager = new SubscriptionManager(logger);
|
|
854
|
+
this.meterService = new MeterService();
|
|
855
|
+
this.staticResponseService = new StaticResponseService();
|
|
856
|
+
const reportedIp = this.ip === "0.0.0.0" ? getLocalIp() : this.ip;
|
|
857
|
+
this.messageHandler = new OscMessageHandler(state, this.subscriptionManager, logger, reportedIp, name, model, this.meterService, this.schemaRegistry, this.staticResponseService);
|
|
858
|
+
this.processPacket = new ProcessPacketUseCase(this.messageHandler, gateway);
|
|
859
|
+
this.broadcastUpdates = new BroadcastUpdatesUseCase(this.subscriptionManager, state, gateway, logger, this.meterService, this.schemaRegistry);
|
|
860
|
+
this.manageSessions = new ManageSessionsUseCase(this.subscriptionManager);
|
|
861
|
+
state.on("change", (evt) => {
|
|
862
|
+
this.logger.info(LogCategory.STATE, `State Changed`, evt);
|
|
863
|
+
this.broadcastUpdates.broadcastSingleChange(evt.address, evt.value);
|
|
864
|
+
});
|
|
865
|
+
this.gateway.onPacket((packet, source) => this.processPacket.execute(packet, source));
|
|
866
|
+
}
|
|
867
|
+
async start() {
|
|
868
|
+
await this.gateway.start(this.port, this.ip);
|
|
869
|
+
this.cleanupInterval = setInterval(() => {
|
|
870
|
+
this.manageSessions.cleanup();
|
|
871
|
+
}, 5e3);
|
|
872
|
+
this.updateInterval = setInterval(() => {
|
|
873
|
+
this.broadcastUpdates.execute();
|
|
874
|
+
}, 100);
|
|
875
|
+
}
|
|
876
|
+
async stop() {
|
|
877
|
+
if (this.updateInterval) clearInterval(this.updateInterval);
|
|
878
|
+
if (this.cleanupInterval) clearInterval(this.cleanupInterval);
|
|
879
|
+
await this.gateway.stop();
|
|
880
|
+
}
|
|
881
|
+
resetState() {
|
|
882
|
+
this.stateRepo.reset();
|
|
883
|
+
}
|
|
884
|
+
};
|
|
885
|
+
|
|
886
|
+
//#endregion
|
|
887
|
+
//#region src/domain/entities/X32State.ts
|
|
888
|
+
/**
|
|
889
|
+
* Manages the internal "Digital Twin" state of the X32 console.
|
|
890
|
+
* acts as a single source of truth for all parameters.
|
|
891
|
+
* Emits 'change' events whenever a value is updated.
|
|
892
|
+
*/
|
|
893
|
+
var X32State = class extends EventEmitter {
|
|
894
|
+
/** Map storing all OSC paths and their current values. */
|
|
895
|
+
state = /* @__PURE__ */ new Map();
|
|
896
|
+
defaultState = /* @__PURE__ */ new Map();
|
|
897
|
+
/**
|
|
898
|
+
* Initializes the state with default values from the schema.
|
|
899
|
+
* @param schema - The schema definition map.
|
|
900
|
+
*/
|
|
901
|
+
constructor(schema) {
|
|
902
|
+
super();
|
|
903
|
+
for (const [addr, def] of Object.entries(schema)) this.defaultState.set(addr, def.default);
|
|
904
|
+
this.reset();
|
|
905
|
+
}
|
|
906
|
+
/**
|
|
907
|
+
* Resets all state parameters to their default values defined in the schema.
|
|
908
|
+
*/
|
|
909
|
+
reset() {
|
|
910
|
+
this.state.clear();
|
|
911
|
+
this.defaultState.forEach((val, key) => {
|
|
912
|
+
this.state.set(key, val);
|
|
913
|
+
});
|
|
914
|
+
}
|
|
915
|
+
/**
|
|
916
|
+
* Retrieves the value of a specific OSC node.
|
|
917
|
+
* @param address - The full OSC address path.
|
|
918
|
+
* @returns The stored value (number or string) or undefined if not found.
|
|
919
|
+
*/
|
|
920
|
+
get(address) {
|
|
921
|
+
return this.state.get(address);
|
|
922
|
+
}
|
|
923
|
+
/**
|
|
924
|
+
* Updates the value of a specific OSC node and notifies subscribers.
|
|
925
|
+
* @param address - The full OSC address path.
|
|
926
|
+
* @param value - The new value to store.
|
|
927
|
+
*/
|
|
928
|
+
set(address, value) {
|
|
929
|
+
this.state.set(address, value);
|
|
930
|
+
this.emit("change", {
|
|
931
|
+
address,
|
|
932
|
+
value
|
|
933
|
+
});
|
|
934
|
+
}
|
|
935
|
+
/**
|
|
936
|
+
* Specialized logic to handle X32 Mute Groups side-effects.
|
|
937
|
+
* When a mute group is toggled, it iterates through all channels
|
|
938
|
+
* assigned to that group and updates their individual mute status.
|
|
939
|
+
* @param groupIdx - The index of the mute group (1-6).
|
|
940
|
+
* @param isOn - The new state of the group master switch (0 or 1).
|
|
941
|
+
*/
|
|
942
|
+
handleMuteGroupChange(groupIdx, isOn) {
|
|
943
|
+
for (let i = 1; i <= 32; i++) {
|
|
944
|
+
const ch = i.toString().padStart(2, "0");
|
|
945
|
+
const grpVal = this.get(`/ch/${ch}/grp/mute`);
|
|
946
|
+
if (typeof grpVal === "number") {
|
|
947
|
+
if ((grpVal & 1 << groupIdx - 1) !== 0) {
|
|
948
|
+
const targetMute = isOn === 1 ? 0 : 1;
|
|
949
|
+
const muteAddr = `/ch/${ch}/mix/on`;
|
|
950
|
+
this.set(muteAddr, targetMute);
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
};
|
|
956
|
+
|
|
957
|
+
//#endregion
|
|
958
|
+
//#region src/infrastructure/repositories/InMemoryStateRepository.ts
|
|
959
|
+
var InMemoryStateRepository = class {
|
|
960
|
+
state;
|
|
961
|
+
constructor(logger, schemaRegistry) {
|
|
962
|
+
this.logger = logger;
|
|
963
|
+
this.state = new X32State(schemaRegistry.getSchema());
|
|
964
|
+
}
|
|
965
|
+
getState() {
|
|
966
|
+
return this.state;
|
|
967
|
+
}
|
|
968
|
+
reset() {
|
|
969
|
+
this.logger.info(LogCategory.SYSTEM, "Resetting state to defaults");
|
|
970
|
+
this.state.reset();
|
|
971
|
+
}
|
|
972
|
+
};
|
|
973
|
+
|
|
974
|
+
//#endregion
|
|
975
|
+
//#region src/infrastructure/services/ConsoleLogger.ts
|
|
976
|
+
/**
|
|
977
|
+
* Log levels for the application.
|
|
978
|
+
*/
|
|
979
|
+
let LogLevel = /* @__PURE__ */ function(LogLevel$1) {
|
|
980
|
+
LogLevel$1[LogLevel$1["DEBUG"] = 0] = "DEBUG";
|
|
981
|
+
LogLevel$1[LogLevel$1["INFO"] = 1] = "INFO";
|
|
982
|
+
LogLevel$1[LogLevel$1["WARN"] = 2] = "WARN";
|
|
983
|
+
LogLevel$1[LogLevel$1["ERROR"] = 3] = "ERROR";
|
|
984
|
+
LogLevel$1[LogLevel$1["NONE"] = 4] = "NONE";
|
|
985
|
+
return LogLevel$1;
|
|
986
|
+
}({});
|
|
987
|
+
/**
|
|
988
|
+
* Enhanced logger with category support and environment-based filtering.
|
|
989
|
+
*/
|
|
990
|
+
var ConsoleLogger = class ConsoleLogger {
|
|
991
|
+
static instance;
|
|
992
|
+
level = LogLevel.DEBUG;
|
|
993
|
+
hiddenPatterns = [];
|
|
994
|
+
enabledCategories = new Set([
|
|
995
|
+
LogCategory.SYSTEM,
|
|
996
|
+
LogCategory.OSC_IN,
|
|
997
|
+
LogCategory.OSC_OUT,
|
|
998
|
+
LogCategory.DISPATCH,
|
|
999
|
+
LogCategory.STATE,
|
|
1000
|
+
LogCategory.SUB
|
|
1001
|
+
]);
|
|
1002
|
+
constructor() {
|
|
1003
|
+
if (process.env.HIDDEN_LOG) this.hiddenPatterns = process.env.HIDDEN_LOG.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
|
|
1004
|
+
}
|
|
1005
|
+
/**
|
|
1006
|
+
* Gets the singleton instance.
|
|
1007
|
+
* @returns ConsoleLogger instance.
|
|
1008
|
+
*/
|
|
1009
|
+
static getInstance() {
|
|
1010
|
+
if (!ConsoleLogger.instance) ConsoleLogger.instance = new ConsoleLogger();
|
|
1011
|
+
return ConsoleLogger.instance;
|
|
1012
|
+
}
|
|
1013
|
+
/**
|
|
1014
|
+
* Sets the minimum log level.
|
|
1015
|
+
* @param level - LogLevel.
|
|
1016
|
+
*/
|
|
1017
|
+
setLevel(level) {
|
|
1018
|
+
this.level = level;
|
|
1019
|
+
}
|
|
1020
|
+
/**
|
|
1021
|
+
* Enables a category for output.
|
|
1022
|
+
* @param cat - Category name.
|
|
1023
|
+
*/
|
|
1024
|
+
enableCategory(cat) {
|
|
1025
|
+
this.enabledCategories.add(cat);
|
|
1026
|
+
}
|
|
1027
|
+
/**
|
|
1028
|
+
* Disables a category for output.
|
|
1029
|
+
* @param cat - Category name.
|
|
1030
|
+
*/
|
|
1031
|
+
disableCategory(cat) {
|
|
1032
|
+
this.enabledCategories.delete(cat);
|
|
1033
|
+
}
|
|
1034
|
+
/**
|
|
1035
|
+
* Logs a debug message.
|
|
1036
|
+
* @param category - Log category.
|
|
1037
|
+
* @param msg - Message string.
|
|
1038
|
+
* @param data - Optional metadata.
|
|
1039
|
+
*/
|
|
1040
|
+
debug(category, msg, data) {
|
|
1041
|
+
if (this.shouldHide(msg, data)) return;
|
|
1042
|
+
if (this.level <= LogLevel.DEBUG && this.enabledCategories.has(category)) console.log(this.format("DEBUG", category, msg, data));
|
|
1043
|
+
}
|
|
1044
|
+
/**
|
|
1045
|
+
* Logs an info message.
|
|
1046
|
+
* @param category - Log category.
|
|
1047
|
+
* @param msg - Message string.
|
|
1048
|
+
* @param data - Optional metadata.
|
|
1049
|
+
*/
|
|
1050
|
+
info(category, msg, data) {
|
|
1051
|
+
if (this.shouldHide(msg, data)) return;
|
|
1052
|
+
if (this.level <= LogLevel.INFO && this.enabledCategories.has(category)) console.log(this.format("INFO ", category, msg, data));
|
|
1053
|
+
}
|
|
1054
|
+
/**
|
|
1055
|
+
* Logs a warning message.
|
|
1056
|
+
* @param category - Log category.
|
|
1057
|
+
* @param msg - Message string.
|
|
1058
|
+
* @param data - Optional metadata.
|
|
1059
|
+
*/
|
|
1060
|
+
warn(category, msg, data) {
|
|
1061
|
+
if (this.shouldHide(msg, data)) return;
|
|
1062
|
+
if (this.level <= LogLevel.WARN && this.enabledCategories.has(category)) console.log(this.format("WARN ", category, msg, data));
|
|
1063
|
+
}
|
|
1064
|
+
/**
|
|
1065
|
+
* Logs an error message.
|
|
1066
|
+
* @param category - Log category.
|
|
1067
|
+
* @param msg - Message string.
|
|
1068
|
+
* @param err - Optional error object.
|
|
1069
|
+
*/
|
|
1070
|
+
error(category, msg, err) {
|
|
1071
|
+
if (this.shouldHide(msg, err)) return;
|
|
1072
|
+
if (this.level <= LogLevel.ERROR) console.log(this.format("ERROR", category, msg, err));
|
|
1073
|
+
}
|
|
1074
|
+
/**
|
|
1075
|
+
* Checks if a log should be hidden based on patterns.
|
|
1076
|
+
* @param msg - Message.
|
|
1077
|
+
* @param data - Metadata.
|
|
1078
|
+
* @returns True if log should be suppressed.
|
|
1079
|
+
*/
|
|
1080
|
+
shouldHide(msg, data) {
|
|
1081
|
+
if (this.hiddenPatterns.length === 0) return false;
|
|
1082
|
+
if (this.hiddenPatterns.some((p) => msg.includes(p))) return true;
|
|
1083
|
+
if (data !== void 0) {
|
|
1084
|
+
const strData = Buffer.isBuffer(data) ? data.toString("hex") : typeof data === "object" ? JSON.stringify(data) : String(data);
|
|
1085
|
+
if (this.hiddenPatterns.some((p) => strData.includes(p))) return true;
|
|
1086
|
+
}
|
|
1087
|
+
return false;
|
|
1088
|
+
}
|
|
1089
|
+
/**
|
|
1090
|
+
* Formats the log message.
|
|
1091
|
+
* @param level - Level label.
|
|
1092
|
+
* @param category - Category label.
|
|
1093
|
+
* @param msg - Message.
|
|
1094
|
+
* @param data - Metadata.
|
|
1095
|
+
* @returns Formatted string.
|
|
1096
|
+
*/
|
|
1097
|
+
format(level, category, msg, data) {
|
|
1098
|
+
const now = /* @__PURE__ */ new Date();
|
|
1099
|
+
let out = `\x1b[90m[${`${now.getHours().toString().padStart(2, "0")}:${now.getMinutes().toString().padStart(2, "0")}:${now.getSeconds().toString().padStart(2, "0")}.${now.getMilliseconds().toString().padStart(3, "0")}`}]\x1b[0m `;
|
|
1100
|
+
switch (level) {
|
|
1101
|
+
case "DEBUG":
|
|
1102
|
+
out += `\x1b[36m[DEBUG]\x1b[0m`;
|
|
1103
|
+
break;
|
|
1104
|
+
case "INFO ":
|
|
1105
|
+
out += `\x1b[32m[INFO ]\x1b[0m`;
|
|
1106
|
+
break;
|
|
1107
|
+
case "WARN ":
|
|
1108
|
+
out += `\x1b[33m[WARN ]\x1b[0m`;
|
|
1109
|
+
break;
|
|
1110
|
+
case "ERROR":
|
|
1111
|
+
out += `\x1b[31m[ERROR]\x1b[0m`;
|
|
1112
|
+
break;
|
|
1113
|
+
}
|
|
1114
|
+
out += ` \x1b[35m[${category.padEnd(8)}]\x1b[0m ${msg}`;
|
|
1115
|
+
if (data !== void 0) try {
|
|
1116
|
+
if (Buffer.isBuffer(data)) out += ` \x1b[90m<Buffer ${data.length}b>\x1b[0m`;
|
|
1117
|
+
else if (typeof data === "object" && data !== null) out += ` \x1b[90m${JSON.stringify(data)}\x1b[0m`;
|
|
1118
|
+
else out += ` \x1b[90m${data}\x1b[0m`;
|
|
1119
|
+
} catch {
|
|
1120
|
+
out += ` [Circular/Error]`;
|
|
1121
|
+
}
|
|
1122
|
+
return out;
|
|
1123
|
+
}
|
|
1124
|
+
};
|
|
1125
|
+
|
|
1126
|
+
//#endregion
|
|
1127
|
+
//#region src/infrastructure/services/UdpNetworkGateway.ts
|
|
1128
|
+
var UdpNetworkGateway = class {
|
|
1129
|
+
socket;
|
|
1130
|
+
isRunning = false;
|
|
1131
|
+
packetCallback = null;
|
|
1132
|
+
constructor(logger, codec) {
|
|
1133
|
+
this.logger = logger;
|
|
1134
|
+
this.codec = codec;
|
|
1135
|
+
this.socket = dgram.createSocket("udp4");
|
|
1136
|
+
}
|
|
1137
|
+
onPacket(callback) {
|
|
1138
|
+
this.packetCallback = callback;
|
|
1139
|
+
}
|
|
1140
|
+
start(port, ip) {
|
|
1141
|
+
return new Promise((resolve, reject) => {
|
|
1142
|
+
this.socket.on("error", (err) => {
|
|
1143
|
+
this.logger.error(LogCategory.SYSTEM, "Socket Error", err);
|
|
1144
|
+
reject(err);
|
|
1145
|
+
});
|
|
1146
|
+
this.socket.on("message", (msg, rinfo) => {
|
|
1147
|
+
try {
|
|
1148
|
+
this.logger.debug(LogCategory.OSC_IN, `Packet received ${msg.length}b`, { ip: rinfo.address });
|
|
1149
|
+
const decoded = this.codec.decode(msg);
|
|
1150
|
+
if (this.packetCallback) this.packetCallback(decoded, {
|
|
1151
|
+
address: rinfo.address,
|
|
1152
|
+
port: rinfo.port
|
|
1153
|
+
});
|
|
1154
|
+
} catch (err) {
|
|
1155
|
+
this.logger.error(LogCategory.OSC_IN, "Decode Error", err instanceof Error ? err : new Error(String(err)));
|
|
1156
|
+
}
|
|
1157
|
+
});
|
|
1158
|
+
this.socket.bind(port, ip, () => {
|
|
1159
|
+
this.logger.info(LogCategory.SYSTEM, `Server bound to ${ip}:${port}`);
|
|
1160
|
+
this.isRunning = true;
|
|
1161
|
+
resolve();
|
|
1162
|
+
});
|
|
1163
|
+
});
|
|
1164
|
+
}
|
|
1165
|
+
stop() {
|
|
1166
|
+
return new Promise((resolve) => {
|
|
1167
|
+
if (!this.isRunning) return resolve();
|
|
1168
|
+
this.socket.close(() => {
|
|
1169
|
+
this.isRunning = false;
|
|
1170
|
+
this.logger.info(LogCategory.SYSTEM, "Server stopped");
|
|
1171
|
+
resolve();
|
|
1172
|
+
});
|
|
1173
|
+
});
|
|
1174
|
+
}
|
|
1175
|
+
send(target, address, args) {
|
|
1176
|
+
const buf = this.codec.encode(address, args);
|
|
1177
|
+
const cat = address.startsWith("/meters") ? LogCategory.METER : LogCategory.OSC_OUT;
|
|
1178
|
+
this.logger.debug(cat, `Sending ${address}`, {
|
|
1179
|
+
ip: target.address,
|
|
1180
|
+
args
|
|
1181
|
+
});
|
|
1182
|
+
this.socket.send(buf, target.port, target.address, (err) => {
|
|
1183
|
+
if (err) this.logger.error(LogCategory.OSC_OUT, "Send Error", err);
|
|
1184
|
+
});
|
|
1185
|
+
}
|
|
1186
|
+
};
|
|
1187
|
+
|
|
1188
|
+
//#endregion
|
|
1189
|
+
export { SimulationService as a, InMemoryStateRepository as i, ConsoleLogger as n, STATIC_RESPONSES_DATA as o, LogLevel as r, LogCategory as s, UdpNetworkGateway as t };
|