-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathshaper_tuning.html
More file actions
2859 lines (2789 loc) · 178 KB
/
Copy pathshaper_tuning.html
File metadata and controls
2859 lines (2789 loc) · 178 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
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title data-i18n="shaper_tuning.title">Shaper tuning — Klipper / Kalico</title>
<link rel="icon" type="image/svg+xml" href="favicon.svg">
<script src="i18n.js"></script>
<style>
html{overflow-y:scroll;}
/* --blue/--amber/--teal/--purple/--red/--cyan (the chart "data colors")
are owned by nav.js, which applies the user's chosen colorblind-safe
palette (Okabe & Ito / Tol) as inline vars on <html> -- not redefined
here. */
:root{
--text-primary:#0b0b0b; --text-secondary:#52514e; --text-muted:#898781;
--surface-0:#f4f3ee; --surface-1:#faf9f5; --surface-2:#ffffff;
--border:rgba(11,11,11,.12); --radius:8px;
--grid:#e1e0d9;
--font-sans:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
--font-mono:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
}
@media (prefers-color-scheme:dark){
:root{
--text-primary:#f2f1ea; --text-secondary:#c3c2b7; --text-muted:#898781;
--surface-0:#191918; --surface-1:#1f1f1e; --surface-2:#262625;
--border:rgba(255,255,255,.14); --grid:#2c2c2a;
}
}
*{box-sizing:border-box}
body{margin:0;background:var(--surface-0);color:var(--text-primary);font-family:var(--font-sans);line-height:1.6;}
.wrap{max-width:1040px;margin:0 auto;padding:2rem 1.25rem 4rem;}
h1{font-size:22px;font-weight:500;margin:.5rem 0 .25rem;}
h2{font-size:16px;font-weight:500;margin:2rem 0 .5rem;}
/* Manual/multimode explorer (+ Deeper analysis nested inside it) is for
advanced users -- collapsed behind a native <details> disclosure, closed
by default, so a first-time visitor never sees its generic placeholder
numbers (mzv @ 50Hz etc.) before loading real data. summary mimics the
h2 style so it reads as the section's heading; the native marker is
replaced with a rotating ▸ so it still doubles as an expand/collapse cue. */
details.advanced{margin-top:2rem;}
summary.advanced-summary{cursor:pointer;font-size:16px;font-weight:500;color:var(--text-primary);list-style:none;display:flex;align-items:center;gap:8px;margin:0 0 .5rem;}
summary.advanced-summary::-webkit-details-marker{display:none;}
summary.advanced-summary::before{content:'▸';display:inline-block;color:var(--text-muted);font-size:12px;transition:transform .15s;}
details.advanced[open] summary.advanced-summary::before{transform:rotate(90deg);}
summary.advanced-summary:hover{color:var(--text-primary);}
summary.advanced-summary .tag{font-size:11px;letter-spacing:.06em;text-transform:uppercase;color:var(--text-muted);font-weight:400;}
.advanced-body{padding-top:.25rem;}
.sub{color:var(--text-secondary);font-size:15px;margin:0 0 1.5rem;}
.configref-note{margin-top:-.9rem;font-size:14px;}
code{font-family:var(--font-mono);font-size:.9em;}
.grouplabel{font-size:12px;letter-spacing:.04em;text-transform:uppercase;color:var(--text-muted);margin:1rem 0 .5rem;}
.row{display:flex;flex-wrap:wrap;gap:16px;margin-bottom:1rem;align-items:flex-end;}
.ctl{flex:1;min-width:150px;}
.ctl .lbl{display:flex;justify-content:space-between;font-size:13px;color:var(--text-secondary);margin-bottom:4px;}
.ctl .val{font-weight:500;color:var(--text-primary);}
input[type=range]{width:100%;accent-color:var(--blue);}
input[type=number]{width:100%;font-family:inherit;font-size:13px;padding:6px 8px;border-radius:6px;border:.5px solid var(--border);background:var(--surface-2);color:var(--text-primary);}
select{font-family:inherit;font-size:13px;padding:6px 8px;border-radius:6px;border:.5px solid var(--border);background:var(--surface-2);color:var(--text-primary);}
button.btn{font-family:inherit;font-size:13px;padding:6px 12px;border-radius:var(--radius);border:.5px solid var(--border);background:var(--surface-2);color:var(--text-secondary);cursor:pointer;}
button.btn:hover{color:var(--text-primary);border-color:var(--blue);}
button.btn:disabled{opacity:.4;cursor:not-allowed;}
button.btn.primary{color:var(--blue);border-color:var(--blue);background:color-mix(in srgb, var(--blue) 8%, transparent);font-weight:500;}
.cap{font-size:13px;color:var(--text-secondary);margin:1.1rem 0 2px;}
.chart{position:relative;width:100%;height:320px;}
/* Elevates the primary measured-spectrum chart (one of the page's 3 named
focus points, alongside the recommendation + config) to the same bordered-
panel visual weight as .card/.note, instead of a bare canvas floating on
the page background -- the secondary "residual vs frequency" chart inside
the advanced section stays plain by contrast. */
.chart.panel{border:.5px solid var(--border);border-radius:var(--radius);background:var(--surface-1);padding:1rem 1rem .25rem;}
.busybox{position:relative;}
.busybox.busy .busy-mute{opacity:.35;filter:saturate(.5);transition:opacity .15s,filter .15s;pointer-events:none;}
.busy-overlay{position:absolute;inset:0;display:none;align-items:center;justify-content:center;z-index:5;border-radius:var(--radius);}
.busybox.busy .busy-overlay{display:flex;}
.kv-spinner{width:32px;height:32px;border-radius:50%;border:3px solid var(--border);border-top-color:var(--blue);animation:kv-spin .8s linear infinite;}
@keyframes kv-spin{to{transform:rotate(360deg);}}
.note{margin:1rem 0;padding:.85rem 1.1rem;border-radius:var(--radius);font-size:13.5px;border:.5px solid var(--border);background:var(--surface-1);color:var(--text-secondary);}
.note.warn{border-color:var(--red);color:var(--red);background:color-mix(in srgb, var(--red) 8%, transparent);}
.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:12px;margin-top:1.25rem;}
.card{background:var(--surface-1);border:.5px solid var(--border);border-radius:var(--radius);padding:.85rem 1rem;}
.card .k{font-size:13px;color:var(--text-secondary);}
.card .v{font-size:19px;font-weight:500;margin-top:2px;}
/* Soft blue accent on the recommendation box, carried through to the
suggested config panel below it (.cfgpanel.primary) -- together they read
as one connected "this is your answer" block, distinct from the neutral
(and collapsed-by-default) advanced section beneath. */
.recopanel{border:.5px solid color-mix(in srgb, var(--blue) 30%, var(--border));border-radius:var(--radius);padding:1rem 1rem .75rem;background:color-mix(in srgb, var(--blue) 4%, var(--surface-0));}
.foot{margin-top:2.5rem;font-size:13px;color:var(--text-muted);border-top:.5px solid var(--border);padding-top:1rem;}
/* Legal/credits link -- its own line at the very bottom, set apart from the
provenance footer above it. Present on every page. */
.foot-legal{margin-top:1.75rem;font-size:13px;}
.foot-legal a{color:var(--text-secondary);}
a{color:var(--blue);}
.peakrows{display:flex;flex-direction:column;gap:8px;margin-bottom:.6rem;}
.peakrow{display:grid;grid-template-columns:20px 1fr 100px 100px 32px;gap:10px;align-items:center;}
.peakrow .swatch{width:12px;height:12px;border-radius:50%;justify-self:center;}
.peakrow .fld-lbl{font-size:11px;color:var(--text-muted);display:block;margin-bottom:2px;}
.peakctl-head{display:grid;grid-template-columns:20px 1fr 100px 100px 32px;gap:10px;font-size:11px;color:var(--text-muted);margin-bottom:2px;}
.budget{font-size:12.5px;color:var(--text-muted);margin:.4rem 0 1rem;}
.databar{display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin-bottom:.6rem;}
.databar input[type=file]{font-size:12.5px;max-width:220px;}
.library{margin:0 0 .8rem;}
.library-label{font-size:11px;letter-spacing:.04em;text-transform:uppercase;color:var(--text-muted);margin-bottom:5px;}
.library-list{display:flex;flex-direction:column;gap:5px;}
.library-row{display:flex;align-items:center;gap:10px;padding:6px 10px;border-radius:6px;border:.5px solid var(--border);background:var(--surface-2);cursor:pointer;font-size:12.5px;}
.library-row:hover{border-color:var(--blue);}
.library-row.active{border-color:var(--blue);background:color-mix(in srgb, var(--blue) 8%, transparent);}
.library-name{font-family:var(--font-mono);color:var(--text-primary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;}
.library-date{color:var(--text-muted);font-size:11px;white-space:nowrap;}
.library-remove{padding:1px 8px;line-height:1;font-size:15px;}
.examples-hint{font-size:12.5px;color:var(--text-muted);margin:0 0 1rem;display:flex;flex-direction:column;align-items:flex-start;gap:4px;}
.examples-hint button.linklike{background:none;border:none;padding:0;margin:0;color:var(--blue);text-decoration:underline;cursor:pointer;font:inherit;font-size:12.5px;}
.examples-hint button.linklike:hover{color:var(--text-primary);}
.aspectbox{position:relative;width:100%;overflow:hidden;border-radius:var(--radius);border:.5px solid var(--border);background:var(--surface-1);}
.aspectbox canvas{position:absolute;inset:0;width:100%!important;height:100%!important;}
.bandhdr{margin:3rem 0 .25rem;padding-top:1.25rem;border-top:1px solid var(--border);}
.bandhdr .tag{font-size:11px;letter-spacing:.06em;text-transform:uppercase;color:var(--text-muted);}
pre.cfgout{font-family:var(--font-mono);font-size:12.5px;line-height:1.5;background:var(--surface-2);border:.5px solid var(--border);border-radius:var(--radius);padding:.85rem 2.4rem .85rem 1rem;overflow-x:auto;white-space:pre;color:var(--text-primary);margin:0;min-height:4.5em;}
/* One config panel = a labeled config-output box with its own copy button
and save_config-prefix checkbox. Two independent instances (suggested +
manual) replace the old single side-by-side box+toggle -- see
cfgOutSuggested/cfgOutManual in the JS below. */
.cfgpanel{margin:1rem 0;}
.cfgpanel-head{display:flex;align-items:center;gap:10px;margin-bottom:6px;}
.cfgpanel-label{font-size:13px;font-weight:500;color:var(--text-primary);}
/* Axis badge/picker only appears on the suggested panel (axis assignment is
a property of the loaded capture, not of manual-vs-suggested, so there's
only one control point for it) -- pushed to the row's right edge. */
.cfgpanel-head .axis-badge,.cfgpanel-head .cfgtoggle{margin-left:auto;}
.cfgoutwrap{position:relative;}
.cfgcopy{position:absolute;top:8px;right:8px;width:28px;height:28px;display:flex;align-items:center;justify-content:center;border-radius:6px;border:.5px solid var(--border);background:var(--surface-1);color:var(--text-muted);cursor:pointer;padding:0;}
.cfgcopy:hover{color:var(--text-primary);background:var(--surface-2);}
.cfgcopy svg{width:15px;height:15px;display:block;}
.cfgprefix{font-size:13px;color:var(--text-secondary);display:flex;align-items:center;gap:6px;margin-top:8px;}
.cfgtoggle{display:flex;gap:8px;align-items:center;}
.axistoggle-label{font-size:13px;color:var(--text-secondary);}
/* The suggested panel is the direct product of "2. Recommended shaper" --
a soft blue accent (paired with .recopanel above it) ties the two
together as one "this is your answer" block, distinct from the neutral,
collapsed-by-default advanced section below. */
.cfgpanel.primary .cfgpanel-label{color:var(--blue);}
.cfgpanel.primary pre.cfgout{border-color:color-mix(in srgb, var(--blue) 35%, var(--border));}
/* Info icon: replaces the old always-visible step-4 intro paragraph --
same content, now a hover/focus tooltip (native title/aria-label, same
pattern as accelEditHint elsewhere on this page). */
.infoicon{display:inline-flex;align-items:center;justify-content:center;width:20px;height:20px;border-radius:50%;color:var(--text-muted);cursor:help;flex:none;}
.infoicon:hover,.infoicon:focus-visible{color:var(--text-primary);}
.infoicon svg{width:16px;height:16px;display:block;}
/* Inline variant: an (i) sitting next to a heading or chart caption, holding
supplemental "how this works" text in its native tooltip (\n-broken so the
popup stays narrow). vertical-align:middle keeps it centred on the text. */
.infoicon.inline{vertical-align:middle;margin-left:4px;width:18px;height:18px;}
.infoicon.inline svg{width:15px;height:15px;}
.axis-badge{font-size:13px;color:var(--text-secondary);padding:4px 10px;border-radius:6px;background:var(--surface-2);border:.5px solid var(--border);}
.tierinfo{display:flex;align-items:flex-start;gap:12px;margin:0 0 1.25rem;padding:.7rem 1rem;border-radius:var(--radius);border:.5px solid var(--border);background:var(--surface-1);color:var(--text-secondary);font-size:13px;line-height:1.5;}
.tierinfo-close{flex:none;background:none;border:none;color:var(--text-muted);font-size:20px;line-height:1;padding:0 2px;cursor:pointer;}
.tierinfo-close:hover{color:var(--text-primary);}
.val.editable{cursor:pointer;border-bottom:1px dashed var(--text-muted);}
input.val-edit{font:inherit;width:5.5em;background:var(--surface-2);color:var(--text-primary);border:.5px solid var(--blue);border-radius:4px;padding:0 4px;}
</style>
</head>
<body>
<script src="nav.js"></script>
<div class="wrap">
<h1 data-i18n="shaper_tuning.heading">Shaper tuning</h1>
<p class="sub" data-i18n-html="shaper_tuning.intro">Load a resonance capture and everything on this page is computed from <em>your</em> data: the measured spectrum, the shaper <code>SHAPER_CALIBRATE</code> would recommend (the full <code>find_best_shaper</code> search ported to your browser), a hands-on explorer for any shaper type, and a ready-to-paste <code>[input_shaper]</code> config.<br>Nothing is uploaded anywhere — peak detection, damping estimation, and the whole shaper search run locally.</p>
<!-- Pointer to the (nav-hidden) config reference sub-page. Its own element so
the intro's data-i18n-html doesn't clobber the link. -->
<p class="sub configref-note" data-i18n-html="shaper_tuning.configRefLink">Setting up a new printer? See the <a href="config_reference.html">config reference</a> for every changed <code>[input_shaper]</code> and <code>[resonance_tester]</code> option.</p>
<!-- Firmware tier: gates which shaper types are offered/searched, since
smooth_* smoothers and multimode aren't on every firmware. The math
itself never changes per tier -- only the candidate list does. -->
<div class="row" style="gap:8px;align-items:center;margin:.75rem 0 .75rem;">
<button type="button" class="btn" id="tierKlipper" data-i18n="shaper_tuning.tier.klipper">Klipper</button>
<button type="button" class="btn" id="tierKalicoBE" data-i18n="shaper_tuning.tier.kalicobe">Kalico BE</button>
<button type="button" class="btn" id="tierMultimode" data-i18n="shaper_tuning.tier.multimode">Multimode</button>
<span id="tierNote" style="font-size:12.5px;color:var(--text-muted);"></span>
</div>
<!-- Shown only on the Multimode tier: multimode isn't in any released
firmware yet, so point testers at the branch to install it. As of this
writing multimode-shaping-v3 is a byte-for-byte-identical, cleaned-up-
history re-post of bleeding-edge-v2's multimode commits (verified via
`git rev-parse <branch>^{tree}` matching on both) -- if you're updating
this link because a newer branch superseded it, re-verify that claim
rather than assuming it still holds. -->
<div id="multimodeDownload" style="display:none;font-size:12.5px;color:var(--text-muted);margin:0 0 .75rem;">
<span data-i18n="shaper_tuning.tier.downloadNote">Want to try this on your own printer?</span> <a href="https://github.com/Hannott/kalico/tree/multimode-shaping-v3" target="_blank" rel="noopener noreferrer" data-i18n="shaper_tuning.tier.downloadLink">Download the multimode-shaping-v3 branch for testing</a>.
</div>
<!-- Dismissible info box for the single-shaper tiers: a single shaper (or
smoother) can only target one resonance, so the tool keeps the tallest
peak and sets the rest aside. Dismissal persists (localStorage). -->
<div class="tierinfo" id="tierInfo" style="display:none;">
<span data-i18n-html="shaper_tuning.tier.info">On the <strong>Klipper</strong> and <strong>Kalico BE</strong> tiers each shaper (or smoother) targets a single resonance, so it can't cancel several peaks at once.<br>This tool keeps your tallest peak and sets the rest aside — click any peak marker in the measured-spectrum legend below to include or exclude it.<br>To shape multiple resonances at once, use the <strong>Multimode</strong> tier.</span>
<button type="button" class="tierinfo-close" id="tierInfoClose" title="Dismiss" aria-label="Dismiss" data-i18n-attr="title:shaper_tuning.tier.dismiss">×</button>
</div>
<h2 data-i18n="shaper_tuning.steps.load">1. Load a resonance capture</h2>
<div class="databar">
<!-- Native file input is hidden; the styled button below triggers it, so
the label matches the site's buttons and is localizable (the browser's
own "Choose File" text can't be styled or translated). -->
<input type="file" id="csvFile" accept=".csv,text/csv" style="display:none">
<button class="btn" id="uploadBtn" data-i18n="shaper_tuning.databar.upload">Upload capture…</button>
<button class="btn" id="clearData" data-i18n="shaper_tuning.databar.clear">Clear</button>
<span style="flex-basis:100%;height:0;"></span>
<label for="maxAnalysisFreq" style="font-size:12.5px;color:var(--text-muted);white-space:nowrap;" data-i18n="shaper_tuning.databar.maxFreqLabel">Max frequency to analyze (Hz):</label>
<input type="number" id="maxAnalysisFreq" value="400" min="50" max="1000" step="10" style="width:5.5em;flex:0 0 auto;">
<span class="infoicon inline" tabindex="0" role="button" data-i18n-attr="title:shaper_tuning.databar.maxFreqInfo|aria-label:shaper_tuning.databar.maxFreqInfo"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="16" x2="12" y2="12"></line><line x1="12" y1="8" x2="12.01" y2="8"></line></svg></span>
</div>
<!-- Examples are de-emphasized on purpose: this page is about the user's
own data, not the bundled samples. -->
<div class="examples-hint">
<div data-i18n="shaper_tuning.databar.tryExample">No data yet? Try an example:</div>
<button type="button" class="linklike" id="loadExX" data-i18n="shaper_tuning.databar.loadExX">Example: X-axis capture</button>
<button type="button" class="linklike" id="loadExY" data-i18n="shaper_tuning.databar.loadExY">Example: Y-axis capture</button>
</div>
<!-- Every successfully parsed upload is cached (freq/x/y/z/xyz, not the
raw file) in localStorage so it can be reloaded instantly without
re-uploading -- see addToLibrary/renderLibrary. Hidden while empty. -->
<div class="library" id="libraryBox" style="display:none;">
<div class="library-label" data-i18n="shaper_tuning.library.label">Previously uploaded</div>
<div class="library-list" id="libraryList"></div>
</div>
<!-- No data-i18n here on purpose: renderDetectStatus() owns this text (it
reflects live detection state, not a static string) and re-runs on
locale change, so i18n's apply() must not overwrite it -- doing so
clobbered a session-restored capture's "detected peaks" line. -->
<div class="note" id="detectStatus">No capture loaded. Load an example, or upload a Kalico <code>calibration_data_*.csv</code> / <code>resonances_*.csv</code> (a <code>freq</code> column plus <code>psd_x</code>/<code>psd_y</code>/<code>psd_z</code>/<code>psd_xyz</code>).<br>Peak detection and half-power damping-ratio estimation run live in your browser — nothing is uploaded anywhere.</div>
<div class="cap"><span data-i18n="shaper_tuning.captions.measuredSpectrum">measured resonance spectrum</span><span class="infoicon inline" tabindex="0" role="button" data-i18n-attr="title:shaper_tuning.captions.measuredSpectrumNote|aria-label:shaper_tuning.captions.measuredSpectrumNote"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="16" x2="12" y2="12"></line><line x1="12" y1="8" x2="12.01" y2="8"></line></svg></span>
<span style="float:right;font-size:12.5px;color:var(--text-muted);"><span data-i18n="shaper_tuning.scale.label">scale:</span>
<button class="btn" id="inpLog" style="padding:2px 8px;" data-i18n="shaper_tuning.scale.log">log</button>
<button class="btn primary" id="inpLin" style="padding:2px 8px;" data-i18n="shaper_tuning.scale.linear">linear</button>
</span>
</div>
<div class="chart busybox panel" id="inputChartBox">
<canvas id="cInput" class="busy-mute"></canvas>
<div class="busy-overlay"><div class="kv-spinner"></div></div>
</div>
<!-- Info tooltip is tier-aware (the candidate list differs per tier), so
renderInfoTooltips() sets its title/aria-label in JS -- NOT data-i18n-attr
(which would clobber the tier-specific text on locale change). -->
<h2><span data-i18n="shaper_tuning.steps.recommended">2. Recommended shaper</span><span class="infoicon inline" id="recoInfo" tabindex="0" role="button"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="16" x2="12" y2="12"></line><line x1="12" y1="8" x2="12.01" y2="8"></line></svg></span></h2>
<div class="busybox recopanel" id="recoBox">
<div class="busy-mute">
<div class="note" id="recoStatus" data-i18n="shaper_tuning.recommended.statusDefault">Load a capture above to compute a recommendation.</div>
<div class="cards" id="cardsReco"></div>
<div class="note" id="noteReco"></div>
</div>
<div class="busy-overlay"><div class="kv-spinner"></div></div>
</div>
<!-- The config this recommendation produces -- kept right beside "2." (not
tucked away with the manual/advanced controls below) since it's one of
the page's primary outcomes, same as the recommendation itself. Its
manual counterpart lives further down, inside the advanced section. -->
<div class="cfgpanel primary">
<div class="cfgpanel-head">
<span class="cfgpanel-label" data-i18n="shaper_tuning.config.suggestedHeading">Suggested config</span>
<!-- Axis indicator: a plain badge when the axis is known (detected from
the filename), OR an X/Y picker when it isn't -- so an ambiguously
named capture still gets written under a definite axis instead of
dumping both. renderCfgAxisBadge() shows exactly one of these. -->
<span class="axis-badge" id="cfgAxisBadge" style="display:none;"></span>
<div class="cfgtoggle" id="cfgAxisToggle" style="display:none;">
<span class="axistoggle-label" data-i18n="shaper_tuning.config.editingLabel">Editing:</span>
<button class="btn" id="cfgAxisX" type="button">X</button>
<button class="btn" id="cfgAxisY" type="button">Y</button>
</div>
</div>
<div class="cfgoutwrap">
<pre class="cfgout" id="cfgOutSuggested"></pre>
<button class="cfgcopy" id="cfgCopySuggested" type="button" title="Copy to clipboard" aria-label="Copy to clipboard" data-i18n-attr="title:shaper_tuning.config.copyTitle|aria-label:shaper_tuning.config.copyTitle">
<svg id="cfgCopyIconSuggested" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="8" y="2" width="8" height="4" rx="1" ry="1"></rect><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"></path></svg>
</button>
</div>
<label class="cfgprefix"><input type="checkbox" id="cfgPrefixSuggested"> <span data-i18n-html="shaper_tuning.config.prefixLabel">apply save_config prefix (<code>#*#</code>)</span></label>
</div>
<!-- Manual/multimode explorer is for advanced users -- collapsed by default
(native <details>, no JS state needed) so a first-time visitor never
sees its generic placeholder numbers (mzv @ 50Hz etc.) before loading
real data; the primary flow above (graph -> recommendation -> suggested
config) never requires opening this. -->
<details class="advanced" id="advancedDetails">
<summary class="advanced-summary">
<span data-i18n="shaper_tuning.steps.explorer">3. Manual shaper explorer</span>
<span class="tag" data-i18n="shaper_tuning.steps.advancedTag">advanced</span>
<span class="infoicon inline" tabindex="0" role="button" data-i18n-attr="title:shaper_tuning.explorer.intro|aria-label:shaper_tuning.explorer.intro"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="16" x2="12" y2="12"></line><line x1="12" y1="8" x2="12.01" y2="8"></line></svg></span>
</summary>
<div class="advanced-body">
<div class="row" style="align-items:flex-end;">
<div class="ctl" style="max-width:260px;">
<div class="lbl"><span data-i18n="shaper_tuning.explorer.shaperType">shaper type</span></div>
<select id="explType">
<optgroup id="optgroupSmoothers" label="smoothers (no damping input)" data-i18n-attr="label:shaper_tuning.explorer.optgroupSmoothers">
<option value="smooth_zv">smooth_zv</option>
<option value="smooth_mzv">smooth_mzv</option>
<option value="smooth_ei">smooth_ei</option>
<option value="smooth_2hump_ei">smooth_2hump_ei</option>
<option value="smooth_zvd_ei">smooth_zvd_ei</option>
<option value="smooth_si" selected>smooth_si</option>
</optgroup>
<optgroup label="classic impulse shapers" data-i18n-attr="label:shaper_tuning.explorer.optgroupClassic">
<option value="zv">zv</option>
<option value="mzv">mzv</option>
<option value="zvd">zvd</option>
<option value="ei">ei</option>
<option value="2hump_ei">2hump_ei</option>
<option value="3hump_ei">3hump_ei</option>
</optgroup>
<optgroup id="optgroupMultimode" label="multimode (per-peak)" data-i18n-attr="label:shaper_tuning.explorer.optgroupMultimode">
<option value="multimode">multimode</option>
</optgroup>
</select>
</div>
<div class="ctl" id="explFreqWrap" style="max-width:150px;">
<div class="lbl"><span data-i18n="shaper_tuning.explorer.frequencyHz">frequency (Hz)</span></div>
<input type="number" id="explFreq" step="0.1" min="1" value="50">
</div>
<div class="ctl" id="explDampWrap" style="max-width:150px;">
<div class="lbl"><span data-i18n="shaper_tuning.explorer.dampingZeta">damping ζ</span></div>
<input type="number" id="explDamp" step="0.001" min="0.005" max="0.5" value="0.1">
</div>
</div>
<div id="explMulti" style="display:none;">
<div class="peakctl-head"><span></span><span data-i18n="shaper_tuning.explorer.baseShaperHeader">base shaper</span><span data-i18n="shaper_tuning.explorer.freqHzShort">freq (Hz)</span><span data-i18n="shaper_tuning.explorer.dampingZeta">damping ζ</span><span></span></div>
<div class="peakrows" id="peakRows"></div>
<div class="row" style="margin-bottom:.25rem;">
<button class="btn" id="addPeak" data-i18n="shaper_tuning.explorer.addPeak">+ add peak</button>
</div>
<div class="budget" id="budgetNote"></div>
</div>
<div class="row">
<div class="ctl"><div class="lbl"><span data-i18n="shaper_tuning.explorer.scv">SCV</span><span class="val" id="o-SCV">5</span></div><input type="range" id="SCV" min="1" max="20" step="0.1" value="5"></div>
<div class="ctl"><div class="lbl"><span data-i18n="shaper_tuning.explorer.acceleration">acceleration</span><span class="val editable" id="o-accl" tabindex="0" title="Click to enter an exact value" data-i18n-attr="title:shaper_tuning.explorer.accelEditHint">6000</span></div><input type="range" id="accl" min="3000" max="30000" step="100" value="6000"></div>
</div>
<div class="cards" id="cards"></div>
<div class="note" id="note"></div>
<div class="cap" style="margin-top:1rem;"><span data-i18n="shaper_tuning.captions.residualVsFreq">residual vibration vs frequency</span><span class="infoicon inline" tabindex="0" role="button" data-i18n-attr="title:shaper_tuning.captions.residualVsFreqNote|aria-label:shaper_tuning.captions.residualVsFreqNote"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="16" x2="12" y2="12"></line><line x1="12" y1="8" x2="12.01" y2="8"></line></svg></span></div>
<div class="chart"><canvas id="c1"></canvas></div>
<!-- The manual explorer's own config output -- independent of the suggested
panel above; reflects whatever this explorer is currently set to. No
axis picker of its own (axis assignment happens once, in the suggested
panel above). -->
<div class="cfgpanel">
<div class="cfgpanel-head">
<span class="cfgpanel-label" data-i18n="shaper_tuning.config.manualHeading">Manual config</span>
</div>
<div class="cfgoutwrap">
<pre class="cfgout" id="cfgOutManual"></pre>
<button class="cfgcopy" id="cfgCopyManual" type="button" title="Copy to clipboard" aria-label="Copy to clipboard" data-i18n-attr="title:shaper_tuning.config.copyTitle|aria-label:shaper_tuning.config.copyTitle">
<svg id="cfgCopyIconManual" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="8" y="2" width="8" height="4" rx="1" ry="1"></rect><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"></path></svg>
</button>
</div>
<label class="cfgprefix"><input type="checkbox" id="cfgPrefixManual"> <span data-i18n-html="shaper_tuning.config.prefixLabel">apply save_config prefix (<code>#*#</code>)</span></label>
</div>
<div id="deepSection" style="display:none;">
<div class="bandhdr"><span class="tag" data-i18n="shaper_tuning.deep.tag">Deeper analysis — multimode extruder synchronization</span></div>
<h2><span data-i18n="shaper_tuning.steps.deepAnalysis">Deeper analysis</span><span class="infoicon inline" tabindex="0" role="button" data-i18n-attr="title:shaper_tuning.deep.intro|aria-label:shaper_tuning.deep.intro"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="16" x2="12" y2="12"></line><line x1="12" y1="8" x2="12.01" y2="8"></line></svg></span></h2>
<div id="deepBody">
<div class="cap"><span data-i18n="shaper_tuning.captions.notchDepth">notch depth vs a uniformly-assumed damping ratio</span><span class="infoicon inline" tabindex="0" role="button" data-i18n-attr="title:shaper_tuning.captions.notchDepthNote|aria-label:shaper_tuning.captions.notchDepthNote"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="16" x2="12" y2="12"></line><line x1="12" y1="8" x2="12.01" y2="8"></line></svg></span></div>
<div class="chart" style="height:270px;"><canvas id="c2"></canvas></div>
<div class="cap" style="margin-top:1.5rem;"><span data-i18n="shaper_tuning.captions.extruderKernel">extruder synchronization kernel</span><span class="infoicon inline" tabindex="0" role="button" data-i18n-attr="title:shaper_tuning.captions.extruderKernelNote|aria-label:shaper_tuning.captions.extruderKernelNote"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="16" x2="12" y2="12"></line><line x1="12" y1="8" x2="12.01" y2="8"></line></svg></span></div>
<div class="chart" style="height:230px;"><canvas id="c3"></canvas></div>
<div class="cap"><span data-i18n="shaper_tuning.captions.cornerAccel">extruder acceleration demand at a sharp corner</span><span class="infoicon inline" tabindex="0" role="button" data-i18n-attr="title:shaper_tuning.captions.cornerAccelNote|aria-label:shaper_tuning.captions.cornerAccelNote"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="16" x2="12" y2="12"></line><line x1="12" y1="8" x2="12.01" y2="8"></line></svg></span></div>
<div class="chart" style="height:230px;"><canvas id="c4"></canvas></div>
<div class="cards" id="cards2"></div>
<div class="note" id="note2"></div>
</div>
</div>
</div>
</details>
<p class="foot" data-i18n-html="shaper_tuning.footer">Reproduces Kalico's <code>find_best_shaper</code> shaper calibration and DSP entirely in your browser — your capture is never uploaded. Rendered with Chart.js. Source on <a href="https://github.com/Hannott/KlipperVisuals">GitHub</a>.</p>
<!-- Legal/credits link: its own line at the very bottom of every page. -->
<p class="foot-legal"><a href="disclaimer.html" data-i18n="common.disclaimer">Disclaimer & credits</a></p>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.1/chart.umd.js"></script>
<script>
/* =====================================================================
PORTING MAP & PROVENANCE (read this first if you're an AI editing this file)
This page reproduces Kalico/Klipper firmware DSP in browser JS. The
ground-truth source it mirrors lives in a SEPARATE repo, not here:
- Fork (what this tool actually implements):
Hannott/kalico @ branch bleeding-edge-v2 -> klippy/extras/shaper_calibrate.py
klippy/extras/shaper_defs.py
klippy/extras/extruder_smoother.py
Locally that's the `kalico-be` repo next to this one.
- Vanilla references (for comparison only; NOT implemented here):
Klipper3d/klipper @ master, KalicoCrew/kalico.
To verify behavior, run the fork's real .py against a capture with a
Python harness (stub `klippy`/`klippy.extras` via types.ModuleType,
load the .py with importlib.util.spec_from_file_location, build the
helper with ShaperCalibrate.__new__ and manually set .numpy/.error/
.printer=None/._smoother_integrals_cache={}/._vibr_threshold_cache=None,
and pass scv=5.0 -- find_best_shaper crashes on scv=None).
Function -> firmware source (grep the .py by NAME, not line number --
line numbers drift and the old comments' :NNN-NNN refs were already stale):
fitSingleMode <- fit_shaper
fitMultiMode <- fit_multimode_shaper
findBestShaperReal <- find_best_shaper
estimateShaperReal <- estimate_shaper (accurate time-domain)
estimateSmootherReal <- estimate_smoother
estimateRemainingVibrations <- _estimate_remaining_vibrations + _calc_vibr_threshold
shaperSmoothing <- _get_shaper_smoothing
getSmootherSmoothing <- _get_smoother_smoothing / _calc_smoother_integrals
detectPeaks/estimateDampingRatio <- _detect_resonance_peaks / _estimate_damping_ratio
zv/mzv/zvd/ei/h2/h3, convolve, buildMultiMode <- shaper_defs.get_*_shaper / convolve_shapers / get_multimode_shaper
makeSmoother, multiModeExtruderSmoother <- extruder_smoother.get_multi_mode_extruder_smoother
SCORING NOTE -- fork scoring now matches mainline, with one extension:
The fork's "Revert to mainline vibration scoring" commit dropped the old
f^2 bin weighting (it let high-frequency floor noise outvote the actual
resonances) and restored vanilla Klipper's flat psd.max()/20 threshold,
unweighted sums, score formula, selection tolerances, upgrade walk and
ZV demotion. The ONE remaining fork extension: the flat threshold is
lowered near each *detected* resonance peak (calcVibrThreshold), so
multimode candidates keep getting credit for shaping secondary peaks.
Single-peak spectra score identically to mainline. The tier tabs still
ONLY filter the candidate list (one engine); the Klipper tab is now
close to real Klipper but not identical (fork sweeps to 200 Hz vs
mainline's MAX_SHAPER_FREQ=150, uses the newer windowed estimator, and
has the peak-aware threshold).
i18n: every user-facing string lives in a per-language shaper_tuning.js
under locales/ (en is the fallback; more languages get added over time).
Any string add/change MUST update EVERY locale dir present under locales/ --
enumerate the dirs, don't assume a fixed set.
===================================================================== */
(function(){
var t=window.KV_I18N.t;
var css=getComputedStyle(document.documentElement);
function gv(n,f){return (css.getPropertyValue(n).trim())||f;}
var muted=gv('--text-muted','#898781'), grid=gv('--grid','#e1e0d9');
var BLUE=gv('--blue','#2a78d6'), AMBER=gv('--amber','#c98500'), TEAL=gv('--teal','#1d9e75');
var PURPLE=gv('--purple','#8e5cd9'), RED=gv('--red','#d64545'), CYAN=gv('--cyan','#3d8fd1');
var PALETTE=[BLUE,AMBER,TEAL,PURPLE,RED,CYAN];
// Re-read the shared palette (nav.js) and repaint every chart/card that
// bakes a color in as a JS hex string rather than a live CSS var(). Wired
// to nav.js's "kv-palette-change" event below, near the other chart wiring.
function refreshPaletteColors(){
css=getComputedStyle(document.documentElement);
muted=gv('--text-muted','#898781'); grid=gv('--grid','#e1e0d9');
BLUE=gv('--blue','#2a78d6'); AMBER=gv('--amber','#c98500'); TEAL=gv('--teal','#1d9e75');
PURPLE=gv('--purple','#8e5cd9'); RED=gv('--red','#d64545'); CYAN=gv('--cyan','#3d8fd1');
PALETTE=[BLUE,AMBER,TEAL,PURPLE,RED,CYAN];
}
// ---- base shaper definitions (shaper_defs.get_*_shaper) ----
function zv(f,z){var df=Math.sqrt(1-z*z),K=Math.exp(-z*Math.PI/df),td=1/(f*df);return{A:[1,K],T:[0,0.5*td]};}
function zvd(f,z){var df=Math.sqrt(1-z*z),K=Math.exp(-z*Math.PI/df),td=1/(f*df);return{A:[1,2*K,K*K],T:[0,0.5*td,td]};}
function mzv(f,z){var df=Math.sqrt(1-z*z),K=Math.exp(-0.75*z*Math.PI/df),td=1/(f*df);
var a1=1-1/Math.SQRT2,a2=(Math.SQRT2-1)*K,a3=a1*K*K;return{A:[a1,a2,a3],T:[0,0.375*td,0.75*td]};}
function ei(f,z){var vt=1/20,df=Math.sqrt(1-z*z),td=1/(f*df);
var a1=(0.24968+0.24961*vt)+((0.80008+1.23328*vt)+(0.49599+3.17316*vt)*z)*z;
var a3=(0.25149+0.21474*vt)+((-0.83249+1.41498*vt)+(0.85181-4.90094*vt)*z)*z;
var a2=1-a1-a3;var t2=0.4999+(((0.46159+8.57843*vt)*vt)+(((4.26169-108.644*vt)*vt)+((1.75601+336.989*vt)*vt)*z)*z)*z;
return{A:[a1,a2,a3],T:[0,t2*td,td]};}
function expShaper(f,z,t,a){var tau=1/f,T=[],A=[],n=a.length,k=a[0].length;
for(var i=0;i<n;i++){var u=t[i][k-1],vv=a[i][k-1];
for(var j=0;j<k-1;j++){u=u*z+t[i][k-2-j];vv=vv*z+a[i][k-2-j];}
T.push(u*tau);A.push(vv);}return{A:A,T:T};}
var T2=[[0,0,0,0],[0.49890,0.16270,-0.54262,6.16180],[0.99748,0.18382,-1.58270,8.17120],[1.49920,-0.09297,-0.28338,1.85710]];
var A2=[[0.16054,0.76699,2.26560,-1.22750],[0.33911,0.45081,-2.58080,1.73650],[0.34089,-0.61533,-0.68765,0.42261],[0.15997,-0.60246,1.00280,-0.93145]];
var T3=[[0,0,0,0],[0.49974,0.23834,0.44559,12.4720],[0.99849,0.29808,-2.36460,23.3990],[1.49870,0.10306,-2.01390,17.0320],[1.99960,-0.28231,0.61536,5.40450]];
var A3=[[0.11275,0.76632,3.29160,-1.44380],[0.23698,0.61164,-2.57850,4.85220],[0.30008,-0.19062,-2.14560,0.13744],[0.23775,-0.73297,0.46885,-2.08650],[0.11244,-0.45439,0.96382,-1.46000]];
function h2(f,z){return expShaper(f,z,T2,A2);}
function h3(f,z){return expShaper(f,z,T3,A3);}
// All six are valid single-mode shaper_type_<axis> values (shaper_defs.INPUT_SHAPERS);
// only zv/mzv/ei are offered as multimode BASES below (see MULTIMODE_AUTOTUNE_BASES).
var BASEFN={zv:zv,mzv:mzv,zvd:zvd,ei:ei,'2hump_ei':h2,'3hump_ei':h3};
var PULSE_COUNT={zv:2,mzv:3,zvd:3,ei:3,'2hump_ei':4,'3hump_ei':5};
var MAX_SHAPER_PULSES=32;
var BASE_LABEL={zv:'ZV',mzv:'MZV',zvd:'ZVD',ei:'EI','2hump_ei':'2HUMP_EI','3hump_ei':'3HUMP_EI'};
function convolve(s1,s2){var A=[],T=[];
for(var i=0;i<s1.A.length;i++)for(var j=0;j<s2.A.length;j++){A.push(s1.A[i]*s2.A[j]);T.push(s1.T[i]+s2.T[j]);}
// merge coincident impulse times and renormalize/rebase, matching shaper_defs.convolve_shapers
var pairs=[]; for(i=0;i<A.length;i++)pairs.push([T[i],A[i]]); pairs.sort(function(a,b){return a[0]-b[0];});
var A2=[],T2=[]; for(i=0;i<pairs.length;i++){ if(T2.length && pairs[i][0]-T2[T2.length-1]<1e-9){A2[A2.length-1]+=pairs[i][1];} else {A2.push(pairs[i][1]);T2.push(pairs[i][0]);} }
var sA=0; for(i=0;i<A2.length;i++)sA+=A2[i]; var invD=1/sA; for(i=0;i<A2.length;i++)A2[i]*=invD;
var t0=T2[0]; for(i=0;i<T2.length;i++)T2[i]-=t0;
return{A:A2,T:T2};}
function buildMultiMode(peaksArr){
var sh=null;
for(var i=0;i<peaksArr.length;i++){
var comp=BASEFN[peaksArr[i].base](peaksArr[i].freq,peaksArr[i].damping);
sh = sh? convolve(sh,comp): comp;
}
return sh;
}
function pulseCount(peaksArr){var p=1;for(var i=0;i<peaksArr.length;i++)p*=PULSE_COUNT[peaksArr[i].base];return p;}
function shaperLabel(peaksArr){var bases=peaksArr.map(function(p){return p.base;});
var allSame=bases.every(function(b){return b===bases[0];});
return allSame?bases[0]:bases.join('/');}
function residual(sh,f,z){var A=sh.A,T=sh.T,n=A.length,sA=0,Tn=0,i;
for(i=0;i<n;i++){sA+=A[i];if(T[i]>Tn)Tn=T[i];}
var w=2*Math.PI*f,wd=w*Math.sqrt(1-z*z),S=0,C=0;
for(i=0;i<n;i++){var Wk=A[i]*Math.exp(-z*w*(Tn-T[i]));S+=Wk*Math.sin(wd*T[i]);C+=Wk*Math.cos(wd*T[i]);}
return Math.sqrt(S*S+C*C)/sA;}
function dur(sh){var m=0;for(var i=0;i<sh.T.length;i++)if(sh.T[i]>m)m=sh.T[i];return m;}
// Corner "smoothing" offset (mm) — ports shaper_calibrate._get_shaper_smoothing.
function shaperOffset(sh){var A=sh.A,T=sh.T,sA=0,sAT=0;
for(var i=0;i<A.length;i++){sA+=A[i];sAT+=A[i]*T[i];}return sA?sAT/sA:0;}
function shaperSmoothing(sh,accel,scv){
accel=accel||5000; scv=(scv==null?5.0:scv);
var A=sh.A,T=sh.T,n=A.length,sA=0,i;
for(i=0;i<n;i++)sA+=A[i];
var invD=1/sA, ha=accel*0.5, ts=shaperOffset(sh), o90x=0,o90y=0,o180=0;
for(i=0;i<n;i++){var dt=T[i]-ts;
if(T[i]>=ts)o90x+=A[i]*(scv+ha*dt)*dt;
else o90y+=A[i]*(scv-ha*dt)*dt;
o180+=A[i]*ha*dt*dt;}
var o90=invD*Math.sqrt(o90x*o90x+o90y*o90y);
return Math.max(o90,Math.abs(invD*o180));}
// ---- fitted extruder-smoother counterpart (extruder_smoother.get_multi_mode_extruder_smoother) ----
function legendreCoeffTable(maxDeg){
var legC=[[1],[0,1]];
for(var k=2;k<=maxDeg;k++){
var prev1=legC[k-1],prev2=legC[k-2];
var xTimesPrev1=[0].concat(prev1);
var len=Math.max(xTimesPrev1.length,prev2.length),newP=[];
for(var i=0;i<len;i++){var a=(2*k-1)*(xTimesPrev1[i]||0),b=(k-1)*(prev2[i]||0);newP.push((a-b)/k);}
legC.push(newP);
}
return legC;
}
function legendreVander(xs,degMax){
var n=degMax+1,P=[];
for(var i=0;i<xs.length;i++){
var row=new Array(n); row[0]=1; if(n>1)row[1]=xs[i];
for(var k=2;k<n;k++)row[k]=((2*k-1)*xs[i]*row[k-1]-(k-1)*row[k-2])/k;
P.push(row);
}
return P;
}
function leg2poly(a,legC){
var n=a.length,out=new Array(n).fill(0);
for(var k=0;k<n;k++){var pk=legC[k];for(var i=0;i<pk.length;i++)out[i]+=a[k]*pk[i];}
return out;
}
function solveLinear(A,b){
var n=b.length,M=A.map(function(row,i){return row.slice().concat([b[i]]);});
for(var col=0;col<n;col++){
var piv=col;
for(var r=col+1;r<n;r++)if(Math.abs(M[r][col])>Math.abs(M[piv][col]))piv=r;
var tmp=M[col];M[col]=M[piv];M[piv]=tmp;
var pv=M[col][col];
for(var c=col;c<=n;c++)M[col][c]/=pv;
for(r=0;r<n;r++){if(r===col)continue;var factor=M[r][col];if(!factor)continue;for(c=col;c<=n;c++)M[r][c]-=factor*M[col][c];}
}
return M.map(function(row){return row[n];});
}
function stepVelZeta(t,omega,z){if(t<0)t=0;var df=Math.sqrt(1-z*z);return Math.exp(-z*omega*t)*Math.sin(omega*df*t)*(omega/df);}
function estimateShaperVel(sh,testZ,testFreqs){
var A=sh.A,T=sh.T,n=A.length,invD=1/A.reduce(function(a,b){return a+b;},0);
var t_s=T[T.length-1]-T[0],hst=t_s*0.5,n_t=1000;
var time=new Array(n_t);
for(var i=0;i<n_t;i++)time[i]=-hst+(2*hst)*i/(n_t-1);
var omegas=testFreqs.filter(function(f){return f>0;}).map(function(f){return 2*Math.PI*f;});
var velocity=omegas.map(function(){return new Array(n_t).fill(0);});
for(var oi=0;oi<omegas.length;oi++)for(var ti=0;ti<n_t;ti++){
var v=0;for(var k=0;k<n;k++)v+=A[k]*stepVelZeta(time[ti]-T[k]+hst,omegas[oi],testZ);
velocity[oi][ti]=v*invD;
}
return {t:time,velocities:velocity};
}
var EXTRUDER_ORDER={zv:5,mzv:7,zvd:7,ei:7,'2hump_ei':9,'3hump_ei':10};
var EXTRUDER_RANGE={zv:[0.98,1.02,5],mzv:[0.95,1.05,11],zvd:[0.93,1.06,14],ei:[0.83,0.89,7],'2hump_ei':[0.65,0.75,11],'3hump_ei':[0.54,0.66,13]};
function calcExtruderSmoother(t,velocityRows,n,t_sm){
var n_t=t.length;
var x=t.map(function(tt){return 2*tt/t_sm;});
var weight=x.map(function(xx){return Math.max(1-xx*xx,0);});
var dt=t[1]-t[0],m=velocityRows.length,target=new Array(n_t).fill(0);
for(var r=0;r<m;r++){
var row=velocityRows[r],sumRow=row.reduce(function(a,b){return a+b;},0),scale=t_sm/(sumRow*dt);
for(var i=0;i<n_t;i++)target[i]+=row[i]*scale;
}
for(i=0;i<n_t;i++)target[i]/=m;
var P=legendreVander(x,n-1);
function buildG(extraPenalty){
var G=[];
for(var a=0;a<n;a++){var row=new Array(n).fill(0);
for(var b=0;b<n;b++){var s=0;for(var i2=0;i2<n_t;i2++)s+=P[i2][a]*(weight[i2]+(extraPenalty?extraPenalty[i2]:0))*P[i2][b];row[b]=s;}
G.push(row);}
return G;
}
var g=new Array(n).fill(0);
for(var a=0;a<n;a++){var s=0;for(var i3=0;i3<n_t;i3++)s+=P[i3][a]*weight[i3]*target[i3];g[a]=s;}
var k=[];for(var kk=0;kk<n;kk++)k.push(kk);
var sign=k.map(function(kk){return Math.pow(-1,kk);}),ones=k.map(function(){return 1;}),e0=k.map(function(kk){return kk===0?1:0;});
var constraints=[e0,sign,ones],rhs=[1,0,0],n_c=3;
var penalty=new Array(n_t).fill(0),a_sol=null;
for(var iter=0;iter<20;iter++){
var G=buildG(penalty),size=n+n_c,K=[];
for(var r2=0;r2<size;r2++)K.push(new Array(size).fill(0));
for(r2=0;r2<n;r2++)for(var c2=0;c2<n;c2++)K[r2][c2]=G[r2][c2];
for(r2=0;r2<n_c;r2++){for(c2=0;c2<n;c2++){K[n+r2][c2]=constraints[r2][c2];K[c2][n+r2]=constraints[r2][c2];}}
var f=g.concat(rhs),sol=solveLinear(K,f);
a_sol=sol.slice(0,n);
var h=P.map(function(row){return row.reduce(function(s2,p,idx){return s2+p*a_sol[idx];},0);});
var hmin=Math.min.apply(null,h);
if(hmin>-2e-3)break;
for(i=0;i<n_t;i++)if(h[i]<0)penalty[i]+=10;
}
var legC=legendreCoeffTable(n-1),Cpoly=leg2poly(a_sol,legC);
return Cpoly.map(function(c,i){return c*Math.pow(2,i);});
}
function multiModeExtruderSmoother(peaksArr){
var ratios=peaksArr.map(function(p){return p.freq/peaksArr[0].freq;});
var shp=null;
for(var i=0;i<peaksArr.length;i++){
var comp=BASEFN[peaksArr[i].base](ratios[i],peaksArr[i].damping);
shp = shp? convolve(shp,comp): comp;
}
var n=Math.max.apply(null,peaksArr.map(function(p){return EXTRUDER_ORDER[p.base];}));
var t=null, velRows=[];
for(i=0;i<peaksArr.length;i++){
var rg=EXTRUDER_RANGE[peaksArr[i].base];
var testFreqs=[]; for(var k=0;k<rg[2];k++)testFreqs.push(ratios[i]*(rg[0]+(rg[1]-rg[0])*k/(rg[2]-1)));
var est=estimateShaperVel(shp,peaksArr[i].damping,testFreqs);
t=est.t; velRows=velRows.concat(est.velocities);
}
var t_sm_unit=shp.T[shp.T.length-1]-shp.T[0];
var C=calcExtruderSmoother(t,velRows,n,t_sm_unit);
return {C:C, t_sm_unit:t_sm_unit};
}
function evalPoly(c,tau){var w=0;for(var i=0;i<c.length;i++)w+=c[i]*Math.pow(tau,i);return w;}
function cornerAccelDemand(sh,C,t_sm){
var V1=100,V2=20,DT=5e-5,PAD=Math.max(0.06,t_sm*1.5);
var N=Math.round(2*PAD/DT), t=new Array(N), posN=new Array(N), v=0, t0=-PAD;
for(var i=0;i<N;i++){t[i]=t0+i*DT; var vel=(t[i]<0)?V1:V2; v+=vel*DT; posN[i]=v;}
var mid=Math.round(PAD/DT), base=posN[mid]; for(i=0;i<N;i++)posN[i]-=base;
function interpAt(tt){
var idx=(tt-t0)/DT;
if(idx<=0)return posN[0]; if(idx>=N-1)return posN[N-1];
var i0=Math.floor(idx),frac=idx-i0;
return posN[i0]*(1-frac)+posN[i0+1]*frac;
}
var A=sh.A,T=sh.T,tOffs=0;for(i=0;i<A.length;i++)tOffs+=A[i]*T[i];
var posExact=new Array(N);
for(i=0;i<N;i++){var s=0;for(var k=0;k<A.length;k++){var shift=T[k]-tOffs;s+=A[k]*interpAt(t[i]-shift);}posExact[i]=s;}
var ns=150,ds=t_sm/ns,ws=[],ss=[];
for(var j=0;j<=ns;j++){var sVal=-t_sm/2+j*ds; var tau=sVal/t_sm; ws.push(evalPoly(C,tau)/t_sm); ss.push(sVal);}
var posSmooth=new Array(N);
for(i=0;i<N;i++){var acc=0;for(j=0;j<=ns;j++)acc+=ws[j]*interpAt(t[i]-ss[j])*ds; posSmooth[i]=acc;}
function accFromPos(pos){
var vel=new Array(N),acc=new Array(N);
for(var i2=1;i2<N-1;i2++)vel[i2]=(pos[i2+1]-pos[i2-1])/(2*DT);
vel[0]=vel[1];vel[N-1]=vel[N-2];
for(i2=1;i2<N-1;i2++)acc[i2]=(vel[i2+1]-vel[i2-1])/(2*DT);
acc[0]=acc[1];acc[N-1]=acc[N-2];
return acc;
}
var accExact=accFromPos(posExact), accSmooth=accFromPos(posSmooth);
var trim=Math.round(0.002/DT);
var peakExact=0,peakSmooth=0;
var stepMs=Math.max(1,Math.floor(N/400));
var seriesExact=[],seriesSmooth=[];
for(i=trim;i<N-trim;i++){
if(Math.abs(accExact[i])>peakExact)peakExact=Math.abs(accExact[i]);
if(Math.abs(accSmooth[i])>peakSmooth)peakSmooth=Math.abs(accSmooth[i]);
if((i-trim)%stepMs===0){seriesExact.push({x:t[i]*1000,y:accExact[i]});seriesSmooth.push({x:t[i]*1000,y:accSmooth[i]});}
}
return {peakExact:peakExact,peakSmooth:peakSmooth,seriesExact:seriesExact,seriesSmooth:seriesSmooth};
}
// ---- resonance-CSV peak detection (shaper_calibrate._detect_resonance_peaks / _estimate_damping_ratio) ----
function detectPeaks(freqArr,psdArr,minFreq,maxFreq,minProminence,minSeparation,maxPeaks){
var freqs=[],vals=[];
for(var i=0;i<freqArr.length;i++)if(freqArr[i]>=minFreq&&freqArr[i]<=maxFreq){freqs.push(freqArr[i]);vals.push(psdArr[i]);}
if(freqs.length<3)return [];
var idx=[];
for(i=1;i<vals.length-1;i++)if(vals[i]>vals[i-1]&&vals[i]>vals[i+1])idx.push(i);
if(!idx.length)return [];
var order=idx.slice().sort(function(a,b){return vals[b]-vals[a];});
var topVal=vals[order[0]], selected=[];
for(i=0;i<order.length;i++){
var v=vals[order[i]];
if(v<minProminence*topVal)break;
var f=freqs[order[i]];
if(selected.some(function(sf){return Math.abs(f-sf)<minSeparation;}))continue;
selected.push(f);
if(selected.length>=maxPeaks)break;
}
return selected;
}
function estimateDampingRatio(freqArr,psdArr,f0,maxSpanLo,maxSpanHi){
if(f0<=0||freqArr.length<3)return null;
var i0=0,bd=Math.abs(freqArr[0]-f0);
for(var i=1;i<freqArr.length;i++){var d=Math.abs(freqArr[i]-f0);if(d<bd){bd=d;i0=i;}}
var p0=psdArr[i0];
if(p0<=0)return null;
var half=0.5*p0;
var defaultSpan=Math.max(15,0.3*f0);
var spanLo=(maxSpanLo!=null)?maxSpanLo:defaultSpan;
var spanHi=(maxSpanHi!=null)?maxSpanHi:defaultSpan;
var n=freqArr.length;
function findCrossing(step,span){
var idx=i0;
while(idx+step>=0 && idx+step<n && psdArr[idx+step]>half && Math.abs(freqArr[idx+step]-f0)<=span)idx+=step;
var j=idx+step;
if(j<0||j>=n||Math.abs(freqArr[j]-f0)>span)return null;
var fI=freqArr[idx],pI=psdArr[idx],fJ=freqArr[j],pJ=psdArr[j];
if(pI===pJ)return fJ;
var t=(half-pI)/(pJ-pI);
return fI+t*(fJ-fI);
}
var fLo=findCrossing(-1,spanLo), fHi=findCrossing(1,spanHi);
if(fLo==null||fHi==null||fHi<=fLo)return null;
var zeta=(fHi-fLo)/(2*f0);
if(zeta<0.005||zeta>0.5)return null;
return zeta;
}
function detectAndEstimate(freqArr,psdArr,maxPeaks,maxFreqIn){
var maxFreq=maxFreqIn||MAX_ANALYSIS_FREQ_DEFAULT;
var found=detectPeaks(freqArr,psdArr,5.0,maxFreq,0.12,8.0,maxPeaks||4);
var withDamp=found.map(function(f,i){
var lower=found.filter(function(pf,j){return j!==i&&pf<f;});
var upper=found.filter(function(pf,j){return j!==i&&pf>f;});
var spanLo=lower.length?Math.max(5,(f-Math.max.apply(null,lower))*0.4):null;
var spanHi=upper.length?Math.max(5,(Math.min.apply(null,upper)-f)*0.4):null;
var dr=estimateDampingRatio(freqArr,psdArr,f,spanLo,spanHi);
return {freq:f,damping:(dr!=null?dr:0.1)};
});
withDamp.sort(function(a,b){return a.freq-b.freq;});
return withDamp;
}
// The effective excluded set = the user's manual exclusions PLUS the
// tier-scoped auto-exclusions (all-but-tallest, applied only on the
// single-shaper Klipper/Kalico BE tiers -- see recomputeAutoExclude). Both
// are masked out of everything the recommendation analyzes.
function effectiveExcluded(){return excludedPeaks.concat(autoExcluded);}
function isManualExcluded(f){return excludedPeaks.some(function(pf){return Math.abs(pf-f)<3.0;});}
// A detected peak is "excluded" if a marker within a few Hz of it is in the
// effective set (detection can shift slightly between recomputes).
function isPeakExcluded(f){
return effectiveExcluded().some(function(pf){return Math.abs(pf-f)<3.0;});
}
// Returns a copy of psdArr with the band around each excluded peak zeroed,
// so "deselect a peak" means "analyze as if it weren't there": peak
// detection, the single-shaper score, and the multimode search all run on
// this masked spectrum.
//
// Zeroing only to the surrounding valleys is NOT enough: a broad
// resonance's tail often rises again slightly past its first valley (a
// shoulder), and the leftover stump then reads as a fresh local maximum --
// prominent enough for _detect_resonance_peaks to flag it as a resonance
// of its own, which the (peak-aware) scoring then demands be shaped. So
// after the valley walk, keep extending outward through everything that
// is clearly this peak's tail or trailing floor (below 20% of the excluded
// peak's own height), stopping at the midpoint toward the nearest ACTIVE
// detected peak on that side so a kept neighbour's flank is never eaten.
function maskExcludedPeaks(freqArr,psdArr){
var ex=effectiveExcluded();
if(!ex.length)return psdArr.slice();
var out=psdArr.slice(),n=psdArr.length;
var activePeaks=detectPeaks(freqArr,psdArr,MIN_FREQ,freqArr[n-1],0.12,8.0,MULTIMODE_MAX_PEAKS)
.filter(function(f){return !ex.some(function(pf){return Math.abs(pf-f)<3.0;});});
ex.forEach(function(pf){
var i0=0,bd=Infinity;
for(var i=0;i<n;i++){var d=Math.abs(freqArr[i]-pf);if(d<bd){bd=d;i0=i;}}
// Only mask if there's actually a local peak here (guards stale entries).
if(!(psdArr[i0]>0))return;
// Walk out to the surrounding valleys, then widen to at least +-8 Hz
// (the peak-detector's own min separation).
var lo=i0;while(lo>0&&psdArr[lo-1]<psdArr[lo])lo--;
var hi=i0;while(hi<n-1&&psdArr[hi+1]<psdArr[hi])hi++;
while(lo>0&&freqArr[i0]-freqArr[lo-1]<=8.0)lo--;
while(hi<n-1&&freqArr[hi+1]-freqArr[i0]<=8.0)hi++;
// Extend through the peak's low tails (see comment above). Bounds:
// halfway to the nearest active peak on each side, or the spectrum edge.
var tail=0.2*psdArr[i0];
var actLo=activePeaks.filter(function(f){return f<freqArr[i0];});
var actHi=activePeaks.filter(function(f){return f>freqArr[i0];});
var boundLo=actLo.length?(Math.max.apply(null,actLo)+freqArr[i0])/2:-Infinity;
var boundHi=actHi.length?(Math.min.apply(null,actHi)+freqArr[i0])/2:Infinity;
while(lo>0&&psdArr[lo-1]<tail&&freqArr[lo-1]>boundLo)lo--;
while(hi<n-1&&psdArr[hi+1]<tail&&freqArr[hi+1]<boundHi)hi++;
for(var j=lo;j<=hi;j++)out[j]=0;
});
return out;
}
// Tier-scoped auto-exclusion. On the single-shaper tiers (Klipper / Kalico
// BE) a shaper can only target one resonance, so we keep the tallest
// detected peak and set the rest aside by default; the user can still click
// any marker to override. On Multimode this clears (every peak stays in
// play). Manual exclusions are never touched here. Recomputed on load, axis
// change, and tier change -- NOT persisted (it's derived from tier + data).
function recomputeAutoExclude(){
autoExcluded=[];
if(!loadedCapture||tierAllowsMultimode())return;
var freqArr=loadedCapture.freq,psdArr=loadedCapture.psd;
var detected=detectAndEstimate(freqArr,psdArr,4,captureMaxFreq(freqArr))
.filter(function(p){return !isManualExcluded(p.freq);});
if(detected.length<2)return; // 0-1 active peaks: nothing to set aside
var tallest=null,bestV=-1;
detected.forEach(function(p){
var i0=0,bd=Infinity;
for(var i=0;i<freqArr.length;i++){var d=Math.abs(freqArr[i]-p.freq);if(d<bd){bd=d;i0=i;}}
if(psdArr[i0]>bestV){bestV=psdArr[i0];tallest=p.freq;}
});
detected.forEach(function(p){if(Math.abs(p.freq-tallest)>=3.0)autoExcluded.push(p.freq);});
}
// ================= real find_best_shaper recommendation =================
// Everything below ports klippy/extras/shaper_calibrate.py's find_best_shaper
// as faithfully as is practical client-side: the same candidate shapers and
// smoothers, the same scoring formula, the same single-mode/multimode
// selection thresholds, and the same dual-peak-cluster-widening fix
// (_find_peak_cluster_bounds) the auto-tuner uses server-side. It reuses
// zv/mzv/zvd/ei/h2/BASEFN/convolve/detectPeaks/estimateDampingRatio/
// shaperOffset/shaperSmoothing already defined above.
var SHAPER_MIN_FREQ={zv:21.0,mzv:23.0,zvd:29.0,ei:29.0,'2hump_ei':39.0};
// Bases the multimode auto-search considers, and the peak cap -- both
// match shaper_calibrate.py exactly (MULTIMODE_AUTOTUNE_BASES /
// MULTIMODE_MAX_PEAKS). zvd/2hump_ei/3hump_ei are excluded as bases
// because convolving copies of a high-impulse base smooths so much it can
// never win; the search caps at 4 detected peaks (manual configs may list
// more).
var MULTIMODE_AUTOTUNE_BASES=['zv','mzv','ei'];
var MULTIMODE_MAX_PEAKS=4;
// Firmware tier: gates which shaper types are offered/searched, since
// smooth_* smoothers and multimode aren't on every firmware build. The
// calculations never change per tier -- only which candidates are allowed.
var FIRMWARE_TIER_KEY='kv-shaper-tuning-tier';
var firmwareTier=(function(){
try{var v=localStorage.getItem(FIRMWARE_TIER_KEY);}catch(e){}
return(v==='kalicobe'||v==='multimode')?v:'klipper';
})();
function tierAllowsSmoothers(){return firmwareTier==='kalicobe'||firmwareTier==='multimode';}
function tierAllowsMultimode(){return firmwareTier==='multimode';}
var AUTOTUNE_SHAPER_NAMES=['mzv','ei','2hump_ei'];
var TEST_DAMPING_RATIOS=[0.075,0.1,0.15];
var DEFAULT_DAMPING_RATIO=0.1;
var MIN_FREQ=5.0;
// User-configurable analysis ceiling: peak detection, the measured-spectrum
// chart, and the recommendation search (fitSingleMode caps its sweep at
// min(MAX_SHAPER_FREQ, maxFreq)) all stop here. 400 Hz default is
// comfortably past a normal ADXL345-class capture's usual ~200 Hz range,
// but a high-bandwidth accelerometer (Beacon and similar sample well past
// 1 kHz) can carry a genuine, comparably-sized resonance further out (seen
// in practice: a real capture with peaks at both ~190 Hz and ~246 Hz) --
// hence the input below the upload button rather than a fixed constant.
// Persisted like firmwareTier (a standing tool preference, not tied to any
// one capture). Clamped to [50,1000]: the fitSingleMode sweep steps every
// 0.2 Hz, so an unreasonably high ceiling makes that sweep (and the
// browser tab) crawl for no benefit -- no real shaper design frequency
// lives anywhere near 1 kHz.
var MAX_FREQ_KEY='kv-shaper-tuning-maxfreq';
var MAX_ANALYSIS_FREQ_MIN=50.0,MAX_ANALYSIS_FREQ_MAX=1000.0,MAX_ANALYSIS_FREQ_DEFAULT=400.0;
function clampMaxAnalysisFreq(v){return isFinite(v)?Math.min(MAX_ANALYSIS_FREQ_MAX,Math.max(MAX_ANALYSIS_FREQ_MIN,v)):MAX_ANALYSIS_FREQ_DEFAULT;}
var MAX_SHAPER_FREQ=(function(){
var v;try{v=parseFloat(localStorage.getItem(MAX_FREQ_KEY));}catch(e){}
return clampMaxAnalysisFreq(v);
})();
// Ceiling for a given capture: the user's configured max, but never past
// what the capture actually measured (a design frequency above that puts
// the notch entirely outside the scored data anyway).
function captureMaxFreq(freqArr){
if(!freqArr||!freqArr.length)return MAX_SHAPER_FREQ;
return Math.min(MAX_SHAPER_FREQ,freqArr[freqArr.length-1]);
}
// Applies a new ceiling: persists it, re-syncs the input (in case the
// clamp changed what the user typed), and -- if a capture is already
// loaded -- redetects peaks and re-searches under the new ceiling. Mirrors
// setFirmwareTier's cascade (recomputeAutoExclude -> renderInput -> update
// -> renderRecommendation), since both change which candidates/peaks are
// in play.
function setMaxAnalysisFreq(freq){
MAX_SHAPER_FREQ=clampMaxAnalysisFreq(freq);
try{localStorage.setItem(MAX_FREQ_KEY,String(MAX_SHAPER_FREQ));}catch(e){}
var input=document.getElementById('maxAnalysisFreq');
if(input&&+input.value!==MAX_SHAPER_FREQ)input.value=MAX_SHAPER_FREQ;
if(!loadedCapture)return;
var d=loadedCapture;
applyDetected(detectAndEstimate(d.freq,d.xyz,4,captureMaxFreq(d.freq)));
recomputeAutoExclude();
renderInput();
update();
renderRecommendation();
}
// estimate_shaper time-resolution for the multimode fit. Python uses
// n_t=1000 on a 0.02-step unit grid; the unit-grid build is the dominant
// cost (rebuilt per base-combo x ratio-combo), so it runs at a reduced
// n_t tuned to still reproduce the auto-tuner's winner and design
// frequencies (validated against real Python on real captures). The
// small freq_bins-grid re-scores in the N>2 coordinate descent keep the
// full n_t=1000 since that grid is short.
var MULTIMODE_UNIT_STEP=0.02, MULTIMODE_UNIT_NT=600, MULTIMODE_FREQBINS_NT=1000;
// General N-mode shaper from parallel base/freq/damping arrays
// (== shaper_defs.get_multimode_shaper: convolve one base per peak).
function mmShaper(bases,freqs,dampings){
var arr=[];
for(var i=0;i<freqs.length;i++)arr.push({base:bases[i],freq:freqs[i],damping:dampings[i]});
return buildMultiMode(arr);
}
// ---- smoothers: shaper_defs.get_*_smoother / init_smoother ----
var SMOOTHER_TABLE={
smooth_zv:{coeffs:[-118.4265334338076,5.861885495127615,29.52796003014231,-1.465471373781904,0.01966833207740377],k:0.8025,minFreq:18.0},
smooth_mzv:{coeffs:[-1906.717580206364,125.8892756660212,698.0200035767849,-37.75923018121473,-62.18762409216703,1.57172781617736,1.713117990217123],k:0.95625,minFreq:20.0},
smooth_ei:{coeffs:[-1797.048868963208,120.5310596109878,669.6653197989012,-35.71975707450795,-62.49388325512682,1.396748042940248,1.848276903900512],k:1.06625,minFreq:21.0},
smooth_2hump_ei:{coeffs:[-22525.88434486782,2524.826047114184,10554.22832043971,-1051.778387878068,-1475.914693073253,121.2177946817349,57.95603221424528,-4.018706414213658,0.8375784787864095],k:1.14875,minFreq:21.5},
smooth_zvd_ei:{coeffs:[-18835.07746719777,1914.349309746547,8786.608981369287,-807.3061869131075,-1209.429748155012,96.48879052981883,43.1595785340444,-3.577268915175282,1.083220648523371],k:1.475,minFreq:26.0},
smooth_si:{coeffs:[-6186.76006449789,1206.747198930197,2579.985143622855,-476.8554763069169,-295.546608490564,52.69679971161049,4.234582468800491,-2.226157642004671,1.267781046297883],k:1.245,minFreq:30.0}
};
var AUTOTUNE_SMOOTHER_NAMES=['smooth_zv','smooth_mzv','smooth_ei','smooth_2hump_ei','smooth_zvd_ei','smooth_si'];
function makeSmoother(name,freq){
var def=SMOOTHER_TABLE[name],coeffs=def.coeffs,tSm=def.k/freq,n=coeffs.length;
var invT=1/tSm,invTn=invT,C=new Array(n);
for(var i=n-1;i>=0;i--){C[n-i-1]=coeffs[i]*invTn;invTn*=invT;}
return {C:C,t_sm:tSm};
}
function stepResponseVelocity(t,omega,z){
var tt=Math.max(t,0),df=Math.sqrt(1-z*z);
return Math.exp(-z*omega*tt)*Math.sin(omega*df*tt)*(omega/df);
}
function stepResponseMinVelocity(z){
var d2=z*z,dr=z/Math.sqrt(1-d2);
var t=0.5*Math.atan2(2*d2,(2*d2-1)*dr)+Math.PI;
var phase=Math.acos(z);
return Math.exp(-dr*t)*(dr*Math.sin(t+phase)-Math.cos(t+phase));
}
// Faithful port of the real `estimate_shaper` in shaper_calibrate.py (the
// ACCURATE time-domain step-response version, NOT `estimate_shaper_old`).
// Used by fit_shaper's classic-shaper branch and fit_multimode_shaper. This
// page's own residual() below is a separate, cheaper closed form used ONLY
// for the manual peak-mismatch charts -- do not conflate the two.
function estimateShaperReal(sh,testDampingRatio,f,nT){
if(!(f>0))return 1.0;
nT=nT||1000;
var A=sh.A,T=sh.T,n=A.length,i,k;
var sA=0;for(i=0;i<n;i++)sA+=A[i];
var invD=1/sA;
var tS=T[n-1]-T[0],tStart=T[0],tEnd=T[n-1]+2.0*Math.max(1.0/f,tS);
var omega=2*Math.PI*f;
var time=new Array(nT);
for(i=0;i<nT;i++)time[i]=tStart+(tEnd-tStart)*i/(nT-1);
var velocity=new Array(nT).fill(0);
for(k=0;k<n;k++)for(i=0;i<nT;i++)velocity[i]+=A[k]*stepResponseVelocity(time[i]-T[k],omega,testDampingRatio);
for(i=0;i<nT;i++)velocity[i]*=invD/omega;
var kinkMin=Infinity;
for(var j=0;j<n;j++){
var kv=0;for(k=0;k<n;k++)kv+=A[k]*stepResponseVelocity(T[j]-T[k],omega,testDampingRatio);
kv*=invD/omega;if(kv<kinkMin)kinkMin=kv;
}
var vmin=Infinity,vminIdx=-1;
for(i=0;i<nT;i++)if(velocity[i]<vmin){vmin=velocity[i];vminIdx=i;}
var refined=vmin;
var jc=Math.min(Math.max(vminIdx,1),nT-2);
if(jc>=1&&jc<=nT-2&&vminIdx===jc){
var vM=velocity[jc-1],v0=velocity[jc],vP=velocity[jc+1],denom=vM-2*v0+vP;
if(denom>0)refined=velocity[vminIdx]-(vP-vM)*(vP-vM)/(8*denom);
}
var velocityMin=Math.min(refined,kinkMin);
var minV=-stepResponseMinVelocity(testDampingRatio);
return -velocityMin/minV;
}
// Faithful port of the real `estimate_smoother` in shaper_calibrate.py
// -- a windowed convolution of the smoother against the analytic step
// response, materially more accurate for wide smoothers (e.g.
// smooth_zvd_ei) than the closed-form estimate_smoother_old approximation.
// nT defaults lower than Python's 1000 as a deliberate browser-performance
// tradeoff; validated against the real Python to still pick the same
// overall winner on real capture data.
function estimateSmootherReal(sm,testDampingRatio,f,nT){
if(!(f>0))return 1.0;
nT=nT||250;
var C=sm.C,tSm=sm.t_sm,hst=tSm*0.5,i,k;
var omega=2*Math.PI*f,tStart=-tSm,tEnd=hst+Math.max(1.5/f,2.0*tSm);
var time=new Array(nT);
for(i=0;i<nT;i++)time[i]=tStart+(tEnd-tStart)*i/(nT-1);
var dt=(time[nT-1]-time[0])/nT;
var w=new Array(nT);
for(i=0;i<nT;i++){
var inWin=time[i]<=hst&&time[i]>=-hst,tau=inWin?time[i]:0,wv=0;
for(k=C.length-1;k>=0;k--)wv=wv*tau+C[k];
w[i]=inWin?wv:0;
}