@rydr/game-sdk 3.18.0 → 3.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -9,5 +9,5 @@
9
9
  export declare const RYDR_PROTOCOL_VERSION: 16;
10
10
  /** Semver of this SDK build. Sent in the handshake for telemetry/debugging.
11
11
  * (Bumped to 2.0.0 by `npm version major` on release — see CHANGELOG [Unreleased].) */
12
- export declare const RYDR_SDK_VERSION = "3.18.0";
12
+ export declare const RYDR_SDK_VERSION = "3.19.0";
13
13
  //# sourceMappingURL=version.d.ts.map
@@ -52,5 +52,5 @@
52
52
  export const RYDR_PROTOCOL_VERSION = 16;
53
53
  /** Semver of this SDK build. Sent in the handshake for telemetry/debugging.
54
54
  * (Bumped to 2.0.0 by `npm version major` on release — see CHANGELOG [Unreleased].) */
55
- export const RYDR_SDK_VERSION = "3.18.0";
55
+ export const RYDR_SDK_VERSION = "3.19.0";
56
56
  //# sourceMappingURL=version.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/sound-gallery/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,OAAO,EAAoB,KAAK,UAAU,EAAsB,KAAK,WAAW,EAAE,MAAM,oBAAoB,CAAC;AAG7G,+FAA+F;AAC/F,MAAM,WAAW,mBAAmB;IAClC,QAAQ,CAAC,QAAQ,EAAE;QAAE,OAAO,CAAC,EAAE,OAAO,CAAA;KAAE,CAAC;IACzC,UAAU,IAAI,OAAO,CAAC,UAAU,EAAE,CAAC,CAAC;IACpC,SAAS,CAAC,KAAK,EAAE,UAAU,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC5C,WAAW,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACxC,0DAA0D;IAC1D,UAAU,CAAC,KAAK,EAAE;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,IAAI,CAAA;KAAE,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;IACrE,kFAAkF;IAClF,OAAO,CAAC,MAAM,EAAE,UAAU,EAAE,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;IAE/C,WAAW,IAAI,OAAO,CAAC,WAAW,EAAE,CAAC,CAAC;IACtC,UAAU,CAAC,MAAM,EAAE,WAAW,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC/C,YAAY,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CACzC;AAED,MAAM,WAAW,mBAAmB;IAClC,OAAO,EAAE,mBAAmB,CAAC;CAC9B;AACD,MAAM,WAAW,YAAY;IAC3B,OAAO,IAAI,IAAI,CAAC;CACjB;AAoGD,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,WAAW,EAAE,IAAI,EAAE,mBAAmB,GAAG,YAAY,CAwoC5F"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/sound-gallery/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,OAAO,EAAoB,KAAK,UAAU,EAAsB,KAAK,WAAW,EAAE,MAAM,oBAAoB,CAAC;AAkB7G,+FAA+F;AAC/F,MAAM,WAAW,mBAAmB;IAClC,QAAQ,CAAC,QAAQ,EAAE;QAAE,OAAO,CAAC,EAAE,OAAO,CAAA;KAAE,CAAC;IACzC,UAAU,IAAI,OAAO,CAAC,UAAU,EAAE,CAAC,CAAC;IACpC,SAAS,CAAC,KAAK,EAAE,UAAU,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC5C,WAAW,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACxC,0DAA0D;IAC1D,UAAU,CAAC,KAAK,EAAE;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,IAAI,CAAA;KAAE,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;IACrE,kFAAkF;IAClF,OAAO,CAAC,MAAM,EAAE,UAAU,EAAE,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;IAE/C,WAAW,IAAI,OAAO,CAAC,WAAW,EAAE,CAAC,CAAC;IACtC,UAAU,CAAC,MAAM,EAAE,WAAW,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC/C,YAAY,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CACzC;AAED,MAAM,WAAW,mBAAmB;IAClC,OAAO,EAAE,mBAAmB,CAAC;CAC9B;AACD,MAAM,WAAW,YAAY;IAC3B,OAAO,IAAI,IAAI,CAAC;CACjB;AAqHD,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,WAAW,EAAE,IAAI,EAAE,mBAAmB,GAAG,YAAY,CAy1C5F"}
@@ -12,7 +12,21 @@
12
12
  * browser.
13
13
  */
14
14
  import { SOUND_CATEGORIES } from "../sounds/types.js";
15
- import { computePeaks, cropBuffer, decodeAudio, encodeMp3, peakDb, trimLeadingSilence } from "../sounds/audio-utils.js";
15
+ import { computePeaks, cropBuffer, dbToGain, decodeAudio, encodeMp3, integratedLufs, peakDb, trimLeadingSilence } from "../sounds/audio-utils.js";
16
+ import { STANDARD_BINDINGS, STANDARD_CUES } from "../sounds/soundboard.js";
17
+ /** Per-category target integrated loudness (LUFS). "Normalize" trims a sound's gainDb to hit its
18
+ * channel's target so the library is level-consistent. UI/SFX/voice punchier; beds sit lower. */
19
+ const LUFS_TARGETS = {
20
+ sfx: -16,
21
+ ui: -16,
22
+ voice: -16,
23
+ music: -20,
24
+ ambience: -20,
25
+ };
26
+ /** Allowed range for the gain trim slider + Normalize, in dB. */
27
+ const GAIN_MIN = -24;
28
+ const GAIN_MAX = 12;
29
+ const clampGain = (db) => Math.min(GAIN_MAX, Math.max(GAIN_MIN, db));
16
30
  const CSS = `
17
31
  .rydr-sg{position:fixed;inset:0;display:flex;flex-direction:column;font:14px/1.5 system-ui,sans-serif;color:#e7e9ee;background:#0d0f14}
18
32
  .rydr-sg *{box-sizing:border-box}
@@ -83,6 +97,17 @@ const CSS = `
83
97
  .rydr-sg-fv-row .nm{font-weight:600;cursor:pointer}
84
98
  .rydr-sg-fv-row .nm:hover{color:#6fb4ff}
85
99
  .rydr-sg-fv-row .c{margin-left:auto;font-size:10px;text-transform:uppercase;letter-spacing:.06em;color:#8b93a7;border:1px solid #2a3242;border-radius:999px;padding:1px 7px}
100
+ .rydr-sg-gain{display:flex;align-items:center;gap:10px}
101
+ .rydr-sg-gain input[type=range]{width:150px;accent-color:#3f74ee}
102
+ .rydr-sg-gain input[type=number]{width:64px;padding:7px 8px;border-radius:8px;border:1px solid #2a3242;background:#12151d;color:#e7e9ee;font:inherit}
103
+ .rydr-sg-ref{margin:12px 0;padding-top:12px;border-top:1px solid #1a1e28}
104
+ .rydr-sg-ref-lbl{font-size:11px;text-transform:uppercase;letter-spacing:.06em;color:#8b93a7;margin-bottom:8px}
105
+ .rydr-sg-ref-row{display:flex;gap:8px;flex-wrap:wrap;align-items:center}
106
+ .rydr-sg-chip{padding:6px 11px;border-radius:999px;border:1px solid #2a3242;background:#12151d;color:#c4cbd8;cursor:pointer;font:inherit;font-size:12px}
107
+ .rydr-sg-chip:hover{border-color:#3f74ee;color:#fff}
108
+ .rydr-sg-chip.on{background:#16233c;border-color:#3f74ee;color:#fff}
109
+ .rydr-sg-chip:disabled{opacity:.5;cursor:default}
110
+ .rydr-sg-ref-none{color:#6b7385;font-size:12px}
86
111
  `;
87
112
  const STYLE_ID = "rydr-sg-style";
88
113
  function ensureStyle() {
@@ -102,6 +127,13 @@ function el(tag, cls, text) {
102
127
  return n;
103
128
  }
104
129
  const fmtTime = (s) => `${Math.floor(s / 60)}:${(s % 60).toFixed(2).padStart(5, "0")}`;
130
+ /** Short chip label for a standard cue id, e.g. "nav.confirm" → "Confirm", "record.wr" → "WR". */
131
+ const cueLabel = (cueId) => {
132
+ const leaf = (cueId.split(".").pop() ?? cueId).replace(/_/g, " ");
133
+ if (leaf.length <= 3)
134
+ return leaf.toUpperCase(); // pb, wr
135
+ return leaf.charAt(0).toUpperCase() + leaf.slice(1);
136
+ };
105
137
  const safeKey = (s) => s.toLowerCase().replace(/\.[a-z0-9]+$/, "").replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "").slice(0, 48) || "sound";
106
138
  export function mountSoundGallery(root, opts) {
107
139
  ensureStyle();
@@ -116,13 +148,74 @@ export function mountSoundGallery(root, opts) {
116
148
  let cropBufKey = null; // which asset.key `cropBuf` was decoded from (guards selection races)
117
149
  let regionStart = 0;
118
150
  let regionEnd = 0;
119
- const audio = new Audio();
151
+ const audio = new Audio(); // edited-sound preview
152
+ const refAudio = new Audio(); // reference-sound preview (A/B)
153
+ // ── Web Audio preview graph ──
154
+ // The preview routes through a mixer that MIRRORS the runtime SoundBank (per-category bus →
155
+ // master → limiter), so the Gain (dB) trim is audible here and the editor sounds like the game.
156
+ // Built lazily on the first play (inside a user gesture, so the AudioContext can resume).
157
+ let previewCtx = null;
158
+ let previewMaster = null;
159
+ const previewBuses = new Map();
160
+ let mainGain = null; // edited-sound trim; updated live by the gain slider
161
+ let refGain = null; // reference-sound trim
162
+ function ensurePreviewGraph() {
163
+ if (previewCtx && previewMaster)
164
+ return previewCtx;
165
+ const AC = typeof window !== "undefined"
166
+ ? window.AudioContext ?? window.webkitAudioContext
167
+ : undefined;
168
+ if (!AC)
169
+ return null;
170
+ const ctx = new AC();
171
+ const master = ctx.createGain();
172
+ const lim = ctx.createDynamicsCompressor(); // same settings as SoundBank's master limiter
173
+ lim.threshold.value = -3;
174
+ lim.knee.value = 0;
175
+ lim.ratio.value = 20;
176
+ lim.attack.value = 0.002;
177
+ lim.release.value = 0.15;
178
+ master.connect(lim).connect(ctx.destination);
179
+ for (const cat of SOUND_CATEGORIES) {
180
+ const g = ctx.createGain();
181
+ g.connect(master);
182
+ previewBuses.set(cat, g);
183
+ }
184
+ // Each media element gets ONE source (a MediaElementSource is one-per-element for the element's
185
+ // life) → its own trim gain → the bus chosen at play time.
186
+ mainGain = ctx.createGain();
187
+ ctx.createMediaElementSource(audio).connect(mainGain);
188
+ refGain = ctx.createGain();
189
+ ctx.createMediaElementSource(refAudio).connect(refGain);
190
+ previewCtx = ctx;
191
+ previewMaster = master;
192
+ return ctx;
193
+ }
194
+ /** Route a trim gain to a category bus (falling back to master) and set its level in dB. */
195
+ function routeGain(g, cat, db) {
196
+ try {
197
+ g.disconnect();
198
+ }
199
+ catch {
200
+ /* not connected yet */
201
+ }
202
+ g.gain.value = dbToGain(db);
203
+ g.connect(previewBuses.get(cat) ?? previewMaster);
204
+ }
205
+ /** Live-update the edited-sound trim while the gain slider moves (no reconnect). */
206
+ function liveGain(db) {
207
+ if (previewCtx && mainGain)
208
+ mainGain.gain.setTargetAtTime(dbToGain(db), previewCtx.currentTime, 0.01);
209
+ }
120
210
  // Playback cursor (a seekable playhead over the waveform). Set while a detail view is mounted.
121
211
  let waveCursor = null;
122
212
  let cursorDur = 0; // duration the waveform spans, in seconds (the full clip)
123
213
  let cursorSec = 0; // playback position / start, in full-clip seconds
124
214
  let cursorRaf = null;
125
215
  let playBtnRef = null; // current toolbar Play button, for its label
216
+ let refKey = null; // reference sound focused for A/B compare (reset per selection)
217
+ let refChips = null; // reference-chip buttons, to reflect focus
218
+ let abOnRef = false; // A/B toggle side: true = reference is the current side, false = edited sound
126
219
  // Folder hierarchy (entities) + tree state. `expanded` holds open folder ids; roots auto-open once.
127
220
  let folders = [];
128
221
  const expanded = new Set();
@@ -193,6 +286,7 @@ export function mountSoundGallery(root, opts) {
193
286
  }
194
287
  function stopPreview() {
195
288
  audio.pause();
289
+ refAudio.pause();
196
290
  stopCursor();
197
291
  }
198
292
  // ── Playback cursor & transport ──
@@ -222,6 +316,16 @@ export function mountSoundGallery(root, opts) {
222
316
  const clip = asset?.clips[0];
223
317
  if (!clip)
224
318
  return;
319
+ refAudio.pause(); // A/B: only one preview sounds at a time
320
+ abOnRef = false;
321
+ // Route through the preview mixer so the Gain (dB) trim is audible (mirrors the game).
322
+ const ctx = ensurePreviewGraph();
323
+ if (ctx) {
324
+ if (ctx.state === "suspended")
325
+ void ctx.resume().catch(() => undefined);
326
+ if (mainGain)
327
+ routeGain(mainGain, asset.category, asset.gainDb ?? 0);
328
+ }
225
329
  if (audio.src !== clip.url)
226
330
  audio.src = clip.url;
227
331
  if (cursorSec >= regionEnd - 0.001)
@@ -284,6 +388,55 @@ export function mountSoundGallery(root, opts) {
284
388
  placeCursor(regionStart);
285
389
  refreshPlayBtn();
286
390
  }
391
+ // ── Reference A/B: audition a `sys.*` standard-cue exemplar at matched monitoring ──
392
+ /** Play a reference sound through the same mixer (own gainDb + bus), pausing the edited preview. */
393
+ function playReference(ref) {
394
+ const clip = ref.clips[0];
395
+ if (!clip)
396
+ return;
397
+ pausePlayback(); // only one preview at a time
398
+ refKey = ref.key;
399
+ abOnRef = true;
400
+ if (refChips)
401
+ for (const [k, btn] of refChips)
402
+ btn.classList.toggle("on", k === ref.key);
403
+ const ctx = ensurePreviewGraph();
404
+ if (ctx) {
405
+ if (ctx.state === "suspended")
406
+ void ctx.resume().catch(() => undefined);
407
+ if (refGain)
408
+ routeGain(refGain, ref.category, ref.gainDb ?? 0);
409
+ }
410
+ if (refAudio.src !== clip.url)
411
+ refAudio.src = clip.url;
412
+ try {
413
+ refAudio.currentTime = 0;
414
+ }
415
+ catch {
416
+ /* noop */
417
+ }
418
+ void refAudio.play().catch(() => undefined);
419
+ }
420
+ /** A/B toggle: alternate the edited sound ↔ the focused reference so the ear can match levels. */
421
+ function toggleAb() {
422
+ if (abOnRef) {
423
+ playFromCursor(); // → edited side (also flips abOnRef=false)
424
+ return;
425
+ }
426
+ const ref = (refKey ? sounds.find((s) => s.key === refKey) : null) ?? firstRef();
427
+ if (ref)
428
+ playReference(ref); // → reference side (also flips abOnRef=true)
429
+ }
430
+ /** The first published reference sound (first bound sys.* cue that resolves), or null. */
431
+ function firstRef() {
432
+ for (const cueId of Object.keys(STANDARD_CUES)) {
433
+ const refK = STANDARD_BINDINGS[cueId];
434
+ const found = refK ? sounds.find((s) => s.key === refK) : undefined;
435
+ if (found && found.key !== selectedKey)
436
+ return found;
437
+ }
438
+ return null;
439
+ }
287
440
  // ── Sidebar: folder hierarchy (entities) → sounds. Folders are authoring-only. ──
288
441
  const UNFILED = "__unfiled__"; // pseudo-folder id for sounds with no folderId
289
442
  const errMsg = (e) => (e instanceof Error ? e.message : String(e));
@@ -807,6 +960,9 @@ export function mountSoundGallery(root, opts) {
807
960
  // ── Detail / editor ──
808
961
  function renderDetail() {
809
962
  stopPreview();
963
+ refKey = null;
964
+ refChips = null;
965
+ abOnRef = false;
810
966
  if (selectedFolderId) {
811
967
  renderFolderView(selectedFolderId);
812
968
  return;
@@ -868,9 +1024,40 @@ export function mountSoundGallery(root, opts) {
868
1024
  row2.appendChild(folderField);
869
1025
  row2.appendChild(mkField("Label", asset.label ?? "", (v) => void patch(asset, { label: v || undefined })));
870
1026
  row2.appendChild(mkField("Tags (comma-sep)", (asset.tags ?? []).join(", "), (v) => void patch(asset, { tags: v ? v.split(",").map((t) => t.trim()).filter(Boolean) : undefined })));
871
- row2.appendChild(mkField("Gain (dB)", String(asset.gainDb ?? 0), (v) => void patch(asset, { gainDb: Number(v) || 0 }), {
872
- width: "80px",
873
- }));
1027
+ // Gain trim a live slider + numeric readout. Editing either updates the preview mixer's gain
1028
+ // immediately (audible while playing) and persists on release.
1029
+ const gainField = el("div", "rydr-sg-field");
1030
+ gainField.appendChild(el("label", undefined, "Gain (dB)"));
1031
+ const gwrap = el("div", "rydr-sg-gain");
1032
+ const gslider = el("input");
1033
+ gslider.type = "range";
1034
+ gslider.min = String(GAIN_MIN);
1035
+ gslider.max = String(GAIN_MAX);
1036
+ gslider.step = "0.5";
1037
+ gslider.value = String(asset.gainDb ?? 0);
1038
+ gslider.disabled = !isAdmin;
1039
+ const gnum = el("input");
1040
+ gnum.type = "number";
1041
+ gnum.min = String(GAIN_MIN);
1042
+ gnum.max = String(GAIN_MAX);
1043
+ gnum.step = "0.5";
1044
+ gnum.value = String(asset.gainDb ?? 0);
1045
+ gnum.disabled = !isAdmin;
1046
+ const applyGain = (raw, persist) => {
1047
+ const db = clampGain(Number.isFinite(raw) ? raw : 0);
1048
+ gslider.value = String(db);
1049
+ gnum.value = String(db);
1050
+ liveGain(db); // audible immediately on the currently-playing edited preview
1051
+ if (persist)
1052
+ void patch(asset, { gainDb: db });
1053
+ };
1054
+ gslider.addEventListener("input", () => applyGain(Number(gslider.value), false));
1055
+ gslider.addEventListener("change", () => applyGain(Number(gslider.value), true));
1056
+ gnum.addEventListener("change", () => applyGain(Number(gnum.value), true));
1057
+ gwrap.appendChild(gslider);
1058
+ gwrap.appendChild(gnum);
1059
+ gainField.appendChild(gwrap);
1060
+ row2.appendChild(gainField);
874
1061
  detail.appendChild(row2);
875
1062
  // Waveform + crop
876
1063
  const waveWrap = el("div", "rydr-sg-wave");
@@ -994,6 +1181,10 @@ export function mountSoundGallery(root, opts) {
994
1181
  stopBtn.addEventListener("click", () => stopPlayback());
995
1182
  bar.appendChild(stopBtn);
996
1183
  if (isAdmin) {
1184
+ const normBtn = el("button", "rydr-sg-btn", "⇩ Normalize");
1185
+ normBtn.title = `Measure loudness and trim gain to ${LUFS_TARGETS[asset.category]} LUFS (${asset.category})`;
1186
+ normBtn.addEventListener("click", () => void doNormalize(asset, normBtn, gslider, gnum));
1187
+ bar.appendChild(normBtn);
997
1188
  const cropBtn = el("button", "rydr-sg-btn primary", "✂ Crop & save");
998
1189
  cropBtn.addEventListener("click", () => void doCrop(asset, cropBtn));
999
1190
  bar.appendChild(cropBtn);
@@ -1005,9 +1196,43 @@ export function mountSoundGallery(root, opts) {
1005
1196
  bar.appendChild(delBtn);
1006
1197
  }
1007
1198
  detail.appendChild(bar);
1199
+ // Reference A/B — audition against the platform's sys.* standard-cue exemplars (whichever are
1200
+ // published), at matched monitoring, so levels can be tuned by ear against known-good sounds.
1201
+ const refWrap = el("div", "rydr-sg-ref");
1202
+ refWrap.appendChild(el("div", "rydr-sg-ref-lbl", "Compare against reference"));
1203
+ const refRow = el("div", "rydr-sg-ref-row");
1204
+ refChips = new Map();
1205
+ for (const [cueId, def] of Object.entries(STANDARD_CUES)) {
1206
+ const refK = STANDARD_BINDINGS[cueId];
1207
+ const refAsset = refK ? sounds.find((s) => s.key === refK) : undefined;
1208
+ if (!refAsset || refAsset.key === asset.key)
1209
+ continue; // only published refs; never self
1210
+ const chip = el("button", "rydr-sg-chip", cueLabel(cueId));
1211
+ chip.title = def.description ?? cueId;
1212
+ chip.addEventListener("click", () => playReference(refAsset));
1213
+ refRow.appendChild(chip);
1214
+ refChips.set(refAsset.key, chip);
1215
+ }
1216
+ if (refChips.size === 0) {
1217
+ refRow.appendChild(el("span", "rydr-sg-ref-none", "No sys.* reference sounds published yet."));
1218
+ }
1219
+ else {
1220
+ const abBtn = el("button", "rydr-sg-btn", "⇄ A/B");
1221
+ abBtn.title = "Alternate this sound and the focused reference";
1222
+ abBtn.addEventListener("click", () => toggleAb());
1223
+ refRow.appendChild(abBtn);
1224
+ }
1225
+ refWrap.appendChild(refRow);
1226
+ detail.appendChild(refWrap);
1008
1227
  // Meta footer
1009
1228
  const meta = el("div", "rydr-sg-meta");
1010
- meta.appendChild(el("div", undefined, `${clip?.filename ?? "—"} · ${dur ? fmtTime(dur) : "?"} · ${asset.loudness?.peakDb != null ? `peak ${asset.loudness.peakDb} dBFS` : "peak ?"}`));
1229
+ const lufs = asset.loudness?.integratedLufs;
1230
+ const peakStr = asset.loudness?.peakDb != null ? `peak ${asset.loudness.peakDb} dBFS` : "peak ?";
1231
+ let lufsStr = lufs != null && Number.isFinite(lufs) ? `${lufs} LUFS` : "LUFS ?";
1232
+ if (lufs != null && Number.isFinite(lufs) && asset.gainDb) {
1233
+ lufsStr += ` (→ ${Math.round((lufs + asset.gainDb) * 10) / 10} w/ trim)`;
1234
+ }
1235
+ meta.appendChild(el("div", undefined, `${clip?.filename ?? "—"} · ${dur ? fmtTime(dur) : "?"} · ${peakStr} · ${lufsStr}`));
1011
1236
  if (clip) {
1012
1237
  const link = el("a", undefined, "open file ↗");
1013
1238
  link.href = clip.url;
@@ -1075,7 +1300,7 @@ export function mountSoundGallery(root, opts) {
1075
1300
  ...asset,
1076
1301
  clips: [{ url, filename, durationSec: cropped.duration }],
1077
1302
  peaks,
1078
- loudness: { ...asset.loudness, peakDb: peakDb(cropped) },
1303
+ loudness: { ...asset.loudness, peakDb: peakDb(cropped), integratedLufs: await integratedLufs(cropped) },
1079
1304
  updatedAt: Date.now(),
1080
1305
  };
1081
1306
  await session.saveSound(next);
@@ -1094,6 +1319,35 @@ export function mountSoundGallery(root, opts) {
1094
1319
  btn.textContent = "✂ Crop & save";
1095
1320
  }
1096
1321
  }
1322
+ /** Measure integrated loudness (LUFS) and trim gainDb so the sound hits its channel's target. */
1323
+ async function doNormalize(asset, btn, gslider, gnum) {
1324
+ const buf = await ensureCropBuf(asset);
1325
+ if (!buf)
1326
+ return;
1327
+ btn.disabled = true;
1328
+ btn.textContent = "Measuring…";
1329
+ try {
1330
+ const lufs = await integratedLufs(buf);
1331
+ if (!Number.isFinite(lufs)) {
1332
+ toast("Too quiet to measure loudness.");
1333
+ return;
1334
+ }
1335
+ const gainDb = clampGain(Math.round((LUFS_TARGETS[asset.category] - lufs) * 10) / 10);
1336
+ gslider.value = String(gainDb);
1337
+ gnum.value = String(gainDb);
1338
+ liveGain(gainDb);
1339
+ await patch(asset, { gainDb, loudness: { ...asset.loudness, integratedLufs: lufs } });
1340
+ renderDetail(); // refresh the footer's LUFS readout
1341
+ toast(`Measured ${lufs} LUFS → trim ${gainDb > 0 ? "+" : ""}${gainDb} dB (target ${LUFS_TARGETS[asset.category]}).`);
1342
+ }
1343
+ catch (e) {
1344
+ toast(`Normalize failed: ${e instanceof Error ? e.message : String(e)}`);
1345
+ }
1346
+ finally {
1347
+ btn.disabled = false;
1348
+ btn.textContent = "⇩ Normalize";
1349
+ }
1350
+ }
1097
1351
  async function patch(asset, changes) {
1098
1352
  const next = { ...asset, ...changes, updatedAt: Date.now() };
1099
1353
  try {
@@ -1195,7 +1449,7 @@ export function mountSoundGallery(root, opts) {
1195
1449
  folderId: target,
1196
1450
  clips: [{ url, filename, durationSec: buf.duration }],
1197
1451
  peaks: computePeaks(buf),
1198
- loudness: { peakDb: peakDb(buf) },
1452
+ loudness: { peakDb: peakDb(buf), integratedLufs: await integratedLufs(buf) },
1199
1453
  updatedAt: Date.now(),
1200
1454
  };
1201
1455
  await session.saveSound(asset);
@@ -1292,6 +1546,10 @@ export function mountSoundGallery(root, opts) {
1292
1546
  closeMenu();
1293
1547
  document.removeEventListener("keydown", onKey);
1294
1548
  document.removeEventListener("click", onDocClick);
1549
+ if (previewCtx)
1550
+ void previewCtx.close().catch(() => undefined);
1551
+ previewCtx = null;
1552
+ previewMaster = null;
1295
1553
  container.remove();
1296
1554
  },
1297
1555
  };