@vessel-dsp/compiler 0.0.0-stage → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +118 -2
- package/dist/bake.d.ts +38 -0
- package/dist/bake.d.ts.map +1 -0
- package/dist/bake.js +101 -0
- package/dist/bake.js.map +1 -0
- package/dist/bbd-clock.d.ts +171 -0
- package/dist/bbd-clock.d.ts.map +1 -0
- package/dist/bbd-clock.js +1750 -0
- package/dist/bbd-clock.js.map +1 -0
- package/dist/block-row.d.ts +51 -0
- package/dist/block-row.d.ts.map +1 -0
- package/dist/block-row.js +17 -0
- package/dist/block-row.js.map +1 -0
- package/dist/bypass.d.ts +3 -0
- package/dist/bypass.d.ts.map +1 -0
- package/dist/bypass.js +228 -0
- package/dist/bypass.js.map +1 -0
- package/dist/compile.d.ts +24 -0
- package/dist/compile.d.ts.map +1 -0
- package/dist/compile.js +348 -0
- package/dist/compile.js.map +1 -0
- package/dist/converter-scale.d.ts +14 -0
- package/dist/converter-scale.d.ts.map +1 -0
- package/dist/converter-scale.js +133 -0
- package/dist/converter-scale.js.map +1 -0
- package/dist/couple.d.ts +28 -0
- package/dist/couple.d.ts.map +1 -0
- package/dist/couple.js +567 -0
- package/dist/couple.js.map +1 -0
- package/dist/dangling-active-terminal.d.ts +47 -0
- package/dist/dangling-active-terminal.d.ts.map +1 -0
- package/dist/dangling-active-terminal.js +542 -0
- package/dist/dangling-active-terminal.js.map +1 -0
- package/dist/declared-program.d.ts +23 -0
- package/dist/declared-program.d.ts.map +1 -0
- package/dist/declared-program.js +387 -0
- package/dist/declared-program.js.map +1 -0
- package/dist/denomination.d.ts +48 -0
- package/dist/denomination.d.ts.map +1 -0
- package/dist/denomination.js +27 -0
- package/dist/denomination.js.map +1 -0
- package/dist/device-laws.d.ts +237 -0
- package/dist/device-laws.d.ts.map +1 -0
- package/dist/device-laws.js +3513 -0
- package/dist/device-laws.js.map +1 -0
- package/dist/diagnostics.d.ts +13 -0
- package/dist/diagnostics.d.ts.map +1 -0
- package/dist/diagnostics.js +19 -0
- package/dist/diagnostics.js.map +1 -0
- package/dist/emit.d.ts +8 -0
- package/dist/emit.d.ts.map +1 -0
- package/dist/emit.js +43 -0
- package/dist/emit.js.map +1 -0
- package/dist/errors.d.ts +22 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +39 -0
- package/dist/errors.js.map +1 -0
- package/dist/firmware-class.d.ts +69 -0
- package/dist/firmware-class.d.ts.map +1 -0
- package/dist/firmware-class.js +20 -0
- package/dist/firmware-class.js.map +1 -0
- package/dist/fixtures/circuits.d.ts +1742 -0
- package/dist/fixtures/circuits.d.ts.map +1 -0
- package/dist/fixtures/circuits.js +13896 -0
- package/dist/fixtures/circuits.js.map +1 -0
- package/dist/fixtures/expected.d.ts +261 -0
- package/dist/fixtures/expected.d.ts.map +1 -0
- package/dist/fixtures/expected.js +315 -0
- package/dist/fixtures/expected.js.map +1 -0
- package/dist/fixtures/index.d.ts +4 -0
- package/dist/fixtures/index.d.ts.map +1 -0
- package/dist/fixtures/index.js +8 -0
- package/dist/fixtures/index.js.map +1 -0
- package/dist/fixtures/registry.d.ts +52 -0
- package/dist/fixtures/registry.d.ts.map +1 -0
- package/dist/fixtures/registry.js +341 -0
- package/dist/fixtures/registry.js.map +1 -0
- package/dist/generic-tube-fit.d.ts +12 -0
- package/dist/generic-tube-fit.d.ts.map +1 -0
- package/dist/generic-tube-fit.js +45 -0
- package/dist/generic-tube-fit.js.map +1 -0
- package/dist/grounded-clock-supply.d.ts +13 -0
- package/dist/grounded-clock-supply.d.ts.map +1 -0
- package/dist/grounded-clock-supply.js +45 -0
- package/dist/grounded-clock-supply.js.map +1 -0
- package/dist/identify.d.ts +50 -0
- package/dist/identify.d.ts.map +1 -0
- package/dist/identify.js +319 -0
- package/dist/identify.js.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +19 -0
- package/dist/index.js.map +1 -0
- package/dist/inert-controls.d.ts +15 -0
- package/dist/inert-controls.d.ts.map +1 -0
- package/dist/inert-controls.js +266 -0
- package/dist/inert-controls.js.map +1 -0
- package/dist/latch-seed.d.ts +53 -0
- package/dist/latch-seed.d.ts.map +1 -0
- package/dist/latch-seed.js +257 -0
- package/dist/latch-seed.js.map +1 -0
- package/dist/link.d.ts +7 -0
- package/dist/link.d.ts.map +1 -0
- package/dist/link.js +270 -0
- package/dist/link.js.map +1 -0
- package/dist/lower.d.ts +79 -0
- package/dist/lower.d.ts.map +1 -0
- package/dist/lower.js +1855 -0
- package/dist/lower.js.map +1 -0
- package/dist/netlist.d.ts +79 -0
- package/dist/netlist.d.ts.map +1 -0
- package/dist/netlist.js +4746 -0
- package/dist/netlist.js.map +1 -0
- package/dist/non-executable-clock-drivers.d.ts +8 -0
- package/dist/non-executable-clock-drivers.d.ts.map +1 -0
- package/dist/non-executable-clock-drivers.js +33 -0
- package/dist/non-executable-clock-drivers.js.map +1 -0
- package/dist/open-category.d.ts +98 -0
- package/dist/open-category.d.ts.map +1 -0
- package/dist/open-category.js +111 -0
- package/dist/open-category.js.map +1 -0
- package/dist/over-driven-node.d.ts +33 -0
- package/dist/over-driven-node.d.ts.map +1 -0
- package/dist/over-driven-node.js +188 -0
- package/dist/over-driven-node.js.map +1 -0
- package/dist/part-catalog.d.ts +15 -0
- package/dist/part-catalog.d.ts.map +1 -0
- package/dist/part-catalog.js +6712 -0
- package/dist/part-catalog.js.map +1 -0
- package/dist/part-number.d.ts +32 -0
- package/dist/part-number.d.ts.map +1 -0
- package/dist/part-number.js +80 -0
- package/dist/part-number.js.map +1 -0
- package/dist/partition.d.ts +7 -0
- package/dist/partition.d.ts.map +1 -0
- package/dist/partition.js +242 -0
- package/dist/partition.js.map +1 -0
- package/dist/port-full-scale.d.ts +70 -0
- package/dist/port-full-scale.d.ts.map +1 -0
- package/dist/port-full-scale.js +235 -0
- package/dist/port-full-scale.js.map +1 -0
- package/dist/pot-orientation.d.ts +114 -0
- package/dist/pot-orientation.d.ts.map +1 -0
- package/dist/pot-orientation.js +551 -0
- package/dist/pot-orientation.js.map +1 -0
- package/dist/power-domain-control.d.ts +40 -0
- package/dist/power-domain-control.d.ts.map +1 -0
- package/dist/power-domain-control.js +142 -0
- package/dist/power-domain-control.js.map +1 -0
- package/dist/proxy-declaration.d.ts +121 -0
- package/dist/proxy-declaration.d.ts.map +1 -0
- package/dist/proxy-declaration.js +50 -0
- package/dist/proxy-declaration.js.map +1 -0
- package/dist/registry.d.ts +323 -0
- package/dist/registry.d.ts.map +1 -0
- package/dist/registry.js +192 -0
- package/dist/registry.js.map +1 -0
- package/dist/sparse-schedule.d.ts +65 -0
- package/dist/sparse-schedule.d.ts.map +1 -0
- package/dist/sparse-schedule.js +421 -0
- package/dist/sparse-schedule.js.map +1 -0
- package/dist/speaker-load.d.ts +38 -0
- package/dist/speaker-load.d.ts.map +1 -0
- package/dist/speaker-load.js +97 -0
- package/dist/speaker-load.js.map +1 -0
- package/dist/stage-coverage.d.ts +21 -0
- package/dist/stage-coverage.d.ts.map +1 -0
- package/dist/stage-coverage.js +124 -0
- package/dist/stage-coverage.js.map +1 -0
- package/dist/stamp-partition.d.ts +314 -0
- package/dist/stamp-partition.d.ts.map +1 -0
- package/dist/stamp-partition.js +472 -0
- package/dist/stamp-partition.js.map +1 -0
- package/dist/supply-reference.d.ts +19 -0
- package/dist/supply-reference.d.ts.map +1 -0
- package/dist/supply-reference.js +66 -0
- package/dist/supply-reference.js.map +1 -0
- package/dist/supply-short.d.ts +32 -0
- package/dist/supply-short.d.ts.map +1 -0
- package/dist/supply-short.js +118 -0
- package/dist/supply-short.js.map +1 -0
- package/dist/supply-stamps.d.ts +51 -0
- package/dist/supply-stamps.d.ts.map +1 -0
- package/dist/supply-stamps.js +363 -0
- package/dist/supply-stamps.js.map +1 -0
- package/dist/terminal-role.d.ts +109 -0
- package/dist/terminal-role.d.ts.map +1 -0
- package/dist/terminal-role.js +46 -0
- package/dist/terminal-role.js.map +1 -0
- package/dist/terminal-roles.d.ts +59 -0
- package/dist/terminal-roles.d.ts.map +1 -0
- package/dist/terminal-roles.js +92 -0
- package/dist/terminal-roles.js.map +1 -0
- package/dist/transformer.d.ts +181 -0
- package/dist/transformer.d.ts.map +1 -0
- package/dist/transformer.js +476 -0
- package/dist/transformer.js.map +1 -0
- package/dist/types.d.ts +3157 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +11 -0
- package/dist/types.js.map +1 -0
- package/dist/unbounded-operating-point.d.ts +35 -0
- package/dist/unbounded-operating-point.d.ts.map +1 -0
- package/dist/unbounded-operating-point.js +181 -0
- package/dist/unbounded-operating-point.js.map +1 -0
- package/dist/unexecuted-region.d.ts +3 -0
- package/dist/unexecuted-region.d.ts.map +1 -0
- package/dist/unexecuted-region.js +122 -0
- package/dist/unexecuted-region.js.map +1 -0
- package/dist/unimplemented-control-role.d.ts +3 -0
- package/dist/unimplemented-control-role.d.ts.map +1 -0
- package/dist/unimplemented-control-role.js +61 -0
- package/dist/unimplemented-control-role.js.map +1 -0
- package/dist/unreachable-output.d.ts +3 -0
- package/dist/unreachable-output.d.ts.map +1 -0
- package/dist/unreachable-output.js +97 -0
- package/dist/unreachable-output.js.map +1 -0
- package/dist/unreadable-terminal-role.d.ts +67 -0
- package/dist/unreadable-terminal-role.d.ts.map +1 -0
- package/dist/unreadable-terminal-role.js +409 -0
- package/dist/unreadable-terminal-role.js.map +1 -0
- package/dist/unverified-pinout.d.ts +27 -0
- package/dist/unverified-pinout.d.ts.map +1 -0
- package/dist/unverified-pinout.js +121 -0
- package/dist/unverified-pinout.js.map +1 -0
- package/package.json +75 -5
package/dist/netlist.js
ADDED
|
@@ -0,0 +1,4746 @@
|
|
|
1
|
+
// Stage 1: `.vdsp` source text -> Netlist.
|
|
2
|
+
//
|
|
3
|
+
// This is the only stage permitted to touch the format libraries, and the only stage
|
|
4
|
+
// that ever sees geometry, names or prose. Everything it does not put into the
|
|
5
|
+
// Netlist is unreachable from every later stage, which is the whole point: the
|
|
6
|
+
// evidence discipline is a property of the type, not a rule someone has to follow.
|
|
7
|
+
//
|
|
8
|
+
// Dropped here, permanently: origins, rotation, flip, terminal coordinates, wires,
|
|
9
|
+
// panel layout, appearance, component names, Description, Role, and every other
|
|
10
|
+
// free-text field. Kept: typed kinds, resolved nodes, parameters as numbers,
|
|
11
|
+
// controls, and the closed identity evidence stage 2 needs.
|
|
12
|
+
//
|
|
13
|
+
// Node precedence, stated once: the document-level `nodes:` ledger is authoritative when
|
|
14
|
+
// present for a terminal; the terminal's inline `node:` is fallback where the ledger is
|
|
15
|
+
// absent; geometry resolves only what neither encoding states. Geometry is used to
|
|
16
|
+
// *resolve* nodes and is then discarded -- no coordinate survives into the Netlist, so no
|
|
17
|
+
// later stage can accidentally derive connectivity from one.
|
|
18
|
+
import { componentDevices, deviceTerminalRoles, parseInterchangeYamlWithTopology, } from "@vessel-dsp/core";
|
|
19
|
+
import { load as loadYaml } from "js-yaml";
|
|
20
|
+
import { StageRefusal } from "./errors.js";
|
|
21
|
+
import { foldToken } from "./registry.js";
|
|
22
|
+
import { findBistableLatches } from "./latch-seed.js";
|
|
23
|
+
import { speakerOnePort } from "./speaker-load.js";
|
|
24
|
+
import { GROUND, } from "./types.js";
|
|
25
|
+
export class NetlistError extends StageRefusal {
|
|
26
|
+
constructor(message, device = null, cause) {
|
|
27
|
+
super("netlist", device, message, cause);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/** Source component kinds that map to a device the compiler models. */
|
|
31
|
+
const deviceKindByComponentKind = {
|
|
32
|
+
resistor: "resistor",
|
|
33
|
+
capacitor: "capacitor",
|
|
34
|
+
inductor: "inductor",
|
|
35
|
+
diode: "diode",
|
|
36
|
+
led: "diode",
|
|
37
|
+
potentiometer: "potentiometer",
|
|
38
|
+
// A rheostat, not a pot. Every one of the corpus's variable-resistors has exactly
|
|
39
|
+
// two terminals, so aliasing it to `potentiometer` refused all of them for want of
|
|
40
|
+
// a wiper the device does not have.
|
|
41
|
+
"variable-resistor": "rheostat",
|
|
42
|
+
opamp: "opamp",
|
|
43
|
+
// 126 of the 24 canonical amp documents' tube instances are triodes, and every one of
|
|
44
|
+
// those documents refused at this table until now.
|
|
45
|
+
triode: "triode",
|
|
46
|
+
pentode: "pentode",
|
|
47
|
+
// Three instances across the 24 amp documents, and all three are **dual** rectifiers:
|
|
48
|
+
// `plate_a`, `plate_b` and one shared `cathode_filament`. See `tubeDiodeElements`.
|
|
49
|
+
"tube-diode": "tube-diode",
|
|
50
|
+
bjt: "bjt",
|
|
51
|
+
jfet: "jfet",
|
|
52
|
+
mosfet: "mosfet",
|
|
53
|
+
transformer: "transformer",
|
|
54
|
+
"voltage-source": "voltage-source",
|
|
55
|
+
battery: "voltage-source",
|
|
56
|
+
jack: "jack",
|
|
57
|
+
ground: "ground",
|
|
58
|
+
rail: "rail",
|
|
59
|
+
switch: "switch",
|
|
60
|
+
// **A fuse is a resistor, not a switch** (`kind: fuse`, core 0.6.38). It conducts until it
|
|
61
|
+
// opens once and nothing operates it, so it has no control to bind and no throw to select.
|
|
62
|
+
// Mapping it here is also what stops it being offered as one: the control fallback in
|
|
63
|
+
// `controlIdFor` fires for `potentiometer`, `rheostat` and `switch`, and a resistor is none
|
|
64
|
+
// of those -- so `vox-ac30-top-boost`'s four fuses stop arriving as knobs a player can sweep,
|
|
65
|
+
// with no property read and no spelling table.
|
|
66
|
+
fuse: "resistor",
|
|
67
|
+
ic: "ic",
|
|
68
|
+
bbd: "ic",
|
|
69
|
+
"delay-ic": "ic",
|
|
70
|
+
flipflop: "ic",
|
|
71
|
+
"analog-switch": "analog-switch",
|
|
72
|
+
regulator: "ic",
|
|
73
|
+
"power-converter": "ic",
|
|
74
|
+
ota: "ota",
|
|
75
|
+
// **Its own kind, not `ic`.** An optocoupler carries a universal device-class law -- an LED
|
|
76
|
+
// driving a photoresistor -- so an unregistered one must still work, exactly as an `ota` does.
|
|
77
|
+
// Mapping it to `ic` made the registry the only source of its behaviour, which is right for a
|
|
78
|
+
// VTL5C1 and wrong for the discrete neon/LDR pair in the Fender tremolo circuits: they name no
|
|
79
|
+
// part to look up, so they resolved to `open` and the tremolo was silent unless something read
|
|
80
|
+
// their terminal *names*. See `mayCarryRegistrySections`, which is what keeps the 13 packets
|
|
81
|
+
// whose optocouplers *are* registry parts resolving through their sections.
|
|
82
|
+
// A logic gate names itself as of `@vessel-dsp/core@0.6.39`, so a hex inverter can declare its
|
|
83
|
+
// six gates as devices instead of depending on a registry entry keyed on the part number to
|
|
84
|
+
// split it. The device kind, the law and the stamp all already existed; only the source had no
|
|
85
|
+
// way to say the word.
|
|
86
|
+
inverter: "inverter",
|
|
87
|
+
optocoupler: "optocoupler",
|
|
88
|
+
"power-amp": "power-amp",
|
|
89
|
+
};
|
|
90
|
+
/**
|
|
91
|
+
* Kinds that carry no electrical behaviour and are dropped rather than modelled.
|
|
92
|
+
*
|
|
93
|
+
* `port` is here because a schematic port is usually an annotation — a sheet connector, a test
|
|
94
|
+
* point, a name for a net. **But a port that declares a typed `Voltage` is a rail**, and dropping
|
|
95
|
+
* those cost the corpus 76 of them across 39 packets, 24 of which compile: `boss-sd-1`'s
|
|
96
|
+
* `VBIAS_IN` sits on node 4 with `{raw: "4.5 V", value: 4.5, unit: "V"}` and the description
|
|
97
|
+
* "SD-1 virtual half-supply bias rail", and `klon-centaur` loses 4.5 V, 18 V and -9 V at once.
|
|
98
|
+
*
|
|
99
|
+
* The consequence was not a refusal but a *wrong answer that renders*: the bias resistor's far end
|
|
100
|
+
* became a node nothing else touched, the stage sat unbiased, the pedal went silent, and
|
|
101
|
+
* `active-device-terminal-unwired` reported the terminal as unwired. Several packets were filed as
|
|
102
|
+
* source omissions on the strength of that.
|
|
103
|
+
*
|
|
104
|
+
* See `promotedKindFor` for how the decision is made, and note that it is the *presence of a typed
|
|
105
|
+
* property*, never the component's name or description — both of which say "rail" here and must
|
|
106
|
+
* stay unread.
|
|
107
|
+
*/
|
|
108
|
+
const ignoredComponentKinds = new Set([
|
|
109
|
+
"label",
|
|
110
|
+
"named-wire",
|
|
111
|
+
"display",
|
|
112
|
+
"port",
|
|
113
|
+
"unsupported",
|
|
114
|
+
]);
|
|
115
|
+
/**
|
|
116
|
+
* Terminal roles that name an op-amp's driven output.
|
|
117
|
+
*
|
|
118
|
+
* An op-amp output forces its node exactly as a supply does, so it counts as a potential the
|
|
119
|
+
* circuit already generates. **Only the output**: an op-amp's inputs draw no current and assert
|
|
120
|
+
* nothing, and treating all three terminals as driven would suppress the promotion `boss-sd-1`
|
|
121
|
+
* needs, whose `VBIAS_OP` reaches an op-amp *input* through its bias resistor.
|
|
122
|
+
*
|
|
123
|
+
* Missing this cost `boss-bd-2-blues-driver` and its Keeley mod a `node-driven-by-two-sources`
|
|
124
|
+
* warning apiece: their promoted rail landed on a node an op-amp output already drove.
|
|
125
|
+
*/
|
|
126
|
+
const opampOutputRoles = new Set([
|
|
127
|
+
"out",
|
|
128
|
+
"output",
|
|
129
|
+
"vout",
|
|
130
|
+
]);
|
|
131
|
+
/**
|
|
132
|
+
* Role tokens naming an op-amp's **supply** pin, folded as `terminalRoleToken` leaves them.
|
|
133
|
+
*
|
|
134
|
+
* An op-amp is powered by its rails: a rail is an input to every op-amp that touches it, and no
|
|
135
|
+
* op-amp *output* generates one. `generated()` below uses this to stop the op-amp-output branch
|
|
136
|
+
* from certifying a supply node.
|
|
137
|
+
*
|
|
138
|
+
* Built from an exhaustive census of every op-amp terminal-role token the corpus declares.
|
|
139
|
+
* Excludes `vplus`/`vminus` -- and the original justification for that ("`lower.ts`'s
|
|
140
|
+
* `opampInputRoles` documents them as this corpus's input spelling") did not survive a sharper
|
|
141
|
+
* census: checked against each op-amp's *full* role set (2026-08-25, `device-laws.ts`'s
|
|
142
|
+
* `opampRails`), those spellings are supply rails in all 60 corpus devices that declare them.
|
|
143
|
+
* The exclusion is kept anyway, on a different and honest ground: leaving them out only
|
|
144
|
+
* *narrows* the no-generate guard below, which is the safe direction, and widening it changes
|
|
145
|
+
* which rails `generated()` protects -- a behaviour change that needs its own corpus
|
|
146
|
+
* measurement, not a comment edit. The numbered `vpluspin8`/`vminuspin4` ARE included, because
|
|
147
|
+
* pins 8 and 4 of a dual op-amp are unambiguously its rails. `v+`/`v` (folded from `v-`) is
|
|
148
|
+
* `boss-ce-2`'s supply pair under that document's own naming.
|
|
149
|
+
*/
|
|
150
|
+
const opampSupplyRoles = new Set([
|
|
151
|
+
"vcc",
|
|
152
|
+
"vee",
|
|
153
|
+
"vccpin8",
|
|
154
|
+
"veepin4",
|
|
155
|
+
"vpluspin8",
|
|
156
|
+
"vminuspin4",
|
|
157
|
+
"positivesupply",
|
|
158
|
+
"negativesupply",
|
|
159
|
+
"supply",
|
|
160
|
+
"v+",
|
|
161
|
+
"v",
|
|
162
|
+
]);
|
|
163
|
+
/**
|
|
164
|
+
* DC-conducting device kinds, for asking whether a node's potential is already generated.
|
|
165
|
+
*
|
|
166
|
+
* Exported so `lower.ts` can reuse the identical evidence class for a pot's orientation --
|
|
167
|
+
* which end sits electrically closer to a quiet DC reference -- rather than defining a second,
|
|
168
|
+
* possibly-drifting closed set for the same question of "what conducts a steady current here".
|
|
169
|
+
*/
|
|
170
|
+
export const dcConductingKinds = new Set([
|
|
171
|
+
"resistor",
|
|
172
|
+
"potentiometer",
|
|
173
|
+
"rheostat",
|
|
174
|
+
"inductor",
|
|
175
|
+
"switch",
|
|
176
|
+
"selector",
|
|
177
|
+
]);
|
|
178
|
+
/**
|
|
179
|
+
* A rectifier's electrode roles, coarsened to just "supply-side" and "load-side", for the single
|
|
180
|
+
* question `voltagePortRails` asks: does current reach this node from somewhere already asserted?
|
|
181
|
+
*
|
|
182
|
+
* **A fresh, deliberately coarser copy of `lower.ts`'s `diodeTerminalRoles` /
|
|
183
|
+
* `tubeDiodeTerminalRoles`, not an import.** Those tables answer a harder question -- exactly how
|
|
184
|
+
* many junctions a component is and which pairs they connect, needed to lower a bridge or a
|
|
185
|
+
* shared-cathode pack to the right elements -- and live in `lower.ts`, which already imports
|
|
186
|
+
* `dcConductingKinds` from this file; importing the other way would be a cycle. This table only
|
|
187
|
+
* needs "which terminals are upstream (anode/plate/AC leg) and which are downstream (cathode/DC
|
|
188
|
+
* leg)", which every one of those roles already answers unambiguously, so one closed table
|
|
189
|
+
* covering both `diode` and `tube-diode` roles is enough here.
|
|
190
|
+
*/
|
|
191
|
+
const rectifierElectrodeRoles = {
|
|
192
|
+
anode: "supply",
|
|
193
|
+
anodea: "supply",
|
|
194
|
+
anodeb: "supply",
|
|
195
|
+
redanode: "supply",
|
|
196
|
+
greenanode: "supply",
|
|
197
|
+
plate: "supply",
|
|
198
|
+
platea: "supply",
|
|
199
|
+
plateb: "supply",
|
|
200
|
+
aca: "supply",
|
|
201
|
+
acb: "supply",
|
|
202
|
+
acleft: "supply",
|
|
203
|
+
acright: "supply",
|
|
204
|
+
cathode: "load",
|
|
205
|
+
cathodefilament: "load",
|
|
206
|
+
positive: "load",
|
|
207
|
+
positiveraw: "load",
|
|
208
|
+
negative: "load",
|
|
209
|
+
negativeraw: "load",
|
|
210
|
+
};
|
|
211
|
+
/**
|
|
212
|
+
* Ports that assert a potential nothing else in the circuit can generate, as rail devices.
|
|
213
|
+
*
|
|
214
|
+
* **A voltage-carrying port is not always a rail, and getting that wrong regresses the corpus.**
|
|
215
|
+
* `ibanez-ts808` declares `VBIAS_4V5` at 4.5 V on node 3, and that node already reaches the 9 V
|
|
216
|
+
* supply through the divider the packet models — so the port *documents* a node the circuit makes
|
|
217
|
+
* for itself, and forcing an ideal source there double-drives it. Measured: promoting every
|
|
218
|
+
* voltage port took parity from 33 agrees to 31 and turned both Tube Screamers from `agrees` into
|
|
219
|
+
* `both silent`, with 8 A of supply draw.
|
|
220
|
+
*
|
|
221
|
+
* `boss-sd-1` is the opposite and is why this exists at all. Its `VBIAS_IN` sits on node 4, which
|
|
222
|
+
* **reaches no supply through any conductor**, so nothing in the modelled circuit can put a
|
|
223
|
+
* potential there. Dropping it left the bias resistor's far end touched by one terminal, the stage
|
|
224
|
+
* unbiased, the pedal silent, and `active-device-terminal-unwired` reporting a dangling terminal
|
|
225
|
+
* that is not dangling in the source.
|
|
226
|
+
*
|
|
227
|
+
* So the test is whether the node's potential is *already generated*, which is a topological
|
|
228
|
+
* question with no ratio in it — unlike bias adequacy, where two structural rules were measured
|
|
229
|
+
* and rejected.
|
|
230
|
+
*
|
|
231
|
+
* **A rectifier output is generated too, decided 2026-08-14 and added the same way.** A B+
|
|
232
|
+
* reservoir fed only through a `diode` or `tube-diode` used to read as ungenerated, because
|
|
233
|
+
* neither kind conducts DC the way `dcConductingKinds`' set does -- so 94 typed-`Voltage` ports
|
|
234
|
+
* across 21 of 24 amp documents were promoted to ideal rails, deleting the rectifier/reservoir
|
|
235
|
+
* supply that actually feeds them (measured: rail 0.78 V high, ripple suppressed 3.7x, the
|
|
236
|
+
* rectifier passing a thirteenth of the current, no warning). The fix is directional and
|
|
237
|
+
* one-way on purpose -- a load-side (cathode) node may reach back to its supply-side (anode)
|
|
238
|
+
* node, never the other way -- because a rectifier only conducts anode-to-cathode: treating it
|
|
239
|
+
* as bidirectional would let an unrelated node on a diode's load side (a clipping diode's
|
|
240
|
+
* return, a ground reference) retroactively call the *supply* side "generated", which is the
|
|
241
|
+
* same over-broad promotion shape that took parity from 33 to 31 above. This is additive to
|
|
242
|
+
* that history, not a repeat of it: it recognises one more thing that already generates a
|
|
243
|
+
* node's potential, it does not stop checking whether one does.
|
|
244
|
+
*
|
|
245
|
+
* Deduplicated per node: `marshall-blues-breaker` declares `V2`, `V3`, `V4` and `V5` all at 4.5 V
|
|
246
|
+
* on node 7, and four ideal sources on one node is the over-determined system
|
|
247
|
+
* `node-driven-by-two-sources` exists to name.
|
|
248
|
+
*
|
|
249
|
+
* **An op-amp output is only an anchor if reaching it does not loop back through its own other
|
|
250
|
+
* terminals, found 2026-08-14.** `jhs-morning-glory`'s `VBIAS` and `vemuram-jan-ray`'s `VREF` are
|
|
251
|
+
* each declared exactly the `boss-sd-1` way -- a typed-`Voltage` port with no independently-wired
|
|
252
|
+
* divider -- and each was silently not promoted, because the BFS below reached that op-amp's own
|
|
253
|
+
* output through a resistor network that passes back through that *same* op-amp's own input
|
|
254
|
+
* first. That is the BFS re-entering through the device it started from: an op-amp's output is a
|
|
255
|
+
* *dependent* source (a function of its own inputs), so proving a candidate node can reach it by a
|
|
256
|
+
* path that runs through the op-amp's own terminals proves nothing about independent generation --
|
|
257
|
+
* `render:v2 --bias` shows both islands settle away from their declared value (one collapses to
|
|
258
|
+
* millivolts, one to a flat ~3.8 V blob), and both still "agree" with ngspice, because the parity
|
|
259
|
+
* deck is emitted from this same program and inherits the same missing rail. Full trace in
|
|
260
|
+
* `docs/troubleshootings/vbias-vref-ports-not-promoted-through-a-self-referential-opamp-loop.md`.
|
|
261
|
+
*
|
|
262
|
+
* Asking the same question of `loadToSupply` and `windingCoupling` beside it: neither is exposed,
|
|
263
|
+
* because neither adds one of *its own* device's nodes to `asserted` the way the op-amp rule does
|
|
264
|
+
* -- both only add edges (extended reachability), so there is nothing for either rectifier or
|
|
265
|
+
* transformer to circularly assert about itself. The op-amp rule is the only one of the three that
|
|
266
|
+
* claims one of its own terminals is independently generated, so it is the only one that needs
|
|
267
|
+
* this guard.
|
|
268
|
+
*/
|
|
269
|
+
function voltagePortRails(components, devices, nodeOf) {
|
|
270
|
+
const asserted = new Set();
|
|
271
|
+
/**
|
|
272
|
+
* An op-amp output node, mapped to every *other* node that same op-amp device touches.
|
|
273
|
+
*
|
|
274
|
+
* Ground (node 0) is excluded from the exclusion set for the same reason `loadToSupply` and
|
|
275
|
+
* `windingCoupling` exclude it from their own maps: node 0 is the one node every unrelated
|
|
276
|
+
* grounded device in the document shares, so barring it as a pass-through would block unrelated
|
|
277
|
+
* legitimate paths, not just this op-amp's self-reference. Real independent sources (a
|
|
278
|
+
* `voltage-source`/`rail` node, including this same op-amp's own `vcc`/`vee` pins, which are
|
|
279
|
+
* ordinarily wired to one) are never excluded either: they are checked by the unconstrained walk
|
|
280
|
+
* below before any op-amp-specific exclusion applies, so barring them here would only weaken
|
|
281
|
+
* this op-amp's own reachability check, never the independent-source one.
|
|
282
|
+
*/
|
|
283
|
+
const opampSelfTerminals = new Map();
|
|
284
|
+
const opampSupplyNodes = new Set();
|
|
285
|
+
/**
|
|
286
|
+
* Nodes an op-amp *input* terminal sits on.
|
|
287
|
+
*
|
|
288
|
+
* **No op-amp input generates the potential it is handed.** The docstring on
|
|
289
|
+
* `opampOutputRoles` already states the half of this that concerns one op-amp's own
|
|
290
|
+
* terminals -- "an op-amp's inputs draw no current and assert nothing" -- but the
|
|
291
|
+
* per-op-amp exclusion below only bars *that* op-amp's nodes, so a second op-amp's output,
|
|
292
|
+
* reached through the network, still certified the first one's reference and the port on it
|
|
293
|
+
* was dropped. Every dual op-amp sharing one half-supply bias rail is that shape.
|
|
294
|
+
*
|
|
295
|
+
* Measured 2026-09-10 by compiling every packet that declares a voltage port and asking
|
|
296
|
+
* which ports never reach a `dc-source` stamp: 10 of 15 across 6 packets were dropped, and
|
|
297
|
+
* **two** of them are this class -- `analog-man-prince-of-tone` (`VB` 4.5 V) and
|
|
298
|
+
* `klon-centaur` (`VBPLUS_RAIL` 4.5 V), each a half-supply port declared **on** an op-amp's
|
|
299
|
+
* non-inverting input. What that costs is not a refusal but a wrong answer that renders: the
|
|
300
|
+
* bias node floats, the stage biases itself against a rail through its own feedback network,
|
|
301
|
+
* audio still crosses the packet through the passives, and every control reads flat.
|
|
302
|
+
* `analog-man-prince-of-tone` measured 0.0 dB on all four knobs at an output within 2% of
|
|
303
|
+
* its input, and was carried in the ledger as one of two packets whose whole panel is dead
|
|
304
|
+
* with *no* structural explanation.
|
|
305
|
+
*
|
|
306
|
+
* The other eight dropped ports are **not** this and are deliberately left dropped: they are
|
|
307
|
+
* packet-side. `boss-sp-1-spectrum` declares `Voltage: 4.5V nominal`, which is not a
|
|
308
|
+
* quantity, so its port is skipped before this guard is ever consulted -- the same shape as
|
|
309
|
+
* `boss-ce-1`'s three MN3002 bias ports and `boss-dd-3a`'s two. Naming that here keeps the
|
|
310
|
+
* two causes apart: this guard recovers a port the document declared correctly, and it
|
|
311
|
+
* cannot recover one the document did not.
|
|
312
|
+
*
|
|
313
|
+
* Read from `declaredTerminalRoles` -- the same evidence `lower.ts`'s `opampTerminals` uses
|
|
314
|
+
* to place the stamp -- rather than a second folded-token set beside `opampSupplyRoles`,
|
|
315
|
+
* because two vocabularies for one question are how the pin maps drifted.
|
|
316
|
+
*
|
|
317
|
+
* Ordered after the unconstrained walk in `generated()`, exactly as `opampSupplyNodes` is: a
|
|
318
|
+
* bias node genuinely fed by a real independent source is still found by that walk and still
|
|
319
|
+
* left unforced, so this can only narrow the guard, never overrule a real supply.
|
|
320
|
+
*/
|
|
321
|
+
const opampInputNodes = new Set();
|
|
322
|
+
for (const device of devices) {
|
|
323
|
+
if (device.kind === "voltage-source" || device.kind === "rail") {
|
|
324
|
+
for (const node of device.nodes) {
|
|
325
|
+
if (node !== 0) {
|
|
326
|
+
asserted.add(node);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
if (device.kind !== "opamp") {
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
334
|
+
device.identity.terminalRoles.forEach((role, index) => {
|
|
335
|
+
const node = device.nodes[index];
|
|
336
|
+
if (role !== null &&
|
|
337
|
+
opampSupplyRoles.has(role) &&
|
|
338
|
+
node !== undefined &&
|
|
339
|
+
node !== 0) {
|
|
340
|
+
opampSupplyNodes.add(node);
|
|
341
|
+
}
|
|
342
|
+
if (role !== null &&
|
|
343
|
+
opampOutputRoles.has(role) &&
|
|
344
|
+
node !== undefined &&
|
|
345
|
+
node !== 0) {
|
|
346
|
+
const own = new Set();
|
|
347
|
+
for (const other of device.nodes) {
|
|
348
|
+
if (other !== node && other !== 0) {
|
|
349
|
+
own.add(other);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
opampSelfTerminals.set(node, own);
|
|
353
|
+
}
|
|
354
|
+
});
|
|
355
|
+
device.identity.declaredTerminalRoles.forEach((role, index) => {
|
|
356
|
+
const node = device.nodes[index];
|
|
357
|
+
if ((role === "nonInverting" || role === "inverting") &&
|
|
358
|
+
node !== undefined &&
|
|
359
|
+
node !== 0) {
|
|
360
|
+
opampInputNodes.add(node);
|
|
361
|
+
}
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
const adjacency = new Map();
|
|
365
|
+
for (const device of devices) {
|
|
366
|
+
if (!dcConductingKinds.has(device.kind)) {
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
for (let i = 0; i < device.nodes.length; i += 1) {
|
|
370
|
+
for (let j = i + 1; j < device.nodes.length; j += 1) {
|
|
371
|
+
const a = device.nodes[i];
|
|
372
|
+
const b = device.nodes[j];
|
|
373
|
+
if (a === b) {
|
|
374
|
+
continue;
|
|
375
|
+
}
|
|
376
|
+
adjacency.set(a, [...(adjacency.get(a) ?? []), b]);
|
|
377
|
+
adjacency.set(b, [...(adjacency.get(b) ?? []), a]);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
// A rectifier's load-side (cathode) node may step back to its supply-side (anode) node --
|
|
382
|
+
// never the reverse, see `rectifierElectrodeRoles` above. Kept as its own directed map rather
|
|
383
|
+
// than folded into `adjacency`, which is symmetric by construction.
|
|
384
|
+
//
|
|
385
|
+
// Ground (node 0) excluded from both roles, for the same reason the transformer coupling
|
|
386
|
+
// below excludes it: a bridge's DC-negative leg is routinely grounded, and node 0 is the one
|
|
387
|
+
// node every unrelated device in the whole document can share, so keying or targeting an
|
|
388
|
+
// edge on it would let an unrelated node that merely touches ground borrow a rectifier's
|
|
389
|
+
// supply-side status through no real conduction path at all.
|
|
390
|
+
const loadToSupply = new Map();
|
|
391
|
+
for (const device of devices) {
|
|
392
|
+
if (device.kind !== "diode" && device.kind !== "tube-diode") {
|
|
393
|
+
continue;
|
|
394
|
+
}
|
|
395
|
+
const supplyNodes = [];
|
|
396
|
+
const loadNodes = [];
|
|
397
|
+
device.identity.terminalRoles.forEach((role, index) => {
|
|
398
|
+
const node = device.nodes[index];
|
|
399
|
+
if (node === undefined || node === 0 || role === null) {
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
const electrode = rectifierElectrodeRoles[role];
|
|
403
|
+
if (electrode === "supply") {
|
|
404
|
+
supplyNodes.push(node);
|
|
405
|
+
}
|
|
406
|
+
else if (electrode === "load") {
|
|
407
|
+
loadNodes.push(node);
|
|
408
|
+
}
|
|
409
|
+
});
|
|
410
|
+
for (const load of loadNodes) {
|
|
411
|
+
for (const supply of supplyNodes) {
|
|
412
|
+
if (load === supply) {
|
|
413
|
+
continue;
|
|
414
|
+
}
|
|
415
|
+
loadToSupply.set(load, [...(loadToSupply.get(load) ?? []), supply]);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
// A transformer winding may borrow generated status from another winding of the *same*
|
|
420
|
+
// transformer that is itself supply-reachable -- decided 2026-08-14, alongside the
|
|
421
|
+
// rectifier fix above. "Conducts DC" and "generates this node's potential" are different
|
|
422
|
+
// questions: a transformer is correctly excluded from `dcConductingKinds` because it does
|
|
423
|
+
// not conduct DC (galvanic isolation is real), but generating a secondary's potential from a
|
|
424
|
+
// primary's is the device's entire function, so it must count for *this* question.
|
|
425
|
+
//
|
|
426
|
+
// Deliberately **not** read from `primary`/`secondary` role names: those are terminal labels,
|
|
427
|
+
// not evidence of which side drives the other (a power transformer's primary is mains-driven;
|
|
428
|
+
// an output transformer's primary is plate-driven, and both call the driven side "primary").
|
|
429
|
+
// So every terminal of one transformer device is treated as one mutually-coupled group --
|
|
430
|
+
// finer-grained winding boundaries are a strict subset of this and would reach the same
|
|
431
|
+
// answer, since two terminals of one physical winding are already connected once either
|
|
432
|
+
// reaches an asserted node through this same coupling.
|
|
433
|
+
//
|
|
434
|
+
// Kept symmetric rather than directional like `loadToSupply`: unlike a rectifier, a
|
|
435
|
+
// transformer's coupling is not one-way, and guessing a direction from role names is exactly
|
|
436
|
+
// what this decision forbids. This still cannot run backwards into a source by construction
|
|
437
|
+
// -- `asserted` is fixed before this map is built and this map is never written to it, only
|
|
438
|
+
// read from during the BFS below, so a winding can be discovered generated but can never
|
|
439
|
+
// become a generator itself.
|
|
440
|
+
const windingCoupling = new Map();
|
|
441
|
+
for (const device of devices) {
|
|
442
|
+
if (device.kind !== "transformer") {
|
|
443
|
+
continue;
|
|
444
|
+
}
|
|
445
|
+
// Ground (node 0) excluded: it is the one node every unrelated device in the whole
|
|
446
|
+
// document can share (a primary return, a centre tap), and unlike a real winding
|
|
447
|
+
// terminal it asserts nothing on its own -- `asserted` never contains it either. Coupling
|
|
448
|
+
// through it would bridge two unrelated transformers that merely both ground a leg, which
|
|
449
|
+
// is a wider mistake than the one this fix exists to make safe.
|
|
450
|
+
const nodes = [...new Set(device.nodes)].filter((node) => node !== 0);
|
|
451
|
+
for (let i = 0; i < nodes.length; i += 1) {
|
|
452
|
+
for (let j = i + 1; j < nodes.length; j += 1) {
|
|
453
|
+
const a = nodes[i];
|
|
454
|
+
const b = nodes[j];
|
|
455
|
+
windingCoupling.set(a, [...(windingCoupling.get(a) ?? []), b]);
|
|
456
|
+
windingCoupling.set(b, [...(windingCoupling.get(b) ?? []), a]);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
/**
|
|
461
|
+
* Every node reachable from `from` over `adjacency`, `loadToSupply` and `windingCoupling`,
|
|
462
|
+
* without ever stepping onto a node in `excluded` or node 0 (ground). `from` itself is
|
|
463
|
+
* excluded too if it is in `excluded` or is 0 -- the walk cannot even start, which is exactly
|
|
464
|
+
* right when `from` *is* one of the op-amp's own other terminals or ground: ground is a 0 V
|
|
465
|
+
* reference sink that does not conduct DC power supply or op-amp output potentials between
|
|
466
|
+
* unrelated branches.
|
|
467
|
+
*/
|
|
468
|
+
const reachableWithout = (from, excluded) => {
|
|
469
|
+
const seen = new Set();
|
|
470
|
+
if (excluded.has(from) || from === 0) {
|
|
471
|
+
return seen;
|
|
472
|
+
}
|
|
473
|
+
seen.add(from);
|
|
474
|
+
const queue = [from];
|
|
475
|
+
while (queue.length > 0) {
|
|
476
|
+
const at = queue.pop();
|
|
477
|
+
for (const next of [
|
|
478
|
+
...(adjacency.get(at) ?? []),
|
|
479
|
+
...(loadToSupply.get(at) ?? []),
|
|
480
|
+
...(windingCoupling.get(at) ?? []),
|
|
481
|
+
]) {
|
|
482
|
+
if (next === 0 || excluded.has(next) || seen.has(next)) {
|
|
483
|
+
continue;
|
|
484
|
+
}
|
|
485
|
+
seen.add(next);
|
|
486
|
+
queue.push(next);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
return seen;
|
|
490
|
+
};
|
|
491
|
+
const generated = (from) => {
|
|
492
|
+
// Real independent sources first, with no exclusion: a `voltage-source`/`rail` node asserts
|
|
493
|
+
// nothing about itself circularly, so plain reachability is the whole question.
|
|
494
|
+
const unconstrained = reachableWithout(from, new Set());
|
|
495
|
+
for (const node of unconstrained) {
|
|
496
|
+
if (asserted.has(node)) {
|
|
497
|
+
return true;
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
// **No op-amp output generates a supply rail.** An op-amp is powered by its rails, so a
|
|
501
|
+
// rail is an input to every op-amp that touches it. The per-op-amp exclusion below is not
|
|
502
|
+
// enough on its own: it bars only *that* op-amp's terminals, so an op-amp on a *different*
|
|
503
|
+
// rail certifies this one. Measured on `sunn-beta-lead`, whose Channel A +15 V rail was
|
|
504
|
+
// declared "generated" by three Channel B op-amps (`IC108A`, `IC111A`, `IC111B`, all on
|
|
505
|
+
// nodes 22/21) reached through a protection diode from an op-amp output to the rail --
|
|
506
|
+
// `CR103`, whose cathode sits on +15 and which can only ever dump *into* that rail. Both
|
|
507
|
+
// of that packet's +15 V rail ports were dropped, its supplies floated, and its solve ran
|
|
508
|
+
// away to 1e283 V. The unconstrained walk above still runs first, so a rail genuinely fed
|
|
509
|
+
// by a real source is still correctly left unforced.
|
|
510
|
+
if (opampSupplyNodes.has(from)) {
|
|
511
|
+
return false;
|
|
512
|
+
}
|
|
513
|
+
// **No op-amp input generates the reference it is handed** -- the same argument one line
|
|
514
|
+
// up, on the other side of the part. See `opampInputNodes` for the two packets this was
|
|
515
|
+
// measured on and for why it cannot overrule a real source.
|
|
516
|
+
//
|
|
517
|
+
// **Unless an op-amp output is on that same node**, which a unity-gain follower always
|
|
518
|
+
// puts there: its output *is* its inverting input. Without this second clause the rule
|
|
519
|
+
// reintroduced the exact regression `opampOutputRoles` records --
|
|
520
|
+
// `boss-bd-2-blues-driver` and its Keeley mod each raising `node-driven-by-two-sources`
|
|
521
|
+
// on node 5, where a promoted rail met an op-amp output that genuinely drives it. An
|
|
522
|
+
// output forces its node whatever else sits on it, so it wins over this clause.
|
|
523
|
+
if (opampInputNodes.has(from) && !opampSelfTerminals.has(from)) {
|
|
524
|
+
return false;
|
|
525
|
+
}
|
|
526
|
+
// Each op-amp output is checked on its own, excluding only *that* op-amp's own other
|
|
527
|
+
// terminals -- see the docstring above `voltagePortRails`. Skipped entirely when the
|
|
528
|
+
// unconstrained walk never reaches the output at all, which is the common case.
|
|
529
|
+
for (const [output, ownTerminals] of opampSelfTerminals) {
|
|
530
|
+
if (!unconstrained.has(output)) {
|
|
531
|
+
continue;
|
|
532
|
+
}
|
|
533
|
+
if (reachableWithout(from, ownTerminals).has(output)) {
|
|
534
|
+
return true;
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
return false;
|
|
538
|
+
};
|
|
539
|
+
const added = [];
|
|
540
|
+
const claimed = new Set();
|
|
541
|
+
for (const component of components) {
|
|
542
|
+
if (String(component.kind) !== "port" || component.terminals.length !== 1) {
|
|
543
|
+
continue;
|
|
544
|
+
}
|
|
545
|
+
const volts = optionalQuantity(component, "Voltage");
|
|
546
|
+
if (volts === null) {
|
|
547
|
+
continue;
|
|
548
|
+
}
|
|
549
|
+
const terminal = component.terminals[0];
|
|
550
|
+
if (terminal === undefined) {
|
|
551
|
+
continue;
|
|
552
|
+
}
|
|
553
|
+
const node = nodeOf(String(component.id), String(terminal.name), 0);
|
|
554
|
+
if (node === null || node === 0 || claimed.has(node) || generated(node)) {
|
|
555
|
+
continue;
|
|
556
|
+
}
|
|
557
|
+
claimed.add(node);
|
|
558
|
+
added.push({
|
|
559
|
+
id: String(component.id),
|
|
560
|
+
kind: "rail",
|
|
561
|
+
nodes: [node],
|
|
562
|
+
parameters: { volts },
|
|
563
|
+
control: null,
|
|
564
|
+
identity: {
|
|
565
|
+
partNumber: propertyString(component, "PartNumber"),
|
|
566
|
+
declaredType: typeof component.sourceTypeName === "string" &&
|
|
567
|
+
component.sourceTypeName.length > 0
|
|
568
|
+
? component.sourceTypeName
|
|
569
|
+
: null,
|
|
570
|
+
terminalRoles: [terminalRoleToken(String(terminal.name))],
|
|
571
|
+
declaredTerminalRoles: [declaredRole(terminal)],
|
|
572
|
+
declaredWindings: null,
|
|
573
|
+
},
|
|
574
|
+
});
|
|
575
|
+
}
|
|
576
|
+
return added;
|
|
577
|
+
}
|
|
578
|
+
const parameterKeyByDeviceKind = {
|
|
579
|
+
resistor: "Resistance",
|
|
580
|
+
capacitor: "Capacitance",
|
|
581
|
+
inductor: "Inductance",
|
|
582
|
+
potentiometer: "Resistance",
|
|
583
|
+
rheostat: "Resistance",
|
|
584
|
+
"voltage-source": "Voltage",
|
|
585
|
+
transformer: "Ratio",
|
|
586
|
+
};
|
|
587
|
+
/**
|
|
588
|
+
* Second spellings of a value property, per kind, tried only after the primary is absent.
|
|
589
|
+
*
|
|
590
|
+
* **Per kind and not globally, because `R` is not a resistance.** The corpus uses it as a generic
|
|
591
|
+
* printed-value field: 196 resistors state their resistance there (`"470 ohm"`, `"10k"`), and so do
|
|
592
|
+
* 126 potentiometers, but 51 capacitors put a *capacitance* in it (`"100/16"`), 13 ICs a part
|
|
593
|
+
* designation (`"1/2NE570"`), and 7 transistors a part number (`"2SC2458GR"`). Reading `R` wherever
|
|
594
|
+
* it appears would be wrong for 86 of the 408 components carrying it — a part number parsed as ohms,
|
|
595
|
+
* which is the wrong-answer-that-still-renders this file exists to avoid.
|
|
596
|
+
*
|
|
597
|
+
* Surfaced by `report-source-coverage.ts`, whose synonym check exists for exactly this: a quantity
|
|
598
|
+
* the source states plainly under a spelling nothing reads, which refuses the component or silently
|
|
599
|
+
* takes a class default.
|
|
600
|
+
*/
|
|
601
|
+
const fallbackParameterKeys = {
|
|
602
|
+
resistor: ["R"],
|
|
603
|
+
potentiometer: ["R"],
|
|
604
|
+
capacitor: ["C"],
|
|
605
|
+
inductor: ["L"],
|
|
606
|
+
};
|
|
607
|
+
const parameterNameByDeviceKind = {
|
|
608
|
+
resistor: "ohms",
|
|
609
|
+
capacitor: "farads",
|
|
610
|
+
inductor: "henries",
|
|
611
|
+
potentiometer: "ohms",
|
|
612
|
+
rheostat: "maxOhms",
|
|
613
|
+
"voltage-source": "volts",
|
|
614
|
+
transformer: "ratio",
|
|
615
|
+
};
|
|
616
|
+
export function readNetlist(source, options) {
|
|
617
|
+
// One parse. core 0.6.26 contract v1 merges the inline terminal `node:` keys and the
|
|
618
|
+
// `nodes:` ledger into a single connectivity, and refuses a document where the two
|
|
619
|
+
// disagree -- so the three recovery passes this stage ran, and the divergence warning that
|
|
620
|
+
// reported the disagreement, have nothing left to do.
|
|
621
|
+
const parsed = parseDocumentWithConnectivity(source);
|
|
622
|
+
// Properties only. The parser still flattens some structured quantities to strings, so this
|
|
623
|
+
// recovery stays until core preserves them everywhere; its node-token half is gone, replaced
|
|
624
|
+
// by the connectivity below.
|
|
625
|
+
const document = repairStructuredProperties(parsed.document, readRawComponents(source));
|
|
626
|
+
const declaredControlIds = declaredControlNames(document);
|
|
627
|
+
// Core's connectivity is authoritative only where the document *declares* it. When core
|
|
628
|
+
// falls back to geometry this stage keeps its own union-find instead: the two geometric
|
|
629
|
+
// resolutions are not the same, and swapping one for the other is a separate change from
|
|
630
|
+
// adopting the declared-connectivity contract.
|
|
631
|
+
const declaredNodes = parsed.connectivitySource === "declared"
|
|
632
|
+
? declaredNodesFromConnectivity(document, parsed.connectivity)
|
|
633
|
+
: new Map();
|
|
634
|
+
const nodeOf = resolveNodes(document, declaredNodes);
|
|
635
|
+
const warnings = [];
|
|
636
|
+
/**
|
|
637
|
+
* **A switch throw or winding tap alone on its node cannot route anything.**
|
|
638
|
+
*
|
|
639
|
+
* `marshall-jcm800`'s output-transformer taps were stranded exactly this way: `secondary_4`
|
|
640
|
+
* and `throw_4` named node ids the document never declared, so each became its own isolated
|
|
641
|
+
* net and the impedance selector had two positions that went nowhere. Three sessions
|
|
642
|
+
* diagnosed the resulting output stage behaviourally.
|
|
643
|
+
*
|
|
644
|
+
* The **general** dangling-terminal rule was measured and rejected first — 2020 findings, no
|
|
645
|
+
* signal-to-noise (`dangling-active-terminal.ts` records it). This is that narrowing applied
|
|
646
|
+
* to routing, and measured the same way: of 1904 single-pin terminals in the corpus, 1858 are
|
|
647
|
+
* spare pins and test points, **25 are throws and 10 are winding taps** — 35 findings across
|
|
648
|
+
* 18 packets, a 54x cut. It also names, for free, bypass footswitches whose throws are
|
|
649
|
+
* unwired (`boss-bf-2`, `boss-ce-2`, `boss-ce-5`, `boss-ds-2`), which is a structural account
|
|
650
|
+
* of packets whose bypass never engages.
|
|
651
|
+
*
|
|
652
|
+
* Selected on the **typed `role` field**, never on the terminal's name: `role: throw` and
|
|
653
|
+
* `role: winding` are what the format states, and a name is prose.
|
|
654
|
+
*/
|
|
655
|
+
if (parsed.connectivitySource === "declared") {
|
|
656
|
+
const memberCount = new Map();
|
|
657
|
+
for (const [node, pins] of parsed.connectivity.nodeMembers) {
|
|
658
|
+
memberCount.set(node, pins.length);
|
|
659
|
+
}
|
|
660
|
+
for (const component of document.components) {
|
|
661
|
+
// A potentiometer terminal alone on its node is broken whichever terminal it is: a
|
|
662
|
+
// divider needs all three joined to something and a rheostat shares two on one
|
|
663
|
+
// node, so neither shape leaves one stranded. Measured over the corpus's 559 pots:
|
|
664
|
+
// 323 dividers, 128 rheostats, and **31 with a terminal alone** -- and those 31 land
|
|
665
|
+
// on packets already flagged behaviourally, `boss-ch-1` (all four, silent),
|
|
666
|
+
// `boss-ce-5` (four, whole panel flat), `boss-mt-2`, `boss-sp-1-spectrum`,
|
|
667
|
+
// `mxr-m117r-flanger` and `earthquaker-devices-plumes`'s Gain.
|
|
668
|
+
const isPot = String(component.kind) === "potentiometer";
|
|
669
|
+
for (const terminal of component.terminals) {
|
|
670
|
+
const role = declaredRole(terminal);
|
|
671
|
+
if (!isPot && role !== "throw" && role !== "winding") {
|
|
672
|
+
continue;
|
|
673
|
+
}
|
|
674
|
+
const node = declaredNodes.get(declaredKey(String(component.id), String(terminal.name)));
|
|
675
|
+
if (node === undefined || node === GROUND) {
|
|
676
|
+
continue;
|
|
677
|
+
}
|
|
678
|
+
if ((memberCount.get(node) ?? 0) > 1) {
|
|
679
|
+
continue;
|
|
680
|
+
}
|
|
681
|
+
warnings.push({
|
|
682
|
+
code: "throw-or-winding-unwired",
|
|
683
|
+
device: String(component.id),
|
|
684
|
+
detail: `${component.id}.${terminal.name} (role ${role ?? "none"}) is alone on node ${node}: ` +
|
|
685
|
+
`a ${role === "throw" ? "switch position that routes nowhere" : isPot ? "potentiometer terminal joined to nothing, so the track cannot divide" : "winding tap nothing can select"}.`,
|
|
686
|
+
});
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
const devices = [];
|
|
691
|
+
const controlIds = new Set();
|
|
692
|
+
const ownerByControl = new Map();
|
|
693
|
+
// Controls whose every device is a two-terminal switch with no authored position,
|
|
694
|
+
// and so are candidates for the port-connectivity default below. A control that
|
|
695
|
+
// also drives anything else -- a pot, a selector, a switch that states a position
|
|
696
|
+
// -- is disqualified, because the source has spoken for it.
|
|
697
|
+
const unpositionedSwitchControls = new Set();
|
|
698
|
+
const positionedControls = new Set();
|
|
699
|
+
for (const component of document.components) {
|
|
700
|
+
const componentKind = String(component.kind);
|
|
701
|
+
if (ignoredComponentKinds.has(componentKind)) {
|
|
702
|
+
// `unsupported` is a modeled device class the format has a typed entry for but no
|
|
703
|
+
// solver law; `sourceTypeName` carries the source's own name for it. Other ignored
|
|
704
|
+
// kinds (label, port, etc.) are structural noise and do not name a device, so they
|
|
705
|
+
// stay silently dropped.
|
|
706
|
+
if (componentKind === "unsupported") {
|
|
707
|
+
const typeName = typeof component.sourceTypeName === "string" &&
|
|
708
|
+
component.sourceTypeName.length > 0
|
|
709
|
+
? ` (${component.sourceTypeName})`
|
|
710
|
+
: "";
|
|
711
|
+
warnings.push({
|
|
712
|
+
code: "unsupported-component",
|
|
713
|
+
device: String(component.id),
|
|
714
|
+
detail: `Component ${component.id} is declared as an unsupported device class${typeName} and is excluded from the solve.`,
|
|
715
|
+
});
|
|
716
|
+
}
|
|
717
|
+
continue;
|
|
718
|
+
}
|
|
719
|
+
// The source marks this component interface or source context, not modeled graph. v1 gives
|
|
720
|
+
// it an interface-only / source-only disposition that keeps it out of the electrical
|
|
721
|
+
// analysis; v2 reads the same typed flag and drops it, so an open placeholder the source
|
|
722
|
+
// says not to model (schaller-tremolo's R_LED, InterfaceOnly and Value: open) stops
|
|
723
|
+
// refusing the pedal for a value the source deliberately omits.
|
|
724
|
+
if (isInterfaceOrSourceOnly(component)) {
|
|
725
|
+
warnings.push({
|
|
726
|
+
code: "interface-or-source-only",
|
|
727
|
+
device: String(component.id),
|
|
728
|
+
detail: `Component ${component.id} (${componentKind}) is declared interface-only or source-only and is excluded from the solve.`,
|
|
729
|
+
});
|
|
730
|
+
continue;
|
|
731
|
+
}
|
|
732
|
+
// An unpopulated position is an open circuit, so it is dropped for the same reason and
|
|
733
|
+
// by the same mechanism -- but under its own code, because "nothing is fitted here" and
|
|
734
|
+
// "this is interface context" are different facts about a document.
|
|
735
|
+
if (isNotPopulated(component)) {
|
|
736
|
+
// **Say whether this DNP is load-bearing**, i.e. whether dropping the component is
|
|
737
|
+
// the only reason the document compiles. See `NotPopulatedWithoutValueWarning`: a
|
|
738
|
+
// part that declares its value and is also marked DNP conceals nothing, while one
|
|
739
|
+
// that declares no value would have refused by name without the mark. Both are
|
|
740
|
+
// dropped -- an unpopulated position is an open circuit either way -- but only one
|
|
741
|
+
// of them is carrying the compile, and a reader should be told which.
|
|
742
|
+
//
|
|
743
|
+
// Read from the value property alone. Provenance fields (`SourceStatus`,
|
|
744
|
+
// `SourceConfidence`) would say more, and `report-source-integrity.ts` does read
|
|
745
|
+
// them to catch a `DNP` asserted over a deferred decision -- but they are source-lane
|
|
746
|
+
// metadata and lowering must not start branching on them.
|
|
747
|
+
const valueKey = parameterKeyByDeviceKind[componentKind];
|
|
748
|
+
const declaresValue = valueKey === undefined ||
|
|
749
|
+
propertyQuantityText(component, valueKey) !== null ||
|
|
750
|
+
(fallbackParameterKeys[componentKind] ?? []).some((fallback) => propertyQuantityText(component, fallback) !== null);
|
|
751
|
+
warnings.push(declaresValue
|
|
752
|
+
? {
|
|
753
|
+
code: "not-populated",
|
|
754
|
+
device: String(component.id),
|
|
755
|
+
detail: `Component ${component.id} (${componentKind}) is declared DNP and is excluded from the solve as an unpopulated, open position. It declares its value, so what would sit there is still on the record.`,
|
|
756
|
+
}
|
|
757
|
+
: {
|
|
758
|
+
code: "not-populated-without-value",
|
|
759
|
+
device: String(component.id),
|
|
760
|
+
detail: `Component ${component.id} (${componentKind}) is declared DNP and declares no ${valueKey}, so dropping it is what allows this document to compile: without the DNP it would refuse by name. That is what a real unpopulated position looks like, and also what a guess looks like -- check it against the board.`,
|
|
761
|
+
});
|
|
762
|
+
continue;
|
|
763
|
+
}
|
|
764
|
+
// A component declaring no terminals is connected to nothing, so it has no
|
|
765
|
+
// nodes and cannot contribute a row or a column to any matrix. Dropping it
|
|
766
|
+
// removes nothing electrical, *by construction* -- which is the whole reason
|
|
767
|
+
// this is safe where silently dropping a connected inductor was not.
|
|
768
|
+
//
|
|
769
|
+
// The corpus is full of these: panel LEDs, footswitches from a photo, bias
|
|
770
|
+
// markers, pots from a build document. Refusing a pedal over an unconnected
|
|
771
|
+
// panel LED would be strictness with no purpose. Note that the decision is the
|
|
772
|
+
// empty terminal list and never the component's name or description, both of
|
|
773
|
+
// which say "photo" and "marker" here and must stay unread.
|
|
774
|
+
if (component.terminals.length === 0) {
|
|
775
|
+
if (Object.hasOwn(deviceKindByComponentKind, componentKind)) {
|
|
776
|
+
warnings.push({
|
|
777
|
+
code: "unconnected-behavior-component",
|
|
778
|
+
device: String(component.id),
|
|
779
|
+
detail: `Component ${component.id} (${component.kind}) is a behavior-owning part but declares 0 connected terminals and is skipped from MNA modeling.`,
|
|
780
|
+
});
|
|
781
|
+
}
|
|
782
|
+
continue;
|
|
783
|
+
}
|
|
784
|
+
const kind = deviceKindByComponentKind[componentKind];
|
|
785
|
+
if (kind === undefined) {
|
|
786
|
+
throw new NetlistError(`component ${component.id}: unsupported component kind "${componentKind}"`, String(component.id));
|
|
787
|
+
}
|
|
788
|
+
const rawNodes = component.terminals.map((terminal, index) => nodeOf(component.id, terminal.name, index));
|
|
789
|
+
const connectedIndices = [];
|
|
790
|
+
for (let i = 0; i < rawNodes.length; i++) {
|
|
791
|
+
if (rawNodes[i] !== null) {
|
|
792
|
+
connectedIndices.push(i);
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
if (connectedIndices.length === 0) {
|
|
796
|
+
if (Object.hasOwn(deviceKindByComponentKind, componentKind)) {
|
|
797
|
+
warnings.push({
|
|
798
|
+
code: "unconnected-behavior-component",
|
|
799
|
+
device: String(component.id),
|
|
800
|
+
detail: `Component ${component.id} (${component.kind}) is a behavior-owning part but declares 0 connected terminals and is skipped from MNA modeling.`,
|
|
801
|
+
});
|
|
802
|
+
}
|
|
803
|
+
continue;
|
|
804
|
+
}
|
|
805
|
+
if (component.terminals.length >= 2 &&
|
|
806
|
+
connectedIndices.length === 1 &&
|
|
807
|
+
kind !== "ic") {
|
|
808
|
+
if (Object.hasOwn(deviceKindByComponentKind, componentKind)) {
|
|
809
|
+
warnings.push({
|
|
810
|
+
code: "unconnected-behavior-component",
|
|
811
|
+
device: String(component.id),
|
|
812
|
+
detail: `Component ${component.id} (${component.kind}) has only 1 connected terminal and is skipped from MNA modeling.`,
|
|
813
|
+
});
|
|
814
|
+
}
|
|
815
|
+
continue;
|
|
816
|
+
}
|
|
817
|
+
let effectiveTerminals = component.terminals;
|
|
818
|
+
let nodes;
|
|
819
|
+
if (connectedIndices.length < component.terminals.length) {
|
|
820
|
+
if (kind === "ic" && connectedIndices.length >= 2) {
|
|
821
|
+
effectiveTerminals = connectedIndices.map((i) => component.terminals[i]);
|
|
822
|
+
nodes = connectedIndices.map((i) => rawNodes[i]);
|
|
823
|
+
}
|
|
824
|
+
else {
|
|
825
|
+
const missingIndex = rawNodes.findIndex((n) => n === null);
|
|
826
|
+
const missingTerminal = component.terminals[missingIndex];
|
|
827
|
+
throw new NetlistError(`component ${component.id}: terminal "${missingTerminal?.name}" has no declared node and no wire`, String(component.id));
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
else {
|
|
831
|
+
nodes = rawNodes;
|
|
832
|
+
}
|
|
833
|
+
// **A switch whose every connected terminal resolves to one node is a fixed
|
|
834
|
+
// bridge, not a variable** — its pole and throw are the same wire, so no
|
|
835
|
+
// position can change the circuit and exposing a control for it is noise.
|
|
836
|
+
// `boss-od-1`'s `INPUT_BATTERY_CONTACT` is the corpus case: a two-terminal
|
|
837
|
+
// contact with both ends on node 0, offered to the player as a knob to twist.
|
|
838
|
+
// Connectivity-only evidence (`new Set(nodes).size === 1`), because the
|
|
839
|
+
// document gives the device no role to refuse by. Measured across the corpus,
|
|
840
|
+
// four other switches sit on a single node and none of them gains a control
|
|
841
|
+
// from it: two are already unbound and the other two are declared controls,
|
|
842
|
+
// which bind before this gate is reached.
|
|
843
|
+
const fixedContactSwitch = kind === "switch" &&
|
|
844
|
+
nodes.length > 0 &&
|
|
845
|
+
new Set(nodes).size === 1;
|
|
846
|
+
const control = controlBindingFor(component, declaredControlIds, kind, fixedContactSwitch);
|
|
847
|
+
if (control !== null) {
|
|
848
|
+
controlIds.add(control);
|
|
849
|
+
if (kind === "switch" &&
|
|
850
|
+
nodes.length === 2 &&
|
|
851
|
+
!statesPosition(component)) {
|
|
852
|
+
unpositionedSwitchControls.add(control);
|
|
853
|
+
}
|
|
854
|
+
else {
|
|
855
|
+
positionedControls.add(control);
|
|
856
|
+
}
|
|
857
|
+
// The first device bound to a control supplies the taper and wiper position
|
|
858
|
+
// the control itself may not state. On a ganged control both devices carry
|
|
859
|
+
// the same values, so first is enough.
|
|
860
|
+
if (!ownerByControl.has(control)) {
|
|
861
|
+
ownerByControl.set(control, component);
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
const identityOf = (terminals, roles) => ({
|
|
865
|
+
partNumber: propertyString(component, "PartNumber"),
|
|
866
|
+
declaredType: typeof component.sourceTypeName === "string" &&
|
|
867
|
+
component.sourceTypeName.length > 0
|
|
868
|
+
? component.sourceTypeName
|
|
869
|
+
: null,
|
|
870
|
+
terminalRoles: terminals.map((terminal) => terminalRoleToken(String(terminal.name))),
|
|
871
|
+
declaredTerminalRoles: roles,
|
|
872
|
+
declaredWindings: declaredWindings(component, terminals),
|
|
873
|
+
});
|
|
874
|
+
// **A package that states its own devices is split here, from the source.** A dual op-amp
|
|
875
|
+
// is two amplifiers on one supply pair, and until now the only thing that knew so was a
|
|
876
|
+
// registry `sections` entry keyed on the part id -- which means a chip nobody registered
|
|
877
|
+
// could not be split at all, and a registered one was split by a *name signature*
|
|
878
|
+
// (`pinoutMatches` compares folded terminal names positionally). `componentDevices`
|
|
879
|
+
// reads the split the document declares, so it needs neither.
|
|
880
|
+
//
|
|
881
|
+
// Roles are read per device and that is the whole point: a dual op-amp declares two
|
|
882
|
+
// `output`s across its package and neither is ambiguous once scoped to one amplifier.
|
|
883
|
+
// `deviceTerminalRoles` is what scopes it; core validates the claim against the
|
|
884
|
+
// *device's* kind, so an `ic` package may legitimately carry `opamp` devices.
|
|
885
|
+
//
|
|
886
|
+
// **Known limitation, measured rather than assumed: a source-declared split takes the
|
|
887
|
+
// device-class law, not the part's numbers.** `boss-ch-1`'s M5218 split this way produces
|
|
888
|
+
// a byte-identical topology -- same 151 stamps, same plus/minus/output nodes, same rails
|
|
889
|
+
// -- with one difference: `openLoopGain` falls from the registry's 316227.766 (the part's
|
|
890
|
+
// 110 dB) to `DEFAULT_OPEN_LOOP_GAIN`. The two mechanisms should compose, with the source
|
|
891
|
+
// deciding the terminals and the registry deciding the numbers, and until they do a
|
|
892
|
+
// packet that declares `devices` for a *registered* part trades a real gain figure for a
|
|
893
|
+
// class default. No corpus packet declares `devices` yet, so nothing is affected today.
|
|
894
|
+
// **Declared, not merely resolved.** `componentDevices` returns one device for a component
|
|
895
|
+
// that declares none, which is the overwhelming majority, so counting its result cannot
|
|
896
|
+
// tell a package that states a single device from one that states nothing. A single-device
|
|
897
|
+
// declaration is a real statement -- a TA7504S is one amplifier in an eight-pin package,
|
|
898
|
+
// and saying which three pins it uses is exactly the fact the binding was missing.
|
|
899
|
+
const declaredDevices = component.devices !== undefined && component.devices.length > 0
|
|
900
|
+
? componentDevices(component)
|
|
901
|
+
: [];
|
|
902
|
+
if (declaredDevices.length > 0) {
|
|
903
|
+
const terminalByName = new Map(effectiveTerminals.map((terminal) => [String(terminal.name), terminal]));
|
|
904
|
+
const nodeByName = new Map(effectiveTerminals.map((terminal, index) => [
|
|
905
|
+
String(terminal.name),
|
|
906
|
+
nodes[index],
|
|
907
|
+
]));
|
|
908
|
+
for (const declared of declaredDevices) {
|
|
909
|
+
const roleByName = deviceTerminalRoles(component, declared);
|
|
910
|
+
const owned = declared.terminals.flatMap((name) => {
|
|
911
|
+
const terminal = terminalByName.get(name);
|
|
912
|
+
const node = nodeByName.get(name);
|
|
913
|
+
// A terminal the device names but this component does not connect is
|
|
914
|
+
// dropped rather than guessed at. `validateComponentDevices` is what
|
|
915
|
+
// reports a declaration naming a terminal that does not exist at all.
|
|
916
|
+
return terminal !== undefined && node !== undefined
|
|
917
|
+
? [{ terminal, node, name }]
|
|
918
|
+
: [];
|
|
919
|
+
});
|
|
920
|
+
if (owned.length === 0) {
|
|
921
|
+
continue;
|
|
922
|
+
}
|
|
923
|
+
const deviceKind = deviceKindByComponentKind[declared.kind];
|
|
924
|
+
if (deviceKind === undefined) {
|
|
925
|
+
throw new NetlistError(`component ${component.id} device ${declared.id}: unsupported kind "${declared.kind}"`, String(component.id));
|
|
926
|
+
}
|
|
927
|
+
devices.push({
|
|
928
|
+
// Core's address for a device inside a package, and deliberately not the
|
|
929
|
+
// `#N` a registry section uses: these two splits must stay tellable apart.
|
|
930
|
+
id: `${component.id}.${declared.id}`,
|
|
931
|
+
packageDeviceId: declared.id,
|
|
932
|
+
kind: deviceKind,
|
|
933
|
+
nodes: owned.map((entry) => entry.node),
|
|
934
|
+
parameters: parametersFor(component, deviceKind),
|
|
935
|
+
// The control belongs to the package, so only the first device carries it.
|
|
936
|
+
// A ganged part states the same control on both and the owner map already
|
|
937
|
+
// keeps the first.
|
|
938
|
+
control: declared.id === declaredDevices[0]?.id ? control : null,
|
|
939
|
+
identity: identityOf(owned.map((entry) => entry.terminal), owned.map((entry) => roleByName.get(entry.name) ?? null)),
|
|
940
|
+
isLed: declared.kind === "led" ? true : undefined,
|
|
941
|
+
});
|
|
942
|
+
}
|
|
943
|
+
continue;
|
|
944
|
+
}
|
|
945
|
+
devices.push({
|
|
946
|
+
id: String(component.id),
|
|
947
|
+
kind,
|
|
948
|
+
nodes,
|
|
949
|
+
parameters: parametersFor(component, kind),
|
|
950
|
+
control,
|
|
951
|
+
identity: identityOf(effectiveTerminals, effectiveTerminals.map((terminal) => declaredRole(terminal))),
|
|
952
|
+
isLed: componentKind === "led" ? true : undefined,
|
|
953
|
+
program: declaredProgram(component),
|
|
954
|
+
});
|
|
955
|
+
}
|
|
956
|
+
devices.push(...voltagePortRails(document.components, devices, nodeOf));
|
|
957
|
+
// A panel control states its taper only sometimes; the pot it drives states one far
|
|
958
|
+
// more often. Taking the panel's silence as "linear" discards a taper the document
|
|
959
|
+
// gives -- an audio knob rendered linear feels like a different pedal -- so the
|
|
960
|
+
// declaration wins and the component fills the gap.
|
|
961
|
+
//
|
|
962
|
+
// A device bound to no panel control is an internal trimmer or a fixed switch:
|
|
963
|
+
// still adjustable, just not on the enclosure. It gets a control of its own rather
|
|
964
|
+
// than refusing the pedal.
|
|
965
|
+
const userControls = (document.deviceInterface?.controls ?? []).filter(isUserPanelControl);
|
|
966
|
+
const declaredTapers = new Map(userControls.map((control) => [String(control.id), taperFor(control)]));
|
|
967
|
+
// The panel's own role for each control, carried verbatim. Same shape as the tapers above:
|
|
968
|
+
// the declaration is the only source, and absence is `null` rather than a guess.
|
|
969
|
+
const declaredRoles = new Map(userControls.map((control) => [String(control.id), roleFor(control)]));
|
|
970
|
+
const declaredLabels = new Map(userControls.map((control) => [
|
|
971
|
+
String(control.id),
|
|
972
|
+
typeof control.label === "string" &&
|
|
973
|
+
String(control.label).trim().length > 0
|
|
974
|
+
? String(control.label).trim()
|
|
975
|
+
: null,
|
|
976
|
+
]));
|
|
977
|
+
// Where the panel says its knobs ship set, which is a different claim from where a
|
|
978
|
+
// component says its wiper physically sits.
|
|
979
|
+
//
|
|
980
|
+
// **This block was read for `taper` and `role` and not for `defaultPosition`, and one
|
|
981
|
+
// packet paid for it in full.** `mxr-carbon-copy` declares MIX 0.75, REGEN 0.6 and DELAY
|
|
982
|
+
// 1.0 here; every render of it took 0.5 for all three, because the only position source
|
|
983
|
+
// below was `declaredPosition(owner)`, which reads the *component's* `Wipe`/`Position`.
|
|
984
|
+
// MIX is a source-declared logarithmic pot, so 0.5 is 10% of its track against 32.5% at
|
|
985
|
+
// the declared 0.75 -- 10 dB thrown away on the wet leg of a delay whose echo was then
|
|
986
|
+
// investigated as missing. It is the only packet in the corpus that declares these, and
|
|
987
|
+
// `report-source-coverage.ts` cannot see the omission because it scans component
|
|
988
|
+
// properties and this is not one.
|
|
989
|
+
//
|
|
990
|
+
// Read from the raw YAML rather than the parsed document because the format library keeps
|
|
991
|
+
// `id`/`label`/`kind`/`role`/`taper` off a control entry and drops exactly this field --
|
|
992
|
+
// see `readRawControlPositions`.
|
|
993
|
+
const declaredPositions = readRawControlPositions(source);
|
|
994
|
+
const declaredDiscretePositions = readRawControlDiscretePositions(source);
|
|
995
|
+
// Panel controls keep the order the document declares them in, which is the order
|
|
996
|
+
// they sit on the enclosure; the invented ones follow. Order is not electrical, but
|
|
997
|
+
// it is what a panel reads off, so it should not depend on which device happened to
|
|
998
|
+
// bind first.
|
|
999
|
+
// **A scanned control is admitted although no device is bound to it**, and it has to be.
|
|
1000
|
+
// A control reaches the program one of two ways: by varying a device the solver stamps, or
|
|
1001
|
+
// by being read directly because the path to the chip is not modelled. Only the first leaves
|
|
1002
|
+
// a bound device behind, so the second would vanish here -- and then `setControl` would move
|
|
1003
|
+
// nothing, the panel would show no knob, and the program that names it could never be
|
|
1004
|
+
// selected. Core has already checked that the component doing the reading exists.
|
|
1005
|
+
const tappedControls = tappedControlIds(document);
|
|
1006
|
+
const tapLaws = tapLawsByControl(document);
|
|
1007
|
+
// Any declared panel control qualifies here, whatever its kind: a program that says a chip
|
|
1008
|
+
// reads it (scanned) or times it (tapped) is the evidence that it is played, which a jack you
|
|
1009
|
+
// tap into is, even though a jack is not a knob-style user panel control.
|
|
1010
|
+
const anyDeclaredControlById = new Map((document.deviceInterface?.controls ?? []).map((control) => [String(control.id), control]));
|
|
1011
|
+
const anyDeclaredControl = new Set(anyDeclaredControlById.keys());
|
|
1012
|
+
const controllers = declaredControllers(document, nodeOf);
|
|
1013
|
+
const latchToggles = new Set(controllers.latches.map((latch) => latch.toggledBy));
|
|
1014
|
+
const pinControls = controllers.pins.flatMap((pin) => (pin.highAt === undefined ? [] : [pin.highAt.control]));
|
|
1015
|
+
const chipReadControls = new Set([...scannedControls(document), ...latchToggles, ...pinControls]);
|
|
1016
|
+
for (const control of chipReadControls) {
|
|
1017
|
+
if (declaredControlIds.has(control) ||
|
|
1018
|
+
declaredTapers.has(control) ||
|
|
1019
|
+
anyDeclaredControl.has(control)) {
|
|
1020
|
+
controlIds.add(control);
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
const orderedIds = [
|
|
1024
|
+
...declaredTapers.keys(),
|
|
1025
|
+
...[...controlIds].filter((id) => !declaredTapers.has(id)),
|
|
1026
|
+
].filter((id) => controlIds.has(id));
|
|
1027
|
+
// Ports are resolved before controls because the unpositioned-switch default is a
|
|
1028
|
+
// question about the path between them.
|
|
1029
|
+
const ports = resolvePorts(document, devices, options);
|
|
1030
|
+
// A document that declares a transformer but resolves its output to a node no winding
|
|
1031
|
+
// touches is being measured **before its power stage**, which is how an amp's render reads
|
|
1032
|
+
// as a plausible level that is really a preamp or phase-inverter tap. Measured 2026-08-21:
|
|
1033
|
+
// five of six amps were in exactly that state, and `resolvePorts` now prefers the
|
|
1034
|
+
// transformer-coupled jack, so what reaches here is a document that declares no such jack
|
|
1035
|
+
// at all -- `mesa-boogie-mark-v` declares three monitor taps and no speaker load. Naming
|
|
1036
|
+
// it keeps that a stated boundary instead of a flattering number.
|
|
1037
|
+
// Keyed on an **output** transformer -- one declaring a `ratio` -- for the same reason
|
|
1038
|
+
// `resolvePorts` prefers one: a mains transformer's windings say nothing about where the
|
|
1039
|
+
// audio output is. Both conditions here are typed parameters compared whole.
|
|
1040
|
+
const outputTransformers = devices.filter((device) => device.kind === "transformer" &&
|
|
1041
|
+
typeof device.parameters.ratio === "number");
|
|
1042
|
+
if (outputTransformers.length > 0) {
|
|
1043
|
+
// **The same reachability `outputJackLoads` uses, not a stricter copy.** This test used to
|
|
1044
|
+
// require the output port to sit *directly* on a winding, while the port preference beside it
|
|
1045
|
+
// already followed series passives -- so a jack behind a secondary winding resistance, which
|
|
1046
|
+
// is exactly what Stage C models, warned about its own speaker output. `hiwatt-dr103` is the
|
|
1047
|
+
// selector case: its port is the switch common, one hop past three taps that are all on the
|
|
1048
|
+
// winding. Two tests of one question that disagree is how a packet gets told its speaker is a
|
|
1049
|
+
// preamp tap.
|
|
1050
|
+
const windingNodes = transformerCoupledNodes(devices);
|
|
1051
|
+
if (!windingNodes.has(ports.output)) {
|
|
1052
|
+
const owner = devices.find((device) => device.kind === "jack" && device.nodes.includes(ports.output));
|
|
1053
|
+
warnings.push({
|
|
1054
|
+
code: "output-port-not-transformer-coupled",
|
|
1055
|
+
device: owner === undefined ? null : String(owner.id),
|
|
1056
|
+
detail: `the output port is node ${ports.output}` +
|
|
1057
|
+
(owner === undefined ? "" : ` (${owner.id})`) +
|
|
1058
|
+
", which no transformer winding touches, while this document declares a " +
|
|
1059
|
+
"transformer: the render is measured upstream of the output transformer, so " +
|
|
1060
|
+
"its level is a tap rather than a speaker output",
|
|
1061
|
+
});
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
for (const id of positionedControls) {
|
|
1065
|
+
unpositionedSwitchControls.delete(id);
|
|
1066
|
+
}
|
|
1067
|
+
const closedForPortPath = switchesClosedForPortPath(devices, ports, unpositionedSwitchControls);
|
|
1068
|
+
const closedForSupplyPath = switchesClosedForSupplyPath(devices, ports, unpositionedSwitchControls);
|
|
1069
|
+
// The bypass declaration, parsed once for the control defaults below and the
|
|
1070
|
+
// bypass record at the end (same shape, same validation -- see
|
|
1071
|
+
// parseBypassDeclaration).
|
|
1072
|
+
const bypassDeclaration = parseBypassDeclaration(document);
|
|
1073
|
+
const controls = orderedIds.map((id) => {
|
|
1074
|
+
const owner = ownerByControl.get(id);
|
|
1075
|
+
// The panel's declaration wins over both the connectivity fallback (a guess about an
|
|
1076
|
+
// unpositioned switch) and the component's own `Wipe` -- but a disagreement between the
|
|
1077
|
+
// two authored values is a contradiction in the source, not a precedence question, so it
|
|
1078
|
+
// is named rather than silently resolved. An internal trimmer the panel never declares is
|
|
1079
|
+
// unaffected: it has no entry here and keeps `declaredPosition(owner)`.
|
|
1080
|
+
const panelPosition = declaredPositions.get(id);
|
|
1081
|
+
if (panelPosition !== undefined &&
|
|
1082
|
+
owner !== undefined &&
|
|
1083
|
+
statesPosition(owner) &&
|
|
1084
|
+
declaredPosition(owner) !== panelPosition) {
|
|
1085
|
+
warnings.push({
|
|
1086
|
+
code: "control-position-disagrees",
|
|
1087
|
+
device: String(owner.id),
|
|
1088
|
+
detail: `Control ${id}: deviceInterface declares defaultPosition ${panelPosition} but component ${owner.id} states position ${declaredPosition(owner)}; the panel declaration is used.`,
|
|
1089
|
+
});
|
|
1090
|
+
}
|
|
1091
|
+
// The panel's own `taper` wins where it declares one; otherwise the owning component's,
|
|
1092
|
+
// and a component whose declaration this stage cannot execute is named rather than
|
|
1093
|
+
// quietly rendered linear.
|
|
1094
|
+
const componentTaper = owner === undefined ? null : declaredTaper(owner);
|
|
1095
|
+
if (owner !== undefined && componentTaper?.unreadable != null) {
|
|
1096
|
+
warnings.push({
|
|
1097
|
+
code: "taper-not-executable",
|
|
1098
|
+
device: owner.id,
|
|
1099
|
+
declared: componentTaper.unreadable,
|
|
1100
|
+
detail: `Control ${id}: ${owner.id} declares taper "${componentTaper.unreadable}", which ` +
|
|
1101
|
+
"this stage cannot turn into one of its four laws, so the control renders " +
|
|
1102
|
+
"linear. An audio pot rendered linear is audible across the whole sweep.",
|
|
1103
|
+
});
|
|
1104
|
+
}
|
|
1105
|
+
const discretePositions = discretePositionsForControl(id, owner, document, declaredDiscretePositions);
|
|
1106
|
+
// Only for a control a chip reads (scanned or tapped): a jack you tap into is not a
|
|
1107
|
+
// knob-style panel control, so its label and role come from its own declaration. Every
|
|
1108
|
+
// other control keeps the knob-style reading, so this changes nothing else's role.
|
|
1109
|
+
const declaredAny = chipReadControls.has(id) ? anyDeclaredControlById.get(id) : undefined;
|
|
1110
|
+
// A spring-return contact rests released. Derived like `momentary` itself, so the
|
|
1111
|
+
// first press is a rising edge rather than a 0.5 -> 1 step nothing counts.
|
|
1112
|
+
// The one exception is the bypass switch itself when the document declares
|
|
1113
|
+
// it engaged at position 1: every fallback below would rest it at 0, so
|
|
1114
|
+
// every default render plays bypass while the product shows the pedal
|
|
1115
|
+
// engaged. The override is fenced on both sides. It applies only to a
|
|
1116
|
+
// maintained switch: a momentary or latch-toggled contact declaring 1
|
|
1117
|
+
// would rest "held pressed", and its first press would produce no rising
|
|
1118
|
+
// edge -- so those keep 0 and are named by the warning below instead.
|
|
1119
|
+
// And a declared 0 overrides only a default that is no position at all:
|
|
1120
|
+
// half travel on a two-position switch is the boundary between its
|
|
1121
|
+
// throws, which is what an unresolvable stated position falls to
|
|
1122
|
+
// (`boss-ce-2`'s one-throw contact states `Position: Effect` against
|
|
1123
|
+
// `Options: Normal,Effect`, which names no throw, and rested at 0.5,
|
|
1124
|
+
// which the runtime plays as the NORMAL throw). A derived real position
|
|
1125
|
+
// keeps winning over a declared 0: `boss-ph-1r` declares 0 while its
|
|
1126
|
+
// CHECK LED lights at latch 1, so its declaration is not evidence.
|
|
1127
|
+
// Owner-matched, not id-matched: the control id and the switch device
|
|
1128
|
+
// id coincide in some packets and differ in others.
|
|
1129
|
+
const isBypassSwitch = owner !== undefined &&
|
|
1130
|
+
bypassDeclaration.switchId !== null &&
|
|
1131
|
+
String(owner.id) === bypassDeclaration.switchId;
|
|
1132
|
+
const isMomentary = tappedControls.has(id) || latchToggles.has(id);
|
|
1133
|
+
const baseDefault = isMomentary
|
|
1134
|
+
? 0
|
|
1135
|
+
: (panelPosition ??
|
|
1136
|
+
(closedForPortPath.has(id) || closedForSupplyPath.has(id)
|
|
1137
|
+
? 1
|
|
1138
|
+
: owner === undefined
|
|
1139
|
+
? 0.5
|
|
1140
|
+
: declaredPosition(owner)));
|
|
1141
|
+
const engagedOverride = !isBypassSwitch || isMomentary || bypassDeclaration.engaged === null
|
|
1142
|
+
? null
|
|
1143
|
+
: bypassDeclaration.engaged === 1 ||
|
|
1144
|
+
(discretePositions === 2 && baseDefault === 0.5)
|
|
1145
|
+
? bypassDeclaration.engaged
|
|
1146
|
+
: null;
|
|
1147
|
+
const defaultPosition = engagedOverride ?? baseDefault;
|
|
1148
|
+
// A declared engaged position the default does not honor is a product
|
|
1149
|
+
// defect either way (shows engaged while rendering dry, or the reverse),
|
|
1150
|
+
// so it is named rather than silently kept or silently dropped. The one
|
|
1151
|
+
// silent case is the override above doing its job.
|
|
1152
|
+
if (isBypassSwitch &&
|
|
1153
|
+
bypassDeclaration.engaged !== null &&
|
|
1154
|
+
defaultPosition !== bypassDeclaration.engaged) {
|
|
1155
|
+
warnings.push({
|
|
1156
|
+
code: "bypass-engaged-disagrees",
|
|
1157
|
+
device: owner !== undefined ? owner.id : null,
|
|
1158
|
+
detail: `Control ${id}: audio.bypass declares engagedPosition ${bypassDeclaration.engaged} but the derived default is ${defaultPosition}` +
|
|
1159
|
+
(isMomentary
|
|
1160
|
+
? "; the override is withheld because a momentary/latch contact rests released"
|
|
1161
|
+
: "; the declaration stands as read until a source read confirms or corrects it"),
|
|
1162
|
+
});
|
|
1163
|
+
}
|
|
1164
|
+
return {
|
|
1165
|
+
id,
|
|
1166
|
+
taper: declaredTapers.get(id) ?? componentTaper?.taper ?? "linear",
|
|
1167
|
+
defaultPosition,
|
|
1168
|
+
positions: discretePositions,
|
|
1169
|
+
role: declaredRoles.get(id) ??
|
|
1170
|
+
(typeof declaredAny?.role === "string" ? declaredAny.role : null),
|
|
1171
|
+
label: declaredLabels.get(id) ??
|
|
1172
|
+
(typeof declaredAny?.label === "string" ? declaredAny.label : null),
|
|
1173
|
+
// Derived from the program, never declared: a contact a chip times by its presses
|
|
1174
|
+
// springs back, so a panel draws it as a button.
|
|
1175
|
+
// A contact whose press a controller counts to flip a latch springs back the same way.
|
|
1176
|
+
...(tappedControls.has(id) || latchToggles.has(id) ? { momentary: true } : {}),
|
|
1177
|
+
...(tapLaws.has(id) ? { tap: tapLaws.get(id) } : {}),
|
|
1178
|
+
};
|
|
1179
|
+
});
|
|
1180
|
+
// A latch is a control whose position the firmware sets, so it joins the list a program and a
|
|
1181
|
+
// driven pin read from. Two positions, starting where the declaration says the chip powers up.
|
|
1182
|
+
for (const latch of controllers.latches) {
|
|
1183
|
+
const engaged = declaredRoles.get(latch.toggledBy) === "bypass" ? engagedLatchState(document, latch.id) : null;
|
|
1184
|
+
controls.push({
|
|
1185
|
+
id: latch.id,
|
|
1186
|
+
taper: "linear",
|
|
1187
|
+
defaultPosition: engaged ?? latch.initial,
|
|
1188
|
+
positions: 2,
|
|
1189
|
+
role: null,
|
|
1190
|
+
label: latch.id,
|
|
1191
|
+
latch: { toggledBy: latch.toggledBy, initial: latch.initial },
|
|
1192
|
+
});
|
|
1193
|
+
}
|
|
1194
|
+
// A momentary footswitch operating a BJT bistable latch controls the electronic
|
|
1195
|
+
// bypass latch state rather than holding a mechanical contact across DC-blocking
|
|
1196
|
+
// capacitors. The mechanical contact is fixed open at rest (`device.control = null`),
|
|
1197
|
+
// while the control remains in `netlist.controls` to steer the latch.
|
|
1198
|
+
const bistableLatches = findBistableLatches({
|
|
1199
|
+
devices,
|
|
1200
|
+
controls,
|
|
1201
|
+
});
|
|
1202
|
+
if (bistableLatches.length > 0) {
|
|
1203
|
+
const latchControlIds = new Set(bistableLatches
|
|
1204
|
+
.map((latch) => latch.controlId)
|
|
1205
|
+
.filter((id) => id !== null));
|
|
1206
|
+
for (let i = 0; i < devices.length; i += 1) {
|
|
1207
|
+
const device = devices[i];
|
|
1208
|
+
if (device !== undefined &&
|
|
1209
|
+
device.kind === "switch" &&
|
|
1210
|
+
device.control !== null &&
|
|
1211
|
+
latchControlIds.has(device.control)) {
|
|
1212
|
+
devices[i] = { ...device, control: null };
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1216
|
+
// **A power stage the graph does not contain.** `mesa-boogie-mark-v` declares a power
|
|
1217
|
+
// transformer with a typed `voltsHv` winding -- a B+ supply, which only an amp has -- and no
|
|
1218
|
+
// output transformer at all, because its packet models the preamp and hands the OT and
|
|
1219
|
+
// speaker to a companion fixture: its README claims a "2k:8 ohm OT descriptor" and an
|
|
1220
|
+
// "8 ohm WDF speaker-load handoff", so this is a documented boundary rather than a defect.
|
|
1221
|
+
// It is also why that packet's render is a **preamp level** -- 11.7 V at a monitor tap,
|
|
1222
|
+
// which spent a day being read alongside `fender-5e3-deluxe-tweed`'s 26.2 V into a real
|
|
1223
|
+
// 8 ohm load. Naming it is what stops the comparison being made again.
|
|
1224
|
+
//
|
|
1225
|
+
// No pedal can trip this: `voltsHv` is a high-voltage supply winding nothing in the pedal
|
|
1226
|
+
// corpus declares.
|
|
1227
|
+
if (outputTransformers.length === 0 &&
|
|
1228
|
+
devices.some((device) => device.kind === "transformer" &&
|
|
1229
|
+
typeof device.parameters.voltsHv === "number")) {
|
|
1230
|
+
warnings.push({
|
|
1231
|
+
code: "no-output-transformer",
|
|
1232
|
+
device: null,
|
|
1233
|
+
detail: "this document declares a high-voltage power transformer but no output " +
|
|
1234
|
+
"transformer, so the graph contains no output stage: the rendered level is a " +
|
|
1235
|
+
"preamp level and is not comparable with an amp measured at its speaker",
|
|
1236
|
+
});
|
|
1237
|
+
}
|
|
1238
|
+
// A jack's own declared load impedance, lowered into a resistor across its own two
|
|
1239
|
+
// terminals -- appended last and read from `document.components` rather than folded into
|
|
1240
|
+
// the main device loop above, so it cannot perturb port resolution, the switch-closing
|
|
1241
|
+
// path search or control defaults, all of which already ran against `devices` by this
|
|
1242
|
+
// point. See `outputJackLoads` for the measurement and the exclusions.
|
|
1243
|
+
const speakerLoads = outputJackLoads(document, devices);
|
|
1244
|
+
devices.push(...speakerLoads.devices);
|
|
1245
|
+
warnings.push(...speakerLoads.warnings);
|
|
1246
|
+
// A driven controller pin, lowered as the selector it is: the pin to the chip's own ground at
|
|
1247
|
+
// latch 0 and to its own supply at 1 (reversed by `invert`). Appended last for the same reason
|
|
1248
|
+
// as the speaker loads -- port resolution and the switch defaults have already run.
|
|
1249
|
+
for (const pin of controllers.pins) {
|
|
1250
|
+
devices.push(controllerPinDevice(pin, controls));
|
|
1251
|
+
}
|
|
1252
|
+
const nodes = [
|
|
1253
|
+
...new Set([GROUND, ...devices.flatMap((device) => device.nodes)]),
|
|
1254
|
+
].sort((a, b) => a - b);
|
|
1255
|
+
const rawBypass = parseBypassDeclaration(document);
|
|
1256
|
+
const bypass = rawBypass.switchId !== null
|
|
1257
|
+
? {
|
|
1258
|
+
declared: "switch",
|
|
1259
|
+
switch: rawBypass.switchId,
|
|
1260
|
+
engagedPosition: rawBypass.rawEngagedPosition,
|
|
1261
|
+
}
|
|
1262
|
+
: { declared: "none" };
|
|
1263
|
+
return {
|
|
1264
|
+
nodes,
|
|
1265
|
+
devices,
|
|
1266
|
+
controls,
|
|
1267
|
+
ports,
|
|
1268
|
+
bypass,
|
|
1269
|
+
portImpedanceOhms: portImpedanceOhms(document, devices, ports),
|
|
1270
|
+
portDeclaredFullScaleVolts: portDeclaredFullScaleVolts(document, devices, ports),
|
|
1271
|
+
convergenceOptIn: convergenceOptInDeclared(source),
|
|
1272
|
+
warnings,
|
|
1273
|
+
};
|
|
1274
|
+
}
|
|
1275
|
+
/**
|
|
1276
|
+
* Which unpositioned two-terminal switches must close for the input port to reach the
|
|
1277
|
+
* output port, taken as the fewest of them that does it.
|
|
1278
|
+
*
|
|
1279
|
+
* A source that does not state a switch position states nothing about its sense
|
|
1280
|
+
* either, and the corpus uses both senses, so no constant is right. `pro-co-rat` wires
|
|
1281
|
+
* its effect switches *into* the signal path -- node 24 is the input port itself -- and
|
|
1282
|
+
* opening them disconnects the pedal; `ibanez-ts808`'s CR1/CR2 latch contacts are
|
|
1283
|
+
* internal and closing them shorts the path around the circuit. A constant `0.5` kills
|
|
1284
|
+
* the Screamer and a constant `0` kills the Rat. This is the same shape the jack-engage
|
|
1285
|
+
* work already paid for: *same terminal name, opposite electrical sense*, and a
|
|
1286
|
+
* per-packet answer would be product knowledge this stage may not hold.
|
|
1287
|
+
*
|
|
1288
|
+
* Connectivity decides instead, which is evidence this stage may read. Crossing an
|
|
1289
|
+
* unpositioned switch costs 1 and every other device costs 0; take a cheapest input ->
|
|
1290
|
+
* output path and close exactly the switches on it. A pedal whose path already exists
|
|
1291
|
+
* closes nothing, and a pedal whose only path runs through its switches closes them.
|
|
1292
|
+
* The default is fail-safe toward a pedal you can hear either way.
|
|
1293
|
+
*
|
|
1294
|
+
* Rails are not routes. Ground and every node a voltage source touches are excluded
|
|
1295
|
+
* from the traversal: they reach nearly every node in the circuit through returns and
|
|
1296
|
+
* decoupling, so leaving them in makes every path free and the answer always "close
|
|
1297
|
+
* nothing". That exclusion is connectivity too -- it reads device kinds and nodes, no
|
|
1298
|
+
* text.
|
|
1299
|
+
*/
|
|
1300
|
+
function switchesClosedForPortPath(devices, ports, candidates) {
|
|
1301
|
+
const closed = new Set();
|
|
1302
|
+
if (candidates.size === 0 || ports.input === ports.output) {
|
|
1303
|
+
return closed;
|
|
1304
|
+
}
|
|
1305
|
+
const rails = new Set([GROUND]);
|
|
1306
|
+
for (const device of devices) {
|
|
1307
|
+
if (device.kind === "voltage-source") {
|
|
1308
|
+
for (const node of device.nodes) {
|
|
1309
|
+
rails.add(node);
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
}
|
|
1313
|
+
// A port sitting on a rail would be unreachable from the start. That is a different
|
|
1314
|
+
// defect and not this function's to diagnose, so it declines rather than guessing.
|
|
1315
|
+
if (rails.has(ports.input) || rails.has(ports.output)) {
|
|
1316
|
+
return closed;
|
|
1317
|
+
}
|
|
1318
|
+
const adjacency = new Map();
|
|
1319
|
+
const link = (a, b, gate) => {
|
|
1320
|
+
if (a === b || rails.has(a) || rails.has(b)) {
|
|
1321
|
+
return;
|
|
1322
|
+
}
|
|
1323
|
+
for (const [from, to] of [
|
|
1324
|
+
[a, b],
|
|
1325
|
+
[b, a],
|
|
1326
|
+
]) {
|
|
1327
|
+
const edges = adjacency.get(from);
|
|
1328
|
+
if (edges === undefined) {
|
|
1329
|
+
adjacency.set(from, [{ to, gate }]);
|
|
1330
|
+
}
|
|
1331
|
+
else {
|
|
1332
|
+
edges.push({ to, gate });
|
|
1333
|
+
}
|
|
1334
|
+
}
|
|
1335
|
+
};
|
|
1336
|
+
for (const device of devices) {
|
|
1337
|
+
const gate = device.control !== null && candidates.has(device.control)
|
|
1338
|
+
? device.control
|
|
1339
|
+
: null;
|
|
1340
|
+
if (gate !== null) {
|
|
1341
|
+
link(device.nodes[0] ?? GROUND, device.nodes[1] ?? GROUND, gate);
|
|
1342
|
+
continue;
|
|
1343
|
+
}
|
|
1344
|
+
// Every other device conducts. This is a path question and not a frequency one:
|
|
1345
|
+
// a coupling capacitor carries the signal it is there to carry, and a transistor
|
|
1346
|
+
// passes signal between its terminals.
|
|
1347
|
+
for (let i = 0; i < device.nodes.length; i += 1) {
|
|
1348
|
+
for (let j = i + 1; j < device.nodes.length; j += 1) {
|
|
1349
|
+
link(device.nodes[i] ?? GROUND, device.nodes[j] ?? GROUND, null);
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
// 0-1 BFS: free edges go to the front of the queue, gated ones to the back, so the
|
|
1354
|
+
// first time the output settles it has crossed the fewest switches. Adjacency keeps
|
|
1355
|
+
// device declaration order, so a tie between two equal-cost paths always resolves
|
|
1356
|
+
// the same way for the same document.
|
|
1357
|
+
const cost = new Map([[ports.input, 0]]);
|
|
1358
|
+
const cameFrom = new Map();
|
|
1359
|
+
const queue = [
|
|
1360
|
+
{ node: ports.input, cost: 0 },
|
|
1361
|
+
];
|
|
1362
|
+
while (queue.length > 0) {
|
|
1363
|
+
const entry = queue.shift();
|
|
1364
|
+
if (entry === undefined ||
|
|
1365
|
+
entry.cost > (cost.get(entry.node) ?? Infinity)) {
|
|
1366
|
+
continue;
|
|
1367
|
+
}
|
|
1368
|
+
if (entry.node === ports.output) {
|
|
1369
|
+
break;
|
|
1370
|
+
}
|
|
1371
|
+
for (const edge of adjacency.get(entry.node) ?? []) {
|
|
1372
|
+
const next = entry.cost + (edge.gate === null ? 0 : 1);
|
|
1373
|
+
if (next >= (cost.get(edge.to) ?? Infinity)) {
|
|
1374
|
+
continue;
|
|
1375
|
+
}
|
|
1376
|
+
cost.set(edge.to, next);
|
|
1377
|
+
cameFrom.set(edge.to, { from: entry.node, gate: edge.gate });
|
|
1378
|
+
const step = { node: edge.to, cost: next };
|
|
1379
|
+
if (edge.gate === null) {
|
|
1380
|
+
queue.unshift(step);
|
|
1381
|
+
}
|
|
1382
|
+
else {
|
|
1383
|
+
queue.push(step);
|
|
1384
|
+
}
|
|
1385
|
+
}
|
|
1386
|
+
}
|
|
1387
|
+
// No path even with every candidate closed. Closing them all would be a guess with
|
|
1388
|
+
// no evidence behind it, so every switch keeps the open default.
|
|
1389
|
+
if (!cost.has(ports.output)) {
|
|
1390
|
+
return closed;
|
|
1391
|
+
}
|
|
1392
|
+
for (let node = ports.output; node !== ports.input;) {
|
|
1393
|
+
const step = cameFrom.get(node);
|
|
1394
|
+
if (step === undefined) {
|
|
1395
|
+
break;
|
|
1396
|
+
}
|
|
1397
|
+
if (step.gate !== null) {
|
|
1398
|
+
closed.add(step.gate);
|
|
1399
|
+
}
|
|
1400
|
+
node = step.from;
|
|
1401
|
+
}
|
|
1402
|
+
return closed;
|
|
1403
|
+
}
|
|
1404
|
+
/**
|
|
1405
|
+
* Switches that must be closed to connect power sources to active device power terminals.
|
|
1406
|
+
*
|
|
1407
|
+
* In tube amplifiers and analog circuits, mains/standby switches sit on the DC power rail
|
|
1408
|
+
* or transformer primary/secondary. If unpositioned in source, defaulting open isolates
|
|
1409
|
+
* active devices from power supplies, leaving plates, screens and op-amp rails at 0V.
|
|
1410
|
+
*/
|
|
1411
|
+
function switchesClosedForSupplyPath(devices, ports, candidates) {
|
|
1412
|
+
const closed = new Set();
|
|
1413
|
+
if (candidates.size === 0) {
|
|
1414
|
+
return closed;
|
|
1415
|
+
}
|
|
1416
|
+
const powerSourceNodes = new Set();
|
|
1417
|
+
for (const device of devices) {
|
|
1418
|
+
if (device.kind === "voltage-source") {
|
|
1419
|
+
for (const node of device.nodes) {
|
|
1420
|
+
if (node !== GROUND && node !== ports.input) {
|
|
1421
|
+
powerSourceNodes.add(node);
|
|
1422
|
+
}
|
|
1423
|
+
}
|
|
1424
|
+
}
|
|
1425
|
+
}
|
|
1426
|
+
if (powerSourceNodes.size === 0) {
|
|
1427
|
+
return closed;
|
|
1428
|
+
}
|
|
1429
|
+
const activeSupplyTargets = new Set();
|
|
1430
|
+
for (const device of devices) {
|
|
1431
|
+
if (device.kind === "triode") {
|
|
1432
|
+
device.identity.terminalRoles.forEach((role, idx) => {
|
|
1433
|
+
const n = device.nodes[idx];
|
|
1434
|
+
if (role === "plate" && n !== undefined && n !== GROUND) {
|
|
1435
|
+
activeSupplyTargets.add(n);
|
|
1436
|
+
}
|
|
1437
|
+
});
|
|
1438
|
+
}
|
|
1439
|
+
else if (device.kind === "pentode") {
|
|
1440
|
+
device.identity.terminalRoles.forEach((role, idx) => {
|
|
1441
|
+
const n = device.nodes[idx];
|
|
1442
|
+
if ((role === "plate" || role === "screen") &&
|
|
1443
|
+
n !== undefined &&
|
|
1444
|
+
n !== GROUND) {
|
|
1445
|
+
activeSupplyTargets.add(n);
|
|
1446
|
+
}
|
|
1447
|
+
});
|
|
1448
|
+
}
|
|
1449
|
+
else if (device.kind === "opamp" || device.kind === "ota") {
|
|
1450
|
+
device.identity.terminalRoles.forEach((role, idx) => {
|
|
1451
|
+
const n = device.nodes[idx];
|
|
1452
|
+
if ((role === "vcc" || role === "vee") &&
|
|
1453
|
+
n !== undefined &&
|
|
1454
|
+
n !== GROUND) {
|
|
1455
|
+
activeSupplyTargets.add(n);
|
|
1456
|
+
}
|
|
1457
|
+
});
|
|
1458
|
+
}
|
|
1459
|
+
}
|
|
1460
|
+
if (activeSupplyTargets.size === 0) {
|
|
1461
|
+
return closed;
|
|
1462
|
+
}
|
|
1463
|
+
const adjacency = new Map();
|
|
1464
|
+
const link = (a, b, gate) => {
|
|
1465
|
+
if (a === b || a === GROUND || b === GROUND) {
|
|
1466
|
+
return;
|
|
1467
|
+
}
|
|
1468
|
+
for (const [from, to] of [
|
|
1469
|
+
[a, b],
|
|
1470
|
+
[b, a],
|
|
1471
|
+
]) {
|
|
1472
|
+
const edges = adjacency.get(from);
|
|
1473
|
+
if (edges === undefined) {
|
|
1474
|
+
adjacency.set(from, [{ to, gate }]);
|
|
1475
|
+
}
|
|
1476
|
+
else {
|
|
1477
|
+
edges.push({ to, gate });
|
|
1478
|
+
}
|
|
1479
|
+
}
|
|
1480
|
+
};
|
|
1481
|
+
for (const device of devices) {
|
|
1482
|
+
const gate = device.control !== null && candidates.has(device.control)
|
|
1483
|
+
? device.control
|
|
1484
|
+
: null;
|
|
1485
|
+
if (gate !== null) {
|
|
1486
|
+
for (let i = 0; i < device.nodes.length; i += 1) {
|
|
1487
|
+
for (let j = i + 1; j < device.nodes.length; j += 1) {
|
|
1488
|
+
link(device.nodes[i] ?? GROUND, device.nodes[j] ?? GROUND, gate);
|
|
1489
|
+
}
|
|
1490
|
+
}
|
|
1491
|
+
continue;
|
|
1492
|
+
}
|
|
1493
|
+
if (device.kind === "resistor" ||
|
|
1494
|
+
device.kind === "inductor" ||
|
|
1495
|
+
device.kind === "potentiometer") {
|
|
1496
|
+
for (let i = 0; i < device.nodes.length; i += 1) {
|
|
1497
|
+
for (let j = i + 1; j < device.nodes.length; j += 1) {
|
|
1498
|
+
link(device.nodes[i] ?? GROUND, device.nodes[j] ?? GROUND, null);
|
|
1499
|
+
}
|
|
1500
|
+
}
|
|
1501
|
+
}
|
|
1502
|
+
else if (device.kind === "diode" || device.kind === "tube-diode") {
|
|
1503
|
+
for (let i = 0; i < device.nodes.length; i += 1) {
|
|
1504
|
+
for (let j = i + 1; j < device.nodes.length; j += 1) {
|
|
1505
|
+
link(device.nodes[i] ?? GROUND, device.nodes[j] ?? GROUND, null);
|
|
1506
|
+
}
|
|
1507
|
+
}
|
|
1508
|
+
}
|
|
1509
|
+
else if (device.kind === "transformer") {
|
|
1510
|
+
const nonZeroNodes = device.nodes.filter((node) => node !== GROUND);
|
|
1511
|
+
for (let i = 0; i < nonZeroNodes.length; i += 1) {
|
|
1512
|
+
for (let j = i + 1; j < nonZeroNodes.length; j += 1) {
|
|
1513
|
+
link(nonZeroNodes[i] ?? GROUND, nonZeroNodes[j] ?? GROUND, null);
|
|
1514
|
+
}
|
|
1515
|
+
}
|
|
1516
|
+
}
|
|
1517
|
+
}
|
|
1518
|
+
const cost = new Map();
|
|
1519
|
+
const cameFrom = new Map();
|
|
1520
|
+
const queue = [];
|
|
1521
|
+
for (const src of powerSourceNodes) {
|
|
1522
|
+
cost.set(src, 0);
|
|
1523
|
+
queue.push({ node: src, cost: 0 });
|
|
1524
|
+
}
|
|
1525
|
+
while (queue.length > 0) {
|
|
1526
|
+
const entry = queue.shift();
|
|
1527
|
+
if (entry === undefined ||
|
|
1528
|
+
entry.cost > (cost.get(entry.node) ?? Infinity)) {
|
|
1529
|
+
continue;
|
|
1530
|
+
}
|
|
1531
|
+
for (const edge of adjacency.get(entry.node) ?? []) {
|
|
1532
|
+
const next = entry.cost + (edge.gate === null ? 0 : 1);
|
|
1533
|
+
if (next >= (cost.get(edge.to) ?? Infinity)) {
|
|
1534
|
+
continue;
|
|
1535
|
+
}
|
|
1536
|
+
cost.set(edge.to, next);
|
|
1537
|
+
cameFrom.set(edge.to, { from: entry.node, gate: edge.gate });
|
|
1538
|
+
const step = { node: edge.to, cost: next };
|
|
1539
|
+
if (edge.gate === null) {
|
|
1540
|
+
queue.unshift(step);
|
|
1541
|
+
}
|
|
1542
|
+
else {
|
|
1543
|
+
queue.push(step);
|
|
1544
|
+
}
|
|
1545
|
+
}
|
|
1546
|
+
}
|
|
1547
|
+
for (const target of activeSupplyTargets) {
|
|
1548
|
+
if (!cost.has(target)) {
|
|
1549
|
+
continue;
|
|
1550
|
+
}
|
|
1551
|
+
let curr = target;
|
|
1552
|
+
while (!powerSourceNodes.has(curr)) {
|
|
1553
|
+
const step = cameFrom.get(curr);
|
|
1554
|
+
if (step === undefined) {
|
|
1555
|
+
break;
|
|
1556
|
+
}
|
|
1557
|
+
if (step.gate !== null) {
|
|
1558
|
+
closed.add(step.gate);
|
|
1559
|
+
}
|
|
1560
|
+
curr = step.from;
|
|
1561
|
+
}
|
|
1562
|
+
}
|
|
1563
|
+
return closed;
|
|
1564
|
+
}
|
|
1565
|
+
function parseDocument(source) {
|
|
1566
|
+
try {
|
|
1567
|
+
return parseInterchangeYamlWithTopology(source).document;
|
|
1568
|
+
}
|
|
1569
|
+
catch (error) {
|
|
1570
|
+
throw new NetlistError(`invalid .vdsp document: ${error instanceof Error ? error.message : String(error)}`, null, error);
|
|
1571
|
+
}
|
|
1572
|
+
}
|
|
1573
|
+
/** The document together with the one connectivity core resolves for it. */
|
|
1574
|
+
function parseDocumentWithConnectivity(source) {
|
|
1575
|
+
try {
|
|
1576
|
+
return parseInterchangeYamlWithTopology(source);
|
|
1577
|
+
}
|
|
1578
|
+
catch (error) {
|
|
1579
|
+
throw new NetlistError(`invalid .vdsp document: ${error instanceof Error ? error.message : String(error)}`, null, error);
|
|
1580
|
+
}
|
|
1581
|
+
}
|
|
1582
|
+
/**
|
|
1583
|
+
* Core's pin -> node map, in this stage's key shape, renumbered so ground is `GROUND`.
|
|
1584
|
+
*
|
|
1585
|
+
* Two things this has to do that a straight copy of core's ids does not.
|
|
1586
|
+
*
|
|
1587
|
+
* **Every ground, not just the one core names.** Core reports a single `groundNodeId`; a
|
|
1588
|
+
* document can declare many ground symbols, each on its own node unless the source ties them.
|
|
1589
|
+
* Folding only core's id leaves the rest as ordinary floating nodes -- measured on
|
|
1590
|
+
* `electro-harmonix-holy-grail`, that dropped the devices touching ground from 45 to 2. Which
|
|
1591
|
+
* nodes are ground is still decided by what sits on them, never by what they are called: a
|
|
1592
|
+
* ground symbol's own terminal, and a jack's `sleeve`, are closed typed facts.
|
|
1593
|
+
*
|
|
1594
|
+
* **Renumber, do not fold.** Core's ids are arbitrary, so writing ground's pins to `GROUND`
|
|
1595
|
+
* while leaving the others alone collides whenever some *other* node is already numbered 0 --
|
|
1596
|
+
* `namedNodeDivider` is exactly that shape, and folding merged its input node into ground. Every
|
|
1597
|
+
* node therefore gets a fresh id: ground `GROUND`, the rest 1..N in ascending source order so
|
|
1598
|
+
* the numbering stays deterministic.
|
|
1599
|
+
*/
|
|
1600
|
+
function declaredNodesFromConnectivity(document, connectivity) {
|
|
1601
|
+
const nodeOfPin = new Map();
|
|
1602
|
+
for (const [node, pins] of connectivity.nodeMembers) {
|
|
1603
|
+
for (const pin of pins) {
|
|
1604
|
+
nodeOfPin.set(declaredKey(pin.componentId, pin.terminalName), node);
|
|
1605
|
+
}
|
|
1606
|
+
}
|
|
1607
|
+
const grounded = new Set();
|
|
1608
|
+
if (connectivity.groundNodeId !== null) {
|
|
1609
|
+
grounded.add(connectivity.groundNodeId);
|
|
1610
|
+
}
|
|
1611
|
+
for (const component of document.components) {
|
|
1612
|
+
const kind = String(component.kind);
|
|
1613
|
+
if (kind !== "ground" && kind !== "jack") {
|
|
1614
|
+
continue;
|
|
1615
|
+
}
|
|
1616
|
+
for (const terminal of component.terminals) {
|
|
1617
|
+
if (kind !== "ground" &&
|
|
1618
|
+
terminalRoleToken(String(terminal.name)) !== "sleeve") {
|
|
1619
|
+
continue;
|
|
1620
|
+
}
|
|
1621
|
+
const node = nodeOfPin.get(declaredKey(String(component.id), String(terminal.name)));
|
|
1622
|
+
if (node !== undefined) {
|
|
1623
|
+
grounded.add(node);
|
|
1624
|
+
}
|
|
1625
|
+
}
|
|
1626
|
+
}
|
|
1627
|
+
// Ground becomes `GROUND`; everything else keeps core's id. The one exception is a
|
|
1628
|
+
// non-ground node that already carries id 0 -- core's ids are arbitrary, so that happens
|
|
1629
|
+
// (`namedNodeDivider` is exactly that shape) and folding onto it would merge the two. Such a
|
|
1630
|
+
// node is moved above the highest id in use instead, which leaves every other id untouched.
|
|
1631
|
+
const ids = [...connectivity.nodeMembers.keys()];
|
|
1632
|
+
// Nothing is ground: leave every id exactly as core assigned it. Moving a node off 0 with
|
|
1633
|
+
// no ground to take its place would leave the system with no reference node at all, which
|
|
1634
|
+
// the solver cannot fix -- the CMOS NAND fixture diverges to 1e141 that way.
|
|
1635
|
+
if (grounded.size === 0) {
|
|
1636
|
+
return nodeOfPin;
|
|
1637
|
+
}
|
|
1638
|
+
let spare = Math.max(GROUND, ...ids) + 1;
|
|
1639
|
+
const renumbered = new Map();
|
|
1640
|
+
for (const node of ids) {
|
|
1641
|
+
renumbered.set(node, grounded.has(node) ? GROUND : node === GROUND ? spare++ : node);
|
|
1642
|
+
}
|
|
1643
|
+
const declared = new Map();
|
|
1644
|
+
for (const [key, node] of nodeOfPin) {
|
|
1645
|
+
declared.set(key, renumbered.get(node) ?? node);
|
|
1646
|
+
}
|
|
1647
|
+
return declared;
|
|
1648
|
+
}
|
|
1649
|
+
/**
|
|
1650
|
+
* Recover the `node:` each terminal declares. The format library drops it, so the
|
|
1651
|
+
* raw YAML is read once here for that single field. Confined to this stage.
|
|
1652
|
+
*/
|
|
1653
|
+
function readRawComponents(source) {
|
|
1654
|
+
const map = new Map();
|
|
1655
|
+
let parsed;
|
|
1656
|
+
try {
|
|
1657
|
+
parsed = loadYaml(source);
|
|
1658
|
+
}
|
|
1659
|
+
catch {
|
|
1660
|
+
return map;
|
|
1661
|
+
}
|
|
1662
|
+
if (parsed === null || typeof parsed !== "object") {
|
|
1663
|
+
return map;
|
|
1664
|
+
}
|
|
1665
|
+
const netNodes = new Map();
|
|
1666
|
+
const nodesList = parsed.nodes;
|
|
1667
|
+
if (Array.isArray(nodesList)) {
|
|
1668
|
+
for (const net of nodesList) {
|
|
1669
|
+
if (net === null || typeof net !== "object") {
|
|
1670
|
+
continue;
|
|
1671
|
+
}
|
|
1672
|
+
const netRecord = net;
|
|
1673
|
+
// **A net id is a token, not a number** -- the identical lesson the terminal
|
|
1674
|
+
// `node:` filter below already carries, unlearned one function up. The corpus
|
|
1675
|
+
// writes `id: 4` and `id: "n_gnd"` interchangeably, and requiring a string here
|
|
1676
|
+
// silently discarded the ENTIRE ledger for 100 of 142 packets -- usually every
|
|
1677
|
+
// net they declare (`boss-bf-3`: 430 of 430) -- and then reported the terminals
|
|
1678
|
+
// that depended on it as unconnected, with no diagnostic naming the ledger.
|
|
1679
|
+
let netId = null;
|
|
1680
|
+
if (typeof netRecord.id === "number" && Number.isFinite(netRecord.id)) {
|
|
1681
|
+
netId = String(netRecord.id);
|
|
1682
|
+
}
|
|
1683
|
+
else if (typeof netRecord.id === "string" &&
|
|
1684
|
+
netRecord.id.trim() !== "") {
|
|
1685
|
+
netId = netRecord.id.trim();
|
|
1686
|
+
}
|
|
1687
|
+
if (netId === null || !Array.isArray(netRecord.members)) {
|
|
1688
|
+
continue;
|
|
1689
|
+
}
|
|
1690
|
+
// A net with only 1 member connects a terminal to nothing. If the terminal has an
|
|
1691
|
+
// authored inline node connecting it to other components, a singleton ledger entry
|
|
1692
|
+
// must not disconnect it. Only multi-terminal nets (real connections) override inline refs.
|
|
1693
|
+
if (netRecord.members.length <= 1) {
|
|
1694
|
+
continue;
|
|
1695
|
+
}
|
|
1696
|
+
for (const member of netRecord.members) {
|
|
1697
|
+
if (member === null || typeof member !== "object") {
|
|
1698
|
+
continue;
|
|
1699
|
+
}
|
|
1700
|
+
const memberRecord = member;
|
|
1701
|
+
if (typeof memberRecord.componentId === "string" &&
|
|
1702
|
+
typeof memberRecord.terminalName === "string") {
|
|
1703
|
+
netNodes.set(`${memberRecord.componentId}/${memberRecord.terminalName}`, netId);
|
|
1704
|
+
}
|
|
1705
|
+
}
|
|
1706
|
+
}
|
|
1707
|
+
}
|
|
1708
|
+
const components = parsed.components;
|
|
1709
|
+
if (!Array.isArray(components)) {
|
|
1710
|
+
return map;
|
|
1711
|
+
}
|
|
1712
|
+
for (const component of components) {
|
|
1713
|
+
if (component === null || typeof component !== "object") {
|
|
1714
|
+
continue;
|
|
1715
|
+
}
|
|
1716
|
+
const record = component;
|
|
1717
|
+
if (typeof record.id !== "string") {
|
|
1718
|
+
continue;
|
|
1719
|
+
}
|
|
1720
|
+
const nodes = new Map();
|
|
1721
|
+
if (Array.isArray(record.terminals)) {
|
|
1722
|
+
for (const terminal of record.terminals) {
|
|
1723
|
+
if (terminal === null || typeof terminal !== "object") {
|
|
1724
|
+
continue;
|
|
1725
|
+
}
|
|
1726
|
+
const entry = terminal;
|
|
1727
|
+
if (typeof entry.name !== "string") {
|
|
1728
|
+
continue;
|
|
1729
|
+
}
|
|
1730
|
+
// A node id is a token, not a number. The corpus writes bare numerics,
|
|
1731
|
+
// quoted numerics and names (`n_gnd`) interchangeably, and an earlier
|
|
1732
|
+
// version of this filter required `typeof === "number"` -- which dropped
|
|
1733
|
+
// every quoted and every named node without saying so, then reported the
|
|
1734
|
+
// terminal as unconnected. Keep the token as authored and intern later.
|
|
1735
|
+
let inlineNode = null;
|
|
1736
|
+
if (typeof entry.node === "number") {
|
|
1737
|
+
inlineNode = String(entry.node);
|
|
1738
|
+
}
|
|
1739
|
+
else if (typeof entry.node === "string" &&
|
|
1740
|
+
entry.node.trim() !== "") {
|
|
1741
|
+
inlineNode = entry.node.trim();
|
|
1742
|
+
}
|
|
1743
|
+
// The declared node ledger is authoritative when both encodings are present.
|
|
1744
|
+
const netNode = netNodes.get(`${record.id}/${entry.name}`);
|
|
1745
|
+
const node = netNode ?? inlineNode;
|
|
1746
|
+
if (node !== null) {
|
|
1747
|
+
nodes.set(entry.name, node);
|
|
1748
|
+
}
|
|
1749
|
+
}
|
|
1750
|
+
}
|
|
1751
|
+
const properties = record.properties !== null && typeof record.properties === "object"
|
|
1752
|
+
? record.properties
|
|
1753
|
+
: {};
|
|
1754
|
+
map.set(record.id, { nodes, properties });
|
|
1755
|
+
}
|
|
1756
|
+
return map;
|
|
1757
|
+
}
|
|
1758
|
+
/**
|
|
1759
|
+
* Restore structured quantities the format library flattened to text.
|
|
1760
|
+
*
|
|
1761
|
+
* `Resistance: { raw: "1M", value: 1000000, unit: "" }` is a flow mapping, and the
|
|
1762
|
+
* library hands it back as the literal characters `{ raw: "1M", ... }` -- which then
|
|
1763
|
+
* fails to parse as a quantity. The value is fully present in the source, so this is
|
|
1764
|
+
* repairing a lossy read, not repairing the document.
|
|
1765
|
+
*/
|
|
1766
|
+
function repairStructuredProperties(document, raw) {
|
|
1767
|
+
let changed = false;
|
|
1768
|
+
const components = document.components.map((component) => {
|
|
1769
|
+
const entry = raw.get(String(component.id));
|
|
1770
|
+
if (entry === undefined) {
|
|
1771
|
+
return component;
|
|
1772
|
+
}
|
|
1773
|
+
const properties = component.properties;
|
|
1774
|
+
let repaired = null;
|
|
1775
|
+
for (const [key, value] of Object.entries(entry.properties)) {
|
|
1776
|
+
const quantity = asParsedQuantity(value);
|
|
1777
|
+
if (quantity === null || typeof properties[key] !== "string") {
|
|
1778
|
+
continue;
|
|
1779
|
+
}
|
|
1780
|
+
repaired ??= { ...properties };
|
|
1781
|
+
repaired[key] = quantity;
|
|
1782
|
+
}
|
|
1783
|
+
if (repaired === null) {
|
|
1784
|
+
return component;
|
|
1785
|
+
}
|
|
1786
|
+
changed = true;
|
|
1787
|
+
return { ...component, properties: repaired };
|
|
1788
|
+
});
|
|
1789
|
+
return changed ? { ...document, components } : document;
|
|
1790
|
+
}
|
|
1791
|
+
function propertyString(component, key) {
|
|
1792
|
+
const value = component.properties[key];
|
|
1793
|
+
if (typeof value === "string") {
|
|
1794
|
+
return value.trim().length === 0 ? null : value.trim();
|
|
1795
|
+
}
|
|
1796
|
+
if (value !== null && typeof value === "object" && "raw" in value) {
|
|
1797
|
+
const raw = value.raw;
|
|
1798
|
+
return typeof raw === "string" && raw.trim().length > 0 ? raw.trim() : null;
|
|
1799
|
+
}
|
|
1800
|
+
return null;
|
|
1801
|
+
}
|
|
1802
|
+
/**
|
|
1803
|
+
* A component the source files as interface or source context, not part of the modeled graph.
|
|
1804
|
+
*
|
|
1805
|
+
* v1 reads the same two typed booleans in `source-device-capabilities.ts` (`isInterfaceOnlyComponent`,
|
|
1806
|
+
* `isSourceOnlyComponent`) and gives the component an interface-only / source-only disposition that
|
|
1807
|
+
* keeps it out of the electrical analysis. v2 never read them, so an open placeholder the source says
|
|
1808
|
+
* not to model was lowered as an ordinary part and refused for a value the source deliberately omits
|
|
1809
|
+
* -- schaller-tremolo's `R_LED`, `InterfaceOnly: "true"` and `Value: open`, a panel-LED shell with no
|
|
1810
|
+
* driver the selected schematic specifies. The flag is a typed boolean compared whole, not prose.
|
|
1811
|
+
*/
|
|
1812
|
+
function isInterfaceOrSourceOnly(component) {
|
|
1813
|
+
return (unquoteBool(component, "InterfaceOnly") ||
|
|
1814
|
+
unquoteBool(component, "SourceOnly"));
|
|
1815
|
+
}
|
|
1816
|
+
/**
|
|
1817
|
+
* A board position the source says is unpopulated.
|
|
1818
|
+
*
|
|
1819
|
+
* `DNP` -- "do not populate" -- is a typed boolean stating that the footprint exists and nothing
|
|
1820
|
+
* is fitted in it. That is a *fact about the circuit*, not a missing value: an unpopulated
|
|
1821
|
+
* two-terminal position is an **open circuit**. So the component is dropped, exactly as if the
|
|
1822
|
+
* document had not listed it, rather than refusing the pedal for a value it is correct not to
|
|
1823
|
+
* state. Refusing was the previous behaviour, and it is the wrong reading of a complete source.
|
|
1824
|
+
*
|
|
1825
|
+
* `mxr-carbon-copy` is the corpus instance and the only one: `R45`, `R50`, `C32` and `C34`, each
|
|
1826
|
+
* `DNP: 'true'`, `SourceValue: Empty`, `SourceStatus: defer`. Two of them (`R45`, `C34`) bridge
|
|
1827
|
+
* the same node pair, which is what an unfitted either/or position looks like.
|
|
1828
|
+
*
|
|
1829
|
+
* **The typed flag only.** All four also say "unpopulated" in their `Description`, and that prose
|
|
1830
|
+
* is deliberately not read: a component whose description happens to mention an unpopulated
|
|
1831
|
+
* neighbour must not vanish from the solve. Same rule as `isInterfaceOrSourceOnly` above.
|
|
1832
|
+
*/
|
|
1833
|
+
function isNotPopulated(component) {
|
|
1834
|
+
if (unquoteBool(component, "DNP")) {
|
|
1835
|
+
return true;
|
|
1836
|
+
}
|
|
1837
|
+
const sourceStatus = propertyString(component, "SourceStatus");
|
|
1838
|
+
const sourceValue = propertyString(component, "SourceValue");
|
|
1839
|
+
if (sourceStatus === "defer" &&
|
|
1840
|
+
sourceValue !== null &&
|
|
1841
|
+
foldToken(sourceValue) === foldToken("visible-no-value-or-DNP")) {
|
|
1842
|
+
return true;
|
|
1843
|
+
}
|
|
1844
|
+
return false;
|
|
1845
|
+
}
|
|
1846
|
+
/** The interchange parser strips double quotes but keeps single; this reads both. */
|
|
1847
|
+
function unquoteBool(component, key) {
|
|
1848
|
+
const value = propertyString(component, key);
|
|
1849
|
+
if (value === null) {
|
|
1850
|
+
return false;
|
|
1851
|
+
}
|
|
1852
|
+
const unquoted = (value.startsWith("'") && value.endsWith("'")) ||
|
|
1853
|
+
(value.startsWith('"') && value.endsWith('"'))
|
|
1854
|
+
? value.slice(1, -1).trim()
|
|
1855
|
+
: value;
|
|
1856
|
+
return unquoted === "true";
|
|
1857
|
+
}
|
|
1858
|
+
/**
|
|
1859
|
+
* The document's own `convergence-opt-in` declaration, read from the source text.
|
|
1860
|
+
*
|
|
1861
|
+
* Core's interchange parser lifts a fixed allow-list of front-matter scalars into the parsed
|
|
1862
|
+
* document and drops every other key, so a new flag cannot reach the compiler through the
|
|
1863
|
+
* parsed shape without a core change. This reads the flag straight off the document's text --
|
|
1864
|
+
* the same typed-flag evidence class as `DNP` / `InterfaceOnly` above, at document scope
|
|
1865
|
+
* instead of component scope. It matches one exact closed-vocabulary line compared whole, not
|
|
1866
|
+
* prose, so a description that mentions the words cannot set it.
|
|
1867
|
+
*/
|
|
1868
|
+
function convergenceOptInDeclared(source) {
|
|
1869
|
+
for (const line of source.split(/\r?\n/)) {
|
|
1870
|
+
const trimmed = line.trim();
|
|
1871
|
+
if (trimmed === "convergence-opt-in: true" ||
|
|
1872
|
+
trimmed === "convergence-opt-in: 'true'" ||
|
|
1873
|
+
trimmed === 'convergence-opt-in: "true"') {
|
|
1874
|
+
return true;
|
|
1875
|
+
}
|
|
1876
|
+
}
|
|
1877
|
+
return false;
|
|
1878
|
+
}
|
|
1879
|
+
/**
|
|
1880
|
+
* `propertyString`, plus a bare unquoted YAML number.
|
|
1881
|
+
*
|
|
1882
|
+
* Scoped to the quantity fallback path below (`Resistance`/`R`, `Capacitance`/`C`,
|
|
1883
|
+
* `Inductance`/`L`, a rheostat's `MinResistance`, an unclaimed `voltage-source`'s
|
|
1884
|
+
* `Voltage`) and deliberately not folded into `propertyString` itself, whose other
|
|
1885
|
+
* callers read prose-ish closed-vocabulary fields (`Wipe`, `Position`, `Type`,
|
|
1886
|
+
* `PartNumber`, ...) where a bare number carries a different, unrelated meaning
|
|
1887
|
+
* (a control's stated 0..1 travel) that this change must not start reading.
|
|
1888
|
+
*
|
|
1889
|
+
* `boss-dd-3a` writes six resistors' fallback `R` as a plain unquoted number --
|
|
1890
|
+
* `R: 100`, not `R: "100"` or a typed `{raw, value, unit}` -- which is exactly as
|
|
1891
|
+
* unambiguous as its quoted twin: base units, no multiplier, nothing lost. But
|
|
1892
|
+
* `typeof value === "number"` matched neither branch above, so the value was
|
|
1893
|
+
* fully present and still read as absent, refusing `boss-dd-3a` over a value this
|
|
1894
|
+
* stage had in hand -- a wrong answer (refusal) that looked like a stricter read.
|
|
1895
|
+
*/
|
|
1896
|
+
function propertyQuantityText(component, key) {
|
|
1897
|
+
const value = component.properties[key];
|
|
1898
|
+
return typeof value === "number" && Number.isFinite(value)
|
|
1899
|
+
? String(value)
|
|
1900
|
+
: propertyString(component, key);
|
|
1901
|
+
}
|
|
1902
|
+
/**
|
|
1903
|
+
* Unit tokens that mean "already in base units" for each device kind. A prefixed unit
|
|
1904
|
+
* such as `kohm` is deliberately absent: this stage will not scale a unit it did not
|
|
1905
|
+
* read, because guessing wrong is a wrong answer that still renders.
|
|
1906
|
+
*/
|
|
1907
|
+
const baseUnitsByDeviceKind = {
|
|
1908
|
+
resistor: ["", "ohm", "ohms", "Ω"],
|
|
1909
|
+
potentiometer: ["", "ohm", "ohms", "Ω"],
|
|
1910
|
+
rheostat: ["", "ohm", "ohms", "Ω"],
|
|
1911
|
+
capacitor: ["f", "farad", "farads"],
|
|
1912
|
+
inductor: ["h", "henry", "henries"],
|
|
1913
|
+
"voltage-source": ["v", "volt", "volts"],
|
|
1914
|
+
transformer: [""],
|
|
1915
|
+
};
|
|
1916
|
+
/**
|
|
1917
|
+
* Accepted unit tokens for a BJT's typed SPICE parameters, per property rather than per
|
|
1918
|
+
* device, because they do not share a dimension: `IS` is a current and the betas and the
|
|
1919
|
+
* emission coefficient are dimensionless.
|
|
1920
|
+
*
|
|
1921
|
+
* These are compared whole, like every other unit token here. Nothing reads `PartNumber`:
|
|
1922
|
+
* `OC41` is a germanium PNP and `2N3904` is a silicon NPN, and inferring that from the
|
|
1923
|
+
* string would be exactly the prose matching the engineering principles forbid. The device
|
|
1924
|
+
* type comes from `Type`, which the packets declare.
|
|
1925
|
+
*/
|
|
1926
|
+
const bjtParameterUnits = {
|
|
1927
|
+
// `""` is accepted for `IS` because a SPICE saturation current has no plausible alternative
|
|
1928
|
+
// unit, and the corpus states it both ways: `electro-harmonix-lpb-1` declares
|
|
1929
|
+
// `IS: {raw: "1e-12", value: 1e-12, unit: ""}`. Rejecting the unlabelled form silently
|
|
1930
|
+
// substituted the silicon default and lost the declared value -- found by the value comparison
|
|
1931
|
+
// in `report-compiler-fidelity.ts` on its first run, which is the class of bug it exists for.
|
|
1932
|
+
IS: ["", "a", "amp", "amps", "ampere", "amperes"],
|
|
1933
|
+
BF: [""],
|
|
1934
|
+
BR: [""],
|
|
1935
|
+
};
|
|
1936
|
+
/**
|
|
1937
|
+
* The two device types a bipolar transistor can be, as a closed vocabulary compared whole.
|
|
1938
|
+
*
|
|
1939
|
+
* Case is normalised and nothing else is: an unrecognised value is refused by the caller
|
|
1940
|
+
* rather than resolved by prefix, so a `Type` of `N` does not silently become `NPN`. Five
|
|
1941
|
+
* devices in one corpus packet declare exactly that, and guessing would be indistinguishable
|
|
1942
|
+
* from reading it correctly right up to the point where it is wrong.
|
|
1943
|
+
*/
|
|
1944
|
+
const bjtPolarityByType = {
|
|
1945
|
+
NPN: 0,
|
|
1946
|
+
PNP: 1,
|
|
1947
|
+
};
|
|
1948
|
+
/**
|
|
1949
|
+
* A typed numeric property, taken from its structured `value` with the unit checked whole.
|
|
1950
|
+
*
|
|
1951
|
+
* Prefers `value` over `raw` because `raw` is authored prose: `IS` is declared as
|
|
1952
|
+
* `{raw: "1 nA", value: 1e-9, unit: A}`, and parsing "1 nA" means re-deriving a number the
|
|
1953
|
+
* packet already states exactly. Falls back to parsing a bare string, which is how a packet
|
|
1954
|
+
* that never adopted the structured form still reads.
|
|
1955
|
+
*/
|
|
1956
|
+
function typedNumber(component, key, acceptedUnits) {
|
|
1957
|
+
const property = component.properties[key];
|
|
1958
|
+
if (property !== null && typeof property === "object") {
|
|
1959
|
+
const { value, unit } = property;
|
|
1960
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
1961
|
+
const token = typeof unit === "string" ? unit.replaceAll("'", "").trim() : "";
|
|
1962
|
+
return acceptedUnits.includes(token.toLowerCase()) ? value : null;
|
|
1963
|
+
}
|
|
1964
|
+
return null;
|
|
1965
|
+
}
|
|
1966
|
+
const raw = propertyString(component, key);
|
|
1967
|
+
if (raw === null) {
|
|
1968
|
+
return null;
|
|
1969
|
+
}
|
|
1970
|
+
try {
|
|
1971
|
+
return parseQuantity(raw);
|
|
1972
|
+
}
|
|
1973
|
+
catch {
|
|
1974
|
+
return null;
|
|
1975
|
+
}
|
|
1976
|
+
}
|
|
1977
|
+
/** A structured quantity's value, but only when it is already in base units. */
|
|
1978
|
+
function baseUnitQuantity(component, key,
|
|
1979
|
+
// A plain string, not `DeviceKind`: callers pass the component's own `kind`, whose type
|
|
1980
|
+
// admits `"unsupported"`. The lookup is by string and an unknown key already returns null,
|
|
1981
|
+
// which is the same answer a narrower type would have forced at the call site.
|
|
1982
|
+
kind) {
|
|
1983
|
+
const property = component.properties[key];
|
|
1984
|
+
if (property === null || typeof property !== "object") {
|
|
1985
|
+
return null;
|
|
1986
|
+
}
|
|
1987
|
+
const { value, unit } = property;
|
|
1988
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
1989
|
+
return null;
|
|
1990
|
+
}
|
|
1991
|
+
const accepted = baseUnitsByDeviceKind[kind];
|
|
1992
|
+
if (accepted === undefined) {
|
|
1993
|
+
return null;
|
|
1994
|
+
}
|
|
1995
|
+
// Some packets quote the empty unit, so `''` reaches here as two apostrophes.
|
|
1996
|
+
const token = typeof unit === "string" ? unit.replaceAll("'", "").trim() : "";
|
|
1997
|
+
return accepted.includes(token) || accepted.includes(token.toLowerCase())
|
|
1998
|
+
? value
|
|
1999
|
+
: null;
|
|
2000
|
+
}
|
|
2001
|
+
/**
|
|
2002
|
+
* Parse a quantity to SI base units. A value that cannot be parsed is an error, not a
|
|
2003
|
+
* default -- a silently defaulted component is a silently wrong circuit.
|
|
2004
|
+
*/
|
|
2005
|
+
export function parseQuantity(raw) {
|
|
2006
|
+
// Case is load-bearing and must survive: `M` is mega and `m` is milli, a factor
|
|
2007
|
+
// of 1e9 apart. Lower-casing first, as an earlier version of this function did,
|
|
2008
|
+
// silently turned every 1M resistor in the corpus into 1 milliohm -- a wrong
|
|
2009
|
+
// answer that still renders, which is this domain's characteristic failure.
|
|
2010
|
+
//
|
|
2011
|
+
// Real schematic values carry more than a number: `10uF/16V` is a capacitance
|
|
2012
|
+
// with a voltage rating, `2.7nF 5%` a tolerance, `100kB` a taper code, `10 ko`
|
|
2013
|
+
// an ohm sign transliterated to a letter. Reading those is parsing the notation,
|
|
2014
|
+
// not repairing the document -- the value is unambiguous and fully present. What
|
|
2015
|
+
// is still refused is a leading token that is not a quantity at all.
|
|
2016
|
+
let text = raw
|
|
2017
|
+
.trim()
|
|
2018
|
+
.replace(/^['"]+|['"]+$/gu, "")
|
|
2019
|
+
.trim();
|
|
2020
|
+
// A rating after a slash describes the part, not its value.
|
|
2021
|
+
const slash = text.indexOf("/");
|
|
2022
|
+
if (slash > 0) {
|
|
2023
|
+
text = text.slice(0, slash).trim();
|
|
2024
|
+
}
|
|
2025
|
+
// R-notation, where the multiplier letter stands in for the decimal point, so
|
|
2026
|
+
// `4u7` is 4.7 microfarads and `1k5` is 1500 ohms. Checked before the general
|
|
2027
|
+
// form, which would otherwise read `1k5` as 1000 and silently drop the 5.
|
|
2028
|
+
const compact = text.replace(/\s+/gu, "");
|
|
2029
|
+
const rNotation = /^([+-]?\d+)(R|r|k|K|M|m|u|µ|μ|n|p|G|g)(\d+)$/u.exec(compact);
|
|
2030
|
+
if (rNotation !== null) {
|
|
2031
|
+
const whole = rNotation[1];
|
|
2032
|
+
const unit = rNotation[2];
|
|
2033
|
+
const fraction = rNotation[3];
|
|
2034
|
+
const isOhmMarker = unit === "R" || unit === "r";
|
|
2035
|
+
return parseQuantity(`${whole}.${fraction}${isOhmMarker ? "" : unit}`);
|
|
2036
|
+
}
|
|
2037
|
+
// Number, optional multiplier, optional unit. Anything after that is annotation.
|
|
2038
|
+
// The multiplier group is case-sensitive; the unit group is not, because a unit
|
|
2039
|
+
// carries no scale and no multiplier letter appears in it.
|
|
2040
|
+
const match = /^([+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?)\s*(meg|MEG|Meg|[pnuµμmkKMGg]|[PNU])?\s*(ohms?|Ω|ω|o|O|R|farads?|henr(?:y|ies)|volts?|amps?|hz|[fFhHvVaA])?/u.exec(text);
|
|
2041
|
+
if (match === null) {
|
|
2042
|
+
throw new NetlistError(`cannot parse quantity "${raw}"`);
|
|
2043
|
+
}
|
|
2044
|
+
const value = Number(match[1]);
|
|
2045
|
+
if (!Number.isFinite(value)) {
|
|
2046
|
+
throw new NetlistError(`cannot parse quantity "${raw}"`);
|
|
2047
|
+
}
|
|
2048
|
+
const multipliers = {
|
|
2049
|
+
p: 1e-12,
|
|
2050
|
+
P: 1e-12,
|
|
2051
|
+
n: 1e-9,
|
|
2052
|
+
N: 1e-9,
|
|
2053
|
+
u: 1e-6,
|
|
2054
|
+
U: 1e-6,
|
|
2055
|
+
µ: 1e-6,
|
|
2056
|
+
μ: 1e-6,
|
|
2057
|
+
m: 1e-3,
|
|
2058
|
+
k: 1e3,
|
|
2059
|
+
K: 1e3,
|
|
2060
|
+
M: 1e6,
|
|
2061
|
+
meg: 1e6,
|
|
2062
|
+
MEG: 1e6,
|
|
2063
|
+
Meg: 1e6,
|
|
2064
|
+
G: 1e9,
|
|
2065
|
+
g: 1e9,
|
|
2066
|
+
};
|
|
2067
|
+
const symbol = match[2];
|
|
2068
|
+
if (symbol === undefined) {
|
|
2069
|
+
return value;
|
|
2070
|
+
}
|
|
2071
|
+
const multiplier = multipliers[symbol];
|
|
2072
|
+
if (multiplier === undefined) {
|
|
2073
|
+
throw new NetlistError(`cannot parse quantity "${raw}": unknown multiplier "${symbol}"`);
|
|
2074
|
+
}
|
|
2075
|
+
return value * multiplier;
|
|
2076
|
+
}
|
|
2077
|
+
function parametersFor(component, kind) {
|
|
2078
|
+
// A supply rail asserts a potential at its node. Most declare one -- 84 of the
|
|
2079
|
+
// corpus's 90 -- and the six that do not are named support shells with nothing to
|
|
2080
|
+
// assert, so an absent voltage is silence rather than an error.
|
|
2081
|
+
// **A fuse states a current rating, never a resistance.** `FuseRating: "2 A Slow Blow"` is
|
|
2082
|
+
// what it opens at, not what it drops while intact, so demanding a resistance would refuse
|
|
2083
|
+
// every fuse in the corpus for a value no fuse datasheet leads with.
|
|
2084
|
+
//
|
|
2085
|
+
// Zero is the honest answer and the law below already knows what to do with it: the resistor
|
|
2086
|
+
// case stamps a stated zero as `SWITCH_ON_OHMS`, "the same electrically-indistinguishable-
|
|
2087
|
+
// from-ideal short this file already stamps for a closed switch". A fuse is that short --
|
|
2088
|
+
// which is the whole content of "a fuse is just a hard wire" -- and it reaches it through
|
|
2089
|
+
// machinery that was already there rather than a constant invented for fuses.
|
|
2090
|
+
if (component.kind === "fuse") {
|
|
2091
|
+
// **A declared resistance wins, because a real fuse has one.** An intact fuse is a thin
|
|
2092
|
+
// wire, and a lower-rated one is thinner: `marshall-jcm800` states 0.05 Ω for its T2A and
|
|
2093
|
+
// 0.5 Ω for its T1A, which is the only pair in the corpus that scales with rating at all.
|
|
2094
|
+
// `marshall-jtm45` states the same 0.001 Ω for a 2 A and a 500 mA fuse and its own raw
|
|
2095
|
+
// calls that a "closed fuse link"; `orange-gro100` states 0.01 Ω, which is exactly
|
|
2096
|
+
// `SWITCH_ON_OHMS`. Those two are ideal-short placeholders, jcm800's are physical, and
|
|
2097
|
+
// forcing zero here would discard the physical ones on sight.
|
|
2098
|
+
//
|
|
2099
|
+
// Zero is the fallback rather than the rule, and the resistor law turns it into
|
|
2100
|
+
// `SWITCH_ON_OHMS` -- the short this file already stamps for a closed switch. So a fuse
|
|
2101
|
+
// that states nothing still conducts, and one that states a resistance keeps it.
|
|
2102
|
+
// **Read the typed value, not the raw.** `baseUnitQuantity` is the reader the resistor
|
|
2103
|
+
// path uses for exactly this reason -- its own note says to prefer a stated `unit: "ohm"`
|
|
2104
|
+
// "instead of re-parsing an ambiguous schematic label". A fuse is the sharpest case of
|
|
2105
|
+
// that ambiguity: `jcm800`'s `Resistance` is `{ raw: "2 A", value: 0.05, unit: "Ω" }`, and
|
|
2106
|
+
// parsing the raw yields 2 Ω, forty times the stated value. Its T1A raw parses to nothing
|
|
2107
|
+
// at all, which would have silently become the zero fallback.
|
|
2108
|
+
// The typed value first, then the string -- the same order the resistor path uses, and
|
|
2109
|
+
// both rungs are needed. `jcm800` states a typed `{ value: 0.05, unit: "Ω" }` whose raw
|
|
2110
|
+
// says "2 A", so only the typed rung is right there; `orange-rockerverb` states a plain
|
|
2111
|
+
// `R: "0.01 ohm"` with no typed value at all, so only the parsing rung reaches it. Taking
|
|
2112
|
+
// the typed rung alone silently zeroed six of rockerverb's fuses.
|
|
2113
|
+
const declared = baseUnitQuantity(component, "Resistance", "resistor") ??
|
|
2114
|
+
baseUnitQuantity(component, "R", "resistor") ??
|
|
2115
|
+
optionalQuantity(component, "Resistance") ??
|
|
2116
|
+
optionalQuantity(component, "R");
|
|
2117
|
+
return { ohms: declared ?? 0 };
|
|
2118
|
+
}
|
|
2119
|
+
if (kind === "rail") {
|
|
2120
|
+
const volts = optionalQuantity(component, "Voltage");
|
|
2121
|
+
return volts === null ? {} : { volts };
|
|
2122
|
+
}
|
|
2123
|
+
if (kind === "switch") {
|
|
2124
|
+
const pos = isHardwareSwitch(component) && !statesPosition(component)
|
|
2125
|
+
? 1
|
|
2126
|
+
: declaredPosition(component);
|
|
2127
|
+
return { position: pos };
|
|
2128
|
+
}
|
|
2129
|
+
// A transformer's turns ratio, from the impedances the source states as typed quantities.
|
|
2130
|
+
//
|
|
2131
|
+
// **Not one of the 52 transformers in the amp corpus declares a `Ratio`**, so the property
|
|
2132
|
+
// this stage was demanding can never be satisfied by that corpus. What they do declare, and
|
|
2133
|
+
// often as structured `{value, unit}` rather than prose, is a primary and secondary
|
|
2134
|
+
// impedance — and `Zp/Zs = (Np/Ns)^2` is transformer theory, so `n = sqrt(Zp/Zs)` is
|
|
2135
|
+
// arithmetic on typed data rather than a reading of authored text. `fender-5e3-deluxe-tweed`
|
|
2136
|
+
// states `8000 Ω` and `8 Ω`, giving 31.6; `marshall-jtm45` states `6600` and `16`, giving
|
|
2137
|
+
// 20.3.
|
|
2138
|
+
//
|
|
2139
|
+
// Only typed quantities count. Many of the same properties appear elsewhere as prose
|
|
2140
|
+
// (`PrimaryImpedance: "2 kΩ plate-to-plate reference-class"`, `TurnsRatio: "44.72:1"`) and
|
|
2141
|
+
// those stay unread, because parsing a number out of a sentence is exactly what this
|
|
2142
|
+
// pipeline forbids — a typed `Ratio` or `TurnsRatio` remains the direct way to state it.
|
|
2143
|
+
if (kind === "transformer") {
|
|
2144
|
+
// `TurnsRatio` is the same quantity under the spelling two packets already use for it:
|
|
2145
|
+
// `fender-bassman` states `44.72:1` where its impedances give `sqrt(4000/2) = 44.72`, and
|
|
2146
|
+
// `marshall-1959-super-lead-plexi` states `10.31:1` against `sqrt(1700/16) = 10.31`. Both
|
|
2147
|
+
// are prose today and stay unread; typed, either spelling now resolves.
|
|
2148
|
+
const declared = optionalQuantity(component, "Ratio") ??
|
|
2149
|
+
typedRatio(component, "TurnsRatio");
|
|
2150
|
+
if (declared !== null) {
|
|
2151
|
+
return {
|
|
2152
|
+
...windingResistanceParameters(component), ratio: declared
|
|
2153
|
+
};
|
|
2154
|
+
}
|
|
2155
|
+
// The pair comes off the coils that carry it (`@vessel-dsp/core@0.6.37`), replacing
|
|
2156
|
+
// `PrimaryImpedance`/`SecondaryImpedance` and their `InputImpedance`/`OutputImpedance`
|
|
2157
|
+
// spellings -- which were the same two ratings on a reverb tank's drive and pickup coils.
|
|
2158
|
+
// The reference is the coil the signal enters; a tank's is `drive`.
|
|
2159
|
+
const primary = windingRatingOhms(component, ["primary", "drive"]);
|
|
2160
|
+
const secondary = windingRatingOhms(component, ["secondary", "pickup"]);
|
|
2161
|
+
if (primary !== null &&
|
|
2162
|
+
secondary !== null &&
|
|
2163
|
+
primary > 0 &&
|
|
2164
|
+
secondary > 0) {
|
|
2165
|
+
// The pair is carried alongside the ratio it derives, not only folded into it: a
|
|
2166
|
+
// spring reverb tank is specified by this same `InputImpedance`/`OutputImpedance`
|
|
2167
|
+
// pair, and its law needs the two port impedances themselves -- the drive coil's
|
|
2168
|
+
// load and the pickup's source impedance -- which `sqrt(Zp/Zs)` has already thrown
|
|
2169
|
+
// away. Every other transformer ignores these two and reads `ratio` as before.
|
|
2170
|
+
return {
|
|
2171
|
+
...windingResistanceParameters(component),
|
|
2172
|
+
ratio: Math.sqrt(primary / secondary),
|
|
2173
|
+
primaryImpedanceOhms: primary,
|
|
2174
|
+
secondaryImpedanceOhms: secondary,
|
|
2175
|
+
};
|
|
2176
|
+
}
|
|
2177
|
+
// Third and last: a transformer the source specifies by its **coil voltages**, which is
|
|
2178
|
+
// every mains power transformer. It declares no ratio because it does not have one -- a
|
|
2179
|
+
// 290 V HV winding, a 5 V rectifier heater and a 6.3 V filament bus off one primary are
|
|
2180
|
+
// three different ratios on one core -- and, decisively, it does not need one.
|
|
2181
|
+
//
|
|
2182
|
+
// Those voltages live on the coils now, so there is nothing to lift into `parameters`:
|
|
2183
|
+
// `device-laws.ts` reads each coil's own `voltageRmsVolts`. What remains here is the
|
|
2184
|
+
// refusal, which has moved to a sharper question. It used to be "is this `*Secondary`
|
|
2185
|
+
// spelling one of the eight I know", a spelling check that could not see a stated voltage
|
|
2186
|
+
// whose winding was never declared -- `mesa-boogie-dual-rectifier` states a filament
|
|
2187
|
+
// voltage for terminals its transformer does not draw, and that was silently discarded.
|
|
2188
|
+
// Now it is "does any coil state a voltage", and a leftover typed winding property is a
|
|
2189
|
+
// quantity no coil claims.
|
|
2190
|
+
requireWindingSpecification(component);
|
|
2191
|
+
return {
|
|
2192
|
+
...windingResistanceParameters(component),
|
|
2193
|
+
};
|
|
2194
|
+
}
|
|
2195
|
+
// A voltage source's **frequency is what tells AC from DC**, and it is declared as a typed
|
|
2196
|
+
// quantity rather than left to prose.
|
|
2197
|
+
//
|
|
2198
|
+
// 19 of the 25 supplies in the amp corpus carry a `Frequency` beside their `Voltage`, often
|
|
2199
|
+
// structured: `fender-5f1-champ` states
|
|
2200
|
+
// `Frequency: {raw: "60 Hz assumed nominal", value: 60, unit: "Hz"}`. A mains inlet has one and
|
|
2201
|
+
// a battery cannot, so the *presence* of the property is the discriminator — a closed key name
|
|
2202
|
+
// compared whole, not a reading of `raw`, and nothing here parses "VAC" out of a sentence.
|
|
2203
|
+
//
|
|
2204
|
+
// This was previously recorded as an amp-lane blocker needing a source change, on the grounds
|
|
2205
|
+
// that "the compiler has no AC concept and the typed voltage does not say VAC". The first half
|
|
2206
|
+
// was true and the second was not: the discriminator was already in the packets and this stage
|
|
2207
|
+
// simply discarded it, because `parameterKeyByDeviceKind` reads exactly one property per kind.
|
|
2208
|
+
//
|
|
2209
|
+
// **This stage still does not say RMS or peak** — `value: 120` with `unit: "V"` is passed
|
|
2210
|
+
// through as a bare magnitude; only the prose says RMS, and this stage does not read prose.
|
|
2211
|
+
// That reading is decided downstream, in `device-laws.ts`'s `ac-source` law: this project's
|
|
2212
|
+
// convention is that a typed AC-source magnitude means RMS, so the law converts it to the
|
|
2213
|
+
// peak amplitude the sine evaluation needs.
|
|
2214
|
+
if (kind === "voltage-source") {
|
|
2215
|
+
const volts = optionalQuantity(component, "Voltage");
|
|
2216
|
+
const frequency = optionalQuantity(component, "Frequency");
|
|
2217
|
+
if (volts !== null) {
|
|
2218
|
+
return frequency === null ? { volts } : { volts, frequency };
|
|
2219
|
+
}
|
|
2220
|
+
}
|
|
2221
|
+
// An op-amp's open-loop gain, which 31 of the corpus's 52 declare and none of them reached.
|
|
2222
|
+
//
|
|
2223
|
+
// `Aol` is stated two ways and both are already linear: a bare number, and the structured form
|
|
2224
|
+
// with the decibel conversion done -- `{raw: "92 dB", value: 39810, unit: ""}`. So the typed
|
|
2225
|
+
// `value` is taken as-is and nothing here converts from dB, which would double-apply it.
|
|
2226
|
+
//
|
|
2227
|
+
// `GBP` and `SR` are deliberately not read. The gain-bandwidth product has no consumer until a
|
|
2228
|
+
// dominant pole exists, and that was implemented and reverted; reading it into the stamp now
|
|
2229
|
+
// would be a field nothing uses. `SR` is stated as `"1.2 V/us"`, and `parseQuantity` strips
|
|
2230
|
+
// after the slash, so it would yield 1.2 for the wrong reason -- right number, lost units.
|
|
2231
|
+
if (kind === "opamp") {
|
|
2232
|
+
// `typedNumber`, not `optionalQuantity`, and the difference is a 433x error.
|
|
2233
|
+
// `optionalQuantity` reads `raw` first, so `{raw: "92 dB", value: 39810, unit: ""}` parsed as
|
|
2234
|
+
// **92** -- the decibel figure taken as a linear gain. The structured `value` already carries
|
|
2235
|
+
// the conversion, so it has to win. Caught by measuring the stamped gains after the change
|
|
2236
|
+
// rather than by reading the code.
|
|
2237
|
+
const gain = typedNumber(component, "Aol", [""]);
|
|
2238
|
+
return gain !== null && gain > 0 ? { openLoopGain: gain } : {};
|
|
2239
|
+
}
|
|
2240
|
+
// A delay-memory shell's own delay time, which is a **packet** fact rather than a part fact.
|
|
2241
|
+
//
|
|
2242
|
+
// A bucket brigade's delay is `stages / (2 * f_clock)`, so the same chip is a 0.5 ms flanger in
|
|
2243
|
+
// one pedal and a 300 ms echo in another -- Panasonic's own MN3007 record spans 5.12 ms to
|
|
2244
|
+
// 51.2 ms across its clock range alone. The stage count belongs to the part and lives in the
|
|
2245
|
+
// registry; the clock rate belongs to the circuit. Where the clock network is modelled the
|
|
2246
|
+
// `parameter` port derives it, but a real BBD's clock net is a chip-only net between the
|
|
2247
|
+
// memory and its clock driver, so for a packet whose clock driver has no model there is no
|
|
2248
|
+
// derivation to make and the source's own declaration is the only fact available.
|
|
2249
|
+
//
|
|
2250
|
+
// Six corpus packets declare it and five are delay-memory shells (`boss-ce-1`, `boss-vb-2`,
|
|
2251
|
+
// `mxr-m117r-flanger`, `mxr-micro-flanger`, `electro-harmonix-electric-mistress`), so this is
|
|
2252
|
+
// the corpus's own convention for the quantity and not one packet's special case. It was
|
|
2253
|
+
// declared and discarded until now, in the same way `Aol` and `ZenerVoltage` were.
|
|
2254
|
+
//
|
|
2255
|
+
// `MinDelayMs`/`MaxDelayMs` are deliberately not read. They bound a control's travel, and no
|
|
2256
|
+
// control is bound to this parameter yet, so reading them would be a field nothing uses.
|
|
2257
|
+
if (kind === "ic") {
|
|
2258
|
+
const params = {};
|
|
2259
|
+
const delayProp = component.properties?.DelayMs;
|
|
2260
|
+
if (delayProp !== undefined && delayProp !== null) {
|
|
2261
|
+
if (typeof delayProp === "object") {
|
|
2262
|
+
const { value, unit } = delayProp;
|
|
2263
|
+
if (typeof value === "number" && Number.isFinite(value) && value > 0) {
|
|
2264
|
+
const u = typeof unit === "string"
|
|
2265
|
+
? unit.replaceAll("'", "").trim().toLowerCase()
|
|
2266
|
+
: "";
|
|
2267
|
+
if (u === "s" || u === "sec") {
|
|
2268
|
+
params.delaySeconds = value;
|
|
2269
|
+
}
|
|
2270
|
+
else if (u === "ms" || u === "") {
|
|
2271
|
+
params.delaySeconds = value / 1000;
|
|
2272
|
+
}
|
|
2273
|
+
}
|
|
2274
|
+
}
|
|
2275
|
+
else {
|
|
2276
|
+
const raw = propertyString(component, "DelayMs");
|
|
2277
|
+
if (raw !== null) {
|
|
2278
|
+
try {
|
|
2279
|
+
const num = parseQuantity(raw);
|
|
2280
|
+
if (num > 0) {
|
|
2281
|
+
params.delaySeconds =
|
|
2282
|
+
raw.toLowerCase().includes("s") &&
|
|
2283
|
+
!raw.toLowerCase().includes("ms")
|
|
2284
|
+
? num
|
|
2285
|
+
: num / 1000;
|
|
2286
|
+
}
|
|
2287
|
+
}
|
|
2288
|
+
catch { }
|
|
2289
|
+
}
|
|
2290
|
+
}
|
|
2291
|
+
}
|
|
2292
|
+
const outVolts = optionalQuantity(component, "OutputVoltage");
|
|
2293
|
+
if (outVolts !== null) {
|
|
2294
|
+
params.volts = outVolts;
|
|
2295
|
+
}
|
|
2296
|
+
return params;
|
|
2297
|
+
}
|
|
2298
|
+
// A capacitor's DC leakage, opt-in per declared part. At DC an ideal capacitor is an open
|
|
2299
|
+
// circuit, and that openness is load-bearing: a transcription error that leaves a bias
|
|
2300
|
+
// network reachable only through a capacitor shows up as a floating operating point
|
|
2301
|
+
// instead of rendering a plausible wrong answer. So leakage is read only where the source
|
|
2302
|
+
// declares it, never defaulted by class, and an undeclared capacitor stays ideal.
|
|
2303
|
+
//
|
|
2304
|
+
// Two printed datasheet forms, tried in this order:
|
|
2305
|
+
// - `InsulationResistance`, the film/ceramic spec, an ohm quantity used directly;
|
|
2306
|
+
// - `LeakageCurrent` at `VoltageRating`, the electrolytic spec, which only becomes a
|
|
2307
|
+
// resistance when both are declared. A leakage current with no stated voltage is
|
|
2308
|
+
// refused rather than guessed at, the same rule the BJT's `LeakageCurrent` follows
|
|
2309
|
+
// for prose: an unusable declaration is absent, not approximated.
|
|
2310
|
+
// Falls through to the generic tail below rather than returning, because a capacitor's
|
|
2311
|
+
// capacitance still comes from `parameterKeyByDeviceKind` -- these are extras beside it,
|
|
2312
|
+
// not a replacement for it.
|
|
2313
|
+
const capacitorLeak = {};
|
|
2314
|
+
if (kind === "capacitor") {
|
|
2315
|
+
const insulation = optionalQuantity(component, "InsulationResistance");
|
|
2316
|
+
if (insulation !== null && insulation > 0) {
|
|
2317
|
+
capacitorLeak.insulationOhms = insulation;
|
|
2318
|
+
}
|
|
2319
|
+
const leakage = optionalQuantity(component, "LeakageCurrent");
|
|
2320
|
+
const rated = optionalQuantity(component, "VoltageRating");
|
|
2321
|
+
if (leakage !== null && leakage > 0 && rated !== null && rated > 0) {
|
|
2322
|
+
capacitorLeak.leakageAmps = leakage;
|
|
2323
|
+
capacitorLeak.ratedVolts = rated;
|
|
2324
|
+
}
|
|
2325
|
+
}
|
|
2326
|
+
// A zener's breakdown voltage, which is the only thing separating it from a plain diode.
|
|
2327
|
+
//
|
|
2328
|
+
// A ferrite bead spec'd by impedance-vs-frequency carries no inductance in the source.
|
|
2329
|
+
// The default is applied downstream in `device-laws.ts`; an absent value is not an error.
|
|
2330
|
+
if (kind === "inductor") {
|
|
2331
|
+
// **Winding DC resistance, in three states.** A number is stamped; the string `unknown` is
|
|
2332
|
+
// the packet stating that the source does not print it -- Fender sheets give turns ratios
|
|
2333
|
+
// and impedances, never copper resistance -- and is carried as a claim rather than a value;
|
|
2334
|
+
// absence is neither, and is never defaulted to zero. Zero is the one physically impossible
|
|
2335
|
+
// value for a winding, and defaulting to it is how 67 corpus windings came to be lossless.
|
|
2336
|
+
//
|
|
2337
|
+
// `parameters` is `Record<string, number>`, so the third state is carried as its own flag
|
|
2338
|
+
// rather than by widening that map: `windingResistanceOhms` present means declared,
|
|
2339
|
+
// `windingResistanceUnknown: 1` means the packet said so explicitly, and neither means
|
|
2340
|
+
// nobody has said anything. Three states, still machine-readable, no default.
|
|
2341
|
+
const winding = windingResistanceParameters(component);
|
|
2342
|
+
const henries = optionalQuantity(component, "Inductance");
|
|
2343
|
+
if (henries !== null && henries > 0) {
|
|
2344
|
+
return { henries, ...winding };
|
|
2345
|
+
}
|
|
2346
|
+
const fallback = optionalQuantity(component, "L");
|
|
2347
|
+
if (fallback !== null && fallback > 0) {
|
|
2348
|
+
return { henries: fallback, ...winding };
|
|
2349
|
+
}
|
|
2350
|
+
return { ...winding };
|
|
2351
|
+
}
|
|
2352
|
+
// 25 diodes across 18 packets declare it, 13 of those packets compiling, and every one was
|
|
2353
|
+
// discarded: the stamp had no field for it, so a zener never broke down, never clamped, and
|
|
2354
|
+
// every zener-regulated rail floated to the supply. `boss-ph-1r` is the clean case -- it
|
|
2355
|
+
// declares a `5.1 V zener reference for the PH-1r bias rail` *and* a port stating 5.1 V on
|
|
2356
|
+
// that same node, so the source agrees with itself and the rail still solved at 8.441 V.
|
|
2357
|
+
//
|
|
2358
|
+
// Read from `ReverseBreakdownVoltage`, a typed quantity. `Type: Zener` alone is not enough to
|
|
2359
|
+
// act on -- it says the part is a zener without saying at what voltage -- so it is deliberately
|
|
2360
|
+
// not treated as evidence for a magnitude, and nothing reads a part number to guess one.
|
|
2361
|
+
if (kind === "diode") {
|
|
2362
|
+
// Three spellings of one quantity, all volts, all unambiguous. `ReverseBreakdownVoltage`
|
|
2363
|
+
// appears on one packet's diodes; `BreakdownVoltage` on four and `ZenerVoltage` on five
|
|
2364
|
+
// carried the same datum and were discarded, so the zener law reached 15 stamps and missed
|
|
2365
|
+
// nine purely on vocabulary.
|
|
2366
|
+
//
|
|
2367
|
+
// `SaturationCurrent` is not a synonym but the same shape of gap: a diode's own saturation
|
|
2368
|
+
// current, stated in amps, against a hardcoded class default of 2.52e-9.
|
|
2369
|
+
const breakdown = optionalQuantity(component, "ReverseBreakdownVoltage") ??
|
|
2370
|
+
optionalQuantity(component, "BreakdownVoltage") ??
|
|
2371
|
+
optionalQuantity(component, "ZenerVoltage");
|
|
2372
|
+
const saturation = optionalQuantity(component, "SaturationCurrent");
|
|
2373
|
+
const seriesResistance = optionalQuantity(component, "SeriesResistance") ??
|
|
2374
|
+
optionalQuantity(component, "OhmicResistance") ??
|
|
2375
|
+
optionalQuantity(component, "RS") ??
|
|
2376
|
+
optionalQuantity(component, "Rs");
|
|
2377
|
+
return {
|
|
2378
|
+
...(breakdown !== null && breakdown > 0
|
|
2379
|
+
? { breakdownVolts: breakdown }
|
|
2380
|
+
: {}),
|
|
2381
|
+
...(saturation !== null && saturation > 0
|
|
2382
|
+
? { saturationCurrent: saturation }
|
|
2383
|
+
: {}),
|
|
2384
|
+
...(seriesResistance !== null && seriesResistance > 0
|
|
2385
|
+
? { seriesResistance }
|
|
2386
|
+
: {}),
|
|
2387
|
+
};
|
|
2388
|
+
}
|
|
2389
|
+
// A bipolar transistor's device type and SPICE parameters, which this stage used to
|
|
2390
|
+
// discard in full.
|
|
2391
|
+
//
|
|
2392
|
+
// `deviceLaw` reads `parameters.pnp` and `parameters.beta`, and **nothing wrote either**:
|
|
2393
|
+
// there was no `bjt` entry here, so every one of the corpus's 385 bipolar transistors was
|
|
2394
|
+
// stamped as the same silicon NPN with `beta = 100` and `IS = 1e-14`, whatever the packet
|
|
2395
|
+
// said. 43 of them declare `Type: PNP`.
|
|
2396
|
+
//
|
|
2397
|
+
// The parameters are declared, typed, and specific. `sola-sound-tone-bender-professional-mkii`
|
|
2398
|
+
// gives `Q1` a `Type` of PNP, `IS` of `{raw: "1 nA", value: 1e-9, unit: A}`, `BF` 70, `BR` 2
|
|
2399
|
+
// and a `PartNumber` of `OC41` -- a germanium PNP. `Vbe = Vt * ln(Ic / IS)` at 1 mA is 0.345 V
|
|
2400
|
+
// at the declared `IS` against 0.633 V at the silicon default, so a germanium bias network
|
|
2401
|
+
// modelled with silicon never reaches turn-on and the pedal renders near-silence.
|
|
2402
|
+
//
|
|
2403
|
+
// Each is omitted when absent rather than defaulted here, so `deviceLaw` keeps ownership of
|
|
2404
|
+
// what an undeclared parameter means. Only 10% of the corpus's transistors declare `IS`.
|
|
2405
|
+
if (kind === "bjt") {
|
|
2406
|
+
// `Polarity` is the same closed vocabulary under a second spelling, and 17 of the corpus's
|
|
2407
|
+
// transistors declare it with no `Type` at all -- including `boss-tw-1`'s `Q4` and `Q5`,
|
|
2408
|
+
// declared `PNP` with a `2SA1015-Y` part number and stamped NPN because only `Type` was
|
|
2409
|
+
// read. Found by `report-source-coverage.ts` on its first run.
|
|
2410
|
+
const declaredType = propertyString(component, "Type") ??
|
|
2411
|
+
propertyString(component, "Polarity");
|
|
2412
|
+
const parameters = {};
|
|
2413
|
+
if (declaredType !== null) {
|
|
2414
|
+
const polarity = bjtPolarityByType[declaredType.toUpperCase()];
|
|
2415
|
+
if (polarity === undefined) {
|
|
2416
|
+
throw new NetlistError(`component ${component.id}: bjt has Type "${declaredType}", which is not NPN or PNP`, String(component.id));
|
|
2417
|
+
}
|
|
2418
|
+
parameters.pnp = polarity;
|
|
2419
|
+
}
|
|
2420
|
+
for (const [key, name] of [
|
|
2421
|
+
["IS", "saturationCurrent"],
|
|
2422
|
+
["BF", "beta"],
|
|
2423
|
+
["BR", "reverseBeta"],
|
|
2424
|
+
]) {
|
|
2425
|
+
const value = typedNumber(component, key, bjtParameterUnits[key] ?? []);
|
|
2426
|
+
if (value !== null && value > 0) {
|
|
2427
|
+
parameters[name] = value;
|
|
2428
|
+
}
|
|
2429
|
+
}
|
|
2430
|
+
// `LeakageCurrent` is the one BJT property the packets do **not** declare in the
|
|
2431
|
+
// structured form: all 14 that carry it carry a bare string, and they are not
|
|
2432
|
+
// homogeneous -- "0.15 uA", "0.1 \u03bcA" with the micro sign, "100 uA", and on
|
|
2433
|
+
// `dallas-arbiter-fuzz-face-ac128` the sentence "source-bounded germanium specimen
|
|
2434
|
+
// parameter; exact original unit unclaimed", which is a note and not a value.
|
|
2435
|
+
//
|
|
2436
|
+
// So this reads it as a quantity and treats an unparseable one as absent. That is the
|
|
2437
|
+
// same rule the other three follow, and it is honest here for a specific reason: the
|
|
2438
|
+
// one string that will not parse says in words that its value is unclaimed. Nothing
|
|
2439
|
+
// pattern-matches the prose -- `parseQuantity` refuses a leading token that is not a
|
|
2440
|
+
// quantity, and the refusal is the whole decision.
|
|
2441
|
+
const leakage = propertyString(component, "LeakageCurrent");
|
|
2442
|
+
if (leakage !== null) {
|
|
2443
|
+
try {
|
|
2444
|
+
const amps = parseQuantity(leakage);
|
|
2445
|
+
if (Number.isFinite(amps) && amps > 0) {
|
|
2446
|
+
parameters.leakageAmps = amps;
|
|
2447
|
+
}
|
|
2448
|
+
}
|
|
2449
|
+
catch {
|
|
2450
|
+
// Not a quantity, so not declared.
|
|
2451
|
+
}
|
|
2452
|
+
}
|
|
2453
|
+
return parameters;
|
|
2454
|
+
}
|
|
2455
|
+
/**
|
|
2456
|
+
* A FET's channel polarity from its declared type, or `null` when the document does not say.
|
|
2457
|
+
*
|
|
2458
|
+
* **Whole tokens against a closed vocabulary, never `includes("P")`.** The first version tested
|
|
2459
|
+
* `upper.includes("P")` then `upper.includes("N")`, which was correct on all eight values the
|
|
2460
|
+
* corpus happens to carry today only because `'N-channel JFET'` contains no P. Values one word
|
|
2461
|
+
* away break it, and P was tested first so P always won:
|
|
2462
|
+
*
|
|
2463
|
+
* 'N-channel depletion' -> p-channel (the P is in "depletion")
|
|
2464
|
+
* 'N-ch (P-suffix pkg)' -> p-channel
|
|
2465
|
+
* 'Depletion' -> p-channel (carries no channel information at all)
|
|
2466
|
+
*
|
|
2467
|
+
* A polarity read backwards inverts the device's whole transfer curve and stays silent, because
|
|
2468
|
+
* the circuit still solves. This is the same rule `wiperRoles` and `switchCommonRoles` already
|
|
2469
|
+
* follow, and the same failure the CD4047 pin map and the pot-wiper short were.
|
|
2470
|
+
*/
|
|
2471
|
+
function declaredChannel(declared) {
|
|
2472
|
+
// Folded the way `foldToken` folds: lowercased, and every separator dropped, so
|
|
2473
|
+
// `P-Channel`, `p channel`, `P_CHANNEL` and `pchannel` are one token.
|
|
2474
|
+
const folded = declared.toLowerCase().replace(/[^a-z0-9]/gu, "");
|
|
2475
|
+
const P = new Set([
|
|
2476
|
+
"p",
|
|
2477
|
+
"pchannel",
|
|
2478
|
+
"pchanneljfet",
|
|
2479
|
+
"pchannelmosfet",
|
|
2480
|
+
"pjfet",
|
|
2481
|
+
// SPICE's own device keywords, which is what `boss-pq-4` declares ("NJF").
|
|
2482
|
+
"pjf",
|
|
2483
|
+
"pmos",
|
|
2484
|
+
"pmosfet",
|
|
2485
|
+
"pfet",
|
|
2486
|
+
"pch",
|
|
2487
|
+
]);
|
|
2488
|
+
const N = new Set([
|
|
2489
|
+
"n",
|
|
2490
|
+
"nchannel",
|
|
2491
|
+
"nchanneljfet",
|
|
2492
|
+
"nchannelmosfet",
|
|
2493
|
+
"njfet",
|
|
2494
|
+
"njf",
|
|
2495
|
+
"nmos",
|
|
2496
|
+
"nmosfet",
|
|
2497
|
+
"nfet",
|
|
2498
|
+
"nch",
|
|
2499
|
+
]);
|
|
2500
|
+
if (P.has(folded)) {
|
|
2501
|
+
return "p";
|
|
2502
|
+
}
|
|
2503
|
+
if (N.has(folded)) {
|
|
2504
|
+
return "n";
|
|
2505
|
+
}
|
|
2506
|
+
// A prefixed spelling the closed set does not enumerate -- `n-channel depletion`,
|
|
2507
|
+
// `p-channel enhancement`. Anchored at the start and required to be followed by the word
|
|
2508
|
+
// `channel`, so the polarity letter cannot be picked up from the middle of another word.
|
|
2509
|
+
const prefixed = /^([np])channel/u.exec(folded);
|
|
2510
|
+
if (prefixed !== null) {
|
|
2511
|
+
return prefixed[1] === "p" ? "p" : "n";
|
|
2512
|
+
}
|
|
2513
|
+
return null;
|
|
2514
|
+
}
|
|
2515
|
+
if (kind === "jfet" || kind === "mosfet") {
|
|
2516
|
+
const parameters = {};
|
|
2517
|
+
const declaredType = propertyString(component, "Type") ??
|
|
2518
|
+
propertyString(component, "Polarity") ??
|
|
2519
|
+
propertyString(component, "Channel");
|
|
2520
|
+
if (declaredType !== null) {
|
|
2521
|
+
const channel = declaredChannel(declaredType);
|
|
2522
|
+
if (channel !== null) {
|
|
2523
|
+
parameters.pChannel = channel === "p" ? 1 : 0;
|
|
2524
|
+
}
|
|
2525
|
+
}
|
|
2526
|
+
const rawPChannel = component.properties?.pChannel;
|
|
2527
|
+
if (rawPChannel === 1 ||
|
|
2528
|
+
rawPChannel === "1" ||
|
|
2529
|
+
rawPChannel === true ||
|
|
2530
|
+
rawPChannel === "true") {
|
|
2531
|
+
parameters.pChannel = 1;
|
|
2532
|
+
}
|
|
2533
|
+
// "Vt0"/"Beta" first: those are the live corpus spellings (mxr-phase-90,
|
|
2534
|
+
// boss-ph-1r, mxr-noise-gate-line-driver, jhs-morning-glory), and no corpus
|
|
2535
|
+
// file uses the all-caps SPICE forms. Closed whole-key list, not matching.
|
|
2536
|
+
const vto = optionalQuantity(component, "Vt0") ??
|
|
2537
|
+
optionalQuantity(component, "VTO") ??
|
|
2538
|
+
optionalQuantity(component, "ThresholdVoltage") ??
|
|
2539
|
+
optionalQuantity(component, "thresholdVolts");
|
|
2540
|
+
if (vto !== null) {
|
|
2541
|
+
parameters.thresholdVolts = vto;
|
|
2542
|
+
}
|
|
2543
|
+
const beta = optionalQuantity(component, "Beta") ??
|
|
2544
|
+
optionalQuantity(component, "BETA") ??
|
|
2545
|
+
optionalQuantity(component, "Transconductance") ??
|
|
2546
|
+
optionalQuantity(component, "transconductance");
|
|
2547
|
+
if (beta !== null) {
|
|
2548
|
+
parameters.transconductance = beta;
|
|
2549
|
+
}
|
|
2550
|
+
return parameters;
|
|
2551
|
+
}
|
|
2552
|
+
const key = parameterKeyByDeviceKind[kind];
|
|
2553
|
+
if (key === undefined) {
|
|
2554
|
+
return {};
|
|
2555
|
+
}
|
|
2556
|
+
const name = parameterNameByDeviceKind[kind] ?? "value";
|
|
2557
|
+
const raw = propertyQuantityText(component, key) ??
|
|
2558
|
+
(fallbackParameterKeys[kind] ?? []).reduce((found, fallback) => found ?? propertyQuantityText(component, fallback), null);
|
|
2559
|
+
if (raw === null) {
|
|
2560
|
+
throw new NetlistError(`component ${component.id}: ${kind} has no ${key}`, String(component.id));
|
|
2561
|
+
}
|
|
2562
|
+
// A rheostat is the one device with two values: the track it sweeps and the residual
|
|
2563
|
+
// resistance at the bottom of the sweep. Absent, the bottom is a dead short, so the
|
|
2564
|
+
// stamp floors it rather than this stage inventing a minimum.
|
|
2565
|
+
// A pot reads it too, and used not to. This was gated on `rheostat` alone, so a
|
|
2566
|
+
// `MinResistance` declared on a `potentiometer` was parsed and then dropped on the
|
|
2567
|
+
// floor -- the same silent-drop shape as the five parameters `report-source-coverage.ts`
|
|
2568
|
+
// was written to catch, latent rather than live only because all 7 of the corpus's
|
|
2569
|
+
// declarations happen to sit on `variable-resistor` rows. The two kinds spend it
|
|
2570
|
+
// differently (`device-laws.ts`: a sweep floor for a rheostat, an end resistance taken
|
|
2571
|
+
// out of a fixed track for a pot), so reading it here is not the same as sharing a law.
|
|
2572
|
+
const extra = kind === "rheostat" || kind === "potentiometer"
|
|
2573
|
+
? { minOhms: optionalOhms(component, "MinResistance") }
|
|
2574
|
+
: {};
|
|
2575
|
+
// If the source states the quantity in base units (`unit: "F"`, `unit: "ohm"`, etc.),
|
|
2576
|
+
// use that directly instead of re-parsing an ambiguous schematic label like `.01 CER`
|
|
2577
|
+
// or `100/16` where parseQuantity would see `.01` or `100` as base units.
|
|
2578
|
+
const base = baseUnitQuantity(component, key, kind) ??
|
|
2579
|
+
(fallbackParameterKeys[kind] ?? []).reduce((found, fallback) => found ?? baseUnitQuantity(component, fallback, kind), null);
|
|
2580
|
+
if (base !== null) {
|
|
2581
|
+
return { [name]: base, ...extra, ...capacitorLeak };
|
|
2582
|
+
}
|
|
2583
|
+
return { [name]: parseQuantity(raw), ...extra, ...capacitorLeak };
|
|
2584
|
+
}
|
|
2585
|
+
/** An optional quantity, absent rather than defaulted when it is not declared. */
|
|
2586
|
+
function optionalOhms(component, key) {
|
|
2587
|
+
const raw = propertyString(component, key);
|
|
2588
|
+
if (raw === null) {
|
|
2589
|
+
return 0;
|
|
2590
|
+
}
|
|
2591
|
+
try {
|
|
2592
|
+
return parseQuantity(raw);
|
|
2593
|
+
}
|
|
2594
|
+
catch {
|
|
2595
|
+
// Same defect as `optionalQuantity`: read the component's own kind first, keeping
|
|
2596
|
+
// `resistor` as a second attempt so this cannot change a value that already resolved.
|
|
2597
|
+
return (baseUnitQuantity(component, key, component.kind) ??
|
|
2598
|
+
baseUnitQuantity(component, key, "resistor") ??
|
|
2599
|
+
0);
|
|
2600
|
+
}
|
|
2601
|
+
}
|
|
2602
|
+
/** An optional quantity, null when the component does not declare it at all. */
|
|
2603
|
+
/**
|
|
2604
|
+
* An impedance the source states as a **structured quantity** — `{raw, value, unit}` — in ohms.
|
|
2605
|
+
*
|
|
2606
|
+
* Deliberately strict. The same property names carry prose elsewhere in the corpus
|
|
2607
|
+
* (`"2 kΩ plate-to-plate reference-class"`), and a reader that fell back to scraping a number
|
|
2608
|
+
* out of that sentence would be deciding a device parameter from authored text. `null` here
|
|
2609
|
+
* means "the source did not state this as data", which is a source fact and refusable, not a
|
|
2610
|
+
* gap to paper over.
|
|
2611
|
+
*/
|
|
2612
|
+
/**
|
|
2613
|
+
* A turns ratio the source states as a **structured quantity**, or `null`.
|
|
2614
|
+
*
|
|
2615
|
+
* Typed only, and deliberately stricter than the `Ratio` beside it. `Ratio` keeps `optionalQuantity`,
|
|
2616
|
+
* which accepts the format's ordinary string value encoding -- `Ratio: "2"`, the same shape as a
|
|
2617
|
+
* resistor's `R: "10k"` -- and has fixture coverage for it.
|
|
2618
|
+
*
|
|
2619
|
+
* `TurnsRatio` does not inherit that, because its only two occurrences in the corpus are the colon
|
|
2620
|
+
* form: `fender-bassman` states `"44.72:1"` and `marshall-1959-super-lead-plexi` `"10.31:1"`.
|
|
2621
|
+
* `parseQuantity` reads those as `44.72` and `10.31`, which happens to be right -- and would read
|
|
2622
|
+
* `"1:44.72"` as `1`, silently inverting the transformer. A newly recognised spelling should not
|
|
2623
|
+
* inherit a coincidence, so this one requires the source to have committed to a number.
|
|
2624
|
+
*
|
|
2625
|
+
* No transformer declares either spelling typed today, so this reads nothing yet; it exists so that
|
|
2626
|
+
* typing one is not a trap. Both real occurrences match what the impedances give --
|
|
2627
|
+
* `sqrt(4000/2) = 44.72` and `sqrt(1700/16) = 10.31` -- confirming it is the same quantity.
|
|
2628
|
+
*
|
|
2629
|
+
* Dimensionless: an empty or absent unit, or the `:1` a ratio is conventionally written with. A
|
|
2630
|
+
* ratio carrying volts or ohms is a different quantity and is refused rather than guessed at.
|
|
2631
|
+
*/
|
|
2632
|
+
function typedRatio(component, key) {
|
|
2633
|
+
const property = component.properties[key];
|
|
2634
|
+
if (property === null || typeof property !== "object") {
|
|
2635
|
+
return null;
|
|
2636
|
+
}
|
|
2637
|
+
const { value, unit } = property;
|
|
2638
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
|
|
2639
|
+
return null;
|
|
2640
|
+
}
|
|
2641
|
+
const token = typeof unit === "string" ? unit.trim().toLowerCase() : "";
|
|
2642
|
+
return token === "" || token === ":1" ? value : null;
|
|
2643
|
+
}
|
|
2644
|
+
function typedOhms(component, key) {
|
|
2645
|
+
const property = component.properties[key];
|
|
2646
|
+
if (property === null || typeof property !== "object") {
|
|
2647
|
+
return null;
|
|
2648
|
+
}
|
|
2649
|
+
const { value, unit } = property;
|
|
2650
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
2651
|
+
return null;
|
|
2652
|
+
}
|
|
2653
|
+
const token = typeof unit === "string" ? unit.trim().toLowerCase() : "";
|
|
2654
|
+
return token === "" || token === "ω" || token === "ohm" || token === "ohms"
|
|
2655
|
+
? value
|
|
2656
|
+
: null;
|
|
2657
|
+
}
|
|
2658
|
+
/**
|
|
2659
|
+
* A winding voltage the source states as a **structured quantity** in volts.
|
|
2660
|
+
*
|
|
2661
|
+
* Same discipline as `typedOhms`, and deliberately without the empty-unit fallback that
|
|
2662
|
+
* ohms accepts: an unlabelled number could be almost anything, and a voltage-shaped
|
|
2663
|
+
* property with no unit is not the same source fact as one that says `V`. Every winding
|
|
2664
|
+
* voltage in the corpus that reaches this reads `unit: V`.
|
|
2665
|
+
*/
|
|
2666
|
+
function typedVolts(component, key) {
|
|
2667
|
+
const property = component.properties[key];
|
|
2668
|
+
if (property === null || typeof property !== "object") {
|
|
2669
|
+
return null;
|
|
2670
|
+
}
|
|
2671
|
+
const { value, unit } = property;
|
|
2672
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
2673
|
+
return null;
|
|
2674
|
+
}
|
|
2675
|
+
const token = typeof unit === "string" ? unit.trim().toLowerCase() : "";
|
|
2676
|
+
return token === "v" || token === "volt" || token === "volts" ? value : null;
|
|
2677
|
+
}
|
|
2678
|
+
/**
|
|
2679
|
+
* The impedance one of this transformer's coils is rated at, in ohms, or null.
|
|
2680
|
+
*
|
|
2681
|
+
* `roles` is tried in order and the first coil carrying a rating wins, which is how one reader
|
|
2682
|
+
* serves both a transformer (`primary`/`secondary`) and a spring reverb tank (`drive`/`pickup`) --
|
|
2683
|
+
* the two shapes that used to need `PrimaryImpedance`/`SecondaryImpedance` and
|
|
2684
|
+
* `InputImpedance`/`OutputImpedance` as separate property spellings.
|
|
2685
|
+
*
|
|
2686
|
+
* A coil may carry several ratings, one per rated tap. This returns the **first**, which for every
|
|
2687
|
+
* corpus coil but one is the only one; the multi-tap case is decided in `transformer.ts`, where
|
|
2688
|
+
* connectivity says which taps are live and each stamped winding takes the rating of its own tap.
|
|
2689
|
+
* A single number could not answer it here: `orange-rockerverb`'s output secondary rates a 16 Ω
|
|
2690
|
+
* and an 8 Ω tap that are loaded at the same time.
|
|
2691
|
+
*/
|
|
2692
|
+
function windingRatingOhms(component, roles) {
|
|
2693
|
+
const windings = component.windings;
|
|
2694
|
+
if (!Array.isArray(windings)) {
|
|
2695
|
+
return null;
|
|
2696
|
+
}
|
|
2697
|
+
for (const role of roles) {
|
|
2698
|
+
for (const winding of windings) {
|
|
2699
|
+
if (winding === null || typeof winding !== "object") {
|
|
2700
|
+
continue;
|
|
2701
|
+
}
|
|
2702
|
+
const entry = winding;
|
|
2703
|
+
if (entry.role !== role || !Array.isArray(entry.impedances)) {
|
|
2704
|
+
continue;
|
|
2705
|
+
}
|
|
2706
|
+
for (const rating of entry.impedances) {
|
|
2707
|
+
const ohms = quantityValue(rating?.impedance);
|
|
2708
|
+
if (ohms !== null && ohms > 0) {
|
|
2709
|
+
return ohms;
|
|
2710
|
+
}
|
|
2711
|
+
}
|
|
2712
|
+
}
|
|
2713
|
+
}
|
|
2714
|
+
return null;
|
|
2715
|
+
}
|
|
2716
|
+
/**
|
|
2717
|
+
* A transformer must state *something* a stamp can be built from, and this is where that is
|
|
2718
|
+
* required.
|
|
2719
|
+
*
|
|
2720
|
+
* Either a ratio, or a rated impedance pair, or at least one coil's own voltage. A document
|
|
2721
|
+
* satisfying none of the three describes a transformer nobody can lower, and it refuses here
|
|
2722
|
+
* rather than producing a silent open circuit downstream.
|
|
2723
|
+
*
|
|
2724
|
+
* **A typed winding-voltage property that survives on the component is a quantity no coil
|
|
2725
|
+
* claims.** Ratings live on the coils now, so a leftover typed `*Secondary` is either a value
|
|
2726
|
+
* whose winding the document never declared, or a backfill that stopped early. The old check in
|
|
2727
|
+
* this position asked whether the *spelling* was one of eight it recognised, which could not see
|
|
2728
|
+
* either case: `mesa-boogie-dual-rectifier` states a filament voltage for terminals its
|
|
2729
|
+
* transformer does not draw, under a spelling that was in the list, and it was silently dropped.
|
|
2730
|
+
*/
|
|
2731
|
+
function requireWindingSpecification(component) {
|
|
2732
|
+
const windings = component.windings;
|
|
2733
|
+
const coils = Array.isArray(windings) ? windings : [];
|
|
2734
|
+
for (const key of Object.keys(component.properties)) {
|
|
2735
|
+
if (!key.endsWith("Secondary") || typedVolts(component, key) === null) {
|
|
2736
|
+
continue;
|
|
2737
|
+
}
|
|
2738
|
+
throw new NetlistError(`component ${component.id}: transformer states a typed "${key}" winding voltage as a component property, but a coil's voltage belongs to the coil -- either no declared winding claims this value, or it has not been moved onto one`, String(component.id));
|
|
2739
|
+
}
|
|
2740
|
+
if (coils.some((winding) => quantityValue(winding?.voltage) !==
|
|
2741
|
+
null)) {
|
|
2742
|
+
return;
|
|
2743
|
+
}
|
|
2744
|
+
throw new NetlistError(`component ${component.id}: transformer states no Ratio, no rated impedance pair on its coils, and no coil voltage -- a transformer whose primary carries signal needs a ratio or an impedance pair, and one specified by its secondaries needs each coil's own voltage`, String(component.id));
|
|
2745
|
+
}
|
|
2746
|
+
/**
|
|
2747
|
+
* A winding's declared DC resistance, in three states, shared by the inductor and transformer
|
|
2748
|
+
* branches. One definition rather than two, because a shared policy module carrying two spellings
|
|
2749
|
+
* of its own policy is the defect `settle.ts` had this morning.
|
|
2750
|
+
*
|
|
2751
|
+
* A number is a value; the literal `unknown` is the packet stating the source does not print it --
|
|
2752
|
+
* Fender sheets give turns ratios and impedances, never copper resistance -- and is carried as its
|
|
2753
|
+
* own flag because `parameters` is `Record<string, number>`; silence produces neither. **Never
|
|
2754
|
+
* defaulted to zero**: zero is the one physically impossible value for a winding.
|
|
2755
|
+
*/
|
|
2756
|
+
function windingResistanceParameters(component) {
|
|
2757
|
+
// **Every spelling the corpus actually uses, not the one that came to mind.** Surveyed rather
|
|
2758
|
+
// than guessed: `WindingResistance` 2 packets, `WindingResistanceOhms` 4, `SeriesResistance` 2.
|
|
2759
|
+
// The first version of this function read the first and `DCResistance` -- which no packet uses
|
|
2760
|
+
// at all -- so it caught 2 of 8 declared values and silently discarded 6. That is the
|
|
2761
|
+
// synonym-orphan class this repository already has a report for, reproduced in new code hours
|
|
2762
|
+
// after it was written down: a reader that knows one spelling reports the others as absent, and
|
|
2763
|
+
// absent reads as a finding.
|
|
2764
|
+
const KEYS = [
|
|
2765
|
+
"WindingResistance",
|
|
2766
|
+
"WindingResistanceOhms",
|
|
2767
|
+
"SeriesResistance",
|
|
2768
|
+
"DCResistance",
|
|
2769
|
+
];
|
|
2770
|
+
let raw = null;
|
|
2771
|
+
let ohms = null;
|
|
2772
|
+
for (const key of KEYS) {
|
|
2773
|
+
raw ??= propertyString(component, key);
|
|
2774
|
+
ohms ??= optionalQuantity(component, key);
|
|
2775
|
+
}
|
|
2776
|
+
if (ohms !== null && ohms > 0)
|
|
2777
|
+
return { windingResistanceOhms: ohms };
|
|
2778
|
+
if (raw !== null && raw.trim().toLowerCase() === "unknown")
|
|
2779
|
+
return { windingResistanceUnknown: 1 };
|
|
2780
|
+
return {};
|
|
2781
|
+
}
|
|
2782
|
+
function optionalQuantity(component, key) {
|
|
2783
|
+
const raw = propertyString(component, key);
|
|
2784
|
+
if (raw === null) {
|
|
2785
|
+
return null;
|
|
2786
|
+
}
|
|
2787
|
+
try {
|
|
2788
|
+
return parseQuantity(raw);
|
|
2789
|
+
}
|
|
2790
|
+
catch {
|
|
2791
|
+
// The recovery read must use the COMPONENT'S OWN kind. Hardcoding one kind here
|
|
2792
|
+
// checks every typed quantity against that kind's units, so a well-formed
|
|
2793
|
+
// `value: 10, unit: H` on an inductor was tested against volts, rejected, and the
|
|
2794
|
+
// declared value thrown away -- `vox-ac30-top-boost`'s 292-767 supply choke states
|
|
2795
|
+
// 10 H and compiled to `FERRITE_BEAD_DEFAULT_HENRIES`, 10 uH: a factor of 1e6, and a
|
|
2796
|
+
// power-supply filter choke modelled as a wire. Its `raw` is prose-leading
|
|
2797
|
+
// ("source-visible choke 292-767, validation-derived 10 H"), which is why the string
|
|
2798
|
+
// parse threw and the typed value was the only thing left to read.
|
|
2799
|
+
//
|
|
2800
|
+
// The old kind is kept as a second attempt rather than replaced, so this is a pure
|
|
2801
|
+
// widening: every quantity that resolved before resolves to the same number.
|
|
2802
|
+
return (baseUnitQuantity(component, key, component.kind) ??
|
|
2803
|
+
baseUnitQuantity(component, key, "voltage-source"));
|
|
2804
|
+
}
|
|
2805
|
+
}
|
|
2806
|
+
/** Keys that may carry a reference to a declared control, checked in this order. */
|
|
2807
|
+
const controlReferenceKeys = [
|
|
2808
|
+
"InterfaceControlId",
|
|
2809
|
+
"interfaceControlId",
|
|
2810
|
+
"controlId",
|
|
2811
|
+
"ControlId",
|
|
2812
|
+
"control_id",
|
|
2813
|
+
"Control_Id",
|
|
2814
|
+
"Control",
|
|
2815
|
+
"control",
|
|
2816
|
+
"ControlledBy",
|
|
2817
|
+
"controlledBy",
|
|
2818
|
+
"Gang",
|
|
2819
|
+
"gang",
|
|
2820
|
+
"ControlName",
|
|
2821
|
+
"controlName",
|
|
2822
|
+
"ControlRole",
|
|
2823
|
+
"controlRole",
|
|
2824
|
+
"PhysicalControl",
|
|
2825
|
+
"physicalControl",
|
|
2826
|
+
"PanelLabel",
|
|
2827
|
+
"panelLabel",
|
|
2828
|
+
"Name",
|
|
2829
|
+
"name",
|
|
2830
|
+
"Label",
|
|
2831
|
+
"label",
|
|
2832
|
+
];
|
|
2833
|
+
export const HARDWARE_SWITCH_ROLES = new Set([
|
|
2834
|
+
"power",
|
|
2835
|
+
"power-switch",
|
|
2836
|
+
"standby",
|
|
2837
|
+
"mains",
|
|
2838
|
+
"service-trim",
|
|
2839
|
+
"ground-switch",
|
|
2840
|
+
"voltage-selector",
|
|
2841
|
+
"impedance-selector",
|
|
2842
|
+
]);
|
|
2843
|
+
export const HARDWARE_SWITCH_KINDS = new Set([
|
|
2844
|
+
"mains-power",
|
|
2845
|
+
"standby",
|
|
2846
|
+
"mains-voltage-selector",
|
|
2847
|
+
"rotary-impedance-selector",
|
|
2848
|
+
"ground-switch",
|
|
2849
|
+
]);
|
|
2850
|
+
/**
|
|
2851
|
+
* The `DeviceInterfaceControlKind` values that admit a real panel control into
|
|
2852
|
+
* `Program.controls`.
|
|
2853
|
+
*
|
|
2854
|
+
* Closed vocabulary compared as whole values, exactly as `@vessel-dsp/core`'s
|
|
2855
|
+
* `parseDeviceInterfaceControlKind` validates: `knob`, `slider`, `switch`,
|
|
2856
|
+
* `selector`, `footswitch`, `led`, `display`, `jack`. The potentiometer/sweep
|
|
2857
|
+
* class (`knob`, `slider`) is admitted together with the switches a player
|
|
2858
|
+
* operates — bypass footswitches, mode/range selectors, effect switches. The
|
|
2859
|
+
* hardware that cannot move the audio is excluded: `jack`, `led`, `display`,
|
|
2860
|
+
* plus anything outside the vocabulary. A control whose declared kind is not a
|
|
2861
|
+
* member is dropped from `Program.controls`; a control with no declared kind
|
|
2862
|
+
* passes, because an older document without the field is not assumed to be
|
|
2863
|
+
* hardware. A kind is compared whole, never by matching a control's name or
|
|
2864
|
+
* description.
|
|
2865
|
+
*/
|
|
2866
|
+
export const USER_CONTROL_KINDS = new Set([
|
|
2867
|
+
"knob",
|
|
2868
|
+
"slider",
|
|
2869
|
+
"switch",
|
|
2870
|
+
"selector",
|
|
2871
|
+
"footswitch",
|
|
2872
|
+
]);
|
|
2873
|
+
/**
|
|
2874
|
+
* Declared panel-control roles that name non-user hardware which is not a
|
|
2875
|
+
* switch: a contact a power-entry jack carries (`battery-contact`) or a status
|
|
2876
|
+
* light (`indicator`). Closed vocabulary compared as whole values — a role
|
|
2877
|
+
* that is not a member is never matched by substring or prose.
|
|
2878
|
+
*/
|
|
2879
|
+
export const NON_USER_HARDWARE_ROLES = new Set([
|
|
2880
|
+
"indicator",
|
|
2881
|
+
"battery-contact",
|
|
2882
|
+
]);
|
|
2883
|
+
/**
|
|
2884
|
+
* Whether a declared panel control is user-visible hardware the runtime should
|
|
2885
|
+
* expose.
|
|
2886
|
+
*
|
|
2887
|
+
* All decisions are whole-value comparisons against closed vocabularies — the
|
|
2888
|
+
* kind set {@link USER_CONTROL_KINDS} and the role sets
|
|
2889
|
+
* {@link HARDWARE_SWITCH_ROLES} and {@link NON_USER_HARDWARE_ROLES} — never a
|
|
2890
|
+
* read of the control's name or description, which the engineering principles
|
|
2891
|
+
* forbid. An absent kind or role passes: an older document that states neither
|
|
2892
|
+
* is not assumed to be hardware.
|
|
2893
|
+
*/
|
|
2894
|
+
export function isUserPanelControl(control) {
|
|
2895
|
+
const role = control.role;
|
|
2896
|
+
if (typeof role === "string" &&
|
|
2897
|
+
(HARDWARE_SWITCH_ROLES.has(role.toLowerCase()) ||
|
|
2898
|
+
NON_USER_HARDWARE_ROLES.has(role.toLowerCase()))) {
|
|
2899
|
+
return false;
|
|
2900
|
+
}
|
|
2901
|
+
const kind = control.kind;
|
|
2902
|
+
if (typeof kind === "string" &&
|
|
2903
|
+
kind.length > 0 &&
|
|
2904
|
+
!USER_CONTROL_KINDS.has(kind.toLowerCase())) {
|
|
2905
|
+
return false;
|
|
2906
|
+
}
|
|
2907
|
+
return true;
|
|
2908
|
+
}
|
|
2909
|
+
/**
|
|
2910
|
+
* Whether a component represents non-performance hardware, power-domain switching,
|
|
2911
|
+
* or internal service configuration rather than a player-adjustable control.
|
|
2912
|
+
*/
|
|
2913
|
+
export function isHardwareSwitch(component) {
|
|
2914
|
+
const role = component.role ??
|
|
2915
|
+
component.properties?.Role;
|
|
2916
|
+
if (typeof role === "string" &&
|
|
2917
|
+
HARDWARE_SWITCH_ROLES.has(role.toLowerCase())) {
|
|
2918
|
+
return true;
|
|
2919
|
+
}
|
|
2920
|
+
const switchKind = propertyString(component, "SwitchKind") ??
|
|
2921
|
+
propertyString(component, "switchKind");
|
|
2922
|
+
if (switchKind !== null &&
|
|
2923
|
+
HARDWARE_SWITCH_KINDS.has(switchKind.toLowerCase())) {
|
|
2924
|
+
return true;
|
|
2925
|
+
}
|
|
2926
|
+
const powerSwitch = propertyString(component, "PowerSwitch");
|
|
2927
|
+
if (powerSwitch === "true" ||
|
|
2928
|
+
component.properties
|
|
2929
|
+
?.PowerSwitch === true) {
|
|
2930
|
+
return true;
|
|
2931
|
+
}
|
|
2932
|
+
const serviceControl = propertyString(component, "ServiceControl");
|
|
2933
|
+
if (serviceControl === "true" ||
|
|
2934
|
+
component.properties
|
|
2935
|
+
?.ServiceControl === true) {
|
|
2936
|
+
return true;
|
|
2937
|
+
}
|
|
2938
|
+
const visibility = propertyString(component, "Visibility");
|
|
2939
|
+
if (visibility !== null && visibility.toLowerCase() === "internal") {
|
|
2940
|
+
return true;
|
|
2941
|
+
}
|
|
2942
|
+
if (propertyString(component, "ThermalFuse") !== null ||
|
|
2943
|
+
propertyString(component, "SelectedValidationState") !== null ||
|
|
2944
|
+
propertyString(component, "SelectedValidationTap") !== null) {
|
|
2945
|
+
return true;
|
|
2946
|
+
}
|
|
2947
|
+
return false;
|
|
2948
|
+
}
|
|
2949
|
+
/**
|
|
2950
|
+
* Strips multi-pole designator suffixes (e.g. `FOOTSWITCH_A` -> `FOOTSWITCH`, `S1A` -> `S1`, `SW2B` -> `SW2`)
|
|
2951
|
+
* so ganged switch poles bind to the shared base actuator.
|
|
2952
|
+
*/
|
|
2953
|
+
export function stripPoleSuffix(id) {
|
|
2954
|
+
const underscoreMatch = /^(.+?)_[A-Za-z0-9]$/.exec(id);
|
|
2955
|
+
if (underscoreMatch && underscoreMatch[1]) {
|
|
2956
|
+
return underscoreMatch[1];
|
|
2957
|
+
}
|
|
2958
|
+
const letterMatch = /^([A-Za-z]+\d+)[A-Za-z]$/.exec(id);
|
|
2959
|
+
if (letterMatch && letterMatch[1]) {
|
|
2960
|
+
return letterMatch[1];
|
|
2961
|
+
}
|
|
2962
|
+
return id;
|
|
2963
|
+
}
|
|
2964
|
+
/**
|
|
2965
|
+
* Which control varies this device.
|
|
2966
|
+
*
|
|
2967
|
+
* The test is **exact equality against the ids and labels this document itself declares** in
|
|
2968
|
+
* `deviceInterface.controls` -- a closed vocabulary, compared whole. It is emphatically
|
|
2969
|
+
* not "does this property mention a knob": a value only binds if it *is* a declared id or label.
|
|
2970
|
+
* That is why reading `Name` here is safe, and why it would not be safe to read it for
|
|
2971
|
+
* anything else.
|
|
2972
|
+
*
|
|
2973
|
+
* A device matching no declared id still gets a control, named for the component: a
|
|
2974
|
+
* trimmer or an internal switch is adjustable, just not on the enclosure. Refusing the
|
|
2975
|
+
* pedal over one unmapped trimmer would be strictness without a purpose.
|
|
2976
|
+
*/
|
|
2977
|
+
function controlBindingFor(component, declaredControls, kind,
|
|
2978
|
+
// A switch whose every connected terminal resolves to one node. It is a fixed
|
|
2979
|
+
// bridge, so it manufactures no control even though it binds no declared one.
|
|
2980
|
+
fixedContactSwitch) {
|
|
2981
|
+
if (isHardwareSwitch(component)) {
|
|
2982
|
+
return null;
|
|
2983
|
+
}
|
|
2984
|
+
for (const key of controlReferenceKeys) {
|
|
2985
|
+
const value = propertyString(component, key);
|
|
2986
|
+
if (value !== null) {
|
|
2987
|
+
const bound = declaredControls.get(value) ??
|
|
2988
|
+
declaredControls.get(value.toLowerCase());
|
|
2989
|
+
if (bound !== undefined) {
|
|
2990
|
+
return bound;
|
|
2991
|
+
}
|
|
2992
|
+
}
|
|
2993
|
+
}
|
|
2994
|
+
const idStr = String(component.id);
|
|
2995
|
+
const idBound = declaredControls.get(idStr) ?? declaredControls.get(idStr.toLowerCase());
|
|
2996
|
+
if (idBound !== undefined) {
|
|
2997
|
+
return idBound;
|
|
2998
|
+
}
|
|
2999
|
+
const baseId = stripPoleSuffix(idStr);
|
|
3000
|
+
if (baseId !== idStr) {
|
|
3001
|
+
const baseBound = declaredControls.get(baseId) ??
|
|
3002
|
+
declaredControls.get(baseId.toLowerCase());
|
|
3003
|
+
if (baseBound !== undefined) {
|
|
3004
|
+
return baseBound;
|
|
3005
|
+
}
|
|
3006
|
+
}
|
|
3007
|
+
if (kind === "potentiometer" || kind === "rheostat") {
|
|
3008
|
+
return idStr;
|
|
3009
|
+
}
|
|
3010
|
+
if (kind === "switch") {
|
|
3011
|
+
if (fixedContactSwitch) {
|
|
3012
|
+
return null;
|
|
3013
|
+
}
|
|
3014
|
+
return baseId;
|
|
3015
|
+
}
|
|
3016
|
+
return null;
|
|
3017
|
+
}
|
|
3018
|
+
/**
|
|
3019
|
+
* Every name this document uses for each control it declares, mapped to the control's
|
|
3020
|
+
* id.
|
|
3021
|
+
*
|
|
3022
|
+
* A control usually names itself twice: once as `id`, and once as the component name
|
|
3023
|
+
* its `audioBinding` points at -- `id: MID_FREQ` with `controlName: "Mid Frequency"`.
|
|
3024
|
+
* Components then reference whichever of the two their author had to hand, so matching
|
|
3025
|
+
* only the id leaves a pot bound to nothing and it invents a control of its own. That
|
|
3026
|
+
* is how one dual-gang knob became two independent knobs.
|
|
3027
|
+
*
|
|
3028
|
+
* Still a closed vocabulary compared as whole values: every entry is a name this
|
|
3029
|
+
* document declares for a control it declares.
|
|
3030
|
+
*/
|
|
3031
|
+
function declaredControlNames(document) {
|
|
3032
|
+
const names = new Map();
|
|
3033
|
+
for (const control of document.deviceInterface?.controls ?? []) {
|
|
3034
|
+
if (!isUserPanelControl(control)) {
|
|
3035
|
+
continue;
|
|
3036
|
+
}
|
|
3037
|
+
const id = String(control.id);
|
|
3038
|
+
names.set(id, id);
|
|
3039
|
+
names.set(id.toLowerCase(), id);
|
|
3040
|
+
const label = control.label;
|
|
3041
|
+
if (typeof label === "string" && label.trim().length > 0) {
|
|
3042
|
+
const l = label.trim();
|
|
3043
|
+
if (!names.has(l)) {
|
|
3044
|
+
names.set(l, id);
|
|
3045
|
+
}
|
|
3046
|
+
if (!names.has(l.toLowerCase())) {
|
|
3047
|
+
names.set(l.toLowerCase(), id);
|
|
3048
|
+
}
|
|
3049
|
+
}
|
|
3050
|
+
const bound = control
|
|
3051
|
+
.audioBinding?.controlName;
|
|
3052
|
+
if (typeof bound === "string" && bound.trim().length > 0) {
|
|
3053
|
+
const b = bound.trim();
|
|
3054
|
+
// The id wins if two controls claim the same alias.
|
|
3055
|
+
if (!names.has(b)) {
|
|
3056
|
+
names.set(b, id);
|
|
3057
|
+
}
|
|
3058
|
+
if (!names.has(b.toLowerCase())) {
|
|
3059
|
+
names.set(b.toLowerCase(), id);
|
|
3060
|
+
}
|
|
3061
|
+
}
|
|
3062
|
+
}
|
|
3063
|
+
return names;
|
|
3064
|
+
}
|
|
3065
|
+
/**
|
|
3066
|
+
* A switch's contact state, as a **closed vocabulary compared as whole values** — never a
|
|
3067
|
+
* substring or a regex over authored prose, which the engineering principles forbid.
|
|
3068
|
+
*
|
|
3069
|
+
* A switch is the one kind that states where it is set as a contact state rather than as a
|
|
3070
|
+
* travel position: `State: effect-on` on `pro-co-rat`'s bypass-control contact, `State:
|
|
3071
|
+
* closed` on three amp switches. Both mean the contacts are made, which is `1` against the
|
|
3072
|
+
* runtime's `position >= 0.5` threshold.
|
|
3073
|
+
*
|
|
3074
|
+
* Measured before it was written: across every `.vdsp` in the pedal *and* amp corpora,
|
|
3075
|
+
* `State` appears on `kind: switch` and on nothing else, and carries exactly these two
|
|
3076
|
+
* values on nine components. An unrecognised value is a refusal that names it (see
|
|
3077
|
+
* `declaredPosition`) rather than a guess, for the same reason the runtime refuses an
|
|
3078
|
+
* operator it does not implement: a switch silently defaulted to the wrong contact is a
|
|
3079
|
+
* plausible wrong circuit, not a visible failure.
|
|
3080
|
+
*/
|
|
3081
|
+
const switchContactStates = {
|
|
3082
|
+
"effect-on": 1,
|
|
3083
|
+
closed: 1,
|
|
3084
|
+
// The amp corpus's spellings for the same fact. `vox-ac15-top-boost`'s standby switch says
|
|
3085
|
+
// `closed/on`, and until this was here that statement was dropped and the switch fell to the
|
|
3086
|
+
// "nobody said" default of open -- which disconnected B+ from the whole amp.
|
|
3087
|
+
"closed/on": 1,
|
|
3088
|
+
on: 1,
|
|
3089
|
+
// `fender-bassman` spells the same fact this way on its AC and standby switches. Compared
|
|
3090
|
+
// whole, like every other entry: this stage does not read a substring out of it. The spelling
|
|
3091
|
+
// is worth normalising on the artifact side, and until then dropping it would cost that amp
|
|
3092
|
+
// its supply.
|
|
3093
|
+
"closed/on for operating validation": 1,
|
|
3094
|
+
// `hiwatt-dr103`'s power switch states `SelectedState: "ON for validation"`.
|
|
3095
|
+
"on for validation": 1,
|
|
3096
|
+
// `pigtronix-philosophers-tone`'s footswitch states `DefaultState: "effect"`.
|
|
3097
|
+
effect: 1,
|
|
3098
|
+
// `mesa-boogie-mark-v`'s power switch states `SelectedState: "FULL"`.
|
|
3099
|
+
full: 1,
|
|
3100
|
+
};
|
|
3101
|
+
function statedContactState(component) {
|
|
3102
|
+
if (String(component.kind) !== "switch") {
|
|
3103
|
+
return null;
|
|
3104
|
+
}
|
|
3105
|
+
return propertyString(component, "State");
|
|
3106
|
+
}
|
|
3107
|
+
/**
|
|
3108
|
+
* A contact state this stage recognises, from either spelling the corpus uses, or `null`.
|
|
3109
|
+
*
|
|
3110
|
+
* **`State` stays strict and `SelectedState` does not, and the difference is what the corpus
|
|
3111
|
+
* declares.** `State` has one meaning, so an unrecognised value there is an error the caller
|
|
3112
|
+
* raises. `SelectedState` carries a contact state on 5 of its 12 appearances (`closed/on`,
|
|
3113
|
+
* `closed`, `on`) and something else entirely on the rest -- `16 ohms`, `full`,
|
|
3114
|
+
* `90w full-power silicon path`, a whole sentence about input routing -- so it is compared as a
|
|
3115
|
+
* whole value against the closed vocabulary and ignored when it is not in it. Throwing on those
|
|
3116
|
+
* would refuse packets over a field that is not making this claim.
|
|
3117
|
+
*
|
|
3118
|
+
* Measured 2026-08-21: `vox-ac15-top-boost`, `vox-ac30-top-boost` and `fender-bassman` each state
|
|
3119
|
+
* `SelectedState: "closed/on"` on the standby switch that feeds their B+, nothing read it, and all
|
|
3120
|
+
* three rendered silence with every plate at 0.0 V while their rectifiers reached 424 V.
|
|
3121
|
+
*/
|
|
3122
|
+
function recognisedContactPosition(component) {
|
|
3123
|
+
if (String(component.kind) !== "switch") {
|
|
3124
|
+
return null;
|
|
3125
|
+
}
|
|
3126
|
+
// **A fuse conducts.** A two-terminal switch declaring a `FuseRating` is a fuse, and a fuse
|
|
3127
|
+
// is a closed conductor unless it has blown -- which is a fault a source would have to state,
|
|
3128
|
+
// not a default this stage should assume. Measured 2026-08-21: **26 switch components across
|
|
3129
|
+
// the amp corpus declare a `FuseRating` and every one of them states no contact position**, so
|
|
3130
|
+
// every fuse in every amp defaulted open. On `vox-ac15-top-boost` that is an HT fuse sitting
|
|
3131
|
+
// between the standby switch and the reservoir cap, which alone kept B+ off every plate.
|
|
3132
|
+
//
|
|
3133
|
+
// The evidence is the presence of a typed property name, compared whole. Nothing reads its
|
|
3134
|
+
// value, and nothing reads a name or a description.
|
|
3135
|
+
if (propertyString(component, "FuseRating") !== null) {
|
|
3136
|
+
return 1;
|
|
3137
|
+
}
|
|
3138
|
+
const closed = component.properties?.Closed;
|
|
3139
|
+
if (closed === true || closed === "true" || closed === 1 || closed === "1") {
|
|
3140
|
+
return 1;
|
|
3141
|
+
}
|
|
3142
|
+
if (closed === false || closed === "false" || closed === 0 || closed === "0") {
|
|
3143
|
+
return 0;
|
|
3144
|
+
}
|
|
3145
|
+
const selected = propertyString(component, "SelectedState") ??
|
|
3146
|
+
propertyString(component, "DefaultState") ??
|
|
3147
|
+
propertyString(component, "SelectedThrow");
|
|
3148
|
+
if (selected === null) {
|
|
3149
|
+
return null;
|
|
3150
|
+
}
|
|
3151
|
+
const simple = switchContactStates[selected.trim().toLowerCase()];
|
|
3152
|
+
if (simple !== undefined) {
|
|
3153
|
+
return simple;
|
|
3154
|
+
}
|
|
3155
|
+
return selectedThrowPosition(component, selected);
|
|
3156
|
+
}
|
|
3157
|
+
/**
|
|
3158
|
+
* Is this terminal one of a switch's throws, for the purpose of resolving *which* throw a control
|
|
3159
|
+
* selects?
|
|
3160
|
+
*
|
|
3161
|
+
* The declared role decides where the document states one, and a name is read only where it does
|
|
3162
|
+
* not. Both callers used to consult `switchCommonRoles` alone -- the last two program-affecting
|
|
3163
|
+
* readers of it once `switchPoles` moved to declarations, and the reason ten packets still changed
|
|
3164
|
+
* when it was emptied. `coil` and `pin` are excluded here too: a relay coil and a mounting lug are
|
|
3165
|
+
* not throws a control can select, which a name vocabulary of pole spellings could never say.
|
|
3166
|
+
*/
|
|
3167
|
+
function isSelectableThrow(terminal) {
|
|
3168
|
+
// A terminal declaring nothing is treated as selectable, which is what the deleted
|
|
3169
|
+
// `switchCommonRoles` fallback did for every name that was not a pole spelling -- and no
|
|
3170
|
+
// corpus switch reaches here undeclared with a pole-shaped name.
|
|
3171
|
+
const declared = typeof terminal.role === "string" ? terminal.role : null;
|
|
3172
|
+
return declared === null || declared === "throw";
|
|
3173
|
+
}
|
|
3174
|
+
/**
|
|
3175
|
+
* Resolve a multi-throw selector position from authored throw text (e.g. `SelectedThrow: 16 ohm`).
|
|
3176
|
+
*/
|
|
3177
|
+
function selectedThrowPosition(component, stated) {
|
|
3178
|
+
const throwTerminals = component.terminals.filter(isSelectableThrow);
|
|
3179
|
+
if (throwTerminals.length <= 1) {
|
|
3180
|
+
return null;
|
|
3181
|
+
}
|
|
3182
|
+
const statedNorm = stated.toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
3183
|
+
let index = throwTerminals.findIndex((t) => {
|
|
3184
|
+
const token = terminalRoleToken(String(t.name)) ?? "";
|
|
3185
|
+
return token.length > 0 && (statedNorm.includes(token) || token.includes(statedNorm));
|
|
3186
|
+
});
|
|
3187
|
+
if (index === -1) {
|
|
3188
|
+
const numbersInStated = stated.match(/\d+/g) ?? [];
|
|
3189
|
+
for (const num of numbersInStated) {
|
|
3190
|
+
const found = throwTerminals.findIndex((t) => {
|
|
3191
|
+
const numInTerm = String(t.name).match(/\d+/);
|
|
3192
|
+
return numInTerm !== null && numInTerm[0] === num;
|
|
3193
|
+
});
|
|
3194
|
+
if (found !== -1) {
|
|
3195
|
+
index = found;
|
|
3196
|
+
break;
|
|
3197
|
+
}
|
|
3198
|
+
}
|
|
3199
|
+
}
|
|
3200
|
+
if (index === -1) {
|
|
3201
|
+
return null;
|
|
3202
|
+
}
|
|
3203
|
+
return (index + 0.5) / throwTerminals.length;
|
|
3204
|
+
}
|
|
3205
|
+
/** Wiper position as authored, 0..1. */
|
|
3206
|
+
/** Whether the source states where this component is set, at all. */
|
|
3207
|
+
function statesPosition(component) {
|
|
3208
|
+
return ((propertyString(component, "Wipe") ??
|
|
3209
|
+
propertyString(component, "Position") ??
|
|
3210
|
+
statedContactState(component)) !== null ||
|
|
3211
|
+
recognisedContactPosition(component) !== null);
|
|
3212
|
+
}
|
|
3213
|
+
function declaredPosition(component) {
|
|
3214
|
+
const raw = propertyString(component, "Wipe") ?? propertyString(component, "Position");
|
|
3215
|
+
if (raw === null) {
|
|
3216
|
+
// The second spelling first, because it is the non-throwing one: a `SelectedState` this
|
|
3217
|
+
// stage recognises is a stated position, and one it does not recognise is a different
|
|
3218
|
+
// claim that must not become an error.
|
|
3219
|
+
const recognised = recognisedContactPosition(component);
|
|
3220
|
+
if (recognised !== null) {
|
|
3221
|
+
return recognised;
|
|
3222
|
+
}
|
|
3223
|
+
// A switch that states a contact state instead of a travel position. Read before
|
|
3224
|
+
// the `0` default below, because that default is "nobody said", and here somebody
|
|
3225
|
+
// did -- `pro-co-rat`'s bypass-control contact sits off the input-to-output path,
|
|
3226
|
+
// so the port-connectivity fallback left it open against a source that says it is
|
|
3227
|
+
// made.
|
|
3228
|
+
const contact = statedContactState(component);
|
|
3229
|
+
if (contact !== null) {
|
|
3230
|
+
const position = switchContactStates[contact.trim().toLowerCase()];
|
|
3231
|
+
if (position === undefined) {
|
|
3232
|
+
throw new NetlistError(`component ${component.id}: switch declares State "${contact}", which is not a recognised contact state (${Object.keys(switchContactStates).join(", ")})`, String(component.id));
|
|
3233
|
+
}
|
|
3234
|
+
return position;
|
|
3235
|
+
}
|
|
3236
|
+
if (String(component.kind) === "switch") {
|
|
3237
|
+
return 0;
|
|
3238
|
+
}
|
|
3239
|
+
return 0.5;
|
|
3240
|
+
}
|
|
3241
|
+
// A switch states its position as one of the options it declares -- `Position:
|
|
3242
|
+
// Effect` against `Options: Normal,Effect` -- so it must be resolved against that
|
|
3243
|
+
// list before it is read as a number. `Number("Effect")` is NaN and would silently
|
|
3244
|
+
// become half travel, which on a two-throw switch is the *other* throw.
|
|
3245
|
+
const selected = selectedOption(component, raw);
|
|
3246
|
+
if (selected !== null) {
|
|
3247
|
+
return selected;
|
|
3248
|
+
}
|
|
3249
|
+
const value = Number(raw);
|
|
3250
|
+
return Number.isFinite(value) ? Math.min(1, Math.max(0, value)) : 0.5;
|
|
3251
|
+
}
|
|
3252
|
+
/**
|
|
3253
|
+
* Where a stated position sits among the throws the component declares, as 0..1.
|
|
3254
|
+
*
|
|
3255
|
+
* Two lists have to be reconciled: the `Options` the component names, and the throw
|
|
3256
|
+
* terminals it wires. **They are not always in the same order** -- three corpus
|
|
3257
|
+
* selectors declare `Options: low,high` against throws `high,low`, and taking the
|
|
3258
|
+
* option's index would stick a range switch on the wrong range. So the throw whose
|
|
3259
|
+
* role token *is* the selected option name wins, and the option's index is only the
|
|
3260
|
+
* fallback for throws named `throwA`/`throw1`, which carry no such evidence.
|
|
3261
|
+
*
|
|
3262
|
+
* The result is the centre of the selected throw's band, so the runtime's even
|
|
3263
|
+
* division of control travel lands back on the same throw.
|
|
3264
|
+
*/
|
|
3265
|
+
function selectedOption(component, stated) {
|
|
3266
|
+
const declared = propertyString(component, "Options");
|
|
3267
|
+
if (declared === null) {
|
|
3268
|
+
return null;
|
|
3269
|
+
}
|
|
3270
|
+
const options = declared
|
|
3271
|
+
.split(",")
|
|
3272
|
+
.map((option) => option.trim())
|
|
3273
|
+
.filter((option) => option.length > 0);
|
|
3274
|
+
if (options.length < 2) {
|
|
3275
|
+
return null;
|
|
3276
|
+
}
|
|
3277
|
+
const wanted = stated.trim().toLowerCase();
|
|
3278
|
+
const optionIndex = options.findIndex((option) => option.toLowerCase() === wanted);
|
|
3279
|
+
if (optionIndex === -1) {
|
|
3280
|
+
return null;
|
|
3281
|
+
}
|
|
3282
|
+
const throwRoles = component.terminals
|
|
3283
|
+
.filter(isSelectableThrow)
|
|
3284
|
+
.map((terminal) => terminalRoleToken(String(terminal.name)));
|
|
3285
|
+
if (throwRoles.length === 0) {
|
|
3286
|
+
return null;
|
|
3287
|
+
}
|
|
3288
|
+
const named = throwRoles.findIndex((role) => role !== null && role === terminalRoleToken(options[optionIndex] ?? ""));
|
|
3289
|
+
const index = named === -1 ? optionIndex : named;
|
|
3290
|
+
return index >= throwRoles.length ? null : (index + 0.5) / throwRoles.length;
|
|
3291
|
+
}
|
|
3292
|
+
/**
|
|
3293
|
+
* Every taper spelling the corpus declares, as whole values.
|
|
3294
|
+
*
|
|
3295
|
+
* Measured across the corpus rather than guessed, because prefix matching was wrong in
|
|
3296
|
+
* both directions: `startsWith("rev")` read `ReverseLinear` as reverse-*logarithmic*,
|
|
3297
|
+
* and `AntiLogarithmic` matched no rule at all and fell through to linear -- silently,
|
|
3298
|
+
* in both Tube Screamers among others. A taper is one of the most audible things about
|
|
3299
|
+
* a pedal, so an unrecognised spelling must stay unrecognised rather than land on a
|
|
3300
|
+
* plausible neighbour.
|
|
3301
|
+
*
|
|
3302
|
+
* Deliberately absent: `W`, `W20`, `G`, `D`, `L`, `BH`, `Stepped`, `Trim`,
|
|
3303
|
+
* `Boss-G-taper` and the other manufacturer-specific curves. We do not know their
|
|
3304
|
+
* shapes, and guessing one would be inventing a device law from a letter.
|
|
3305
|
+
*/
|
|
3306
|
+
const taperSpellings = new Map([
|
|
3307
|
+
["linear", "linear"],
|
|
3308
|
+
["b", "linear"],
|
|
3309
|
+
["log", "logarithmic"],
|
|
3310
|
+
["logarithmic", "logarithmic"],
|
|
3311
|
+
["audio", "logarithmic"],
|
|
3312
|
+
["a", "logarithmic"],
|
|
3313
|
+
["a/log", "logarithmic"],
|
|
3314
|
+
["reverselog", "reverse-logarithmic"],
|
|
3315
|
+
["reverselogarithmic", "reverse-logarithmic"],
|
|
3316
|
+
["reverseaudio", "reverse-logarithmic"],
|
|
3317
|
+
["antilog", "reverse-logarithmic"],
|
|
3318
|
+
["antilogarithmic", "reverse-logarithmic"],
|
|
3319
|
+
["negativelog", "reverse-logarithmic"],
|
|
3320
|
+
["c", "reverse-logarithmic"],
|
|
3321
|
+
["reverselinear", "reverse-linear"],
|
|
3322
|
+
]);
|
|
3323
|
+
/**
|
|
3324
|
+
* Values whose meaning is "the source does not state a taper".
|
|
3325
|
+
*
|
|
3326
|
+
* These are not markings this stage failed to read -- they are the packet recording that its
|
|
3327
|
+
* source does not say, which is a complete statement. Linear is the stated fallback for them and
|
|
3328
|
+
* no warning is raised, because nothing was lost. 19 of the corpus's declarations are this shape.
|
|
3329
|
+
*/
|
|
3330
|
+
const taperUnknownSpellings = new Set([
|
|
3331
|
+
"unknown",
|
|
3332
|
+
"sourceunmarked",
|
|
3333
|
+
"sourcemarked",
|
|
3334
|
+
"sourceunspecified",
|
|
3335
|
+
]);
|
|
3336
|
+
/**
|
|
3337
|
+
* Tapers `@vessel-dsp/core`'s format supports and this runtime has no curve for.
|
|
3338
|
+
*
|
|
3339
|
+
* `PotentiometerTaper` carries seven values; `TaperKind` -- the executable law -- carries four.
|
|
3340
|
+
* A document declaring one of these three has stated a real taper that the render cannot produce,
|
|
3341
|
+
* which is a different failure from an unreadable marking and is worth saying differently.
|
|
3342
|
+
*/
|
|
3343
|
+
const taperWithoutLaw = new Set([
|
|
3344
|
+
"scurve",
|
|
3345
|
+
"stepped",
|
|
3346
|
+
"custom",
|
|
3347
|
+
]);
|
|
3348
|
+
/** Case and separators folded, so `reverse-log` and `Reverse Log` are one spelling. */
|
|
3349
|
+
function foldTaper(value) {
|
|
3350
|
+
return value.trim().toLowerCase().replace(/[\s_-]+/gu, "");
|
|
3351
|
+
}
|
|
3352
|
+
/**
|
|
3353
|
+
* The taper a declared value names, or null when this vocabulary does not contain it.
|
|
3354
|
+
* Whole-value lookup against a closed set; separators fold so `reverse-log`,
|
|
3355
|
+
* `Reverse Log` and `ReverseLog` are one spelling, but nothing is ever matched as a
|
|
3356
|
+
* substring of a longer phrase.
|
|
3357
|
+
*/
|
|
3358
|
+
function taperKind(value) {
|
|
3359
|
+
return taperSpellings.get(foldTaper(value)) ?? null;
|
|
3360
|
+
}
|
|
3361
|
+
/**
|
|
3362
|
+
* The taper to execute, and whether anything was lost getting there.
|
|
3363
|
+
*
|
|
3364
|
+
* **The fallback to linear is unchanged; what changes is that it stops being silent.** Measured
|
|
3365
|
+
* 2026-09-03, 69 of the corpus's 608 declared tapers fell through to linear without a word, an
|
|
3366
|
+
* audible difference across the whole sweep of an audio knob. Three outcomes now:
|
|
3367
|
+
*
|
|
3368
|
+
* - the value names a law -> use it, say nothing;
|
|
3369
|
+
* - the value says the source does not know -> linear, say nothing, because there is nothing to
|
|
3370
|
+
* have read;
|
|
3371
|
+
* - the value states anything else -> linear, and **name it**, whether it is a part marking whose
|
|
3372
|
+
* curve is not derivable here (`W20`, `Boss-G-taper`) or a taper the format supports and this
|
|
3373
|
+
* runtime has no law for (`s-curve`, `stepped`).
|
|
3374
|
+
*/
|
|
3375
|
+
function declaredTaper(component) {
|
|
3376
|
+
// `Sweep` is what a variable-resistor calls its taper. Both are closed vocabularies
|
|
3377
|
+
// compared as whole values, never substrings of a description.
|
|
3378
|
+
const declared = propertyString(component, "Taper") ?? propertyString(component, "Sweep");
|
|
3379
|
+
if (declared === null) {
|
|
3380
|
+
return { taper: "linear", unreadable: null };
|
|
3381
|
+
}
|
|
3382
|
+
const resolved = taperKind(declared);
|
|
3383
|
+
if (resolved !== null) {
|
|
3384
|
+
return { taper: resolved, unreadable: null };
|
|
3385
|
+
}
|
|
3386
|
+
return {
|
|
3387
|
+
taper: "linear",
|
|
3388
|
+
unreadable: taperUnknownSpellings.has(foldTaper(declared)) ? null : declared,
|
|
3389
|
+
};
|
|
3390
|
+
}
|
|
3391
|
+
/**
|
|
3392
|
+
* The `role` a terminal declares, or null.
|
|
3393
|
+
*
|
|
3394
|
+
* Read verbatim: this is a closed vocabulary core validates, so there is nothing here to fold,
|
|
3395
|
+
* alias or guess. The whole point of the field is that a consumer does not interpret it.
|
|
3396
|
+
*/
|
|
3397
|
+
function declaredRole(terminal) {
|
|
3398
|
+
const value = terminal !== null && typeof terminal === "object"
|
|
3399
|
+
? terminal.role
|
|
3400
|
+
: undefined;
|
|
3401
|
+
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
|
3402
|
+
}
|
|
3403
|
+
/** Panel control ids that some component's program reads `tapped` (core 0.13.0). */
|
|
3404
|
+
function tappedControlIds(document) {
|
|
3405
|
+
const named = new Set();
|
|
3406
|
+
const components = document.components;
|
|
3407
|
+
if (!Array.isArray(components))
|
|
3408
|
+
return named;
|
|
3409
|
+
for (const component of components) {
|
|
3410
|
+
const program = declaredProgram(component);
|
|
3411
|
+
if (program === undefined)
|
|
3412
|
+
continue;
|
|
3413
|
+
for (const position of program.positions) {
|
|
3414
|
+
const cited = [
|
|
3415
|
+
...Object.values(position.lines).flatMap((parameters) => Object.values(parameters)),
|
|
3416
|
+
...Object.values(position.parameters),
|
|
3417
|
+
];
|
|
3418
|
+
for (const parameter of cited) {
|
|
3419
|
+
if (parameter.read === "tapped" && parameter.control !== undefined) {
|
|
3420
|
+
named.add(parameter.control);
|
|
3421
|
+
}
|
|
3422
|
+
}
|
|
3423
|
+
// A hold sampler's gate is a pedal the chip reads while it is held: a press, so the
|
|
3424
|
+
// contact springs back exactly as a tapped one does. The op name is a closed-vocabulary
|
|
3425
|
+
// token compared whole.
|
|
3426
|
+
for (const op of position.ops) {
|
|
3427
|
+
if (op.op !== "hold-loop")
|
|
3428
|
+
continue;
|
|
3429
|
+
const gate = op.gate;
|
|
3430
|
+
const name = typeof gate?.parameter === "string" ? gate.parameter : undefined;
|
|
3431
|
+
const control = name === undefined ? undefined : position.parameters[name]?.control;
|
|
3432
|
+
if (control !== undefined)
|
|
3433
|
+
named.add(control);
|
|
3434
|
+
}
|
|
3435
|
+
}
|
|
3436
|
+
}
|
|
3437
|
+
return named;
|
|
3438
|
+
}
|
|
3439
|
+
/**
|
|
3440
|
+
* Panel control ids that some component's program declares it reads by scanning or timing -- as
|
|
3441
|
+
* its router, or as a cited parameter.
|
|
3442
|
+
*
|
|
3443
|
+
* Read from the raw document rather than from the devices, because the whole point of a
|
|
3444
|
+
* scanned control is that no device carries it.
|
|
3445
|
+
*/
|
|
3446
|
+
function scannedControls(document) {
|
|
3447
|
+
const named = new Set();
|
|
3448
|
+
const components = document.components;
|
|
3449
|
+
if (!Array.isArray(components))
|
|
3450
|
+
return named;
|
|
3451
|
+
for (const component of components) {
|
|
3452
|
+
const program = declaredProgram(component);
|
|
3453
|
+
if (program === undefined)
|
|
3454
|
+
continue;
|
|
3455
|
+
if (program.router?.read === "scanned")
|
|
3456
|
+
named.add(program.router.control);
|
|
3457
|
+
for (const position of program.positions) {
|
|
3458
|
+
const cited = [
|
|
3459
|
+
...Object.values(position.lines).flatMap((parameters) => Object.values(parameters)),
|
|
3460
|
+
...Object.values(position.parameters),
|
|
3461
|
+
];
|
|
3462
|
+
for (const parameter of cited) {
|
|
3463
|
+
if ((parameter.read === "scanned" || parameter.read === "tapped") && parameter.control !== undefined) {
|
|
3464
|
+
named.add(parameter.control);
|
|
3465
|
+
}
|
|
3466
|
+
}
|
|
3467
|
+
}
|
|
3468
|
+
}
|
|
3469
|
+
return named;
|
|
3470
|
+
}
|
|
3471
|
+
/**
|
|
3472
|
+
* The program a component declares, or null.
|
|
3473
|
+
*
|
|
3474
|
+
* Read verbatim like `declaredRole`, and for the same reason: core has already refused the shapes
|
|
3475
|
+
* that cannot be executed -- no positions, several positions with no selector naming the control
|
|
3476
|
+
* that chooses, a position with no ops, an op with no name. What survives that is a declaration
|
|
3477
|
+
* this stage has no business re-adjudicating.
|
|
3478
|
+
*
|
|
3479
|
+
* **The ops are not validated here and that is deliberate.** Their vocabulary is the runtime's op
|
|
3480
|
+
* enum, which stage 1 knows nothing about; checking it here would put a second copy of that
|
|
3481
|
+
* vocabulary in the reader. `validateDeclaredOp` does it at resolution, where the refusal can name
|
|
3482
|
+
* the op and the device together.
|
|
3483
|
+
*/
|
|
3484
|
+
function declaredProgram(component) {
|
|
3485
|
+
const raw = component !== null && typeof component === "object"
|
|
3486
|
+
? component.program
|
|
3487
|
+
: undefined;
|
|
3488
|
+
if (raw === null || typeof raw !== "object")
|
|
3489
|
+
return undefined;
|
|
3490
|
+
const positionsRaw = raw.positions;
|
|
3491
|
+
if (!Array.isArray(positionsRaw) || positionsRaw.length === 0)
|
|
3492
|
+
return undefined;
|
|
3493
|
+
const positions = [];
|
|
3494
|
+
for (const entry of positionsRaw) {
|
|
3495
|
+
if (entry === null || typeof entry !== "object")
|
|
3496
|
+
continue;
|
|
3497
|
+
const position = entry;
|
|
3498
|
+
if (typeof position.id !== "string" || !Array.isArray(position.ops))
|
|
3499
|
+
continue;
|
|
3500
|
+
positions.push({
|
|
3501
|
+
id: position.id,
|
|
3502
|
+
label: typeof position.label === "string" ? position.label : null,
|
|
3503
|
+
ops: position.ops.filter((op) => op !== null &&
|
|
3504
|
+
typeof op === "object" &&
|
|
3505
|
+
typeof op.op === "string"),
|
|
3506
|
+
lines: declaredProgramLines(position.lines),
|
|
3507
|
+
parameters: declaredPositionParameters(position.parameters),
|
|
3508
|
+
});
|
|
3509
|
+
}
|
|
3510
|
+
if (positions.length === 0)
|
|
3511
|
+
return undefined;
|
|
3512
|
+
return { router: declaredProgramRouter(raw.router), positions };
|
|
3513
|
+
}
|
|
3514
|
+
/**
|
|
3515
|
+
* The router a component's program declares, read verbatim.
|
|
3516
|
+
*
|
|
3517
|
+
* Core has already refused every router that cannot be executed -- no control, fewer than two
|
|
3518
|
+
* positions, no routes, a route out of range, a duplicate route, a route naming a program the
|
|
3519
|
+
* component does not declare. What survives that is a declaration this stage has no business
|
|
3520
|
+
* re-adjudicating, so it is copied rather than checked.
|
|
3521
|
+
*/
|
|
3522
|
+
function declaredProgramRouter(raw) {
|
|
3523
|
+
if (raw === null || typeof raw !== "object")
|
|
3524
|
+
return null;
|
|
3525
|
+
const router = raw;
|
|
3526
|
+
if (typeof router.control !== "string" || router.control.trim().length === 0)
|
|
3527
|
+
return null;
|
|
3528
|
+
if (typeof router.positions !== "number" || !Number.isInteger(router.positions))
|
|
3529
|
+
return null;
|
|
3530
|
+
if (!Array.isArray(router.routes))
|
|
3531
|
+
return null;
|
|
3532
|
+
const routes = [];
|
|
3533
|
+
for (const entry of router.routes) {
|
|
3534
|
+
if (entry === null || typeof entry !== "object")
|
|
3535
|
+
continue;
|
|
3536
|
+
const route = entry;
|
|
3537
|
+
if (typeof route.position !== "number" || !Number.isInteger(route.position))
|
|
3538
|
+
continue;
|
|
3539
|
+
if (typeof route.program !== "string" || route.program.length === 0)
|
|
3540
|
+
continue;
|
|
3541
|
+
routes.push({ position: route.position, program: route.program });
|
|
3542
|
+
}
|
|
3543
|
+
if (routes.length === 0)
|
|
3544
|
+
return null;
|
|
3545
|
+
// Core validated these: `read` is one of two values, and a scanned router names a reader
|
|
3546
|
+
// this document has. Read verbatim, as everything else at this boundary is.
|
|
3547
|
+
const read = router.read === "scanned" ? "scanned" : "node";
|
|
3548
|
+
const scannedBy = typeof router.scannedBy === "string" && router.scannedBy.trim().length > 0
|
|
3549
|
+
? router.scannedBy.trim()
|
|
3550
|
+
: null;
|
|
3551
|
+
return {
|
|
3552
|
+
control: router.control.trim(),
|
|
3553
|
+
read,
|
|
3554
|
+
scannedBy,
|
|
3555
|
+
positions: router.positions,
|
|
3556
|
+
routes,
|
|
3557
|
+
};
|
|
3558
|
+
}
|
|
3559
|
+
/**
|
|
3560
|
+
* One cited program parameter, read verbatim: a line's or a position's, the same way. Core
|
|
3561
|
+
* validated `read` / `scannedBy` exactly as it validates a router's.
|
|
3562
|
+
*/
|
|
3563
|
+
function declaredProgramParameter(valueRaw) {
|
|
3564
|
+
if (valueRaw === null || typeof valueRaw !== "object")
|
|
3565
|
+
return null;
|
|
3566
|
+
const value = valueRaw;
|
|
3567
|
+
if (typeof value.min !== "number" || typeof value.max !== "number")
|
|
3568
|
+
return null;
|
|
3569
|
+
const tap = value.tap !== undefined &&
|
|
3570
|
+
typeof value.tap.presses === "number" &&
|
|
3571
|
+
typeof value.tap.timeoutSeconds === "number"
|
|
3572
|
+
? {
|
|
3573
|
+
presses: value.tap.presses,
|
|
3574
|
+
timeoutSeconds: value.tap.timeoutSeconds,
|
|
3575
|
+
defaultSeconds: typeof value.tap.defaultSeconds === "number" ? value.tap.defaultSeconds : null,
|
|
3576
|
+
}
|
|
3577
|
+
: undefined;
|
|
3578
|
+
return {
|
|
3579
|
+
min: value.min,
|
|
3580
|
+
max: value.max,
|
|
3581
|
+
read: value.read === "scanned" || value.read === "tapped" ? value.read : "node",
|
|
3582
|
+
ratio: typeof value.ratio === "number" && Number.isFinite(value.ratio) ? value.ratio : null,
|
|
3583
|
+
...(tap === undefined ? {} : { tap }),
|
|
3584
|
+
scannedBy: typeof value.scannedBy === "string" && value.scannedBy.trim().length > 0
|
|
3585
|
+
? value.scannedBy.trim()
|
|
3586
|
+
: null,
|
|
3587
|
+
...(typeof value.control === "string" ? { control: value.control } : {}),
|
|
3588
|
+
...(typeof value.source === "string" ? { source: value.source } : {}),
|
|
3589
|
+
};
|
|
3590
|
+
}
|
|
3591
|
+
/** A position's named parameters (core 0.12.0), read verbatim. */
|
|
3592
|
+
function declaredPositionParameters(raw) {
|
|
3593
|
+
if (raw === null || typeof raw !== "object")
|
|
3594
|
+
return {};
|
|
3595
|
+
const parameters = {};
|
|
3596
|
+
for (const [name, valueRaw] of Object.entries(raw)) {
|
|
3597
|
+
const parameter = declaredProgramParameter(valueRaw);
|
|
3598
|
+
if (parameter !== null)
|
|
3599
|
+
parameters[name] = parameter;
|
|
3600
|
+
}
|
|
3601
|
+
return parameters;
|
|
3602
|
+
}
|
|
3603
|
+
/** The cited parameters of one position's delay lines, keyed line then parameter. */
|
|
3604
|
+
function declaredProgramLines(raw) {
|
|
3605
|
+
if (raw === null || typeof raw !== "object")
|
|
3606
|
+
return {};
|
|
3607
|
+
const lines = {};
|
|
3608
|
+
for (const [lineId, parametersRaw] of Object.entries(raw)) {
|
|
3609
|
+
if (parametersRaw === null || typeof parametersRaw !== "object")
|
|
3610
|
+
continue;
|
|
3611
|
+
const parameters = {};
|
|
3612
|
+
for (const [name, valueRaw] of Object.entries(parametersRaw)) {
|
|
3613
|
+
const parameter = declaredProgramParameter(valueRaw);
|
|
3614
|
+
if (parameter !== null)
|
|
3615
|
+
parameters[name] = parameter;
|
|
3616
|
+
}
|
|
3617
|
+
if (Object.keys(parameters).length > 0)
|
|
3618
|
+
lines[lineId] = parameters;
|
|
3619
|
+
}
|
|
3620
|
+
return lines;
|
|
3621
|
+
}
|
|
3622
|
+
/**
|
|
3623
|
+
* The coils a component declares, as terminal indices in coil order.
|
|
3624
|
+
*
|
|
3625
|
+
* Read verbatim like `declaredRole`, with one consumer-side step: a terminal *name* becomes the
|
|
3626
|
+
* index this stage addresses ports by. A winding naming a terminal the component does not have is
|
|
3627
|
+
* dropped here rather than refused, because core's own validation already errors on it and this
|
|
3628
|
+
* stage's job is to read, not to re-adjudicate the document.
|
|
3629
|
+
*/
|
|
3630
|
+
function declaredWindings(component, terminals) {
|
|
3631
|
+
const value = component !== null && typeof component === "object"
|
|
3632
|
+
? component.windings
|
|
3633
|
+
: undefined;
|
|
3634
|
+
if (!Array.isArray(value) || value.length === 0) {
|
|
3635
|
+
return null;
|
|
3636
|
+
}
|
|
3637
|
+
const indexOf = new Map();
|
|
3638
|
+
const centerTaps = new Set();
|
|
3639
|
+
terminals.forEach((terminal, index) => {
|
|
3640
|
+
if (typeof terminal.name === "string") {
|
|
3641
|
+
indexOf.set(terminal.name, index);
|
|
3642
|
+
}
|
|
3643
|
+
if (declaredRole(terminal) === "windingCenterTap") {
|
|
3644
|
+
centerTaps.add(index);
|
|
3645
|
+
}
|
|
3646
|
+
});
|
|
3647
|
+
const windings = value.flatMap((entry) => {
|
|
3648
|
+
if (entry === null || typeof entry !== "object") {
|
|
3649
|
+
return [];
|
|
3650
|
+
}
|
|
3651
|
+
const { role, id, terminals: names } = entry;
|
|
3652
|
+
if (typeof role !== "string" || !Array.isArray(names)) {
|
|
3653
|
+
return [];
|
|
3654
|
+
}
|
|
3655
|
+
const terminalIndices = names.flatMap((name) => {
|
|
3656
|
+
const index = typeof name === "string" ? indexOf.get(name) : undefined;
|
|
3657
|
+
return index === undefined ? [] : [index];
|
|
3658
|
+
});
|
|
3659
|
+
const centerTapAt = terminalIndices.findIndex((index) => centerTaps.has(index));
|
|
3660
|
+
const { voltage, impedances: rated } = entry;
|
|
3661
|
+
const impedances = (Array.isArray(rated) ? rated : []).flatMap((rating) => {
|
|
3662
|
+
if (rating === null || typeof rating !== "object") {
|
|
3663
|
+
return [];
|
|
3664
|
+
}
|
|
3665
|
+
const { across, impedance } = rating;
|
|
3666
|
+
const ohms = quantityValue(impedance);
|
|
3667
|
+
if (!Array.isArray(across) || across.length !== 2 || ohms === null) {
|
|
3668
|
+
return [];
|
|
3669
|
+
}
|
|
3670
|
+
const pair = across.map((name) => typeof name === "string" ? indexOf.get(name) : undefined);
|
|
3671
|
+
return pair[0] === undefined || pair[1] === undefined
|
|
3672
|
+
? []
|
|
3673
|
+
: [{ across: [pair[0], pair[1]], ohms }];
|
|
3674
|
+
});
|
|
3675
|
+
return terminalIndices.length === 0
|
|
3676
|
+
? []
|
|
3677
|
+
: [
|
|
3678
|
+
{
|
|
3679
|
+
role,
|
|
3680
|
+
id: typeof id === "string" && id.trim().length > 0 ? id.trim() : null,
|
|
3681
|
+
terminalIndices,
|
|
3682
|
+
centerTapAt: centerTapAt === -1 ? null : centerTapAt,
|
|
3683
|
+
voltageRmsVolts: quantityValue(voltage),
|
|
3684
|
+
impedances,
|
|
3685
|
+
},
|
|
3686
|
+
];
|
|
3687
|
+
});
|
|
3688
|
+
return windings.length === 0 ? null : windings;
|
|
3689
|
+
}
|
|
3690
|
+
/** A typed quantity's numeric value, or null. Core validates the shape; this only reads it. */
|
|
3691
|
+
function quantityValue(value) {
|
|
3692
|
+
if (value === null || typeof value !== "object") {
|
|
3693
|
+
return null;
|
|
3694
|
+
}
|
|
3695
|
+
const magnitude = value.value;
|
|
3696
|
+
return typeof magnitude === "number" && Number.isFinite(magnitude)
|
|
3697
|
+
? magnitude
|
|
3698
|
+
: null;
|
|
3699
|
+
}
|
|
3700
|
+
/** A bare positional pin carries no role. Anything else is a whole role token. */
|
|
3701
|
+
function terminalRoleToken(name) {
|
|
3702
|
+
const folded = name
|
|
3703
|
+
.trim()
|
|
3704
|
+
.toLowerCase()
|
|
3705
|
+
.replace(/[\s_-]+/gu, "");
|
|
3706
|
+
if (folded.length === 0 || /^(?:pin)?\d+$/u.test(folded)) {
|
|
3707
|
+
return null;
|
|
3708
|
+
}
|
|
3709
|
+
return folded.replace(/^pin\d+/u, "");
|
|
3710
|
+
}
|
|
3711
|
+
/**
|
|
3712
|
+
* The taper a control declares for itself, or null when it declares none.
|
|
3713
|
+
*
|
|
3714
|
+
* Same vocabulary as a component's, because a panel control and the pot it drives are
|
|
3715
|
+
* describing the same track. Keeping two lists let a control's `ReverseAudio` or
|
|
3716
|
+
* `AntiLogarithmic` read as "unstated" while the identical value on the pot resolved.
|
|
3717
|
+
*/
|
|
3718
|
+
/** A control's declared panel role, verbatim, or `null`. Never inferred from the id. */
|
|
3719
|
+
function roleFor(control) {
|
|
3720
|
+
const value = control !== null && typeof control === "object"
|
|
3721
|
+
? control.role
|
|
3722
|
+
: undefined;
|
|
3723
|
+
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
|
3724
|
+
}
|
|
3725
|
+
function taperFor(control) {
|
|
3726
|
+
const value = control !== null && typeof control === "object"
|
|
3727
|
+
? control.taper
|
|
3728
|
+
: undefined;
|
|
3729
|
+
return typeof value === "string" ? taperKind(value) : null;
|
|
3730
|
+
}
|
|
3731
|
+
/**
|
|
3732
|
+
* Composite map key. The separator must be a character that cannot occur in either
|
|
3733
|
+
* part, or `R1`+`a` and `R`+`1a` collide and two terminals silently share a node.
|
|
3734
|
+
* A newline is safe here because neither an id nor a terminal name may contain one.
|
|
3735
|
+
*/
|
|
3736
|
+
function declaredKey(componentId, terminalName) {
|
|
3737
|
+
return `${componentId}\n${terminalName}`;
|
|
3738
|
+
}
|
|
3739
|
+
/** The `{ raw, value, unit }` shape, or null for anything else. */
|
|
3740
|
+
function asParsedQuantity(value) {
|
|
3741
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
3742
|
+
return null;
|
|
3743
|
+
}
|
|
3744
|
+
const candidate = value;
|
|
3745
|
+
if (typeof candidate.raw !== "string" ||
|
|
3746
|
+
typeof candidate.value !== "number" ||
|
|
3747
|
+
!Number.isFinite(candidate.value) ||
|
|
3748
|
+
typeof candidate.unit !== "string") {
|
|
3749
|
+
return null;
|
|
3750
|
+
}
|
|
3751
|
+
return { raw: candidate.raw, value: candidate.value, unit: candidate.unit };
|
|
3752
|
+
}
|
|
3753
|
+
/**
|
|
3754
|
+
* Recover `deviceInterface.controls[].defaultPosition`. The format library drops it -- it keeps
|
|
3755
|
+
* `id`, `label`, `kind`, `role` and `taper` from the same entry and silently discards this one --
|
|
3756
|
+
* so the raw YAML is read here for that single field, exactly as `readRawComponents` below does
|
|
3757
|
+
* for terminal `node:`.
|
|
3758
|
+
*
|
|
3759
|
+
* **The durable fix belongs in `@vessel-dsp/core`**, which owns the format; this recovery is the
|
|
3760
|
+
* consumer-side half and should be deleted the day the parser preserves the field.
|
|
3761
|
+
*/
|
|
3762
|
+
function readRawControlPositions(source) {
|
|
3763
|
+
const positions = new Map();
|
|
3764
|
+
let parsed;
|
|
3765
|
+
try {
|
|
3766
|
+
parsed = loadYaml(source);
|
|
3767
|
+
}
|
|
3768
|
+
catch {
|
|
3769
|
+
return positions;
|
|
3770
|
+
}
|
|
3771
|
+
if (parsed === null || typeof parsed !== "object") {
|
|
3772
|
+
return positions;
|
|
3773
|
+
}
|
|
3774
|
+
const controls = (parsed.deviceInterface ??
|
|
3775
|
+
{}).controls;
|
|
3776
|
+
if (!Array.isArray(controls)) {
|
|
3777
|
+
return positions;
|
|
3778
|
+
}
|
|
3779
|
+
for (const control of controls) {
|
|
3780
|
+
if (control === null || typeof control !== "object") {
|
|
3781
|
+
continue;
|
|
3782
|
+
}
|
|
3783
|
+
const record = control;
|
|
3784
|
+
// A control id is a token, not a number -- the same lesson the net-id filter below
|
|
3785
|
+
// carries, and the same cost if it is forgotten.
|
|
3786
|
+
const id = typeof record.id === "number" && Number.isFinite(record.id)
|
|
3787
|
+
? String(record.id)
|
|
3788
|
+
: typeof record.id === "string" && record.id.trim() !== ""
|
|
3789
|
+
? record.id.trim()
|
|
3790
|
+
: null;
|
|
3791
|
+
if (id === null ||
|
|
3792
|
+
typeof record.defaultPosition !== "number" ||
|
|
3793
|
+
!Number.isFinite(record.defaultPosition)) {
|
|
3794
|
+
continue;
|
|
3795
|
+
}
|
|
3796
|
+
positions.set(id, Math.min(1, Math.max(0, record.defaultPosition)));
|
|
3797
|
+
}
|
|
3798
|
+
return positions;
|
|
3799
|
+
}
|
|
3800
|
+
/**
|
|
3801
|
+
* Reads discrete position counts declared on `deviceInterface.controls[].positions` or
|
|
3802
|
+
* `deviceInterface.controls[].options`.
|
|
3803
|
+
*/
|
|
3804
|
+
function readRawControlDiscretePositions(source) {
|
|
3805
|
+
const positions = new Map();
|
|
3806
|
+
let parsed;
|
|
3807
|
+
try {
|
|
3808
|
+
parsed = loadYaml(source);
|
|
3809
|
+
}
|
|
3810
|
+
catch {
|
|
3811
|
+
return positions;
|
|
3812
|
+
}
|
|
3813
|
+
if (parsed === null || typeof parsed !== "object") {
|
|
3814
|
+
return positions;
|
|
3815
|
+
}
|
|
3816
|
+
const controls = (parsed.deviceInterface ??
|
|
3817
|
+
{}).controls;
|
|
3818
|
+
if (!Array.isArray(controls)) {
|
|
3819
|
+
return positions;
|
|
3820
|
+
}
|
|
3821
|
+
for (const control of controls) {
|
|
3822
|
+
if (control === null || typeof control !== "object") {
|
|
3823
|
+
continue;
|
|
3824
|
+
}
|
|
3825
|
+
const record = control;
|
|
3826
|
+
const id = typeof record.id === "number" && Number.isFinite(record.id)
|
|
3827
|
+
? String(record.id)
|
|
3828
|
+
: typeof record.id === "string" && record.id.trim() !== ""
|
|
3829
|
+
? record.id.trim()
|
|
3830
|
+
: null;
|
|
3831
|
+
if (id === null) {
|
|
3832
|
+
continue;
|
|
3833
|
+
}
|
|
3834
|
+
if (Array.isArray(record.positions) && record.positions.length > 0) {
|
|
3835
|
+
positions.set(id, record.positions.length);
|
|
3836
|
+
}
|
|
3837
|
+
else if (typeof record.positions === "number" &&
|
|
3838
|
+
Number.isFinite(record.positions) &&
|
|
3839
|
+
record.positions > 1) {
|
|
3840
|
+
positions.set(id, record.positions);
|
|
3841
|
+
}
|
|
3842
|
+
else if (Array.isArray(record.options) && record.options.length > 0) {
|
|
3843
|
+
positions.set(id, record.options.length);
|
|
3844
|
+
}
|
|
3845
|
+
else if (typeof record.kind === "string" &&
|
|
3846
|
+
(record.kind === "switch" || record.kind === "footswitch")) {
|
|
3847
|
+
positions.set(id, 2);
|
|
3848
|
+
}
|
|
3849
|
+
}
|
|
3850
|
+
return positions;
|
|
3851
|
+
}
|
|
3852
|
+
/**
|
|
3853
|
+
* Computes discrete position count for a control (e.g. 2 for 2-way toggle/footswitch,
|
|
3854
|
+
* N for multi-way selector), or `null` for continuous controls (potentiometers, rheostats).
|
|
3855
|
+
*/
|
|
3856
|
+
function discretePositionsForControl(id, owner, document, rawPositionsMap) {
|
|
3857
|
+
const raw = rawPositionsMap.get(id);
|
|
3858
|
+
if (raw !== undefined && raw > 1) {
|
|
3859
|
+
return raw;
|
|
3860
|
+
}
|
|
3861
|
+
for (const control of document.deviceInterface?.controls ?? []) {
|
|
3862
|
+
if (String(control.id) === id) {
|
|
3863
|
+
const positions = control.positions;
|
|
3864
|
+
if (Array.isArray(positions) && positions.length > 0) {
|
|
3865
|
+
return positions.length;
|
|
3866
|
+
}
|
|
3867
|
+
if (typeof positions === "number" && positions > 1) {
|
|
3868
|
+
return positions;
|
|
3869
|
+
}
|
|
3870
|
+
const options = control.options;
|
|
3871
|
+
if (Array.isArray(options) && options.length > 0) {
|
|
3872
|
+
return options.length;
|
|
3873
|
+
}
|
|
3874
|
+
const kind = control.kind;
|
|
3875
|
+
if (kind === "switch" || kind === "footswitch") {
|
|
3876
|
+
return 2;
|
|
3877
|
+
}
|
|
3878
|
+
}
|
|
3879
|
+
}
|
|
3880
|
+
if (owner !== undefined) {
|
|
3881
|
+
if (owner.kind === "potentiometer") {
|
|
3882
|
+
return null;
|
|
3883
|
+
}
|
|
3884
|
+
const throwsVal = propertyString(owner, "Throws") ?? propertyString(owner, "throws");
|
|
3885
|
+
if (throwsVal !== null) {
|
|
3886
|
+
const count = Number(throwsVal);
|
|
3887
|
+
if (Number.isFinite(count) && count > 1) {
|
|
3888
|
+
return count;
|
|
3889
|
+
}
|
|
3890
|
+
}
|
|
3891
|
+
const positionsVal = propertyString(owner, "Positions") ?? propertyString(owner, "positions");
|
|
3892
|
+
if (positionsVal !== null) {
|
|
3893
|
+
const count = Number(positionsVal);
|
|
3894
|
+
if (Number.isFinite(count) && count > 1) {
|
|
3895
|
+
return count;
|
|
3896
|
+
}
|
|
3897
|
+
}
|
|
3898
|
+
const optionsVal = propertyString(owner, "Options") ?? propertyString(owner, "options");
|
|
3899
|
+
if (optionsVal !== null) {
|
|
3900
|
+
const count = optionsVal
|
|
3901
|
+
.split(",")
|
|
3902
|
+
.map((s) => s.trim())
|
|
3903
|
+
.filter(Boolean).length;
|
|
3904
|
+
if (count > 1) {
|
|
3905
|
+
return count;
|
|
3906
|
+
}
|
|
3907
|
+
}
|
|
3908
|
+
if (owner.kind === "switch") {
|
|
3909
|
+
return 2;
|
|
3910
|
+
}
|
|
3911
|
+
}
|
|
3912
|
+
return null;
|
|
3913
|
+
}
|
|
3914
|
+
/** Union-find over wire geometry, used to resolve nodes and then discarded. */
|
|
3915
|
+
class UnionFind {
|
|
3916
|
+
parents = new Map();
|
|
3917
|
+
find(value) {
|
|
3918
|
+
const parent = this.parents.get(value);
|
|
3919
|
+
if (parent === undefined) {
|
|
3920
|
+
this.parents.set(value, value);
|
|
3921
|
+
return value;
|
|
3922
|
+
}
|
|
3923
|
+
if (parent === value) {
|
|
3924
|
+
return value;
|
|
3925
|
+
}
|
|
3926
|
+
const root = this.find(parent);
|
|
3927
|
+
this.parents.set(value, root);
|
|
3928
|
+
return root;
|
|
3929
|
+
}
|
|
3930
|
+
union(a, b) {
|
|
3931
|
+
const rootA = this.find(a);
|
|
3932
|
+
const rootB = this.find(b);
|
|
3933
|
+
if (rootA !== rootB) {
|
|
3934
|
+
this.parents.set(rootB, rootA);
|
|
3935
|
+
}
|
|
3936
|
+
}
|
|
3937
|
+
}
|
|
3938
|
+
function pointKey(point) {
|
|
3939
|
+
return `${point.x},${point.y}`;
|
|
3940
|
+
}
|
|
3941
|
+
function pointOnSegment(point, a, b) {
|
|
3942
|
+
// Direct endpoint shortcuts
|
|
3943
|
+
const distAPoint = Math.abs(point.x - a.x) + Math.abs(point.y - a.y);
|
|
3944
|
+
const distBPoint = Math.abs(point.x - b.x) + Math.abs(point.y - b.y);
|
|
3945
|
+
if (distAPoint < 1e-6 || distBPoint < 1e-6) {
|
|
3946
|
+
return true;
|
|
3947
|
+
}
|
|
3948
|
+
const withinX = point.x >= Math.min(a.x, b.x) - 1e-6 &&
|
|
3949
|
+
point.x <= Math.max(a.x, b.x) + 1e-6;
|
|
3950
|
+
const withinY = point.y >= Math.min(a.y, b.y) - 1e-6 &&
|
|
3951
|
+
point.y <= Math.max(a.y, b.y) + 1e-6;
|
|
3952
|
+
if (!withinX || !withinY) {
|
|
3953
|
+
return false;
|
|
3954
|
+
}
|
|
3955
|
+
// Collinear with a safe geometry tolerance
|
|
3956
|
+
// One test covers horizontal, vertical and diagonal alike: before this, a wire at any other angle
|
|
3957
|
+
// returned false, so a terminal touching a diagonal wire was dropped as unconnected.
|
|
3958
|
+
const crossProduct = (b.x - a.x) * (point.y - a.y) - (b.y - a.y) * (point.x - a.x);
|
|
3959
|
+
return Math.abs(crossProduct) < 1e-3;
|
|
3960
|
+
}
|
|
3961
|
+
function resolveNodes(document, declared) {
|
|
3962
|
+
const unionFind = new UnionFind();
|
|
3963
|
+
const terminalPoints = [];
|
|
3964
|
+
for (const component of document.components) {
|
|
3965
|
+
for (const terminal of component.terminals) {
|
|
3966
|
+
terminalPoints.push({
|
|
3967
|
+
componentId: String(component.id),
|
|
3968
|
+
terminalName: String(terminal.name),
|
|
3969
|
+
point: terminal.position,
|
|
3970
|
+
});
|
|
3971
|
+
}
|
|
3972
|
+
}
|
|
3973
|
+
for (const wire of document.wires) {
|
|
3974
|
+
const [a, b] = wire.endpoints;
|
|
3975
|
+
const anchor = pointKey(a);
|
|
3976
|
+
unionFind.union(anchor, pointKey(b));
|
|
3977
|
+
for (const entry of terminalPoints) {
|
|
3978
|
+
if (pointOnSegment(entry.point, a, b)) {
|
|
3979
|
+
unionFind.union(anchor, pointKey(entry.point));
|
|
3980
|
+
}
|
|
3981
|
+
}
|
|
3982
|
+
}
|
|
3983
|
+
// A second pass joins wires that meet at a shared point.
|
|
3984
|
+
for (const wire of document.wires) {
|
|
3985
|
+
for (const other of document.wires) {
|
|
3986
|
+
if (wire.id === other.id) {
|
|
3987
|
+
continue;
|
|
3988
|
+
}
|
|
3989
|
+
for (const point of wire.endpoints) {
|
|
3990
|
+
if (pointOnSegment(point, other.endpoints[0], other.endpoints[1])) {
|
|
3991
|
+
unionFind.union(pointKey(wire.endpoints[0]), pointKey(point));
|
|
3992
|
+
unionFind.union(pointKey(point), pointKey(other.endpoints[0]));
|
|
3993
|
+
}
|
|
3994
|
+
}
|
|
3995
|
+
}
|
|
3996
|
+
}
|
|
3997
|
+
const groundComponentIds = new Set(document.components
|
|
3998
|
+
.filter((component) => String(component.kind) === "ground")
|
|
3999
|
+
.map((component) => String(component.id)));
|
|
4000
|
+
// Geometric groups get ids after the declared ones, so the two never collide.
|
|
4001
|
+
const declaredMax = Math.max(0, ...[...declared.values()]);
|
|
4002
|
+
const geometricId = new Map();
|
|
4003
|
+
let nextGeometric = declaredMax + 1;
|
|
4004
|
+
return (componentId, terminalName, index) => {
|
|
4005
|
+
const declaredNode = declared.get(declaredKey(componentId, terminalName));
|
|
4006
|
+
if (declaredNode !== undefined) {
|
|
4007
|
+
return declaredNode;
|
|
4008
|
+
}
|
|
4009
|
+
const entry = terminalPoints.find((candidate) => candidate.componentId === componentId &&
|
|
4010
|
+
candidate.terminalName === terminalName);
|
|
4011
|
+
if (entry === undefined) {
|
|
4012
|
+
return null;
|
|
4013
|
+
}
|
|
4014
|
+
const root = unionFind.find(pointKey(entry.point));
|
|
4015
|
+
// Any group touching a ground symbol is node 0.
|
|
4016
|
+
const touchesGround = terminalPoints.some((candidate) => groundComponentIds.has(candidate.componentId) &&
|
|
4017
|
+
unionFind.find(pointKey(candidate.point)) === root);
|
|
4018
|
+
if (touchesGround) {
|
|
4019
|
+
return GROUND;
|
|
4020
|
+
}
|
|
4021
|
+
const existing = geometricId.get(root);
|
|
4022
|
+
if (existing !== undefined) {
|
|
4023
|
+
return existing;
|
|
4024
|
+
}
|
|
4025
|
+
// A terminal on no wire at all is unconnected, which is an authoring error
|
|
4026
|
+
// rather than a node: refuse instead of inventing an isolated net.
|
|
4027
|
+
const onAnyWire = document.wires.some((wire) => pointOnSegment(entry.point, wire.endpoints[0], wire.endpoints[1]));
|
|
4028
|
+
if (!onAnyWire && index >= 0) {
|
|
4029
|
+
return null;
|
|
4030
|
+
}
|
|
4031
|
+
const assigned = nextGeometric;
|
|
4032
|
+
nextGeometric += 1;
|
|
4033
|
+
geometricId.set(root, assigned);
|
|
4034
|
+
return assigned;
|
|
4035
|
+
};
|
|
4036
|
+
}
|
|
4037
|
+
/**
|
|
4038
|
+
* The role a document declares for each jack component, by component id.
|
|
4039
|
+
*
|
|
4040
|
+
* `deviceInterface.controls` carries a required `role` on every entry, and a jack's
|
|
4041
|
+
* entry says `input`, `output`, `power`, `cv`, `direct-output` and so on. That is the
|
|
4042
|
+
* authoritative statement of what a jack is: a closed vocabulary, in a structured
|
|
4043
|
+
* field, that distinguishes things a type name cannot -- a direct out from the main
|
|
4044
|
+
* out, an expression socket from an audio port.
|
|
4045
|
+
*
|
|
4046
|
+
* Two links reach it, and 104 of the corpus's 114 jack-bearing documents have one:
|
|
4047
|
+
* the interface entry's id may be the component id, or `panel` binds the two with
|
|
4048
|
+
* `componentId` + `controlId`. Reading this rather than `sourceTypeName` is also what
|
|
4049
|
+
* keeps `Circuit.Speaker` meaning a speaker load in the 22 amp packets that use it
|
|
4050
|
+
* that way, instead of being collapsed into "output jack" to suit pedals.
|
|
4051
|
+
*/
|
|
4052
|
+
function declaredJackRoles(document) {
|
|
4053
|
+
const roleByControlId = new Map();
|
|
4054
|
+
for (const control of document.deviceInterface?.controls ?? []) {
|
|
4055
|
+
if (String(control.kind) !== "jack") {
|
|
4056
|
+
continue;
|
|
4057
|
+
}
|
|
4058
|
+
const role = String(control.role ?? "")
|
|
4059
|
+
.trim()
|
|
4060
|
+
.toLowerCase();
|
|
4061
|
+
if (role.length > 0) {
|
|
4062
|
+
roleByControlId.set(String(control.id), role);
|
|
4063
|
+
}
|
|
4064
|
+
}
|
|
4065
|
+
const roleByComponent = new Map();
|
|
4066
|
+
// The interface entry named the component directly.
|
|
4067
|
+
for (const [id, role] of roleByControlId) {
|
|
4068
|
+
roleByComponent.set(id, role);
|
|
4069
|
+
}
|
|
4070
|
+
// Or the panel binds a component to it, which is how a document keeps
|
|
4071
|
+
// panel-facing ids (`INPUT_JACK`) separate from schematic designators (`V1`).
|
|
4072
|
+
for (const face of document.panel?.faces ?? []) {
|
|
4073
|
+
for (const element of face.elements) {
|
|
4074
|
+
const componentId = element.bind?.componentId;
|
|
4075
|
+
const controlId = element.bind?.controlId;
|
|
4076
|
+
if (componentId === undefined || controlId === undefined) {
|
|
4077
|
+
continue;
|
|
4078
|
+
}
|
|
4079
|
+
const role = roleByControlId.get(String(controlId));
|
|
4080
|
+
if (role !== undefined) {
|
|
4081
|
+
roleByComponent.set(String(componentId), role);
|
|
4082
|
+
}
|
|
4083
|
+
}
|
|
4084
|
+
}
|
|
4085
|
+
return roleByComponent;
|
|
4086
|
+
}
|
|
4087
|
+
/**
|
|
4088
|
+
* Declared roles that make a jack the signal input or output.
|
|
4089
|
+
*
|
|
4090
|
+
* Deliberately narrow. `direct-output` is a dry send and not the effect's output;
|
|
4091
|
+
* `cv`, `expression` and `tempo-tap` are control sockets that carry no program audio.
|
|
4092
|
+
* A type name cannot express any of those distinctions, which is the reason to prefer
|
|
4093
|
+
* the role.
|
|
4094
|
+
*/
|
|
4095
|
+
const inputJackRoles = new Set(["input"]);
|
|
4096
|
+
// `output-a-mono` before `output-b`, so a stereo pedal read in mono takes the jack the
|
|
4097
|
+
// pedal itself calls mono rather than whichever came first.
|
|
4098
|
+
const outputJackRoles = new Set([
|
|
4099
|
+
"output",
|
|
4100
|
+
"output-a-mono",
|
|
4101
|
+
"mono-output",
|
|
4102
|
+
"panning-output",
|
|
4103
|
+
"output-b",
|
|
4104
|
+
"wet-output",
|
|
4105
|
+
"dry-output",
|
|
4106
|
+
]);
|
|
4107
|
+
const powerJackRoles = new Set(["power", "power-input"]);
|
|
4108
|
+
/**
|
|
4109
|
+
* Declared jack types, the fallback for the 10 documents that state no role.
|
|
4110
|
+
*
|
|
4111
|
+
* Closed sets, compared whole and lower-cased. `Circuit.Output` is the minority
|
|
4112
|
+
* spelling -- the corpus writes an output jack as `Circuit.Speaker` 99 times and
|
|
4113
|
+
* `Circuit.OutputJack` 9 times against 8 for `Circuit.Output` -- so matching only the
|
|
4114
|
+
* last of those left 108 output jacks unrecognised. This table exists because those
|
|
4115
|
+
* ten documents declare no role; it is not the primary evidence.
|
|
4116
|
+
*/
|
|
4117
|
+
const inputJackTypeNames = new Set([
|
|
4118
|
+
"circuit.input",
|
|
4119
|
+
"circuit.inputjack",
|
|
4120
|
+
"circuit.inputjackswitch",
|
|
4121
|
+
]);
|
|
4122
|
+
const outputJackTypeNames = new Set([
|
|
4123
|
+
"circuit.output",
|
|
4124
|
+
"circuit.outputjack",
|
|
4125
|
+
"circuit.speaker",
|
|
4126
|
+
]);
|
|
4127
|
+
const powerJackTypeNames = new Set([
|
|
4128
|
+
"circuit.powerjack",
|
|
4129
|
+
"circuit.dcjack",
|
|
4130
|
+
"circuit.dcpowerjack",
|
|
4131
|
+
]);
|
|
4132
|
+
/**
|
|
4133
|
+
* The impedance each port declares **about itself as a port**, for the chain's seam divider (§3.3).
|
|
4134
|
+
*
|
|
4135
|
+
* A port impedance is what the *connection* sees: an output port's source impedance is what the next
|
|
4136
|
+
* slot loads, and an input port's is the load this slot presents to whatever drives it. Neither is
|
|
4137
|
+
* stamped -- stamping the input one would put a resistor across the input jack, which is a different
|
|
4138
|
+
* claim from "this is what the port presents".
|
|
4139
|
+
*
|
|
4140
|
+
* **A `Circuit.Speaker` jack is excluded, and finding that out is the whole result of this
|
|
4141
|
+
* function.** Measured over 142 documents, every typed `Impedance` in the corpus sits on a
|
|
4142
|
+
* `Circuit.Speaker` jack -- `vox-ac15-top-boost` 16 Ω, `fender-5e3-deluxe-tweed` 8 Ω,
|
|
4143
|
+
* `marshall-blues-breaker` 1 MΩ, `mxr-m117r-flanger` 1 kΩ, `mxr-noise-gate-line-driver` 10 kΩ. Those
|
|
4144
|
+
* are **loads**, not source impedances: 8 Ω is the driver the transformer secondary drives, and
|
|
4145
|
+
* `outputJackLoads` already consumes exactly that number to build the Stage B speaker network.
|
|
4146
|
+
* Reading it a second time as the port's *source* impedance would both double-count it and invert
|
|
4147
|
+
* its meaning -- a divider given `Zout = 8` claims the amp has an 8 Ω output impedance, when 8 Ω is
|
|
4148
|
+
* what it is driving into.
|
|
4149
|
+
*
|
|
4150
|
+
* The plan's §3.3 recorded this as "four output jacks declare a typed impedance, all of which are the
|
|
4151
|
+
* source impedance this section wants". The first half is close; the second half is wrong, and the
|
|
4152
|
+
* exclusion below is why the divider is not built on it.
|
|
4153
|
+
*
|
|
4154
|
+
* The discriminator is a **closed typed value compared whole** (`sourceTypeName`), never a name or a
|
|
4155
|
+
* description -- the same rule everything else in this file follows.
|
|
4156
|
+
*
|
|
4157
|
+
* So this returns `{ input: null, output: null }` for every corpus document today, and that is the
|
|
4158
|
+
* honest state rather than a gap to paper over: a fabricated "standard 1 MΩ input" would look like a
|
|
4159
|
+
* measurement in every number downstream of it. What §3.3 needs is a typed impedance on **input**
|
|
4160
|
+
* jacks and on non-speaker **output** jacks.
|
|
4161
|
+
*
|
|
4162
|
+
* Matched through the resolved devices, since a parsed component's terminals do not carry their node
|
|
4163
|
+
* (the format library drops it, which is why `readRawComponents` exists at all).
|
|
4164
|
+
*/
|
|
4165
|
+
/**
|
|
4166
|
+
* The full-scale voltage a port's own jack **declares**, in volts, or null.
|
|
4167
|
+
*
|
|
4168
|
+
* `V0dBFS` is the source stating directly what `port-full-scale.ts` otherwise has to infer, and it
|
|
4169
|
+
* is the only number available where nothing infers one: a preamp monitor tap has no output
|
|
4170
|
+
* transformer to step a rail down, so the derived bound is the bare rail and a render of it writes
|
|
4171
|
+
* hundreds of volts into a +/-1 file. `marshall-1959-super-lead-plexi`'s `Tone-Stack-Monitor-Out`
|
|
4172
|
+
* and `mesa-boogie-mark-v`'s `Preamp-Monitor-Out` each declare 100 V on the exact jack the output
|
|
4173
|
+
* port sits on, and both clipped 20,722 and 87,439 samples of 96,000 until this was read.
|
|
4174
|
+
*
|
|
4175
|
+
* **`Circuit.Speaker` jacks are read here, unlike in `portImpedanceOhms`.** That reader excludes
|
|
4176
|
+
* them because a speaker's declared `Impedance` describes the *load* hanging off the port rather
|
|
4177
|
+
* than the port's own level. `V0dBFS` is the opposite: it is only ever a level, and it is the
|
|
4178
|
+
* corpus's monitor taps that carry it -- which are typed `Circuit.Speaker`, since that is how a
|
|
4179
|
+
* tap-off point is drawn. Excluding them dropped every declaration there is.
|
|
4180
|
+
*/
|
|
4181
|
+
function portDeclaredFullScaleVolts(document, devices, ports) {
|
|
4182
|
+
const byId = new Map(document.components
|
|
4183
|
+
.filter((component) => String(component.kind) === "jack")
|
|
4184
|
+
.map((component) => [String(component.id), component]));
|
|
4185
|
+
const at = (node) => {
|
|
4186
|
+
for (const device of devices) {
|
|
4187
|
+
if (device.kind !== "jack" || !device.nodes.includes(node)) {
|
|
4188
|
+
continue;
|
|
4189
|
+
}
|
|
4190
|
+
const component = byId.get(device.id);
|
|
4191
|
+
if (component === undefined) {
|
|
4192
|
+
continue;
|
|
4193
|
+
}
|
|
4194
|
+
const volts = typedVolts(component, "V0dBFS");
|
|
4195
|
+
if (volts !== null && volts > 0 && Number.isFinite(volts)) {
|
|
4196
|
+
return volts;
|
|
4197
|
+
}
|
|
4198
|
+
}
|
|
4199
|
+
return null;
|
|
4200
|
+
};
|
|
4201
|
+
return { input: at(ports.input), output: at(ports.output) };
|
|
4202
|
+
}
|
|
4203
|
+
function portImpedanceOhms(document, devices, ports) {
|
|
4204
|
+
const byId = new Map(document.components
|
|
4205
|
+
.filter((component) => String(component.kind) === "jack" &&
|
|
4206
|
+
// Folded, not exact. An exact match is a claim that every packet spells the
|
|
4207
|
+
// token identically, and the corpus already disproves it: `circuit.led` is
|
|
4208
|
+
// written `Circuit.LED` 111 times and `Circuit.Led` twice. The consumer is the
|
|
4209
|
+
// only place that can be made not to care.
|
|
4210
|
+
foldToken(String(component.sourceTypeName ?? "")) !==
|
|
4211
|
+
foldToken("Circuit.Speaker"))
|
|
4212
|
+
.map((component) => [String(component.id), component]));
|
|
4213
|
+
const at = (node) => {
|
|
4214
|
+
for (const device of devices) {
|
|
4215
|
+
if (device.kind !== "jack" || !device.nodes.includes(node)) {
|
|
4216
|
+
continue;
|
|
4217
|
+
}
|
|
4218
|
+
const component = byId.get(device.id);
|
|
4219
|
+
if (component === undefined) {
|
|
4220
|
+
continue;
|
|
4221
|
+
}
|
|
4222
|
+
const ohms = typedOhms(component, "Impedance");
|
|
4223
|
+
if (ohms !== null && ohms > 0 && Number.isFinite(ohms)) {
|
|
4224
|
+
return ohms;
|
|
4225
|
+
}
|
|
4226
|
+
}
|
|
4227
|
+
return null;
|
|
4228
|
+
};
|
|
4229
|
+
return { input: at(ports.input), output: at(ports.output) };
|
|
4230
|
+
}
|
|
4231
|
+
/**
|
|
4232
|
+
* A jack's own declared load impedance, lowered into a resistor across its own two declared
|
|
4233
|
+
* terminals -- the amp output-stage plan's Stage A.
|
|
4234
|
+
*
|
|
4235
|
+
* Measured 2026-08-21: no amp in the corpus drives a modelled load. `fender-5e3-deluxe-
|
|
4236
|
+
* tweed` (port node 44), `vox-ac15-top-boost` (63) and `vox-ac30-top-boost` (129) have no
|
|
4237
|
+
* resistive element on their output transformer's secondary at all: an unloaded secondary
|
|
4238
|
+
* reflects no load into the primary, so the power tubes work against the wrong plate load and
|
|
4239
|
+
* no power is delivered -- `fender-5e3`'s 26.2 V there is an open-circuit voltage, and 26 V
|
|
4240
|
+
* into 8 Ω would be 43 W from a 12 W amp.
|
|
4241
|
+
*
|
|
4242
|
+
* Exactly 8 jacks across 7 packets state a typed load, read with `typedOhms` -- the same
|
|
4243
|
+
* structured `{raw, value, unit}` discipline `PrimaryImpedance`/`SecondaryImpedance` above
|
|
4244
|
+
* use. The rest of the corpus's speaker-typed jacks are not readable as data this stage may
|
|
4245
|
+
* act on: 25 monitor taps declare `Impedance: "∞ Ω"`, a bare string `typedOhms` never opens
|
|
4246
|
+
* because it is not `{value, unit}`, and five -- `fender-bassman`'s `"2 Ohm selected load"`
|
|
4247
|
+
* among them -- are prose with a number sitting in a sentence, exactly what this pipeline may
|
|
4248
|
+
* not parse. `typedOhms`'s structured-only read already refuses both without any extra check
|
|
4249
|
+
* here.
|
|
4250
|
+
*
|
|
4251
|
+
* Output-side only, by the same `outputJackRoles`/`outputJackTypeNames` evidence
|
|
4252
|
+
* `resolvePorts` uses below for the port itself: an *input* jack's impedance is a claim about
|
|
4253
|
+
* the source driving this circuit, not a load this circuit itself terminates, and lowering it
|
|
4254
|
+
* here would load the wrong side.
|
|
4255
|
+
*
|
|
4256
|
+
* Not a speaker model. This is a nominal-impedance resistor, so it carries no Thiele-Small
|
|
4257
|
+
* impedance curve -- no bass-resonance rise, no voice-coil inductance -- only the plate load
|
|
4258
|
+
* line becoming real. The plan's Stage B (the Thiele-Small one-port) is what earns those.
|
|
4259
|
+
*
|
|
4260
|
+
* Lives in this stage, not `lower.ts`: the load is a source fact (the jack's own declared
|
|
4261
|
+
* `Impedance`) turned directly into a device, exactly like every other typed-quantity-to-
|
|
4262
|
+
* resistor reading in this file (`PrimaryImpedance`, `SecondaryImpedance`, …). It needs no
|
|
4263
|
+
* region, admission, or program-layout knowledge that only `lower.ts` has, and appending it to
|
|
4264
|
+
* `devices` here means it is visible to every later stage as an ordinary two-terminal resistor
|
|
4265
|
+
* -- no new device kind, no new lowering path -- which is the smaller, more honest option
|
|
4266
|
+
* between (a) and (b) in the plan's decision. Appended after port resolution and control
|
|
4267
|
+
* defaulting run, so it cannot perturb either.
|
|
4268
|
+
*/
|
|
4269
|
+
/**
|
|
4270
|
+
* Nodes electrically on an **output** transformer's windings, widened to where its load actually
|
|
4271
|
+
* sits.
|
|
4272
|
+
*
|
|
4273
|
+
* One function because three places asked this question and two of them answered it differently:
|
|
4274
|
+
* `outputJackLoads` followed series passives, while the `output-port-not-transformer-coupled`
|
|
4275
|
+
* warning required direct membership, so a jack one passive hop away was simultaneously loaded as a
|
|
4276
|
+
* speaker and warned about as a preamp tap.
|
|
4277
|
+
*
|
|
4278
|
+
* Keyed on a declared `ratio`, which is what distinguishes an output transformer from a mains one:
|
|
4279
|
+
* a power transformer states winding voltages instead, and its nodes are not a speaker's.
|
|
4280
|
+
*
|
|
4281
|
+
* Widened two ways, both by connectivity and device kind rather than by any name:
|
|
4282
|
+
*
|
|
4283
|
+
* - through two-terminal **passives**, because a source that states the secondary's own winding
|
|
4284
|
+
* resistance puts a resistor between the winding and the jack -- measured on
|
|
4285
|
+
* `report-speaker-load.ts`'s fixture, requiring direct membership dropped the speaker network
|
|
4286
|
+
* from three stamps to none, left the secondary open and doubled the output;
|
|
4287
|
+
* - through an **impedance selector**, meaning a switch two or more of whose nodes are already on
|
|
4288
|
+
* the winding. An ohms selector sits between a secondary's taps and the speaker, so the jack on
|
|
4289
|
+
* its common is this winding's load. The two-or-more guard is what stops this leaking: a switch
|
|
4290
|
+
* selecting among one winding's taps necessarily touches that winding twice, while a pedal's
|
|
4291
|
+
* footswitch touches an output transformer zero times -- and the four pedals declaring a
|
|
4292
|
+
* `Circuit.Speaker` output with a typed impedance are the case the passive-only rule excludes.
|
|
4293
|
+
*/
|
|
4294
|
+
function transformerCoupledNodes(devices) {
|
|
4295
|
+
const reached = new Set();
|
|
4296
|
+
for (const device of devices) {
|
|
4297
|
+
if (device.kind !== "transformer" ||
|
|
4298
|
+
typeof device.parameters.ratio !== "number") {
|
|
4299
|
+
continue;
|
|
4300
|
+
}
|
|
4301
|
+
for (const node of device.nodes) {
|
|
4302
|
+
if (node !== GROUND) {
|
|
4303
|
+
reached.add(node);
|
|
4304
|
+
}
|
|
4305
|
+
}
|
|
4306
|
+
}
|
|
4307
|
+
if (reached.size === 0) {
|
|
4308
|
+
return reached;
|
|
4309
|
+
}
|
|
4310
|
+
const seriesKinds = new Set(["resistor", "inductor", "capacitor"]);
|
|
4311
|
+
for (let widened = true; widened;) {
|
|
4312
|
+
widened = false;
|
|
4313
|
+
for (const device of devices) {
|
|
4314
|
+
const ends = [...new Set(device.nodes)].filter((node) => node !== GROUND);
|
|
4315
|
+
if (device.kind === "switch") {
|
|
4316
|
+
const onWinding = ends.filter((node) => reached.has(node));
|
|
4317
|
+
if (onWinding.length >= 2 && onWinding.length < ends.length) {
|
|
4318
|
+
for (const node of ends) {
|
|
4319
|
+
reached.add(node);
|
|
4320
|
+
}
|
|
4321
|
+
widened = true;
|
|
4322
|
+
}
|
|
4323
|
+
continue;
|
|
4324
|
+
}
|
|
4325
|
+
if (!seriesKinds.has(device.kind) || ends.length !== 2) {
|
|
4326
|
+
continue;
|
|
4327
|
+
}
|
|
4328
|
+
const [a, b] = ends;
|
|
4329
|
+
if (reached.has(a) !== reached.has(b)) {
|
|
4330
|
+
reached.add(a);
|
|
4331
|
+
reached.add(b);
|
|
4332
|
+
widened = true;
|
|
4333
|
+
}
|
|
4334
|
+
}
|
|
4335
|
+
}
|
|
4336
|
+
return reached;
|
|
4337
|
+
}
|
|
4338
|
+
function outputJackLoads(document, devices) {
|
|
4339
|
+
const roles = declaredJackRoles(document);
|
|
4340
|
+
// Stage B needs two nodes per speaker that no component declares. Allocated above every node
|
|
4341
|
+
// already in use, so they cannot collide with a declared or geometric id.
|
|
4342
|
+
let nextNode = Math.max(GROUND, ...devices.flatMap((device) => [...device.nodes])) + 1;
|
|
4343
|
+
// Nodes on a winding of a transformer that declares a `ratio` -- an output transformer. A
|
|
4344
|
+
// mains transformer declares winding voltages instead and its nodes are not a speaker's.
|
|
4345
|
+
const secondaryNodes = transformerCoupledNodes(devices);
|
|
4346
|
+
// **Follow the winding through series passives.** A speaker does not always sit directly on the
|
|
4347
|
+
// winding: a source that states the transformer's own **secondary winding resistance** puts a
|
|
4348
|
+
// resistor between them, and Stage C names exactly that. Requiring direct membership silently
|
|
4349
|
+
// declined to load such a jack -- measured on `report-speaker-load.ts`'s fixture, adding a 0.5 Ω
|
|
4350
|
+
// secondary resistance dropped the speaker network from three stamps to none, left the secondary
|
|
4351
|
+
// open, and doubled the output. It did at least warn, via
|
|
4352
|
+
// `output-port-not-transformer-coupled`.
|
|
4353
|
+
//
|
|
4354
|
+
// Reachability is through two-terminal **passive** elements only -- a resistor, an inductor, a
|
|
4355
|
+
// capacitor. Not through a switch, a pot, an active device or another transformer: those are
|
|
4356
|
+
// where a jack stops being this winding's load and starts being something else's, and the
|
|
4357
|
+
// four pedals that declare a `Circuit.Speaker` output with a typed impedance are exactly the
|
|
4358
|
+
// case this gate exists to exclude.
|
|
4359
|
+
const added = [];
|
|
4360
|
+
const warnings = [];
|
|
4361
|
+
for (const component of document.components) {
|
|
4362
|
+
if (String(component.kind) !== "jack") {
|
|
4363
|
+
continue;
|
|
4364
|
+
}
|
|
4365
|
+
const ohms = typedOhms(component, "Impedance");
|
|
4366
|
+
if (ohms === null || !(ohms > 0)) {
|
|
4367
|
+
continue;
|
|
4368
|
+
}
|
|
4369
|
+
const device = devices.find((candidate) => candidate.kind === "jack" && candidate.id === String(component.id));
|
|
4370
|
+
if (device === undefined) {
|
|
4371
|
+
continue;
|
|
4372
|
+
}
|
|
4373
|
+
const role = roles.get(String(component.id));
|
|
4374
|
+
const typeName = String(component.sourceTypeName ?? "").toLowerCase();
|
|
4375
|
+
const isOutputSide = role === undefined
|
|
4376
|
+
? outputJackTypeNames.has(typeName)
|
|
4377
|
+
: outputJackRoles.has(role);
|
|
4378
|
+
if (!isOutputSide) {
|
|
4379
|
+
continue;
|
|
4380
|
+
}
|
|
4381
|
+
// **On an output transformer's winding, or it is not a speaker load.** Output-side and
|
|
4382
|
+
// typed is not enough, and the corpus says so plainly: four *pedals* declare an output
|
|
4383
|
+
// jack as `Circuit.Speaker` with a typed impedance -- `marshall-blues-breaker`'s `S1` at
|
|
4384
|
+
// 1 MΩ, `mxr-m117r-flanger`'s `J3` at 1 kΩ, `mxr-noise-gate-line-driver`'s `OUT` at
|
|
4385
|
+
// 10 kΩ, `pigtronix-philosophers-tone`'s `OUTPUT` at 150 Ω. Those are interface facts,
|
|
4386
|
+
// an output impedance or a nominal load written on the port, not a driver hanging off a
|
|
4387
|
+
// secondary; stamping them as resistors to ground took `mxr-m117r-flanger` from
|
|
4388
|
+
// `agrees corr=1.0000` to `both silent` against ngspice on the first measured run.
|
|
4389
|
+
//
|
|
4390
|
+
// A speaker sits on the secondary of a transformer that declares a `ratio`, which is
|
|
4391
|
+
// the same evidence `resolvePorts` uses to prefer the speaker over a monitor tap, and
|
|
4392
|
+
// it separates the two cases without reading a name, a description, or guessing from
|
|
4393
|
+
// the magnitude of the impedance.
|
|
4394
|
+
if (!device.nodes.some((node) => secondaryNodes.has(node))) {
|
|
4395
|
+
continue;
|
|
4396
|
+
}
|
|
4397
|
+
// The jack's own resolved nodes, from the device this same component already
|
|
4398
|
+
// produced above. A third terminal -- an impedance-selector tap, as `fender-
|
|
4399
|
+
// bassman`'s speaker jack has -- makes "its own two declared terminals" ambiguous,
|
|
4400
|
+
// so this stage picks neither rather than guessing which pair the load bridges.
|
|
4401
|
+
const nodes = [...new Set(device.nodes)];
|
|
4402
|
+
if (nodes.length !== 2) {
|
|
4403
|
+
continue;
|
|
4404
|
+
}
|
|
4405
|
+
// Stage B: the driver as `Re -- Le -- (Rmot || Lmot || Cmot)`, not a resistor. `ohms` is
|
|
4406
|
+
// the jack's declared nominal, which scales the generic profile -- see `speaker-load.ts`
|
|
4407
|
+
// for why the driver is generic and what that costs the claim.
|
|
4408
|
+
const [hot, ret] = nodes;
|
|
4409
|
+
const coil = nextNode;
|
|
4410
|
+
const motional = nextNode + 1;
|
|
4411
|
+
nextNode += 2;
|
|
4412
|
+
const port = speakerOnePort(ohms);
|
|
4413
|
+
const element = (suffix, kind, pair, parameters) => ({
|
|
4414
|
+
id: `${component.id}-speaker-${suffix}`,
|
|
4415
|
+
kind,
|
|
4416
|
+
nodes: [...pair],
|
|
4417
|
+
parameters,
|
|
4418
|
+
control: null,
|
|
4419
|
+
identity: {
|
|
4420
|
+
partNumber: null,
|
|
4421
|
+
declaredType: null,
|
|
4422
|
+
terminalRoles: [null, null],
|
|
4423
|
+
declaredTerminalRoles: [null, null],
|
|
4424
|
+
declaredWindings: null,
|
|
4425
|
+
},
|
|
4426
|
+
});
|
|
4427
|
+
added.push(element("re", "resistor", [hot, coil], { ohms: port.reOhms }), element("le", "inductor", [coil, motional], { henries: port.leHenries }), element("rmot", "resistor", [motional, ret], { ohms: port.motionalOhms }), element("lmot", "inductor", [motional, ret], {
|
|
4428
|
+
henries: port.motionalHenries,
|
|
4429
|
+
}), element("cmot", "capacitor", [motional, ret], {
|
|
4430
|
+
farads: port.motionalFarads,
|
|
4431
|
+
}));
|
|
4432
|
+
warnings.push({
|
|
4433
|
+
code: "generic-speaker-profile",
|
|
4434
|
+
device: String(component.id),
|
|
4435
|
+
nominalOhms: ohms,
|
|
4436
|
+
detail: `${component.id} is modelled as a generic ${ohms} Ω driver, not the one this amp uses: no packet identifies a driver in a form that resolves, so the impedance curve is a seed profile scaled to the declared nominal. Its shape is a real driver's; its identity is not this amp's.`,
|
|
4437
|
+
});
|
|
4438
|
+
}
|
|
4439
|
+
return { devices: added, warnings };
|
|
4440
|
+
}
|
|
4441
|
+
function resolvePorts(document, devices, options) {
|
|
4442
|
+
const typeNameOf = (device) => {
|
|
4443
|
+
const component = document.components.find((candidate) => String(candidate.id) === device.id);
|
|
4444
|
+
return String(component?.sourceTypeName ?? "").toLowerCase();
|
|
4445
|
+
};
|
|
4446
|
+
// A power inlet is a jack and is never a signal port. Removing it first matters
|
|
4447
|
+
// more than which jack is chosen: the fallback used to take the last jack, and in a
|
|
4448
|
+
// document that lists the DC inlet last, **the output was read off the power jack**
|
|
4449
|
+
// and the pedal rendered silence.
|
|
4450
|
+
const roles = declaredJackRoles(document);
|
|
4451
|
+
const jacks = devices
|
|
4452
|
+
.filter((device) => device.kind === "jack")
|
|
4453
|
+
.filter((device) => {
|
|
4454
|
+
// A declared role decides; the type name only speaks when there is none.
|
|
4455
|
+
const role = roles.get(device.id);
|
|
4456
|
+
return role === undefined
|
|
4457
|
+
? !powerJackTypeNames.has(typeNameOf(device))
|
|
4458
|
+
: !powerJackRoles.has(role);
|
|
4459
|
+
});
|
|
4460
|
+
const byRole = (acceptedRoles, acceptedTypes) => jacks.find((device) => {
|
|
4461
|
+
const role = roles.get(device.id);
|
|
4462
|
+
return role === undefined
|
|
4463
|
+
? acceptedTypes.has(typeNameOf(device))
|
|
4464
|
+
: acceptedRoles.has(role);
|
|
4465
|
+
});
|
|
4466
|
+
let input;
|
|
4467
|
+
if (options?.inputJack !== undefined) {
|
|
4468
|
+
const target = options.inputJack.toLowerCase();
|
|
4469
|
+
input = devices.find((d) => d.kind === "jack" &&
|
|
4470
|
+
(d.id === options.inputJack || d.id.toLowerCase() === target));
|
|
4471
|
+
if (input === undefined) {
|
|
4472
|
+
const available = devices
|
|
4473
|
+
.filter((d) => d.kind === "jack")
|
|
4474
|
+
.map((d) => d.id);
|
|
4475
|
+
throw new NetlistError(`requested input jack "${options.inputJack}" was not found. Available jacks: ${available.join(", ")}`);
|
|
4476
|
+
}
|
|
4477
|
+
}
|
|
4478
|
+
else {
|
|
4479
|
+
// A port comes from a jack that declares its role, or it does not come at all.
|
|
4480
|
+
input = byRole(inputJackRoles, inputJackTypeNames);
|
|
4481
|
+
}
|
|
4482
|
+
// An amp's output port is **after** its output transformer, and a monitor tap is not.
|
|
4483
|
+
//
|
|
4484
|
+
// **Reached through a switch or selector, not only directly.** A speaker jack usually sits
|
|
4485
|
+
// behind an impedance selector: `orange-gro100`'s `J_SPEAKER` is on node 79 and its
|
|
4486
|
+
// `S_SPEAKER_IMPEDANCE` joins that to the winding's 76/77/78 taps, so a direct node test
|
|
4487
|
+
// missed it and the output port fell through to `Output`, a jack whose own
|
|
4488
|
+
// `SourceBoundaryRole` reads "tone-stack output-amp handoff label". The amp then rendered its
|
|
4489
|
+
// tone stack, and the render clipped because a preamp node has no transformer bounding it.
|
|
4490
|
+
//
|
|
4491
|
+
// Which throw is closed is a control position this stage does not read, so every terminal of
|
|
4492
|
+
// a switch is treated as reachable from every other -- the same bound `port-full-scale.ts`
|
|
4493
|
+
// takes for the same reason, since any throw may be the live one.
|
|
4494
|
+
const transformerNodes = new Set();
|
|
4495
|
+
for (const device of devices) {
|
|
4496
|
+
if (device.kind !== "transformer") {
|
|
4497
|
+
continue;
|
|
4498
|
+
}
|
|
4499
|
+
if (typeof device.parameters.ratio !== "number") {
|
|
4500
|
+
continue;
|
|
4501
|
+
}
|
|
4502
|
+
for (const node of device.nodes) {
|
|
4503
|
+
if (node !== GROUND) {
|
|
4504
|
+
transformerNodes.add(node);
|
|
4505
|
+
}
|
|
4506
|
+
}
|
|
4507
|
+
}
|
|
4508
|
+
// Two hops covers a jack behind a selector behind a switch. Unbounded would let a chain of
|
|
4509
|
+
// contacts carry the label across the amp to a jack the winding does not feed.
|
|
4510
|
+
for (let hop = 0; hop < 2; hop += 1) {
|
|
4511
|
+
const reached = [];
|
|
4512
|
+
for (const device of devices) {
|
|
4513
|
+
// Only `switch` at this stage: a selector is still one, and `lower.ts` is where it
|
|
4514
|
+
// becomes per-throw stamps.
|
|
4515
|
+
if (device.kind !== "switch") {
|
|
4516
|
+
continue;
|
|
4517
|
+
}
|
|
4518
|
+
const touches = device.nodes.some((node) => node !== GROUND && transformerNodes.has(node));
|
|
4519
|
+
if (!touches) {
|
|
4520
|
+
continue;
|
|
4521
|
+
}
|
|
4522
|
+
for (const node of device.nodes) {
|
|
4523
|
+
if (node !== GROUND) {
|
|
4524
|
+
reached.push(node);
|
|
4525
|
+
}
|
|
4526
|
+
}
|
|
4527
|
+
}
|
|
4528
|
+
for (const node of reached) {
|
|
4529
|
+
transformerNodes.add(node);
|
|
4530
|
+
}
|
|
4531
|
+
}
|
|
4532
|
+
let output;
|
|
4533
|
+
if (options?.outputJack !== undefined) {
|
|
4534
|
+
const target = options.outputJack.toLowerCase();
|
|
4535
|
+
output = devices.find((d) => d.kind === "jack" &&
|
|
4536
|
+
(d.id === options.outputJack || d.id.toLowerCase() === target));
|
|
4537
|
+
if (output === undefined) {
|
|
4538
|
+
const available = devices
|
|
4539
|
+
.filter((d) => d.kind === "jack")
|
|
4540
|
+
.map((d) => d.id);
|
|
4541
|
+
throw new NetlistError(`requested output jack "${options.outputJack}" was not found. Available jacks: ${available.join(", ")}`);
|
|
4542
|
+
}
|
|
4543
|
+
}
|
|
4544
|
+
else {
|
|
4545
|
+
// **By role only, and the empty type set is the fix rather than an omission.** This
|
|
4546
|
+
// preference exists for a jack that *declares* `wet-output`; there is no type name that
|
|
4547
|
+
// means it. Passing `outputJackTypeNames` here made it match the first output-**typed**
|
|
4548
|
+
// jack in any document that declares no roles at all -- which is every amp -- so it
|
|
4549
|
+
// always won, and it silently preempted anything ranked below it. Behaviour-neutral while
|
|
4550
|
+
// it happened to pick what the positional fallback would have picked, and a wrong answer
|
|
4551
|
+
// the moment a better-evidenced preference was added underneath.
|
|
4552
|
+
const wetOutput = byRole(new Set(["wet-output"]), new Set());
|
|
4553
|
+
const speakerSide = jacks.find((device) => {
|
|
4554
|
+
const role = roles.get(device.id);
|
|
4555
|
+
const accepted = role === undefined
|
|
4556
|
+
? outputJackTypeNames.has(typeNameOf(device))
|
|
4557
|
+
: outputJackRoles.has(role);
|
|
4558
|
+
return (accepted &&
|
|
4559
|
+
device.nodes.some((node) => node !== GROUND && transformerNodes.has(node)));
|
|
4560
|
+
});
|
|
4561
|
+
output =
|
|
4562
|
+
wetOutput ?? speakerSide ?? byRole(outputJackRoles, outputJackTypeNames);
|
|
4563
|
+
}
|
|
4564
|
+
if (input === undefined || output === undefined) {
|
|
4565
|
+
// Two different source facts, and conflating them sends the reader to the wrong
|
|
4566
|
+
// place: a document with no connected jack at all is missing its interface,
|
|
4567
|
+
// where one whose jacks are all untyped is missing only their roles.
|
|
4568
|
+
if (jacks.length === 0) {
|
|
4569
|
+
throw new NetlistError("document declares no connected jack");
|
|
4570
|
+
}
|
|
4571
|
+
const missing = input === undefined && output === undefined
|
|
4572
|
+
? "an input or an output role"
|
|
4573
|
+
: input === undefined
|
|
4574
|
+
? "an input role"
|
|
4575
|
+
: "an output role";
|
|
4576
|
+
throw new NetlistError(`no jack declares ${missing}, so the port cannot be resolved without inventing one`);
|
|
4577
|
+
}
|
|
4578
|
+
const signalNode = (device) => device.nodes.find((node) => node !== GROUND) ?? GROUND;
|
|
4579
|
+
// A speaker jack can carry more than two terminals -- `fender-bassman`'s
|
|
4580
|
+
// `J_SPEAKER_OUTPUT` declares `[43, 0, 41]`, an impedance-selected load -- and only one of
|
|
4581
|
+
// them is the transformer secondary. Taking the first non-ground node probed node 43 while
|
|
4582
|
+
// the winding is node 41, so the render measured a terminal the power stage does not
|
|
4583
|
+
// drive: 6.07e-16 of swing, read as a silent amp. Picking the winding-touching node when
|
|
4584
|
+
// the jack has one is the same connectivity evidence that chose the jack.
|
|
4585
|
+
const outputNode = output.nodes.find((node) => node !== GROUND && transformerNodes.has(node)) ?? signalNode(output);
|
|
4586
|
+
return { input: signalNode(input), output: outputNode };
|
|
4587
|
+
}
|
|
4588
|
+
/**
|
|
4589
|
+
* A driven pin as the selector it is. `follows` a latch: two throws, ground then supply.
|
|
4590
|
+
* `highAt` a control's detents: one throw per detent, each on the supply or the ground, so the
|
|
4591
|
+
* selector law's own detent mapping (`floor(position x count)`) chooses the level.
|
|
4592
|
+
*/
|
|
4593
|
+
function controllerPinDevice(pin, controls) {
|
|
4594
|
+
const [on, off] = pin.invert ? [pin.low, pin.high] : [pin.high, pin.low];
|
|
4595
|
+
const control = pin.highAt?.control ?? pin.follows ?? "";
|
|
4596
|
+
const count = pin.highAt === undefined
|
|
4597
|
+
? 2
|
|
4598
|
+
: Math.max(2, controls.find((candidate) => candidate.id === control)?.positions ?? 2);
|
|
4599
|
+
const throws = pin.highAt === undefined
|
|
4600
|
+
? [off, on]
|
|
4601
|
+
: Array.from({ length: count }, (_, detent) => (pin.highAt?.positions.includes(detent) ? on : off));
|
|
4602
|
+
return {
|
|
4603
|
+
id: pin.id,
|
|
4604
|
+
kind: "switch",
|
|
4605
|
+
nodes: [pin.node, ...throws],
|
|
4606
|
+
parameters: {},
|
|
4607
|
+
control,
|
|
4608
|
+
identity: {
|
|
4609
|
+
partNumber: null,
|
|
4610
|
+
declaredType: null,
|
|
4611
|
+
terminalRoles: ["common", ...throws.map(() => "throw")],
|
|
4612
|
+
declaredTerminalRoles: ["common", ...throws.map(() => "throw")],
|
|
4613
|
+
declaredWindings: null,
|
|
4614
|
+
},
|
|
4615
|
+
};
|
|
4616
|
+
}
|
|
4617
|
+
/**
|
|
4618
|
+
* The firmware rules the document's controllers declare: their latches, and each driven pin as a
|
|
4619
|
+
* three-node switch device between the chip's declared supply and ground.
|
|
4620
|
+
*
|
|
4621
|
+
* Core has already refused a pin on a terminal the chip lacks, a pin following no latch, and pins
|
|
4622
|
+
* on a chip with no declared supply, so what reaches here is executable. The terminal a pin names
|
|
4623
|
+
* is an exact identifier the declaration points at, never a name this stage interprets.
|
|
4624
|
+
*/
|
|
4625
|
+
function declaredControllers(document, nodeOf) {
|
|
4626
|
+
const latches = [];
|
|
4627
|
+
const pins = [];
|
|
4628
|
+
const components = document.components;
|
|
4629
|
+
if (!Array.isArray(components))
|
|
4630
|
+
return { latches, pins };
|
|
4631
|
+
for (const component of components) {
|
|
4632
|
+
const controller = component.controller;
|
|
4633
|
+
if (controller === undefined)
|
|
4634
|
+
continue;
|
|
4635
|
+
for (const latch of controller.latches) {
|
|
4636
|
+
latches.push({ id: latch.id, toggledBy: latch.toggledBy, initial: latch.initial });
|
|
4637
|
+
}
|
|
4638
|
+
const terminalNode = (predicate) => {
|
|
4639
|
+
const index = component.terminals.findIndex(predicate);
|
|
4640
|
+
const terminal = component.terminals[index];
|
|
4641
|
+
return terminal === undefined ? null : nodeOf(component.id, terminal.name, index);
|
|
4642
|
+
};
|
|
4643
|
+
const high = terminalNode((terminal) => terminal.role === "supplyPositive");
|
|
4644
|
+
const low = terminalNode((terminal) => terminal.role === "supplyNegative");
|
|
4645
|
+
for (const pin of controller.pins ?? []) {
|
|
4646
|
+
const node = terminalNode((terminal) => terminal.name === pin.terminal);
|
|
4647
|
+
if (node === null || high === null || low === null)
|
|
4648
|
+
continue;
|
|
4649
|
+
pins.push({
|
|
4650
|
+
id: `${component.id}:${pin.terminal}`,
|
|
4651
|
+
node,
|
|
4652
|
+
high,
|
|
4653
|
+
low,
|
|
4654
|
+
invert: pin.invert === true,
|
|
4655
|
+
...(pin.follows === undefined ? {} : { follows: pin.follows }),
|
|
4656
|
+
...(pin.highAt === undefined ? {} : { highAt: pin.highAt }),
|
|
4657
|
+
});
|
|
4658
|
+
}
|
|
4659
|
+
}
|
|
4660
|
+
return { latches, pins };
|
|
4661
|
+
}
|
|
4662
|
+
/**
|
|
4663
|
+
* The bypass declaration both the control defaults and the bypass record
|
|
4664
|
+
* read: which switch is the bypass, and which of its positions the document
|
|
4665
|
+
* declares engaged. One parse, one validation, two readers -- the control
|
|
4666
|
+
* assembly above and the NetlistBypass record below used to read the raw
|
|
4667
|
+
* block independently and could disagree about its shape.
|
|
4668
|
+
*/
|
|
4669
|
+
function parseBypassDeclaration(document) {
|
|
4670
|
+
const raw = document.audio?.bypass;
|
|
4671
|
+
const switchId = raw !== null &&
|
|
4672
|
+
typeof raw === "object" &&
|
|
4673
|
+
"switch" in raw &&
|
|
4674
|
+
typeof raw.switch === "string"
|
|
4675
|
+
? raw.switch
|
|
4676
|
+
: null;
|
|
4677
|
+
const rawEngaged = raw !== null && typeof raw === "object" && "engagedPosition" in raw
|
|
4678
|
+
? raw.engagedPosition
|
|
4679
|
+
: undefined;
|
|
4680
|
+
const engaged = typeof rawEngaged === "number" &&
|
|
4681
|
+
Number.isFinite(rawEngaged) &&
|
|
4682
|
+
(rawEngaged === 0 || rawEngaged === 1)
|
|
4683
|
+
? rawEngaged
|
|
4684
|
+
: null;
|
|
4685
|
+
const rawEngagedPosition = typeof rawEngaged === "string" || typeof rawEngaged === "number"
|
|
4686
|
+
? rawEngaged
|
|
4687
|
+
: undefined;
|
|
4688
|
+
return { switchId, engaged, rawEngagedPosition };
|
|
4689
|
+
}
|
|
4690
|
+
/**
|
|
4691
|
+
* The latch state a program takes as its effect: the end of the latch at which the parameters
|
|
4692
|
+
* reading it are largest. Null where no program reads the latch, or its readers disagree, so a
|
|
4693
|
+
* latch whose meaning the document does not show keeps its declared power-on state.
|
|
4694
|
+
*/
|
|
4695
|
+
function engagedLatchState(document, latchId) {
|
|
4696
|
+
const components = document.components;
|
|
4697
|
+
if (!Array.isArray(components))
|
|
4698
|
+
return null;
|
|
4699
|
+
const votes = new Set();
|
|
4700
|
+
for (const component of components) {
|
|
4701
|
+
const program = declaredProgram(component);
|
|
4702
|
+
if (program === undefined)
|
|
4703
|
+
continue;
|
|
4704
|
+
for (const position of program.positions) {
|
|
4705
|
+
const cited = [
|
|
4706
|
+
...Object.values(position.lines).flatMap((parameters) => Object.values(parameters)),
|
|
4707
|
+
...Object.values(position.parameters),
|
|
4708
|
+
];
|
|
4709
|
+
for (const parameter of cited) {
|
|
4710
|
+
if (parameter.control !== latchId || parameter.max === parameter.min)
|
|
4711
|
+
continue;
|
|
4712
|
+
votes.add(parameter.max > parameter.min ? 1 : 0);
|
|
4713
|
+
}
|
|
4714
|
+
}
|
|
4715
|
+
}
|
|
4716
|
+
return votes.size === 1 ? [...votes][0] : null;
|
|
4717
|
+
}
|
|
4718
|
+
/**
|
|
4719
|
+
* The tap law each tapped control is read with. Every tapped parameter of one control reads the
|
|
4720
|
+
* same presses, so the first stated law is the control's; a DD-5's four TEMPO positions state one.
|
|
4721
|
+
*/
|
|
4722
|
+
function tapLawsByControl(document) {
|
|
4723
|
+
const laws = new Map();
|
|
4724
|
+
const components = document.components;
|
|
4725
|
+
if (!Array.isArray(components))
|
|
4726
|
+
return laws;
|
|
4727
|
+
for (const component of components) {
|
|
4728
|
+
const program = declaredProgram(component);
|
|
4729
|
+
if (program === undefined)
|
|
4730
|
+
continue;
|
|
4731
|
+
for (const position of program.positions) {
|
|
4732
|
+
const cited = [
|
|
4733
|
+
...Object.values(position.lines).flatMap((parameters) => Object.values(parameters)),
|
|
4734
|
+
...Object.values(position.parameters),
|
|
4735
|
+
];
|
|
4736
|
+
for (const parameter of cited) {
|
|
4737
|
+
if (parameter.read !== "tapped" || parameter.control === undefined || parameter.tap === undefined)
|
|
4738
|
+
continue;
|
|
4739
|
+
if (!laws.has(parameter.control))
|
|
4740
|
+
laws.set(parameter.control, parameter.tap);
|
|
4741
|
+
}
|
|
4742
|
+
}
|
|
4743
|
+
}
|
|
4744
|
+
return laws;
|
|
4745
|
+
}
|
|
4746
|
+
//# sourceMappingURL=netlist.js.map
|