seqeyes-python 0.3.10__tar.gz → 0.3.12__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.12}/PKG-INFO +1 -1
  2. {seqeyes_python-0.3.10 → seqeyes_python-0.3.12}/pyproject.toml +1 -1
  3. {seqeyes_python-0.3.10 → seqeyes_python-0.3.12}/src/seqeyes/__init__.py +1 -1
  4. {seqeyes_python-0.3.10 → seqeyes_python-0.3.12}/src/seqeyes/resources/pulseq-bundle.js +287 -10
  5. {seqeyes_python-0.3.10 → seqeyes_python-0.3.12}/src/seqeyes/resources/webview-bundle.js +329 -39
  6. {seqeyes_python-0.3.10 → seqeyes_python-0.3.12}/.gitignore +0 -0
  7. {seqeyes_python-0.3.10 → seqeyes_python-0.3.12}/README.md +0 -0
  8. {seqeyes_python-0.3.10 → seqeyes_python-0.3.12}/examples/demo.ipynb +0 -0
  9. {seqeyes_python-0.3.10 → seqeyes_python-0.3.12}/examples/demo.py +0 -0
  10. {seqeyes_python-0.3.10 → seqeyes_python-0.3.12}/src/seqeyes/_plot.py +0 -0
  11. {seqeyes_python-0.3.10 → seqeyes_python-0.3.12}/src/seqeyes/_renderer.py +0 -0
  12. {seqeyes_python-0.3.10 → seqeyes_python-0.3.12}/src/seqeyes/resources/seqeyes-labels.js +0 -0
  13. {seqeyes_python-0.3.10 → seqeyes_python-0.3.12}/src/seqeyes/resources/viewer.html +0 -0
  14. {seqeyes_python-0.3.10 → seqeyes_python-0.3.12}/tests/conftest.py +0 -0
  15. {seqeyes_python-0.3.10 → seqeyes_python-0.3.12}/tests/debug_full.html +0 -0
  16. {seqeyes_python-0.3.10 → seqeyes_python-0.3.12}/tests/debug_minimal.html +0 -0
  17. {seqeyes_python-0.3.10 → seqeyes_python-0.3.12}/tests/debug_viewer.html +0 -0
  18. {seqeyes_python-0.3.10 → seqeyes_python-0.3.12}/tests/debug_viewer.py +0 -0
  19. {seqeyes_python-0.3.10 → seqeyes_python-0.3.12}/tests/integration_test.py +0 -0
  20. {seqeyes_python-0.3.10 → seqeyes_python-0.3.12}/tests/test_plot.py +0 -0
  21. {seqeyes_python-0.3.10 → seqeyes_python-0.3.12}/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.12
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.12"
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.12"
@@ -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.12";
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
  };
@@ -1794,7 +1804,11 @@ function drawMinimap(){
1794
1804
 
1795
1805
  // ── Blit cached block bands ──
1796
1806
  if(mmCache){
1797
- mmCtx.drawImage(mmCache,0,0);
1807
+ // The cache is built at device resolution while this context carries a
1808
+ // scale(dpr) transform, so it must be given the CSS-pixel destination
1809
+ // size. Without it the cache draws dpr times too wide and only the
1810
+ // leading 1/dpr of the sequence stays on the strip.
1811
+ mmCtx.drawImage(mmCache,0,0,W,H);
1798
1812
  }else{
1799
1813
  mmCtx.fillStyle=s.getPropertyValue('--trbg').trim();mmCtx.fillRect(0,0,W,H);
1800
1814
  }
@@ -1976,10 +1990,18 @@ window.SeqEyesPanelHost={
1976
1990
  },
1977
1991
  requestAudio:function(requestId,startSec,endSec,options){
1978
1992
  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
- });
1993
+ /* The whole options object crosses, the way requestSpectrogram already
1994
+ ships `params`. Enumerating fields here meant every audio option added to
1995
+ panel.js was silently dropped on this lane only: the RF proxy reached the
1996
+ extension as `undefined`, so it synthesised no RF and fell back to its
1997
+ default mix. There is no per-field list left to drift. */
1998
+ var message={};
1999
+ for(var key in options)if(Object.prototype.hasOwnProperty.call(options,key))message[key]=options[key];
2000
+ message.command='synthesizeGradientSound';
2001
+ message.requestId=requestId;
2002
+ message.startSec=startSec;
2003
+ message.endSec=endSec;
2004
+ vscApi.postMessage(message);
1983
2005
  }
1984
2006
  };
1985
2007
 
@@ -3992,6 +4014,33 @@ function sgValueFloor(spec) {
3992
4014
  return m > 0 ? m * 1e-6 : 1e-12;
3993
4015
  }
3994
4016
 
4017
+ var SG_RF_CHANNELS = { rf: 1, rfThermo: 1, rfControl: 1 };
4018
+
4019
+ function sgIsRfChannel(key) { return SG_RF_CHANNELS[key] === 1; }
4020
+
4021
+ /**
4022
+ * dB reference for one channel.
4023
+ *
4024
+ * Gradient channels are absolute — dB re 1 mT/m (or 1 T/m/s) — so their
4025
+ * reference is 1 and the number means the same thing in every view. The RF
4026
+ * proxy has no unit at all, so it is referenced to its own peak in the current
4027
+ * view: 0 dB is "the loudest thing the RF proxy does here". Those two readings
4028
+ * are not comparable, which is the honest situation; pretending otherwise would
4029
+ * need a coil transfer function nobody has measured.
4030
+ */
4031
+ function sgChannelRefDb(spec, key) {
4032
+ if (!sgIsRfChannel(key)) return 0;
4033
+ var peak = spec && spec.rfMaxValue > 0 ? spec.rfMaxValue : 0;
4034
+ return peak > 0 ? 20 * Math.log10(peak) : 0;
4035
+ }
4036
+
4037
+ /** Per-channel magnitude floor, scaled to that channel's own peak. */
4038
+ function sgValueFloorFor(spec, key) {
4039
+ if (!sgIsRfChannel(key)) return sgValueFloor(spec);
4040
+ var peak = spec && spec.rfMaxValue > 0 ? spec.rfMaxValue : 0;
4041
+ return peak > 0 ? peak * 1e-6 : 1e-12;
4042
+ }
4043
+
3995
4044
  function sgFmtDb(v) {
3996
4045
  if (!isFinite(v)) return '--';
3997
4046
  return (v >= 0 ? '+' : '') + v.toFixed(1);
@@ -4053,10 +4102,11 @@ function sgAutoWindowLevel(spec, key, previous) {
4053
4102
  var n = data.length;
4054
4103
  if (!n) return fallback;
4055
4104
 
4056
- var floorValue = sgValueFloor(spec);
4105
+ var floorValue = sgValueFloorFor(spec, key);
4106
+ var refDb = sgChannelRefDb(spec, key);
4057
4107
  var stride = Math.max(1, Math.floor(n / 4096));
4058
4108
  var samples = [];
4059
- for (var i = 0; i < n; i += stride) samples.push(sgToDb(data[i], floorValue));
4109
+ for (var i = 0; i < n; i += stride) samples.push(sgToDb(data[i], floorValue) - refDb);
4060
4110
  if (!samples.length) return fallback;
4061
4111
  samples.sort(function (a, b) { return a - b; });
4062
4112
 
@@ -4149,8 +4199,10 @@ function sgBuildImageData(context) {
4149
4199
  var data = spec.data[context.channel] || spec.data.rss;
4150
4200
  var lut = context.lut;
4151
4201
  var wl = context.windowLevel;
4152
- var floorValue = sgValueFloor(spec);
4153
- var low = wl.level - wl.width / 2;
4202
+ var floorValue = sgValueFloorFor(spec, context.channel);
4203
+ // The channel reference folds into the window's lower edge, so a per-cell
4204
+ // subtraction never enters the inner loop.
4205
+ var low = wl.level - wl.width / 2 + sgChannelRefDb(spec, context.channel);
4154
4206
  var invWidth = wl.width > 0 ? 1 / wl.width : 1;
4155
4207
  var logScale = 20 / Math.LN10;
4156
4208
 
@@ -4538,13 +4590,19 @@ function sgDrawSpectrum(context) {
4538
4590
  ctx.strokeStyle = trace.color;
4539
4591
  ctx.lineWidth = trace.key === 'rss' ? 1.6 : 1.1;
4540
4592
  ctx.globalAlpha = trace.key === 'rss' ? 1 : 0.9;
4593
+ // Dashed for the RF proxies: the same dB axis carries two different
4594
+ // references, and a reader should not have to consult the legend to see
4595
+ // which traces are the uncalibrated ones.
4596
+ ctx.setLineDash(sgIsRfChannel(trace.key) ? [4, 3] : []);
4597
+ var traceFloor = sgValueFloorFor(spec, trace.key);
4598
+ var traceRefDb = sgChannelRefDb(spec, trace.key);
4541
4599
  ctx.beginPath();
4542
4600
  var started = false;
4543
4601
  for (var row = 0; row < values.length; row++) {
4544
4602
  var f = spec.fStartHz + row * spec.fStepHz;
4545
4603
  if (f < range.fMin || f > range.fMax) continue;
4546
4604
  var fp = freqPos(f);
4547
- var mp = magPos(sgToDb(values[row], floorValue));
4605
+ var mp = magPos(sgToDb(values[row], traceFloor) - traceRefDb);
4548
4606
  var xx = rotated ? mp : fp;
4549
4607
  var yy = rotated ? fp : mp;
4550
4608
  if (!started) { ctx.moveTo(xx, yy); started = true; } else ctx.lineTo(xx, yy);
@@ -4552,6 +4610,7 @@ function sgDrawSpectrum(context) {
4552
4610
  ctx.stroke();
4553
4611
  }
4554
4612
  ctx.globalAlpha = 1;
4613
+ ctx.setLineDash([]);
4555
4614
  ctx.restore();
4556
4615
  return rect;
4557
4616
  }
@@ -4562,7 +4621,6 @@ function sgSpectrumScale(spec, slice, windowLevel, freeY, traces) {
4562
4621
  return { min: windowLevel.level - windowLevel.width / 2, max: windowLevel.level + windowLevel.width / 2 };
4563
4622
  }
4564
4623
  if (!spec || !slice) return { min: -80, max: 0 };
4565
- var floorValue = sgValueFloor(spec);
4566
4624
  var max = -Infinity;
4567
4625
  var keys = [];
4568
4626
  for (var t = 0; t < traces.length; t++) if (traces[t].visible) keys.push(traces[t].key);
@@ -4570,8 +4628,10 @@ function sgSpectrumScale(spec, slice, windowLevel, freeY, traces) {
4570
4628
  for (var k = 0; k < keys.length; k++) {
4571
4629
  var values = slice[keys[k]];
4572
4630
  if (!values) continue;
4631
+ var floorValue = sgValueFloorFor(spec, keys[k]);
4632
+ var refDb = sgChannelRefDb(spec, keys[k]);
4573
4633
  for (var i = 0; i < values.length; i++) {
4574
- var db = sgToDb(values[i], floorValue);
4634
+ var db = sgToDb(values[i], floorValue) - refDb;
4575
4635
  if (db > max) max = db;
4576
4636
  }
4577
4637
  }
@@ -4603,8 +4663,8 @@ function sgDetectHotBands(spec, bands, windowLevel, channel) {
4603
4663
  var flags = [];
4604
4664
  if (!spec || !spec.nTime || !bands || !bands.length) return flags;
4605
4665
  var data = spec.data[channel] || spec.data.rss;
4606
- var floorValue = sgValueFloor(spec);
4607
- var threshold = windowLevel.level + windowLevel.width / 2;
4666
+ var floorValue = sgValueFloorFor(spec, channel);
4667
+ var threshold = windowLevel.level + windowLevel.width / 2 + sgChannelRefDb(spec, channel);
4608
4668
  for (var i = 0; i < bands.length; i++) {
4609
4669
  var half = (bands[i].bwHz || 0) / 2;
4610
4670
  var rowLow = Math.max(0, Math.floor((bands[i].freqHz - half - spec.fStartHz) / spec.fStepHz));
@@ -4636,7 +4696,12 @@ function sgSampleCell(spec, channel, timeSec, freqHz) {
4636
4696
  gx: spec.data.gx[index],
4637
4697
  gy: spec.data.gy[index],
4638
4698
  gz: spec.data.gz[index],
4639
- rss: spec.data[channel] ? spec.data[channel][index] : spec.data.rss[index]
4699
+ rss: spec.data[channel] ? spec.data[channel][index] : spec.data.rss[index],
4700
+ /* null, not 0, when RF was not computed: the readout must be able to tell
4701
+ "no RF proxy in this matrix" from "the RF proxy is silent here". */
4702
+ rfThermo: spec.data.rfThermo ? spec.data.rfThermo[index] : null,
4703
+ rfControl: spec.data.rfControl ? spec.data.rfControl[index] : null,
4704
+ rf: spec.data.rf ? spec.data.rf[index] : null
4640
4705
  };
4641
4706
  }
4642
4707
 
@@ -4989,7 +5054,30 @@ var SeqEyesPanel = (function () {
4989
5054
  overlap: getNum('seqeyes.spectrogram.overlap', 0.75),
4990
5055
  oversample: getNum('seqeyes.spectrogram.oversample', 3),
4991
5056
  targetColumns: 256,
4992
- normalize: true
5057
+ normalize: true,
5058
+ /* RF acoustic proxy (rfAcoustic.ts). Off by default and opt-in: these
5059
+ channels are uncalibrated research proxies with no unit, unlike every
5060
+ other channel in this panel. The two mechanism weights stay at the
5061
+ upstream 1:1 default — that ratio is a placeholder until it is fitted to
5062
+ measurements, so exposing it as a dial would suggest a meaning it has
5063
+ not earned. */
5064
+ includeRf: getBool('seqeyes.spectrogram.rf', false),
5065
+ rfScale: getNum('seqeyes.spectrogram.rfscale', 1),
5066
+ rfThermoWeight: 1,
5067
+ rfControlWeight: 1,
5068
+ rfEdgeMode: 'signed',
5069
+ /* Playback only: crossfade between the gradient sound and the RF proxy,
5070
+ each normalised to its own peak first. There is no correct default —
5071
+ the two have no common unit — so an even blend is the honest one. */
5072
+ rfMix: getNum('seqeyes.spectrogram.rfmix', 0.5)
5073
+ };
5074
+ /* Which matrix the image layer paints. The spectrum pane still draws every
5075
+ visible trace; this only picks the colourmapped one. */
5076
+ var imageChannel = get('seqeyes.spectrogram.imagechannel') || 'rss';
5077
+ var SG_RF_SLICE_KEYS = ['rfThermo', 'rfControl', 'rf'];
5078
+ var SG_IMAGE_CHANNEL_LABELS = {
5079
+ rss: 'Σ', gx: 'Gx', gy: 'Gy', gz: 'Gz',
5080
+ rf: 'RF proxy', rfThermo: 'RF thermo', rfControl: 'RF ctrl'
4993
5081
  };
4994
5082
  var colormapName = get('seqeyes.spectrogram.colormap') || 'viridis';
4995
5083
  if (SG_COLORMAP_NAMES.indexOf(colormapName) < 0) colormapName = 'viridis';
@@ -4998,7 +5086,10 @@ var SeqEyesPanel = (function () {
4998
5086
  { key: 'gx', label: 'Gx', varName: '--gx', visible: getBool('seqeyes.spectrogram.trace.gx', true) },
4999
5087
  { key: 'gy', label: 'Gy', varName: '--gy', visible: getBool('seqeyes.spectrogram.trace.gy', true) },
5000
5088
  { 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) }
5089
+ { key: 'rss', label: 'Σ', varName: '--fg', visible: getBool('seqeyes.spectrogram.trace.rss', true) },
5090
+ /* Drawn only while the matrix carries them, i.e. while RF is enabled. */
5091
+ { key: 'rfThermo', label: 'RF thermo', varName: '--rf', rf: true, visible: getBool('seqeyes.spectrogram.trace.rfThermo', true) },
5092
+ { key: 'rfControl', label: 'RF ctrl', varName: '--tr', rf: true, visible: getBool('seqeyes.spectrogram.trace.rfControl', true) }
5002
5093
  ];
5003
5094
  var freeY = getBool('seqeyes.spectrogram.freeY', false);
5004
5095
  var bandsVisible = getBool('seqeyes.spectrogram.bands', true);
@@ -5031,6 +5122,11 @@ var SeqEyesPanel = (function () {
5031
5122
  var audioActivationId = 0;
5032
5123
  var audioActivationPending = false;
5033
5124
  var audioWindow = null; // buffered range plus the first-pass offset
5125
+ /* What the host actually put in the last audio buffer. The panel asks for
5126
+ RF, but only the reply says whether any arrived, and the two lanes reach
5127
+ the synthesiser by different routes — so this is reported rather than
5128
+ assumed from `params.includeRf`. */
5129
+ var audioRfIncluded = null;
5034
5130
  var playheadFrame = 0;
5035
5131
 
5036
5132
  var AUDIO_SAMPLE_RATE = 44100;
@@ -5237,7 +5333,9 @@ var SeqEyesPanel = (function () {
5237
5333
 
5238
5334
  function paramsSignature() {
5239
5335
  return [params.source, params.fMaxHz, params.windowSamples, params.overlap,
5240
- params.oversample, params.targetColumns, params.normalize ? 1 : 0].join('|');
5336
+ params.oversample, params.targetColumns, params.normalize ? 1 : 0,
5337
+ params.includeRf ? 1 : 0, params.rfScale, params.rfThermoWeight,
5338
+ params.rfControlWeight, params.rfEdgeMode].join('|');
5241
5339
  }
5242
5340
 
5243
5341
  function cacheKey(startSec, endSec) {
@@ -5299,6 +5397,11 @@ var SeqEyesPanel = (function () {
5299
5397
  oversample: params.oversample,
5300
5398
  targetColumns: params.targetColumns,
5301
5399
  normalize: params.normalize,
5400
+ includeRf: params.includeRf,
5401
+ rfScale: params.rfScale,
5402
+ rfThermoWeight: params.rfThermoWeight,
5403
+ rfControlWeight: params.rfControlWeight,
5404
+ rfEdgeMode: params.rfEdgeMode,
5302
5405
  trTimeSec: h.getTrTimeSec ? h.getTrTimeSec() : 0
5303
5406
  });
5304
5407
  }
@@ -5335,13 +5438,29 @@ var SeqEyesPanel = (function () {
5335
5438
  render();
5336
5439
  }
5337
5440
 
5441
+ /**
5442
+ * Image channel that is actually present in the current matrix.
5443
+ *
5444
+ * A remembered `rf` preference outlives the RF toggle and survives a reload
5445
+ * into a matrix that has no RF channels, so the fallback is not defensive
5446
+ * padding — it is the normal path the first time the panel opens with RF off.
5447
+ */
5448
+ function activeImageChannel() {
5449
+ if (currentSpec && currentSpec.data && currentSpec.data[imageChannel]) return imageChannel;
5450
+ return 'rss';
5451
+ }
5452
+
5338
5453
  function applySpectrogram(spec) {
5339
5454
  currentSpec = spec;
5340
5455
  currentView = { startSec: spec.requestedStartSec, endSec: spec.requestedEndSec };
5341
- if (windowLevelAuto) windowLevel = sgAutoWindowLevel(spec, 'rss', windowLevel);
5456
+ if (windowLevelAuto) windowLevel = sgAutoWindowLevel(spec, activeImageChannel(), windowLevel);
5342
5457
  clampFrequencyRange();
5343
5458
  imageDirty = true;
5344
- hotBands = sgDetectHotBands(spec, acousticBands, windowLevel, 'rss');
5459
+ hotBands = sgDetectHotBands(spec, acousticBands, windowLevel, activeImageChannel());
5460
+ // The RF chips and the image-channel options both depend on whether this
5461
+ // matrix carries RF, which is only known once it arrives.
5462
+ buildLegend();
5463
+ syncControls();
5345
5464
  publishNotices();
5346
5465
  render();
5347
5466
  if (requestStartedAt) {
@@ -5384,9 +5503,18 @@ var SeqEyesPanel = (function () {
5384
5503
  var anyHot = false;
5385
5504
  for (var k = 0; k < hotBands.length; k++) if (hotBands[k]) anyHot = true;
5386
5505
  if (anyHot) {
5387
- messages.push('Gradient energy falls inside a forbidden acoustic band (advisory: '
5506
+ // The detection runs on whichever channel the image shows, so the wording
5507
+ // has to name that channel rather than always claiming "gradient".
5508
+ messages.push((sgIsRfChannel(activeImageChannel()) ? 'RF proxy energy' : 'Gradient energy')
5509
+ + ' falls inside a forbidden acoustic band (advisory: '
5388
5510
  + 'this compares against the current display window, and is not a compliance check).');
5389
5511
  }
5512
+ if (currentSpec && currentSpec.rfIncluded) {
5513
+ messages.push('RF acoustic channels are a reduced-order research proxy '
5514
+ + '(thermoacoustic d|B1|²/dt plus a hypothesised TX-switching term), with no calibrated '
5515
+ + 'level and no scanner resonance model. They are shown on their own relative scale and '
5516
+ + 'must not be compared with, or added to, the gradient channels.');
5517
+ }
5390
5518
  notice('spectrogram', messages.length ? messages : null);
5391
5519
  }
5392
5520
 
@@ -5417,7 +5545,7 @@ var SeqEyesPanel = (function () {
5417
5545
  if (imageDirty || !imageCache || imageCache.width !== pixelW || imageCache.height !== pixelH) {
5418
5546
  imageCache = sgBuildImageData({
5419
5547
  spectrogram: currentSpec,
5420
- channel: 'rss',
5548
+ channel: activeImageChannel(),
5421
5549
  lut: lut,
5422
5550
  windowLevel: windowLevel,
5423
5551
  view: currentView,
@@ -5512,9 +5640,22 @@ var SeqEyesPanel = (function () {
5512
5640
  out.gz[row] = spec.data.gz[index];
5513
5641
  out.rss[row] = spec.data.rss[index];
5514
5642
  }
5643
+ addRfSliceColumns(out, spec, col);
5515
5644
  return out;
5516
5645
  }
5517
5646
 
5647
+ /** Copy the RF rows of one column into a slice, when the matrix has them. */
5648
+ function addRfSliceColumns(out, spec, col) {
5649
+ for (var i = 0; i < SG_RF_SLICE_KEYS.length; i++) {
5650
+ var key = SG_RF_SLICE_KEYS[i];
5651
+ var source = spec.data[key];
5652
+ if (!source) continue;
5653
+ var target = new Float32Array(spec.nFreq);
5654
+ for (var row = 0; row < spec.nFreq; row++) target[row] = source[row * spec.nTime + col];
5655
+ out[key] = target;
5656
+ }
5657
+ }
5658
+
5518
5659
  var averageCache = null;
5519
5660
  function sliceAverage() {
5520
5661
  var spec = currentSpec;
@@ -5538,6 +5679,21 @@ var SeqEyesPanel = (function () {
5538
5679
  out.gx[row] = rx; out.gy[row] = ry; out.gz[row] = rz;
5539
5680
  out.rss[row] = Math.sqrt(rx * rx + ry * ry + rz * rz);
5540
5681
  }
5682
+ // The RF channels average on their own. `rss` is rebuilt from the per-axis
5683
+ // means above because it is a combination of three signals; `rf` is already
5684
+ // the transform of one combined signal, so it is simply averaged.
5685
+ for (var i = 0; i < SG_RF_SLICE_KEYS.length; i++) {
5686
+ var key = SG_RF_SLICE_KEYS[i];
5687
+ var source = spec.data[key];
5688
+ if (!source) continue;
5689
+ var target = new Float32Array(spec.nFreq);
5690
+ for (var r = 0; r < spec.nFreq; r++) {
5691
+ var b = r * spec.nTime, acc = 0;
5692
+ for (var c = 0; c < spec.nTime; c++) { var v = source[b + c]; acc += v * v; }
5693
+ target[r] = Math.sqrt(acc / spec.nTime);
5694
+ }
5695
+ out[key] = target;
5696
+ }
5541
5697
  averageCache = { spec: spec, slice: out };
5542
5698
  return out;
5543
5699
  }
@@ -5553,7 +5709,9 @@ var SeqEyesPanel = (function () {
5553
5709
  for (var i = 0; i < traces.length; i++) {
5554
5710
  resolved.push({
5555
5711
  key: traces[i].key,
5556
- visible: traces[i].visible,
5712
+ // An RF trace the matrix does not carry is not drawn, whatever the
5713
+ // remembered legend state says.
5714
+ visible: traces[i].visible && (!traces[i].rf || !!(slice && slice[traces[i].key])),
5557
5715
  color: (style.getPropertyValue(traces[i].varName) || '#888').trim()
5558
5716
  });
5559
5717
  }
@@ -5582,13 +5740,25 @@ var SeqEyesPanel = (function () {
5582
5740
  if (currentSpec && currentSpec.nTime) {
5583
5741
  parts.push('dt ' + sgFmtResolution(currentSpec.dtResolutionSec));
5584
5742
  parts.push('df ' + sgFmtHz(currentSpec.dfResolutionHz) + ' Hz');
5585
- parts.push('unit ' + currentSpec.unit);
5743
+ var channel = activeImageChannel();
5744
+ parts.push('img ' + (SG_IMAGE_CHANNEL_LABELS[channel] || channel));
5745
+ // The unit belongs to the channel on screen, so an RF image must not be
5746
+ // labelled mT/m.
5747
+ parts.push('unit ' + (sgIsRfChannel(channel) ? 'relative (0 dB = RF peak in view)' : currentSpec.unit));
5586
5748
  parts.push('D ' + currentSpec.decimationFactor + '×');
5587
5749
  parts.push(currentSpec.nTime + '×' + currentSpec.nFreq);
5588
5750
  parts.push('W ' + windowLevel.width.toFixed(1) + ' dB L ' + sgFmtDb(windowLevel.level) + ' dB');
5589
5751
  } else if (busy) {
5590
5752
  parts.push('Calculating…');
5591
5753
  }
5754
+ if (params.includeRf && audioRfIncluded !== null) {
5755
+ // Says whether the buffer being played actually contains RF, which is not
5756
+ // the same question as whether the RF channels were requested — and at
5757
+ // what share, since a low mix is the other way RF goes unnoticed.
5758
+ parts.push(audioRfIncluded
5759
+ ? 'audio RF ✓ ' + Math.round(params.rfMix * 100) + '%'
5760
+ : 'audio RF ✗ (none reached the buffer)');
5761
+ }
5592
5762
  if (isFinite(playheadTimeSec)) parts.push('▶ ' + sgFmtTime(playheadTimeSec, timeUnit()));
5593
5763
  else if (isFinite(markerTimeSec)) parts.push('marker ' + sgFmtTime(markerTimeSec, timeUnit()));
5594
5764
  else if (currentSpec && currentSpec.nTime) {
@@ -5596,7 +5766,11 @@ var SeqEyesPanel = (function () {
5596
5766
  }
5597
5767
  if (hoverReadout) parts.push(hoverReadout);
5598
5768
  out.textContent = parts.join(' ');
5599
- out.title = 'Simulated gradient spectral content — not calibrated sound pressure level.';
5769
+ out.title = currentSpec && currentSpec.rfIncluded
5770
+ ? 'Simulated gradient spectral content — not calibrated sound pressure level. '
5771
+ + 'The RF channels are an uncalibrated research proxy on a separate relative scale; '
5772
+ + 'they cannot be compared with the gradient channels or summed with them.'
5773
+ : 'Simulated gradient spectral content — not calibrated sound pressure level.';
5600
5774
  }
5601
5775
 
5602
5776
  var hoverReadout = '';
@@ -5631,15 +5805,15 @@ var SeqEyesPanel = (function () {
5631
5805
  };
5632
5806
  windowLevelAuto = false;
5633
5807
  imageDirty = true;
5634
- if (currentSpec) hotBands = sgDetectHotBands(currentSpec, acousticBands, windowLevel, 'rss');
5808
+ if (currentSpec) hotBands = sgDetectHotBands(currentSpec, acousticBands, windowLevel, activeImageChannel());
5635
5809
  render();
5636
5810
  }
5637
5811
 
5638
5812
  function resetWindowLevel() {
5639
5813
  windowLevelAuto = true;
5640
- if (currentSpec) windowLevel = sgAutoWindowLevel(currentSpec, 'rss', windowLevel);
5814
+ if (currentSpec) windowLevel = sgAutoWindowLevel(currentSpec, activeImageChannel(), windowLevel);
5641
5815
  imageDirty = true;
5642
- if (currentSpec) hotBands = sgDetectHotBands(currentSpec, acousticBands, windowLevel, 'rss');
5816
+ if (currentSpec) hotBands = sgDetectHotBands(currentSpec, acousticBands, windowLevel, activeImageChannel());
5643
5817
  render();
5644
5818
  }
5645
5819
 
@@ -5724,7 +5898,13 @@ var SeqEyesPanel = (function () {
5724
5898
  h.requestAudio(pendingAudioId, range.startSec, range.endSec, {
5725
5899
  sampleRate: 44100,
5726
5900
  source: params.source,
5727
- channelWeights: [1, 1, 1]
5901
+ channelWeights: [1, 1, 1],
5902
+ includeRf: params.includeRf,
5903
+ rfScale: params.rfScale,
5904
+ rfThermoWeight: params.rfThermoWeight,
5905
+ rfControlWeight: params.rfControlWeight,
5906
+ rfEdgeMode: params.rfEdgeMode,
5907
+ rfMix: params.rfMix
5728
5908
  });
5729
5909
  syncTransport();
5730
5910
  }
@@ -5777,16 +5957,28 @@ var SeqEyesPanel = (function () {
5777
5957
  function deliverAudio(id, payload) {
5778
5958
  if (id !== pendingAudioId) return;
5779
5959
  pendingAudioId = 0;
5960
+ // The host knows which sources were enabled, so it words the refusal; the
5961
+ // panel used to say "no gradient activity" even when RF was the source the
5962
+ // user was listening for.
5963
+ var emptyMessage = params.includeRf
5964
+ ? 'Nothing to play in this window — no gradient activity and no RF events.'
5965
+ : 'No gradient activity in this window — nothing to play.';
5780
5966
  if (!payload || !payload.left || !payload.left.length) {
5781
- notice('gradientSound', 'No gradient activity in this window — nothing to play.');
5967
+ notice('gradientSound', (payload && payload.warnings && payload.warnings.length)
5968
+ ? payload.warnings : emptyMessage);
5782
5969
  syncTransport();
5783
5970
  return;
5784
5971
  }
5785
5972
  if (payload.silent) {
5786
- notice('gradientSound', 'No gradient activity in this window — nothing to play.');
5973
+ notice('gradientSound', (payload.warnings && payload.warnings.length)
5974
+ ? payload.warnings : emptyMessage);
5787
5975
  syncTransport();
5788
5976
  return;
5789
5977
  }
5978
+ // Partial-source notes ("hearing the RF proxy only") are advisory, not a
5979
+ // refusal, so they are published without stopping playback.
5980
+ notice('gradientSound', (payload.warnings && payload.warnings.length) ? payload.warnings : null);
5981
+ audioRfIncluded = !!payload.rfIncluded;
5790
5982
  if (!SeqEyesAudio.load(payload.sampleRate, payload.left, payload.right, payload.startSec)) {
5791
5983
  if (SeqEyesAudio.contextState() !== 'running') audioActivationFailure();
5792
5984
  else notice('gradientSound', 'This host could not create an audio buffer.');
@@ -5846,6 +6038,7 @@ var SeqEyesPanel = (function () {
5846
6038
  playheadTimeSec = NaN;
5847
6039
  pendingAudioId = 0;
5848
6040
  audioWindow = null;
6041
+ audioRfIncluded = null;
5849
6042
  syncTransport();
5850
6043
  render();
5851
6044
  }
@@ -5901,7 +6094,7 @@ var SeqEyesPanel = (function () {
5901
6094
 
5902
6095
  function setAcousticBands(bands) {
5903
6096
  acousticBands = Array.isArray(bands) ? bands.slice() : [];
5904
- hotBands = currentSpec ? sgDetectHotBands(currentSpec, acousticBands, windowLevel, 'rss') : [];
6097
+ hotBands = currentSpec ? sgDetectHotBands(currentSpec, acousticBands, windowLevel, activeImageChannel()) : [];
5905
6098
  buildLegend();
5906
6099
  publishNotices();
5907
6100
  render();
@@ -5916,10 +6109,15 @@ var SeqEyesPanel = (function () {
5916
6109
  var style = getComputedStyle(document.body);
5917
6110
 
5918
6111
  for (var i = 0; i < traces.length; i++) {
6112
+ // RF chips appear only once the matrix carries RF, so the legend never
6113
+ // offers a toggle that would do nothing.
6114
+ if (traces[i].rf && !(currentSpec && currentSpec.data && currentSpec.data[traces[i].key])) continue;
5919
6115
  (function (trace) {
5920
6116
  var chip = document.createElement('div');
5921
6117
  chip.className = 'li' + (trace.visible ? '' : ' off');
5922
- chip.title = 'Toggle the ' + trace.label + ' spectrum trace';
6118
+ chip.title = trace.rf
6119
+ ? 'Toggle the ' + trace.label + ' trace (uncalibrated proxy, dB relative to its own peak in this view)'
6120
+ : 'Toggle the ' + trace.label + ' spectrum trace';
5923
6121
  var swatch = document.createElement('div');
5924
6122
  swatch.className = 'ld';
5925
6123
  swatch.style.background = (style.getPropertyValue(trace.varName) || '#888').trim();
@@ -5990,6 +6188,32 @@ var SeqEyesPanel = (function () {
5990
6188
  setValue('sgWin', String(params.windowSamples));
5991
6189
  setValue('sgOverlap', String(params.overlap));
5992
6190
  setValue('sgOversample', String(params.oversample));
6191
+
6192
+ var rfToggle = el('sgRf');
6193
+ if (rfToggle) {
6194
+ rfToggle.setAttribute('aria-pressed', params.includeRf ? 'true' : 'false');
6195
+ rfToggle.classList.toggle('on', params.includeRf);
6196
+ }
6197
+ var rfScale = el('sgRfScale');
6198
+ if (rfScale) {
6199
+ setValue('sgRfScale', String(params.rfScale));
6200
+ rfScale.disabled = !params.includeRf;
6201
+ }
6202
+ var rfMix = el('sgRfMix');
6203
+ if (rfMix) {
6204
+ setValue('sgRfMix', String(Math.round(params.rfMix * 100)));
6205
+ rfMix.disabled = !params.includeRf;
6206
+ }
6207
+ var image = el('sgImageChannel');
6208
+ if (image) {
6209
+ // RF options are removed rather than disabled: a disabled option that the
6210
+ // select is currently showing is a state the user cannot get out of.
6211
+ for (var o = image.options.length - 1; o >= 0; o--) {
6212
+ if (sgIsRfChannel(image.options[o].value)) image.options[o].hidden = !params.includeRf;
6213
+ }
6214
+ image.value = activeImageChannel();
6215
+ }
6216
+
5993
6217
  var clear = el('sgMarkerClear');
5994
6218
  if (clear) clear.disabled = !isFinite(markerTimeSec);
5995
6219
  }
@@ -6103,6 +6327,56 @@ var SeqEyesPanel = (function () {
6103
6327
  onParamChanged(true);
6104
6328
  };
6105
6329
 
6330
+ var image = el('sgImageChannel');
6331
+ if (image) image.onchange = function () {
6332
+ imageChannel = this.value;
6333
+ set('seqeyes.spectrogram.imagechannel', imageChannel);
6334
+ // Channels live on different scales, so a channel change invalidates the
6335
+ // contrast as thoroughly as new data would.
6336
+ windowLevelAuto = true;
6337
+ if (currentSpec) windowLevel = sgAutoWindowLevel(currentSpec, activeImageChannel(), windowLevel);
6338
+ if (currentSpec) hotBands = sgDetectHotBands(currentSpec, acousticBands, windowLevel, activeImageChannel());
6339
+ imageDirty = true;
6340
+ publishNotices();
6341
+ render();
6342
+ };
6343
+
6344
+ // Each RF control changes what is being synthesised, so a buffer already
6345
+ // playing is stale the moment one moves.
6346
+ var rfToggle = el('sgRf');
6347
+ if (rfToggle) rfToggle.onclick = function () {
6348
+ stopPlayback();
6349
+ params.includeRf = !params.includeRf;
6350
+ set('seqeyes.spectrogram.rf', params.includeRf ? '1' : '0');
6351
+ if (!params.includeRf && sgIsRfChannel(imageChannel)) {
6352
+ imageChannel = 'rss';
6353
+ set('seqeyes.spectrogram.imagechannel', imageChannel);
6354
+ }
6355
+ onParamChanged(true);
6356
+ buildLegend();
6357
+ };
6358
+
6359
+ var rfScale = el('sgRfScale');
6360
+ if (rfScale) rfScale.onchange = function () {
6361
+ stopPlayback();
6362
+ var value = parseFloat(this.value);
6363
+ params.rfScale = isFinite(value) && value >= 0 ? value : 1;
6364
+ set('seqeyes.spectrogram.rfscale', String(params.rfScale));
6365
+ onParamChanged(true);
6366
+ };
6367
+
6368
+ var rfMix = el('sgRfMix');
6369
+ if (rfMix) rfMix.oninput = function () {
6370
+ stopPlayback();
6371
+ var value = parseFloat(this.value);
6372
+ params.rfMix = isFinite(value) ? Math.max(0, Math.min(1, value / 100)) : 0.5;
6373
+ set('seqeyes.spectrogram.rfmix', String(params.rfMix));
6374
+ // Playback only: the spectrogram keeps the mechanisms on separate
6375
+ // channels, so this must not trigger a recompute.
6376
+ syncControls();
6377
+ syncTransport();
6378
+ };
6379
+
6106
6380
  var win = el('sgWin');
6107
6381
  if (win) win.onchange = function () { params.windowSamples = parseFloat(this.value) || 0; onParamChanged(true); };
6108
6382
  var overlap = el('sgOverlap');
@@ -6351,6 +6625,15 @@ var SeqEyesPanel = (function () {
6351
6625
  + ' Gy ' + sgFmtDb(sgToDb(cell.gy, floorValue))
6352
6626
  + ' Gz ' + sgFmtDb(sgToDb(cell.gz, floorValue))
6353
6627
  + ' Σ ' + sgFmtDb(sgToDb(cell.rss, floorValue)) + ' dB';
6628
+ if (cell.rf !== null) {
6629
+ // Reported against the RF peak, matching the axis the RF traces are drawn
6630
+ // on, and flagged `rel` so it is never read as dB re 1 mT/m.
6631
+ var rfFloor = sgValueFloorFor(currentSpec, 'rf');
6632
+ var rfRef = sgChannelRefDb(currentSpec, 'rf');
6633
+ text += ' RF ' + sgFmtDb(sgToDb(cell.rf, rfFloor) - rfRef)
6634
+ + ' (th ' + sgFmtDb(sgToDb(cell.rfThermo, rfFloor) - rfRef)
6635
+ + ', ct ' + sgFmtDb(sgToDb(cell.rfControl, rfFloor) - rfRef) + ') dB rel';
6636
+ }
6354
6637
  var bandIndex = sgBandAtFrequency(acousticBands, cell.freqHz);
6355
6638
  if (bandIndex >= 0) {
6356
6639
  var band = acousticBands[bandIndex];
@@ -6591,6 +6874,13 @@ var SeqEyesPanel = (function () {
6591
6874
  dtResolutionSec: currentSpec ? currentSpec.dtResolutionSec : null,
6592
6875
  dfResolutionHz: currentSpec ? currentSpec.dfResolutionHz : null,
6593
6876
  decimationFactor: currentSpec ? currentSpec.decimationFactor : null,
6877
+ includeRf: params.includeRf,
6878
+ rfScale: params.rfScale,
6879
+ rfMix: params.rfMix,
6880
+ audioRfIncluded: audioRfIncluded,
6881
+ imageChannel: activeImageChannel(),
6882
+ rfIncluded: !!(currentSpec && currentSpec.rfIncluded),
6883
+ rfMaxValue: currentSpec ? (currentSpec.rfMaxValue || 0) : 0,
6594
6884
  warnings: currentSpec ? currentSpec.warnings.slice() : [],
6595
6885
  error: lastError
6596
6886
  };