seqeyes-python 0.3.10__tar.gz → 0.3.11__tar.gz

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 (21) hide show
  1. {seqeyes_python-0.3.10 → seqeyes_python-0.3.11}/PKG-INFO +1 -1
  2. {seqeyes_python-0.3.10 → seqeyes_python-0.3.11}/pyproject.toml +1 -1
  3. {seqeyes_python-0.3.10 → seqeyes_python-0.3.11}/src/seqeyes/__init__.py +1 -1
  4. {seqeyes_python-0.3.10 → seqeyes_python-0.3.11}/src/seqeyes/resources/pulseq-bundle.js +287 -10
  5. {seqeyes_python-0.3.10 → seqeyes_python-0.3.11}/src/seqeyes/resources/webview-bundle.js +324 -38
  6. {seqeyes_python-0.3.10 → seqeyes_python-0.3.11}/.gitignore +0 -0
  7. {seqeyes_python-0.3.10 → seqeyes_python-0.3.11}/README.md +0 -0
  8. {seqeyes_python-0.3.10 → seqeyes_python-0.3.11}/examples/demo.ipynb +0 -0
  9. {seqeyes_python-0.3.10 → seqeyes_python-0.3.11}/examples/demo.py +0 -0
  10. {seqeyes_python-0.3.10 → seqeyes_python-0.3.11}/src/seqeyes/_plot.py +0 -0
  11. {seqeyes_python-0.3.10 → seqeyes_python-0.3.11}/src/seqeyes/_renderer.py +0 -0
  12. {seqeyes_python-0.3.10 → seqeyes_python-0.3.11}/src/seqeyes/resources/seqeyes-labels.js +0 -0
  13. {seqeyes_python-0.3.10 → seqeyes_python-0.3.11}/src/seqeyes/resources/viewer.html +0 -0
  14. {seqeyes_python-0.3.10 → seqeyes_python-0.3.11}/tests/conftest.py +0 -0
  15. {seqeyes_python-0.3.10 → seqeyes_python-0.3.11}/tests/debug_full.html +0 -0
  16. {seqeyes_python-0.3.10 → seqeyes_python-0.3.11}/tests/debug_minimal.html +0 -0
  17. {seqeyes_python-0.3.10 → seqeyes_python-0.3.11}/tests/debug_viewer.html +0 -0
  18. {seqeyes_python-0.3.10 → seqeyes_python-0.3.11}/tests/debug_viewer.py +0 -0
  19. {seqeyes_python-0.3.10 → seqeyes_python-0.3.11}/tests/integration_test.py +0 -0
  20. {seqeyes_python-0.3.10 → seqeyes_python-0.3.11}/tests/test_plot.py +0 -0
  21. {seqeyes_python-0.3.10 → seqeyes_python-0.3.11}/tests/test_renderer.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: seqeyes-python
3
- Version: 0.3.10
3
+ Version: 0.3.11
4
4
  Summary: Interactive Pulseq MRI sequence viewer for Jupyter — lightweight, beautiful, replaces seq.plot() in pypulseq.
5
5
  Project-URL: Homepage, https://github.com/bughht/seqeyes_plugin
6
6
  Project-URL: Repository, https://github.com/bughht/seqeyes_plugin
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "seqeyes-python"
7
- version = "0.3.10"
7
+ version = "0.3.11"
8
8
  description = "Interactive Pulseq MRI sequence viewer for Jupyter — lightweight, beautiful, replaces seq.plot() in pypulseq."
9
9
  readme = "README.md"
10
10
  license = {text = "MIT"}
@@ -29,4 +29,4 @@ from seqeyes._plot import set, reset
29
29
  from seqeyes._renderer import SeqEyesViewer
30
30
 
31
31
  __all__ = ["set", "reset", "SeqEyesViewer"]
32
- __version__ = "0.3.10"
32
+ __version__ = "0.3.11"
@@ -89,7 +89,7 @@ var Pulseq = (() => {
89
89
  });
90
90
 
91
91
  // package.json
92
- var version = "0.3.10";
92
+ var version = "0.3.11";
93
93
 
94
94
  // src/pulseq/decompressor.ts
95
95
  function decompressShape(compressed, numSamples) {
@@ -2399,7 +2399,7 @@ var Pulseq = (() => {
2399
2399
  decimatedSamples,
2400
2400
  columns,
2401
2401
  fftPoints,
2402
- totalCells: columns * bins * 4,
2402
+ totalCells: columns * bins * (input.includeRf ? 7 : 4),
2403
2403
  decimationFactor
2404
2404
  };
2405
2405
  }
@@ -4657,6 +4657,125 @@ var Pulseq = (() => {
4657
4657
  return Math.floor((coreSamples - 1) / Math.max(1, factor)) + 1;
4658
4658
  }
4659
4659
 
4660
+ // src/pulseq/rfAcoustic.ts
4661
+ function normalizePeak(values) {
4662
+ let peak = 0;
4663
+ for (let i = 0; i < values.length; i++) {
4664
+ const v = values[i] < 0 ? -values[i] : values[i];
4665
+ if (v > peak) peak = v;
4666
+ }
4667
+ if (peak > 0) for (let i = 0; i < values.length; i++) values[i] /= peak;
4668
+ }
4669
+ function rfPowerAt(rf, t) {
4670
+ const tp = rf.timePoints, mag = rf.magnitude;
4671
+ const last = tp.length - 1;
4672
+ if (last < 0) return 0;
4673
+ if (last === 0) return mag[0] * mag[0];
4674
+ if (t < tp[0] - GRADIENT_ENDPOINT_TOLERANCE_SEC || t > tp[last] + GRADIENT_ENDPOINT_TOLERANCE_SEC) return 0;
4675
+ if (t <= tp[0]) return mag[0] * mag[0];
4676
+ if (t >= tp[last]) return mag[last] * mag[last];
4677
+ let lo = 0, hi = last;
4678
+ while (hi - lo > 1) {
4679
+ const m = lo + hi >> 1;
4680
+ if (tp[m] <= t) lo = m;
4681
+ else hi = m;
4682
+ }
4683
+ const span = tp[hi] - tp[lo];
4684
+ const pLo = mag[lo] * mag[lo];
4685
+ if (span <= 0) return pLo;
4686
+ const pHi = mag[hi] * mag[hi];
4687
+ return pLo + (pHi - pLo) * (t - tp[lo]) / span;
4688
+ }
4689
+ function rfPowerArea(rf) {
4690
+ const tp = rf.timePoints, mag = rf.magnitude;
4691
+ let area = 0;
4692
+ for (let i = 1; i < tp.length; i++) {
4693
+ const pPrev = mag[i - 1] * mag[i - 1];
4694
+ const pCurr = mag[i] * mag[i];
4695
+ area += 0.5 * (pPrev + pCurr) * (tp[i] - tp[i - 1]);
4696
+ }
4697
+ return area;
4698
+ }
4699
+ function resampleRfAcousticSources(blocks, options) {
4700
+ const dt = options.dt;
4701
+ if (!(dt > 0)) throw new Error("resampleRfAcousticSources requires a positive dt.");
4702
+ const t0 = Number.isFinite(options.startSec) ? options.startSec : 0;
4703
+ const n = Math.max(0, Math.floor(options.sampleCount));
4704
+ const rfScale = Math.max(0, Number.isFinite(options.rfScale) ? options.rfScale : 1);
4705
+ const edgeMode = options.edgeMode === "absolute" ? "absolute" : "signed";
4706
+ const power = new Float32Array(n);
4707
+ const gate = new Float32Array(n);
4708
+ const thermo = new Float32Array(n);
4709
+ const control = new Float32Array(n);
4710
+ const empty = {
4711
+ t0,
4712
+ dt,
4713
+ n,
4714
+ power,
4715
+ gate,
4716
+ thermo,
4717
+ control,
4718
+ silent: true,
4719
+ eventCount: 0,
4720
+ subSampleEventCount: 0
4721
+ };
4722
+ if (!n) return empty;
4723
+ const tEnd = t0 + (n - 1) * dt;
4724
+ let eventCount = 0;
4725
+ let subSampleEventCount = 0;
4726
+ for (const block of blocks) {
4727
+ const rf = block.rf;
4728
+ if (!rf || !rf.timePoints || !rf.timePoints.length || !rf.magnitude.length) continue;
4729
+ const last = rf.timePoints.length - 1;
4730
+ const rfStart = rf.timePoints[0];
4731
+ const rfEnd = rf.timePoints[last];
4732
+ if (rfEnd < t0 - GRADIENT_ENDPOINT_TOLERANCE_SEC || rfStart > tEnd + GRADIENT_ENDPOINT_TOLERANCE_SEC) continue;
4733
+ eventCount++;
4734
+ const gateLo = clampIndex(Math.floor((rfStart - t0) / dt), n);
4735
+ const gateHi = clampIndex(Math.ceil((rfEnd - t0) / dt), n);
4736
+ for (let i = gateLo; i <= gateHi; i++) gate[i] = 1;
4737
+ const coverLo = clampIndex(Math.ceil((rfStart - t0) / dt), n);
4738
+ const coverHi = clampIndex(Math.floor((rfEnd - t0) / dt), n);
4739
+ const spansASample = rfEnd - rfStart >= dt && coverHi >= coverLo;
4740
+ if (spansASample) {
4741
+ for (let i = coverLo; i <= coverHi; i++) power[i] += rfPowerAt(rf, t0 + i * dt);
4742
+ } else {
4743
+ subSampleEventCount++;
4744
+ const area = last === 0 ? rf.magnitude[0] * rf.magnitude[0] * dt : rfPowerArea(rf);
4745
+ const mid = clampIndex(Math.round((0.5 * (rfStart + rfEnd) - t0) / dt), n);
4746
+ power[mid] += area / dt;
4747
+ }
4748
+ }
4749
+ const silent = eventCount === 0;
4750
+ if (silent) return { ...empty, silent: true };
4751
+ normalizePeak(power);
4752
+ const invDt = 1 / dt;
4753
+ for (let i = 1; i < n; i++) thermo[i] = (power[i] - power[i - 1]) * invDt;
4754
+ normalizePeak(thermo);
4755
+ const amplitudeGain = rfScale * rfScale;
4756
+ if (amplitudeGain !== 1) for (let i = 0; i < n; i++) thermo[i] *= amplitudeGain;
4757
+ for (let i = 1; i < n; i++) {
4758
+ const d = gate[i] - gate[i - 1];
4759
+ control[i] = edgeMode === "absolute" ? Math.abs(d) : d;
4760
+ }
4761
+ return { t0, dt, n, power, gate, thermo, control, silent: false, eventCount, subSampleEventCount };
4762
+ }
4763
+ function clampIndex(index, n) {
4764
+ if (!Number.isFinite(index)) return 0;
4765
+ if (index < 0) return 0;
4766
+ if (index > n - 1) return n - 1;
4767
+ return index;
4768
+ }
4769
+ function combineRfSources(sources, thermoWeight, controlWeight) {
4770
+ const wThermo = Number.isFinite(thermoWeight) ? thermoWeight : 1;
4771
+ const wControl = Number.isFinite(controlWeight) ? controlWeight : 1;
4772
+ const out = new Float32Array(sources.n);
4773
+ for (let i = 0; i < sources.n; i++) {
4774
+ out[i] = wThermo * sources.thermo[i] + wControl * sources.control[i];
4775
+ }
4776
+ return out;
4777
+ }
4778
+
4660
4779
  // src/pulseq/gradSpectrum.ts
4661
4780
  var GAMMA_HZ_PER_M_PER_MT_PER_M = 42576;
4662
4781
  var GAMMA_HZ_PER_M_PER_T_PER_M = 42576e3;
@@ -4669,7 +4788,15 @@ var Pulseq = (() => {
4669
4788
  overlap: 0.75,
4670
4789
  oversample: 3,
4671
4790
  targetColumns: 256,
4672
- normalize: true
4791
+ normalize: true,
4792
+ // Off by default: the RF terms are research proxies with no calibrated
4793
+ // level, so they are opt-in rather than something a user meets by accident
4794
+ // in a panel whose other channels are in mT/m.
4795
+ includeRf: false,
4796
+ rfScale: 1,
4797
+ rfThermoWeight: 1,
4798
+ rfControlWeight: 1,
4799
+ rfEdgeMode: "signed"
4673
4800
  });
4674
4801
  function resolveSpectrogramParams(params) {
4675
4802
  const merged = { ...DEFAULT_SPECTROGRAM_PARAMS, ...params ?? {} };
@@ -4682,7 +4809,12 @@ var Pulseq = (() => {
4682
4809
  overlap: clamp2(finite(merged.overlap, 0.75), 0, 0.9375),
4683
4810
  oversample: clamp2(Math.round(finite(merged.oversample, 3)), 1, 4),
4684
4811
  targetColumns: clamp2(Math.round(finite(merged.targetColumns, 256)), 64, 512),
4685
- normalize: merged.normalize !== false
4812
+ normalize: merged.normalize !== false,
4813
+ includeRf: merged.includeRf === true,
4814
+ rfScale: clamp2(finite(merged.rfScale, 1), 0, 1e3),
4815
+ rfThermoWeight: clamp2(finite(merged.rfThermoWeight, 1), 0, 1e3),
4816
+ rfControlWeight: clamp2(finite(merged.rfControlWeight, 1), 0, 1e3),
4817
+ rfEdgeMode: merged.rfEdgeMode === "absolute" ? "absolute" : "signed"
4686
4818
  };
4687
4819
  }
4688
4820
  function chooseWindowSamples(params, decimatedDt, viewDurationSec, warnings) {
@@ -4786,6 +4918,29 @@ var Pulseq = (() => {
4786
4918
  const dx = decimatePadded(sx, plan, padSamples, nDecimated);
4787
4919
  const dy = decimatePadded(sy, plan, padSamples, nDecimated);
4788
4920
  const dz = decimatePadded(sz, plan, padSamples, nDecimated);
4921
+ let dRfThermo = null;
4922
+ let dRfControl = null;
4923
+ let dRfCombined = null;
4924
+ if (params.includeRf) {
4925
+ const rfSources = resampleRfAcousticSources(blocks, {
4926
+ startSec: startSec - padSamples * raster,
4927
+ dt: raster,
4928
+ sampleCount: totalSamples,
4929
+ rfScale: params.rfScale,
4930
+ edgeMode: params.rfEdgeMode
4931
+ });
4932
+ if (rfSources.silent) {
4933
+ warnings.push("No RF events in the visible window; the RF proxy channels are empty.");
4934
+ } else if (rfSources.subSampleEventCount > 0) {
4935
+ warnings.push(
4936
+ `${rfSources.subSampleEventCount} RF event(s) are shorter than the ${formatSeconds(raster)} gradient raster and were deposited as area-preserving impulses; their edge timing is raster-quantised.`
4937
+ );
4938
+ }
4939
+ const combined = combineRfSources(rfSources, params.rfThermoWeight, params.rfControlWeight);
4940
+ dRfThermo = decimatePadded(rfSources.thermo, plan, padSamples, nDecimated);
4941
+ dRfControl = decimatePadded(rfSources.control, plan, padSamples, nDecimated);
4942
+ dRfCombined = decimatePadded(combined, plan, padSamples, nDecimated);
4943
+ }
4789
4944
  const binLow = Math.max(0, Math.floor(params.fMinHz / dfBin));
4790
4945
  const binHigh = Math.min(fftPoints / 2, Math.ceil(params.fMaxHz / dfBin));
4791
4946
  const nFreq = Math.max(1, binHigh - binLow + 1);
@@ -4794,6 +4949,9 @@ var Pulseq = (() => {
4794
4949
  const gyOut = new Float32Array(cells);
4795
4950
  const gzOut = new Float32Array(cells);
4796
4951
  const rssOut = new Float32Array(cells);
4952
+ const rfThermoOut = dRfThermo ? new Float32Array(cells) : null;
4953
+ const rfControlOut = dRfControl ? new Float32Array(cells) : null;
4954
+ const rfOut = dRfCombined ? new Float32Array(cells) : null;
4797
4955
  const w = hannWindow(windowSamples);
4798
4956
  const gain = params.normalize ? windowCoherentGain(w) : 1;
4799
4957
  const invGain = gain > 0 ? 1 / gain : 1;
@@ -4806,8 +4964,18 @@ var Pulseq = (() => {
4806
4964
  const magA = new Float64Array(fftPoints / 2 + 1);
4807
4965
  const magB = new Float64Array(fftPoints / 2 + 1);
4808
4966
  const magC = new Float64Array(fftPoints / 2 + 1);
4967
+ const rfActive = !!(dRfThermo && dRfControl && dRfCombined && rfThermoOut && rfControlOut && rfOut);
4968
+ const frameD = rfActive ? new Float64Array(fftPoints) : null;
4969
+ const frameE = rfActive ? new Float64Array(fftPoints) : null;
4970
+ const frameF = rfActive ? new Float64Array(fftPoints) : null;
4971
+ const magD = rfActive ? new Float64Array(fftPoints / 2 + 1) : null;
4972
+ const magE = rfActive ? new Float64Array(fftPoints / 2 + 1) : null;
4973
+ const magF = rfActive ? new Float64Array(fftPoints / 2 + 1) : null;
4974
+ const thermoSilent = rfActive && isAllZero(dRfThermo);
4975
+ const controlSilent = rfActive && isAllZero(dRfControl);
4809
4976
  let minValue = Number.POSITIVE_INFINITY;
4810
4977
  let maxValue = 0;
4978
+ let rfMaxValue = 0;
4811
4979
  for (let col = 0; col < columns; col++) {
4812
4980
  const offset = col * hop;
4813
4981
  prepareFrame(frameA, dx, offset, windowSamples, w, fftPoints);
@@ -4815,6 +4983,23 @@ var Pulseq = (() => {
4815
4983
  prepareFrame(frameC, dz, offset, windowSamples, w, fftPoints);
4816
4984
  realFFTPairMagnitude(frameA, frameB, fftPoints, scratchRe, scratchIm, magA, magB);
4817
4985
  realFFTMagnitude(frameC, fftPoints, scratchRe, scratchIm, magC);
4986
+ if (rfActive) {
4987
+ prepareFrame(frameF, dRfCombined, offset, windowSamples, w, fftPoints);
4988
+ realFFTMagnitude(frameF, fftPoints, scratchRe, scratchIm, magF);
4989
+ if (thermoSilent) magD.fill(0);
4990
+ if (controlSilent) magE.fill(0);
4991
+ if (!thermoSilent && !controlSilent) {
4992
+ prepareFrame(frameD, dRfThermo, offset, windowSamples, w, fftPoints);
4993
+ prepareFrame(frameE, dRfControl, offset, windowSamples, w, fftPoints);
4994
+ realFFTPairMagnitude(frameD, frameE, fftPoints, scratchRe, scratchIm, magD, magE);
4995
+ } else if (!thermoSilent) {
4996
+ prepareFrame(frameD, dRfThermo, offset, windowSamples, w, fftPoints);
4997
+ realFFTMagnitude(frameD, fftPoints, scratchRe, scratchIm, magD);
4998
+ } else if (!controlSilent) {
4999
+ prepareFrame(frameE, dRfControl, offset, windowSamples, w, fftPoints);
5000
+ realFFTMagnitude(frameE, fftPoints, scratchRe, scratchIm, magE);
5001
+ }
5002
+ }
4818
5003
  for (let bin = binLow; bin <= binHigh; bin++) {
4819
5004
  const row = bin - binLow;
4820
5005
  const index = row * columns + col;
@@ -4828,6 +5013,13 @@ var Pulseq = (() => {
4828
5013
  rssOut[index] = vr;
4829
5014
  if (vr > maxValue) maxValue = vr;
4830
5015
  if (vr < minValue) minValue = vr;
5016
+ if (rfActive) {
5017
+ const vRf = magF[bin] * invGain;
5018
+ rfThermoOut[index] = magD[bin] * invGain;
5019
+ rfControlOut[index] = magE[bin] * invGain;
5020
+ rfOut[index] = vRf;
5021
+ if (vRf > rfMaxValue) rfMaxValue = vRf;
5022
+ }
4831
5023
  }
4832
5024
  }
4833
5025
  if (!Number.isFinite(minValue)) minValue = 0;
@@ -4845,9 +5037,17 @@ var Pulseq = (() => {
4845
5037
  dfResolutionHz: decimatedRate / windowSamples,
4846
5038
  unit: params.source === "dGdt" ? "T/m/s" : "mT/m",
4847
5039
  source: params.source,
4848
- data: { gx: gxOut, gy: gyOut, gz: gzOut, rss: rssOut },
5040
+ data: {
5041
+ gx: gxOut,
5042
+ gy: gyOut,
5043
+ gz: gzOut,
5044
+ rss: rssOut,
5045
+ ...rfActive ? { rfThermo: rfThermoOut, rfControl: rfControlOut, rf: rfOut } : {}
5046
+ },
4849
5047
  minValue,
4850
5048
  maxValue,
5049
+ rfIncluded: rfActive,
5050
+ rfMaxValue,
4851
5051
  decimationFactor: plan.factor,
4852
5052
  decimatedRateHz: decimatedRate,
4853
5053
  windowSamples,
@@ -4904,10 +5104,17 @@ var Pulseq = (() => {
4904
5104
  gx: new Float32Array(0),
4905
5105
  gy: new Float32Array(0),
4906
5106
  gz: new Float32Array(0),
4907
- rss: new Float32Array(0)
5107
+ rss: new Float32Array(0),
5108
+ ...params.includeRf ? {
5109
+ rfThermo: new Float32Array(0),
5110
+ rfControl: new Float32Array(0),
5111
+ rf: new Float32Array(0)
5112
+ } : {}
4908
5113
  },
4909
5114
  minValue: 0,
4910
5115
  maxValue: 0,
5116
+ rfIncluded: params.includeRf,
5117
+ rfMaxValue: 0,
4911
5118
  decimationFactor,
4912
5119
  decimatedRateHz: decimatedRate,
4913
5120
  windowSamples,
@@ -5047,6 +5254,15 @@ var Pulseq = (() => {
5047
5254
  if (value >= 10) return `${value.toFixed(0)} Hz`;
5048
5255
  return `${value.toFixed(1)} Hz`;
5049
5256
  }
5257
+ function isAllZero(values) {
5258
+ for (let i = 0; i < values.length; i++) if (values[i] !== 0) return false;
5259
+ return true;
5260
+ }
5261
+ function formatSeconds(value) {
5262
+ if (!Number.isFinite(value)) return "\u2014";
5263
+ if (value >= 1e-3) return `${(value * 1e3).toFixed(2)} ms`;
5264
+ return `${(value * 1e6).toFixed(1)} \xB5s`;
5265
+ }
5050
5266
 
5051
5267
  // src/pulseq/gradientSound.ts
5052
5268
  var DEFAULT_AUDIO_SAMPLE_RATE = 44100;
@@ -5121,17 +5337,78 @@ var Pulseq = (() => {
5121
5337
  if (l > rawPeak) rawPeak = l;
5122
5338
  if (r > rawPeak) rawPeak = r;
5123
5339
  }
5124
- const silent = !(rawPeak > 0);
5340
+ const rfMix = clamp01(Number.isFinite(options.rfMix) ? options.rfMix : 0.5);
5341
+ let rfRawPeak = 0;
5342
+ let rfMono = null;
5343
+ if (options.includeRf && rfMix > 0) {
5344
+ const sources = resampleRfAcousticSources(blocks, {
5345
+ startSec,
5346
+ dt,
5347
+ sampleCount: n,
5348
+ rfScale: options.rfScale,
5349
+ edgeMode: options.rfEdgeMode
5350
+ });
5351
+ if (!sources.silent) {
5352
+ const combined = combineRfSources(
5353
+ sources,
5354
+ Number.isFinite(options.rfThermoWeight) ? options.rfThermoWeight : 1,
5355
+ Number.isFinite(options.rfControlWeight) ? options.rfControlWeight : 1
5356
+ );
5357
+ rfMono = convolveSame(combined, kernel);
5358
+ for (let i = 0; i < n; i++) {
5359
+ const v = Math.abs(rfMono[i]);
5360
+ if (v > rfRawPeak) rfRawPeak = v;
5361
+ }
5362
+ }
5363
+ }
5364
+ const rfIncluded = !!(rfMono && rfRawPeak > 0);
5365
+ if (rfIncluded) {
5366
+ const gradGain = rawPeak > 0 ? (1 - rfMix) / rawPeak : 0;
5367
+ const rfGain = rfMix / rfRawPeak;
5368
+ for (let i = 0; i < n; i++) {
5369
+ const rfSample = rfMono[i] * rfGain;
5370
+ left[i] = left[i] * gradGain + rfSample;
5371
+ right[i] = right[i] * gradGain + rfSample;
5372
+ }
5373
+ }
5374
+ let mixedPeak = 0;
5375
+ for (let i = 0; i < n; i++) {
5376
+ const l = Math.abs(left[i]);
5377
+ const r = Math.abs(right[i]);
5378
+ if (l > mixedPeak) mixedPeak = l;
5379
+ if (r > mixedPeak) mixedPeak = r;
5380
+ }
5381
+ const silent = !(mixedPeak > 0);
5125
5382
  if (silent) {
5126
- warnings.push("No gradient activity in this window \u2014 nothing to play.");
5383
+ warnings.push(options.includeRf ? "Nothing to play in this window \u2014 no gradient activity and no RF events." : "No gradient activity in this window \u2014 nothing to play.");
5127
5384
  } else {
5128
- const scale = AUDIO_PEAK / rawPeak;
5385
+ const scale = AUDIO_PEAK / mixedPeak;
5129
5386
  for (let i = 0; i < n; i++) {
5130
5387
  left[i] *= scale;
5131
5388
  right[i] *= scale;
5132
5389
  }
5390
+ if (options.includeRf && !rfIncluded && rfMix > 0) {
5391
+ warnings.push("No RF events in this window; you are hearing the gradients only.");
5392
+ } else if (rfIncluded && rawPeak === 0) {
5393
+ warnings.push("No gradient activity in this window; you are hearing the RF proxy only.");
5394
+ }
5133
5395
  }
5134
- return { sampleRate, n, left, right, startSec, endSec, rawPeak, silent, warnings };
5396
+ return {
5397
+ sampleRate,
5398
+ n,
5399
+ left,
5400
+ right,
5401
+ startSec,
5402
+ endSec,
5403
+ rawPeak,
5404
+ rfRawPeak,
5405
+ rfIncluded,
5406
+ silent,
5407
+ warnings
5408
+ };
5409
+ }
5410
+ function clamp01(value) {
5411
+ return value < 0 ? 0 : value > 1 ? 1 : value;
5135
5412
  }
5136
5413
 
5137
5414
  // src/pulseq/derivedWindow.ts
@@ -279,18 +279,26 @@ function decodeB64F32(b64,n){
279
279
  function deserializeSpectrogram(payload){
280
280
  if(!payload)return null;
281
281
  var cells=payload.nTime*payload.nFreq;
282
+ var data={
283
+ gx:decodeB64F32(payload.gxB64,cells),
284
+ gy:decodeB64F32(payload.gyB64,cells),
285
+ gz:decodeB64F32(payload.gzB64,cells),
286
+ rss:decodeB64F32(payload.rssB64,cells)
287
+ };
288
+ /* RF blobs travel only when the RF proxy was computed. */
289
+ if(payload.rfB64&&payload.rfThermoB64&&payload.rfControlB64){
290
+ data.rfThermo=decodeB64F32(payload.rfThermoB64,cells);
291
+ data.rfControl=decodeB64F32(payload.rfControlB64,cells);
292
+ data.rf=decodeB64F32(payload.rfB64,cells);
293
+ }
282
294
  return{
283
295
  nTime:payload.nTime,nFreq:payload.nFreq,
284
296
  tStartSec:payload.tStartSec,tStepSec:payload.tStepSec,
285
297
  fStartHz:payload.fStartHz,fStepHz:payload.fStepHz,
286
298
  dtResolutionSec:payload.dtResolutionSec,dfResolutionHz:payload.dfResolutionHz,
287
299
  unit:payload.unit,source:payload.source,
288
- data:{
289
- gx:decodeB64F32(payload.gxB64,cells),
290
- gy:decodeB64F32(payload.gyB64,cells),
291
- gz:decodeB64F32(payload.gzB64,cells),
292
- rss:decodeB64F32(payload.rssB64,cells)
293
- },
300
+ data:data,
301
+ rfIncluded:!!payload.rfIncluded,rfMaxValue:payload.rfMaxValue||0,
294
302
  minValue:payload.minValue,maxValue:payload.maxValue,
295
303
  decimationFactor:payload.decimationFactor,decimatedRateHz:payload.decimatedRateHz,
296
304
  windowSamples:payload.windowSamples,hopSamples:payload.hopSamples,fftPoints:payload.fftPoints,
@@ -308,6 +316,8 @@ function deserializeGradientSound(payload){
308
316
  startSec:payload.startSec,
309
317
  endSec:payload.endSec,
310
318
  silent:!!payload.silent,
319
+ rfIncluded:!!payload.rfIncluded,
320
+ warnings:payload.warnings||[],
311
321
  left:decodeB64F32(payload.leftB64,payload.n),
312
322
  right:decodeB64F32(payload.rightB64,payload.n)
313
323
  };
@@ -1976,10 +1986,18 @@ window.SeqEyesPanelHost={
1976
1986
  },
1977
1987
  requestAudio:function(requestId,startSec,endSec,options){
1978
1988
  if(!vscApi)return;
1979
- vscApi.postMessage({
1980
- command:'synthesizeGradientSound',requestId:requestId,startSec:startSec,endSec:endSec,
1981
- sampleRate:options.sampleRate,channelWeights:options.channelWeights,source:options.source
1982
- });
1989
+ /* The whole options object crosses, the way requestSpectrogram already
1990
+ ships `params`. Enumerating fields here meant every audio option added to
1991
+ panel.js was silently dropped on this lane only: the RF proxy reached the
1992
+ extension as `undefined`, so it synthesised no RF and fell back to its
1993
+ default mix. There is no per-field list left to drift. */
1994
+ var message={};
1995
+ for(var key in options)if(Object.prototype.hasOwnProperty.call(options,key))message[key]=options[key];
1996
+ message.command='synthesizeGradientSound';
1997
+ message.requestId=requestId;
1998
+ message.startSec=startSec;
1999
+ message.endSec=endSec;
2000
+ vscApi.postMessage(message);
1983
2001
  }
1984
2002
  };
1985
2003
 
@@ -3992,6 +4010,33 @@ function sgValueFloor(spec) {
3992
4010
  return m > 0 ? m * 1e-6 : 1e-12;
3993
4011
  }
3994
4012
 
4013
+ var SG_RF_CHANNELS = { rf: 1, rfThermo: 1, rfControl: 1 };
4014
+
4015
+ function sgIsRfChannel(key) { return SG_RF_CHANNELS[key] === 1; }
4016
+
4017
+ /**
4018
+ * dB reference for one channel.
4019
+ *
4020
+ * Gradient channels are absolute — dB re 1 mT/m (or 1 T/m/s) — so their
4021
+ * reference is 1 and the number means the same thing in every view. The RF
4022
+ * proxy has no unit at all, so it is referenced to its own peak in the current
4023
+ * view: 0 dB is "the loudest thing the RF proxy does here". Those two readings
4024
+ * are not comparable, which is the honest situation; pretending otherwise would
4025
+ * need a coil transfer function nobody has measured.
4026
+ */
4027
+ function sgChannelRefDb(spec, key) {
4028
+ if (!sgIsRfChannel(key)) return 0;
4029
+ var peak = spec && spec.rfMaxValue > 0 ? spec.rfMaxValue : 0;
4030
+ return peak > 0 ? 20 * Math.log10(peak) : 0;
4031
+ }
4032
+
4033
+ /** Per-channel magnitude floor, scaled to that channel's own peak. */
4034
+ function sgValueFloorFor(spec, key) {
4035
+ if (!sgIsRfChannel(key)) return sgValueFloor(spec);
4036
+ var peak = spec && spec.rfMaxValue > 0 ? spec.rfMaxValue : 0;
4037
+ return peak > 0 ? peak * 1e-6 : 1e-12;
4038
+ }
4039
+
3995
4040
  function sgFmtDb(v) {
3996
4041
  if (!isFinite(v)) return '--';
3997
4042
  return (v >= 0 ? '+' : '') + v.toFixed(1);
@@ -4053,10 +4098,11 @@ function sgAutoWindowLevel(spec, key, previous) {
4053
4098
  var n = data.length;
4054
4099
  if (!n) return fallback;
4055
4100
 
4056
- var floorValue = sgValueFloor(spec);
4101
+ var floorValue = sgValueFloorFor(spec, key);
4102
+ var refDb = sgChannelRefDb(spec, key);
4057
4103
  var stride = Math.max(1, Math.floor(n / 4096));
4058
4104
  var samples = [];
4059
- for (var i = 0; i < n; i += stride) samples.push(sgToDb(data[i], floorValue));
4105
+ for (var i = 0; i < n; i += stride) samples.push(sgToDb(data[i], floorValue) - refDb);
4060
4106
  if (!samples.length) return fallback;
4061
4107
  samples.sort(function (a, b) { return a - b; });
4062
4108
 
@@ -4149,8 +4195,10 @@ function sgBuildImageData(context) {
4149
4195
  var data = spec.data[context.channel] || spec.data.rss;
4150
4196
  var lut = context.lut;
4151
4197
  var wl = context.windowLevel;
4152
- var floorValue = sgValueFloor(spec);
4153
- var low = wl.level - wl.width / 2;
4198
+ var floorValue = sgValueFloorFor(spec, context.channel);
4199
+ // The channel reference folds into the window's lower edge, so a per-cell
4200
+ // subtraction never enters the inner loop.
4201
+ var low = wl.level - wl.width / 2 + sgChannelRefDb(spec, context.channel);
4154
4202
  var invWidth = wl.width > 0 ? 1 / wl.width : 1;
4155
4203
  var logScale = 20 / Math.LN10;
4156
4204
 
@@ -4538,13 +4586,19 @@ function sgDrawSpectrum(context) {
4538
4586
  ctx.strokeStyle = trace.color;
4539
4587
  ctx.lineWidth = trace.key === 'rss' ? 1.6 : 1.1;
4540
4588
  ctx.globalAlpha = trace.key === 'rss' ? 1 : 0.9;
4589
+ // Dashed for the RF proxies: the same dB axis carries two different
4590
+ // references, and a reader should not have to consult the legend to see
4591
+ // which traces are the uncalibrated ones.
4592
+ ctx.setLineDash(sgIsRfChannel(trace.key) ? [4, 3] : []);
4593
+ var traceFloor = sgValueFloorFor(spec, trace.key);
4594
+ var traceRefDb = sgChannelRefDb(spec, trace.key);
4541
4595
  ctx.beginPath();
4542
4596
  var started = false;
4543
4597
  for (var row = 0; row < values.length; row++) {
4544
4598
  var f = spec.fStartHz + row * spec.fStepHz;
4545
4599
  if (f < range.fMin || f > range.fMax) continue;
4546
4600
  var fp = freqPos(f);
4547
- var mp = magPos(sgToDb(values[row], floorValue));
4601
+ var mp = magPos(sgToDb(values[row], traceFloor) - traceRefDb);
4548
4602
  var xx = rotated ? mp : fp;
4549
4603
  var yy = rotated ? fp : mp;
4550
4604
  if (!started) { ctx.moveTo(xx, yy); started = true; } else ctx.lineTo(xx, yy);
@@ -4552,6 +4606,7 @@ function sgDrawSpectrum(context) {
4552
4606
  ctx.stroke();
4553
4607
  }
4554
4608
  ctx.globalAlpha = 1;
4609
+ ctx.setLineDash([]);
4555
4610
  ctx.restore();
4556
4611
  return rect;
4557
4612
  }
@@ -4562,7 +4617,6 @@ function sgSpectrumScale(spec, slice, windowLevel, freeY, traces) {
4562
4617
  return { min: windowLevel.level - windowLevel.width / 2, max: windowLevel.level + windowLevel.width / 2 };
4563
4618
  }
4564
4619
  if (!spec || !slice) return { min: -80, max: 0 };
4565
- var floorValue = sgValueFloor(spec);
4566
4620
  var max = -Infinity;
4567
4621
  var keys = [];
4568
4622
  for (var t = 0; t < traces.length; t++) if (traces[t].visible) keys.push(traces[t].key);
@@ -4570,8 +4624,10 @@ function sgSpectrumScale(spec, slice, windowLevel, freeY, traces) {
4570
4624
  for (var k = 0; k < keys.length; k++) {
4571
4625
  var values = slice[keys[k]];
4572
4626
  if (!values) continue;
4627
+ var floorValue = sgValueFloorFor(spec, keys[k]);
4628
+ var refDb = sgChannelRefDb(spec, keys[k]);
4573
4629
  for (var i = 0; i < values.length; i++) {
4574
- var db = sgToDb(values[i], floorValue);
4630
+ var db = sgToDb(values[i], floorValue) - refDb;
4575
4631
  if (db > max) max = db;
4576
4632
  }
4577
4633
  }
@@ -4603,8 +4659,8 @@ function sgDetectHotBands(spec, bands, windowLevel, channel) {
4603
4659
  var flags = [];
4604
4660
  if (!spec || !spec.nTime || !bands || !bands.length) return flags;
4605
4661
  var data = spec.data[channel] || spec.data.rss;
4606
- var floorValue = sgValueFloor(spec);
4607
- var threshold = windowLevel.level + windowLevel.width / 2;
4662
+ var floorValue = sgValueFloorFor(spec, channel);
4663
+ var threshold = windowLevel.level + windowLevel.width / 2 + sgChannelRefDb(spec, channel);
4608
4664
  for (var i = 0; i < bands.length; i++) {
4609
4665
  var half = (bands[i].bwHz || 0) / 2;
4610
4666
  var rowLow = Math.max(0, Math.floor((bands[i].freqHz - half - spec.fStartHz) / spec.fStepHz));
@@ -4636,7 +4692,12 @@ function sgSampleCell(spec, channel, timeSec, freqHz) {
4636
4692
  gx: spec.data.gx[index],
4637
4693
  gy: spec.data.gy[index],
4638
4694
  gz: spec.data.gz[index],
4639
- rss: spec.data[channel] ? spec.data[channel][index] : spec.data.rss[index]
4695
+ rss: spec.data[channel] ? spec.data[channel][index] : spec.data.rss[index],
4696
+ /* null, not 0, when RF was not computed: the readout must be able to tell
4697
+ "no RF proxy in this matrix" from "the RF proxy is silent here". */
4698
+ rfThermo: spec.data.rfThermo ? spec.data.rfThermo[index] : null,
4699
+ rfControl: spec.data.rfControl ? spec.data.rfControl[index] : null,
4700
+ rf: spec.data.rf ? spec.data.rf[index] : null
4640
4701
  };
4641
4702
  }
4642
4703
 
@@ -4989,7 +5050,30 @@ var SeqEyesPanel = (function () {
4989
5050
  overlap: getNum('seqeyes.spectrogram.overlap', 0.75),
4990
5051
  oversample: getNum('seqeyes.spectrogram.oversample', 3),
4991
5052
  targetColumns: 256,
4992
- normalize: true
5053
+ normalize: true,
5054
+ /* RF acoustic proxy (rfAcoustic.ts). Off by default and opt-in: these
5055
+ channels are uncalibrated research proxies with no unit, unlike every
5056
+ other channel in this panel. The two mechanism weights stay at the
5057
+ upstream 1:1 default — that ratio is a placeholder until it is fitted to
5058
+ measurements, so exposing it as a dial would suggest a meaning it has
5059
+ not earned. */
5060
+ includeRf: getBool('seqeyes.spectrogram.rf', false),
5061
+ rfScale: getNum('seqeyes.spectrogram.rfscale', 1),
5062
+ rfThermoWeight: 1,
5063
+ rfControlWeight: 1,
5064
+ rfEdgeMode: 'signed',
5065
+ /* Playback only: crossfade between the gradient sound and the RF proxy,
5066
+ each normalised to its own peak first. There is no correct default —
5067
+ the two have no common unit — so an even blend is the honest one. */
5068
+ rfMix: getNum('seqeyes.spectrogram.rfmix', 0.5)
5069
+ };
5070
+ /* Which matrix the image layer paints. The spectrum pane still draws every
5071
+ visible trace; this only picks the colourmapped one. */
5072
+ var imageChannel = get('seqeyes.spectrogram.imagechannel') || 'rss';
5073
+ var SG_RF_SLICE_KEYS = ['rfThermo', 'rfControl', 'rf'];
5074
+ var SG_IMAGE_CHANNEL_LABELS = {
5075
+ rss: 'Σ', gx: 'Gx', gy: 'Gy', gz: 'Gz',
5076
+ rf: 'RF proxy', rfThermo: 'RF thermo', rfControl: 'RF ctrl'
4993
5077
  };
4994
5078
  var colormapName = get('seqeyes.spectrogram.colormap') || 'viridis';
4995
5079
  if (SG_COLORMAP_NAMES.indexOf(colormapName) < 0) colormapName = 'viridis';
@@ -4998,7 +5082,10 @@ var SeqEyesPanel = (function () {
4998
5082
  { key: 'gx', label: 'Gx', varName: '--gx', visible: getBool('seqeyes.spectrogram.trace.gx', true) },
4999
5083
  { key: 'gy', label: 'Gy', varName: '--gy', visible: getBool('seqeyes.spectrogram.trace.gy', true) },
5000
5084
  { key: 'gz', label: 'Gz', varName: '--gz', visible: getBool('seqeyes.spectrogram.trace.gz', true) },
5001
- { key: 'rss', label: 'Σ', varName: '--fg', visible: getBool('seqeyes.spectrogram.trace.rss', true) }
5085
+ { key: 'rss', label: 'Σ', varName: '--fg', visible: getBool('seqeyes.spectrogram.trace.rss', true) },
5086
+ /* Drawn only while the matrix carries them, i.e. while RF is enabled. */
5087
+ { key: 'rfThermo', label: 'RF thermo', varName: '--rf', rf: true, visible: getBool('seqeyes.spectrogram.trace.rfThermo', true) },
5088
+ { key: 'rfControl', label: 'RF ctrl', varName: '--tr', rf: true, visible: getBool('seqeyes.spectrogram.trace.rfControl', true) }
5002
5089
  ];
5003
5090
  var freeY = getBool('seqeyes.spectrogram.freeY', false);
5004
5091
  var bandsVisible = getBool('seqeyes.spectrogram.bands', true);
@@ -5031,6 +5118,11 @@ var SeqEyesPanel = (function () {
5031
5118
  var audioActivationId = 0;
5032
5119
  var audioActivationPending = false;
5033
5120
  var audioWindow = null; // buffered range plus the first-pass offset
5121
+ /* What the host actually put in the last audio buffer. The panel asks for
5122
+ RF, but only the reply says whether any arrived, and the two lanes reach
5123
+ the synthesiser by different routes — so this is reported rather than
5124
+ assumed from `params.includeRf`. */
5125
+ var audioRfIncluded = null;
5034
5126
  var playheadFrame = 0;
5035
5127
 
5036
5128
  var AUDIO_SAMPLE_RATE = 44100;
@@ -5237,7 +5329,9 @@ var SeqEyesPanel = (function () {
5237
5329
 
5238
5330
  function paramsSignature() {
5239
5331
  return [params.source, params.fMaxHz, params.windowSamples, params.overlap,
5240
- params.oversample, params.targetColumns, params.normalize ? 1 : 0].join('|');
5332
+ params.oversample, params.targetColumns, params.normalize ? 1 : 0,
5333
+ params.includeRf ? 1 : 0, params.rfScale, params.rfThermoWeight,
5334
+ params.rfControlWeight, params.rfEdgeMode].join('|');
5241
5335
  }
5242
5336
 
5243
5337
  function cacheKey(startSec, endSec) {
@@ -5299,6 +5393,11 @@ var SeqEyesPanel = (function () {
5299
5393
  oversample: params.oversample,
5300
5394
  targetColumns: params.targetColumns,
5301
5395
  normalize: params.normalize,
5396
+ includeRf: params.includeRf,
5397
+ rfScale: params.rfScale,
5398
+ rfThermoWeight: params.rfThermoWeight,
5399
+ rfControlWeight: params.rfControlWeight,
5400
+ rfEdgeMode: params.rfEdgeMode,
5302
5401
  trTimeSec: h.getTrTimeSec ? h.getTrTimeSec() : 0
5303
5402
  });
5304
5403
  }
@@ -5335,13 +5434,29 @@ var SeqEyesPanel = (function () {
5335
5434
  render();
5336
5435
  }
5337
5436
 
5437
+ /**
5438
+ * Image channel that is actually present in the current matrix.
5439
+ *
5440
+ * A remembered `rf` preference outlives the RF toggle and survives a reload
5441
+ * into a matrix that has no RF channels, so the fallback is not defensive
5442
+ * padding — it is the normal path the first time the panel opens with RF off.
5443
+ */
5444
+ function activeImageChannel() {
5445
+ if (currentSpec && currentSpec.data && currentSpec.data[imageChannel]) return imageChannel;
5446
+ return 'rss';
5447
+ }
5448
+
5338
5449
  function applySpectrogram(spec) {
5339
5450
  currentSpec = spec;
5340
5451
  currentView = { startSec: spec.requestedStartSec, endSec: spec.requestedEndSec };
5341
- if (windowLevelAuto) windowLevel = sgAutoWindowLevel(spec, 'rss', windowLevel);
5452
+ if (windowLevelAuto) windowLevel = sgAutoWindowLevel(spec, activeImageChannel(), windowLevel);
5342
5453
  clampFrequencyRange();
5343
5454
  imageDirty = true;
5344
- hotBands = sgDetectHotBands(spec, acousticBands, windowLevel, 'rss');
5455
+ hotBands = sgDetectHotBands(spec, acousticBands, windowLevel, activeImageChannel());
5456
+ // The RF chips and the image-channel options both depend on whether this
5457
+ // matrix carries RF, which is only known once it arrives.
5458
+ buildLegend();
5459
+ syncControls();
5345
5460
  publishNotices();
5346
5461
  render();
5347
5462
  if (requestStartedAt) {
@@ -5384,9 +5499,18 @@ var SeqEyesPanel = (function () {
5384
5499
  var anyHot = false;
5385
5500
  for (var k = 0; k < hotBands.length; k++) if (hotBands[k]) anyHot = true;
5386
5501
  if (anyHot) {
5387
- messages.push('Gradient energy falls inside a forbidden acoustic band (advisory: '
5502
+ // The detection runs on whichever channel the image shows, so the wording
5503
+ // has to name that channel rather than always claiming "gradient".
5504
+ messages.push((sgIsRfChannel(activeImageChannel()) ? 'RF proxy energy' : 'Gradient energy')
5505
+ + ' falls inside a forbidden acoustic band (advisory: '
5388
5506
  + 'this compares against the current display window, and is not a compliance check).');
5389
5507
  }
5508
+ if (currentSpec && currentSpec.rfIncluded) {
5509
+ messages.push('RF acoustic channels are a reduced-order research proxy '
5510
+ + '(thermoacoustic d|B1|²/dt plus a hypothesised TX-switching term), with no calibrated '
5511
+ + 'level and no scanner resonance model. They are shown on their own relative scale and '
5512
+ + 'must not be compared with, or added to, the gradient channels.');
5513
+ }
5390
5514
  notice('spectrogram', messages.length ? messages : null);
5391
5515
  }
5392
5516
 
@@ -5417,7 +5541,7 @@ var SeqEyesPanel = (function () {
5417
5541
  if (imageDirty || !imageCache || imageCache.width !== pixelW || imageCache.height !== pixelH) {
5418
5542
  imageCache = sgBuildImageData({
5419
5543
  spectrogram: currentSpec,
5420
- channel: 'rss',
5544
+ channel: activeImageChannel(),
5421
5545
  lut: lut,
5422
5546
  windowLevel: windowLevel,
5423
5547
  view: currentView,
@@ -5512,9 +5636,22 @@ var SeqEyesPanel = (function () {
5512
5636
  out.gz[row] = spec.data.gz[index];
5513
5637
  out.rss[row] = spec.data.rss[index];
5514
5638
  }
5639
+ addRfSliceColumns(out, spec, col);
5515
5640
  return out;
5516
5641
  }
5517
5642
 
5643
+ /** Copy the RF rows of one column into a slice, when the matrix has them. */
5644
+ function addRfSliceColumns(out, spec, col) {
5645
+ for (var i = 0; i < SG_RF_SLICE_KEYS.length; i++) {
5646
+ var key = SG_RF_SLICE_KEYS[i];
5647
+ var source = spec.data[key];
5648
+ if (!source) continue;
5649
+ var target = new Float32Array(spec.nFreq);
5650
+ for (var row = 0; row < spec.nFreq; row++) target[row] = source[row * spec.nTime + col];
5651
+ out[key] = target;
5652
+ }
5653
+ }
5654
+
5518
5655
  var averageCache = null;
5519
5656
  function sliceAverage() {
5520
5657
  var spec = currentSpec;
@@ -5538,6 +5675,21 @@ var SeqEyesPanel = (function () {
5538
5675
  out.gx[row] = rx; out.gy[row] = ry; out.gz[row] = rz;
5539
5676
  out.rss[row] = Math.sqrt(rx * rx + ry * ry + rz * rz);
5540
5677
  }
5678
+ // The RF channels average on their own. `rss` is rebuilt from the per-axis
5679
+ // means above because it is a combination of three signals; `rf` is already
5680
+ // the transform of one combined signal, so it is simply averaged.
5681
+ for (var i = 0; i < SG_RF_SLICE_KEYS.length; i++) {
5682
+ var key = SG_RF_SLICE_KEYS[i];
5683
+ var source = spec.data[key];
5684
+ if (!source) continue;
5685
+ var target = new Float32Array(spec.nFreq);
5686
+ for (var r = 0; r < spec.nFreq; r++) {
5687
+ var b = r * spec.nTime, acc = 0;
5688
+ for (var c = 0; c < spec.nTime; c++) { var v = source[b + c]; acc += v * v; }
5689
+ target[r] = Math.sqrt(acc / spec.nTime);
5690
+ }
5691
+ out[key] = target;
5692
+ }
5541
5693
  averageCache = { spec: spec, slice: out };
5542
5694
  return out;
5543
5695
  }
@@ -5553,7 +5705,9 @@ var SeqEyesPanel = (function () {
5553
5705
  for (var i = 0; i < traces.length; i++) {
5554
5706
  resolved.push({
5555
5707
  key: traces[i].key,
5556
- visible: traces[i].visible,
5708
+ // An RF trace the matrix does not carry is not drawn, whatever the
5709
+ // remembered legend state says.
5710
+ visible: traces[i].visible && (!traces[i].rf || !!(slice && slice[traces[i].key])),
5557
5711
  color: (style.getPropertyValue(traces[i].varName) || '#888').trim()
5558
5712
  });
5559
5713
  }
@@ -5582,13 +5736,25 @@ var SeqEyesPanel = (function () {
5582
5736
  if (currentSpec && currentSpec.nTime) {
5583
5737
  parts.push('dt ' + sgFmtResolution(currentSpec.dtResolutionSec));
5584
5738
  parts.push('df ' + sgFmtHz(currentSpec.dfResolutionHz) + ' Hz');
5585
- parts.push('unit ' + currentSpec.unit);
5739
+ var channel = activeImageChannel();
5740
+ parts.push('img ' + (SG_IMAGE_CHANNEL_LABELS[channel] || channel));
5741
+ // The unit belongs to the channel on screen, so an RF image must not be
5742
+ // labelled mT/m.
5743
+ parts.push('unit ' + (sgIsRfChannel(channel) ? 'relative (0 dB = RF peak in view)' : currentSpec.unit));
5586
5744
  parts.push('D ' + currentSpec.decimationFactor + '×');
5587
5745
  parts.push(currentSpec.nTime + '×' + currentSpec.nFreq);
5588
5746
  parts.push('W ' + windowLevel.width.toFixed(1) + ' dB L ' + sgFmtDb(windowLevel.level) + ' dB');
5589
5747
  } else if (busy) {
5590
5748
  parts.push('Calculating…');
5591
5749
  }
5750
+ if (params.includeRf && audioRfIncluded !== null) {
5751
+ // Says whether the buffer being played actually contains RF, which is not
5752
+ // the same question as whether the RF channels were requested — and at
5753
+ // what share, since a low mix is the other way RF goes unnoticed.
5754
+ parts.push(audioRfIncluded
5755
+ ? 'audio RF ✓ ' + Math.round(params.rfMix * 100) + '%'
5756
+ : 'audio RF ✗ (none reached the buffer)');
5757
+ }
5592
5758
  if (isFinite(playheadTimeSec)) parts.push('▶ ' + sgFmtTime(playheadTimeSec, timeUnit()));
5593
5759
  else if (isFinite(markerTimeSec)) parts.push('marker ' + sgFmtTime(markerTimeSec, timeUnit()));
5594
5760
  else if (currentSpec && currentSpec.nTime) {
@@ -5596,7 +5762,11 @@ var SeqEyesPanel = (function () {
5596
5762
  }
5597
5763
  if (hoverReadout) parts.push(hoverReadout);
5598
5764
  out.textContent = parts.join(' ');
5599
- out.title = 'Simulated gradient spectral content — not calibrated sound pressure level.';
5765
+ out.title = currentSpec && currentSpec.rfIncluded
5766
+ ? 'Simulated gradient spectral content — not calibrated sound pressure level. '
5767
+ + 'The RF channels are an uncalibrated research proxy on a separate relative scale; '
5768
+ + 'they cannot be compared with the gradient channels or summed with them.'
5769
+ : 'Simulated gradient spectral content — not calibrated sound pressure level.';
5600
5770
  }
5601
5771
 
5602
5772
  var hoverReadout = '';
@@ -5631,15 +5801,15 @@ var SeqEyesPanel = (function () {
5631
5801
  };
5632
5802
  windowLevelAuto = false;
5633
5803
  imageDirty = true;
5634
- if (currentSpec) hotBands = sgDetectHotBands(currentSpec, acousticBands, windowLevel, 'rss');
5804
+ if (currentSpec) hotBands = sgDetectHotBands(currentSpec, acousticBands, windowLevel, activeImageChannel());
5635
5805
  render();
5636
5806
  }
5637
5807
 
5638
5808
  function resetWindowLevel() {
5639
5809
  windowLevelAuto = true;
5640
- if (currentSpec) windowLevel = sgAutoWindowLevel(currentSpec, 'rss', windowLevel);
5810
+ if (currentSpec) windowLevel = sgAutoWindowLevel(currentSpec, activeImageChannel(), windowLevel);
5641
5811
  imageDirty = true;
5642
- if (currentSpec) hotBands = sgDetectHotBands(currentSpec, acousticBands, windowLevel, 'rss');
5812
+ if (currentSpec) hotBands = sgDetectHotBands(currentSpec, acousticBands, windowLevel, activeImageChannel());
5643
5813
  render();
5644
5814
  }
5645
5815
 
@@ -5724,7 +5894,13 @@ var SeqEyesPanel = (function () {
5724
5894
  h.requestAudio(pendingAudioId, range.startSec, range.endSec, {
5725
5895
  sampleRate: 44100,
5726
5896
  source: params.source,
5727
- channelWeights: [1, 1, 1]
5897
+ channelWeights: [1, 1, 1],
5898
+ includeRf: params.includeRf,
5899
+ rfScale: params.rfScale,
5900
+ rfThermoWeight: params.rfThermoWeight,
5901
+ rfControlWeight: params.rfControlWeight,
5902
+ rfEdgeMode: params.rfEdgeMode,
5903
+ rfMix: params.rfMix
5728
5904
  });
5729
5905
  syncTransport();
5730
5906
  }
@@ -5777,16 +5953,28 @@ var SeqEyesPanel = (function () {
5777
5953
  function deliverAudio(id, payload) {
5778
5954
  if (id !== pendingAudioId) return;
5779
5955
  pendingAudioId = 0;
5956
+ // The host knows which sources were enabled, so it words the refusal; the
5957
+ // panel used to say "no gradient activity" even when RF was the source the
5958
+ // user was listening for.
5959
+ var emptyMessage = params.includeRf
5960
+ ? 'Nothing to play in this window — no gradient activity and no RF events.'
5961
+ : 'No gradient activity in this window — nothing to play.';
5780
5962
  if (!payload || !payload.left || !payload.left.length) {
5781
- notice('gradientSound', 'No gradient activity in this window — nothing to play.');
5963
+ notice('gradientSound', (payload && payload.warnings && payload.warnings.length)
5964
+ ? payload.warnings : emptyMessage);
5782
5965
  syncTransport();
5783
5966
  return;
5784
5967
  }
5785
5968
  if (payload.silent) {
5786
- notice('gradientSound', 'No gradient activity in this window — nothing to play.');
5969
+ notice('gradientSound', (payload.warnings && payload.warnings.length)
5970
+ ? payload.warnings : emptyMessage);
5787
5971
  syncTransport();
5788
5972
  return;
5789
5973
  }
5974
+ // Partial-source notes ("hearing the RF proxy only") are advisory, not a
5975
+ // refusal, so they are published without stopping playback.
5976
+ notice('gradientSound', (payload.warnings && payload.warnings.length) ? payload.warnings : null);
5977
+ audioRfIncluded = !!payload.rfIncluded;
5790
5978
  if (!SeqEyesAudio.load(payload.sampleRate, payload.left, payload.right, payload.startSec)) {
5791
5979
  if (SeqEyesAudio.contextState() !== 'running') audioActivationFailure();
5792
5980
  else notice('gradientSound', 'This host could not create an audio buffer.');
@@ -5846,6 +6034,7 @@ var SeqEyesPanel = (function () {
5846
6034
  playheadTimeSec = NaN;
5847
6035
  pendingAudioId = 0;
5848
6036
  audioWindow = null;
6037
+ audioRfIncluded = null;
5849
6038
  syncTransport();
5850
6039
  render();
5851
6040
  }
@@ -5901,7 +6090,7 @@ var SeqEyesPanel = (function () {
5901
6090
 
5902
6091
  function setAcousticBands(bands) {
5903
6092
  acousticBands = Array.isArray(bands) ? bands.slice() : [];
5904
- hotBands = currentSpec ? sgDetectHotBands(currentSpec, acousticBands, windowLevel, 'rss') : [];
6093
+ hotBands = currentSpec ? sgDetectHotBands(currentSpec, acousticBands, windowLevel, activeImageChannel()) : [];
5905
6094
  buildLegend();
5906
6095
  publishNotices();
5907
6096
  render();
@@ -5916,10 +6105,15 @@ var SeqEyesPanel = (function () {
5916
6105
  var style = getComputedStyle(document.body);
5917
6106
 
5918
6107
  for (var i = 0; i < traces.length; i++) {
6108
+ // RF chips appear only once the matrix carries RF, so the legend never
6109
+ // offers a toggle that would do nothing.
6110
+ if (traces[i].rf && !(currentSpec && currentSpec.data && currentSpec.data[traces[i].key])) continue;
5919
6111
  (function (trace) {
5920
6112
  var chip = document.createElement('div');
5921
6113
  chip.className = 'li' + (trace.visible ? '' : ' off');
5922
- chip.title = 'Toggle the ' + trace.label + ' spectrum trace';
6114
+ chip.title = trace.rf
6115
+ ? 'Toggle the ' + trace.label + ' trace (uncalibrated proxy, dB relative to its own peak in this view)'
6116
+ : 'Toggle the ' + trace.label + ' spectrum trace';
5923
6117
  var swatch = document.createElement('div');
5924
6118
  swatch.className = 'ld';
5925
6119
  swatch.style.background = (style.getPropertyValue(trace.varName) || '#888').trim();
@@ -5990,6 +6184,32 @@ var SeqEyesPanel = (function () {
5990
6184
  setValue('sgWin', String(params.windowSamples));
5991
6185
  setValue('sgOverlap', String(params.overlap));
5992
6186
  setValue('sgOversample', String(params.oversample));
6187
+
6188
+ var rfToggle = el('sgRf');
6189
+ if (rfToggle) {
6190
+ rfToggle.setAttribute('aria-pressed', params.includeRf ? 'true' : 'false');
6191
+ rfToggle.classList.toggle('on', params.includeRf);
6192
+ }
6193
+ var rfScale = el('sgRfScale');
6194
+ if (rfScale) {
6195
+ setValue('sgRfScale', String(params.rfScale));
6196
+ rfScale.disabled = !params.includeRf;
6197
+ }
6198
+ var rfMix = el('sgRfMix');
6199
+ if (rfMix) {
6200
+ setValue('sgRfMix', String(Math.round(params.rfMix * 100)));
6201
+ rfMix.disabled = !params.includeRf;
6202
+ }
6203
+ var image = el('sgImageChannel');
6204
+ if (image) {
6205
+ // RF options are removed rather than disabled: a disabled option that the
6206
+ // select is currently showing is a state the user cannot get out of.
6207
+ for (var o = image.options.length - 1; o >= 0; o--) {
6208
+ if (sgIsRfChannel(image.options[o].value)) image.options[o].hidden = !params.includeRf;
6209
+ }
6210
+ image.value = activeImageChannel();
6211
+ }
6212
+
5993
6213
  var clear = el('sgMarkerClear');
5994
6214
  if (clear) clear.disabled = !isFinite(markerTimeSec);
5995
6215
  }
@@ -6103,6 +6323,56 @@ var SeqEyesPanel = (function () {
6103
6323
  onParamChanged(true);
6104
6324
  };
6105
6325
 
6326
+ var image = el('sgImageChannel');
6327
+ if (image) image.onchange = function () {
6328
+ imageChannel = this.value;
6329
+ set('seqeyes.spectrogram.imagechannel', imageChannel);
6330
+ // Channels live on different scales, so a channel change invalidates the
6331
+ // contrast as thoroughly as new data would.
6332
+ windowLevelAuto = true;
6333
+ if (currentSpec) windowLevel = sgAutoWindowLevel(currentSpec, activeImageChannel(), windowLevel);
6334
+ if (currentSpec) hotBands = sgDetectHotBands(currentSpec, acousticBands, windowLevel, activeImageChannel());
6335
+ imageDirty = true;
6336
+ publishNotices();
6337
+ render();
6338
+ };
6339
+
6340
+ // Each RF control changes what is being synthesised, so a buffer already
6341
+ // playing is stale the moment one moves.
6342
+ var rfToggle = el('sgRf');
6343
+ if (rfToggle) rfToggle.onclick = function () {
6344
+ stopPlayback();
6345
+ params.includeRf = !params.includeRf;
6346
+ set('seqeyes.spectrogram.rf', params.includeRf ? '1' : '0');
6347
+ if (!params.includeRf && sgIsRfChannel(imageChannel)) {
6348
+ imageChannel = 'rss';
6349
+ set('seqeyes.spectrogram.imagechannel', imageChannel);
6350
+ }
6351
+ onParamChanged(true);
6352
+ buildLegend();
6353
+ };
6354
+
6355
+ var rfScale = el('sgRfScale');
6356
+ if (rfScale) rfScale.onchange = function () {
6357
+ stopPlayback();
6358
+ var value = parseFloat(this.value);
6359
+ params.rfScale = isFinite(value) && value >= 0 ? value : 1;
6360
+ set('seqeyes.spectrogram.rfscale', String(params.rfScale));
6361
+ onParamChanged(true);
6362
+ };
6363
+
6364
+ var rfMix = el('sgRfMix');
6365
+ if (rfMix) rfMix.oninput = function () {
6366
+ stopPlayback();
6367
+ var value = parseFloat(this.value);
6368
+ params.rfMix = isFinite(value) ? Math.max(0, Math.min(1, value / 100)) : 0.5;
6369
+ set('seqeyes.spectrogram.rfmix', String(params.rfMix));
6370
+ // Playback only: the spectrogram keeps the mechanisms on separate
6371
+ // channels, so this must not trigger a recompute.
6372
+ syncControls();
6373
+ syncTransport();
6374
+ };
6375
+
6106
6376
  var win = el('sgWin');
6107
6377
  if (win) win.onchange = function () { params.windowSamples = parseFloat(this.value) || 0; onParamChanged(true); };
6108
6378
  var overlap = el('sgOverlap');
@@ -6351,6 +6621,15 @@ var SeqEyesPanel = (function () {
6351
6621
  + ' Gy ' + sgFmtDb(sgToDb(cell.gy, floorValue))
6352
6622
  + ' Gz ' + sgFmtDb(sgToDb(cell.gz, floorValue))
6353
6623
  + ' Σ ' + sgFmtDb(sgToDb(cell.rss, floorValue)) + ' dB';
6624
+ if (cell.rf !== null) {
6625
+ // Reported against the RF peak, matching the axis the RF traces are drawn
6626
+ // on, and flagged `rel` so it is never read as dB re 1 mT/m.
6627
+ var rfFloor = sgValueFloorFor(currentSpec, 'rf');
6628
+ var rfRef = sgChannelRefDb(currentSpec, 'rf');
6629
+ text += ' RF ' + sgFmtDb(sgToDb(cell.rf, rfFloor) - rfRef)
6630
+ + ' (th ' + sgFmtDb(sgToDb(cell.rfThermo, rfFloor) - rfRef)
6631
+ + ', ct ' + sgFmtDb(sgToDb(cell.rfControl, rfFloor) - rfRef) + ') dB rel';
6632
+ }
6354
6633
  var bandIndex = sgBandAtFrequency(acousticBands, cell.freqHz);
6355
6634
  if (bandIndex >= 0) {
6356
6635
  var band = acousticBands[bandIndex];
@@ -6591,6 +6870,13 @@ var SeqEyesPanel = (function () {
6591
6870
  dtResolutionSec: currentSpec ? currentSpec.dtResolutionSec : null,
6592
6871
  dfResolutionHz: currentSpec ? currentSpec.dfResolutionHz : null,
6593
6872
  decimationFactor: currentSpec ? currentSpec.decimationFactor : null,
6873
+ includeRf: params.includeRf,
6874
+ rfScale: params.rfScale,
6875
+ rfMix: params.rfMix,
6876
+ audioRfIncluded: audioRfIncluded,
6877
+ imageChannel: activeImageChannel(),
6878
+ rfIncluded: !!(currentSpec && currentSpec.rfIncluded),
6879
+ rfMaxValue: currentSpec ? (currentSpec.rfMaxValue || 0) : 0,
6594
6880
  warnings: currentSpec ? currentSpec.warnings.slice() : [],
6595
6881
  error: lastError
6596
6882
  };