seqeyes-python 0.3.9__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.9 → seqeyes_python-0.3.11}/PKG-INFO +1 -1
  2. {seqeyes_python-0.3.9 → seqeyes_python-0.3.11}/pyproject.toml +1 -1
  3. {seqeyes_python-0.3.9 → seqeyes_python-0.3.11}/src/seqeyes/__init__.py +1 -1
  4. {seqeyes_python-0.3.9 → seqeyes_python-0.3.11}/src/seqeyes/resources/pulseq-bundle.js +287 -10
  5. {seqeyes_python-0.3.9 → seqeyes_python-0.3.11}/src/seqeyes/resources/seqeyes-labels.js +20 -4
  6. {seqeyes_python-0.3.9 → seqeyes_python-0.3.11}/src/seqeyes/resources/webview-bundle.js +814 -92
  7. {seqeyes_python-0.3.9 → seqeyes_python-0.3.11}/.gitignore +0 -0
  8. {seqeyes_python-0.3.9 → seqeyes_python-0.3.11}/README.md +0 -0
  9. {seqeyes_python-0.3.9 → seqeyes_python-0.3.11}/examples/demo.ipynb +0 -0
  10. {seqeyes_python-0.3.9 → seqeyes_python-0.3.11}/examples/demo.py +0 -0
  11. {seqeyes_python-0.3.9 → seqeyes_python-0.3.11}/src/seqeyes/_plot.py +0 -0
  12. {seqeyes_python-0.3.9 → seqeyes_python-0.3.11}/src/seqeyes/_renderer.py +0 -0
  13. {seqeyes_python-0.3.9 → seqeyes_python-0.3.11}/src/seqeyes/resources/viewer.html +0 -0
  14. {seqeyes_python-0.3.9 → seqeyes_python-0.3.11}/tests/conftest.py +0 -0
  15. {seqeyes_python-0.3.9 → seqeyes_python-0.3.11}/tests/debug_full.html +0 -0
  16. {seqeyes_python-0.3.9 → seqeyes_python-0.3.11}/tests/debug_minimal.html +0 -0
  17. {seqeyes_python-0.3.9 → seqeyes_python-0.3.11}/tests/debug_viewer.html +0 -0
  18. {seqeyes_python-0.3.9 → seqeyes_python-0.3.11}/tests/debug_viewer.py +0 -0
  19. {seqeyes_python-0.3.9 → seqeyes_python-0.3.11}/tests/integration_test.py +0 -0
  20. {seqeyes_python-0.3.9 → seqeyes_python-0.3.11}/tests/test_plot.py +0 -0
  21. {seqeyes_python-0.3.9 → 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.9
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.9"
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.9"
32
+ __version__ = "0.3.11"
@@ -89,7 +89,7 @@ var Pulseq = (() => {
89
89
  });
90
90
 
91
91
  // package.json
92
- var version = "0.3.9";
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
@@ -7,13 +7,29 @@
7
7
  the 2-D context, the time→x mapping and the row geometry. The Python
8
8
  viewer loads this file on its own, without the rest of the bundle.
9
9
 
10
- The DOM and localStorage are only touched inside functions, so the tests
11
- can run the shipped file in a bare VM context.
10
+ The DOM and storage are only touched inside functions, so the tests can
11
+ run the shipped file in a bare VM context.
12
12
 
13
13
  A "labels" argument is anything with `names` and `kinds`: the popup and the
14
14
  styles need nothing more, so ⚙ works before the per-ADC values arrive. */
15
15
  var SeqEyesLabels = (function () {
16
16
  var STORAGE_KEY = 'seqeyes.labelStyles.v1';
17
+
18
+ /* The Python viewer inlines this file on its own, so the bundle's
19
+ preferences gateway may not be there. Go through it when it is, so the
20
+ settings popover's master switch and Forget button cover marker styles
21
+ too, and fall back to raw storage when it is not. */
22
+ function prefsApi() { return (typeof SeqEyesPrefs !== 'undefined') ? SeqEyesPrefs : null; }
23
+ function readStored(key) {
24
+ var api = prefsApi();
25
+ if (api) return api.get(key);
26
+ try { return localStorage.getItem(key); } catch (_) { return null; }
27
+ }
28
+ function writeStored(key, value) {
29
+ var api = prefsApi();
30
+ if (api) { api.set(key, value); return; }
31
+ try { localStorage.setItem(key, value); } catch (_) {}
32
+ }
17
33
  // Tableau 10: distinct hues that stay readable on light and dark themes.
18
34
  var PALETTE = ['#4e79a7', '#f28e2b', '#e15759', '#76b7b2', '#59a14f', '#edc948', '#b07aa1', '#ff9da7', '#9c755f', '#bab0ac'];
19
35
  var SHAPES = ['circle', 'square', 'triangle', 'triangleDown', 'diamond', 'cross', 'plus'];
@@ -74,14 +90,14 @@ var SeqEyesLabels = (function () {
74
90
  if (overrides) return overrides;
75
91
  overrides = {};
76
92
  try {
77
- var saved = JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}');
93
+ var saved = JSON.parse(readStored(STORAGE_KEY) || '{}');
78
94
  if (saved && typeof saved === 'object') overrides = saved;
79
95
  } catch (_) {}
80
96
  return overrides;
81
97
  }
82
98
 
83
99
  function writeOverrides() {
84
- try { localStorage.setItem(STORAGE_KEY, JSON.stringify(overrides)); } catch (_) {}
100
+ writeStored(STORAGE_KEY, JSON.stringify(overrides));
85
101
  }
86
102
 
87
103
  /* Defaults follow the label's position in its sequence, so one sequence's