@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.
- package/dist/protocol/version.d.ts +1 -1
- package/dist/protocol/version.js +1 -1
- package/dist/sound-gallery/index.d.ts.map +1 -1
- package/dist/sound-gallery/index.js +279 -8
- package/dist/sound-gallery/index.js.map +1 -1
- package/dist/soundboard-editor/index.d.ts +4 -0
- package/dist/soundboard-editor/index.d.ts.map +1 -1
- package/dist/soundboard-editor/index.js +111 -18
- package/dist/soundboard-editor/index.js.map +1 -1
- package/dist/sounds/audio-utils.d.ts +12 -0
- package/dist/sounds/audio-utils.d.ts.map +1 -1
- package/dist/sounds/audio-utils.js +65 -0
- package/dist/sounds/audio-utils.js.map +1 -1
- package/dist/sounds/index.d.ts +1 -1
- package/dist/sounds/index.d.ts.map +1 -1
- package/dist/sounds/index.js +1 -1
- package/dist/sounds/index.js.map +1 -1
- package/dist/sounds/music-director.d.ts +2 -1
- package/dist/sounds/music-director.d.ts.map +1 -1
- package/dist/sounds/music-director.js +5 -1
- package/dist/sounds/music-director.js.map +1 -1
- package/dist/sounds/soundboard.d.ts +4 -0
- package/dist/sounds/soundboard.d.ts.map +1 -1
- package/dist/sounds/soundboard.js.map +1 -1
- package/package.json +1 -1
|
@@ -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.
|
|
12
|
+
export declare const RYDR_SDK_VERSION = "3.19.1";
|
|
13
13
|
//# sourceMappingURL=version.d.ts.map
|
package/dist/protocol/version.js
CHANGED
|
@@ -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.
|
|
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;
|
|
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
|
-
|
|
872
|
-
|
|
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
|
-
|
|
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
|
};
|