@rydr/game-sdk 3.17.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.
- 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 +266 -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 +2 -0
- 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.0";
|
|
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.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;
|
|
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
|
-
|
|
872
|
-
|
|
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
|
-
|
|
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
|
};
|