@real-music-packages/web-core 0.9.7 → 0.11.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/audio.d.ts +9 -2
- package/dist/audio.js +1 -1
- package/dist/chunk-HXTRNE74.js +325 -0
- package/dist/chunk-HXTRNE74.js.map +1 -0
- package/dist/{chunk-BPL5LLQH.js → chunk-JVGAABTK.js} +4 -2
- package/dist/chunk-JVGAABTK.js.map +1 -0
- package/dist/promo.d.ts +17 -2
- package/dist/promo.js +66 -6
- package/dist/promo.js.map +1 -1
- package/dist/scene/index.d.ts +950 -0
- package/dist/scene/index.js +2008 -0
- package/dist/scene/index.js.map +1 -0
- package/dist/video.d.ts +9 -1
- package/dist/video.js +17 -244
- package/dist/video.js.map +1 -1
- package/package.json +10 -1
- package/dist/chunk-BPL5LLQH.js.map +0 -1
package/dist/audio.d.ts
CHANGED
|
@@ -145,11 +145,18 @@ interface SalamanderSamplerOpts {
|
|
|
145
145
|
baseUrl: string;
|
|
146
146
|
release: number;
|
|
147
147
|
volumeDb?: number;
|
|
148
|
+
attack?: number;
|
|
148
149
|
onload?: () => void;
|
|
149
150
|
onerror?: (e: unknown) => void;
|
|
151
|
+
/** Route the sampler straight to the speakers. Default true (preserves the
|
|
152
|
+
* inline behavior both engines had). Pass false to build a custom signal
|
|
153
|
+
* chain off the sampler instead (e.g. the promo mastering bus). */
|
|
154
|
+
connectToDestination?: boolean;
|
|
150
155
|
}
|
|
151
|
-
/** Create a Salamander Tone.Sampler routed to destination
|
|
152
|
-
* `new Tone.Sampler({...}).toDestination()` both engines did inline.
|
|
156
|
+
/** Create a Salamander Tone.Sampler. By default routed to destination, mirroring
|
|
157
|
+
* the exact `new Tone.Sampler({...}).toDestination()` both engines did inline.
|
|
158
|
+
* With `connectToDestination:false` it returns an unrouted sampler so the caller
|
|
159
|
+
* can insert a mastering chain. */
|
|
153
160
|
declare function createSalamanderSampler(Tone: ToneModule, opts: SalamanderSamplerOpts): any;
|
|
154
161
|
/** Generate a Tone.Reverb, racing reverb.generate() against a timeout so it can
|
|
155
162
|
* never hang init on iOS. Returns the reverb if generated, else null. Caller
|
package/dist/audio.js
CHANGED
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
// src/video.ts
|
|
2
|
+
var SAFE_ZONE = { top: 0.15, bottom: 0.28, left: 0.06, right: 0.12 };
|
|
3
|
+
function safeBox(W, H) {
|
|
4
|
+
const left = W * SAFE_ZONE.left;
|
|
5
|
+
const right = W * (1 - SAFE_ZONE.right);
|
|
6
|
+
const top = H * SAFE_ZONE.top;
|
|
7
|
+
const bottom = H * (1 - SAFE_ZONE.bottom);
|
|
8
|
+
return {
|
|
9
|
+
left,
|
|
10
|
+
right,
|
|
11
|
+
top,
|
|
12
|
+
bottom,
|
|
13
|
+
w: right - left,
|
|
14
|
+
h: bottom - top,
|
|
15
|
+
cx: (left + right) / 2,
|
|
16
|
+
cy: (top + bottom) / 2,
|
|
17
|
+
centeredW: 2 * Math.min(W / 2 - left, right - W / 2)
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
function safeTitleBaseline(size, H) {
|
|
21
|
+
return H * SAFE_ZONE.top + size * 0.8 + 4;
|
|
22
|
+
}
|
|
23
|
+
function drawSafeGuides(ctx) {
|
|
24
|
+
const W = ctx.canvas.width, H = ctx.canvas.height;
|
|
25
|
+
const b = safeBox(W, H);
|
|
26
|
+
ctx.save();
|
|
27
|
+
ctx.globalAlpha = 1;
|
|
28
|
+
ctx.fillStyle = "rgba(220,40,40,0.35)";
|
|
29
|
+
ctx.fillRect(0, 0, W, b.top);
|
|
30
|
+
ctx.fillRect(0, b.bottom, W, H - b.bottom);
|
|
31
|
+
ctx.fillRect(0, b.top, b.left, b.h);
|
|
32
|
+
ctx.fillRect(b.right, b.top, W - b.right, b.h);
|
|
33
|
+
ctx.strokeStyle = "rgba(40,200,90,1)";
|
|
34
|
+
ctx.lineWidth = 6;
|
|
35
|
+
ctx.strokeRect(b.left, b.top, b.w, b.h);
|
|
36
|
+
ctx.restore();
|
|
37
|
+
}
|
|
38
|
+
function pickMimeType() {
|
|
39
|
+
const candidates = [
|
|
40
|
+
"video/webm;codecs=vp9,opus",
|
|
41
|
+
"video/webm;codecs=vp8,opus",
|
|
42
|
+
"video/webm"
|
|
43
|
+
];
|
|
44
|
+
for (const t of candidates) {
|
|
45
|
+
if (typeof MediaRecorder !== "undefined" && MediaRecorder.isTypeSupported(t)) return t;
|
|
46
|
+
}
|
|
47
|
+
return "video/webm";
|
|
48
|
+
}
|
|
49
|
+
async function recordScenes(scenes, opts) {
|
|
50
|
+
const { audioStream, width, height, fps = 30, background = "#000000", onProgress } = opts;
|
|
51
|
+
const canvas = document.createElement("canvas");
|
|
52
|
+
canvas.width = width;
|
|
53
|
+
canvas.height = height;
|
|
54
|
+
const maybeCtx = canvas.getContext("2d");
|
|
55
|
+
if (!maybeCtx) throw new Error("Failed to get 2D context");
|
|
56
|
+
const ctx = maybeCtx;
|
|
57
|
+
const videoStream = canvas.captureStream(fps);
|
|
58
|
+
const combined = new MediaStream([
|
|
59
|
+
...videoStream.getVideoTracks(),
|
|
60
|
+
...audioStream.getAudioTracks()
|
|
61
|
+
]);
|
|
62
|
+
const mimeType = pickMimeType();
|
|
63
|
+
const recorder = new MediaRecorder(combined, {
|
|
64
|
+
mimeType,
|
|
65
|
+
videoBitsPerSecond: 4e6,
|
|
66
|
+
audioBitsPerSecond: 128e3
|
|
67
|
+
});
|
|
68
|
+
const chunks = [];
|
|
69
|
+
recorder.ondataavailable = (e) => {
|
|
70
|
+
if (e.data.size > 0) chunks.push(e.data);
|
|
71
|
+
};
|
|
72
|
+
const stopped = new Promise((res) => {
|
|
73
|
+
recorder.onstop = () => res();
|
|
74
|
+
});
|
|
75
|
+
recorder.start();
|
|
76
|
+
const totalMs = scenes.reduce((s, sc) => s + sc.durationMs, 0);
|
|
77
|
+
const t0 = performance.now();
|
|
78
|
+
onProgress?.({ phase: "capturing", progress01: 0 });
|
|
79
|
+
let sceneStart = 0;
|
|
80
|
+
let sceneIdx = 0;
|
|
81
|
+
let enteredScene = -1;
|
|
82
|
+
ctx.fillStyle = background;
|
|
83
|
+
ctx.fillRect(0, 0, width, height);
|
|
84
|
+
scenes[0].draw(ctx, 0);
|
|
85
|
+
await new Promise((resolve) => {
|
|
86
|
+
function tick() {
|
|
87
|
+
const now = performance.now() - t0;
|
|
88
|
+
if (now >= totalMs) {
|
|
89
|
+
setTimeout(resolve, 100);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
while (sceneIdx < scenes.length - 1 && now - sceneStart >= scenes[sceneIdx].durationMs) {
|
|
93
|
+
sceneStart += scenes[sceneIdx].durationMs;
|
|
94
|
+
sceneIdx += 1;
|
|
95
|
+
}
|
|
96
|
+
const scene = scenes[sceneIdx];
|
|
97
|
+
if (enteredScene !== sceneIdx) {
|
|
98
|
+
enteredScene = sceneIdx;
|
|
99
|
+
scene.onEnter?.();
|
|
100
|
+
}
|
|
101
|
+
const tInScene = (now - sceneStart) / scene.durationMs;
|
|
102
|
+
ctx.fillStyle = background;
|
|
103
|
+
ctx.fillRect(0, 0, width, height);
|
|
104
|
+
scene.draw(ctx, Math.min(1, Math.max(0, tInScene)));
|
|
105
|
+
onProgress?.({
|
|
106
|
+
phase: "capturing",
|
|
107
|
+
progress01: Math.min(0.95, now / totalMs)
|
|
108
|
+
});
|
|
109
|
+
requestAnimationFrame(tick);
|
|
110
|
+
}
|
|
111
|
+
requestAnimationFrame(tick);
|
|
112
|
+
});
|
|
113
|
+
onProgress?.({ phase: "finalizing", progress01: 0.96 });
|
|
114
|
+
recorder.stop();
|
|
115
|
+
await stopped;
|
|
116
|
+
return new Blob(chunks, { type: mimeType });
|
|
117
|
+
}
|
|
118
|
+
function truncate(s, max) {
|
|
119
|
+
return s.length > max ? s.slice(0, max - 1).trimEnd() + "\u2026" : s;
|
|
120
|
+
}
|
|
121
|
+
function initials(name) {
|
|
122
|
+
return name.split(/\s+/).filter(Boolean).slice(0, 3).map((w) => w[0]).join("").toUpperCase();
|
|
123
|
+
}
|
|
124
|
+
function drawBottomGradient(ctx, opts = {}) {
|
|
125
|
+
const W = ctx.canvas.width, H = ctx.canvas.height;
|
|
126
|
+
const heightFrac = opts.heightFrac ?? 0.3;
|
|
127
|
+
const maxAlpha = opts.maxAlpha ?? 0.45;
|
|
128
|
+
const top = H * (1 - heightFrac);
|
|
129
|
+
const g = ctx.createLinearGradient(0, top, 0, H);
|
|
130
|
+
g.addColorStop(0, "rgba(0,0,0,0)");
|
|
131
|
+
g.addColorStop(1, `rgba(0,0,0,${maxAlpha})`);
|
|
132
|
+
ctx.save();
|
|
133
|
+
ctx.fillStyle = g;
|
|
134
|
+
ctx.fillRect(0, top, W, H - top);
|
|
135
|
+
ctx.restore();
|
|
136
|
+
}
|
|
137
|
+
function hookScene(theme, opts) {
|
|
138
|
+
return {
|
|
139
|
+
durationMs: 2e3,
|
|
140
|
+
draw(ctx, _t01) {
|
|
141
|
+
const W = ctx.canvas.width;
|
|
142
|
+
const H = ctx.canvas.height;
|
|
143
|
+
drawBottomGradient(ctx);
|
|
144
|
+
ctx.textAlign = "center";
|
|
145
|
+
ctx.fillStyle = theme.ink;
|
|
146
|
+
ctx.font = `bold 88px ${theme.fontDisplay}`;
|
|
147
|
+
opts.lines.forEach((line, i) => {
|
|
148
|
+
ctx.fillText(line, W / 2, H * 0.42 + i * 104);
|
|
149
|
+
});
|
|
150
|
+
const brand = opts.brand ?? theme.brand;
|
|
151
|
+
ctx.font = `italic 38px ${theme.fontBody}`;
|
|
152
|
+
ctx.fillStyle = theme.sepia;
|
|
153
|
+
ctx.fillText(brand, W / 2, H * 0.42 + opts.lines.length * 104 + 12);
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
var _portraitCache = /* @__PURE__ */ new Map();
|
|
158
|
+
function loadPortrait(url) {
|
|
159
|
+
if (!url) return Promise.resolve(null);
|
|
160
|
+
const cached = _portraitCache.get(url);
|
|
161
|
+
if (cached) return cached;
|
|
162
|
+
const p = new Promise((resolve) => {
|
|
163
|
+
if (typeof Image === "undefined") {
|
|
164
|
+
resolve(null);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
const img = new Image();
|
|
168
|
+
img.crossOrigin = "anonymous";
|
|
169
|
+
img.onload = () => resolve(img);
|
|
170
|
+
img.onerror = () => resolve(null);
|
|
171
|
+
img.src = url;
|
|
172
|
+
});
|
|
173
|
+
_portraitCache.set(url, p);
|
|
174
|
+
return p;
|
|
175
|
+
}
|
|
176
|
+
function wrapLines(ctx, text, maxW, maxLines) {
|
|
177
|
+
const words = text.split(/\s+/).filter(Boolean);
|
|
178
|
+
const lines = [];
|
|
179
|
+
let cur = "";
|
|
180
|
+
for (const w of words) {
|
|
181
|
+
const next = cur ? `${cur} ${w}` : w;
|
|
182
|
+
if (ctx.measureText(next).width > maxW && cur) {
|
|
183
|
+
lines.push(cur);
|
|
184
|
+
cur = w;
|
|
185
|
+
if (lines.length === maxLines - 1) break;
|
|
186
|
+
} else {
|
|
187
|
+
cur = next;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
if (cur && lines.length < maxLines) lines.push(cur);
|
|
191
|
+
const used = lines.join(" ");
|
|
192
|
+
if (used.length < text.length && lines.length) {
|
|
193
|
+
lines[lines.length - 1] = truncate(lines[lines.length - 1] + "\u2026", 999);
|
|
194
|
+
}
|
|
195
|
+
return lines;
|
|
196
|
+
}
|
|
197
|
+
function revealScene(theme, opts) {
|
|
198
|
+
return {
|
|
199
|
+
durationMs: 2200,
|
|
200
|
+
draw(ctx, t01) {
|
|
201
|
+
const W = ctx.canvas.width;
|
|
202
|
+
const H = ctx.canvas.height;
|
|
203
|
+
const b = safeBox(W, H);
|
|
204
|
+
const cx = W / 2;
|
|
205
|
+
const cy = b.top + b.h * 0.4286;
|
|
206
|
+
const r = 92;
|
|
207
|
+
const k = Math.min(1, t01 * 1.4);
|
|
208
|
+
ctx.globalAlpha = k;
|
|
209
|
+
ctx.textAlign = "center";
|
|
210
|
+
ctx.beginPath();
|
|
211
|
+
ctx.arc(cx, cy, r, 0, Math.PI * 2);
|
|
212
|
+
ctx.fillStyle = theme.paper;
|
|
213
|
+
ctx.fill();
|
|
214
|
+
if (opts.portrait) {
|
|
215
|
+
ctx.save();
|
|
216
|
+
ctx.beginPath();
|
|
217
|
+
ctx.arc(cx, cy, r - 3, 0, Math.PI * 2);
|
|
218
|
+
ctx.clip();
|
|
219
|
+
const img = opts.portrait;
|
|
220
|
+
const iw = img.naturalWidth || img.width || 1;
|
|
221
|
+
const ih = img.naturalHeight || img.height || 1;
|
|
222
|
+
const scale = Math.max(2 * (r - 3) / iw, 2 * (r - 3) / ih);
|
|
223
|
+
const dw = iw * scale;
|
|
224
|
+
const dh = ih * scale;
|
|
225
|
+
ctx.drawImage(img, cx - dw / 2, cy - dh / 2, dw, dh);
|
|
226
|
+
ctx.restore();
|
|
227
|
+
} else {
|
|
228
|
+
ctx.fillStyle = theme.ink;
|
|
229
|
+
ctx.font = `bold 76px ${theme.fontDisplay}`;
|
|
230
|
+
const badge = opts.initials ?? initials(opts.title);
|
|
231
|
+
ctx.fillText(badge, cx, cy + 27);
|
|
232
|
+
}
|
|
233
|
+
ctx.lineWidth = 5;
|
|
234
|
+
ctx.strokeStyle = theme.gold;
|
|
235
|
+
ctx.stroke();
|
|
236
|
+
const truncated = truncate(opts.title, 24);
|
|
237
|
+
let fontSize = 80;
|
|
238
|
+
ctx.font = `bold ${fontSize}px ${theme.fontDisplay}`;
|
|
239
|
+
if (ctx.measureText(truncated).width > W * 0.86) {
|
|
240
|
+
fontSize = 68;
|
|
241
|
+
ctx.font = `bold ${fontSize}px ${theme.fontDisplay}`;
|
|
242
|
+
}
|
|
243
|
+
ctx.fillStyle = theme.ink;
|
|
244
|
+
ctx.fillText(truncated, cx, cy + r + 110);
|
|
245
|
+
ctx.font = `italic 50px ${theme.fontBody}`;
|
|
246
|
+
ctx.fillStyle = theme.sepia;
|
|
247
|
+
ctx.fillText(opts.subtitle, cx, cy + r + 176);
|
|
248
|
+
if (opts.funFact) {
|
|
249
|
+
ctx.font = `34px ${theme.fontBody}`;
|
|
250
|
+
ctx.fillStyle = theme.sepia;
|
|
251
|
+
const lines = wrapLines(ctx, opts.funFact, b.w * 0.92, 2);
|
|
252
|
+
lines.forEach((line, i) => {
|
|
253
|
+
ctx.fillText(line, cx, cy + r + 246 + i * 44);
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
ctx.globalAlpha = 1;
|
|
257
|
+
}
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
function ctaScene(theme, opts) {
|
|
261
|
+
return {
|
|
262
|
+
durationMs: 2500,
|
|
263
|
+
draw(ctx, _t01) {
|
|
264
|
+
const W = ctx.canvas.width;
|
|
265
|
+
const H = ctx.canvas.height;
|
|
266
|
+
const b = safeBox(W, H);
|
|
267
|
+
const baseY = b.cy - 40;
|
|
268
|
+
ctx.textAlign = "center";
|
|
269
|
+
if (opts.lines.length > 0) {
|
|
270
|
+
ctx.fillStyle = theme.ink;
|
|
271
|
+
ctx.font = `bold 66px ${theme.fontDisplay}`;
|
|
272
|
+
ctx.fillText(opts.lines[0], W / 2, baseY);
|
|
273
|
+
}
|
|
274
|
+
opts.lines.slice(1).forEach((line, i) => {
|
|
275
|
+
ctx.fillStyle = theme.accent;
|
|
276
|
+
ctx.font = `bold 72px ${theme.fontDisplay}`;
|
|
277
|
+
ctx.fillText(line, W / 2, baseY + 100 * (i + 1));
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
async function runPromoCapture(opts, record = recordScenes) {
|
|
283
|
+
const w = window;
|
|
284
|
+
w.__promoMeta = opts.meta;
|
|
285
|
+
try {
|
|
286
|
+
const blob = await record(opts.buildScenes(), {
|
|
287
|
+
audioStream: opts.audioStream,
|
|
288
|
+
width: opts.width ?? 1080,
|
|
289
|
+
height: opts.height ?? 1920,
|
|
290
|
+
fps: opts.fps,
|
|
291
|
+
background: opts.background
|
|
292
|
+
});
|
|
293
|
+
const url = URL.createObjectURL(blob);
|
|
294
|
+
w.__promoBlobUrl = url;
|
|
295
|
+
if (opts.download !== false) {
|
|
296
|
+
const a = document.createElement("a");
|
|
297
|
+
a.href = url;
|
|
298
|
+
a.download = `promo-${opts.meta.id ?? "clip"}.webm`;
|
|
299
|
+
document.body.appendChild(a);
|
|
300
|
+
a.click();
|
|
301
|
+
}
|
|
302
|
+
w.__promoReady = true;
|
|
303
|
+
} catch (e) {
|
|
304
|
+
w.__promoError = String(e);
|
|
305
|
+
throw e;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
export {
|
|
310
|
+
SAFE_ZONE,
|
|
311
|
+
safeBox,
|
|
312
|
+
safeTitleBaseline,
|
|
313
|
+
drawSafeGuides,
|
|
314
|
+
pickMimeType,
|
|
315
|
+
recordScenes,
|
|
316
|
+
truncate,
|
|
317
|
+
initials,
|
|
318
|
+
drawBottomGradient,
|
|
319
|
+
hookScene,
|
|
320
|
+
loadPortrait,
|
|
321
|
+
revealScene,
|
|
322
|
+
ctaScene,
|
|
323
|
+
runPromoCapture
|
|
324
|
+
};
|
|
325
|
+
//# sourceMappingURL=chunk-HXTRNE74.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/video.ts"],"sourcesContent":["/**\n * Framework-agnostic promo video module.\n *\n * Provides:\n * - PromoTheme — colour/font/brand tokens\n * - Scene — {durationMs, draw, onEnter?}\n * - recordScenes — rAF-based canvas capture → Blob (browser only)\n * - hookScene / revealScene / ctaScene — scene builders (pure, unit-testable)\n * - runPromoCapture — orchestrator with dependency-injected recorder\n *\n * Visual style ported from whozart/src/lib/promo.ts + video.ts.\n */\n\n// ─── Types ───────────────────────────────────────────────────────────────────\n\nexport interface PromoTheme {\n paper: string;\n ink: string;\n accent: string;\n sepia: string;\n gold: string;\n fontDisplay: string;\n fontBody: string;\n brand: string;\n}\n\nexport interface Scene {\n durationMs: number;\n draw: (ctx: CanvasRenderingContext2D, t01: number) => void;\n onEnter?: () => void;\n}\n\nexport interface RecordOpts {\n audioStream: MediaStream;\n width: number;\n height: number;\n fps?: number;\n background?: string;\n onProgress?: (p: { phase: string; progress01: number }) => void;\n}\n\nexport interface PromoMeta {\n id?: string;\n title?: string;\n composer?: string;\n [k: string]: unknown;\n}\n\nexport interface PromoCaptureOpts {\n buildScenes: () => Scene[];\n audioStream: MediaStream;\n meta: PromoMeta;\n width?: number;\n height?: number;\n fps?: number;\n background?: string;\n download?: boolean;\n}\n\n// ─── Safe zone (phone short-form UI) ─────────────────────────────────────────\n\n/**\n * Insets (fractions of frame) kept clear of TikTok/Reels/Shorts UI overlays.\n * `bottom: 0.28` puts the safe-box bottom at 0.72·H — TikTok's caption + right\n * action rail reach ~25–28% up, tighter than the old 0.20, and a posted RET clip\n * had its keyboard clipped at 0.20. `top: 0.15` clears TikTok's top tabs/search\n * row — measured from a real screenshot, the \"Find related content\" search bar\n * bottom sits at ~0.114·H, so 0.10 clashed and 0.13 cleared by only ~31px; 0.15\n * gives a comfortable ~73px. This is the SINGLE source of truth for both safe lines:\n * consumers anchor bottom content to `safeBox().bottom` and top titles to\n * `safeTitleBaseline()` instead of hardcoding. Verify any layout with `?safe=1`.\n */\nexport const SAFE_ZONE = { top: 0.15, bottom: 0.28, left: 0.06, right: 0.12 } as const;\n\nexport interface SafeBox {\n left: number; right: number; top: number; bottom: number;\n w: number; h: number; cx: number; cy: number;\n /** Widest block centered at frame W/2 that still fits inside the box. */\n centeredW: number;\n}\n\n/** The usable content rectangle for a W×H frame, in pixels. */\nexport function safeBox(W: number, H: number): SafeBox {\n const left = W * SAFE_ZONE.left;\n const right = W * (1 - SAFE_ZONE.right);\n const top = H * SAFE_ZONE.top;\n const bottom = H * (1 - SAFE_ZONE.bottom);\n return {\n left, right, top, bottom,\n w: right - left, h: bottom - top,\n cx: (left + right) / 2, cy: (top + bottom) / 2,\n centeredW: 2 * Math.min(W / 2 - left, right - W / 2),\n };\n}\n\n/**\n * Lowest first-line baseline (for an ALPHABETIC-baseline title of `size` px) that\n * keeps the title's cap-top inside the safe box top. Phone top chrome (TikTok/Reels\n * \"For You\" tabs + search) sits above `safeBox().top`, and an alphabetic baseline\n * draws glyphs ~0.8*size ABOVE the baseline — so a small `topY` silently breaches\n * the top. Apps clamp their top titles: `y = Math.max(topY, safeTitleBaseline(size, H))`.\n * Single source of truth for the top-safe line, mirroring `safeBox().bottom`.\n */\nexport function safeTitleBaseline(size: number, H: number): number {\n return H * SAFE_ZONE.top + size * 0.8 + 4;\n}\n\n/** Debug overlay: dim the four unsafe margins + outline the safe box. */\nexport function drawSafeGuides(ctx: CanvasRenderingContext2D): void {\n const W = ctx.canvas.width, H = ctx.canvas.height;\n const b = safeBox(W, H);\n ctx.save();\n ctx.globalAlpha = 1;\n ctx.fillStyle = 'rgba(220,40,40,0.35)';\n ctx.fillRect(0, 0, W, b.top); // top\n ctx.fillRect(0, b.bottom, W, H - b.bottom); // bottom\n ctx.fillRect(0, b.top, b.left, b.h); // left\n ctx.fillRect(b.right, b.top, W - b.right, b.h); // right (action rail)\n ctx.strokeStyle = 'rgba(40,200,90,1)';\n ctx.lineWidth = 6;\n ctx.strokeRect(b.left, b.top, b.w, b.h);\n ctx.restore();\n}\n\n// ─── MIME type picker ─────────────────────────────────────────────────────────\n\nexport function pickMimeType(): string {\n const candidates = [\n 'video/webm;codecs=vp9,opus',\n 'video/webm;codecs=vp8,opus',\n 'video/webm',\n ];\n for (const t of candidates) {\n if (typeof MediaRecorder !== 'undefined' && MediaRecorder.isTypeSupported(t)) return t;\n }\n return 'video/webm';\n}\n\n// ─── recordScenes ─────────────────────────────────────────────────────────────\n\nexport async function recordScenes(scenes: Scene[], opts: RecordOpts): Promise<Blob> {\n const { audioStream, width, height, fps = 30, background = '#000000', onProgress } = opts;\n\n const canvas = document.createElement('canvas');\n canvas.width = width;\n canvas.height = height;\n\n const maybeCtx = canvas.getContext('2d');\n if (!maybeCtx) throw new Error('Failed to get 2D context');\n const ctx: CanvasRenderingContext2D = maybeCtx;\n\n const videoStream = canvas.captureStream(fps);\n const combined = new MediaStream([\n ...videoStream.getVideoTracks(),\n ...audioStream.getAudioTracks(),\n ]);\n\n const mimeType = pickMimeType();\n const recorder = new MediaRecorder(combined, {\n mimeType,\n videoBitsPerSecond: 4_000_000,\n audioBitsPerSecond: 128_000,\n });\n\n const chunks: Blob[] = [];\n recorder.ondataavailable = (e) => {\n if (e.data.size > 0) chunks.push(e.data);\n };\n const stopped = new Promise<void>((res) => {\n recorder.onstop = () => res();\n });\n\n recorder.start();\n\n const totalMs = scenes.reduce((s, sc) => s + sc.durationMs, 0);\n const t0 = performance.now();\n\n onProgress?.({ phase: 'capturing', progress01: 0 });\n\n let sceneStart = 0;\n let sceneIdx = 0;\n let enteredScene = -1;\n\n // Draw first frame immediately\n ctx.fillStyle = background;\n ctx.fillRect(0, 0, width, height);\n scenes[0].draw(ctx, 0);\n\n await new Promise<void>((resolve) => {\n function tick() {\n const now = performance.now() - t0;\n if (now >= totalMs) {\n setTimeout(resolve, 100);\n return;\n }\n\n // Advance scene index\n while (\n sceneIdx < scenes.length - 1 &&\n now - sceneStart >= scenes[sceneIdx].durationMs\n ) {\n sceneStart += scenes[sceneIdx].durationMs;\n sceneIdx += 1;\n }\n\n const scene = scenes[sceneIdx];\n\n // Fire onEnter once per scene\n if (enteredScene !== sceneIdx) {\n enteredScene = sceneIdx;\n scene.onEnter?.();\n }\n\n const tInScene = (now - sceneStart) / scene.durationMs;\n\n // Clear to background\n ctx.fillStyle = background;\n ctx.fillRect(0, 0, width, height);\n\n scene.draw(ctx, Math.min(1, Math.max(0, tInScene)));\n\n onProgress?.({\n phase: 'capturing',\n progress01: Math.min(0.95, now / totalMs),\n });\n\n requestAnimationFrame(tick);\n }\n\n requestAnimationFrame(tick);\n });\n\n onProgress?.({ phase: 'finalizing', progress01: 0.96 });\n recorder.stop();\n await stopped;\n\n return new Blob(chunks, { type: mimeType });\n}\n\n// ─── Helpers ─────────────────────────────────────────────────────────────────\n\nexport function truncate(s: string, max: number): string {\n return s.length > max ? s.slice(0, max - 1).trimEnd() + '…' : s;\n}\n\nexport function initials(name: string): string {\n return name\n .split(/\\s+/)\n .filter(Boolean)\n .slice(0, 3)\n .map((w) => w[0])\n .join('')\n .toUpperCase();\n}\n\n// ─── Scene builders ───────────────────────────────────────────────────────────\n\n/**\n * Dark gradient rising from the frame bottom (opaque-ish at the very bottom,\n * fading out by `heightFrac` up). TikTok overlays the view count bottom-left of\n * every profile-grid thumbnail in WHITE — on our light start screens it was\n * unreadable. Draw this after the background, before content.\n */\nexport function drawBottomGradient(\n ctx: CanvasRenderingContext2D,\n opts: { heightFrac?: number; maxAlpha?: number } = {},\n): void {\n const W = ctx.canvas.width, H = ctx.canvas.height;\n const heightFrac = opts.heightFrac ?? 0.30;\n const maxAlpha = opts.maxAlpha ?? 0.45;\n const top = H * (1 - heightFrac);\n const g = ctx.createLinearGradient(0, top, 0, H);\n g.addColorStop(0, \"rgba(0,0,0,0)\");\n g.addColorStop(1, `rgba(0,0,0,${maxAlpha})`);\n ctx.save();\n ctx.fillStyle = g;\n ctx.fillRect(0, top, W, H - top);\n ctx.restore();\n}\n\nexport interface HookSceneOpts {\n lines: string[];\n brand?: string;\n}\n\nexport function hookScene(theme: PromoTheme, opts: HookSceneOpts): Scene {\n return {\n durationMs: 2000,\n draw(ctx, _t01) {\n const W = ctx.canvas.width;\n const H = ctx.canvas.height;\n // The hook is the profile-grid thumbnail; the bottom gradient keeps\n // TikTok's white view-count overlay readable on light backdrops.\n drawBottomGradient(ctx);\n ctx.textAlign = 'center';\n ctx.fillStyle = theme.ink;\n ctx.font = `bold 88px ${theme.fontDisplay}`;\n opts.lines.forEach((line, i) => {\n ctx.fillText(line, W / 2, H * 0.42 + i * 104);\n });\n const brand = opts.brand ?? theme.brand;\n ctx.font = `italic 38px ${theme.fontBody}`;\n ctx.fillStyle = theme.sepia;\n ctx.fillText(brand, W / 2, H * 0.42 + opts.lines.length * 104 + 12);\n },\n };\n}\n\nexport interface RevealSceneOpts {\n title: string;\n subtitle: string;\n initials?: string;\n /** Composer portrait (already decoded) drawn clipped into the medallion. If\n * omitted/null, falls back to the initials badge. Load it with `loadPortrait`\n * so a cross-origin image (e.g. Wikimedia) doesn't taint the capture canvas. */\n portrait?: HTMLImageElement | null;\n /** Optional one-line fun fact wrapped below the subtitle (e.g. corpus\n * `fun_fact`). Trimmed to two lines. */\n funFact?: string;\n}\n\n/** Load an image for canvas capture: `crossOrigin='anonymous'` so a CORS-enabled\n * cross-origin source (Wikimedia portraits send `Access-Control-Allow-Origin: *`)\n * can be drawn onto a captured canvas WITHOUT tainting it. Fail-soft: resolves\n * null on error/missing url (callers fall back to initials). Cached per-url. */\nconst _portraitCache = new Map<string, Promise<HTMLImageElement | null>>();\nexport function loadPortrait(url?: string | null): Promise<HTMLImageElement | null> {\n if (!url) return Promise.resolve(null);\n const cached = _portraitCache.get(url);\n if (cached) return cached;\n const p = new Promise<HTMLImageElement | null>((resolve) => {\n if (typeof Image === 'undefined') { resolve(null); return; }\n const img = new Image();\n img.crossOrigin = 'anonymous';\n img.onload = () => resolve(img);\n img.onerror = () => resolve(null);\n img.src = url;\n });\n _portraitCache.set(url, p);\n return p;\n}\n\n/** Greedy word-wrap for the current ctx.font into at most `maxLines` lines. */\nfunction wrapLines(ctx: CanvasRenderingContext2D, text: string, maxW: number, maxLines: number): string[] {\n const words = text.split(/\\s+/).filter(Boolean);\n const lines: string[] = [];\n let cur = '';\n for (const w of words) {\n const next = cur ? `${cur} ${w}` : w;\n if (ctx.measureText(next).width > maxW && cur) {\n lines.push(cur);\n cur = w;\n if (lines.length === maxLines - 1) break;\n } else {\n cur = next;\n }\n }\n if (cur && lines.length < maxLines) lines.push(cur);\n // If we ran out of line budget mid-text, ellipsize the last line.\n const used = lines.join(' ');\n if (used.length < text.length && lines.length) {\n lines[lines.length - 1] = truncate(lines[lines.length - 1] + '…', 999);\n }\n return lines;\n}\n\nexport function revealScene(theme: PromoTheme, opts: RevealSceneOpts): Scene {\n return {\n durationMs: 2200,\n draw(ctx, t01) {\n const W = ctx.canvas.width;\n const H = ctx.canvas.height;\n const b = safeBox(W, H);\n const cx = W / 2;\n // Anchor through the safe box; ~0.4286 of box height ≈ original H*0.40 (no visible shift at 1080×1920).\n const cy = b.top + b.h * 0.4286;\n const r = 92;\n const k = Math.min(1, t01 * 1.4);\n ctx.globalAlpha = k;\n ctx.textAlign = 'center';\n\n // Medallion circle (faint surface) + gold ring.\n ctx.beginPath();\n ctx.arc(cx, cy, r, 0, Math.PI * 2);\n ctx.fillStyle = theme.paper;\n ctx.fill();\n\n if (opts.portrait) {\n // Portrait clipped into the medallion, cover-fit (centred crop).\n ctx.save();\n ctx.beginPath();\n ctx.arc(cx, cy, r - 3, 0, Math.PI * 2);\n ctx.clip();\n const img = opts.portrait;\n const iw = img.naturalWidth || img.width || 1;\n const ih = img.naturalHeight || img.height || 1;\n const scale = Math.max((2 * (r - 3)) / iw, (2 * (r - 3)) / ih);\n const dw = iw * scale;\n const dh = ih * scale;\n ctx.drawImage(img, cx - dw / 2, cy - dh / 2, dw, dh);\n ctx.restore();\n } else {\n // Initials inside medallion (fallback).\n ctx.fillStyle = theme.ink;\n ctx.font = `bold 76px ${theme.fontDisplay}`;\n const badge = opts.initials ?? initials(opts.title);\n ctx.fillText(badge, cx, cy + 27);\n }\n\n ctx.lineWidth = 5;\n ctx.strokeStyle = theme.gold;\n ctx.stroke();\n\n // Title (with font-size fallback if too wide)\n const truncated = truncate(opts.title, 24);\n let fontSize = 80;\n ctx.font = `bold ${fontSize}px ${theme.fontDisplay}`;\n if (ctx.measureText(truncated).width > W * 0.86) {\n fontSize = 68;\n ctx.font = `bold ${fontSize}px ${theme.fontDisplay}`;\n }\n ctx.fillStyle = theme.ink;\n ctx.fillText(truncated, cx, cy + r + 110);\n\n // Subtitle\n ctx.font = `italic 50px ${theme.fontBody}`;\n ctx.fillStyle = theme.sepia;\n ctx.fillText(opts.subtitle, cx, cy + r + 176);\n\n // Optional fun fact, wrapped to two lines below the subtitle.\n if (opts.funFact) {\n ctx.font = `34px ${theme.fontBody}`;\n ctx.fillStyle = theme.sepia;\n const lines = wrapLines(ctx, opts.funFact, b.w * 0.92, 2);\n lines.forEach((line, i) => {\n ctx.fillText(line, cx, cy + r + 246 + i * 44);\n });\n }\n\n ctx.globalAlpha = 1;\n },\n };\n}\n\nexport interface CtaSceneOpts {\n lines: string[];\n}\n\nexport function ctaScene(theme: PromoTheme, opts: CtaSceneOpts): Scene {\n return {\n durationMs: 2500,\n draw(ctx, _t01) {\n const W = ctx.canvas.width;\n const H = ctx.canvas.height;\n const b = safeBox(W, H);\n // Safe-box-relative base ≈ original H*0.42 (no visible shift at 1080×1920).\n const baseY = b.cy - 40;\n ctx.textAlign = 'center';\n if (opts.lines.length > 0) {\n ctx.fillStyle = theme.ink;\n ctx.font = `bold 66px ${theme.fontDisplay}`;\n ctx.fillText(opts.lines[0], W / 2, baseY);\n }\n opts.lines.slice(1).forEach((line, i) => {\n ctx.fillStyle = theme.accent;\n ctx.font = `bold 72px ${theme.fontDisplay}`;\n ctx.fillText(line, W / 2, baseY + 100 * (i + 1));\n });\n },\n };\n}\n\n// ─── runPromoCapture ──────────────────────────────────────────────────────────\n\nexport async function runPromoCapture(\n opts: PromoCaptureOpts,\n record: (scenes: Scene[], o: RecordOpts) => Promise<Blob> = recordScenes,\n): Promise<void> {\n const w = window as unknown as Record<string, unknown>;\n w.__promoMeta = opts.meta;\n try {\n const blob = await record(opts.buildScenes(), {\n audioStream: opts.audioStream,\n width: opts.width ?? 1080,\n height: opts.height ?? 1920,\n fps: opts.fps,\n background: opts.background,\n });\n const url = URL.createObjectURL(blob);\n w.__promoBlobUrl = url;\n if (opts.download !== false) {\n const a = document.createElement('a');\n a.href = url;\n a.download = `promo-${opts.meta.id ?? 'clip'}.webm`;\n document.body.appendChild(a);\n a.click();\n }\n w.__promoReady = true;\n } catch (e) {\n w.__promoError = String(e);\n throw e;\n }\n}\n"],"mappings":";AAwEO,IAAM,YAAY,EAAE,KAAK,MAAM,QAAQ,MAAM,MAAM,MAAM,OAAO,KAAK;AAUrE,SAAS,QAAQ,GAAW,GAAoB;AACrD,QAAM,OAAO,IAAI,UAAU;AAC3B,QAAM,QAAQ,KAAK,IAAI,UAAU;AACjC,QAAM,MAAM,IAAI,UAAU;AAC1B,QAAM,SAAS,KAAK,IAAI,UAAU;AAClC,SAAO;AAAA,IACL;AAAA,IAAM;AAAA,IAAO;AAAA,IAAK;AAAA,IAClB,GAAG,QAAQ;AAAA,IAAM,GAAG,SAAS;AAAA,IAC7B,KAAK,OAAO,SAAS;AAAA,IAAG,KAAK,MAAM,UAAU;AAAA,IAC7C,WAAW,IAAI,KAAK,IAAI,IAAI,IAAI,MAAM,QAAQ,IAAI,CAAC;AAAA,EACrD;AACF;AAUO,SAAS,kBAAkB,MAAc,GAAmB;AACjE,SAAO,IAAI,UAAU,MAAM,OAAO,MAAM;AAC1C;AAGO,SAAS,eAAe,KAAqC;AAClE,QAAM,IAAI,IAAI,OAAO,OAAO,IAAI,IAAI,OAAO;AAC3C,QAAM,IAAI,QAAQ,GAAG,CAAC;AACtB,MAAI,KAAK;AACT,MAAI,cAAc;AAClB,MAAI,YAAY;AAChB,MAAI,SAAS,GAAG,GAAG,GAAG,EAAE,GAAG;AAC3B,MAAI,SAAS,GAAG,EAAE,QAAQ,GAAG,IAAI,EAAE,MAAM;AACzC,MAAI,SAAS,GAAG,EAAE,KAAK,EAAE,MAAM,EAAE,CAAC;AAClC,MAAI,SAAS,EAAE,OAAO,EAAE,KAAK,IAAI,EAAE,OAAO,EAAE,CAAC;AAC7C,MAAI,cAAc;AAClB,MAAI,YAAY;AAChB,MAAI,WAAW,EAAE,MAAM,EAAE,KAAK,EAAE,GAAG,EAAE,CAAC;AACtC,MAAI,QAAQ;AACd;AAIO,SAAS,eAAuB;AACrC,QAAM,aAAa;AAAA,IACjB;AAAA,IACA;AAAA,IACA;AAAA,EACF;AACA,aAAW,KAAK,YAAY;AAC1B,QAAI,OAAO,kBAAkB,eAAe,cAAc,gBAAgB,CAAC,EAAG,QAAO;AAAA,EACvF;AACA,SAAO;AACT;AAIA,eAAsB,aAAa,QAAiB,MAAiC;AACnF,QAAM,EAAE,aAAa,OAAO,QAAQ,MAAM,IAAI,aAAa,WAAW,WAAW,IAAI;AAErF,QAAM,SAAS,SAAS,cAAc,QAAQ;AAC9C,SAAO,QAAQ;AACf,SAAO,SAAS;AAEhB,QAAM,WAAW,OAAO,WAAW,IAAI;AACvC,MAAI,CAAC,SAAU,OAAM,IAAI,MAAM,0BAA0B;AACzD,QAAM,MAAgC;AAEtC,QAAM,cAAc,OAAO,cAAc,GAAG;AAC5C,QAAM,WAAW,IAAI,YAAY;AAAA,IAC/B,GAAG,YAAY,eAAe;AAAA,IAC9B,GAAG,YAAY,eAAe;AAAA,EAChC,CAAC;AAED,QAAM,WAAW,aAAa;AAC9B,QAAM,WAAW,IAAI,cAAc,UAAU;AAAA,IAC3C;AAAA,IACA,oBAAoB;AAAA,IACpB,oBAAoB;AAAA,EACtB,CAAC;AAED,QAAM,SAAiB,CAAC;AACxB,WAAS,kBAAkB,CAAC,MAAM;AAChC,QAAI,EAAE,KAAK,OAAO,EAAG,QAAO,KAAK,EAAE,IAAI;AAAA,EACzC;AACA,QAAM,UAAU,IAAI,QAAc,CAAC,QAAQ;AACzC,aAAS,SAAS,MAAM,IAAI;AAAA,EAC9B,CAAC;AAED,WAAS,MAAM;AAEf,QAAM,UAAU,OAAO,OAAO,CAAC,GAAG,OAAO,IAAI,GAAG,YAAY,CAAC;AAC7D,QAAM,KAAK,YAAY,IAAI;AAE3B,eAAa,EAAE,OAAO,aAAa,YAAY,EAAE,CAAC;AAElD,MAAI,aAAa;AACjB,MAAI,WAAW;AACf,MAAI,eAAe;AAGnB,MAAI,YAAY;AAChB,MAAI,SAAS,GAAG,GAAG,OAAO,MAAM;AAChC,SAAO,CAAC,EAAE,KAAK,KAAK,CAAC;AAErB,QAAM,IAAI,QAAc,CAAC,YAAY;AACnC,aAAS,OAAO;AACd,YAAM,MAAM,YAAY,IAAI,IAAI;AAChC,UAAI,OAAO,SAAS;AAClB,mBAAW,SAAS,GAAG;AACvB;AAAA,MACF;AAGA,aACE,WAAW,OAAO,SAAS,KAC3B,MAAM,cAAc,OAAO,QAAQ,EAAE,YACrC;AACA,sBAAc,OAAO,QAAQ,EAAE;AAC/B,oBAAY;AAAA,MACd;AAEA,YAAM,QAAQ,OAAO,QAAQ;AAG7B,UAAI,iBAAiB,UAAU;AAC7B,uBAAe;AACf,cAAM,UAAU;AAAA,MAClB;AAEA,YAAM,YAAY,MAAM,cAAc,MAAM;AAG5C,UAAI,YAAY;AAChB,UAAI,SAAS,GAAG,GAAG,OAAO,MAAM;AAEhC,YAAM,KAAK,KAAK,KAAK,IAAI,GAAG,KAAK,IAAI,GAAG,QAAQ,CAAC,CAAC;AAElD,mBAAa;AAAA,QACX,OAAO;AAAA,QACP,YAAY,KAAK,IAAI,MAAM,MAAM,OAAO;AAAA,MAC1C,CAAC;AAED,4BAAsB,IAAI;AAAA,IAC5B;AAEA,0BAAsB,IAAI;AAAA,EAC5B,CAAC;AAED,eAAa,EAAE,OAAO,cAAc,YAAY,KAAK,CAAC;AACtD,WAAS,KAAK;AACd,QAAM;AAEN,SAAO,IAAI,KAAK,QAAQ,EAAE,MAAM,SAAS,CAAC;AAC5C;AAIO,SAAS,SAAS,GAAW,KAAqB;AACvD,SAAO,EAAE,SAAS,MAAM,EAAE,MAAM,GAAG,MAAM,CAAC,EAAE,QAAQ,IAAI,WAAM;AAChE;AAEO,SAAS,SAAS,MAAsB;AAC7C,SAAO,KACJ,MAAM,KAAK,EACX,OAAO,OAAO,EACd,MAAM,GAAG,CAAC,EACV,IAAI,CAAC,MAAM,EAAE,CAAC,CAAC,EACf,KAAK,EAAE,EACP,YAAY;AACjB;AAUO,SAAS,mBACd,KACA,OAAmD,CAAC,GAC9C;AACN,QAAM,IAAI,IAAI,OAAO,OAAO,IAAI,IAAI,OAAO;AAC3C,QAAM,aAAa,KAAK,cAAc;AACtC,QAAM,WAAW,KAAK,YAAY;AAClC,QAAM,MAAM,KAAK,IAAI;AACrB,QAAM,IAAI,IAAI,qBAAqB,GAAG,KAAK,GAAG,CAAC;AAC/C,IAAE,aAAa,GAAG,eAAe;AACjC,IAAE,aAAa,GAAG,cAAc,QAAQ,GAAG;AAC3C,MAAI,KAAK;AACT,MAAI,YAAY;AAChB,MAAI,SAAS,GAAG,KAAK,GAAG,IAAI,GAAG;AAC/B,MAAI,QAAQ;AACd;AAOO,SAAS,UAAU,OAAmB,MAA4B;AACvE,SAAO;AAAA,IACL,YAAY;AAAA,IACZ,KAAK,KAAK,MAAM;AACd,YAAM,IAAI,IAAI,OAAO;AACrB,YAAM,IAAI,IAAI,OAAO;AAGrB,yBAAmB,GAAG;AACtB,UAAI,YAAY;AAChB,UAAI,YAAY,MAAM;AACtB,UAAI,OAAO,aAAa,MAAM,WAAW;AACzC,WAAK,MAAM,QAAQ,CAAC,MAAM,MAAM;AAC9B,YAAI,SAAS,MAAM,IAAI,GAAG,IAAI,OAAO,IAAI,GAAG;AAAA,MAC9C,CAAC;AACD,YAAM,QAAQ,KAAK,SAAS,MAAM;AAClC,UAAI,OAAO,eAAe,MAAM,QAAQ;AACxC,UAAI,YAAY,MAAM;AACtB,UAAI,SAAS,OAAO,IAAI,GAAG,IAAI,OAAO,KAAK,MAAM,SAAS,MAAM,EAAE;AAAA,IACpE;AAAA,EACF;AACF;AAmBA,IAAM,iBAAiB,oBAAI,IAA8C;AAClE,SAAS,aAAa,KAAuD;AAClF,MAAI,CAAC,IAAK,QAAO,QAAQ,QAAQ,IAAI;AACrC,QAAM,SAAS,eAAe,IAAI,GAAG;AACrC,MAAI,OAAQ,QAAO;AACnB,QAAM,IAAI,IAAI,QAAiC,CAAC,YAAY;AAC1D,QAAI,OAAO,UAAU,aAAa;AAAE,cAAQ,IAAI;AAAG;AAAA,IAAQ;AAC3D,UAAM,MAAM,IAAI,MAAM;AACtB,QAAI,cAAc;AAClB,QAAI,SAAS,MAAM,QAAQ,GAAG;AAC9B,QAAI,UAAU,MAAM,QAAQ,IAAI;AAChC,QAAI,MAAM;AAAA,EACZ,CAAC;AACD,iBAAe,IAAI,KAAK,CAAC;AACzB,SAAO;AACT;AAGA,SAAS,UAAU,KAA+B,MAAc,MAAc,UAA4B;AACxG,QAAM,QAAQ,KAAK,MAAM,KAAK,EAAE,OAAO,OAAO;AAC9C,QAAM,QAAkB,CAAC;AACzB,MAAI,MAAM;AACV,aAAW,KAAK,OAAO;AACrB,UAAM,OAAO,MAAM,GAAG,GAAG,IAAI,CAAC,KAAK;AACnC,QAAI,IAAI,YAAY,IAAI,EAAE,QAAQ,QAAQ,KAAK;AAC7C,YAAM,KAAK,GAAG;AACd,YAAM;AACN,UAAI,MAAM,WAAW,WAAW,EAAG;AAAA,IACrC,OAAO;AACL,YAAM;AAAA,IACR;AAAA,EACF;AACA,MAAI,OAAO,MAAM,SAAS,SAAU,OAAM,KAAK,GAAG;AAElD,QAAM,OAAO,MAAM,KAAK,GAAG;AAC3B,MAAI,KAAK,SAAS,KAAK,UAAU,MAAM,QAAQ;AAC7C,UAAM,MAAM,SAAS,CAAC,IAAI,SAAS,MAAM,MAAM,SAAS,CAAC,IAAI,UAAK,GAAG;AAAA,EACvE;AACA,SAAO;AACT;AAEO,SAAS,YAAY,OAAmB,MAA8B;AAC3E,SAAO;AAAA,IACL,YAAY;AAAA,IACZ,KAAK,KAAK,KAAK;AACb,YAAM,IAAI,IAAI,OAAO;AACrB,YAAM,IAAI,IAAI,OAAO;AACrB,YAAM,IAAI,QAAQ,GAAG,CAAC;AACtB,YAAM,KAAK,IAAI;AAEf,YAAM,KAAK,EAAE,MAAM,EAAE,IAAI;AACzB,YAAM,IAAI;AACV,YAAM,IAAI,KAAK,IAAI,GAAG,MAAM,GAAG;AAC/B,UAAI,cAAc;AAClB,UAAI,YAAY;AAGhB,UAAI,UAAU;AACd,UAAI,IAAI,IAAI,IAAI,GAAG,GAAG,KAAK,KAAK,CAAC;AACjC,UAAI,YAAY,MAAM;AACtB,UAAI,KAAK;AAET,UAAI,KAAK,UAAU;AAEjB,YAAI,KAAK;AACT,YAAI,UAAU;AACd,YAAI,IAAI,IAAI,IAAI,IAAI,GAAG,GAAG,KAAK,KAAK,CAAC;AACrC,YAAI,KAAK;AACT,cAAM,MAAM,KAAK;AACjB,cAAM,KAAK,IAAI,gBAAgB,IAAI,SAAS;AAC5C,cAAM,KAAK,IAAI,iBAAiB,IAAI,UAAU;AAC9C,cAAM,QAAQ,KAAK,IAAK,KAAK,IAAI,KAAM,IAAK,KAAK,IAAI,KAAM,EAAE;AAC7D,cAAM,KAAK,KAAK;AAChB,cAAM,KAAK,KAAK;AAChB,YAAI,UAAU,KAAK,KAAK,KAAK,GAAG,KAAK,KAAK,GAAG,IAAI,EAAE;AACnD,YAAI,QAAQ;AAAA,MACd,OAAO;AAEL,YAAI,YAAY,MAAM;AACtB,YAAI,OAAO,aAAa,MAAM,WAAW;AACzC,cAAM,QAAQ,KAAK,YAAY,SAAS,KAAK,KAAK;AAClD,YAAI,SAAS,OAAO,IAAI,KAAK,EAAE;AAAA,MACjC;AAEA,UAAI,YAAY;AAChB,UAAI,cAAc,MAAM;AACxB,UAAI,OAAO;AAGX,YAAM,YAAY,SAAS,KAAK,OAAO,EAAE;AACzC,UAAI,WAAW;AACf,UAAI,OAAO,QAAQ,QAAQ,MAAM,MAAM,WAAW;AAClD,UAAI,IAAI,YAAY,SAAS,EAAE,QAAQ,IAAI,MAAM;AAC/C,mBAAW;AACX,YAAI,OAAO,QAAQ,QAAQ,MAAM,MAAM,WAAW;AAAA,MACpD;AACA,UAAI,YAAY,MAAM;AACtB,UAAI,SAAS,WAAW,IAAI,KAAK,IAAI,GAAG;AAGxC,UAAI,OAAO,eAAe,MAAM,QAAQ;AACxC,UAAI,YAAY,MAAM;AACtB,UAAI,SAAS,KAAK,UAAU,IAAI,KAAK,IAAI,GAAG;AAG5C,UAAI,KAAK,SAAS;AAChB,YAAI,OAAO,QAAQ,MAAM,QAAQ;AACjC,YAAI,YAAY,MAAM;AACtB,cAAM,QAAQ,UAAU,KAAK,KAAK,SAAS,EAAE,IAAI,MAAM,CAAC;AACxD,cAAM,QAAQ,CAAC,MAAM,MAAM;AACzB,cAAI,SAAS,MAAM,IAAI,KAAK,IAAI,MAAM,IAAI,EAAE;AAAA,QAC9C,CAAC;AAAA,MACH;AAEA,UAAI,cAAc;AAAA,IACpB;AAAA,EACF;AACF;AAMO,SAAS,SAAS,OAAmB,MAA2B;AACrE,SAAO;AAAA,IACL,YAAY;AAAA,IACZ,KAAK,KAAK,MAAM;AACd,YAAM,IAAI,IAAI,OAAO;AACrB,YAAM,IAAI,IAAI,OAAO;AACrB,YAAM,IAAI,QAAQ,GAAG,CAAC;AAEtB,YAAM,QAAQ,EAAE,KAAK;AACrB,UAAI,YAAY;AAChB,UAAI,KAAK,MAAM,SAAS,GAAG;AACzB,YAAI,YAAY,MAAM;AACtB,YAAI,OAAO,aAAa,MAAM,WAAW;AACzC,YAAI,SAAS,KAAK,MAAM,CAAC,GAAG,IAAI,GAAG,KAAK;AAAA,MAC1C;AACA,WAAK,MAAM,MAAM,CAAC,EAAE,QAAQ,CAAC,MAAM,MAAM;AACvC,YAAI,YAAY,MAAM;AACtB,YAAI,OAAO,aAAa,MAAM,WAAW;AACzC,YAAI,SAAS,MAAM,IAAI,GAAG,QAAQ,OAAO,IAAI,EAAE;AAAA,MACjD,CAAC;AAAA,IACH;AAAA,EACF;AACF;AAIA,eAAsB,gBACpB,MACA,SAA4D,cAC7C;AACf,QAAM,IAAI;AACV,IAAE,cAAc,KAAK;AACrB,MAAI;AACF,UAAM,OAAO,MAAM,OAAO,KAAK,YAAY,GAAG;AAAA,MAC5C,aAAa,KAAK;AAAA,MAClB,OAAO,KAAK,SAAS;AAAA,MACrB,QAAQ,KAAK,UAAU;AAAA,MACvB,KAAK,KAAK;AAAA,MACV,YAAY,KAAK;AAAA,IACnB,CAAC;AACD,UAAM,MAAM,IAAI,gBAAgB,IAAI;AACpC,MAAE,iBAAiB;AACnB,QAAI,KAAK,aAAa,OAAO;AAC3B,YAAM,IAAI,SAAS,cAAc,GAAG;AACpC,QAAE,OAAO;AACT,QAAE,WAAW,SAAS,KAAK,KAAK,MAAM,MAAM;AAC5C,eAAS,KAAK,YAAY,CAAC;AAC3B,QAAE,MAAM;AAAA,IACV;AACA,MAAE,eAAe;AAAA,EACnB,SAAS,GAAG;AACV,MAAE,eAAe,OAAO,CAAC;AACzB,UAAM;AAAA,EACR;AACF;","names":[]}
|
|
@@ -49,9 +49,11 @@ function createSalamanderSampler(Tone, opts) {
|
|
|
49
49
|
urls: opts.urls,
|
|
50
50
|
baseUrl: opts.baseUrl,
|
|
51
51
|
release: opts.release,
|
|
52
|
+
attack: opts.attack,
|
|
52
53
|
onload: opts.onload,
|
|
53
54
|
onerror: opts.onerror
|
|
54
|
-
})
|
|
55
|
+
});
|
|
56
|
+
if (opts.connectToDestination !== false) sampler.toDestination();
|
|
55
57
|
if (opts.volumeDb !== void 0) sampler.volume.value = opts.volumeDb;
|
|
56
58
|
return sampler;
|
|
57
59
|
}
|
|
@@ -71,4 +73,4 @@ export {
|
|
|
71
73
|
createSalamanderSampler,
|
|
72
74
|
generateReverb
|
|
73
75
|
};
|
|
74
|
-
//# sourceMappingURL=chunk-
|
|
76
|
+
//# sourceMappingURL=chunk-JVGAABTK.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/salamander.ts","../src/audioHelpers.ts"],"sourcesContent":["export const SALAMANDER_CDN_BASE = 'https://tonejs.github.io/audio/salamander/';\n\n/** 8-anchor sample set (Stave uses these from the CDN). */\nexport const SALAMANDER_URLS_8: Record<string, string> = {\n C2: 'C2.mp3', 'F#2': 'Fs2.mp3', C3: 'C3.mp3', 'F#3': 'Fs3.mp3',\n C4: 'C4.mp3', 'F#4': 'Fs4.mp3', C5: 'C5.mp3', 'F#5': 'Fs5.mp3',\n};\n\n/** Full chromatic-anchor set A0..C8 (RET ships these locally under /audio/salamander/). */\nexport const SALAMANDER_URLS_FULL: Record<string, string> = {\n A0: 'A0.mp3', C1: 'C1.mp3', 'D#1': 'Ds1.mp3', 'F#1': 'Fs1.mp3', A1: 'A1.mp3',\n C2: 'C2.mp3', 'D#2': 'Ds2.mp3', 'F#2': 'Fs2.mp3', A2: 'A2.mp3',\n C3: 'C3.mp3', 'D#3': 'Ds3.mp3', 'F#3': 'Fs3.mp3', A3: 'A3.mp3',\n C4: 'C4.mp3', 'D#4': 'Ds4.mp3', 'F#4': 'Fs4.mp3', A4: 'A4.mp3',\n C5: 'C5.mp3', 'D#5': 'Ds5.mp3', 'F#5': 'Fs5.mp3', A5: 'A5.mp3',\n C6: 'C6.mp3', 'D#6': 'Ds6.mp3', 'F#6': 'Fs6.mp3', A6: 'A6.mp3',\n C7: 'C7.mp3', 'D#7': 'Ds7.mp3', 'F#7': 'Fs7.mp3', A7: 'A7.mp3',\n C8: 'C8.mp3',\n};\n","// Tone is provided by the consumer (peer dep); type loosely to avoid coupling.\ntype ToneModule = any; // eslint-disable-line @typescript-eslint/no-explicit-any\n\nexport interface SalamanderSamplerOpts {\n urls: Record<string, string>;\n baseUrl: string;\n release: number;\n volumeDb?: number;\n attack?: number;\n onload?: () => void;\n onerror?: (e: unknown) => void;\n /** Route the sampler straight to the speakers. Default true (preserves the\n * inline behavior both engines had). Pass false to build a custom signal\n * chain off the sampler instead (e.g. the promo mastering bus). */\n connectToDestination?: boolean;\n}\n\n/** Create a Salamander Tone.Sampler. By default routed to destination, mirroring\n * the exact `new Tone.Sampler({...}).toDestination()` both engines did inline.\n * With `connectToDestination:false` it returns an unrouted sampler so the caller\n * can insert a mastering chain. */\nexport function createSalamanderSampler(Tone: ToneModule, opts: SalamanderSamplerOpts) {\n const sampler = new Tone.Sampler({\n urls: opts.urls,\n baseUrl: opts.baseUrl,\n release: opts.release,\n attack: opts.attack,\n onload: opts.onload,\n onerror: opts.onerror,\n });\n if (opts.connectToDestination !== false) sampler.toDestination();\n if (opts.volumeDb !== undefined) sampler.volume.value = opts.volumeDb;\n return sampler;\n}\n\n/** Generate a Tone.Reverb, racing reverb.generate() against a timeout so it can\n * never hang init on iOS. Returns the reverb if generated, else null. Caller\n * wires routing (toDestination / reroute). Mirrors RET's addReverbInBackground. */\nexport async function generateReverb(\n Tone: ToneModule,\n opts: { decay: number; wet: number },\n timeoutMs: number,\n): Promise<any | null> { // eslint-disable-line @typescript-eslint/no-explicit-any\n const reverb = new Tone.Reverb(opts);\n const ok = await Promise.race([\n reverb.generate().then(() => true),\n new Promise<boolean>((r) => setTimeout(() => r(false), timeoutMs)),\n ]);\n return ok ? reverb : null;\n}\n"],"mappings":";AAAO,IAAM,sBAAsB;AAG5B,IAAM,oBAA4C;AAAA,EACvD,IAAI;AAAA,EAAU,OAAO;AAAA,EAAW,IAAI;AAAA,EAAU,OAAO;AAAA,EACrD,IAAI;AAAA,EAAU,OAAO;AAAA,EAAW,IAAI;AAAA,EAAU,OAAO;AACvD;AAGO,IAAM,uBAA+C;AAAA,EAC1D,IAAI;AAAA,EAAU,IAAI;AAAA,EAAU,OAAO;AAAA,EAAW,OAAO;AAAA,EAAW,IAAI;AAAA,EACpE,IAAI;AAAA,EAAU,OAAO;AAAA,EAAW,OAAO;AAAA,EAAW,IAAI;AAAA,EACtD,IAAI;AAAA,EAAU,OAAO;AAAA,EAAW,OAAO;AAAA,EAAW,IAAI;AAAA,EACtD,IAAI;AAAA,EAAU,OAAO;AAAA,EAAW,OAAO;AAAA,EAAW,IAAI;AAAA,EACtD,IAAI;AAAA,EAAU,OAAO;AAAA,EAAW,OAAO;AAAA,EAAW,IAAI;AAAA,EACtD,IAAI;AAAA,EAAU,OAAO;AAAA,EAAW,OAAO;AAAA,EAAW,IAAI;AAAA,EACtD,IAAI;AAAA,EAAU,OAAO;AAAA,EAAW,OAAO;AAAA,EAAW,IAAI;AAAA,EACtD,IAAI;AACN;;;ACGO,SAAS,wBAAwB,MAAkB,MAA6B;AACrF,QAAM,UAAU,IAAI,KAAK,QAAQ;AAAA,IAC/B,MAAM,KAAK;AAAA,IACX,SAAS,KAAK;AAAA,IACd,SAAS,KAAK;AAAA,IACd,QAAQ,KAAK;AAAA,IACb,QAAQ,KAAK;AAAA,IACb,SAAS,KAAK;AAAA,EAChB,CAAC;AACD,MAAI,KAAK,yBAAyB,MAAO,SAAQ,cAAc;AAC/D,MAAI,KAAK,aAAa,OAAW,SAAQ,OAAO,QAAQ,KAAK;AAC7D,SAAO;AACT;AAKA,eAAsB,eACpB,MACA,MACA,WACqB;AACrB,QAAM,SAAS,IAAI,KAAK,OAAO,IAAI;AACnC,QAAM,KAAK,MAAM,QAAQ,KAAK;AAAA,IAC5B,OAAO,SAAS,EAAE,KAAK,MAAM,IAAI;AAAA,IACjC,IAAI,QAAiB,CAAC,MAAM,WAAW,MAAM,EAAE,KAAK,GAAG,SAAS,CAAC;AAAA,EACnE,CAAC;AACD,SAAO,KAAK,SAAS;AACvB;","names":[]}
|
package/dist/promo.d.ts
CHANGED
|
@@ -61,20 +61,35 @@ interface PromoSampler {
|
|
|
61
61
|
Tone: any;
|
|
62
62
|
sampler: unknown;
|
|
63
63
|
getStream(): MediaStream;
|
|
64
|
+
/** Master-bus analyser (post-chain, sink-only) for reactive visualizers, or
|
|
65
|
+
* null if `analyser` wasn't requested. whozart's "listen" spectrum reads it. */
|
|
66
|
+
getAnalyser(): AnalyserNode | null;
|
|
64
67
|
/** Current audio-clock time in seconds (Tone.now()). */
|
|
65
68
|
audioNow(): number;
|
|
66
69
|
/** releaseAll on the sampler; safe no-op on failure. */
|
|
67
70
|
stop(): void;
|
|
68
71
|
}
|
|
72
|
+
/** Per-app tone shaping for the shared promo mastering bus. Ported from
|
|
73
|
+
* whozart's audio-impl chain (the richest of the fleet); `reading` is a drier,
|
|
74
|
+
* closer voicing so sight-reading notation stays legible note-to-note. Apps
|
|
75
|
+
* pick one via `createPromoSampler({ voicing })`. */
|
|
76
|
+
type PromoVoicing = 'concertHall' | 'reading' | 'dry';
|
|
69
77
|
declare function createPromoSampler(opts?: {
|
|
70
78
|
/** Feed a 0-value ConstantSource into the capture stream so the recorder's
|
|
71
79
|
* audio track is live from t=0 (silent intro scenes aren't dropped).
|
|
72
80
|
* Default false (RSR behavior). RMT passes true. */
|
|
73
81
|
keepAlive?: boolean;
|
|
74
|
-
/**
|
|
82
|
+
/** Override the voicing's sampler release time (seconds). */
|
|
75
83
|
release?: number;
|
|
76
84
|
/** Sampler volume in dB. Default -2. */
|
|
77
85
|
volumeDb?: number;
|
|
86
|
+
/** Per-app tone shaping (see PromoVoicing). Default 'concertHall'. */
|
|
87
|
+
voicing?: PromoVoicing;
|
|
88
|
+
/** Use the full A0..C8 Salamander anchor set instead of the 8-anchor set —
|
|
89
|
+
* fuller tone, more samples to fetch. Default false (8-anchor). */
|
|
90
|
+
fullSamples?: boolean;
|
|
91
|
+
/** Expose a master-bus analyser (for reactive visualizers). Default false. */
|
|
92
|
+
analyser?: boolean;
|
|
78
93
|
}): Promise<PromoSampler>;
|
|
79
94
|
|
|
80
|
-
export { type Box, type MeasureColumnBox, type MidiNote, type ParsedMidi, type PromoSampler, type RenderNotationOpts, type RenderedNotation, type StaffMeasureBox, createPromoSampler, midiDurationMs, parseMidi, renderNotation };
|
|
95
|
+
export { type Box, type MeasureColumnBox, type MidiNote, type ParsedMidi, type PromoSampler, type PromoVoicing, type RenderNotationOpts, type RenderedNotation, type StaffMeasureBox, createPromoSampler, midiDurationMs, parseMidi, renderNotation };
|
package/dist/promo.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import {
|
|
2
2
|
SALAMANDER_CDN_BASE,
|
|
3
3
|
SALAMANDER_URLS_8,
|
|
4
|
-
|
|
5
|
-
|
|
4
|
+
SALAMANDER_URLS_FULL,
|
|
5
|
+
createSalamanderSampler,
|
|
6
|
+
generateReverb
|
|
7
|
+
} from "./chunk-JVGAABTK.js";
|
|
6
8
|
|
|
7
9
|
// src/promo.ts
|
|
8
10
|
function parseMidi(buf) {
|
|
@@ -318,21 +320,76 @@ async function renderNotation(xml, opts) {
|
|
|
318
320
|
throw e;
|
|
319
321
|
}
|
|
320
322
|
}
|
|
323
|
+
var PROMO_VOICINGS = {
|
|
324
|
+
// whozart's hall sound: long ringing release, lifted lows/air, gentle glue
|
|
325
|
+
// compression, wide stereo, a real-hall reverb tail.
|
|
326
|
+
concertHall: {
|
|
327
|
+
release: 1.8,
|
|
328
|
+
attack: 5e-3,
|
|
329
|
+
eq: { low: 1.5, mid: -1, high: 1.5, lowFrequency: 280, highFrequency: 4500 },
|
|
330
|
+
comp: { threshold: -18, ratio: 1.6, attack: 0.012, release: 0.3, knee: 18 },
|
|
331
|
+
widen: 0.3,
|
|
332
|
+
reverb: { decay: 3.5, preDelay: 0.04, wet: 0.22 }
|
|
333
|
+
},
|
|
334
|
+
// Sight-reading: shorter release + much drier/shorter reverb so consecutive
|
|
335
|
+
// notes don't smear into each other while the eye tracks the staff. Small
|
|
336
|
+
// presence bump in the mids for note-attack clarity, narrower stereo.
|
|
337
|
+
reading: {
|
|
338
|
+
release: 1.1,
|
|
339
|
+
attack: 4e-3,
|
|
340
|
+
eq: { low: 0.5, mid: 1, high: 1, lowFrequency: 280, highFrequency: 5e3 },
|
|
341
|
+
comp: { threshold: -16, ratio: 2, attack: 8e-3, release: 0.25, knee: 12 },
|
|
342
|
+
widen: 0.18,
|
|
343
|
+
reverb: { decay: 1.4, preDelay: 0.02, wet: 0.1 }
|
|
344
|
+
},
|
|
345
|
+
// Bone-dry — no reverb at all (e.g. a debugging/reference voicing).
|
|
346
|
+
dry: {
|
|
347
|
+
release: 1,
|
|
348
|
+
attack: 4e-3,
|
|
349
|
+
eq: { low: 0, mid: 0, high: 0, lowFrequency: 280, highFrequency: 4500 },
|
|
350
|
+
comp: { threshold: -14, ratio: 2, attack: 8e-3, release: 0.25, knee: 10 },
|
|
351
|
+
widen: 0,
|
|
352
|
+
reverb: { decay: 1, preDelay: 0, wet: 0 }
|
|
353
|
+
}
|
|
354
|
+
};
|
|
321
355
|
async function createPromoSampler(opts) {
|
|
322
|
-
const
|
|
356
|
+
const voicing = PROMO_VOICINGS[opts?.voicing ?? "concertHall"];
|
|
357
|
+
const release = opts?.release ?? voicing.release;
|
|
323
358
|
const volumeDb = opts?.volumeDb ?? -2;
|
|
324
359
|
const keepAlive = opts?.keepAlive ?? false;
|
|
325
360
|
const Tone = await import("tone");
|
|
326
361
|
await Tone.start();
|
|
327
362
|
const sampler = createSalamanderSampler(Tone, {
|
|
328
|
-
urls: SALAMANDER_URLS_8,
|
|
363
|
+
urls: opts?.fullSamples ? SALAMANDER_URLS_FULL : SALAMANDER_URLS_8,
|
|
329
364
|
baseUrl: SALAMANDER_CDN_BASE,
|
|
330
365
|
release,
|
|
331
|
-
|
|
366
|
+
attack: voicing.attack,
|
|
367
|
+
volumeDb,
|
|
368
|
+
connectToDestination: false
|
|
332
369
|
});
|
|
370
|
+
const eq = new Tone.EQ3(voicing.eq);
|
|
371
|
+
const compressor = new Tone.Compressor(voicing.comp);
|
|
372
|
+
const widener = new Tone.StereoWidener(voicing.widen);
|
|
373
|
+
const limiter = new Tone.Limiter(-1.5);
|
|
374
|
+
const reverb = voicing.reverb.wet > 0 ? await generateReverb(
|
|
375
|
+
Tone,
|
|
376
|
+
{ decay: voicing.reverb.decay, wet: voicing.reverb.wet },
|
|
377
|
+
4e3
|
|
378
|
+
) : null;
|
|
379
|
+
if (reverb) reverb.preDelay = voicing.reverb.preDelay;
|
|
380
|
+
const chainNodes = reverb ? [eq, compressor, widener, reverb, limiter] : [eq, compressor, widener, limiter];
|
|
381
|
+
sampler.chain(...chainNodes);
|
|
333
382
|
const ctx = Tone.getContext().rawContext;
|
|
334
383
|
const mediaDest = ctx.createMediaStreamDestination();
|
|
335
|
-
|
|
384
|
+
limiter.connect(mediaDest);
|
|
385
|
+
limiter.connect(Tone.getDestination());
|
|
386
|
+
let analyserNode = null;
|
|
387
|
+
if (opts?.analyser) {
|
|
388
|
+
analyserNode = ctx.createAnalyser();
|
|
389
|
+
analyserNode.fftSize = 2048;
|
|
390
|
+
analyserNode.smoothingTimeConstant = 0.8;
|
|
391
|
+
limiter.connect(analyserNode);
|
|
392
|
+
}
|
|
336
393
|
if (keepAlive) {
|
|
337
394
|
const source = ctx.createConstantSource();
|
|
338
395
|
source.offset.value = 0;
|
|
@@ -346,6 +403,9 @@ async function createPromoSampler(opts) {
|
|
|
346
403
|
getStream() {
|
|
347
404
|
return mediaDest.stream;
|
|
348
405
|
},
|
|
406
|
+
getAnalyser() {
|
|
407
|
+
return analyserNode;
|
|
408
|
+
},
|
|
349
409
|
audioNow() {
|
|
350
410
|
return Tone.now();
|
|
351
411
|
},
|