Repository navigation
Expand file tree
/
Copy pathYam-RCP.js
More file actions
1028 lines (922 loc) · 46.2 KB
/
Copy pathYam-RCP.js
File metadata and controls
1028 lines (922 loc) · 46.2 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
/*
* Yamaha RCP - Chataigne custom module.
*
* @author Victor Koeppel
*
* SINGLE FILE ON PURPOSE. Chataigne loads each entry in module.json "scripts"
* in its own isolated scope, so helpers in separate files are NOT visible here.
* Everything the module needs lives in this one file.
*
* Chataigne's script engine is a restricted JS dialect:
* - NO try/catch (use `x == undefined` null checks)
* - NO regex literals
* - NO `delete` operator
* - don't assume globals like isNaN
* - local.send() does NOT append the line delimiter (we add "\n")
* - getChild() logs a warning for a missing child (only call for known ones)
*
* Two-way: sends RCP commands to the console AND reflects console-side changes
* (NOTIFY / OK get) back into Chataigne values.
*/
var DEBUG = false; // flip to true during bring-up to log every line in/out
// =========================================================================
// PARAMETER TABLE (CL/QL)
// Adapted from the community "CLQL Parameters" table (companion-module-yamaha-rcp).
// DM / Rivage later = add a sibling table object and select it by "Console Model".
// scale: raw = value * scale (dB uses 100; raw -32768 == -inf)
// Colours: the 8 named colours plus "Off" (uncolored), sent verbatim as quoted strings.
// =========================================================================
// Colour palettes, verified on the console editors. The wire value is the colour name,
// quoted; "Off" clears the colour on every model.
// CL/QL : 8 colours + Off.
// DM3 : same 8-colour set as CL/QL (the DM3 OSC spec's 11-colour Table 3 is wrong
// per the editor).
// DM7 : 11 colours, live-confirmed on hardware 2026-09-14/15. Two gotchas:
// (1) the DM7 UI/editor *labels* three swatches with the CL/QL names
// ("Cyan", "Magenta", "LtGreen"), but the RCP wire strings are
// SkyBlue / Pink / LightGreen. Send the wire string, not the label.
// (2) the off/no-colour state is ASYMMETRIC: the desk *reports* "OFF" on
// read but REJECTS "OFF" on write (InvalidArgument) - the write token
// is "Off". This array holds the WRITE tokens (used by Set Channel
// Color), so it uses "Off"; an incoming read of "OFF" is just shown
// verbatim in the free-text Colour field, no match needed.
// Rivage : identical to DM7's 11-colour palette.
var CLQL_COLORS = ["Blue", "Orange", "Yellow", "Purple", "Cyan", "Magenta", "Red", "Green", "Off"];
var DM3_COLORS = ["Purple", "Magenta", "Red", "Orange", "Yellow", "Blue", "Cyan", "Green", "Off"];
var DM7_COLORS = ["Blue", "Orange", "Yellow", "Purple", "SkyBlue", "Pink", "Red", "Green", "LightGreen", "White", "Off"];
var RIVAGE_COLORS = DM7_COLORS;
// Channel groups. The fader groups share the same address shape
// (Fader/Level, Fader/On, Label/Name, Label/Color). The mute group is special:
// its On lives at .../<key>/On and it has no Level or Color. Level/On/Name/Color
// are handled identically for CL/QL and DM7 (DM7's "binary" name/color are just
// quoted strings/names on the wire). Channel COUNTS are per-model.
var CLQL_GROUPS = [
{ key: "InCh", label: "Input Channels" },
{ key: "StInCh", label: "Stereo In" },
{ key: "Mix", label: "Mix" },
{ key: "Mtrx", label: "Matrix" },
{ key: "St", label: "Stereo Main" },
{ key: "DCA", label: "DCA" },
{ key: "MuteMaster", label: "Mute Groups", mute: true }
];
var DM7_GROUPS = [
{ key: "InCh", label: "Input Channels" },
{ key: "Mix", label: "Mix" },
{ key: "Mtrx", label: "Matrix" },
{ key: "St", label: "Stereo Main" },
{ key: "DCA", label: "DCA" },
{ key: "MuteGrpCtrl", label: "Mute Groups", mute: true }
];
// DM3 has Stereo In (like CL/QL) but no DCA.
var DM3_GROUPS = [
{ key: "InCh", label: "Input Channels" },
{ key: "StInCh", label: "Stereo In" },
{ key: "Mix", label: "Mix" },
{ key: "Mtrx", label: "Matrix" },
{ key: "St", label: "Stereo Main" },
{ key: "MuteGrpCtrl", label: "Mute Groups", mute: true }
];
// Rivage PM: large frame, string (not binary) name/color, no Stereo In group.
var RIVAGE_GROUPS = [
{ key: "InCh", label: "Input Channels" },
{ key: "Mix", label: "Mix" },
{ key: "Mtrx", label: "Matrix" },
{ key: "St", label: "Stereo Main" },
{ key: "DCA", label: "DCA" },
{ key: "MuteMaster", label: "Mute Groups", mute: true }
];
// Per-model channel counts.
// CL/QL: from l-r-r/Yamaha-CLQL-Chataigne-Module models.json (Stereo In is the
// real 8, not the RCP table's max of 16), with CL1 InCh corrected to 48 per
// Yamaha's official Script Template command_list.pdf (CL1 0-47, CL3 0-63,
// CL5 0-71, QL1 0-31, QL5 0-63). DM7: from DM7 Parameters-2.txt.
var CLQL_MODELS = {
CL1: { InCh: 48, StInCh: 8, Mix: 24, Mtrx: 8, St: 3, DCA: 16, MuteMaster: 8 },
CL3: { InCh: 64, StInCh: 8, Mix: 24, Mtrx: 8, St: 3, DCA: 16, MuteMaster: 8 },
CL5: { InCh: 72, StInCh: 8, Mix: 24, Mtrx: 8, St: 3, DCA: 16, MuteMaster: 8 },
QL1: { InCh: 32, StInCh: 8, Mix: 16, Mtrx: 8, St: 3, DCA: 16, MuteMaster: 8 },
QL5: { InCh: 64, StInCh: 8, Mix: 16, Mtrx: 8, St: 3, DCA: 16, MuteMaster: 8 }
};
var DM7_MODELS = {
// DM7 counts live-confirmed (2026-09-14 Sync): the desk answered OK for exactly
// InCh 0-119, Mix 0-47, Mtrx 0-11, St 0-3, DCA 0-23, MuteGrpCtrl 0-11 (no
// InvalidArgument at any group's top edge). DM7C still from spec only.
DM7: { InCh: 120, Mix: 48, Mtrx: 12, St: 4, DCA: 24, MuteGrpCtrl: 12 },
DM7C: { InCh: 72, Mix: 48, Mtrx: 12, St: 4, DCA: 24, MuteGrpCtrl: 12 } // DM7 Compact
};
var DM3_MODELS = {
DM3: { InCh: 16, StInCh: 2, Mix: 6, Mtrx: 2, St: 2, MuteGrpCtrl: 6 }
};
// Rivage capacity is set by the DSP engine, not the control surface, so the
// models are named by engine. PM3/PM5 run DSP-RX or DSP-RX-EX; PM10 runs DSP-R10;
// PM7 has its own integrated DSP (CSD-R7). St/DCA/Mute are constant per the table.
// Yamaha-spec-confirmed capacities: RX 120/48/24, RX-EX 288/72/36, PM7-internal
// 144/60/36. NB `St` counts RCP *channels*: a stereo bus is L+R, so Yamaha's "2
// stereo buses" == St: 4 - do NOT "correct" this to 2.
var RIVAGE_MODELS = {
RX: { InCh: 120, Mix: 48, Mtrx: 24, St: 4, DCA: 24, MuteMaster: 12 },
RXEX: { InCh: 288, Mix: 72, Mtrx: 36, St: 4, DCA: 24, MuteMaster: 12 },
R10: { InCh: 144, Mix: 72, Mtrx: 36, St: 4, DCA: 24, MuteMaster: 12 },
PM7: { InCh: 144, Mix: 60, Mtrx: 36, St: 4, DCA: 24, MuteMaster: 12 }
};
// Head-amp (preamp) gain on Input Channels. prminfo-verified per model; the
// address, range and scale differ, so it's a per-table descriptor. min/max/def
// are RAW wire units (divided by scale for the dB-valued parameter):
// DM7 : MIXER:Current/InCh/Port/HA/Gain -6..66 dB scale 100 [X=120] (live-verified; see docs)
// CL/QL : MIXER:Current/InCh/Port/HA/Gain -6..66 dB scale 100 [X=72]
// DM3 : IO:Current/InCh/HAGain 0..64 dB scale 1 [X=16] (IO: namespace!)
// Rivage : Port/HA/Gain exists but X=6 (engine-local inputs; HA is rack-based) - not wired.
// NOTE (2026-09-14 DM7 capture): the desk's GET replies are dB*100 (raw -600 = -6.00 dB,
// raw 100 = +1.00 dB), i.e. scale 100 like CL/QL - NOT scale 1 as an earlier prminfo dump
// read. Live wire wins; the old scale 1 also made Set HA Gain send ~1/100th of the dB asked.
var DM7_HAGAIN = { address: "MIXER:Current/InCh/Port/HA/Gain", min: -600, max: 6600, scale: 100, def: 0 };
var CLQL_HAGAIN = { address: "MIXER:Current/InCh/Port/HA/Gain", min: -600, max: 6600, scale: 100, def: -600 };
var DM3_HAGAIN = { address: "IO:Current/InCh/HAGain", min: 0, max: 64, scale: 1, def: 0 };
// Build the parameter specs for a group from its address prefix. `haGain` (or null)
// adds the Input-Channel head-amp gain param for tables that expose it.
function specsForGroup(g, colors, haGain) {
if (g.mute) {
return [
{ id: "on", label: "On", address: "MIXER:Current/" + g.key + "/On", type: "bool", scale: 1, min: 0, max: 1 },
{ id: "name", label: "Name", address: "MIXER:Current/" + g.key + "/Label/Name", type: "string", scale: 1 }
];
}
var specs = [
{ id: "level", label: "Level", address: "MIXER:Current/" + g.key + "/Fader/Level", type: "int", scale: 100, min: -32768, max: 1000 },
{ id: "on", label: "On", address: "MIXER:Current/" + g.key + "/Fader/On", type: "bool", scale: 1, min: 0, max: 1 },
{ id: "name", label: "Name", address: "MIXER:Current/" + g.key + "/Label/Name", type: "string", scale: 1 },
{ id: "color", label: "Color", address: "MIXER:Current/" + g.key + "/Label/Color", type: "enum", scale: 1, options: colors }
];
// Tier-1 extras, DM7-firmware-confirmed addresses (dm7_mixer_addresses.txt). Other
// models are inferred (same RCP MIXER: family) - an address a model lacks just
// ERRORs on poll and is quietly ignored (bulk-poll noise is already suppressed).
// All booleans, so no wire-scale ambiguity. Icon is a free-text string like Name.
specs.push({ id: "icon", label: "Icon", address: "MIXER:Current/" + g.key + "/Label/Icon", type: "string", scale: 1 });
// Cue / Solo lives in its own section indexed by the strip's x: Cue/<group>/On.
if (g.key == "InCh" || g.key == "Mix" || g.key == "Mtrx" || g.key == "St" || g.key == "DCA") {
specs.push({ id: "cue", label: "Cue", address: "MIXER:Current/Cue/" + g.key + "/On", type: "bool", scale: 1, min: 0, max: 1 });
}
// Filters / EQ in-out (channels & buses that have them: InCh, Mix, Mtrx).
if (g.key == "InCh" || g.key == "Mix" || g.key == "Mtrx") {
specs.push({ id: "hpfon", label: "HPF On", address: "MIXER:Current/" + g.key + "/HPF/On", type: "bool", scale: 1, min: 0, max: 1 });
specs.push({ id: "lpfon", label: "LPF On", address: "MIXER:Current/" + g.key + "/LPF/On", type: "bool", scale: 1, min: 0, max: 1 });
specs.push({ id: "peqon", label: "EQ On", address: "MIXER:Current/" + g.key + "/PEQ/On", type: "bool", scale: 1, min: 0, max: 1 });
}
// To-Stereo assign (pairs with the ToSt/Pan below). Sources that route to St.
if (g.key == "InCh" || g.key == "Mix") {
specs.push({ id: "toston", label: "To St On", address: "MIXER:Current/" + g.key + "/ToSt/On", type: "bool", scale: 1, min: 0, max: 1 });
}
if (g.key == "InCh" && haGain) {
specs.push({ id: "hagain", label: "HA Gain", address: haGain.address, type: "int",
scale: haGain.scale, min: haGain.min, max: haGain.max, def: haGain.def });
}
if (g.key == "InCh") {
// Input-channel pan to the Stereo main. 1-D (y=0). Range -63(L63)..0(C)..+63(R63),
// scale 1 (raw == the pan value). Source: the CL/QL protocol reference
// (CLQLProtocol.txt: "Pan/Balance = -63 to 63") + standard Yamaha convention, and
// `InCh/ToSt/Pan` is listed in the DM7 firmware doc too. NOT yet confirmed on a
// desk's `prminfo` (see TODO) - unlike Level's dB*100, pan has no scale ambiguity.
specs.push({ id: "pan", label: "Pan", address: "MIXER:Current/" + g.key + "/ToSt/Pan",
type: "int", scale: 1, min: -63, max: 63, def: 0 });
}
return specs;
}
function attachSpecs(groups, colors, haGain) { for (var i = 0; i < groups.length; i++) groups[i].params = specsForGroup(groups[i], colors, haGain); }
attachSpecs(CLQL_GROUPS, CLQL_COLORS, CLQL_HAGAIN);
attachSpecs(DM7_GROUPS, DM7_COLORS, DM7_HAGAIN);
attachSpecs(DM3_GROUPS, DM3_COLORS, DM3_HAGAIN); // DM3 uses the CL/QL 8-colour set; HA via IO: namespace
attachSpecs(RIVAGE_GROUPS, RIVAGE_COLORS); // Rivage HA is rack-based (Port/HA/Gain X=6) - not wired
// Scene-recall descriptors (verbs/format differ per console, from Companion):
// verb "ssrecall_ex" (CL/QL, DM3) | "ssrecallt_ex" (DM7)
// target "MIXER:Lib/Scene" (CL/QL) | "bank" -> scene_a / scene_b (DM3, DM7)
// quote quote the scene value? (DM7 uses "N.MM" strings; others integers)
// incBank append scene_a/b to RecallInc/Dec? (DM7 only)
var CLQL_SCENE = { verb: "ssrecall_ex", target: "MIXER:Lib/Scene", quote: false, incBank: false };
var DM7_SCENE = { verb: "ssrecallt_ex", target: "bank", quote: true, incBank: true };
var DM3_SCENE = { verb: "ssrecall_ex", target: "bank", quote: false, incBank: false };
var RIVAGE_SCENE = { verb: "ssrecallt_ex", target: "MIXER:Lib/Scene", quote: true, incBank: false };
var PARAM_TABLES = {
clql: { label: "CL / QL", groups: CLQL_GROUPS, models: CLQL_MODELS, defaultModel: "CL5", scene: CLQL_SCENE, colors: CLQL_COLORS },
dm7: { label: "DM7", groups: DM7_GROUPS, models: DM7_MODELS, defaultModel: "DM7", scene: DM7_SCENE, colors: DM7_COLORS },
dm3: { label: "DM3", groups: DM3_GROUPS, models: DM3_MODELS, defaultModel: "DM3", scene: DM3_SCENE, colors: DM3_COLORS },
rivage: { label: "Rivage PM", groups: RIVAGE_GROUPS, models: RIVAGE_MODELS, defaultModel: "R10", scene: RIVAGE_SCENE, colors: RIVAGE_COLORS }
};
// Which parameter table each console model uses.
var MODEL_TABLE = {
CL1: "clql", CL3: "clql", CL5: "clql", QL1: "clql", QL5: "clql",
DM7: "dm7", DM7C: "dm7",
DM3: "dm3",
RX: "rivage", RXEX: "rivage", R10: "rivage", PM7: "rivage"
};
// Expected `devinfo productname` string per model, used only to warn when the
// selected Console Model doesn't match the connected desk. Confident entries
// only: DM7 Compact's and the Rivage engines' exact product names aren't
// confirmed, so they're omitted (no entry = no mismatch check, no false alarm).
// TODO(hw): confirm strings on a real desk - see README "Still to confirm on real hardware" (#4, #5)
var EXPECTED_PRODUCT = {
CL1: "CL1", CL3: "CL3", CL5: "CL5", QL1: "QL1", QL5: "QL5",
DM3: "DM3", DM7: "DM7"
};
// =========================================================================
// RCP-PROTOCOL-START
// Pure helpers (no Chataigne API). Unit-tested by test/rcp.test.js, which
// extracts everything between the START/END markers and runs it in Node.
// Keep this block free of `local`, `script`, etc.
// =========================================================================
var RCP_MINUS_INF = -32768; // raw sentinel for -inf on dB faders
var RCP_DB_FLOOR = -138.0; // display floor we map -inf to (Chataigne Float)
// Round to nearest integer without Math or bitwise (restricted engine).
function roundInt(n) {
if (n >= 0) return parseInt("" + (n + 0.5), 10);
return -parseInt("" + (0.5 - n), 10);
}
// dB (float) -> raw integer for a param with the given scale (usually 100).
function dbToRaw(db, min, max, scale) {
if (scale === 100 && db <= RCP_DB_FLOOR) return RCP_MINUS_INF;
var raw = roundInt(db * scale);
if (raw < min) raw = min;
if (raw > max) raw = max;
return raw;
}
// raw integer -> dB (float). -inf becomes RCP_DB_FLOOR for display.
function rawToDb(raw, scale) {
if (scale === 100 && raw === RCP_MINUS_INF) return RCP_DB_FLOOR;
return raw / scale;
}
// Double-quote and escape a string value for RCP (no regex literals).
function quote(str) {
if (str === null || str === undefined) str = "";
str = "" + str;
var out = "";
for (var i = 0; i < str.length; i++) {
var c = str.charAt(i);
if (c === "\\" || c === '"') out += "\\";
out += c;
}
return '"' + out + '"';
}
// Trim leading/trailing whitespace and CR without regex.
function stripEnds(s) {
s = "" + s;
var a = 0, b = s.length;
while (a < b) { var c = s.charAt(a); if (c === " " || c === "\t" || c === "\r" || c === "\n") a++; else break; }
while (b > a) { var d = s.charAt(b - 1); if (d === " " || d === "\t" || d === "\r" || d === "\n") b--; else break; }
return s.substring(a, b);
}
// Build a "set" line. `value` is already the raw wire value (int or string).
function buildSet(address, x, y, value, isString) {
var v = isString ? quote(value) : ("" + value);
return "set " + address + " " + x + " " + y + " " + v;
}
// Build a "get" line.
function buildGet(address, x, y) {
return "get " + address + " " + x + " " + y;
}
// Split a line into tokens, honoring double-quoted substrings.
// Returns array of { value, quoted }.
function tokenize(line) {
var tokens = [];
var i = 0, n = line.length;
while (i < n) {
while (i < n && (line.charAt(i) === " " || line.charAt(i) === "\t")) i++;
if (i >= n) break;
if (line.charAt(i) === '"') {
i++;
var s = "";
while (i < n) {
var c = line.charAt(i);
if (c === "\\" && i + 1 < n) { s += line.charAt(i + 1); i += 2; continue; }
if (c === '"') { i++; break; }
s += c; i++;
}
tokens.push({ value: s, quoted: true });
} else {
var start = i;
while (i < n && line.charAt(i) !== " " && line.charAt(i) !== "\t") i++;
tokens.push({ value: line.substring(start, i), quoted: false });
}
}
return tokens;
}
// Manual substring search (charAt/length only). Chataigne's script engine does
// not reliably support String.indexOf() - on real CL5 hardware it silently
// returned false/-1 for calls that work fine under plain Node, which broke
// every indexOf()-based check below (isSceneVerb, isSceneInfoVerb,
// sceneVerbSuffix, applyScene) and made all scene handling a silent no-op,
// even though tokenize()/parseLine() themselves were correct. Returns the
// index of the first match, or -1.
function indexOfSafe(hay, needle) {
hay = "" + hay;
needle = "" + needle;
var hn = hay.length, nn = needle.length;
if (nn === 0) return 0;
for (var i = 0; i + nn <= hn; i++) {
var match = true;
for (var j = 0; j < nn; j++) {
if (hay.charAt(i + j) !== needle.charAt(j)) { match = false; break; }
}
if (match) return i;
}
return -1;
}
// True for scene recall/current verbs (`ssrecall_ex`, `ssrecallt_ex`, `sscurrent_ex`,
// `sscurrentt_ex`). Both the *_ex integer and *t_ex string families are covered.
// TODO(hw): Rivage *t_ex is hardware-confirmed, and sscurrent_ex/sscurrentt_ex are also
// corroborated by the Bitfocus Companion module (it keys scene feedback on those). The
// ssrecall_ex feedback verb stays assumed - see README "Still to confirm on real hardware".
function isSceneVerb(action) {
return action != null && (indexOfSafe(action, "ssrecall") === 0 || indexOfSafe(action, "sscurrent") === 0);
}
// True for scene-info verbs (`ssinfo_ex`, `ssinfot_ex`). These carry scene
// metadata (index, name, comment, type) rather than just a scene number.
function isSceneInfoVerb(action) {
return action != null && indexOfSafe(action, "ssinfo") === 0;
}
// Parse one reply line into { status, action, address, x, y, val, isString, raw }
// or null if it isn't a value-bearing reply we understand.
function parseLine(line) {
if (!line) return null;
line = stripEnds(line);
if (line.length === 0) return null;
var tokens = tokenize(line);
if (tokens.length === 0) return null;
var status = tokens[0].value;
if (status !== "OK" && status !== "OKm" && status !== "NOTIFY" && status !== "ERROR") return null;
if (status === "ERROR") {
return { status: status, action: null, address: null, x: 0, y: 0, val: null, isString: false, raw: line };
}
var action = tokens[1].value;
// Identity replies (`OK devinfo productname "CL5"`, `OK devstatus runmode
// "normal"`) have no address/x/y - they carry a sub-command and one value.
if (action === "devinfo" || action === "devstatus") {
if (tokens.length < 4) return null;
var dlast = tokens[tokens.length - 1];
return { status: status, action: action, sub: tokens[2].value, val: dlast.value, isString: dlast.quoted, raw: line };
}
// Scene feedback (`NOTIFY ssrecallt_ex MIXER:Lib/Scene "8.00"`, `sscurrentt_ex ...`,
// and the CL/QL integer `ssrecall_ex ... 5`). Verb + target + one scene value at
// tokens[3]; no address/x/y. Covers both the *_ex and *t_ex families.
if (isSceneVerb(action)) {
if (tokens.length < 4) return null;
var sv = tokens[3];
return { status: status, action: action, target: tokens[2].value, val: sv.value, isString: sv.quoted, raw: line };
}
// Scene-info reply (`OK ssinfot_ex MIXER:Lib/Scene "8.00" 7 "Blank" "" user`):
// target, scene value, index, NAME, comment, type. We surface name + comment.
if (isSceneInfoVerb(action)) {
if (tokens.length < 4) return null;
return {
status: status, action: action, target: tokens[2].value,
val: tokens[3].value, isString: tokens[3].quoted,
sceneName: tokens.length > 5 ? tokens[5].value : "",
sceneComment: tokens.length > 6 ? tokens[6].value : "",
raw: line
};
}
if (tokens.length < 6) return null;
var address = tokens[2].value;
var x = parseInt(tokens[3].value, 10);
var y = parseInt(tokens[4].value, 10);
if (x != x) x = 0; // NaN self-inequality (avoids isNaN dependency)
if (y != y) y = 0;
// The value is always the token right after x,y (tokens[5]). On set/NOTIFY
// lines a decorative quoted label follows it (`... 1 "ON"`, `... -4760 "-47.60"`,
// `... -32768 "-Inf"`) - that trailing token is NOT the value, so do not read
// tokens[length-1] here (doing so fed the dB label string into rawToDb -> the
// "'/' is not allowed on the String type" crash - and set `synced` to "OFF"
// instead of 0, defeating echo suppression).
var vtok = tokens[5];
var out = {
status: status, action: action, address: address,
x: x, y: y, isString: vtok.quoted, raw: line
};
out.val = vtok.quoted ? vtok.value : parseInt(vtok.value, 10);
return out;
}
// =========================================================================
// RCP-PROTOCOL-END
// =========================================================================
// ---- module state --------------------------------------------------------
var currentTable = null; // PARAM_TABLES entry in use
var valueByAddrX = {}; // "address#x" -> { param, spec, x, synced } (incoming)
var revByKey = {}; // "groupName|chName|label" -> { spec, x } (outgoing)
var locked = false; // guard: don't echo network updates back out
var built = false; // group containers created yet?
var builtTableKey = null; // which table (family) the tree was built for
var groupContainers = {}; // groupKey -> Chataigne container
var groupCounts = {}; // groupKey -> channels currently in the tree
var deviceCont = null; // "Device" container: identity read from devinfo
var deviceParamBySub = {}; // "productname"|"deviceid"|"version" -> feedback param
var sceneCurrentParam = null; // "Scene/Current" feedback param (current scene string)
var sceneNameParam = null; // "Scene/Name" feedback param (current scene's name)
var sceneCommentParam = null; // "Scene/Comment" feedback param (current scene's comment)
var syncTrigger = null; // "Sync Now" button at the top of the Values panel
var kaTickSec = 0; // seconds counted since the last keep-alive ping
// ---- lifecycle -----------------------------------------------------------
function init() {
// A one-click "Sync Now" at the top of the Values panel (mirrors the Sync Now
// command, matching the Live-OSC / QLab-OSC modules). Added once here - NOT in
// buildValues() - so a console-model change (which tears down and rebuilds the
// value tree) never removes or duplicates it. Dispatched in moduleValueChanged.
syncTrigger = local.values.addTrigger("Sync Now", "Prime all values from the console (same as the Sync Now command).", false);
buildValues();
// Drive update() at 1 Hz so the keep-alive counter below ticks in whole
// seconds regardless of the deltaTime unit. The loop is inert when the
// "Keep Alive Interval" parameter is 0.
script.setUpdateRate(1);
}
// Resize the value tree when the console model changes.
function moduleParameterChanged(param) {
if (!param.isParameter()) return;
if (param.name == "consoleModel") buildValues();
if (param.name == "keepAliveInterval") kaTickSec = 0; // restart the ping window
}
// Called ~once/second (see init's setUpdateRate). When keep-alive is enabled,
// send harmless traffic (`devstatus runmode`) every N seconds so the desk does
// not close an idle RCP connection - which would silently kill NOTIFY feedback.
// Opt-in and UNVERIFIED on hardware; see README "Still to confirm" #1.
function update(deltaTime) {
var s = keepAliveSec();
if (s <= 0) return;
kaTickSec++;
if (kaTickSec >= s) {
kaTickSec = 0;
sendLine("devstatus runmode");
}
}
// Keep-alive interval in seconds (0 = disabled).
function keepAliveSec() {
var s = readModuleParam("keepAliveInterval", 0);
return (s > 0) ? s : 0;
}
// ---- value tree ----------------------------------------------------------
// Group containers (and Scene) are created once, in order, so Scene stays last.
// On each call we grow/shrink each group's channel count to match the model -
// this handles the initial build AND live model changes without a teardown.
function buildValues() {
locked = true; // adding params must not trigger outgoing sets
var modelKey = readModuleParam("consoleModel", "CL5");
var tableKey = MODEL_TABLE[modelKey];
if (tableKey == undefined) tableKey = "clql";
// Switching console families (CL/QL <-> DM7) has a different group set, so
// tear the tree down and rebuild it fresh. Same-family model changes just
// resize channel counts below.
if (built && tableKey != builtTableKey) teardownTree();
currentTable = PARAM_TABLES[tableKey];
builtTableKey = tableKey;
var model = currentTable.models[modelKey];
if (model == undefined) model = currentTable.models[currentTable.defaultModel];
var groups = currentTable.groups;
if (!built) {
valueByAddrX = {};
revByKey = {};
// Device identity (top of the tree): read-only-by-convention values filled
// from the desk's `devinfo` replies during syncAll(). Not in revByKey, so
// edits to them are never echoed back out.
deviceCont = local.values.addContainer("Device");
deviceCont.setCollapsed(true);
deviceParamBySub = {
productname: deviceCont.addStringParameter("Product Name", "Model the console reports (devinfo productname)", ""),
deviceid: deviceCont.addStringParameter("Device ID", "Device ID the console reports (devinfo deviceid)", ""),
version: deviceCont.addStringParameter("Firmware Version", "Firmware the console reports (devinfo version)", "")
};
for (var gi = 0; gi < groups.length; gi++) {
var gCont = local.values.addContainer(groups[gi].label);
gCont.setCollapsed(true); // groups collapsed by default (tidy tree)
groupContainers[groups[gi].key] = gCont;
groupCounts[groups[gi].key] = 0;
}
var scene = local.values.addContainer("Scene");
// Feedback-only (not in revByKey, so edits are never echoed out). String holds
// both CL/QL integers ("8") and DM7/Rivage "N.MM" ("8.00").
sceneCurrentParam = scene.addStringParameter("Current", "Current scene reported by the console", "");
// Name/Comment of the current scene, fetched via ssinfo(t)_ex whenever the
// current scene changes (or on Sync). Feedback-only.
sceneNameParam = scene.addStringParameter("Name", "Name of the current scene (from ssinfo)", "");
sceneCommentParam = scene.addStringParameter("Comment", "Comment of the current scene (from ssinfo)", "");
built = true;
}
for (var gj = 0; gj < groups.length; gj++) setGroupCount(groups[gj], model[groups[gj].key]);
script.log("Yamaha RCP: model " + modelKey + " (" + currentTable.label + ")");
locked = false;
}
// Remove the whole value tree (used when switching console families).
// Remove by the stored container reference (not the label): a container's
// sanitized .name can differ from its label, so removeContainer(label) is not
// guaranteed to match.
function teardownTree() {
var oldGroups = PARAM_TABLES[builtTableKey].groups;
for (var i = 0; i < oldGroups.length; i++) {
var cont = groupContainers[oldGroups[i].key];
if (cont != undefined) local.values.removeContainer(cont);
}
var scene = local.values.getChild("Scene");
if (scene != undefined) local.values.removeContainer(scene);
if (deviceCont != undefined) local.values.removeContainer(deviceCont);
built = false;
groupContainers = {};
groupCounts = {};
deviceCont = null;
deviceParamBySub = {};
sceneCurrentParam = null;
sceneNameParam = null;
sceneCommentParam = null;
valueByAddrX = {};
revByKey = {};
}
// Grow or shrink one group's channel sub-containers to `target`.
function setGroupCount(g, target) {
var cur = groupCounts[g.key];
var cont = groupContainers[g.key];
if (target > cur) {
for (var ch = cur; ch < target; ch++) addChannel(g, cont, ch);
} else if (target < cur) {
for (var ch2 = target; ch2 < cur; ch2++) removeChannel(g, cont, ch2);
}
groupCounts[g.key] = target;
}
function addChannel(g, cont, ch) {
var gName = cont.name;
var chCont = cont.addContainer(pad2(ch + 1));
chCont.setCollapsed(true); // channels collapsed by default too
var chName = chCont.name;
for (var p = 0; p < g.params.length; p++) {
var spec = g.params[p];
var param = addChannelParam(chCont, spec);
// valueByAddrX: incoming NOTIFY/OK (keyed by wire address + channel index).
valueByAddrX[spec.address + "#" + ch] = { param: param, spec: spec, x: ch, synced: undefined };
// revByKey: outgoing user edits, keyed by the value's place in the tree.
revByKey[gName + "|" + chName + "|" + param.niceName] = { spec: spec, x: ch };
}
}
function removeChannel(g, cont, ch) {
var chName = pad2(ch + 1);
for (var p = 0; p < g.params.length; p++) {
valueByAddrX[g.params[p].address + "#" + ch] = undefined;
revByKey[cont.name + "|" + chName + "|" + g.params[p].label] = undefined;
}
cont.removeContainer(chName);
}
function addChannelParam(container, spec) {
if (spec.id == "level") {
return container.addFloatParameter(spec.label, spec.address, 0, RCP_DB_FLOOR, spec.max / spec.scale);
}
if (spec.id == "hagain") {
return container.addFloatParameter(spec.label, spec.address, spec.def / spec.scale, spec.min / spec.scale, spec.max / spec.scale);
}
if (spec.type == "bool") {
return container.addBoolParameter(spec.label, spec.address, true);
}
if (spec.id == "pan") {
return container.addIntParameter(spec.label, spec.address, spec.def, spec.min, spec.max);
}
// name / colour are exposed as strings. A colour *picker* isn't feasible here: the
// Chataigne (JUCE) script engine can't build a 9-11 option EnumParameter - inline
// options hit a native-call argument cap (only ~8 survive), there's no addOption(),
// and no Function.apply to work around it. So colour stays free text in the tree;
// the model-gated "Set Channel Color" command (a static module.json enum) is the
// picker. The wire value is the quoted colour name either way (isStringSpec).
return container.addStringParameter(spec.label, spec.address, "");
}
// ---- sending / subscribing ----------------------------------------------
// The "Lines" protocol does NOT append the delimiter, so we add "\n".
function sendLine(line) {
if (DEBUG) script.log(">> " + line);
local.send(line + "\n");
}
// Prime all modeled values and (re)establish notifications.
function syncAll() {
subscribeAll();
// If keep-alive is on, tell the desk our idle window. The firmware treats the
// value as ~2x the expected ping interval (it stores keepalive/2), so we send
// 2x our ping interval (min 1000ms, the desk's floor). update() does the pings.
var ka = keepAliveSec();
if (ka > 0) {
var winMs = ka * 2000;
if (winMs < 1000) winMs = 1000;
sendLine("scpmode keepalive " + winMs);
}
// Ask the desk who it is (fills the Device container + mismatch warning).
sendLine("devinfo productname");
sendLine("devinfo deviceid");
sendLine("devinfo version");
// Ask which scene is current; the OK reply drives Scene/Current and, via
// applyScene, a follow-up ssinfo query that fills Scene/Name + Scene/Comment.
// DM7/DM3 have banks - default to bank A here; a later NOTIFY corrects it.
var sc = currentTable.scene;
var curTarget = (sc.target == "bank") ? sceneBank("A") : sc.target;
sendLine("sscurrent" + sceneVerbSuffix() + " " + curTarget);
getAllValues();
}
// Re-get every modeled value (no identity query). Used by the initial sync and to
// refresh the tree after a desk-side scene change, which alters many values at once.
function getAllValues() {
var groups = currentTable.groups;
for (var gi = 0; gi < groups.length; gi++) {
var g = groups[gi];
var n = groupCounts[g.key];
for (var p = 0; p < g.params.length; p++) {
for (var ch = 0; ch < n; ch++) sendLine(buildGet(g.params[p].address, ch, 0));
}
}
}
/*
* VERIFY ON HARDWARE: CL/QL is assumed to push NOTIFY for every change on any
* open RCP session, so no explicit subscribe line is sent (the get()s in
* syncAll prime initial state). If your desk needs an explicit subscribe, add
* it here - this is the single place to change.
* TODO(hw): confirm on a real desk - see README "Still to confirm on real hardware" (#1)
*/
function subscribeAll() {
// intentionally empty for the auto-notify assumption
}
// ---- command callbacks (see module.json) --------------------------------
function cmdSetFaderLevel(group, channel, levelDb) {
var spec = groupParamSpec(group, "level");
if (spec == undefined) return;
sendLine(buildSet(spec.address, channel - 1, 0, dbToRaw(levelDb, spec.min, spec.max, spec.scale), false));
}
// HA gain wire value: snap to a whole dB first (the desk quantizes HA gain to 1 dB
// steps - live-observed on DM7, which rounds any sub-step value to the nearest dB),
// then apply scale. Keeps the tree in sync with the desk and lets the synced-guard
// collapse a drag's flood of near-identical sets. (round(dB) then *scale, not
// round(dB*scale).)
function haGainRaw(spec, gainDb) {
return clampInt(roundInt(gainDb) * spec.scale, spec.min, spec.max);
}
// Head-amp gain on an input channel (dB). Only sent for models that expose it
// (DM7); a no-op on models without a modeled InCh HA gain.
function cmdSetHAGain(channel, gainDb) {
var spec = groupParamSpec("InCh", "hagain");
if (spec == undefined) return;
sendLine(buildSet(spec.address, channel - 1, 0, haGainRaw(spec, gainDb), false));
}
// Input-channel pan to the Stereo main. pan -63(L)..0(C)..+63(R), scale 1.
function cmdSetPan(channel, pan) {
var spec = groupParamSpec("InCh", "pan");
if (spec == undefined) return;
sendLine(buildSet(spec.address, channel - 1, 0, clampInt(roundInt(pan * spec.scale), spec.min, spec.max), false));
}
function cmdSetChannelOn(group, channel, on) {
var spec = groupParamSpec(group, "on");
if (spec == undefined) return;
sendLine(buildSet(spec.address, channel - 1, 0, on ? 1 : 0, false));
}
function cmdSetChannelName(group, channel, name) {
var spec = groupParamSpec(group, "name");
if (spec == undefined) return;
sendLine(buildSet(spec.address, channel - 1, 0, name, true));
}
function cmdSetChannelColor(group, channel, color) {
var spec = groupParamSpec(group, "color");
if (spec == undefined) return;
// The command enum is static (it lists every model's colours). Skip colours
// the active console doesn't have, else the desk answers ERROR.
if (!contains(currentTable.colors, color)) {
script.logWarning("Yamaha RCP: colour '" + color + "' not valid for " + currentTable.label + " - ignored");
return;
}
sendLine(buildSet(spec.address, channel - 1, 0, color, true));
}
// Scene commands use console-specific verbs/targets (see the scene descriptors).
// CL/QL : ssrecall_ex MIXER:Lib/Scene <n> ; event MIXER:Lib/Scene/RecallInc
// DM3 : ssrecall_ex scene_a <n> ; event MIXER:Lib/Scene/RecallInc
// DM7 : ssrecallt_ex scene_a "<N.MM>" ; event MIXER:Lib/Scene/RecallInc scene_a
// `bank` is "A"/"B" and only applies to the DM consoles' two scene lists.
function sceneBank(bank) { return "scene_" + (bank == "B" ? "b" : "a"); }
function cmdRecallScene(scene, bank) {
var s = currentTable.scene;
var target = (s.target == "bank") ? sceneBank(bank) : s.target;
var val = s.quote ? quote("" + scene) : ("" + scene);
sendLine(s.verb + " " + target + " " + val);
}
function cmdSceneInc(bank) {
var s = currentTable.scene;
sendLine("event MIXER:Lib/Scene/RecallInc" + (s.incBank ? (" " + sceneBank(bank)) : ""));
}
function cmdSceneDec(bank) {
var s = currentTable.scene;
sendLine("event MIXER:Lib/Scene/RecallDec" + (s.incBank ? (" " + sceneBank(bank)) : ""));
}
function cmdGenericSet(address, x, y, value, quoteValue) {
sendLine(buildSet(address, x, y, value, quoteValue));
}
function cmdGenericGet(address, x, y) {
sendLine(buildGet(address, x, y));
}
function cmdSyncNow() {
syncAll();
}
// ---- receiving -----------------------------------------------------------
// Chataigne calls this for each incoming line (Lines protocol).
function dataReceived(data) {
if (DEBUG) script.log("<< " + data);
var msg = parseLine(data);
if (msg == null) return;
if (msg.status == "ERROR") {
// `ERROR ... InvalidArgument` is an EXPECTED reply during bulk polling: an
// address/index that doesn't exist on this model/patch (hardware-confirmed
// on DM7 - `InCh/Port/HA/Gain/N` only answers for channels patched to a
// physical head amp; the rest error, ~345 per sync). It's benign - the
// param just isn't there - so don't spam warnings for it; log only under
// DEBUG. Real errors (bad syntax, etc.) still surface as warnings.
if (indexOfSafe("" + msg.raw, "InvalidArgument") >= 0) {
if (DEBUG) script.log("(ignored) " + msg.raw);
} else {
script.logWarning("Yamaha RCP error: " + msg.raw);
}
return;
}
if (msg.action == "devinfo" || msg.action == "devstatus") {
applyDevinfo(msg);
return;
}
if (isSceneVerb(msg.action)) {
applyScene(msg);
return;
}
if (isSceneInfoVerb(msg.action)) {
applySceneInfo(msg);
return;
}
applyIncoming(msg);
}
// Reflect the console's current scene into Scene/Current (display-only). Fires on
// recall/current NOTIFY and on the OK reply to our own recall/query.
function applyScene(msg) {
if (sceneCurrentParam == undefined) return;
// DM7/Rivage quirk (hardware-verified, dm7-rcp2.pcapng): the desk's spontaneous
// scene NOTIFYs use the NON-t_ex verbs carrying a 0-based scene INDEX (scene
// 1.00 -> 0), not the "N.MM" string. That index is wrong for Scene/Current and
// ssinfot_ex rejects it ("InvalidArgument"). Detect that shape (t_ex model +
// unquoted value) and instead re-query sscurrentt_ex; its OK reply is a quoted
// "N.MM" string that re-enters here and takes the normal path below (no loop -
// that reply is an OK, not a NOTIFY). Non-t_ex models (CL/QL, DM3) use the
// integer scene number directly and skip this.
if (isTExModel() && !msg.isString) {
if (msg.status == "NOTIFY" && indexOfSafe("" + msg.action, "sscurrent") === 0) {
querySceneCurrent(msg.target); // fetch the real "N.MM" (drives display + name)
getAllValues(); // a recall changed many values - re-read the tree
}
return;
}
setGuarded(sceneCurrentParam, "" + msg.val);
// Fetch this scene's name/comment, reusing the exact verb family + target the
// desk reported (robust to per-model bank/target differences).
querySceneInfo(msg.target, msg.val, msg.isString);
// A scene recall changes many parameters at once. When the desk reports the new
// current scene (sscurrent* NOTIFY), re-read the tree so channel values don't go
// stale - mirrors the Bitfocus Companion re-poll behaviour. The get replies route
// to applyIncoming, not back here, so there's no loop.
// TODO(hw): the desk is assumed not to send sscurrent to the client that recalled,
// so our own Recall Scene won't auto-refresh; and gets issued right after a recall
// are assumed to read post-recall (not mid-fade) values - see README "Still to
// confirm on real hardware".
if (msg.status == "NOTIFY" && indexOfSafe("" + msg.action, "sscurrent") === 0) {
getAllValues();
}
}
// True for models whose scenes are the string "N.MM" family (DM7/Rivage, verb
// ...t_ex), false for the integer family (CL/QL, DM3, verb ..._ex).
function isTExModel() {
return indexOfSafe("" + currentTable.scene.verb, "t_ex") >= 0;
}
// Scene verb suffix for the current model: "t_ex" (DM7/Rivage string scenes) or
// "_ex" (CL/QL, DM3 integer scenes) - derived from the recall descriptor.
function sceneVerbSuffix() {
return isTExModel() ? "t_ex" : "_ex";
}
// Ask the desk for the current scene of a target (drives Scene/Current + the
// chained ssinfo name query via applyScene). Uses the model's t_ex/_ex verb.
function querySceneCurrent(target) {
if (target == undefined || ("" + target).length === 0) return;
sendLine("sscurrent" + sceneVerbSuffix() + " " + target);
}
// Ask the desk for a scene's metadata so Scene/Name + Scene/Comment track the
// current scene. Echoes the target/quoting the desk itself used for the scene.
function querySceneInfo(target, val, isString) {
if (sceneNameParam == undefined) return;
if (target == undefined || ("" + target).length === 0) return;
if (val == undefined || ("" + val).length === 0) return;
var v = isString ? quote("" + val) : ("" + val);
sendLine("ssinfo" + sceneVerbSuffix() + " " + target + " " + v);
}
// Reflect a scene-info reply into Scene/Name and Scene/Comment (display-only).
function applySceneInfo(msg) {
if (sceneNameParam != undefined) setGuarded(sceneNameParam, "" + msg.sceneName);
if (sceneCommentParam != undefined) setGuarded(sceneCommentParam, "" + msg.sceneComment);
}
// Fill the Device container from a `devinfo` reply, and warn if the reported
// product name doesn't match the selected Console Model. `devstatus` is ignored.
function applyDevinfo(msg) {
if (msg.action != "devinfo") return;
var param = deviceParamBySub[msg.sub];
if (param != undefined) setGuarded(param, msg.val);
if (msg.sub == "productname") checkModelMatch(msg.val);
}
function checkModelMatch(reported) {
var modelKey = readModuleParam("consoleModel", "CL5");
var expected = EXPECTED_PRODUCT[modelKey];
if (expected == undefined) return; // unconfirmed product name (DM7C/Rivage) - skip
if (("" + reported).toUpperCase() != expected.toUpperCase()) {
script.logWarning("Yamaha RCP: console reports '" + reported + "' but Console Model is set to " +
modelKey + " (expected '" + expected + "') - check the model selector");
}
}
function applyIncoming(msg) {
var entry = valueByAddrX[msg.address + "#" + msg.x];
if (entry != undefined) {
var spec = entry.spec;
var v;
if (spec.id == "level") v = rawToDb(msg.val, spec.scale);
else if (spec.id == "hagain" || spec.id == "pan") v = msg.val / spec.scale; // scaled int -> value (pan scale 1)
else if (spec.type == "bool") v = (msg.val == 1);
else v = msg.val; // name / color / icon strings
// Record the console's value BEFORE applying it, so the value change it
// triggers (which may fire asynchronously) is recognised as an echo.
entry.synced = msg.val;
setGuarded(entry.param, v);
}
}
function setGuarded(param, value) {
locked = true;
param.set(value);
locked = false;
}
// ---- reverse sync: user edits a value ------------------------------------
function moduleValueChanged(value) {
if (locked) return;
// The Values-panel "Sync Now" trigger. Checked by identity BEFORE the
// isParameter() guard below - a Chataigne Trigger is not a Parameter, so it
// would otherwise be dropped here.
if (value === syncTrigger) { syncAll(); return; }
if (!value.isParameter()) return;
// Locate the value in the tree: value -> channel container -> group container.
var parent = value.getParent();
if (parent == undefined) return;
var grand = parent.getParent();
if (grand == undefined) return;
var meta = revByKey[grand.name + "|" + parent.name + "|" + value.niceName];
if (meta == undefined) return; // not a channel value (e.g. Scene/Current)
var spec = meta.spec;
var x = meta.x;
var wire = wireValueOf(spec, value);
var entry = valueByAddrX[spec.address + "#" + x];
// Echo suppression: `entry.synced` tracks the value currently in sync with the
// console. If this change already matches it, the change came FROM the console
// (applyIncoming set `synced` just before triggering it, even if the callback
// fires asynchronously/coalesced) - don't bounce it back. A genuine user change
// differs from `synced`, so it's sent and becomes the new synced value.
// NB: compare stringified values only - do NOT add `entry.synced != undefined`,
// as this engine treats `0 != undefined` as false (it coerces undefined to 0).
// An unset `synced` stringifies to "undefined", which won't match a wire value.
if (entry != undefined && ("" + entry.synced) == ("" + wire)) {
return;
}
if (entry != undefined) entry.synced = wire; // we're pushing this; console will hold it
sendLine(buildSet(spec.address, x, 0, wire, isStringSpec(spec)));
}
// The RCP wire value for a given value parameter (raw int or string).
function wireValueOf(spec, value) {
if (spec.id == "level") return dbToRaw(value.get(), spec.min, spec.max, spec.scale);
if (spec.id == "hagain") return haGainRaw(spec, value.get());
if (spec.id == "pan") return clampInt(roundInt(value.get() * spec.scale), spec.min, spec.max);
if (spec.type == "bool") return value.get() ? 1 : 0;
return value.get();
}
// Clamp an integer wire value into [min, max] (avoids Math.min/max 2-arg quirks).
function clampInt(v, min, max) {
if (v < min) return min;
if (v > max) return max;
return v;
}
function isStringSpec(spec) {
return spec.type == "string" || spec.type == "enum";
}
// ---- helpers -------------------------------------------------------------
// Find a group's param spec by group key + param id, or undefined.