Repository navigation
Expand file tree
/
Copy pathliberationDMX.js
More file actions
1201 lines (1019 loc) · 49.5 KB
/
Copy pathliberationDMX.js
File metadata and controls
1201 lines (1019 loc) · 49.5 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
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
// Liberation DMX - Chataigne custom module script
//
// Copyright (C) 2026 Jon Sands
//
// This program is free software: you can redistribute it and/or modify it under
// the terms of the GNU General Public License as published by the Free Software
// Foundation, either version 3 of the License, or (at your option) any later
// version.
//
// This program is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
// PARTICULAR PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// this program. If not, see <https://www.gnu.org/licenses/>.
//
// Streams Liberation's "DMX Input" fixture profiles (Basic 16ch / Extended 32ch)
// out of Chataigne's DMX module. One Chataigne zone == one Liberation DMX Input
// profile row == one consecutive block of channels in one universe.
//
// Nothing here polls or re-sends on a timer. The DMX module's own send thread hands
// every output universe to the device at the module's "Send Rate", and the device's
// sender thread emits whatever it was handed at its own "Send Rate" (a separate
// parameter under the device). Both are 44 Hz. So the channel values written here keep
// flowing and Liberation never sees the input go stale (it disables a zone after 2 s
// without fresh data).
//
// The one clock this script does keep is for Select Clip's Duration: update() counts
// each zone's remaining seconds down and clears the clip when they run out. See
// "Timed clips" below for why it is done that way.
//
// That same continuous re-send is why channels have to be actively released: a zone
// that is deleted, shrunk or re-addressed would otherwise keep streaming its last
// values - including Arm 255 - with nothing left in the UI to turn it off. Every block
// this script writes is remembered in sentBlocks, and vacated channels are zeroed.
// (Turning the module off, deleting it or closing the project needs no such handling:
// the send thread stops, so Liberation disarms the zone on its own 2 s timeout.)
//
// See CHANNEL_MAP.md for the channel table and the value conversions.
// ============================================================
// Constants
// ============================================================
var MAX_ZONES = 8;
var DECK_COLS = 8; // deck columns one Gobo Bank page covers
var DECK_ROWS = 5; // clip deck rows - the deck is this tall, always
var SLOTS_PER_PAGE = 40; // DECK_COLS * DECK_ROWS
var NUM_FX = 4;
// Liberation labels every clip with the 0-based (x, y) it occupies on the deck - the
// pair its clip settings header prints as "CLIP SETTINGS 21 1". x counts deck columns
// across the whole deck, y is the row. That is the module's only way of naming a clip:
// pages are a detail of the wire format, worked out on the way out in buildZoneBytes.
// bank = floor(x / DECK_COLS), slot = (x % DECK_COLS) * DECK_ROWS + y + 1
var MAX_BANKS = 256; // Gobo Bank is one 8-bit channel
var MAX_DECK_COLUMNS = MAX_BANKS * DECK_COLS;
var MAX_CLIP_X = MAX_DECK_COLUMNS - 1;
var MAX_CLIP_Y = DECK_ROWS - 1;
var DEFAULT_DECK_COLUMNS = 96; // Liberation's own factory deck is 89 columns wide
var CLIP_NUMBER_NONE = -1; // an (x, y) pair has no way to say "no clip", so -1 does it
// Select Clip's Mode enum, by option data (module.json "Select Clip" > "Mode"). Anything
// else ("number") is the Clip X / Clip Y pair.
var SELECT_MODE_LIST = "list";
var SELECT_MODE_STOP = "stop";
// How often update() looks at the clip countdowns. 25 Hz puts a stop within 40 ms of its
// time, under the 44 Hz DMX send period, and keeps the update thread mostly idle.
var CLIP_STOP_UPDATE_RATE = 25;
// Enum option data is the value we actually work with: an Enum's get() returns its
// DATA, not its key (EnumParameter::getValue -> getValueData), and command callbacks
// receive the same. So every enum here carries its meaning in the data: profile data
// is the block size and clip data is the whole-deck clip number.
// Use setData() to write one, set() would expect a key.
var PROFILE_EXTENDED_SIZE = 32;
var PROFILE_BASIC_SIZE = 16;
var PROFILE_EXTENDED_KEY = "Extended 32ch";
var PROFILE_BASIC_KEY = "Basic 16ch";
// Channel offsets, 1-based inside a zone's block
var CH_ARM = 1;
var CH_INTENSITY = 2;
var CH_GOBO_BANK = 3;
var CH_GOBO_SELECT = 4;
var CH_RED = 5;
var CH_GREEN = 6;
var CH_BLUE = 7;
var CH_COLOUR_BLEND = 8;
var CH_ZOOM = 9;
var CH_SCALE_X = 10;
var CH_SCALE_Y = 11;
var CH_POS_X_COARSE = 12;
var CH_POS_X_FINE = 13;
var CH_POS_Y_COARSE = 14;
var CH_POS_Y_FINE = 15;
var CH_ROTATION = 16;
var CH_FX_BASE = 17; // 3 channels per FX slot: level, param 1, param 2
var CH_RESERVED_1 = 29;
var CH_RESERVED_2 = 30;
var CH_TEMPO_COARSE = 31;
var CH_TEMPO_FINE = 32;
var CLIP_NONE_KEY = "None (no clip)";
// ============================================================
// State
// ============================================================
var ready = false; // init() finished
var suspendUpdates = false; // guards against re-entry while the script writes parameters
var warnedMissingZones = []; // zone numbers already warned about, cleared on rebuild
var warnedAddressing = []; // addressing warnings already logged, cleared on rebuild
// The channels each zone currently owns on the wire: sentBlocks[zone] is either null or
// [universe, startChannel, size], recorded when a block is actually sent. Anything the
// zone stops covering has to be zeroed, or the DMX module keeps re-sending it forever.
// Filled here rather than in init() so it is never read short: a parameter restored
// while a project loads can reach the send path before init() has run.
var sentBlocks = [];
for (var zi = 0; zi <= MAX_ZONES; zi++) sentBlocks.push(null);
// Seconds left before each zone's clip is cleared again (Select Clip with a Duration).
// 0 = nothing pending. Written by the commands on their thread, counted down by update().
var clipStopIn = [];
for (var ci = 0; ci <= MAX_ZONES; ci++) clipStopIn.push(0);
var BLOCK_UNIVERSE = 0;
var BLOCK_START = 1;
var BLOCK_SIZE = 2;
// sendUniverse() silently does nothing when the module holds no matching Output
// Universe, and that is exactly the state right after a project loads: this script's
// init() runs when the module is first created, before the project restores its
// universes, so the opening push can land nowhere. Remember that it happened and
// re-push on the next message-thread event so the zones recover on their own.
// (A timed retry is not an option - script update() runs on a background thread and
// Chataigne's script engine lock is disabled, so it would race the message thread.)
var pendingFullPush = false;
var universesVerified = false; // once true, the lookup stays out of the hot path
// The same load order made validateAddressing() cry wolf on every project load. A custom
// module's script is loaded twice: once while the module is being constructed, before any
// saved data exists, and again once the project has restored it. On that first pass the
// Output Universes really are empty - Chataigne skips the one it would otherwise create
// while a file is loading - so every zone looks unrouted through no fault of the user.
// An empty list is therefore not evidence of anything: it buys silence and a re-check
// from update(), which only starts running once the session has finished loading and so
// always sees the settled tree.
var LOAD_SETTLE_SECONDS = 2;
var loadSettled = false; // until then, "no universes at all" proves nothing
var settleIn = 0; // seconds left on that re-check, 0 = none pending
// ============================================================
// Entry points
// ============================================================
function init() {
suspendUpdates = false; // recover if a previous run died mid-update
script.setUpdateRate(CLIP_STOP_UPDATE_RATE);
enableScriptLog();
// No zone tree yet means the module was just added (a loaded project restores the
// tree before the script runs), so this is the one time to set the panel's defaults.
var freshModule = getZonesContainer() == null;
rebuildZones();
ready = true;
if (freshModule) {
var universes = findContainer(local.parameters, "Output Universes");
if (universes != null) universes.setCollapsed(true);
}
script.log("Liberation DMX ready - " + toInt(getSetupValue("Zone Count", 0)) + " zone(s). Setup > Address Map shows what to enter in Liberation's DMX Input window.");
}
// A script's log lines and warnings only reach the Logger panel while its own Log
// toggle is on, and that toggle defaults to off and is hidden from the editor. Every
// addressing warning this script raises goes through it, so switch it on ourselves.
function enableScriptLog() {
var scripts = findContainer(local, "Scripts");
if (scripts == null) return;
var me = findContainer(scripts, "liberationDMX");
if (me == null) return;
var logToggle = me.getChild("Log");
if (logToggle != null) logToggle.set(true);
}
function moduleParameterChanged(param) {
var parent = param.getParent();
if (parent == null) return;
// Setup is handled before the suspend guard: the script never writes these, and it
// keeps 'Rebuild Zones' working as a recovery button if suspendUpdates ever sticks.
var setup = getSetupContainer();
if (setup != null && parent.is(setup)) {
setupParameterChanged(param.niceName);
return;
}
var glob = getGlobalContainer();
if (glob != null && parent.is(glob)) {
if (param.niceName == "Master Intensity") pushAllZones(); // the follow toggles act on the next clip change
return;
}
if (suspendUpdates || !ready) return;
// first message-thread event after a load where the opening push found no universe
if (pendingFullPush) pushAllZones();
var zoneIndex = zoneIndexForParam(param, parent);
if (zoneIndex <= 0) return;
var name = param.niceName;
if (name == "Profile" || name == "Universe" || name == "Start Address") {
// block size or placement changed - re-stack everything, then re-send all zones
applyAddressing();
pushAllZones();
validateAddressing();
return;
}
if (name == "Clip") {
clipStopIn[zoneIndex] = 0; // a clip picked by hand or over OSC is not on a timer
syncClipNumber(zoneIndex);
applyClipFollows(zoneIndex);
} else if (name == "Clip X" || name == "Clip Y") {
clipStopIn[zoneIndex] = 0;
applyClipNumber(zoneIndex, name);
}
pushZone(zoneIndex);
}
function setupParameterChanged(name) {
if (name == "Zone Count" || name == "New Zone Profile" || name == "Deck Columns") {
rebuildZones(); // Deck Columns resizes every zone's clip list
} else if (name == "Auto Address" || name == "Base Universe" || name == "Base Address") {
applyAddressing();
pushAllZones();
validateAddressing();
} else if (name == "Rebuild Zones") {
endLoadWindow();
rebuildZones();
} else if (name == "Log Addressing") {
endLoadWindow();
logAddressing();
}
}
// Both of those buttons are somebody asking a question by hand, long after any load, so
// they answer now instead of leaving the universe check to the re-check in update().
function endLoadWindow() {
loadSettled = true;
settleIn = 0;
}
// ============================================================
// The clip list
// ============================================================
// One flat list of every clip on the deck, named the way Liberation names it. Options
// run down each column in turn, so an option's index is a whole-deck clip number:
// index n -> x = floor((n - 1) / DECK_ROWS), y = (n - 1) % DECK_ROWS
// Index 0 is "no clip". Nothing else is needed to place a clip - the page it happens to
// fall on is arithmetic, done once on the way to the wire.
// The labels are terse on purpose: an enum's key is also what it answers to over OSC,
// and "21-1" is something you can type into a message by hand.
function clipKeys() {
var cols = deckColumns();
var keys = [CLIP_NONE_KEY];
for (var x = 0; x < cols; x++) {
for (var y = 0; y < DECK_ROWS; y++) keys.push(toInt(x) + "-" + toInt(y));
}
return keys;
}
function clipIndexFor(x, y) { return toInt(x * DECK_ROWS + y + 1); }
function clipXForIndex(n) { return toInt(Math.floor((n - 1) / DECK_ROWS)); }
function clipYForIndex(n) { return toInt((n - 1) % DECK_ROWS); }
// How wide the user's deck is. Only sizes the list - a deck narrower than a zone's
// current clip drops that zone to "no clip", since the option it pointed at is gone.
function deckColumns() {
return clampInt(toInt(getSetupValue("Deck Columns", DEFAULT_DECK_COLUMNS)), 1, MAX_DECK_COLUMNS);
}
function lastClipIndex() { return toInt(deckColumns() * DECK_ROWS); }
// Gobo Select is a 1-255 channel divided across 40 slots:
// slot = 1 + floor((gobo - 1) * 40 / 255)
// Each slot owns a band of ~6.375 DMX steps, so aim at the middle of the band -
// rounding to an edge would land on the neighbouring clip.
function goboValueForSlot(slot) {
if (slot <= 0) return 0;
return clampInt(1 + Math.round((2 * slot - 1) * 255 / (2 * SLOTS_PER_PAGE)), 1, 255);
}
// ============================================================
// Zone tree
// ============================================================
function rebuildZones() {
suspendUpdates = true;
warnedMissingZones = [];
warnedAddressing = [];
var zones = getZonesContainer();
if (zones == null) zones = local.parameters.addContainer("Zones");
var count = clampInt(toInt(getSetupValue("Zone Count", 1)), 0, MAX_ZONES);
for (var i = count + 1; i <= MAX_ZONES; i++) zones.removeContainer("Zone " + toInt(i));
for (var j = 1; j <= count; j++) ensureZone(zones, j);
suspendUpdates = false;
applyAddressing();
pushAllZones();
validateAddressing();
}
function ensureZone(zones, index) {
var name = "Zone " + toInt(index);
var isNewZone = findContainer(zones, name) == null;
var z = zones.addContainer(name);
if (isNewZone) z.setCollapsed(index > 1);
// --- placement ---
addBool(z, "Enabled", "Stream this zone. When off the zone keeps streaming but its Arm channel is forced to 0, so Liberation stops rendering it.", true);
addEnum(z, "Profile", "Which Liberation DMX Input profile this zone uses. Must match the profile chosen for this zone in Liberation.", [PROFILE_EXTENDED_KEY, PROFILE_BASIC_KEY], [PROFILE_EXTENDED_SIZE, PROFILE_BASIC_SIZE], isNewZone ? newZoneProfileKey() : "");
addInt(z, "Universe", "Universe in Liberation's 1-based UI numbering (1 = Art-Net Port-Address 0). Managed automatically unless Setup > Auto Address is off.", 1, 1, 4096);
addInt(z, "Start Address", "First channel of this zone's block (1-512). Managed automatically unless Setup > Auto Address is off.", 1, 1, 512);
// --- live control ---
addBool(z, "Arm", "Arm channel. Liberation only renders this zone's DMX layer when Arm is at least 250, a clip is selected and Intensity is above zero.", false);
addFloat(z, "Intensity", "0-1 mapped to 0-100% brightness.", 1, 0, 1);
addEnum(z, "Clip", "Every clip on the deck, named the way Liberation names it: right-click a clip in Liberation and its settings header prints the same 'x y' pair. 'None' = no clip / blackout.", clipKeys(), null, "");
addInt(z, "Clip X", "First figure of Liberation's clip number: deck column, 0 = leftmost. Type what Liberation shows and the Clip dropdown follows. -1 = no clip.", CLIP_NUMBER_NONE, CLIP_NUMBER_NONE, deckColumns() - 1);
addInt(z, "Clip Y", "Second figure of Liberation's clip number: deck row, 0 = top. -1 = no clip.", CLIP_NUMBER_NONE, CLIP_NUMBER_NONE, MAX_CLIP_Y);
syncClipNumber(index); // the Clip dropdown is the source of truth, here and on load
addColor(z, "Colour", "Desk RGB colour. Alpha is ignored - use Intensity for brightness.", [1, 1, 1, 1]);
addFloat(z, "Colour Blend", "0 = the clip's own colour, 1 = the desk RGB colour above.", 0, 0, 1);
addFloat(z, "Zoom", "0 = collapsed, 1 = normal size.", 1, 0, 1);
addPoint2D(z, "Position", "-1..1 on each axis, (0,0) = centre. +X right, +Y DOWN (Liberation's convention). Sent 16-bit over the coarse + fine channel pairs.", 0, 0, -1, 1);
addPoint2D(z, "Scale", "Size on each axis: 1 = 100%, the clip as authored (DMX 255). 0 = 0%, nothing renders (DMX 128). -1 = 100% mirrored on that axis (DMX 0).", 1, 1, -1, 1);
addFloat(z, "Rotation", "Spin: -1 = max counter-clockwise, 0 = stopped, 1 = max clockwise.", 0, -1, 1);
addBool(z, "Tempo Override", "Extended 32ch only. Off = follow Liberation's tempo. On = drive this zone from Tempo BPM below.", false);
addFloat(z, "Tempo BPM", "Extended 32ch only. Only sent while Tempo Override is on. Coarse BPM plus a 1/256 fine part.", 120, 1, 255);
for (var f = 1; f <= NUM_FX; f++) {
var fxName = "FX " + toInt(f);
var isNewFX = findContainer(z, fxName) == null;
var fx = z.addContainer(fxName);
if (isNewFX) fx.setCollapsed(true);
addFloat(fx, "Level", "Extended 32ch only. Effect depth / amount for this slot. At 0 the effect is not applied at all.", 0, 0, 1);
addFloat(fx, "Param 1", "Extended 32ch only. First exposed parameter of this effect. 0.5 is the recommended neutral value (DMX 128).", 0.5, 0, 1);
addFloat(fx, "Param 2", "Extended 32ch only. Second exposed parameter of this effect. 0.5 is the recommended neutral value (DMX 128).", 0.5, 0, 1);
}
return z;
}
function newZoneProfileKey() {
return toInt(getSetupValue("New Zone Profile", PROFILE_EXTENDED_SIZE)) == PROFILE_BASIC_SIZE ? PROFILE_BASIC_KEY : PROFILE_EXTENDED_KEY;
}
function zoneProfileSize(z) {
var p = z.getChild("Profile");
if (p == null) return PROFILE_EXTENDED_SIZE;
return toInt(p.get()) == PROFILE_BASIC_SIZE ? PROFILE_BASIC_SIZE : PROFILE_EXTENDED_SIZE;
}
// ============================================================
// Clip number (Liberation's "CLIP SETTINGS x y")
// ============================================================
// Clip X / Clip Y are a second, typeable view of the Clip dropdown. The dropdown stays
// the source of truth: it is what goes on the wire, and the pair is written back from it
// on every selection.
// Clip -> Clip X / Clip Y
function syncClipNumber(index) {
var z = getZoneContainer(index);
if (z == null) return;
var cx = z.getChild("Clip X");
var cy = z.getChild("Clip Y");
if (cx == null || cy == null) return;
var n = clampInt(toInt(z.getChild("Clip").get()), 0, lastClipIndex());
var wasSuspended = suspendUpdates;
suspendUpdates = true;
if (n == 0) {
cx.set(CLIP_NUMBER_NONE);
cy.set(CLIP_NUMBER_NONE);
} else {
cx.set(clipXForIndex(n));
cy.set(clipYForIndex(n));
}
suspendUpdates = wasSuspended;
}
// Clip X / Clip Y -> Clip. 'changed' names the figure the user just typed, or is ""
// when a command set the pair outright.
function applyClipNumber(index, changed) {
var z = getZoneContainer(index);
if (z == null) return;
var cx = z.getChild("Clip X");
var cy = z.getChild("Clip Y");
if (cx == null || cy == null) return;
var x = toInt(cx.get());
var y = toInt(cy.get());
// -1 is no clip, but a typed figure only blanks the zone when the -1 is the figure
// that was typed. Otherwise a zone sitting at -1 / -1 could never be typed back onto
// the deck: the first figure entered would be stomped by the one still reading -1.
var none = x < 0 || y < 0;
if (changed == "Clip X") none = x < 0;
else if (changed == "Clip Y") none = y < 0;
var wasSuspended = suspendUpdates;
suspendUpdates = true;
if (none) {
cx.set(CLIP_NUMBER_NONE);
cy.set(CLIP_NUMBER_NONE);
z.getChild("Clip").setData(0);
} else {
if (x < 0) { x = 0; cx.set(0); } // the figure not being typed comes back in
if (y < 0) { y = 0; cy.set(0); }
x = clampInt(x, 0, deckColumns() - 1); // a command can ask for more deck than there is
y = clampInt(y, 0, MAX_CLIP_Y);
cx.set(x);
cy.set(y);
z.getChild("Clip").setData(clipIndexFor(x, y));
}
applyClipFollows(index); // the Clip change path is suspended here
suspendUpdates = wasSuspended;
}
// ============================================================
// Clip follow actions
// ============================================================
// Lets a trigger or mapping just set the clip: the zone arms itself and comes up
// at full, instead of needing Arm and Intensity sent alongside every clip change.
// Selecting "None" disarms again, so Clear Clip is a real blackout.
// Runs with updates suspended so the whole change goes out as one push, and it
// restores the previous suspend state because commands call it while suspended.
function applyClipFollows(index) {
var z = getZoneContainer(index);
if (z == null) return;
var armFollows = getGlobalValue("Arm Follows Clip", true);
var intensityFollows = getGlobalValue("Intensity Follows Clip", true);
if (!armFollows && !intensityFollows) return;
var slot = toInt(z.getChild("Clip").get());
var wasSuspended = suspendUpdates;
suspendUpdates = true;
if (armFollows) z.getChild("Arm").set(slot > 0);
if (intensityFollows && slot > 0) z.getChild("Intensity").set(1);
suspendUpdates = wasSuspended;
}
// ============================================================
// Addressing
// ============================================================
function applyAddressing() {
var zones = getZonesContainer();
if (zones == null) return;
var setup = getSetupContainer();
if (setup == null) return;
var auto = getSetupValue("Auto Address", true);
var universe = clampInt(toInt(getSetupValue("Base Universe", 1)), 1, 4096);
var address = clampInt(toInt(getSetupValue("Base Address", 1)), 1, 512);
// Placement is about to change, so the "every universe we target exists" shortcut is
// no longer earned - make the next push re-check and defer again if it has to.
universesVerified = false;
suspendUpdates = true;
for (var i = 1; i <= MAX_ZONES; i++) {
var z = getZoneContainer(i);
if (z == null) continue;
var uniParam = z.getChild("Universe");
var addrParam = z.getChild("Start Address");
if (uniParam == null || addrParam == null) continue;
if (auto) {
var size = zoneProfileSize(z);
if (address + size - 1 > 512) { // spill into the next universe
universe = universe + 1;
address = 1;
}
uniParam.set(universe);
addrParam.set(address);
address = address + size;
}
// hand-editable only when Auto Address is off
uniParam.setAttribute("readonly", auto);
addrParam.setAttribute("readonly", auto);
}
suspendUpdates = false;
}
// Called on every addressing change, so each distinct problem is logged once and then
// stays quiet - otherwise dragging a Start Address spinner would fill the log. Rebuild
// Zones and Log Addressing clear the list, so a real re-check always reports again.
function warnAddressing(key, message) {
if (warnedAddressing.indexOf(key) >= 0) return;
warnedAddressing.push(key);
script.logWarning(message);
}
// Checks every zone's placement, warns once per problem, and rewrites Setup > Address Map
// so the Inspector always shows the current map with any problem listed under it.
function validateAddressing() {
var used = []; // "universe:channel" strings already claimed
var problems = []; // short versions of the warnings, for the map
// Nothing routed anywhere and the project may still be loading: say nothing now and
// look again in a moment, rather than blaming the user for Chataigne's restore order.
var canJudgeUniverses = loadSettled || outputUniverseCount() > 0;
if (!canJudgeUniverses && settleIn <= 0) settleIn = LOAD_SETTLE_SECONDS;
for (var i = 1; i <= MAX_ZONES; i++) {
var z = getZoneContainer(i);
if (z == null) continue;
// Read through containerValue: the re-check above runs on the update thread, and a
// zone half-built by a rebuild on the message thread would otherwise throw here.
var size = zoneProfileSize(z);
var universe = toInt(containerValue(z, "Universe", 1));
var start = toInt(containerValue(z, "Start Address", 1));
if (start + size - 1 > 512) {
warnAddressing("overflow:" + toInt(i), "Zone " + toInt(i) + " needs channels " + toInt(start) + "-" + toInt(start + size - 1) + " but a universe only has 512. Lower its Start Address or move it to another universe.");
problems.push("Zone " + toInt(i) + " runs past channel 512 - lower its Start Address or move it to another universe");
}
for (var c = start; c < start + size && c <= 512; c++) {
var key = toInt(universe) + ":" + toInt(c);
if (used.indexOf(key) >= 0) {
warnAddressing("overlap:" + toInt(i), "Zone " + toInt(i) + " overlaps another zone at universe " + toInt(universe) + ", channel " + toInt(c) + ". Liberation will see both zones fighting over the same channels.");
problems.push("Zone " + toInt(i) + " overlaps another zone at universe " + toInt(universe) + ", channel " + toInt(c));
break;
}
used.push(key);
}
if (canJudgeUniverses && !outputUniverseExists(universe)) {
warnAddressing("universe:" + toInt(universe), "Zone " + toInt(i) + " targets universe " + toInt(universe) + " (Art-Net " + artnetSignatureString(universe) + ") but the module has no matching Output Universe, so nothing will be sent. Add it under Module Parameters > Output Universes, then hit Rebuild Zones.");
problems.push("Universe " + toInt(universe) + " is missing under Output Universes (Art-Net " + artnetSignatureString(universe) + ") - nothing is sent to Zone " + toInt(i) + " until it is added");
}
}
writeAddressMap(problems);
return problems;
}
// One line per zone, the way Liberation's DMX Input window wants it, then the problems.
function zoneAddressLine(index) {
var z = getZoneContainer(index);
if (z == null) return "";
var size = zoneProfileSize(z);
var universe = toInt(containerValue(z, "Universe", 1));
var start = toInt(containerValue(z, "Start Address", 1));
return "Zone " + toInt(index) + ": universe " + toInt(universe) + ", channels " + toInt(start) + "-" + toInt(start + size - 1) + ", " + (size == 16 ? PROFILE_BASIC_KEY : PROFILE_EXTENDED_KEY) + (containerValue(z, "Enabled", true) ? "" : " (disabled)");
}
function addressMapLines(problems) {
var lines = [];
for (var i = 1; i <= MAX_ZONES; i++) if (getZoneContainer(i) != null) lines.push(zoneAddressLine(i));
if (lines.length == 0) lines.push("No zones. Raise Zone Count.");
for (var p = 0; p < problems.length; p++) lines.push("! " + problems[p]);
return lines;
}
function writeAddressMap(problems) {
var setup = getSetupContainer();
if (setup == null) return;
var map = setup.getChild("Address Map");
if (map == null) return;
map.set(addressMapLines(problems).join("\n"));
}
function logAddressing() {
warnedAddressing = []; // this button is the re-check - always report
var lines = addressMapLines(validateAddressing()); // refreshes the map and re-warns
script.log("--- Liberation DMX addressing (set these in Liberation's DMX Input window) ---");
for (var i = 0; i < lines.length; i++) script.log(lines[i]);
}
// Liberation UI universe (1-based) -> Art-Net Port-Address (0-based) -> Chataigne net/subnet/universe
function universeNet(universe) { return (toInt(universe - 1) >> 8) & 0x7F; }
function universeSubnet(universe) { return (toInt(universe - 1) >> 4) & 0x0F; }
function universeUniverse(universe) { return toInt(universe - 1) & 0x0F; }
function artnetSignatureString(universe) {
return "net " + toInt(universeNet(universe)) + " / subnet " + toInt(universeSubnet(universe)) + " / universe " + toInt(universeUniverse(universe));
}
// How many Output Universes the module holds, or 0 while the manager is missing - both
// are "nothing to match against", which is all the caller wants to know.
function outputUniverseCount() {
var mgr = findContainer(local.parameters, "Output Universes");
if (mgr == null) return 0;
return mgr.getContainers().length;
}
function outputUniverseExists(universe) {
var mgr = findContainer(local.parameters, "Output Universes");
if (mgr == null) return true; // can't tell - don't cry wolf
var net = universeNet(universe);
var subnet = universeSubnet(universe);
var uni = universeUniverse(universe);
var items = mgr.getContainers();
for (var i = 0; i < items.length; i++) {
var n = items[i].getChild("Net");
var s = items[i].getChild("Subnet");
var u = items[i].getChild("Universe");
if (n == null || s == null || u == null) continue;
if (toInt(n.get()) == net && toInt(s.get()) == subnet && toInt(u.get()) == uni) return true;
}
return false;
}
// ============================================================
// Encoding + sending
// ============================================================
function pushAllZones() {
if (suspendUpdates) return; // the caller that suspended will push
pendingFullPush = false;
// Release before writing, never interleaved: a zone that shifted down must not be
// blanked by the neighbour vacating the channels it has just moved into.
for (var i = 1; i <= MAX_ZONES; i++) if (blockChanged(i)) clearZoneBlock(i);
var sent = 0;
for (var j = 1; j <= MAX_ZONES; j++) if (pushZone(j)) sent++;
// Earned only by a push that actually reached a universe, with nothing left deferred.
if (sent > 0 && !pendingFullPush) universesVerified = true;
}
// The block pushZone() would write for this zone right now, or null if it would write
// nothing. Returned as [universe, startChannel, size].
function plannedBlock(index) {
var z = getZoneContainer(index);
if (z == null) return null;
var size = zoneProfileSize(z);
var start = clampInt(toInt(z.getChild("Start Address").get()), 1, 512);
if (start + size - 1 > 512) return null; // already warned in validateAddressing()
return [toInt(z.getChild("Universe").get()), start, size];
}
// Is anything this zone currently holds on the wire about to stop being covered by it?
function blockChanged(index) {
var prev = sentBlocks[index];
if (prev == null) return false;
var next = plannedBlock(index);
if (next == null) return true; // zone deleted, or no longer sendable
return prev[BLOCK_UNIVERSE] != next[BLOCK_UNIVERSE] || prev[BLOCK_START] != next[BLOCK_START] || prev[BLOCK_SIZE] != next[BLOCK_SIZE];
}
// Zero every channel the zone last claimed. This is what actually disarms Liberation
// when a zone is deleted or moved: Arm sits on the block's first channel, so a block of
// zeros is a disarm, and the module keeps re-sending it from then on.
function clearZoneBlock(index) {
var b = sentBlocks[index];
if (b == null) return;
sentBlocks[index] = null;
var zeros = [];
for (var i = 0; i < b[BLOCK_SIZE]; i++) zeros.push(0);
var universe = b[BLOCK_UNIVERSE];
local.sendUniverse(universeNet(universe), universeSubnet(universe), universeUniverse(universe), b[BLOCK_START], zeros);
}
// 'force' is for the update thread: it pushes even while a command on another thread
// has updates suspended, because nobody else is going to push this zone for it.
function pushZone(index, force) {
if (suspendUpdates && !force) return false;
var z = getZoneContainer(index);
if (z == null) return false;
var b = plannedBlock(index);
if (b == null) return false;
var universe = b[BLOCK_UNIVERSE];
if (!universesVerified && !outputUniverseExists(universe)) {
pendingFullPush = true; // retried from moduleParameterChanged
return false;
}
local.sendUniverse(universeNet(universe), universeSubnet(universe), universeUniverse(universe), b[BLOCK_START], buildZoneBytes(z, b[BLOCK_SIZE]));
sentBlocks[index] = b;
return true;
}
function buildZoneBytes(z, size) {
var b = [];
for (var i = 0; i < size; i++) b.push(0);
var enabled = z.getChild("Enabled").get();
var armed = z.getChild("Arm").get();
// Arm: Liberation enables output at 250-255, disables below
b[CH_ARM - 1] = (enabled && armed) ? 255 : 0;
b[CH_INTENSITY - 1] = dmx8FromUnit(z.getChild("Intensity").get() * getGlobalValue("Master Intensity", 1));
// The one place pages exist: a whole-deck clip number splits into the bank/slot pair
// the two channels want. No clip leaves both at 0, which is Liberation's blackout.
var clipIndex = clampInt(toInt(z.getChild("Clip").get()), 0, lastClipIndex());
if (clipIndex > 0) {
var clipX = clipXForIndex(clipIndex);
b[CH_GOBO_BANK - 1] = clampInt(toInt(Math.floor(clipX / DECK_COLS)), 0, MAX_BANKS - 1);
b[CH_GOBO_SELECT - 1] = goboValueForSlot(toInt((clipX % DECK_COLS) * DECK_ROWS + clipYForIndex(clipIndex) + 1));
}
var col = z.getChild("Colour").get();
b[CH_RED - 1] = dmx8FromUnit(col[0]);
b[CH_GREEN - 1] = dmx8FromUnit(col[1]);
b[CH_BLUE - 1] = dmx8FromUnit(col[2]);
b[CH_COLOUR_BLEND - 1] = dmx8FromUnit(z.getChild("Colour Blend").get());
b[CH_ZOOM - 1] = dmx8FromUnit(z.getChild("Zoom").get());
var scale = z.getChild("Scale").get();
b[CH_SCALE_X - 1] = dmx8FromBipolar(scale[0]);
b[CH_SCALE_Y - 1] = dmx8FromBipolar(scale[1]);
// 16-bit position: 0 -> -200, 32768 -> centre, 65535 -> +200, +X right / +Y down
var pos = z.getChild("Position").get();
var px = dmx16FromBipolar(pos[0]);
var py = dmx16FromBipolar(pos[1]);
b[CH_POS_X_COARSE - 1] = (px >> 8) & 255;
b[CH_POS_X_FINE - 1] = px & 255;
b[CH_POS_Y_COARSE - 1] = (py >> 8) & 255;
b[CH_POS_Y_FINE - 1] = py & 255;
b[CH_ROTATION - 1] = dmx8FromBipolar(z.getChild("Rotation").get());
if (size < 32) return b; // Basic 16ch stops here
for (var f = 1; f <= NUM_FX; f++) {
var fx = findContainer(z, "FX " + toInt(f));
if (fx == null) continue;
var base = CH_FX_BASE + (f - 1) * 3;
b[base - 1] = dmx8FromUnit(fx.getChild("Level").get());
b[base] = dmx8FromUnit(fx.getChild("Param 1").get());
b[base + 1] = dmx8FromUnit(fx.getChild("Param 2").get());
}
b[CH_RESERVED_1 - 1] = 0;
b[CH_RESERVED_2 - 1] = 0;
// Tempo override: coarse 0 = follow Liberation, otherwise bpm = coarse + fine/256
if (z.getChild("Tempo Override").get()) {
var bpm = z.getChild("Tempo BPM").get();
var coarse = clampInt(Math.floor(bpm), 1, 255);
var fine = clampInt(Math.round((bpm - Math.floor(bpm)) * 256), 0, 255);
b[CH_TEMPO_COARSE - 1] = coarse;
b[CH_TEMPO_FINE - 1] = fine;
} else {
b[CH_TEMPO_COARSE - 1] = 0;
b[CH_TEMPO_FINE - 1] = 0;
}
return b;
}
// ============================================================
// Timed clips
// ============================================================
// Select Clip's Duration works like the MIDI module's Full Note "On Time": the clip goes
// on now and the module takes it off again later. A custom module has no timer of its
// own - util.getTime() is a float of the machine's uptime, too coarse after a few weeks
// up, and util.delayThreadMS() blocks whichever thread called it - so the clock is
// update(), which Chataigne calls from the script's own thread at script.setUpdateRate().
// Each zone keeps the seconds it has left and update() subtracts the deltaTime it is
// handed, so no wall clock is involved at all.
//
// That thread runs with the script engine's lock disabled. It is the same footing that
// sequence triggers and delayed consequences already fire commands from (the Sequence
// and Stagger Launcher threads), so nothing new is being risked, but update() is still
// kept trivial: an idle tick is nine comparisons and a return, and only a zone whose
// time has run out - or the one-off re-check below - does any real work.
function update(deltaTime) {
// The post-load re-check. Armed only while the module looked unrouted, and it fires
// once: from here on an empty Output Universes list is a real problem worth saying.
// Held off while the message thread has updates suspended, because that is exactly
// when it is adding and removing zone containers, and reading a tree mid-rebuild from
// this thread is what would take the thread down for good.
if (settleIn > 0) {
if (suspendUpdates || !ready) {
settleIn = LOAD_SETTLE_SECONDS;
} else {
settleIn = settleIn - deltaTime;
if (settleIn <= 0) {
settleIn = 0;
loadSettled = true;
validateAddressing();
}
}
}
for (var i = 1; i <= MAX_ZONES; i++) {
if (clipStopIn[i] <= 0) continue;
clipStopIn[i] = clipStopIn[i] - deltaTime;
if (clipStopIn[i] > 0) continue;
clipStopIn[i] = 0;
stopClipAfterDuration(i);
}
}
// Same result as Select Clip > Stop clips, done from the update thread. The Clip change
// handler would normally sync the pair, apply the follow rules and push, but it stays
// quiet while a command on the message thread has updates suspended, so all three are
// done here directly and the push is forced.
function stopClipAfterDuration(index) {
var z = getZoneContainer(index);
if (z == null) return;
var wasSuspended = suspendUpdates;
suspendUpdates = true;
z.getChild("Clip").setData(0);
syncClipNumber(index);
applyClipFollows(index);
suspendUpdates = wasSuspended;
pushZone(index, true);
}
// ============================================================
// Command callbacks (names must match module.json "callback")
// ============================================================
function cmdSetArm(zone, on) { setOnZones(zone, "Arm", on); }
function cmdSetIntensity(zone, value) { setOnZones(zone, "Intensity", value); }
function cmdSetEnabled(zone, on) { setOnZones(zone, "Enabled", on); }
function cmdBlackout(zone) { setOnZones(zone, "Arm", false); }
function cmdResetZone(zone) {
var idx = zoneIndices(zone);
for (var i = 0; i < idx.length; i++) {
var z = getZoneContainer(idx[i]);
clipStopIn[idx[i]] = 0;
suspendUpdates = true;
z.getChild("Arm").set(false);
z.getChild("Intensity").set(1);
z.getChild("Clip").setData(0);
syncClipNumber(idx[i]);
z.getChild("Colour").set([1, 1, 1, 1]);
z.getChild("Colour Blend").set(0);
z.getChild("Zoom").set(1);
z.getChild("Position").set(0, 0);
z.getChild("Scale").set(1, 1);
z.getChild("Rotation").set(0);
z.getChild("Tempo Override").set(false);
z.getChild("Tempo BPM").set(120);
for (var f = 1; f <= NUM_FX; f++) {
var fx = findContainer(z, "FX " + toInt(f));
if (fx == null) continue;
fx.getChild("Level").set(0);
fx.getChild("Param 1").set(0.5);
fx.getChild("Param 2").set(0.5);
}
suspendUpdates = false;
pushZone(idx[i]);
}
}
// Chataigne calls this with the new command every time a Select Clip consequence or
// mapping output is created (module.json "setupCallback"). Command parameters are
// otherwise fixed by module.json, and this is what turns the command's Clip enum into
// the same whole-deck list the zones have, at the deck width set right now.
function setupSelectClipCommand(cmd) {
var clip = cmd.getChild("Clip");
if (clip == null) return;
setEnumOptions(clip, clipKeys(), null, "");
}
// One command for every way of naming a clip. Mode "number" is the pair straight from
// Liberation's clip settings header, "list" is the deck list above, "stop" clears the
// zone. Selecting goes through the zone's own parameters, so the zone lands in the same
// state as if the user had typed it, and a Duration puts the zone on the countdown.
function cmdSelectClip(zone, mode, x, y, clip, duration) {
var m = "" + mode;
var idx = zoneIndices(zone);
for (var i = 0; i < idx.length; i++) {
var index = idx[i];
var z = getZoneContainer(index);
clipStopIn[index] = 0; // whatever was pending is superseded
suspendUpdates = true;
if (m == SELECT_MODE_LIST || m == SELECT_MODE_STOP) {
z.getChild("Clip").setData(m == SELECT_MODE_STOP ? 0 : clampInt(toInt(clip), 0, lastClipIndex()));
syncClipNumber(index);
applyClipFollows(index);
} else {
z.getChild("Clip X").set(clampInt(x, CLIP_NUMBER_NONE, MAX_CLIP_X));
z.getChild("Clip Y").set(clampInt(y, CLIP_NUMBER_NONE, MAX_CLIP_Y));
applyClipNumber(index, ""); // both figures came from the command
}
suspendUpdates = false;
pushZone(index);
// the countdown only starts once a clip is actually on
if (duration > 0 && toInt(z.getChild("Clip").get()) > 0) clipStopIn[index] = duration;
}
}
function cmdClearClip(zone) {
var idx = zoneIndices(zone);
for (var i = 0; i < idx.length; i++) clipStopIn[idx[i]] = 0;
setDataOnZones(zone, "Clip", 0);
}
function cmdSetColour(zone, colour) { setOnZones(zone, "Colour", colour); }
function cmdSetColourBlend(zone, value) { setOnZones(zone, "Colour Blend", value); }
function cmdSetPosition(zone, position) { setPoint2DOnZones(zone, "Position", position[0], position[1]); }
function cmdSetPositionX(zone, value) { setPoint2DAxisOnZones(zone, "Position", 0, value); }
function cmdSetPositionY(zone, value) { setPoint2DAxisOnZones(zone, "Position", 1, value); }
function cmdSetScale(zone, scale) { setPoint2DOnZones(zone, "Scale", scale[0], scale[1]); }
function cmdSetScaleX(zone, value) { setPoint2DAxisOnZones(zone, "Scale", 0, value); }
function cmdSetScaleY(zone, value) { setPoint2DAxisOnZones(zone, "Scale", 1, value); }
function cmdSetZoom(zone, value) { setOnZones(zone, "Zoom", value); }
function cmdSetRotation(zone, value) { setOnZones(zone, "Rotation", value); }
function cmdSetFXLevel(zone, fx, value) { setFXParam(zone, fx, "Level", value); }
function cmdSetFXParameter(zone, fx, parameter, value) { setFXParam(zone, fx, "Param " + toInt(parameter), value); }
function cmdClearEffects(zone) {
var idx = zoneIndices(zone);
for (var i = 0; i < idx.length; i++) {
var z = getZoneContainer(idx[i]);
suspendUpdates = true;
for (var f = 1; f <= NUM_FX; f++) {
var fx = findContainer(z, "FX " + toInt(f));
if (fx != null) fx.getChild("Level").set(0);
}
suspendUpdates = false;
pushZone(idx[i]);
}
}
function cmdSetTempoOverride(zone, bpm) {
var idx = zoneIndices(zone);
for (var i = 0; i < idx.length; i++) {
var z = getZoneContainer(idx[i]);
suspendUpdates = true;
z.getChild("Tempo BPM").set(bpm);
z.getChild("Tempo Override").set(true);
suspendUpdates = false;
pushZone(idx[i]);
}
}
function cmdClearTempoOverride(zone) { setOnZones(zone, "Tempo Override", false); }
function cmdSetMasterIntensity(value) {
var glob = getGlobalContainer();
if (glob == null) return;
var p = glob.getChild("Master Intensity");
if (p != null) p.set(value); // change handler re-pushes every zone
}
// ============================================================
// Command helpers
// ============================================================
// Zone 0 means "every zone that exists"
function zoneIndices(zone) {
var result = [];
var z = toInt(zone);