@real-music-packages/web-core 0.34.0 → 0.35.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.js +1 -1
- package/dist/{chunk-JVGAABTK.js → chunk-74TMWRNK.js} +6 -2
- package/dist/chunk-74TMWRNK.js.map +1 -0
- package/dist/index.d.ts +25 -1
- package/dist/index.js +81 -0
- package/dist/index.js.map +1 -1
- package/dist/promo.js +1 -1
- package/package.json +1 -1
- package/dist/chunk-JVGAABTK.js.map +0 -1
package/dist/audio.js
CHANGED
|
@@ -49,7 +49,11 @@ function createSalamanderSampler(Tone, opts) {
|
|
|
49
49
|
urls: opts.urls,
|
|
50
50
|
baseUrl: opts.baseUrl,
|
|
51
51
|
release: opts.release,
|
|
52
|
-
|
|
52
|
+
// Never let `undefined` through: Tone.Sampler stores it verbatim, each
|
|
53
|
+
// note's source then gets `fadeIn: undefined`, and toSeconds(undefined)
|
|
54
|
+
// means "now" — a fade-in equal to the context's clock. Audibly, every
|
|
55
|
+
// chord after the first swells in like a pad, slower as playback runs.
|
|
56
|
+
attack: opts.attack ?? 0,
|
|
53
57
|
onload: opts.onload,
|
|
54
58
|
onerror: opts.onerror
|
|
55
59
|
});
|
|
@@ -73,4 +77,4 @@ export {
|
|
|
73
77
|
createSalamanderSampler,
|
|
74
78
|
generateReverb
|
|
75
79
|
};
|
|
76
|
-
//# sourceMappingURL=chunk-
|
|
80
|
+
//# sourceMappingURL=chunk-74TMWRNK.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 // Never let `undefined` through: Tone.Sampler stores it verbatim, each\n // note's source then gets `fadeIn: undefined`, and toSeconds(undefined)\n // means \"now\" — a fade-in equal to the context's clock. Audibly, every\n // chord after the first swells in like a pad, slower as playback runs.\n attack: opts.attack ?? 0,\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;AAAA;AAAA;AAAA;AAAA,IAKd,QAAQ,KAAK,UAAU;AAAA,IACvB,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/index.d.ts
CHANGED
|
@@ -160,4 +160,28 @@ interface ChordVoicing {
|
|
|
160
160
|
*/
|
|
161
161
|
declare function distributeChord(midiNotes: number[]): ChordVoicing[];
|
|
162
162
|
|
|
163
|
-
|
|
163
|
+
interface ShareCardOptions {
|
|
164
|
+
appName: string;
|
|
165
|
+
accentColor: string;
|
|
166
|
+
drillTitle: string;
|
|
167
|
+
score: number;
|
|
168
|
+
total: number;
|
|
169
|
+
streakBest: number;
|
|
170
|
+
url: string;
|
|
171
|
+
logoText?: string;
|
|
172
|
+
}
|
|
173
|
+
declare function shareFilename(appName: string, drillTitle: string): string;
|
|
174
|
+
declare function shareCardLines(r: {
|
|
175
|
+
score: number;
|
|
176
|
+
total: number;
|
|
177
|
+
streakBest: number;
|
|
178
|
+
}): {
|
|
179
|
+
scoreLine: string;
|
|
180
|
+
percentLine: string;
|
|
181
|
+
streakLine: string;
|
|
182
|
+
};
|
|
183
|
+
declare function renderShareCard(opts: ShareCardOptions): Promise<Blob>;
|
|
184
|
+
/** Share via navigator.share(files) → clipboard image → download. Never throws. */
|
|
185
|
+
declare function shareOrDownload(blob: Blob, filename: string, shareText: string): Promise<'shared' | 'copied' | 'downloaded'>;
|
|
186
|
+
|
|
187
|
+
export { ALL_KEYS, type BestPositionOpts, CHORD_TEMPLATES, type ChordTemplate, type ChordVoicing, type FretPosition, INTERVALS, type IntervalDef, KEYS_PREFER_FLATS, MAJOR_SCALE_INTERVALS, MAX_FRET, NOTE_NAMES, NOTE_NAMES_FLAT, STRINGS, STRING_COUNT, type ShareCardOptions, bestPosition, buildPlacementMap, chosenPlacement, distributeChord, enumerateBoardCells, getMidiNote, getScaleDegree, getStability, intervalBySemitones, isInScale, midiToFrequency, midiToNoteName, midiToPositions, noteNameToIndex, noteToFret, pitchClass, renderShareCard, shareCardLines, shareFilename, shareOrDownload, useFlatsForKeyFifths, useFlatsForKeyName };
|
package/dist/index.js
CHANGED
|
@@ -50,6 +50,83 @@ var CHORD_TEMPLATES = [
|
|
|
50
50
|
{ name: "sus4", intervals: [0, 5, 7] },
|
|
51
51
|
{ name: "sus2", intervals: [0, 2, 7] }
|
|
52
52
|
];
|
|
53
|
+
|
|
54
|
+
// src/shareCard.ts
|
|
55
|
+
function shareFilename(appName, drillTitle) {
|
|
56
|
+
const slug = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
57
|
+
return `${slug(appName)}-${slug(drillTitle)}-score.png`;
|
|
58
|
+
}
|
|
59
|
+
function shareCardLines(r) {
|
|
60
|
+
return {
|
|
61
|
+
scoreLine: `${r.score} / ${r.total}`,
|
|
62
|
+
percentLine: `${Math.round(r.score / r.total * 100)}%`,
|
|
63
|
+
streakLine: `Best streak: ${r.streakBest}`
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
var W = 1080;
|
|
67
|
+
var H = 1350;
|
|
68
|
+
var FONT = "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif";
|
|
69
|
+
async function renderShareCard(opts) {
|
|
70
|
+
const canvas = document.createElement("canvas");
|
|
71
|
+
canvas.width = W;
|
|
72
|
+
canvas.height = H;
|
|
73
|
+
const ctx = canvas.getContext("2d");
|
|
74
|
+
if (!ctx) throw new Error("canvas 2d unavailable");
|
|
75
|
+
const { scoreLine, percentLine, streakLine } = shareCardLines(opts);
|
|
76
|
+
ctx.fillStyle = "#0b0e14";
|
|
77
|
+
ctx.fillRect(0, 0, W, H);
|
|
78
|
+
const glow = ctx.createRadialGradient(W * 0.8, H * 0.15, 0, W * 0.8, H * 0.15, W * 0.9);
|
|
79
|
+
glow.addColorStop(0, opts.accentColor + "55");
|
|
80
|
+
glow.addColorStop(1, "transparent");
|
|
81
|
+
ctx.fillStyle = glow;
|
|
82
|
+
ctx.fillRect(0, 0, W, H);
|
|
83
|
+
ctx.textAlign = "center";
|
|
84
|
+
ctx.fillStyle = "#9aa3b2";
|
|
85
|
+
ctx.font = `600 44px ${FONT}`;
|
|
86
|
+
ctx.fillText((opts.logoText ?? opts.appName).toUpperCase(), W / 2, 140);
|
|
87
|
+
ctx.fillStyle = "#e8ecf3";
|
|
88
|
+
ctx.font = `800 72px ${FONT}`;
|
|
89
|
+
ctx.fillText(opts.drillTitle, W / 2, 320);
|
|
90
|
+
ctx.fillStyle = opts.accentColor;
|
|
91
|
+
ctx.font = `800 260px ${FONT}`;
|
|
92
|
+
ctx.fillText(percentLine, W / 2, 680);
|
|
93
|
+
ctx.fillStyle = "#e8ecf3";
|
|
94
|
+
ctx.font = `600 56px ${FONT}`;
|
|
95
|
+
ctx.fillText(`${scoreLine} correct`, W / 2, 790);
|
|
96
|
+
ctx.fillText(streakLine, W / 2, 880);
|
|
97
|
+
ctx.fillStyle = "#9aa3b2";
|
|
98
|
+
ctx.font = `600 44px ${FONT}`;
|
|
99
|
+
ctx.fillText("Can you beat it?", W / 2, 1120);
|
|
100
|
+
ctx.fillStyle = opts.accentColor;
|
|
101
|
+
ctx.font = `700 48px ${FONT}`;
|
|
102
|
+
ctx.fillText(opts.url.replace(/^https?:\/\//, ""), W / 2, 1200);
|
|
103
|
+
return await new Promise(
|
|
104
|
+
(resolve, reject) => canvas.toBlob((b) => b ? resolve(b) : reject(new Error("toBlob failed")), "image/png")
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
async function shareOrDownload(blob, filename, shareText) {
|
|
108
|
+
const file = new File([blob], filename, { type: "image/png" });
|
|
109
|
+
try {
|
|
110
|
+
if (typeof navigator !== "undefined" && navigator.canShare?.({ files: [file] })) {
|
|
111
|
+
await navigator.share({ files: [file], text: shareText });
|
|
112
|
+
return "shared";
|
|
113
|
+
}
|
|
114
|
+
} catch {
|
|
115
|
+
}
|
|
116
|
+
try {
|
|
117
|
+
if (typeof ClipboardItem !== "undefined" && navigator.clipboard?.write) {
|
|
118
|
+
await navigator.clipboard.write([new ClipboardItem({ "image/png": blob })]);
|
|
119
|
+
return "copied";
|
|
120
|
+
}
|
|
121
|
+
} catch {
|
|
122
|
+
}
|
|
123
|
+
const a = document.createElement("a");
|
|
124
|
+
a.href = URL.createObjectURL(blob);
|
|
125
|
+
a.download = filename;
|
|
126
|
+
a.click();
|
|
127
|
+
URL.revokeObjectURL(a.href);
|
|
128
|
+
return "downloaded";
|
|
129
|
+
}
|
|
53
130
|
export {
|
|
54
131
|
ALL_KEYS,
|
|
55
132
|
CHORD_TEMPLATES,
|
|
@@ -77,6 +154,10 @@ export {
|
|
|
77
154
|
noteNameToIndex,
|
|
78
155
|
noteToFret,
|
|
79
156
|
pitchClass,
|
|
157
|
+
renderShareCard,
|
|
158
|
+
shareCardLines,
|
|
159
|
+
shareFilename,
|
|
160
|
+
shareOrDownload,
|
|
80
161
|
useFlatsForKeyFifths,
|
|
81
162
|
useFlatsForKeyName
|
|
82
163
|
};
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/frequency.ts","../src/chords.ts"],"sourcesContent":["/** Equal-temperament MIDI → frequency (A4 = MIDI 69 = 440 Hz). */\nexport function midiToFrequency(midi: number): number {\n return 440 * Math.pow(2, (midi - 69) / 12);\n}\n","export interface ChordTemplate { name: string; intervals: number[] }\n\n/** Chord-quality templates, richer qualities first so naming matches the\n * most specific quality (e.g. maj7 before plain major). */\nexport const CHORD_TEMPLATES: ChordTemplate[] = [\n { name: 'maj7', intervals: [0, 4, 7, 11] },\n { name: '7', intervals: [0, 4, 7, 10] },\n { name: 'm7', intervals: [0, 3, 7, 10] },\n { name: 'dim7', intervals: [0, 3, 6, 9] },\n { name: 'm7b5', intervals: [0, 3, 6, 10] },\n { name: '', intervals: [0, 4, 7] },\n { name: 'm', intervals: [0, 3, 7] },\n { name: 'dim', intervals: [0, 3, 6] },\n { name: 'aug', intervals: [0, 4, 8] },\n { name: 'sus4', intervals: [0, 5, 7] },\n { name: 'sus2', intervals: [0, 2, 7] },\n];\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AACO,SAAS,gBAAgB,MAAsB;AACpD,SAAO,MAAM,KAAK,IAAI,IAAI,OAAO,MAAM,EAAE;AAC3C;;;ACCO,IAAM,kBAAmC;AAAA,EAC9C,EAAE,MAAM,QAAQ,WAAW,CAAC,GAAG,GAAG,GAAG,EAAE,EAAE;AAAA,EACzC,EAAE,MAAM,KAAQ,WAAW,CAAC,GAAG,GAAG,GAAG,EAAE,EAAE;AAAA,EACzC,EAAE,MAAM,MAAQ,WAAW,CAAC,GAAG,GAAG,GAAG,EAAE,EAAE;AAAA,EACzC,EAAE,MAAM,QAAQ,WAAW,CAAC,GAAG,GAAG,GAAG,CAAC,EAAE;AAAA,EACxC,EAAE,MAAM,QAAQ,WAAW,CAAC,GAAG,GAAG,GAAG,EAAE,EAAE;AAAA,EACzC,EAAE,MAAM,IAAQ,WAAW,CAAC,GAAG,GAAG,CAAC,EAAE;AAAA,EACrC,EAAE,MAAM,KAAQ,WAAW,CAAC,GAAG,GAAG,CAAC,EAAE;AAAA,EACrC,EAAE,MAAM,OAAQ,WAAW,CAAC,GAAG,GAAG,CAAC,EAAE;AAAA,EACrC,EAAE,MAAM,OAAQ,WAAW,CAAC,GAAG,GAAG,CAAC,EAAE;AAAA,EACrC,EAAE,MAAM,QAAQ,WAAW,CAAC,GAAG,GAAG,CAAC,EAAE;AAAA,EACrC,EAAE,MAAM,QAAQ,WAAW,CAAC,GAAG,GAAG,CAAC,EAAE;AACvC;","names":[]}
|
|
1
|
+
{"version":3,"sources":["../src/frequency.ts","../src/chords.ts","../src/shareCard.ts"],"sourcesContent":["/** Equal-temperament MIDI → frequency (A4 = MIDI 69 = 440 Hz). */\nexport function midiToFrequency(midi: number): number {\n return 440 * Math.pow(2, (midi - 69) / 12);\n}\n","export interface ChordTemplate { name: string; intervals: number[] }\n\n/** Chord-quality templates, richer qualities first so naming matches the\n * most specific quality (e.g. maj7 before plain major). */\nexport const CHORD_TEMPLATES: ChordTemplate[] = [\n { name: 'maj7', intervals: [0, 4, 7, 11] },\n { name: '7', intervals: [0, 4, 7, 10] },\n { name: 'm7', intervals: [0, 3, 7, 10] },\n { name: 'dim7', intervals: [0, 3, 6, 9] },\n { name: 'm7b5', intervals: [0, 3, 6, 10] },\n { name: '', intervals: [0, 4, 7] },\n { name: 'm', intervals: [0, 3, 7] },\n { name: 'dim', intervals: [0, 3, 6] },\n { name: 'aug', intervals: [0, 4, 8] },\n { name: 'sus4', intervals: [0, 5, 7] },\n { name: 'sus2', intervals: [0, 2, 7] },\n];\n","// Post-round score-share card: branded 1080×1350 PNG rendered client-side.\n// Pure-canvas, no font/image fetches — safe on any origin.\n\nexport interface ShareCardOptions {\n appName: string; // \"Real Ear Trainer\"\n accentColor: string; // app theme accent, e.g. \"#5492D4\"\n drillTitle: string; // \"Name the Interval\"\n score: number;\n total: number;\n streakBest: number;\n url: string; // canonical drill URL printed on the card\n logoText?: string; // defaults to appName\n}\n\nexport function shareFilename(appName: string, drillTitle: string): string {\n const slug = (s: string) =>\n s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');\n return `${slug(appName)}-${slug(drillTitle)}-score.png`;\n}\n\nexport function shareCardLines(r: { score: number; total: number; streakBest: number }) {\n return {\n scoreLine: `${r.score} / ${r.total}`,\n percentLine: `${Math.round((r.score / r.total) * 100)}%`,\n streakLine: `Best streak: ${r.streakBest}`,\n };\n}\n\nconst W = 1080;\nconst H = 1350;\nconst FONT = \"-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif\";\n\nexport async function renderShareCard(opts: ShareCardOptions): Promise<Blob> {\n const canvas = document.createElement('canvas');\n canvas.width = W;\n canvas.height = H;\n const ctx = canvas.getContext('2d');\n if (!ctx) throw new Error('canvas 2d unavailable');\n const { scoreLine, percentLine, streakLine } = shareCardLines(opts);\n\n // Background: near-black with a soft accent glow top-right.\n ctx.fillStyle = '#0b0e14';\n ctx.fillRect(0, 0, W, H);\n const glow = ctx.createRadialGradient(W * 0.8, H * 0.15, 0, W * 0.8, H * 0.15, W * 0.9);\n glow.addColorStop(0, opts.accentColor + '55');\n glow.addColorStop(1, 'transparent');\n ctx.fillStyle = glow;\n ctx.fillRect(0, 0, W, H);\n\n ctx.textAlign = 'center';\n ctx.fillStyle = '#9aa3b2';\n ctx.font = `600 44px ${FONT}`;\n ctx.fillText((opts.logoText ?? opts.appName).toUpperCase(), W / 2, 140);\n\n ctx.fillStyle = '#e8ecf3';\n ctx.font = `800 72px ${FONT}`;\n ctx.fillText(opts.drillTitle, W / 2, 320);\n\n ctx.fillStyle = opts.accentColor;\n ctx.font = `800 260px ${FONT}`;\n ctx.fillText(percentLine, W / 2, 680);\n\n ctx.fillStyle = '#e8ecf3';\n ctx.font = `600 56px ${FONT}`;\n ctx.fillText(`${scoreLine} correct`, W / 2, 790);\n ctx.fillText(streakLine, W / 2, 880);\n\n ctx.fillStyle = '#9aa3b2';\n ctx.font = `600 44px ${FONT}`;\n ctx.fillText('Can you beat it?', W / 2, 1120);\n ctx.fillStyle = opts.accentColor;\n ctx.font = `700 48px ${FONT}`;\n ctx.fillText(opts.url.replace(/^https?:\\/\\//, ''), W / 2, 1200);\n\n return await new Promise<Blob>((resolve, reject) =>\n canvas.toBlob((b) => (b ? resolve(b) : reject(new Error('toBlob failed'))), 'image/png')\n );\n}\n\n/** Share via navigator.share(files) → clipboard image → download. Never throws. */\nexport async function shareOrDownload(\n blob: Blob,\n filename: string,\n shareText: string\n): Promise<'shared' | 'copied' | 'downloaded'> {\n const file = new File([blob], filename, { type: 'image/png' });\n try {\n if (typeof navigator !== 'undefined' && navigator.canShare?.({ files: [file] })) {\n await navigator.share({ files: [file], text: shareText });\n return 'shared';\n }\n } catch {\n /* user cancelled or share failed — fall through */\n }\n try {\n if (typeof ClipboardItem !== 'undefined' && navigator.clipboard?.write) {\n await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]);\n return 'copied';\n }\n } catch {\n /* clipboard blocked — fall through */\n }\n const a = document.createElement('a');\n a.href = URL.createObjectURL(blob);\n a.download = filename;\n a.click();\n URL.revokeObjectURL(a.href);\n return 'downloaded';\n}\n"],"mappings":"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","names":[]}
|
package/dist/promo.js
CHANGED
package/package.json
CHANGED
|
@@ -1 +0,0 @@
|
|
|
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":[]}
|