-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathspatial.ts
More file actions
163 lines (143 loc) · 5.15 KB
/
Copy pathspatial.ts
File metadata and controls
163 lines (143 loc) · 5.15 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
/**
* Open Stage Control custom module — spatial-control.
*
* Translates the browser control-surface widgets (session.json) into SPAT5 OSC
* and forwards it to Pd on the same machine. The widgets use "semantic" addresses
* (/stage, /select/N, /add, ...) that SPAT5 does not understand; we intercept them
* in oscOutFilter(), translate, and send the real SPAT5 messages. Handled
* addresses are dropped (we return nothing) so only translated OSC reaches Pd.
*
* browser ──ws──> O-S-C server ──this module──> [netreceive -u -b 9000] in Pd ──> spat5.spat~
*
* Build: `npm run build:module` (esbuild bundles this + config into one CJS file).
*/
import { CONFIG } from "../config";
import { spat5 } from "../spat5";
import type { CustomModule, OscArg, OscData } from "../osc-types";
const C = CONFIG;
interface State {
selected: number;
count: number;
pos: Record<number, { x: number; y: number }>;
}
const state: State = { selected: 1, count: C.startSources, pos: {} };
function num(a: OscArg): number {
return a && typeof a === "object" && "value" in a ? Number(a.value) : Number(a);
}
/** tap/toggle widgets emit a trailing value; act only on the pressed (non-zero) edge. */
function pressed(args: OscArg[]): boolean {
if (!args || !args.length) return true;
return num(args[args.length - 1]!) !== 0;
}
/** Send one OSC message to Pd. */
function spat(address: string, ...rest: Array<number | string>): void {
try {
send(C.engineHost, C.enginePort, address, ...rest);
} catch (e) {
console.log(`spatial: send failed for ${address}`, e);
}
}
/** Map a stage point in [-1,1]^2 to SPAT5 azimuth (deg) + distance (m). */
function xyToAzDist(x: number, y: number): { az: number; dist: number } {
x *= C.flipX;
y *= C.flipY;
// SPAT5/IRCAM: 0deg = front, counter-clockwise positive (90 = left).
// Pad: +y = front (up), +x = right.
const az = (Math.atan2(-x, y) * 180) / Math.PI + C.azimOffset;
const r = Math.min(1, Math.hypot(x, y));
const dist = C.minDist + r * (C.maxDist - C.minDist);
return { az: Math.round(az * 10) / 10, dist: Math.round(dist * 100) / 100 };
}
function moveSource(i: number, x: number, y: number): void {
const p = xyToAzDist(x, y);
state.pos[i] = { x, y };
spat(spat5.sourceAzim(i), p.az); // CONFIRMED
spat(spat5.sourceDist(i), p.dist); // BEST-EFFORT (see README if distance does nothing)
}
function setupRenderer(): void {
spat(spat5.sourceNumber, state.count);
spat(spat5.speakerNumber, C.speakers.length);
spat(spat5.speakersAz, ...C.speakers);
spat(spat5.panningType, C.panning);
console.log(
`spatial: setup -> ${state.count} sources, ${C.speakers.length} speakers ` +
`@ [${C.speakers.join(" ")}], ${C.panning}`,
);
}
const mod: CustomModule = {
init() {
console.log(
`spatial-control ready -> Max ${C.engineHost}:${C.enginePort}, ` +
`${state.count}/${C.maxSources} sources`,
);
},
oscOutFilter(data: OscData): OscData | void {
const { address } = data;
const args = data.args || [];
// move the selected source
if (address === "/stage") {
moveSource(state.selected, num(args[0]!), num(args[1]!));
return;
}
// pick which source the stage controls
if (address.indexOf("/select/") === 0) {
if (pressed(args)) {
state.selected = parseInt(address.split("/")[2] ?? "1", 10) || 1;
console.log(`spatial: selected source ${state.selected}`);
const p = state.pos[state.selected];
if (p) {
try {
receive("/stage", p.x, p.y); // reflect stored position on the pad
} catch {
/* non-critical */
}
}
}
return;
}
// add / remove emitters
if (address === "/add") {
if (pressed(args) && state.count < C.maxSources) {
state.count++;
spat(spat5.sourceNumber, state.count);
spat(spat5.sourceAzim(state.count), 0); // park new source at front
state.selected = state.count;
console.log(`spatial: added -> ${state.count} sources`);
}
return;
}
if (address === "/remove") {
if (pressed(args) && state.count > 1) {
state.count--;
spat(spat5.sourceNumber, state.count);
if (state.selected > state.count) state.selected = state.count;
console.log(`spatial: removed -> ${state.count} sources`);
}
return;
}
// level of the selected source (SPAT5 "presence", 0..120)
if (address === "/presence") {
spat(spat5.sourcePresence(state.selected), num(args[0]!));
return;
}
// panning algorithm: /panning/<type>
if (address.indexOf("/panning/") === 0) {
if (pressed(args)) spat(spat5.panningType, address.split("/")[2] ?? C.panning);
return;
}
// (re)initialise the renderer
if (address === "/setup") {
if (pressed(args)) setupRenderer();
return;
}
// pop the spat5 viewer window on the server's screen
if (address === "/viewer") {
const on = num(args[0]!) ? 1 : 0;
spat(spat5.viewerVisible, on);
spat(on ? spat5.windowOpen : spat5.windowClose);
return;
}
return data; // unhandled -> pass through unchanged
},
};
export = mod;