@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,71 @@
|
|
|
1
|
+
import * as dgram from 'node:dgram';
|
|
2
|
+
import { INetworkGateway } from '../../domain/ports/INetworkGateway';
|
|
3
|
+
import { OscPacket, RemoteClient } from '../../domain/models/types';
|
|
4
|
+
import { OscCodec } from '../mappers/OscCodec';
|
|
5
|
+
import { ILogger, LogCategory } from '../../domain/ports/ILogger';
|
|
6
|
+
|
|
7
|
+
export class UdpNetworkGateway implements INetworkGateway {
|
|
8
|
+
private socket: dgram.Socket;
|
|
9
|
+
private isRunning = false;
|
|
10
|
+
private packetCallback: ((packet: OscPacket, source: RemoteClient) => void) | null = null;
|
|
11
|
+
|
|
12
|
+
constructor(private logger: ILogger, private codec: OscCodec) {
|
|
13
|
+
this.socket = dgram.createSocket('udp4');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
public onPacket(callback: (packet: OscPacket, source: RemoteClient) => void): void {
|
|
17
|
+
this.packetCallback = callback;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
public start(port: number, ip: string): Promise<void> {
|
|
21
|
+
return new Promise((resolve, reject) => {
|
|
22
|
+
this.socket.on('error', (err) => {
|
|
23
|
+
this.logger.error(LogCategory.SYSTEM, 'Socket Error', err);
|
|
24
|
+
reject(err);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
this.socket.on('message', (msg, rinfo) => {
|
|
28
|
+
try {
|
|
29
|
+
this.logger.debug(LogCategory.OSC_IN, `Packet received ${msg.length}b`, { ip: rinfo.address });
|
|
30
|
+
const decoded = this.codec.decode(msg);
|
|
31
|
+
if (this.packetCallback) {
|
|
32
|
+
this.packetCallback(decoded, { address: rinfo.address, port: rinfo.port });
|
|
33
|
+
}
|
|
34
|
+
} catch (err) {
|
|
35
|
+
this.logger.error(LogCategory.OSC_IN, 'Decode Error', err instanceof Error ? err : new Error(String(err)));
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
this.socket.bind(port, ip, () => {
|
|
40
|
+
this.logger.info(LogCategory.SYSTEM, `Server bound to ${ip}:${port}`);
|
|
41
|
+
this.isRunning = true;
|
|
42
|
+
resolve();
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
public stop(): Promise<void> {
|
|
48
|
+
return new Promise((resolve) => {
|
|
49
|
+
if (!this.isRunning) return resolve();
|
|
50
|
+
this.socket.close(() => {
|
|
51
|
+
this.isRunning = false;
|
|
52
|
+
this.logger.info(LogCategory.SYSTEM, 'Server stopped');
|
|
53
|
+
resolve();
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
public send(target: RemoteClient, address: string, args: any[]): void {
|
|
59
|
+
const buf = this.codec.encode(address, args);
|
|
60
|
+
const cat = address.startsWith('/meters') ? LogCategory.METER : LogCategory.OSC_OUT;
|
|
61
|
+
|
|
62
|
+
// Skip noisy meter logging if needed, or rely on logger level
|
|
63
|
+
this.logger.debug(cat, `Sending ${address}`, { ip: target.address, args });
|
|
64
|
+
|
|
65
|
+
this.socket.send(buf, target.port, target.address, (err) => {
|
|
66
|
+
if (err) {
|
|
67
|
+
this.logger.error(LogCategory.OSC_OUT, 'Send Error', err);
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import * as readline from 'node:readline';
|
|
3
|
+
import * as fs from 'node:fs';
|
|
4
|
+
import * as path from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { SimulationService } from '../../application/use-cases/SimulationService';
|
|
7
|
+
import { ConsoleLogger, LogLevel } from '../../infrastructure/services/ConsoleLogger';
|
|
8
|
+
import { UdpNetworkGateway } from '../../infrastructure/services/UdpNetworkGateway';
|
|
9
|
+
import { InMemoryStateRepository } from '../../infrastructure/repositories/InMemoryStateRepository';
|
|
10
|
+
import { SchemaRegistry } from '../../domain/services/SchemaRegistry';
|
|
11
|
+
import { SchemaFactory } from '../../domain/services/SchemaFactory';
|
|
12
|
+
import { OscCodec } from '../../infrastructure/mappers/OscCodec';
|
|
13
|
+
|
|
14
|
+
// Basic .env loader
|
|
15
|
+
export const loadEnv = () => {
|
|
16
|
+
try {
|
|
17
|
+
const envPath = path.resolve(process.cwd(), '.env');
|
|
18
|
+
if (fs.existsSync(envPath)) {
|
|
19
|
+
const content = fs.readFileSync(envPath, 'utf8');
|
|
20
|
+
content.split('\n').forEach(line => {
|
|
21
|
+
const match = line.match(/^([^=]+)=(.*)$/);
|
|
22
|
+
if (match) {
|
|
23
|
+
const key = match[1].trim();
|
|
24
|
+
const value = match[2].trim();
|
|
25
|
+
if (!process.env[key]) {
|
|
26
|
+
process.env[key] = value;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
} catch (err) {
|
|
32
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
33
|
+
console.warn('Failed to load .env file', message);
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export const parseArgs = (argv: string[]) => {
|
|
38
|
+
const args = argv.slice(2);
|
|
39
|
+
let cliPort: number | undefined;
|
|
40
|
+
let cliHost: string | undefined;
|
|
41
|
+
|
|
42
|
+
for (let i = 0; i < args.length; i++) {
|
|
43
|
+
const arg = args[i];
|
|
44
|
+
|
|
45
|
+
// Handle flags
|
|
46
|
+
if (arg === '--port' || arg === '-p') {
|
|
47
|
+
const next = args[i + 1];
|
|
48
|
+
if (next && /^\d+$/.test(next)) {
|
|
49
|
+
cliPort = parseInt(next, 10);
|
|
50
|
+
i++; // Skip next
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (arg === '--ip' || arg === '-h' || arg === '--host') {
|
|
55
|
+
const next = args[i + 1];
|
|
56
|
+
if (next) {
|
|
57
|
+
cliHost = next;
|
|
58
|
+
i++; // Skip next
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Handle positional arguments (simple heuristic)
|
|
64
|
+
// If it looks like an IP (x.x.x.x), treat as Host
|
|
65
|
+
if (!cliHost && /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(arg)) {
|
|
66
|
+
cliHost = arg;
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// If it looks like a port number (and we haven't set port yet), treat as Port
|
|
71
|
+
if (!cliPort && /^\d+$/.test(arg)) {
|
|
72
|
+
cliPort = parseInt(arg, 10);
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const PORT = cliPort || parseInt(process.env.X32_PORT || '10023', 10);
|
|
78
|
+
const HOST = cliHost || process.env.X32_IP || '0.0.0.0';
|
|
79
|
+
|
|
80
|
+
return { PORT, HOST };
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
export const bootstrap = async () => {
|
|
84
|
+
loadEnv();
|
|
85
|
+
const { PORT, HOST } = parseArgs(process.argv);
|
|
86
|
+
|
|
87
|
+
// 0. Configure Logging
|
|
88
|
+
const logger = ConsoleLogger.getInstance();
|
|
89
|
+
logger.setLevel(LogLevel.DEBUG);
|
|
90
|
+
|
|
91
|
+
// 1. Initialize Domain Services (Registry) & Mappers
|
|
92
|
+
const schemaFactory = new SchemaFactory();
|
|
93
|
+
const schemaRegistry = new SchemaRegistry(schemaFactory);
|
|
94
|
+
const oscCodec = new OscCodec(schemaRegistry);
|
|
95
|
+
|
|
96
|
+
// 2. Initialize Infrastructure
|
|
97
|
+
const gateway = new UdpNetworkGateway(logger, oscCodec);
|
|
98
|
+
const stateRepo = new InMemoryStateRepository(logger, schemaRegistry);
|
|
99
|
+
|
|
100
|
+
// 3. Initialize Application Service
|
|
101
|
+
const service = new SimulationService(
|
|
102
|
+
gateway,
|
|
103
|
+
logger,
|
|
104
|
+
stateRepo,
|
|
105
|
+
schemaRegistry,
|
|
106
|
+
PORT,
|
|
107
|
+
HOST
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
try {
|
|
111
|
+
await service.start();
|
|
112
|
+
console.log(`
|
|
113
|
+
╔════════════════════════════════════════╗
|
|
114
|
+
║ 🚀 X32 SIMULATION SERVER ACTIVE ║
|
|
115
|
+
╚════════════════════════════════════════╝
|
|
116
|
+
• IP: ${HOST}
|
|
117
|
+
• Port: ${PORT}
|
|
118
|
+
• Protocol: UDP (Strict X32 Schema)
|
|
119
|
+
• Logging: ENHANCED (DEBUG)
|
|
120
|
+
|
|
121
|
+
👉 Type "exit" or "stop" to shut down.
|
|
122
|
+
👉 Type "reset" to reset faders to default.
|
|
123
|
+
`);
|
|
124
|
+
} catch (err: any) {
|
|
125
|
+
if (err.code === 'EADDRNOTAVAIL') {
|
|
126
|
+
console.error(`\n\x1b[31m❌ FAILED TO BIND TO IP: ${HOST}\x1b[0m`);
|
|
127
|
+
console.error(`\nThe IP address you requested does not exist on this machine.`);
|
|
128
|
+
console.error(`You likely need to create a network alias (Loopback) for it.\n`);
|
|
129
|
+
|
|
130
|
+
const isMac = process.platform === 'darwin';
|
|
131
|
+
const isLinux = process.platform === 'linux';
|
|
132
|
+
const isWin = process.platform === 'win32';
|
|
133
|
+
|
|
134
|
+
if (isMac || isLinux || isWin) {
|
|
135
|
+
console.error(`To fix this, try running the following command:\n`);
|
|
136
|
+
if (isMac) {
|
|
137
|
+
console.error(` \x1b[36msudo ifconfig lo0 alias ${HOST}\x1b[0m`);
|
|
138
|
+
} else if (isLinux) {
|
|
139
|
+
console.error(` \x1b[36msudo ip addr add ${HOST}/32 dev lo\x1b[0m`);
|
|
140
|
+
} else if (isWin) {
|
|
141
|
+
console.error(` \x1b[36mnetsh interface ip add address "Loopback" ${HOST} 255.255.255.255\x1b[0m`);
|
|
142
|
+
console.error(` (Note: Requires 'Microsoft Loopback Adapter' installed and named "Loopback")`);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
console.error(`\nAlternatively, remove the IP argument to listen on all interfaces (0.0.0.0).`);
|
|
146
|
+
process.exit(1);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
console.error("Failed to start server:", err);
|
|
150
|
+
process.exit(1);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// 3. Setup CLI Input
|
|
154
|
+
const rl = readline.createInterface({
|
|
155
|
+
input: process.stdin,
|
|
156
|
+
output: process.stdout
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
rl.on('line', async (input) => {
|
|
160
|
+
const command = input.trim().toLowerCase();
|
|
161
|
+
|
|
162
|
+
switch (command) {
|
|
163
|
+
case 'exit':
|
|
164
|
+
case 'stop':
|
|
165
|
+
case 'quit':
|
|
166
|
+
console.log('Shutting down...');
|
|
167
|
+
await service.stop();
|
|
168
|
+
process.exit(0);
|
|
169
|
+
break;
|
|
170
|
+
|
|
171
|
+
case 'reset':
|
|
172
|
+
service.resetState();
|
|
173
|
+
console.log('✨ Console state reset to defaults.');
|
|
174
|
+
break;
|
|
175
|
+
|
|
176
|
+
default:
|
|
177
|
+
if (command) console.log(`Unknown command: "${command}"`);
|
|
178
|
+
break;
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
// Handle Ctrl+C
|
|
183
|
+
process.on('SIGINT', async () => {
|
|
184
|
+
console.log('\n(SIGINT received)');
|
|
185
|
+
await service.stop();
|
|
186
|
+
process.exit(0);
|
|
187
|
+
});
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
// Check if main module
|
|
191
|
+
const isMainModule = process.argv[1] === fileURLToPath(import.meta.url);
|
|
192
|
+
if (isMainModule) {
|
|
193
|
+
bootstrap();
|
|
194
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export { SimulationService } from '../../application/use-cases/SimulationService';
|
|
2
|
+
export type { INetworkGateway } from '../../domain/ports/INetworkGateway';
|
|
3
|
+
export type { ILogger, LogData } from '../../domain/ports/ILogger';
|
|
4
|
+
export { LogCategory } from '../../domain/ports/ILogger';
|
|
5
|
+
export type { IStateRepository } from '../../domain/ports/IStateRepository';
|
|
6
|
+
export { InMemoryStateRepository } from '../../infrastructure/repositories/InMemoryStateRepository';
|
|
7
|
+
export { ConsoleLogger } from '../../infrastructure/services/ConsoleLogger';
|
|
8
|
+
export { UdpNetworkGateway } from '../../infrastructure/services/UdpNetworkGateway';
|
|
9
|
+
export * from '../../domain/models/types';
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { BroadcastUpdatesUseCase } from '../../../src/application/use-cases/BroadcastUpdatesUseCase';
|
|
3
|
+
import { SubscriptionManager } from '../../../src/domain/entities/SubscriptionManager';
|
|
4
|
+
import { X32State } from '../../../src/domain/entities/X32State';
|
|
5
|
+
import { INetworkGateway } from '../../../src/domain/ports/INetworkGateway';
|
|
6
|
+
import { MeterService } from '../../../src/domain/services/MeterService';
|
|
7
|
+
import { SchemaRegistry } from '../../../src/domain/services/SchemaRegistry';
|
|
8
|
+
import { X32Node } from '../../../src/domain/models/X32Node';
|
|
9
|
+
|
|
10
|
+
describe('BroadcastUpdatesUseCase', () => {
|
|
11
|
+
it('should broadcast single change to xremote', () => {
|
|
12
|
+
const logger = { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() };
|
|
13
|
+
const manager = new SubscriptionManager(logger);
|
|
14
|
+
manager.addPathSubscriber({ address: '1.2.3.4', port: 1234 }, '/xremote');
|
|
15
|
+
|
|
16
|
+
const state = new X32State({});
|
|
17
|
+
const gateway = { send: vi.fn() } as unknown as INetworkGateway;
|
|
18
|
+
const meterService = new MeterService();
|
|
19
|
+
const registry = {} as unknown as SchemaRegistry;
|
|
20
|
+
|
|
21
|
+
const useCase = new BroadcastUpdatesUseCase(manager, state, gateway, logger as any, meterService, registry);
|
|
22
|
+
useCase.broadcastSingleChange('/ch/01/mix/on', 1);
|
|
23
|
+
|
|
24
|
+
expect(gateway.send).toHaveBeenCalledWith(
|
|
25
|
+
expect.objectContaining({ address: '1.2.3.4' }),
|
|
26
|
+
'/ch/01/mix/on',
|
|
27
|
+
[1]
|
|
28
|
+
);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('should execute batch updates', () => {
|
|
32
|
+
const logger = { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() };
|
|
33
|
+
const manager = new SubscriptionManager(logger);
|
|
34
|
+
const client = { address: '1.2.3.4', port: 1234 };
|
|
35
|
+
|
|
36
|
+
// Add a batch subscriber
|
|
37
|
+
manager.addBatchSubscriber(client, '/batch/alias', ['/mix/fader'], 0, 1, 0);
|
|
38
|
+
|
|
39
|
+
const state = new X32State({ '/ch/01/mix/fader': new X32Node('f', 0.5) });
|
|
40
|
+
const gateway = { send: vi.fn() } as unknown as INetworkGateway;
|
|
41
|
+
const meterService = new MeterService();
|
|
42
|
+
const registry = {
|
|
43
|
+
getRootFromIndex: vi.fn().mockReturnValue('/ch/01'),
|
|
44
|
+
getNode: vi.fn().mockReturnValue(new X32Node('f', 0.0))
|
|
45
|
+
} as unknown as SchemaRegistry;
|
|
46
|
+
|
|
47
|
+
const useCase = new BroadcastUpdatesUseCase(manager, state, gateway, logger as any, meterService, registry);
|
|
48
|
+
useCase.execute();
|
|
49
|
+
|
|
50
|
+
expect(gateway.send).toHaveBeenCalledWith(
|
|
51
|
+
expect.objectContaining({ address: '1.2.3.4' }),
|
|
52
|
+
'/batch/alias',
|
|
53
|
+
[expect.any(Buffer)]
|
|
54
|
+
);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('should execute format updates', () => {
|
|
58
|
+
const logger = { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() };
|
|
59
|
+
const manager = new SubscriptionManager(logger);
|
|
60
|
+
const client = { address: '1.2.3.4', port: 1234 };
|
|
61
|
+
|
|
62
|
+
// Add a format subscriber: alias, pattern, start, count, factor
|
|
63
|
+
manager.addFormatSubscriber(client, '/format/alias', '/ch/*/mix/fader', 1, 2, 1);
|
|
64
|
+
|
|
65
|
+
const state = new X32State({
|
|
66
|
+
'/ch/01/mix/fader': new X32Node('f', 0.5),
|
|
67
|
+
'/ch/02/mix/fader': new X32Node('f', 0.6)
|
|
68
|
+
});
|
|
69
|
+
const gateway = { send: vi.fn() } as unknown as INetworkGateway;
|
|
70
|
+
const meterService = new MeterService();
|
|
71
|
+
const registry = {
|
|
72
|
+
getNode: vi.fn().mockReturnValue(new X32Node('f', 0.0))
|
|
73
|
+
} as unknown as SchemaRegistry;
|
|
74
|
+
|
|
75
|
+
const useCase = new BroadcastUpdatesUseCase(manager, state, gateway, logger as any, meterService, registry);
|
|
76
|
+
useCase.execute();
|
|
77
|
+
|
|
78
|
+
expect(gateway.send).toHaveBeenCalledWith(
|
|
79
|
+
expect.objectContaining({ address: '1.2.3.4' }),
|
|
80
|
+
'/format/alias',
|
|
81
|
+
[expect.any(Buffer)]
|
|
82
|
+
);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('should broadcast meter updates', () => {
|
|
86
|
+
const logger = { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() };
|
|
87
|
+
const manager = new SubscriptionManager(logger);
|
|
88
|
+
manager.addMeterSubscriber({ address: '1.2.3.4', port: 1234 }, '/meters/1');
|
|
89
|
+
|
|
90
|
+
const state = new X32State({});
|
|
91
|
+
const gateway = { send: vi.fn() } as unknown as INetworkGateway;
|
|
92
|
+
const meterService = new MeterService();
|
|
93
|
+
const registry = {} as unknown as SchemaRegistry;
|
|
94
|
+
|
|
95
|
+
const useCase = new BroadcastUpdatesUseCase(manager, state, gateway, logger as any, meterService, registry);
|
|
96
|
+
useCase.execute();
|
|
97
|
+
|
|
98
|
+
expect(gateway.send).toHaveBeenCalledWith(
|
|
99
|
+
expect.objectContaining({ address: '1.2.3.4' }),
|
|
100
|
+
'/meters/1',
|
|
101
|
+
[expect.any(Buffer)]
|
|
102
|
+
);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { ManageSessionsUseCase } from '../../../src/application/use-cases/ManageSessionsUseCase';
|
|
3
|
+
import { SubscriptionManager } from '../../../src/domain/entities/SubscriptionManager';
|
|
4
|
+
|
|
5
|
+
describe('ManageSessionsUseCase', () => {
|
|
6
|
+
it('should cleanup sessions', () => {
|
|
7
|
+
const manager = { cleanup: vi.fn() } as unknown as SubscriptionManager;
|
|
8
|
+
const useCase = new ManageSessionsUseCase(manager);
|
|
9
|
+
useCase.cleanup();
|
|
10
|
+
expect(manager.cleanup).toHaveBeenCalled();
|
|
11
|
+
});
|
|
12
|
+
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { ProcessPacketUseCase } from '../../../src/application/use-cases/ProcessPacketUseCase';
|
|
3
|
+
import { OscMessageHandler } from '../../../src/domain/services/OscMessageHandler';
|
|
4
|
+
import { INetworkGateway } from '../../../src/domain/ports/INetworkGateway';
|
|
5
|
+
|
|
6
|
+
describe('ProcessPacketUseCase', () => {
|
|
7
|
+
it('should process message and send replies', () => {
|
|
8
|
+
const handler = {
|
|
9
|
+
handle: vi.fn().mockReturnValue([{ address: '/reply', args: [1] }])
|
|
10
|
+
} as unknown as OscMessageHandler;
|
|
11
|
+
|
|
12
|
+
const gateway = {
|
|
13
|
+
send: vi.fn()
|
|
14
|
+
} as unknown as INetworkGateway;
|
|
15
|
+
|
|
16
|
+
const useCase = new ProcessPacketUseCase(handler, gateway);
|
|
17
|
+
|
|
18
|
+
useCase.execute(
|
|
19
|
+
{ oscType: 'message', address: '/test', args: [123] },
|
|
20
|
+
{ address: '1.2.3.4', port: 1234 }
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
expect(handler.handle).toHaveBeenCalled();
|
|
24
|
+
expect(gateway.send).toHaveBeenCalledWith({ address: '1.2.3.4', port: 1234 }, '/reply', [1]);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('should process bundles', () => {
|
|
28
|
+
const handler = {
|
|
29
|
+
handle: vi.fn().mockReturnValue([])
|
|
30
|
+
} as unknown as OscMessageHandler;
|
|
31
|
+
const gateway = { send: vi.fn() } as unknown as INetworkGateway;
|
|
32
|
+
const useCase = new ProcessPacketUseCase(handler, gateway);
|
|
33
|
+
|
|
34
|
+
useCase.execute(
|
|
35
|
+
{
|
|
36
|
+
oscType: 'bundle',
|
|
37
|
+
address: '', // Bundles don't have address
|
|
38
|
+
args: [],
|
|
39
|
+
elements: [
|
|
40
|
+
{ oscType: 'message', address: '/msg1', args: [] },
|
|
41
|
+
{ oscType: 'message', address: '/msg2', args: [] }
|
|
42
|
+
]
|
|
43
|
+
},
|
|
44
|
+
{ address: '1.2.3.4', port: 1234 }
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
expect(handler.handle).toHaveBeenCalledTimes(2);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { SimulationService } from '../../../src/application/use-cases/SimulationService';
|
|
3
|
+
import { INetworkGateway } from '../../../src/domain/ports/INetworkGateway';
|
|
4
|
+
import { ILogger } from '../../../src/domain/ports/ILogger';
|
|
5
|
+
import { IStateRepository } from '../../../src/domain/ports/IStateRepository';
|
|
6
|
+
import { SchemaRegistry } from '../../../src/domain/services/SchemaRegistry';
|
|
7
|
+
import { X32State } from '../../../src/domain/entities/X32State';
|
|
8
|
+
import * as os from 'os';
|
|
9
|
+
|
|
10
|
+
vi.mock('os', () => ({
|
|
11
|
+
networkInterfaces: vi.fn().mockReturnValue({
|
|
12
|
+
eth0: [{ family: 'IPv4', internal: false, address: '192.168.1.1' }]
|
|
13
|
+
})
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
describe('SimulationService', () => {
|
|
17
|
+
let service: SimulationService;
|
|
18
|
+
let gateway: INetworkGateway;
|
|
19
|
+
let logger: ILogger;
|
|
20
|
+
let stateRepo: IStateRepository;
|
|
21
|
+
let schemaRegistry: SchemaRegistry;
|
|
22
|
+
let state: X32State;
|
|
23
|
+
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
gateway = {
|
|
26
|
+
start: vi.fn().mockResolvedValue(undefined),
|
|
27
|
+
stop: vi.fn().mockResolvedValue(undefined),
|
|
28
|
+
send: vi.fn(),
|
|
29
|
+
onPacket: vi.fn()
|
|
30
|
+
};
|
|
31
|
+
logger = { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() };
|
|
32
|
+
state = new X32State({});
|
|
33
|
+
stateRepo = {
|
|
34
|
+
getState: vi.fn().mockReturnValue(state),
|
|
35
|
+
reset: vi.fn()
|
|
36
|
+
};
|
|
37
|
+
schemaRegistry = {
|
|
38
|
+
getSchema: vi.fn().mockReturnValue({}),
|
|
39
|
+
getNode: vi.fn(),
|
|
40
|
+
getAllPaths: vi.fn().mockReturnValue([])
|
|
41
|
+
} as unknown as SchemaRegistry;
|
|
42
|
+
|
|
43
|
+
// Use 0.0.0.0 to trigger getLocalIp
|
|
44
|
+
service = new SimulationService(
|
|
45
|
+
gateway,
|
|
46
|
+
logger as any,
|
|
47
|
+
stateRepo,
|
|
48
|
+
schemaRegistry,
|
|
49
|
+
10023,
|
|
50
|
+
'0.0.0.0'
|
|
51
|
+
);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('should start the service and detect local IP', async () => {
|
|
55
|
+
await service.start();
|
|
56
|
+
expect(gateway.start).toHaveBeenCalledWith(10023, '0.0.0.0');
|
|
57
|
+
expect(os.networkInterfaces).toHaveBeenCalled();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('should stop the service', async () => {
|
|
61
|
+
await service.start();
|
|
62
|
+
await service.stop();
|
|
63
|
+
expect(gateway.stop).toHaveBeenCalled();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('should handle state change events', () => {
|
|
67
|
+
// Trigger change on the state
|
|
68
|
+
state.emit('change', { address: '/test', value: 1 });
|
|
69
|
+
// Should log and broadcast (indirectly verified by coverage)
|
|
70
|
+
expect(logger.info).toHaveBeenCalled();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('should reset state', () => {
|
|
74
|
+
service.resetState();
|
|
75
|
+
expect(stateRepo.reset).toHaveBeenCalled();
|
|
76
|
+
});
|
|
77
|
+
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { SubscriptionManager } from '../../../src/domain/entities/SubscriptionManager';
|
|
3
|
+
import { ILogger } from '../../../src/domain/ports/ILogger';
|
|
4
|
+
|
|
5
|
+
describe('SubscriptionManager', () => {
|
|
6
|
+
const logger: ILogger = {
|
|
7
|
+
debug: vi.fn(),
|
|
8
|
+
info: vi.fn(),
|
|
9
|
+
warn: vi.fn(),
|
|
10
|
+
error: vi.fn()
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
it('should add a path subscriber', () => {
|
|
14
|
+
const manager = new SubscriptionManager(logger);
|
|
15
|
+
const client = { address: '127.0.0.1', port: 10000 };
|
|
16
|
+
|
|
17
|
+
manager.addPathSubscriber(client, '/ch/01/mix/fader');
|
|
18
|
+
const subs = manager.getSubscribers();
|
|
19
|
+
|
|
20
|
+
expect(subs).toHaveLength(1);
|
|
21
|
+
expect(subs[0].type).toBe('path');
|
|
22
|
+
expect(subs[0].path).toBe('/ch/01/mix/fader');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('should remove subscriber', () => {
|
|
26
|
+
const manager = new SubscriptionManager(logger);
|
|
27
|
+
const client = { address: '127.0.0.1', port: 10000 };
|
|
28
|
+
|
|
29
|
+
manager.addPathSubscriber(client, '/test');
|
|
30
|
+
manager.removeSubscriber(client, '/test');
|
|
31
|
+
|
|
32
|
+
expect(manager.getSubscribers()).toHaveLength(0);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('should cleanup expired subscribers', () => {
|
|
36
|
+
const manager = new SubscriptionManager(logger);
|
|
37
|
+
const client = { address: '127.0.0.1', port: 10000 };
|
|
38
|
+
|
|
39
|
+
manager.addPathSubscriber(client, '/test');
|
|
40
|
+
|
|
41
|
+
// Mock Date.now to fast forward
|
|
42
|
+
const originalNow = Date.now;
|
|
43
|
+
Date.now = vi.fn(() => originalNow() + 20000); // +20s
|
|
44
|
+
|
|
45
|
+
manager.cleanup();
|
|
46
|
+
expect(manager.getSubscribers()).toHaveLength(0);
|
|
47
|
+
|
|
48
|
+
Date.now = originalNow;
|
|
49
|
+
});
|
|
50
|
+
});
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { X32State } from '../../../src/domain/entities/X32State';
|
|
3
|
+
import { X32Node } from '../../../src/domain/models/X32Node';
|
|
4
|
+
|
|
5
|
+
describe('X32State', () => {
|
|
6
|
+
const schema = {
|
|
7
|
+
'/ch/01/mix/fader': new X32Node('f', 0.0),
|
|
8
|
+
'/ch/01/mix/on': new X32Node('i', 0),
|
|
9
|
+
'/ch/01/grp/mute': new X32Node('i', 0), // needed for mute group logic
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
it('should initialize with default values', () => {
|
|
13
|
+
const state = new X32State(schema);
|
|
14
|
+
expect(state.get('/ch/01/mix/fader')).toBe(0.0);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('should update values and emit events', () => {
|
|
18
|
+
const state = new X32State(schema);
|
|
19
|
+
const spy = vi.fn();
|
|
20
|
+
state.on('change', spy);
|
|
21
|
+
|
|
22
|
+
state.set('/ch/01/mix/fader', 0.5);
|
|
23
|
+
expect(state.get('/ch/01/mix/fader')).toBe(0.5);
|
|
24
|
+
expect(spy).toHaveBeenCalledWith({ address: '/ch/01/mix/fader', value: 0.5 });
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('should reset to defaults', () => {
|
|
28
|
+
const state = new X32State(schema);
|
|
29
|
+
state.set('/ch/01/mix/fader', 0.8);
|
|
30
|
+
state.reset();
|
|
31
|
+
expect(state.get('/ch/01/mix/fader')).toBe(0.0);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
// Mute Group Logic Test
|
|
35
|
+
it('should handle mute group side effects', () => {
|
|
36
|
+
const state = new X32State(schema);
|
|
37
|
+
// Assign CH 01 to Mute Group 1 (Bit 0)
|
|
38
|
+
state.set('/ch/01/grp/mute', 1); // 1 << 0
|
|
39
|
+
|
|
40
|
+
const spy = vi.fn();
|
|
41
|
+
state.on('change', spy);
|
|
42
|
+
|
|
43
|
+
// Turn Mute Group 1 ON (1)
|
|
44
|
+
state.handleMuteGroupChange(1, 1);
|
|
45
|
+
|
|
46
|
+
// Expect CH 01 Mute to be 0 (Muted? Logic: isOn=1 -> target=0? Check code: isOn=1 -> target=0. Wait, 0 usually means OFF/Muted or ON/Active? In X32 'on' means passing audio. So Mute Group ON means 'on' parameter becomes 0)
|
|
47
|
+
// Code: const targetMute = isOn === 1 ? 0 : 1;
|
|
48
|
+
// So if MG is ON, channel.on = 0 (Muted). Correct.
|
|
49
|
+
expect(state.get('/ch/01/mix/on')).toBe(0);
|
|
50
|
+
expect(spy).toHaveBeenCalledWith({ address: '/ch/01/mix/on', value: 0 });
|
|
51
|
+
});
|
|
52
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { MeterData } from '../../../src/domain/models/MeterData';
|
|
3
|
+
|
|
4
|
+
describe('MeterData', () => {
|
|
5
|
+
it('should create and store values', () => {
|
|
6
|
+
const data = new MeterData('/meters/1', [0.1, 0.2]);
|
|
7
|
+
expect(data.path).toBe('/meters/1');
|
|
8
|
+
expect(data.values).toEqual([0.1, 0.2]);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it('should encode to binary blob', () => {
|
|
12
|
+
const data = new MeterData('/meters/1', [0.5]);
|
|
13
|
+
const blob = data.toBlob();
|
|
14
|
+
|
|
15
|
+
// Size = 4 (total size) + 4 (count) + 4 (float) = 12 bytes body + header?
|
|
16
|
+
// Implementation: 4 byte size + 4 byte count + floats
|
|
17
|
+
// totalSize = 4 + 4 + (1 * 4) = 12
|
|
18
|
+
|
|
19
|
+
expect(blob.readInt32BE(0)).toBe(12); // Header Size
|
|
20
|
+
expect(blob.readInt32LE(4)).toBe(1); // Count
|
|
21
|
+
expect(blob.readFloatLE(8)).toBeCloseTo(0.5); // Value
|
|
22
|
+
});
|
|
23
|
+
});
|