/* ============================================================
   Audio Insight · Post-Call Analysis: data layer
   ------------------------------------------------------------
   - DIMS:        the 7 model dimensions + display metadata
   - CALLS:       deterministic mock recordings w/ frame-level scores
   - helpers:     severity→color ramp, formatting, CSV parser scaffold
   All numbers are in [0,1]. For every dimension LOWER = less
   problematic, EXCEPT loudness which is informational (a level).
   ============================================================ */

;(function(){
/* ---- deterministic RNG so refreshes are stable ---- */
function mulberry32(seed) {
  let a = seed >>> 0;
  return function () {
    a |= 0; a = (a + 0x6d2b79f5) | 0;
    let t = Math.imul(a ^ (a >>> 15), 1 | a);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}
const clamp = (v, lo = 0, hi = 1) => Math.max(lo, Math.min(hi, v));
const bump = (t, c, w) => Math.exp(-((t - c) * (t - c)) / (2 * w * w));

/* ---- the 7 dimensions ---- */
const DIMS = [
  {
    key: "risk_score", label: "Risk score", short: "Risk score", icon: "gauge.needle.50",
    headline: true, lowerBetter: true,
    desc: "Headline score: the likelihood this audio causes failures in downstream models such as STT, VAD, turn-taking or speech-to-speech.",
    downstream: "VAD · turn-taking · STT · S2S",
    scale: "0 = pristine · 1 = high failure risk",
  },
  {
    key: "speaker_reverb", label: "Speaker reverb", short: "Speaker reverb", icon: "ear.badge.waveform",
    lowerBetter: true,
    desc: "Speaker distance and room reverberance. Low = dry, close to the mic; high = distant, echoey, far-field.",
    downstream: "STT errors · smeared word boundaries · delayed VAD endpointing",
    scale: "0 = close to mic · 0.2 = slight room echo · 0.8 = heavy reverb, far away",
  },
  {
    key: "speaker_loudness", label: "Speaker loudness", short: "Speaker loudness", icon: "person.wave.2.fill",
    lowerBetter: false, neutral: true,
    desc: "A level meter, not a degradation score. Consistently low values are the failure mode. Quiet speech can be missed by VAD/STT.",
    downstream: "Neutral. Flag only when consistently low (VAD/STT may miss quiet speech)",
    scale: "0 = absent / silent · 0.8 = very loud",
  },
  {
    key: "interfering_speech", label: "Interfering speech", short: "Interfering speech", icon: "person.2.wave.2.fill",
    lowerBetter: true,
    desc: "Interference from additional live speakers audible in the audio.",
    downstream: "VAD false triggers · STT transcribes the wrong person · S2S interruption",
    scale: "0 = none · 0.8 = loud competing speaker",
  },
  {
    key: "media_speech", label: "Background media speech", short: "Media speech", icon: "speaker.wave.2.fill",
    lowerBetter: true,
    desc: "Interfering speech from media devices: a TV, radio or phone playing in the background.",
    downstream: "VAD false triggers · STT inserts media content · S2S interruption",
    scale: "0 = none · 0.8 = loud and distracting",
  },
  {
    key: "noise", label: "Noise", short: "Noise", icon: "dot.radiowaves.left.and.right",
    lowerBetter: true,
    desc: "Ambient noise relative to the speaker, a content-aware SNR. Low = clean; high = noise is strong vs. the speaker.",
    downstream: "VAD false triggers · STT errors",
    scale: "0 = clean · 0.8 = loud, overwhelming",
  },
  {
    key: "packet_loss", label: "Packet loss", short: "Packet loss", icon: "waveform.slash",
    lowerBetter: true,
    desc: "Audio dropouts or discontinuities: network packet loss, jitter, frame erasure or CPU overload.",
    downstream: "Missed or mangled words · STT errors",
    scale: "0 = no dropouts · 0.8 = frequent missing chunks",
  },
];
const SUBDIMS = DIMS.filter((d) => !d.headline);
const DIM_KEYS = DIMS.map((d) => d.key);

/* ---- outcome dispositions ---- */
const OUTCOME_TONE = {
  "Resolved": "positive",
  "Transferred": "accent",
  "Voicemail": "neutral",
  "Escalated": "warning",
  "Abandoned": "critical",
  "No resolution": "critical",
};

/* ============================================================
   Frame generation
   ============================================================ */
// shape evaluators → contribution in [0,1] before scaling
function shapeVal(shape, t, i, rng, ev) {
  switch (shape) {
    case "flat": return 0;
    case "ramp": return t;
    case "rampDown": return 1 - t;
    case "mid": return bump(t, 0.5, 0.22);
    case "early": return bump(t, 0.24, 0.16);
    case "late": return bump(t, 0.8, 0.18);
    case "bursts": return ev.burst[i] ? 1 : 0.06;
    case "spikes": return ev.spike[i] ? 1 : 0;
    case "dip": return ev.dip[i] ? 1 : 0; // used to *remove* loudness
    default: return 0;
  }
}

function makeFrames(profile, durationSec, seed) {
  const rng = mulberry32(seed);
  const N = Math.max(96, Math.round(durationSec * 7));
  // event maps for stochastic shapes (shared scaffolding)
  const ev = { burst: [], spike: [], dip: [] };
  // burst windows
  let inB = false, bLeft = 0;
  for (let i = 0; i < N; i++) {
    if (inB) { ev.burst[i] = true; if (--bLeft <= 0) inB = false; }
    else { ev.burst[i] = false; if (rng() < 0.012) { inB = true; bLeft = 8 + Math.floor(rng() * 22); } }
  }
  // spikes (short, sharp)
  for (let i = 0; i < N; i++) ev.spike[i] = rng() < 0.05;
  for (let i = 1; i < N; i++) if (ev.spike[i - 1] && rng() < 0.5) ev.spike[i] = true;
  // dips (absent speaker stretches)
  let inD = false, dLeft = 0;
  for (let i = 0; i < N; i++) {
    if (inD) { ev.dip[i] = true; if (--dLeft <= 0) inD = false; }
    else { ev.dip[i] = false; if (rng() < 0.01) { inD = true; dLeft = 6 + Math.floor(rng() * 16); } }
  }

  const out = { amp: [] };
  DIM_KEYS.forEach((k) => (out[k] = []));

  for (let i = 0; i < N; i++) {
    const t = i / (N - 1);
    const vals = {};
    SUBDIMS.concat(DIMS[0]).forEach((d) => {
      const cfg = profile.dims[d.key] || { base: 0.05, wobble: 0.04, shape: "flat", amp: 0 };
      let v = cfg.base + (cfg.amp || 0) * shapeVal(cfg.shape || "flat", t, i, rng, ev);
      v += (cfg.wobble || 0.04) * (rng() - 0.5);
      vals[d.key] = clamp(v);
    });
    // loudness: start from level, subtract dips (absence)
    const lcfg = profile.dims.speaker_loudness;
    let loud = lcfg.base + (lcfg.amp || 0) * shapeVal(lcfg.shape || "flat", t, i, rng, ev);
    loud -= (lcfg.dipDepth || 0) * shapeVal("dip", t, i, rng, ev);
    loud += (lcfg.wobble || 0.05) * (rng() - 0.5);
    loud = clamp(loud);
    vals.speaker_loudness = loud;

    // amplitude envelope: speech-like syllabic modulation gated by loudness
    const syl = 0.5 + 0.5 * Math.sin(t * durationSec * 5.5 + i * 0.7) * (0.6 + 0.4 * rng());
    let amp = loud * (0.32 + 0.68 * Math.abs(syl));
    amp = clamp(amp * (0.9 + 0.2 * rng()));

    // risk_score = downstream-failure risk blended from the problematic dims
    const probs = [vals.noise, vals.packet_loss, vals.interfering_speech, vals.media_speech, vals.speaker_reverb];
    const worst = Math.max(...probs);
    const mean = probs.reduce((a, b) => a + b, 0) / probs.length;
    let q = 0.05 + 0.9 * worst + 0.12 * mean;
    if (loud < 0.16) q += (0.16 - loud) * 1.4;     // speaker absent → STT fails
    if (loud > 0.92) q += (loud - 0.92) * 0.8;     // clipping
    q += 0.04 * (rng() - 0.5);
    vals.risk_score = clamp(q);

    out.amp.push(amp);
    DIM_KEYS.forEach((k) => out[k].push(vals[k]));
  }
  return out;
}

function avg(arr) { return arr.reduce((a, b) => a + b, 0) / arr.length; }

/* ============================================================
   Mock recordings: each profile is a distinct failure signature
   so clicking different calls tells a different story on camera.
   ============================================================ */
const PROFILES = [
  { id: 4821, outcome: "Resolved", dur: 142, label: "Clean office line",
    dims: { speaker_reverb: { base: 0.08, amp: 0.05, shape: "flat" }, speaker_loudness: { base: 0.56 }, interfering_speech: { base: 0.04 }, media_speech: { base: 0.03 }, noise: { base: 0.07 }, packet_loss: { base: 0.02, amp: 0.4, shape: "spikes" } } },
  { id: 4830, outcome: "Escalated", dur: 268, label: "Café, rising noise",
    dims: { speaker_reverb: { base: 0.2 }, speaker_loudness: { base: 0.5 }, interfering_speech: { base: 0.12, amp: 0.2, shape: "bursts" }, media_speech: { base: 0.05 }, noise: { base: 0.28, amp: 0.42, shape: "ramp" }, packet_loss: { base: 0.03 } } },
  { id: 4844, outcome: "Resolved", dur: 96, label: "Speaker across the room",
    dims: { speaker_reverb: { base: 0.62, amp: 0.18, shape: "mid" }, speaker_loudness: { base: 0.34 }, interfering_speech: { base: 0.05 }, media_speech: { base: 0.04 }, noise: { base: 0.14 }, packet_loss: { base: 0.03 } } },
  { id: 4851, outcome: "Abandoned", dur: 187, label: "Heavy packet loss",
    dims: { speaker_reverb: { base: 0.16 }, speaker_loudness: { base: 0.52 }, interfering_speech: { base: 0.06 }, media_speech: { base: 0.04 }, noise: { base: 0.12 }, packet_loss: { base: 0.06, amp: 0.78, shape: "spikes" } } },
  { id: 4863, outcome: "Transferred", dur: 224, label: "TV in the background",
    dims: { speaker_reverb: { base: 0.18 }, speaker_loudness: { base: 0.5 }, interfering_speech: { base: 0.07 }, media_speech: { base: 0.08, amp: 0.62, shape: "bursts" }, noise: { base: 0.18 }, packet_loss: { base: 0.03 } } },
  { id: 4870, outcome: "Escalated", dur: 312, label: "Second speaker crosstalk",
    dims: { speaker_reverb: { base: 0.22 }, speaker_loudness: { base: 0.5 }, interfering_speech: { base: 0.1, amp: 0.6, shape: "mid" }, media_speech: { base: 0.05 }, noise: { base: 0.15 }, packet_loss: { base: 0.04 } } },
  { id: 4888, outcome: "No resolution", dur: 154, label: "Speaker too quiet",
    dims: { speaker_reverb: { base: 0.24 }, speaker_loudness: { base: 0.18, dipDepth: 0.16, shape: "flat" }, interfering_speech: { base: 0.06 }, media_speech: { base: 0.05 }, noise: { base: 0.24 }, packet_loss: { base: 0.05 } } },
  { id: 4901, outcome: "Transferred", dur: 203, label: "Mixed degradation",
    dims: { speaker_reverb: { base: 0.3 }, speaker_loudness: { base: 0.48 }, interfering_speech: { base: 0.2, amp: 0.22, shape: "bursts" }, media_speech: { base: 0.22, amp: 0.2, shape: "late" }, noise: { base: 0.3, amp: 0.12, shape: "ramp" }, packet_loss: { base: 0.16, amp: 0.4, shape: "spikes" } } },
  { id: 4912, outcome: "Resolved", dur: 78, label: "Pristine, close mic",
    dims: { speaker_reverb: { base: 0.05 }, speaker_loudness: { base: 0.6 }, interfering_speech: { base: 0.03 }, media_speech: { base: 0.02 }, noise: { base: 0.05 }, packet_loss: { base: 0.01 } } },
  { id: 4920, outcome: "Resolved", dur: 176, label: "Echoey conference room",
    dims: { speaker_reverb: { base: 0.72, amp: 0.12, shape: "flat" }, speaker_loudness: { base: 0.46 }, interfering_speech: { base: 0.08 }, media_speech: { base: 0.04 }, noise: { base: 0.12 }, packet_loss: { base: 0.03 } } },
  { id: 4934, outcome: "Abandoned", dur: 119, label: "Loud street noise",
    dims: { speaker_reverb: { base: 0.2 }, speaker_loudness: { base: 0.52 }, interfering_speech: { base: 0.08 }, media_speech: { base: 0.05 }, noise: { base: 0.55, amp: 0.18, shape: "mid" }, packet_loss: { base: 0.14, amp: 0.3, shape: "spikes" } } },
  { id: 4948, outcome: "Escalated", dur: 251, label: "Open-plan crosstalk",
    dims: { speaker_reverb: { base: 0.26 }, speaker_loudness: { base: 0.5 }, interfering_speech: { base: 0.42, amp: 0.28, shape: "bursts" }, media_speech: { base: 0.18 }, noise: { base: 0.2 }, packet_loss: { base: 0.05 } } },
  { id: 4955, outcome: "Transferred", dur: 198, label: "Network degrades late",
    dims: { speaker_reverb: { base: 0.16 }, speaker_loudness: { base: 0.5 }, interfering_speech: { base: 0.06 }, media_speech: { base: 0.04 }, noise: { base: 0.12 }, packet_loss: { base: 0.05, amp: 0.6, shape: "late" } } },
  { id: 4967, outcome: "No resolution", dur: 232, label: "Loud TV + clipping",
    dims: { speaker_reverb: { base: 0.18 }, speaker_loudness: { base: 0.64 }, interfering_speech: { base: 0.1 }, media_speech: { base: 0.5, amp: 0.18, shape: "flat" }, noise: { base: 0.26 }, packet_loss: { base: 0.04 } } },
  { id: 4972, outcome: "Resolved", dur: 134, label: "Slight room tone",
    dims: { speaker_reverb: { base: 0.16 }, speaker_loudness: { base: 0.54 }, interfering_speech: { base: 0.05 }, media_speech: { base: 0.04 }, noise: { base: 0.16 }, packet_loss: { base: 0.02 } } },
];

function buildCalls() {
  // spread start times across two business days, most-recent first
  const base = new Date("2026-06-09T16:48:00");
  let cursor = base.getTime();
  return PROFILES.map((p, idx) => {
    cursor -= (p.dur * 1000 + (480000 + (p.id % 7) * 90000)); // gap between calls
    const frames = makeFrames(p, p.dur, p.id);
    const scores = {};
    DIM_KEYS.forEach((k) => (scores[k] = avg(frames[k])));
    return {
      id: p.id,
      file: `rec_${p.id}.wav`,
      label: p.label,
      started: new Date(cursor),
      durationSec: p.dur,
      outcome: p.outcome,
      scores,
      frames,
      driver: dominantDimension(scores),
      metrics: { mean: scores.risk_score, p95: p95(frames.risk_score), fracDegraded: fracDegraded(frames.risk_score) },
      audioUrl: null, // populated by the loader when real files are attached
    };
  });
}

/* ============================================================
   Severity → color  (single-hue blue, intensity by severity)
   On the inverse (dark) surface: clean reads as a calm, dim
   slate-blue; problematic reads as a brighter, more saturated
   blue. One family, no traffic-light.
   ============================================================ */
function _lerp(a, b, t) { return a + (b - a) * t; }
function severityRGB(s) {
  s = clamp(s);
  const stops = [
    [0.0, [78, 94, 120]],   // calm slate, Good band (< 0.35)
    [0.35, [96, 140, 238]], // entering Warn
    [0.6, [70, 132, 255]],  // entering Bad
    [1.0, [126, 184, 255]], // severe
  ];
  let lo = stops[0], hi = stops[stops.length - 1];
  for (let i = 0; i < stops.length - 1; i++) {
    if (s >= stops[i][0] && s <= stops[i + 1][0]) { lo = stops[i]; hi = stops[i + 1]; break; }
  }
  const span = hi[0] - lo[0] || 1;
  const f = (s - lo[0]) / span;
  return [0, 1, 2].map((j) => Math.round(_lerp(lo[1][j], hi[1][j], f)));
}
function severityColor(s, alpha = 1) {
  const [r, g, b] = severityRGB(s);
  return `rgba(${r},${g},${b},${alpha})`;
}
// alpha ramp so clean audio recedes and problems advance
function severityAlpha(s) { return 0.4 + 0.6 * clamp(s); }

/* Tyto Risk Score bands (from the model docs):
   🟢 Good < 0.35 · 🟡 Warn 0.35–0.60 · 🔴 Bad > 0.60 */
const RISK_WARN = 0.35;   // noticeable degradation; expect elevated error rates
const RISK_BAD = 0.6;     // severe; downstream failure likely; intervene
const LOUDNESS_LOW = 0.25; // below this, quiet speech is itself a failure mode

function qualityLabel(v) {
  if (v < RISK_WARN) return "Good";
  if (v <= RISK_BAD) return "Warn";
  return "Bad";
}

/* Review status: traffic light keyed to the headline risk score.
   High risk_score = problematic audio = needs review. */
function reviewStatus(q) {
  if (q > RISK_BAD) return { key: "critical", tone: "critical", color: "var(--clay)", glow: "rgba(226,140,124,0.22)", label: "Needs review", needsReview: true };
  if (q >= RISK_WARN) return { key: "warning", tone: "warning", color: "var(--amber)", glow: "rgba(244,185,66,0.20)", label: "Review recommended", needsReview: true };
  return { key: "ok", tone: "positive", color: "var(--teal)", glow: "transparent", label: "No review needed", needsReview: false };
}

/* Traffic-light band color for the sub-dimensions, same thresholds as the risk score:
   green < 0.35, amber 0.35-0.60, red > 0.60. */
function bandColor(v) {
  if (v > RISK_BAD) return "var(--clay)";
  if (v >= RISK_WARN) return "var(--amber)";
  return "var(--teal)";
}

// match audio to a call by filename WITHOUT its extension, so e.g. an
// analysis entry "rec_0001.wav" still matches an uploaded "rec_0001.mp3"
function fileStem(name) { return String(name).replace(/\.[^/.]+$/, "").toLowerCase(); }

/* ---- triage aggregates (docs: mean is weak; prefer fraction-degraded / p95) ---- */
function p95(arr) {
  if (!arr || !arr.length) return 0;
  const s = [...arr].sort((a, b) => a - b);
  return s[Math.min(s.length - 1, Math.floor(0.95 * (s.length - 1)))];
}
function fracDegraded(arr, thr = RISK_WARN) {
  if (!arr || !arr.length) return 0;
  return arr.filter((v) => v >= thr).length / arr.length;
}
function loudnessConcern(v) { return v != null && v < LOUDNESS_LOW; }

/* "Why does it fail?": the worst sub-dimension (per-dimension argmax).
   Loudness is neutral, but consistently-low loudness is itself a failure mode,
   so it enters as its own driver via a deficit-based pseudo-severity. */
function dominantDimension(scores) {
  const cands = SUBDIMS.filter((d) => !d.neutral)
    .map((d) => ({ key: d.key, label: d.label, short: d.short, value: scores[d.key] || 0 }));
  const loud = scores.speaker_loudness;
  if (loud != null) {
    const sev = clamp((LOUDNESS_LOW - loud) / LOUDNESS_LOW);
    cands.push({ key: "speaker_loudness", label: "Low loudness", short: "Low loudness", value: sev, low: true });
  }
  cands.sort((a, b) => b.value - a.value);
  return cands[0] || null;
}

/* ---- formatting ---- */
function fmtTime(sec) {
  sec = Math.max(0, Math.round(sec));
  const m = Math.floor(sec / 60), s = sec % 60;
  return `${m}:${String(s).padStart(2, "0")}`;
}
function fmtScore(v) { return v.toFixed(2); }
function fmtClock(d) {
  return d.toLocaleString("en-US", { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", hour12: false });
}

/* ============================================================
   Analysis JSON loader: wire a real export to this later.
   ONE file carries everything. Shape:
     {
       "model": "Tyto",
       "calls": [
         {
           "file": "rec_0001.wav",
           "duration_sec": 142,
           "frames": {                      // 7 arrays, all in [0,1]
             "risk_score":     [...],
             "speaker_reverb": [...], ...
           }
         }
       ]
     }
   Table values are the per-call averages of each frame array. A call
   may instead supply a flat "scores" object (averages only); we then
   synthesize a believable curve to match. Audio is matched by `file`.
   ============================================================ */
function parseAnalysis(text) {
  const data = JSON.parse(text);
  const arr = Array.isArray(data) ? data : (data.calls || data.recordings || []);
  if (!Array.isArray(arr) || !arr.length) throw new Error("No calls found.");
  return arr.map((row, idx) => {
    const file = row.file || row.filename || `rec_${idx}.wav`;
    const fr = row.frames;
    const hasFrames = fr && Array.isArray(fr.risk_score) && fr.risk_score.length > 1;
    let durationSec = parseFloat(row.duration_sec || row.duration) || 0;
    let frames, scores = {};

    if (hasFrames) {
      const N = fr.risk_score.length;
      if (!durationSec) durationSec = Math.round(N / 7);
      frames = { amp: [] };
      DIM_KEYS.forEach((k) => { frames[k] = (Array.isArray(fr[k]) ? fr[k] : []).map((v) => clamp(+v || 0)); });
      // amplitude envelope isn't a model output, so derive a speech-like shape from loudness
      for (let i = 0; i < N; i++) {
        const t = N > 1 ? i / (N - 1) : 0;
        const loud = frames.speaker_loudness[i] != null ? frames.speaker_loudness[i] : 0.45;
        frames.amp.push(clamp(loud * (0.35 + 0.65 * Math.abs(Math.sin(t * durationSec * 5 + i)))));
      }
      DIM_KEYS.forEach((k) => (scores[k] = frames[k].length ? avg(frames[k]) : 0));
    } else {
      const src = row.scores || row;
      DIM_KEYS.forEach((k) => (scores[k] = clamp(parseFloat(src[k]) || 0)));
      if (!durationSec) durationSec = 120;
      frames = synthFramesFromAverages(scores, durationSec, idx + 1);
    }
    return {
      id: row.id || file,
      file,
      label: row.label || "",
      started: row.started ? new Date(row.started) : new Date(Date.now() - idx * 6e5),
      durationSec,
      outcome: row.outcome || "",
      scores,
      frames,
      driver: dominantDimension(scores),
      metrics: { mean: scores.risk_score, p95: p95(frames.risk_score), fracDegraded: fracDegraded(frames.risk_score) },
      audioUrl: null,
    };
  });
}
// believable temporal curve whose mean ≈ the supplied average
function synthFramesFromAverages(scores, durationSec, seed) {
  const rng = mulberry32((seed * 2654435761) >>> 0);
  const N = Math.max(96, Math.round(durationSec * 7));
  const out = { amp: [] };
  DIM_KEYS.forEach((k) => (out[k] = []));
  for (let i = 0; i < N; i++) {
    const t = i / (N - 1);
    DIM_KEYS.forEach((k) => {
      const m = scores[k];
      const wobble = (rng() - 0.5) * 0.18 + Math.sin(t * 9 + seed) * 0.06;
      out[k].push(clamp(m + wobble * (0.4 + m)));
    });
    out.amp.push(clamp(scores.speaker_loudness * (0.35 + 0.65 * Math.abs(Math.sin(t * durationSec * 5 + i)))));
  }
  return out;
}

const CALLS = buildCalls(); // built-in fallback set (used only if the bundled demo can't be fetched)

/* ---- bundled demo: real Tyto-analysed recordings + audio, served statically ----
   So a visitor can explore the product (with playback) without uploading anything. */
const DEMO_URL = "assets/demo/analysis.json";
const DEMO_AUDIO_DIR = "assets/demo/";
function loadDemo() {
  return fetch(DEMO_URL)
    .then((r) => { if (!r.ok) throw new Error("demo " + r.status); return r.text(); })
    .then((text) => parseAnalysis(text).map((c) => ({ ...c, audioUrl: DEMO_AUDIO_DIR + c.file })));
}

window.AIData = {
  DIMS, SUBDIMS, DIM_KEYS, CALLS, OUTCOME_TONE,
  RISK_WARN, RISK_BAD, LOUDNESS_LOW,
  severityColor, severityRGB, severityAlpha, qualityLabel, reviewStatus, bandColor,
  dominantDimension, loudnessConcern, p95, fracDegraded,
  fmtTime, fmtScore, fmtClock, parseAnalysis, loadDemo, fileStem, clamp,
};
})();
