Add the Academy: narrated, animated roasting & brewing lessons; cupping link on roast detail
Test and deploy / test-and-deploy (push) Successful in 53s
Test and deploy / test-and-deploy (push) Successful in 53s
Academy (/academy, 'Learn' nav group): - 10 lessons / 45 slides across two tracks. Roasting: seed anatomy and composition, drying & the turning point, Maillard, first crack & development (DTR), curve/RoR reading (crash, flick, stall), defects → one-change discipline. Brewing: extraction physics (dissolving order, yield, grind, temp/time/ratio), then technique per brewer family — immersion (French press, AeroPress, Clever/Hario Switch, cold brew, cupping), percolation (bloom, V60, Chemex/Kalita, batch, percolator), espresso & pressure (puck prep, shot reading, dialing in, moka) - One cohesive scene system: 25 parameterized animated SVG scenes drawn from the app's palette (beans, curves, phase bars, brewers reusing the silhouette library), CSS keyframe animations, reduced-motion support - Narration: Azure TTS (en-US-Andrew HD, eastus) pre-generated to 45 committed MP3s by scripts/generate-academy-audio.mjs; slides speak phonetic respellings (my-YARD, KEM-ex, MOH-kah…) while captions show normal spelling; hands-free autoplay advances after each clip - Player: slide dots, keyboard arrows/space, per-slide captions Roast detail: 'Open cupping session' button next to cup notes (opens or creates the linked plan's session) — its score and flavors flow into the updated .alog download. Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Fable 5
parent
0b4857c225
commit
64c1b3605b
@@ -0,0 +1,73 @@
|
||||
// Pre-generates the Academy narration MP3s with Azure TTS and writes them to
|
||||
// public/academy-audio/. Run locally (the key never ships): reads AZURE_VOICE_KEY and
|
||||
// AZURE_VOICE from .env, region eastus. Idempotent — pass --force to regenerate everything;
|
||||
// otherwise only missing slide audio is synthesized. The spoken text is slide.tts (phonetic
|
||||
// respelling) when present, else the display transcript.
|
||||
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
||||
import { existsSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const { ACADEMY_TRACKS } = await import(path.join(root, "public/js/academy-content.js"));
|
||||
|
||||
const env = Object.fromEntries(
|
||||
(await readFile(path.join(root, ".env"), "utf8"))
|
||||
.split("\n")
|
||||
.filter((line) => line.includes("="))
|
||||
.map((line) => {
|
||||
const i = line.indexOf("=");
|
||||
return [line.slice(0, i).trim(), line.slice(i + 1).trim().replace(/^"|"$/g, "")];
|
||||
}),
|
||||
);
|
||||
const KEY = env.AZURE_VOICE_KEY;
|
||||
const VOICE = env.AZURE_VOICE;
|
||||
if (!KEY || !VOICE) throw new Error("AZURE_VOICE_KEY / AZURE_VOICE missing from .env");
|
||||
|
||||
const outDir = path.join(root, "public", "academy-audio");
|
||||
await mkdir(outDir, { recursive: true });
|
||||
const force = process.argv.includes("--force");
|
||||
|
||||
const escapeXml = (s) =>
|
||||
s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
|
||||
const slides = [];
|
||||
const seen = new Set();
|
||||
for (const track of ACADEMY_TRACKS)
|
||||
for (const lesson of track.lessons)
|
||||
for (const slide of lesson.slides) {
|
||||
if (seen.has(slide.id)) throw new Error(`duplicate slide id ${slide.id}`);
|
||||
seen.add(slide.id);
|
||||
slides.push(slide);
|
||||
}
|
||||
|
||||
let made = 0;
|
||||
let skipped = 0;
|
||||
for (const slide of slides) {
|
||||
const file = path.join(outDir, `${slide.id}.mp3`);
|
||||
if (!force && existsSync(file)) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
const ssml = `<speak version='1.0' xml:lang='en-US'><voice name='${VOICE}'>${escapeXml(slide.tts ?? slide.transcript)}</voice></speak>`;
|
||||
const res = await fetch("https://eastus.tts.speech.microsoft.com/cognitiveservices/v1", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Ocp-Apim-Subscription-Key": KEY,
|
||||
"Content-Type": "application/ssml+xml",
|
||||
"X-Microsoft-OutputFormat": "audio-24khz-48kbitrate-mono-mp3",
|
||||
"User-Agent": "roast-academy",
|
||||
},
|
||||
body: ssml,
|
||||
});
|
||||
if (!res.ok)
|
||||
throw new Error(`TTS failed for ${slide.id}: ${res.status} ${await res.text()}`);
|
||||
const buffer = Buffer.from(await res.arrayBuffer());
|
||||
if (buffer.length < 2_000) throw new Error(`suspiciously small audio for ${slide.id}`);
|
||||
await writeFile(file, buffer);
|
||||
made++;
|
||||
console.log(`✓ ${slide.id} (${Math.round(buffer.length / 1024)} KB)`);
|
||||
// Be polite to the TTS quota.
|
||||
await new Promise((resolve) => setTimeout(resolve, 400));
|
||||
}
|
||||
console.log(`Done: ${made} generated, ${skipped} already present, ${slides.length} total.`);
|
||||
Reference in New Issue
Block a user