@rydr/game-sdk 3.18.0 → 3.19.1

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.1";
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.1";
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,CAq2C5F"}
@@ -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,86 @@ 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
+ // Required so routing a cross-origin R2 clip through a MediaElementSource isn't read as silence
154
+ // (a CORS-tainted stream yields zeros). Must be set before the src loads. The R2 bucket allows
155
+ // anonymous CORS GETs (same as the runtime music-stream path).
156
+ audio.crossOrigin = "anonymous";
157
+ refAudio.crossOrigin = "anonymous";
158
+ // ── Web Audio preview graph ──
159
+ // The preview routes through a mixer that MIRRORS the runtime SoundBank (per-category bus →
160
+ // master → limiter), so the Gain (dB) trim is audible here and the editor sounds like the game.
161
+ // Built lazily on the first play (inside a user gesture, so the AudioContext can resume).
162
+ let previewCtx = null;
163
+ let previewMaster = null;
164
+ const previewBuses = new Map();
165
+ let mainGain = null; // edited-sound trim; updated live by the gain slider
166
+ let refGain = null; // reference-sound trim
167
+ function ensurePreviewGraph() {
168
+ if (previewCtx && previewMaster)
169
+ return previewCtx;
170
+ const AC = typeof window !== "undefined"
171
+ ? window.AudioContext ?? window.webkitAudioContext
172
+ : undefined;
173
+ if (!AC)
174
+ return null;
175
+ const ctx = new AC();
176
+ const master = ctx.createGain();
177
+ const lim = ctx.createDynamicsCompressor(); // same settings as SoundBank's master limiter
178
+ lim.threshold.value = -3;
179
+ lim.knee.value = 0;
180
+ lim.ratio.value = 20;
181
+ lim.attack.value = 0.002;
182
+ lim.release.value = 0.15;
183
+ master.connect(lim).connect(ctx.destination);
184
+ for (const cat of SOUND_CATEGORIES) {
185
+ const g = ctx.createGain();
186
+ g.connect(master);
187
+ previewBuses.set(cat, g);
188
+ }
189
+ // Each media element gets ONE source (a MediaElementSource is one-per-element for the element's
190
+ // life) → its own trim gain → the bus chosen at play time.
191
+ mainGain = ctx.createGain();
192
+ ctx.createMediaElementSource(audio).connect(mainGain);
193
+ refGain = ctx.createGain();
194
+ ctx.createMediaElementSource(refAudio).connect(refGain);
195
+ previewCtx = ctx;
196
+ previewMaster = master;
197
+ return ctx;
198
+ }
199
+ /** Route a trim gain to a category bus (falling back to master) and set its level in dB. */
200
+ function routeGain(g, cat, db) {
201
+ try {
202
+ g.disconnect();
203
+ }
204
+ catch {
205
+ /* not connected yet */
206
+ }
207
+ g.gain.value = dbToGain(db);
208
+ g.connect(previewBuses.get(cat) ?? previewMaster);
209
+ }
210
+ /** Live-update the edited-sound trim while the gain slider moves (no reconnect). */
211
+ function liveGain(db) {
212
+ if (previewCtx && mainGain)
213
+ mainGain.gain.setTargetAtTime(dbToGain(db), previewCtx.currentTime, 0.01);
214
+ }
215
+ /** Build + resume the graph under a user gesture (selecting an item, pressing Play) so the
216
+ * AudioContext is running by the time a clip plays — browsers start it suspended otherwise. */
217
+ function unlockAudio() {
218
+ const ctx = ensurePreviewGraph();
219
+ if (ctx && ctx.state === "suspended")
220
+ void ctx.resume().catch(() => undefined);
221
+ }
120
222
  // Playback cursor (a seekable playhead over the waveform). Set while a detail view is mounted.
121
223
  let waveCursor = null;
122
224
  let cursorDur = 0; // duration the waveform spans, in seconds (the full clip)
123
225
  let cursorSec = 0; // playback position / start, in full-clip seconds
124
226
  let cursorRaf = null;
125
227
  let playBtnRef = null; // current toolbar Play button, for its label
228
+ let refKey = null; // reference sound focused for A/B compare (reset per selection)
229
+ let refChips = null; // reference-chip buttons, to reflect focus
230
+ let abOnRef = false; // A/B toggle side: true = reference is the current side, false = edited sound
126
231
  // Folder hierarchy (entities) + tree state. `expanded` holds open folder ids; roots auto-open once.
127
232
  let folders = [];
128
233
  const expanded = new Set();
@@ -193,6 +298,7 @@ export function mountSoundGallery(root, opts) {
193
298
  }
194
299
  function stopPreview() {
195
300
  audio.pause();
301
+ refAudio.pause();
196
302
  stopCursor();
197
303
  }
198
304
  // ── Playback cursor & transport ──
@@ -222,6 +328,16 @@ export function mountSoundGallery(root, opts) {
222
328
  const clip = asset?.clips[0];
223
329
  if (!clip)
224
330
  return;
331
+ refAudio.pause(); // A/B: only one preview sounds at a time
332
+ abOnRef = false;
333
+ // Route through the preview mixer so the Gain (dB) trim is audible (mirrors the game).
334
+ const ctx = ensurePreviewGraph();
335
+ if (ctx) {
336
+ if (ctx.state === "suspended")
337
+ void ctx.resume().catch(() => undefined);
338
+ if (mainGain)
339
+ routeGain(mainGain, asset.category, asset.gainDb ?? 0);
340
+ }
225
341
  if (audio.src !== clip.url)
226
342
  audio.src = clip.url;
227
343
  if (cursorSec >= regionEnd - 0.001)
@@ -284,6 +400,55 @@ export function mountSoundGallery(root, opts) {
284
400
  placeCursor(regionStart);
285
401
  refreshPlayBtn();
286
402
  }
403
+ // ── Reference A/B: audition a `sys.*` standard-cue exemplar at matched monitoring ──
404
+ /** Play a reference sound through the same mixer (own gainDb + bus), pausing the edited preview. */
405
+ function playReference(ref) {
406
+ const clip = ref.clips[0];
407
+ if (!clip)
408
+ return;
409
+ pausePlayback(); // only one preview at a time
410
+ refKey = ref.key;
411
+ abOnRef = true;
412
+ if (refChips)
413
+ for (const [k, btn] of refChips)
414
+ btn.classList.toggle("on", k === ref.key);
415
+ const ctx = ensurePreviewGraph();
416
+ if (ctx) {
417
+ if (ctx.state === "suspended")
418
+ void ctx.resume().catch(() => undefined);
419
+ if (refGain)
420
+ routeGain(refGain, ref.category, ref.gainDb ?? 0);
421
+ }
422
+ if (refAudio.src !== clip.url)
423
+ refAudio.src = clip.url;
424
+ try {
425
+ refAudio.currentTime = 0;
426
+ }
427
+ catch {
428
+ /* noop */
429
+ }
430
+ void refAudio.play().catch(() => undefined);
431
+ }
432
+ /** A/B toggle: alternate the edited sound ↔ the focused reference so the ear can match levels. */
433
+ function toggleAb() {
434
+ if (abOnRef) {
435
+ playFromCursor(); // → edited side (also flips abOnRef=false)
436
+ return;
437
+ }
438
+ const ref = (refKey ? sounds.find((s) => s.key === refKey) : null) ?? firstRef();
439
+ if (ref)
440
+ playReference(ref); // → reference side (also flips abOnRef=true)
441
+ }
442
+ /** The first published reference sound (first bound sys.* cue that resolves), or null. */
443
+ function firstRef() {
444
+ for (const cueId of Object.keys(STANDARD_CUES)) {
445
+ const refK = STANDARD_BINDINGS[cueId];
446
+ const found = refK ? sounds.find((s) => s.key === refK) : undefined;
447
+ if (found && found.key !== selectedKey)
448
+ return found;
449
+ }
450
+ return null;
451
+ }
287
452
  // ── Sidebar: folder hierarchy (entities) → sounds. Folders are authoring-only. ──
288
453
  const UNFILED = "__unfiled__"; // pseudo-folder id for sounds with no folderId
289
454
  const errMsg = (e) => (e instanceof Error ? e.message : String(e));
@@ -514,6 +679,7 @@ export function mountSoundGallery(root, opts) {
514
679
  }
515
680
  async function selectSound(key) {
516
681
  stopPreview();
682
+ unlockAudio(); // this call runs under the item click — resume the context now
517
683
  selectedKey = key;
518
684
  selectedFolderId = null;
519
685
  cropBuf = null;
@@ -807,6 +973,9 @@ export function mountSoundGallery(root, opts) {
807
973
  // ── Detail / editor ──
808
974
  function renderDetail() {
809
975
  stopPreview();
976
+ refKey = null;
977
+ refChips = null;
978
+ abOnRef = false;
810
979
  if (selectedFolderId) {
811
980
  renderFolderView(selectedFolderId);
812
981
  return;
@@ -868,9 +1037,40 @@ export function mountSoundGallery(root, opts) {
868
1037
  row2.appendChild(folderField);
869
1038
  row2.appendChild(mkField("Label", asset.label ?? "", (v) => void patch(asset, { label: v || undefined })));
870
1039
  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
- }));
1040
+ // Gain trim a live slider + numeric readout. Editing either updates the preview mixer's gain
1041
+ // immediately (audible while playing) and persists on release.
1042
+ const gainField = el("div", "rydr-sg-field");
1043
+ gainField.appendChild(el("label", undefined, "Gain (dB)"));
1044
+ const gwrap = el("div", "rydr-sg-gain");
1045
+ const gslider = el("input");
1046
+ gslider.type = "range";
1047
+ gslider.min = String(GAIN_MIN);
1048
+ gslider.max = String(GAIN_MAX);
1049
+ gslider.step = "0.5";
1050
+ gslider.value = String(asset.gainDb ?? 0);
1051
+ gslider.disabled = !isAdmin;
1052
+ const gnum = el("input");
1053
+ gnum.type = "number";
1054
+ gnum.min = String(GAIN_MIN);
1055
+ gnum.max = String(GAIN_MAX);
1056
+ gnum.step = "0.5";
1057
+ gnum.value = String(asset.gainDb ?? 0);
1058
+ gnum.disabled = !isAdmin;
1059
+ const applyGain = (raw, persist) => {
1060
+ const db = clampGain(Number.isFinite(raw) ? raw : 0);
1061
+ gslider.value = String(db);
1062
+ gnum.value = String(db);
1063
+ liveGain(db); // audible immediately on the currently-playing edited preview
1064
+ if (persist)
1065
+ void patch(asset, { gainDb: db });
1066
+ };
1067
+ gslider.addEventListener("input", () => applyGain(Number(gslider.value), false));
1068
+ gslider.addEventListener("change", () => applyGain(Number(gslider.value), true));
1069
+ gnum.addEventListener("change", () => applyGain(Number(gnum.value), true));
1070
+ gwrap.appendChild(gslider);
1071
+ gwrap.appendChild(gnum);
1072
+ gainField.appendChild(gwrap);
1073
+ row2.appendChild(gainField);
874
1074
  detail.appendChild(row2);
875
1075
  // Waveform + crop
876
1076
  const waveWrap = el("div", "rydr-sg-wave");
@@ -994,6 +1194,10 @@ export function mountSoundGallery(root, opts) {
994
1194
  stopBtn.addEventListener("click", () => stopPlayback());
995
1195
  bar.appendChild(stopBtn);
996
1196
  if (isAdmin) {
1197
+ const normBtn = el("button", "rydr-sg-btn", "⇩ Normalize");
1198
+ normBtn.title = `Measure loudness and trim gain to ${LUFS_TARGETS[asset.category]} LUFS (${asset.category})`;
1199
+ normBtn.addEventListener("click", () => void doNormalize(asset, normBtn, gslider, gnum));
1200
+ bar.appendChild(normBtn);
997
1201
  const cropBtn = el("button", "rydr-sg-btn primary", "✂ Crop & save");
998
1202
  cropBtn.addEventListener("click", () => void doCrop(asset, cropBtn));
999
1203
  bar.appendChild(cropBtn);
@@ -1005,9 +1209,43 @@ export function mountSoundGallery(root, opts) {
1005
1209
  bar.appendChild(delBtn);
1006
1210
  }
1007
1211
  detail.appendChild(bar);
1212
+ // Reference A/B — audition against the platform's sys.* standard-cue exemplars (whichever are
1213
+ // published), at matched monitoring, so levels can be tuned by ear against known-good sounds.
1214
+ const refWrap = el("div", "rydr-sg-ref");
1215
+ refWrap.appendChild(el("div", "rydr-sg-ref-lbl", "Compare against reference"));
1216
+ const refRow = el("div", "rydr-sg-ref-row");
1217
+ refChips = new Map();
1218
+ for (const [cueId, def] of Object.entries(STANDARD_CUES)) {
1219
+ const refK = STANDARD_BINDINGS[cueId];
1220
+ const refAsset = refK ? sounds.find((s) => s.key === refK) : undefined;
1221
+ if (!refAsset || refAsset.key === asset.key)
1222
+ continue; // only published refs; never self
1223
+ const chip = el("button", "rydr-sg-chip", cueLabel(cueId));
1224
+ chip.title = def.description ?? cueId;
1225
+ chip.addEventListener("click", () => playReference(refAsset));
1226
+ refRow.appendChild(chip);
1227
+ refChips.set(refAsset.key, chip);
1228
+ }
1229
+ if (refChips.size === 0) {
1230
+ refRow.appendChild(el("span", "rydr-sg-ref-none", "No sys.* reference sounds published yet."));
1231
+ }
1232
+ else {
1233
+ const abBtn = el("button", "rydr-sg-btn", "⇄ A/B");
1234
+ abBtn.title = "Alternate this sound and the focused reference";
1235
+ abBtn.addEventListener("click", () => toggleAb());
1236
+ refRow.appendChild(abBtn);
1237
+ }
1238
+ refWrap.appendChild(refRow);
1239
+ detail.appendChild(refWrap);
1008
1240
  // Meta footer
1009
1241
  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 ?"}`));
1242
+ const lufs = asset.loudness?.integratedLufs;
1243
+ const peakStr = asset.loudness?.peakDb != null ? `peak ${asset.loudness.peakDb} dBFS` : "peak ?";
1244
+ let lufsStr = lufs != null && Number.isFinite(lufs) ? `${lufs} LUFS` : "LUFS ?";
1245
+ if (lufs != null && Number.isFinite(lufs) && asset.gainDb) {
1246
+ lufsStr += ` (→ ${Math.round((lufs + asset.gainDb) * 10) / 10} w/ trim)`;
1247
+ }
1248
+ meta.appendChild(el("div", undefined, `${clip?.filename ?? "—"} · ${dur ? fmtTime(dur) : "?"} · ${peakStr} · ${lufsStr}`));
1011
1249
  if (clip) {
1012
1250
  const link = el("a", undefined, "open file ↗");
1013
1251
  link.href = clip.url;
@@ -1075,7 +1313,7 @@ export function mountSoundGallery(root, opts) {
1075
1313
  ...asset,
1076
1314
  clips: [{ url, filename, durationSec: cropped.duration }],
1077
1315
  peaks,
1078
- loudness: { ...asset.loudness, peakDb: peakDb(cropped) },
1316
+ loudness: { ...asset.loudness, peakDb: peakDb(cropped), integratedLufs: await integratedLufs(cropped) },
1079
1317
  updatedAt: Date.now(),
1080
1318
  };
1081
1319
  await session.saveSound(next);
@@ -1094,6 +1332,35 @@ export function mountSoundGallery(root, opts) {
1094
1332
  btn.textContent = "✂ Crop & save";
1095
1333
  }
1096
1334
  }
1335
+ /** Measure integrated loudness (LUFS) and trim gainDb so the sound hits its channel's target. */
1336
+ async function doNormalize(asset, btn, gslider, gnum) {
1337
+ const buf = await ensureCropBuf(asset);
1338
+ if (!buf)
1339
+ return;
1340
+ btn.disabled = true;
1341
+ btn.textContent = "Measuring…";
1342
+ try {
1343
+ const lufs = await integratedLufs(buf);
1344
+ if (!Number.isFinite(lufs)) {
1345
+ toast("Too quiet to measure loudness.");
1346
+ return;
1347
+ }
1348
+ const gainDb = clampGain(Math.round((LUFS_TARGETS[asset.category] - lufs) * 10) / 10);
1349
+ gslider.value = String(gainDb);
1350
+ gnum.value = String(gainDb);
1351
+ liveGain(gainDb);
1352
+ await patch(asset, { gainDb, loudness: { ...asset.loudness, integratedLufs: lufs } });
1353
+ renderDetail(); // refresh the footer's LUFS readout
1354
+ toast(`Measured ${lufs} LUFS → trim ${gainDb > 0 ? "+" : ""}${gainDb} dB (target ${LUFS_TARGETS[asset.category]}).`);
1355
+ }
1356
+ catch (e) {
1357
+ toast(`Normalize failed: ${e instanceof Error ? e.message : String(e)}`);
1358
+ }
1359
+ finally {
1360
+ btn.disabled = false;
1361
+ btn.textContent = "⇩ Normalize";
1362
+ }
1363
+ }
1097
1364
  async function patch(asset, changes) {
1098
1365
  const next = { ...asset, ...changes, updatedAt: Date.now() };
1099
1366
  try {
@@ -1195,7 +1462,7 @@ export function mountSoundGallery(root, opts) {
1195
1462
  folderId: target,
1196
1463
  clips: [{ url, filename, durationSec: buf.duration }],
1197
1464
  peaks: computePeaks(buf),
1198
- loudness: { peakDb: peakDb(buf) },
1465
+ loudness: { peakDb: peakDb(buf), integratedLufs: await integratedLufs(buf) },
1199
1466
  updatedAt: Date.now(),
1200
1467
  };
1201
1468
  await session.saveSound(asset);
@@ -1292,6 +1559,10 @@ export function mountSoundGallery(root, opts) {
1292
1559
  closeMenu();
1293
1560
  document.removeEventListener("keydown", onKey);
1294
1561
  document.removeEventListener("click", onDocClick);
1562
+ if (previewCtx)
1563
+ void previewCtx.close().catch(() => undefined);
1564
+ previewCtx = null;
1565
+ previewMaster = null;
1295
1566
  container.remove();
1296
1567
  },
1297
1568
  };