@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 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. Mirrors the exact
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
@@ -9,7 +9,7 @@ import {
9
9
  SALAMANDER_URLS_FULL,
10
10
  createSalamanderSampler,
11
11
  generateReverb
12
- } from "./chunk-BPL5LLQH.js";
12
+ } from "./chunk-JVGAABTK.js";
13
13
 
14
14
  // src/engine.ts
15
15
  var browser = typeof window !== "undefined";
@@ -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":"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","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
- }).toDestination();
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-BPL5LLQH.js.map
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
- /** Sampler release time in seconds. Default 1.4. */
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
- createSalamanderSampler
5
- } from "./chunk-BPL5LLQH.js";
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 release = opts?.release ?? 1.4;
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
- volumeDb
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
- sampler.connect(mediaDest);
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
  },