@vessel-dsp/core 0.5.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.
Files changed (99) hide show
  1. package/LICENSE.md +21 -0
  2. package/README.md +6 -0
  3. package/package.json +56 -0
  4. package/src/editor/commands.ts +344 -0
  5. package/src/editor/factory.ts +148 -0
  6. package/src/editor/history.ts +142 -0
  7. package/src/editor/index.ts +11 -0
  8. package/src/editor/layout.ts +207 -0
  9. package/src/formats/circuit-json/serializer.ts +1410 -0
  10. package/src/formats/document.ts +274 -0
  11. package/src/formats/interchange/parser.ts +1165 -0
  12. package/src/formats/interchange/serializer.ts +594 -0
  13. package/src/formats/ltspice/catalog.ts +181 -0
  14. package/src/formats/ltspice/encoding.ts +151 -0
  15. package/src/formats/ltspice/parser.ts +432 -0
  16. package/src/formats/ltspice/serializer.ts +169 -0
  17. package/src/formats/schx/catalog.ts +439 -0
  18. package/src/formats/schx/parser.ts +261 -0
  19. package/src/formats/schx/runtime-descriptors.ts +502 -0
  20. package/src/formats/schx/serializer.ts +211 -0
  21. package/src/formats/schx/transforms.ts +38 -0
  22. package/src/formats/spice/parser.ts +373 -0
  23. package/src/formats/spice/serializer.ts +43 -0
  24. package/src/index.ts +205 -0
  25. package/src/model/connectivity.ts +239 -0
  26. package/src/model/netlist.ts +375 -0
  27. package/src/model/properties.ts +101 -0
  28. package/src/model/quantity.ts +173 -0
  29. package/src/model/types.ts +309 -0
  30. package/src/model/validation.ts +985 -0
  31. package/src/model/wires.ts +86 -0
  32. package/src/panel/extract.ts +878 -0
  33. package/src/panel/index.ts +39 -0
  34. package/src/panel/knobs.ts +70 -0
  35. package/src/panel/protocol.ts +117 -0
  36. package/src/panel/types.ts +180 -0
  37. package/src/preview/bounds.ts +85 -0
  38. package/src/preview/box-layout.ts +24 -0
  39. package/src/preview/colors.ts +43 -0
  40. package/src/preview/hanging.ts +94 -0
  41. package/src/preview/junctions.ts +94 -0
  42. package/src/preview/label-layout.ts +90 -0
  43. package/src/preview/ports.ts +101 -0
  44. package/src/preview/renderable-wires.ts +113 -0
  45. package/src/preview/routing.ts +15 -0
  46. package/src/preview/snap.ts +104 -0
  47. package/src/preview/symbols/analog-switch.svg +17 -0
  48. package/src/preview/symbols/battery.svg +16 -0
  49. package/src/preview/symbols/bbd.svg +21 -0
  50. package/src/preview/symbols/bjt-npn.svg +16 -0
  51. package/src/preview/symbols/bjt-pnp.svg +17 -0
  52. package/src/preview/symbols/capacitor-electrolytic.svg +13 -0
  53. package/src/preview/symbols/capacitor.svg +12 -0
  54. package/src/preview/symbols/current-source.svg +14 -0
  55. package/src/preview/symbols/delay-ic.svg +22 -0
  56. package/src/preview/symbols/diode-schottky.svg +12 -0
  57. package/src/preview/symbols/diode-zener.svg +12 -0
  58. package/src/preview/symbols/diode.svg +13 -0
  59. package/src/preview/symbols/flipflop.svg +20 -0
  60. package/src/preview/symbols/ground.svg +12 -0
  61. package/src/preview/symbols/ic-block.svg +20 -0
  62. package/src/preview/symbols/ic.svg +19 -0
  63. package/src/preview/symbols/inductor.svg +11 -0
  64. package/src/preview/symbols/jack-input.svg +16 -0
  65. package/src/preview/symbols/jack-output.svg +16 -0
  66. package/src/preview/symbols/jfet-junction-n.svg +17 -0
  67. package/src/preview/symbols/jfet-n.svg +17 -0
  68. package/src/preview/symbols/jfet-p.svg +17 -0
  69. package/src/preview/symbols/label.svg +8 -0
  70. package/src/preview/symbols/led.svg +18 -0
  71. package/src/preview/symbols/mosfet-n.svg +21 -0
  72. package/src/preview/symbols/mosfet-p.svg +21 -0
  73. package/src/preview/symbols/named-wire.svg +11 -0
  74. package/src/preview/symbols/opamp.svg +21 -0
  75. package/src/preview/symbols/optocoupler.svg +30 -0
  76. package/src/preview/symbols/ota.svg +20 -0
  77. package/src/preview/symbols/pentode.svg +25 -0
  78. package/src/preview/symbols/photoresistor.svg +19 -0
  79. package/src/preview/symbols/port.svg +8 -0
  80. package/src/preview/symbols/potentiometer.svg +15 -0
  81. package/src/preview/symbols/power-amp.svg +20 -0
  82. package/src/preview/symbols/rail.svg +11 -0
  83. package/src/preview/symbols/regulator.svg +13 -0
  84. package/src/preview/symbols/relay.svg +20 -0
  85. package/src/preview/symbols/resistor.svg +11 -0
  86. package/src/preview/symbols/svg-content.ts +59 -0
  87. package/src/preview/symbols/switch-3pdt.svg +32 -0
  88. package/src/preview/symbols/switch-rotary.svg +23 -0
  89. package/src/preview/symbols/switch-spdt.svg +16 -0
  90. package/src/preview/symbols/switch-spst.svg +14 -0
  91. package/src/preview/symbols/switch-toggle.svg +14 -0
  92. package/src/preview/symbols/transformer.svg +17 -0
  93. package/src/preview/symbols/triode.svg +17 -0
  94. package/src/preview/symbols/tube-diode.svg +13 -0
  95. package/src/preview/symbols/unsupported.svg +8 -0
  96. package/src/preview/symbols/variable-resistor.svg +13 -0
  97. package/src/preview/symbols/voltage-source.svg +15 -0
  98. package/src/preview/symbols.ts +207 -0
  99. package/src/preview/wire-chains.ts +153 -0
@@ -0,0 +1,878 @@
1
+ import { isParsedQuantity, propertyNumericValue, propertyStringValue } from '../model/properties';
2
+ import type {
3
+ CircuitDocument,
4
+ Component,
5
+ ControlInterface,
6
+ DeviceInterfaceBinding,
7
+ DeviceInterfaceControl,
8
+ DeviceInterfaceControlKind,
9
+ ParsedQuantity,
10
+ PropertyValue,
11
+ Warning,
12
+ } from '../model/types';
13
+ import type {
14
+ DeviceInterfaceProvenance,
15
+ ExtractedDeviceInterface,
16
+ ExtractedDeviceInterfaceControl,
17
+ ExternalControlAssignmentHint,
18
+ JackAudioRole,
19
+ JackPort,
20
+ JackRole,
21
+ Knob,
22
+ KnobStep,
23
+ KnobTaper,
24
+ LedIndicator,
25
+ Panel,
26
+ SliderControl,
27
+ SliderOrientation,
28
+ SliderRange,
29
+ SwitchControl,
30
+ SwitchKind,
31
+ } from './types';
32
+ import { buildKnobSteps, snapKnobPosition } from './knobs';
33
+
34
+ type RuntimeContinuousControlSpec = Readonly<{
35
+ key: string;
36
+ controlProperty: string;
37
+ wipeProperty: string;
38
+ sweepProperty: string;
39
+ }>;
40
+
41
+ const RUNTIME_CONTINUOUS_CONTROL_SPECS: readonly RuntimeContinuousControlSpec[] = [
42
+ { key: 'time', controlProperty: 'TimeControl', wipeProperty: 'TimeControlWipe', sweepProperty: 'TimeControlSweep' },
43
+ { key: 'feedback', controlProperty: 'FeedbackControl', wipeProperty: 'FeedbackControlWipe', sweepProperty: 'FeedbackControlSweep' },
44
+ { key: 'mix', controlProperty: 'MixControl', wipeProperty: 'MixControlWipe', sweepProperty: 'MixControlSweep' },
45
+ { key: 'level', controlProperty: 'LevelControl', wipeProperty: 'LevelControlWipe', sweepProperty: 'LevelControlSweep' },
46
+ { key: 'tone', controlProperty: 'ToneControl', wipeProperty: 'ToneControlWipe', sweepProperty: 'ToneControlSweep' },
47
+ { key: 'mod-rate', controlProperty: 'ModRateControl', wipeProperty: 'ModRateControlWipe', sweepProperty: 'ModRateControlSweep' },
48
+ { key: 'mod-depth', controlProperty: 'ModDepthControl', wipeProperty: 'ModDepthControlWipe', sweepProperty: 'ModDepthControlSweep' },
49
+ ];
50
+
51
+ // extractPanel inspects a CircuitDocument and emits the typed Panel descriptor
52
+ // that drives the runtime control surface. It's a pure read over the existing
53
+ // schematic model — no parsing of the source format, no UI knowledge.
54
+ export function extractPanel(doc: CircuitDocument): Panel {
55
+ const knobs: Knob[] = [];
56
+ const sliders: SliderControl[] = [];
57
+ const switches: SwitchControl[] = [];
58
+ const leds: LedIndicator[] = [];
59
+ const jacks: JackPort[] = [];
60
+
61
+ for (const component of doc.components) {
62
+ switch (component.kind) {
63
+ case 'potentiometer': {
64
+ if (isSliderControl(component)) {
65
+ sliders.push(toSlider(component));
66
+ } else {
67
+ knobs.push(toKnob(component));
68
+ }
69
+ break;
70
+ }
71
+ case 'switch': {
72
+ switches.push(toSwitch(component));
73
+ break;
74
+ }
75
+ case 'led': {
76
+ leds.push(toLed(component));
77
+ break;
78
+ }
79
+ case 'jack': {
80
+ jacks.push(toJack(component));
81
+ break;
82
+ }
83
+ default:
84
+ if (isRuntimeDescriptor(component)) {
85
+ knobs.push(...runtimeDescriptorKnobs(component));
86
+ const tempoTap = runtimeDescriptorTempoTap(component);
87
+ if (tempoTap !== undefined) {
88
+ jacks.push(tempoTap);
89
+ }
90
+ const directOut = runtimeDescriptorDirectOut(component);
91
+ if (directOut !== undefined) {
92
+ jacks.push(directOut);
93
+ }
94
+ }
95
+ break;
96
+ }
97
+ }
98
+ applyControlInterfaces(doc.controlInterfaces, jacks);
99
+
100
+ return {
101
+ ...(doc.panel === undefined ? {} : { placement: doc.panel }),
102
+ knobs,
103
+ sliders,
104
+ switches,
105
+ leds,
106
+ jacks,
107
+ };
108
+ }
109
+
110
+ export function extractDeviceInterface(doc: CircuitDocument): ExtractedDeviceInterface {
111
+ const panel = extractPanel(doc);
112
+ const inferredControls = inferDeviceInterfaceControls(doc, panel);
113
+ const diagnostics: Warning[] = [];
114
+ const controls = new Map<string, ExtractedDeviceInterfaceControl>();
115
+
116
+ for (const control of doc.deviceInterface?.controls ?? []) {
117
+ controls.set(control.id, {
118
+ ...control,
119
+ provenance: 'vdsp-declared',
120
+ });
121
+ }
122
+
123
+ for (const inferred of inferredControls) {
124
+ const declared = controls.get(inferred.id);
125
+ if (declared === undefined) {
126
+ controls.set(inferred.id, inferred);
127
+ continue;
128
+ }
129
+
130
+ if (declared.binding === undefined && inferred.binding !== undefined) {
131
+ controls.set(declared.id, {
132
+ ...declared,
133
+ inferredBinding: inferred.binding,
134
+ });
135
+ continue;
136
+ }
137
+
138
+ if (
139
+ declared.binding !== undefined
140
+ && inferred.binding !== undefined
141
+ && bindingSignature(declared.binding) !== bindingSignature(inferred.binding)
142
+ ) {
143
+ diagnostics.push({
144
+ code: 'device-interface-inferred-binding-conflict',
145
+ message: `Declared device interface control "${declared.id}" conflicts with inferred binding`,
146
+ componentId: declared.id,
147
+ });
148
+ }
149
+ }
150
+
151
+ return {
152
+ groups: doc.controlGroups ?? [],
153
+ contexts: doc.controlContexts ?? [],
154
+ controls: Array.from(controls.values()),
155
+ ...(panel.placement === undefined ? {} : { placement: panel.placement }),
156
+ diagnostics,
157
+ };
158
+ }
159
+
160
+ function inferDeviceInterfaceControls(
161
+ doc: CircuitDocument,
162
+ panel: Panel,
163
+ ): readonly ExtractedDeviceInterfaceControl[] {
164
+ const controls: ExtractedDeviceInterfaceControl[] = [];
165
+ const controlInterfaceIds = new Set<string>();
166
+ for (const controlInterface of doc.controlInterfaces ?? []) {
167
+ controlInterfaceIds.add(controlInterface.id);
168
+ if (controlInterface.componentId !== undefined) {
169
+ controlInterfaceIds.add(controlInterface.componentId);
170
+ }
171
+ }
172
+
173
+ for (const knob of panel.knobs) {
174
+ const componentId = componentIdFromControlId(knob.id);
175
+ const property = runtimeControlProperty(knob.id);
176
+ controls.push({
177
+ id: knob.id,
178
+ label: knob.name,
179
+ kind: 'knob',
180
+ role: roleFromControlId(knob.id),
181
+ binding: {
182
+ componentId,
183
+ controlId: knob.id,
184
+ controlName: knob.name,
185
+ ...(property === undefined ? {} : { property }),
186
+ },
187
+ provenance: provenanceForComponentControl(doc, componentId),
188
+ });
189
+ }
190
+
191
+ for (const slider of panel.sliders ?? []) {
192
+ controls.push({
193
+ id: slider.id,
194
+ label: slider.name,
195
+ kind: 'slider',
196
+ role: roleFromControlId(slider.id),
197
+ binding: {
198
+ componentId: componentIdFromControlId(slider.id),
199
+ controlId: slider.id,
200
+ controlName: slider.name,
201
+ },
202
+ provenance: 'source-inferred',
203
+ });
204
+ }
205
+
206
+ for (const switchControl of panel.switches) {
207
+ controls.push({
208
+ id: switchControl.id,
209
+ label: switchControl.name,
210
+ kind: 'switch',
211
+ role: roleFromControlId(switchControl.id),
212
+ binding: {
213
+ componentId: componentIdFromControlId(switchControl.id),
214
+ controlId: switchControl.id,
215
+ controlName: switchControl.name,
216
+ },
217
+ provenance: provenanceForComponentControl(doc, componentIdFromControlId(switchControl.id)),
218
+ });
219
+ }
220
+
221
+ for (const led of panel.leds) {
222
+ controls.push({
223
+ id: led.id,
224
+ label: led.name,
225
+ kind: 'led',
226
+ role: 'indicator',
227
+ binding: {
228
+ componentId: componentIdFromControlId(led.id),
229
+ controlId: led.id,
230
+ controlName: led.name,
231
+ },
232
+ provenance: 'source-inferred',
233
+ });
234
+ }
235
+
236
+ for (const jack of panel.jacks) {
237
+ const componentId = jack.sourceComponentId ?? componentIdFromControlId(jack.id);
238
+ const binding = deviceBindingForJack(jack, componentId);
239
+ controls.push({
240
+ id: jack.id,
241
+ label: jack.name,
242
+ kind: 'jack',
243
+ role: jack.controlRole ?? jack.role,
244
+ ...(binding === undefined ? {} : { binding }),
245
+ provenance: controlInterfaceIds.has(jack.id)
246
+ ? 'control-interface-declared'
247
+ : provenanceForComponentControl(doc, componentId),
248
+ });
249
+ }
250
+
251
+ return controls;
252
+ }
253
+
254
+ function deviceBindingForJack(jack: JackPort, componentId: string): DeviceInterfaceBinding | undefined {
255
+ if (jack.binding !== undefined) {
256
+ return deviceBindingFromControlInterfaceBinding(jack.binding, componentId);
257
+ }
258
+ if (jack.id.endsWith(':tempo-tap')) {
259
+ return {
260
+ componentId,
261
+ controlId: jack.id,
262
+ controlName: jack.name,
263
+ property: 'TempoTapControl',
264
+ };
265
+ }
266
+ if (jack.sourceComponentId !== undefined || jack.id === componentId) {
267
+ return {
268
+ componentId,
269
+ controlId: jack.id,
270
+ controlName: jack.name,
271
+ };
272
+ }
273
+ return undefined;
274
+ }
275
+
276
+ function deviceBindingFromControlInterfaceBinding(
277
+ binding: ControlInterface['binding'],
278
+ fallbackComponentId: string,
279
+ ): DeviceInterfaceBinding | undefined {
280
+ if (binding === undefined) {
281
+ return undefined;
282
+ }
283
+ const componentId = binding.sourceComponentId ?? fallbackComponentId;
284
+ return {
285
+ componentId,
286
+ ...(binding.controlId === undefined ? {} : { controlId: binding.controlId }),
287
+ ...(binding.controlName === undefined ? {} : { controlName: binding.controlName }),
288
+ ...(binding.property === undefined ? {} : { property: binding.property }),
289
+ };
290
+ }
291
+
292
+ function provenanceForComponentControl(doc: CircuitDocument, componentId: string): DeviceInterfaceProvenance {
293
+ const component = doc.components.find((candidate) => candidate.id === componentId);
294
+ return component !== undefined && isRuntimeDescriptor(component)
295
+ ? 'runtime-descriptor-inferred'
296
+ : 'source-inferred';
297
+ }
298
+
299
+ function componentIdFromControlId(id: string): string {
300
+ const separator = id.indexOf(':');
301
+ return separator <= 0 ? id : id.slice(0, separator);
302
+ }
303
+
304
+ function roleFromControlId(id: string): string {
305
+ const separator = id.indexOf(':');
306
+ const raw = separator >= 0 ? id.slice(separator + 1) : id;
307
+ return normalizeToken(raw);
308
+ }
309
+
310
+ function runtimeControlProperty(id: string): string | undefined {
311
+ const key = roleFromControlId(id);
312
+ for (const spec of RUNTIME_CONTINUOUS_CONTROL_SPECS) {
313
+ if (spec.key === key) {
314
+ return spec.controlProperty;
315
+ }
316
+ }
317
+ if (key === 'mode') {
318
+ return 'ModeControl';
319
+ }
320
+ if (key === 'tempo-tap') {
321
+ return 'TempoTapControl';
322
+ }
323
+ if (key === 'direct-out') {
324
+ return 'DirectOutputJack';
325
+ }
326
+ return undefined;
327
+ }
328
+
329
+ function bindingSignature(binding: DeviceInterfaceBinding): string {
330
+ return [
331
+ binding.componentId,
332
+ binding.controlId ?? '',
333
+ binding.controlName ?? '',
334
+ binding.property ?? '',
335
+ binding.externalInterfaceId ?? '',
336
+ ].join(':');
337
+ }
338
+
339
+ function applyControlInterfaces(
340
+ controlInterfaces: readonly ControlInterface[] | undefined,
341
+ jacks: JackPort[],
342
+ ): void {
343
+ for (const controlInterface of controlInterfaces ?? []) {
344
+ const port = toControlInterfaceJack(controlInterface);
345
+ const existingIndex = controlInterface.componentId === undefined
346
+ ? -1
347
+ : jacks.findIndex((jack) => jack.id === controlInterface.componentId);
348
+ if (existingIndex >= 0) {
349
+ const existing = jacks[existingIndex];
350
+ if (existing !== undefined) {
351
+ jacks[existingIndex] = { ...existing, ...port };
352
+ }
353
+ } else {
354
+ jacks.push(port);
355
+ }
356
+ }
357
+ }
358
+
359
+ function toControlInterfaceJack(controlInterface: ControlInterface): JackPort {
360
+ const sourceComponentId = controlInterface.binding?.sourceComponentId;
361
+ const controlRole = controlInterface.controlRole ?? defaultControlRole(controlInterface);
362
+ const interfaceName = controlInterface.interface ?? defaultInterfaceName(controlInterface);
363
+ return {
364
+ id: controlInterface.componentId ?? controlInterface.id,
365
+ name: controlInterface.name,
366
+ role: jackRoleForControlInterface(controlInterface),
367
+ ...(sourceComponentId === undefined ? {} : { sourceComponentId }),
368
+ ...(controlRole === undefined ? {} : { controlRole }),
369
+ ...(interfaceName === undefined ? {} : { interface: interfaceName }),
370
+ ...(controlInterface.connector === undefined ? {} : { connector: controlInterface.connector }),
371
+ ...(controlInterface.assignmentHint === undefined ? {} : { assignmentHint: controlInterface.assignmentHint }),
372
+ ...(controlInterface.polarity === undefined ? {} : { polarity: controlInterface.polarity }),
373
+ ...(controlInterface.binding === undefined ? {} : { binding: controlInterface.binding }),
374
+ ...(controlInterface.description === undefined ? {} : { description: controlInterface.description }),
375
+ };
376
+ }
377
+
378
+ function jackRoleForControlInterface(controlInterface: ControlInterface): JackRole {
379
+ switch (controlInterface.role) {
380
+ case 'tempo-tap':
381
+ return 'tempo-tap';
382
+ case 'expression':
383
+ return 'expression';
384
+ case 'external-control':
385
+ case 'trigger':
386
+ case 'reset':
387
+ case 'sampler-trigger':
388
+ return 'external-control';
389
+ case 'unknown':
390
+ return 'unknown';
391
+ }
392
+ }
393
+
394
+ function defaultControlRole(controlInterface: ControlInterface): string | undefined {
395
+ return controlInterface.role === 'unknown' || controlInterface.role === 'external-control'
396
+ ? undefined
397
+ : controlInterface.role;
398
+ }
399
+
400
+ function defaultInterfaceName(controlInterface: ControlInterface): string | undefined {
401
+ if (controlInterface.role === 'tempo-tap') {
402
+ return 'tap-tempo';
403
+ }
404
+ if (controlInterface.role === 'unknown') {
405
+ return undefined;
406
+ }
407
+ return 'external-control-input';
408
+ }
409
+
410
+ function toKnob(component: Component): Knob {
411
+ const taper = resolveTaper(propertyString(component, 'Sweep') ?? propertyString(component, 'Taper'));
412
+ const stepLabels = parseStepLabels(propertyStringAny(component, ['StepLabels', 'Steps']));
413
+ const explicitStepCount = parseStepCount(propertyStringAny(component, ['StepCount', 'Detents', 'Positions', 'Steps']));
414
+ const steps = buildKnobSteps(stepLabels.length >= 2 ? stepLabels.length : explicitStepCount ?? 0, stepLabels);
415
+ const rawDefaultPosition = clamp01(parseNumeric(component.properties.Wipe) ?? 0.5);
416
+ const defaultPosition = steps === undefined ? rawDefaultPosition : snapKnobPosition({ steps }, rawDefaultPosition);
417
+ const resistance = quantityProperty(component, 'Resistance');
418
+ const gangGroup = propertyString(component, 'Group') ?? undefined;
419
+ const description = propertyString(component, 'Description') ?? undefined;
420
+ return {
421
+ id: component.id,
422
+ name: component.name,
423
+ taper,
424
+ controlMode: steps === undefined ? 'continuous' : 'stepped',
425
+ defaultPosition,
426
+ ...(steps !== undefined ? { steps } : {}),
427
+ ...(resistance !== undefined ? { resistance } : {}),
428
+ ...(gangGroup !== undefined && gangGroup.length > 0 ? { gangGroup } : {}),
429
+ ...(description !== undefined && description.length > 0 ? { description } : {}),
430
+ };
431
+ }
432
+
433
+ function toSlider(component: Component): SliderControl {
434
+ const defaultPosition = clamp01(parseNumeric(component.properties.Wipe) ?? 0.5);
435
+ const orientation = resolveSliderOrientation(propertyStringAny(component, ['Orientation', 'SliderOrientation']));
436
+ const range = sliderRange(component);
437
+ const gangGroup = propertyString(component, 'Group') ?? undefined;
438
+ const description = propertyString(component, 'Description') ?? undefined;
439
+ return {
440
+ id: component.id,
441
+ name: component.name,
442
+ defaultPosition,
443
+ orientation,
444
+ ...(range !== undefined ? { range } : {}),
445
+ ...(gangGroup !== undefined && gangGroup.length > 0 ? { gangGroup } : {}),
446
+ ...(description !== undefined && description.length > 0 ? { description } : {}),
447
+ };
448
+ }
449
+
450
+ function toSwitch(component: Component): SwitchControl {
451
+ const switchKind = resolveSwitchKind(component);
452
+ const { poles, positions } = switchGeometry(switchKind);
453
+ const defaultPosition = clampInt(parseNumeric(component.properties.Position) ?? 0, 0, positions - 1);
454
+ const gangGroup = propertyString(component, 'Group') ?? undefined;
455
+ const partNumber = propertyString(component, 'PartNumber') ?? undefined;
456
+ const description = propertyString(component, 'Description') ?? undefined;
457
+ return {
458
+ id: component.id,
459
+ name: component.name,
460
+ switchKind,
461
+ poles,
462
+ positions,
463
+ defaultPosition,
464
+ ...(gangGroup !== undefined && gangGroup.length > 0 ? { gangGroup } : {}),
465
+ ...(partNumber !== undefined && partNumber.length > 0 ? { partNumber } : {}),
466
+ ...(description !== undefined && description.length > 0 ? { description } : {}),
467
+ };
468
+ }
469
+
470
+ function toLed(component: Component): LedIndicator {
471
+ const color = propertyString(component, 'Color') ?? inferLedColor(component);
472
+ const partNumber = propertyString(component, 'PartNumber') ?? undefined;
473
+ const description = propertyString(component, 'Description') ?? undefined;
474
+ return {
475
+ id: component.id,
476
+ name: component.name,
477
+ ...(color !== undefined ? { color } : {}),
478
+ ...(partNumber !== undefined && partNumber.length > 0 ? { partNumber } : {}),
479
+ ...(description !== undefined && description.length > 0 ? { description } : {}),
480
+ };
481
+ }
482
+
483
+ function toJack(component: Component): JackPort {
484
+ const role = resolveJackRole(component);
485
+ const name = nonEmptyString(propertyStringAny(component, ['JackLabel', 'Label'])) ?? component.name;
486
+ const audioRole = nonEmptyString(propertyString(component, 'AudioRole')) as JackAudioRole | undefined;
487
+ const impedance = quantityProperty(component, 'Impedance');
488
+ const controlRole = nonEmptyString(propertyString(component, 'ControlRole'));
489
+ const interfaceName = nonEmptyString(propertyString(component, 'Interface'));
490
+ const description = propertyString(component, 'Description') ?? undefined;
491
+ const sourceTypeName = component.sourceTypeName ?? undefined;
492
+ return {
493
+ id: component.id,
494
+ name,
495
+ role,
496
+ ...(audioRole !== undefined ? { audioRole } : {}),
497
+ ...(impedance !== undefined ? { impedance } : {}),
498
+ ...(sourceTypeName !== undefined ? { sourceTypeName } : {}),
499
+ ...(controlRole !== undefined ? { controlRole } : {}),
500
+ ...(interfaceName !== undefined ? { interface: interfaceName } : {}),
501
+ ...(description !== undefined && description.length > 0 ? { description } : {}),
502
+ };
503
+ }
504
+
505
+ function runtimeDescriptorKnobs(component: Component): readonly Knob[] {
506
+ const knobs: Knob[] = [];
507
+
508
+ for (const spec of RUNTIME_CONTINUOUS_CONTROL_SPECS) {
509
+ const name = nonEmptyString(propertyString(component, spec.controlProperty));
510
+ if (name === undefined) {
511
+ continue;
512
+ }
513
+
514
+ knobs.push({
515
+ id: `${component.id}:${spec.key}`,
516
+ name,
517
+ taper: resolveTaper(propertyString(component, spec.sweepProperty)),
518
+ controlMode: 'continuous',
519
+ defaultPosition: clamp01(parseNumeric(component.properties[spec.wipeProperty]) ?? 0.5),
520
+ });
521
+ }
522
+
523
+ const mode = runtimeDescriptorMode(component);
524
+ if (mode !== undefined) {
525
+ knobs.push(mode);
526
+ }
527
+
528
+ return knobs;
529
+ }
530
+
531
+ function runtimeDescriptorMode(component: Component): Knob | undefined {
532
+ const name = nonEmptyString(propertyString(component, 'ModeControl'));
533
+ const labels = parseStepLabels(propertyStringAny(component, ['ModeLabels', 'ModeOptions']));
534
+ const explicitStepCount = parseStepCount(propertyStringAny(component, ['ModeStepCount', 'ModeSteps', 'ModeCount']));
535
+ const steps = buildKnobSteps(labels.length >= 2 ? labels.length : explicitStepCount ?? 0, labels);
536
+ if (name === undefined || steps === undefined) {
537
+ return undefined;
538
+ }
539
+
540
+ return {
541
+ id: `${component.id}:mode`,
542
+ name,
543
+ taper: 'unknown',
544
+ controlMode: 'stepped',
545
+ defaultPosition: runtimeModeDefaultPosition(
546
+ steps,
547
+ parseNumericAny(component, ['ModeControlWipe', 'ModeDefaultIndex', 'ModeIndex']),
548
+ ),
549
+ steps,
550
+ };
551
+ }
552
+
553
+ function runtimeDescriptorTempoTap(component: Component): JackPort | undefined {
554
+ const name = nonEmptyString(propertyStringAny(component, ['TempoTapControl', 'TapTempoControl', 'TempoControl']));
555
+ if (name === undefined) {
556
+ return undefined;
557
+ }
558
+
559
+ const sourceTypeName = component.sourceTypeName ?? undefined;
560
+ const assignmentHint: ExternalControlAssignmentHint = 'momentary';
561
+ return {
562
+ id: `${component.id}:tempo-tap`,
563
+ name,
564
+ role: 'tempo-tap',
565
+ sourceComponentId: component.id,
566
+ controlRole: 'tempo-tap',
567
+ interface: 'tap-tempo',
568
+ assignmentHint,
569
+ ...(sourceTypeName !== undefined ? { sourceTypeName } : {}),
570
+ };
571
+ }
572
+
573
+ function runtimeDescriptorDirectOut(component: Component): JackPort | undefined {
574
+ const name = nonEmptyString(propertyStringAny(component, [
575
+ 'DirectOutputJack',
576
+ 'DirectOutJack',
577
+ 'DirectOutputControl',
578
+ 'DirectOutControl',
579
+ ]));
580
+ if (name === undefined) {
581
+ return undefined;
582
+ }
583
+
584
+ const sourceTypeName = component.sourceTypeName ?? undefined;
585
+ const description = nonEmptyString(propertyStringAny(component, [
586
+ 'DirectOutputRuntimeBoundary',
587
+ 'DirectOutputDescription',
588
+ 'DirectOutDescription',
589
+ ]));
590
+ const controlId = `${component.id}:direct-out`;
591
+ return {
592
+ id: controlId,
593
+ name,
594
+ role: 'direct-output',
595
+ sourceComponentId: component.id,
596
+ controlRole: 'direct-output',
597
+ interface: 'audio-output',
598
+ binding: {
599
+ sourceComponentId: component.id,
600
+ controlId,
601
+ controlName: name,
602
+ property: 'DirectOutputJack',
603
+ },
604
+ ...(sourceTypeName !== undefined ? { sourceTypeName } : {}),
605
+ ...(description === undefined ? {} : { description }),
606
+ };
607
+ }
608
+
609
+ function isSliderControl(component: Component): boolean {
610
+ const style = propertyStringAny(component, ['ControlStyle', 'ControlType', 'PanelControl', 'UiControl', 'Style']);
611
+ if (style === null) {
612
+ return false;
613
+ }
614
+ const lower = style.toLowerCase();
615
+ return lower.includes('slider') || lower.includes('fader');
616
+ }
617
+
618
+ function resolveSliderOrientation(value: string | null): SliderOrientation {
619
+ if (value?.toLowerCase().includes('horizontal')) {
620
+ return 'horizontal';
621
+ }
622
+ return 'vertical';
623
+ }
624
+
625
+ function sliderRange(component: Component): SliderRange | undefined {
626
+ const min = parseNumericAny(component, ['RangeMin', 'Min', 'Minimum']);
627
+ const max = parseNumericAny(component, ['RangeMax', 'Max', 'Maximum']);
628
+ if (min === undefined || max === undefined || min >= max) {
629
+ return undefined;
630
+ }
631
+ const unit = propertyStringAny(component, ['Unit', 'RangeUnit']) ?? undefined;
632
+ const center = parseNumericAny(component, ['Center', 'CenterValue', 'RangeCenter']);
633
+ return {
634
+ min,
635
+ max,
636
+ ...(unit !== undefined && unit.length > 0 ? { unit } : {}),
637
+ ...(center !== undefined ? { center } : {}),
638
+ };
639
+ }
640
+
641
+ function resolveTaper(value: string | null | undefined): KnobTaper {
642
+ if (value === null || value === undefined) {
643
+ return 'unknown';
644
+ }
645
+ const lower = value.toLowerCase();
646
+ if (lower.includes('log') && lower.includes('rev')) {
647
+ return 'reverse-log';
648
+ }
649
+ if (lower.includes('log') || lower.includes('audio')) {
650
+ return 'log';
651
+ }
652
+ if (lower.includes('lin')) {
653
+ return 'linear';
654
+ }
655
+ return 'unknown';
656
+ }
657
+
658
+ function resolveSwitchKind(component: Component): SwitchKind {
659
+ const short = shortType(component.sourceTypeName);
660
+ if (short === null) {
661
+ return inferFromTerminals(component.terminals.length);
662
+ }
663
+ const upper = short.toUpperCase();
664
+ if (upper === 'SPDT') return 'spdt';
665
+ if (upper === 'SP3T') return 'sp3t';
666
+ if (upper === 'SP4T') return 'sp4t';
667
+ if (upper === '3PDT') return '3pdt';
668
+ if (upper === 'TOGGLE') return 'toggle';
669
+ if (upper === 'ROTARY') return 'rotary';
670
+ if (upper === 'SWITCH') return 'spst';
671
+ return inferFromTerminals(component.terminals.length);
672
+ }
673
+
674
+ function inferFromTerminals(count: number): SwitchKind {
675
+ if (count <= 2) return 'spst';
676
+ if (count === 3) return 'spdt';
677
+ if (count === 9) return '3pdt';
678
+ return 'unknown';
679
+ }
680
+
681
+ function switchGeometry(kind: SwitchKind): { poles: number; positions: number } {
682
+ switch (kind) {
683
+ case 'spst':
684
+ case 'toggle':
685
+ return { poles: 1, positions: 2 };
686
+ case 'spdt':
687
+ return { poles: 1, positions: 2 };
688
+ case 'sp3t':
689
+ return { poles: 1, positions: 3 };
690
+ case 'sp4t':
691
+ return { poles: 1, positions: 4 };
692
+ case '3pdt':
693
+ return { poles: 3, positions: 2 };
694
+ case 'rotary':
695
+ return { poles: 1, positions: 6 };
696
+ case 'unknown':
697
+ return { poles: 1, positions: 2 };
698
+ }
699
+ }
700
+
701
+ function resolveJackRole(component: Component): JackRole {
702
+ const semanticRole = resolveSemanticJackRole(component);
703
+ if (semanticRole !== null) {
704
+ return semanticRole;
705
+ }
706
+
707
+ const short = shortType(component.sourceTypeName);
708
+ if (short === null) {
709
+ return 'unknown';
710
+ }
711
+ const upper = short.toUpperCase();
712
+ if (upper === 'INPUT' || upper === 'INPUTJACK') return 'input';
713
+ if (upper === 'SPEAKER' || upper === 'OUTPUTJACK') return 'output';
714
+ if (upper === 'SEND') return 'send';
715
+ if (upper === 'RETURN') return 'return';
716
+ if (upper === 'EXPRESSION' || upper === 'EXP') return 'expression';
717
+ return 'unknown';
718
+ }
719
+
720
+ function resolveSemanticJackRole(component: Component): JackRole | null {
721
+ const semanticProperties = ['Role', 'ControlRole', 'Interface'] as const;
722
+ for (const name of semanticProperties) {
723
+ const value = propertyString(component, name);
724
+ if (value === null) {
725
+ continue;
726
+ }
727
+ const role = normalizeJackRole(value);
728
+ if (role !== null) {
729
+ return role;
730
+ }
731
+ }
732
+ return null;
733
+ }
734
+
735
+ function normalizeJackRole(value: string): JackRole | null {
736
+ const normalized = normalizeToken(value);
737
+ if (['input', 'audio-input', 'in'].includes(normalized)) return 'input';
738
+ if (['direct-output', 'direct-out', 'dry-output', 'dry-out'].includes(normalized)) return 'direct-output';
739
+ if (['output', 'audio-output', 'out'].includes(normalized)) return 'output';
740
+ if (normalized === 'send') return 'send';
741
+ if (normalized === 'return') return 'return';
742
+ if (['expression', 'exp', 'expression-pedal'].includes(normalized)) return 'expression';
743
+ if (['tempo-tap', 'tap-tempo', 'tempo-in', 'tap', 'tempo'].includes(normalized)) return 'tempo-tap';
744
+ if (
745
+ [
746
+ 'external-control',
747
+ 'external-control-input',
748
+ 'control-input',
749
+ 'remote',
750
+ 'footswitch',
751
+ 'trigger',
752
+ 'reset',
753
+ ].includes(normalized)
754
+ ) {
755
+ return 'external-control';
756
+ }
757
+ return null;
758
+ }
759
+
760
+ function inferLedColor(component: Component): string | undefined {
761
+ // Common pedal LED colors are usually red / amber / green. Try the part number.
762
+ const part = propertyString(component, 'PartNumber')?.toLowerCase() ?? '';
763
+ if (part.includes('red')) return 'red';
764
+ if (part.includes('green')) return 'green';
765
+ if (part.includes('amber')) return 'amber';
766
+ if (part.includes('blue')) return 'blue';
767
+ if (part.includes('yellow')) return 'yellow';
768
+ if (part.includes('white')) return 'white';
769
+ return undefined;
770
+ }
771
+
772
+ function shortType(sourceTypeName: string | null): string | null {
773
+ if (sourceTypeName === null) {
774
+ return null;
775
+ }
776
+ const match = sourceTypeName.match(/Circuit\.(?:Components\.)?([A-Za-z0-9_]+)/);
777
+ if (match?.[1] !== undefined) {
778
+ return match[1];
779
+ }
780
+ const ltspice = sourceTypeName.match(/^ltspice:([A-Za-z0-9_]+)/);
781
+ return ltspice?.[1] ?? null;
782
+ }
783
+
784
+ function propertyString(component: Component, name: string): string | null {
785
+ return propertyStringValue(component.properties[name]);
786
+ }
787
+
788
+ function nonEmptyString(value: string | null): string | undefined {
789
+ const trimmed = value?.trim();
790
+ return trimmed === undefined || trimmed.length === 0 ? undefined : trimmed;
791
+ }
792
+
793
+ function propertyStringAny(component: Component, names: readonly string[]): string | null {
794
+ for (const name of names) {
795
+ const value = propertyString(component, name);
796
+ if (value !== null) {
797
+ return value;
798
+ }
799
+ }
800
+ return null;
801
+ }
802
+
803
+ function quantityProperty(component: Component, name: string): ParsedQuantity | undefined {
804
+ const value = component.properties[name];
805
+ return isParsedQuantity(value) ? value : undefined;
806
+ }
807
+
808
+ function parseNumeric(value: PropertyValue | undefined): number | undefined {
809
+ return propertyNumericValue(value);
810
+ }
811
+
812
+ function parseNumericAny(component: Component, names: readonly string[]): number | undefined {
813
+ for (const name of names) {
814
+ const value = parseNumeric(component.properties[name]);
815
+ if (value !== undefined) {
816
+ return value;
817
+ }
818
+ }
819
+ return undefined;
820
+ }
821
+
822
+ function parseStepLabels(value: string | null): readonly string[] {
823
+ if (value === null) {
824
+ return [];
825
+ }
826
+ const parts = value
827
+ .split(/[,;|]/)
828
+ .map((part) => part.trim())
829
+ .filter((part) => part.length > 0);
830
+ return parts.length >= 2 ? parts : [];
831
+ }
832
+
833
+ function parseStepCount(value: string | null): number | undefined {
834
+ if (value === null) {
835
+ return undefined;
836
+ }
837
+ const trimmed = value.trim();
838
+ if (!/^\d+(?:\.0+)?$/.test(trimmed)) {
839
+ return undefined;
840
+ }
841
+ const count = Number(trimmed);
842
+ return Number.isInteger(count) && count >= 2 ? count : undefined;
843
+ }
844
+
845
+ function runtimeModeDefaultPosition(steps: readonly KnobStep[], rawValue: number | undefined): number {
846
+ if (steps.length === 0) {
847
+ return 0;
848
+ }
849
+ if (rawValue === undefined || !Number.isFinite(rawValue)) {
850
+ return steps[0]?.position ?? 0;
851
+ }
852
+ if (Number.isInteger(rawValue) || rawValue > 1) {
853
+ const index = clampInt(rawValue, 0, steps.length - 1);
854
+ return steps[index]?.position ?? steps[0]?.position ?? 0;
855
+ }
856
+ return snapKnobPosition({ steps }, clamp01(rawValue));
857
+ }
858
+
859
+ function isRuntimeDescriptor(component: Component): boolean {
860
+ return component.kind === 'ic' && component.properties.RuntimeDescriptor === 'true';
861
+ }
862
+
863
+ function normalizeToken(value: string): string {
864
+ return value.trim().toLowerCase().replace(/[\s_]+/g, '-');
865
+ }
866
+
867
+ function clamp01(v: number): number {
868
+ if (v < 0) return 0;
869
+ if (v > 1) return 1;
870
+ return v;
871
+ }
872
+
873
+ function clampInt(v: number, lo: number, hi: number): number {
874
+ const n = Math.trunc(v);
875
+ if (n < lo) return lo;
876
+ if (n > hi) return hi;
877
+ return n;
878
+ }