From cd7b4449e5c15c36e2fee8b3364a01ea21416476 Mon Sep 17 00:00:00 2001 From: Josh Ribakoff Date: Sun, 14 Dec 2025 14:23:28 -0800 Subject: [PATCH] Add text-to-speech audio generation with OpenAI TTS API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add audio generation script and helper modules using OpenAI TTS API - Replace TextToSpeech component with AudioPlayer component - Update slug page to use new AudioPlayer - Ignore generated audio files Uses OPENAI_API_KEY environment variable for API authentication. Generates MP3 files using the 'alloy' voice from tts-1 model. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .gitignore | 4 + package.json | 2 + scripts/audio/content-hash.ts | 37 +++ scripts/audio/extract-text.ts | 82 +++++++ scripts/audio/manifest.ts | 116 ++++++++++ scripts/audio/openai-wrapper.ts | 124 ++++++++++ scripts/generate-audio.ts | 337 ++++++++++++++++++++++++++++ src/components/AudioPlayer.astro | 276 +++++++++++++++++++++++ src/components/TextToSpeech.astro | 209 ----------------- src/pages/[collection]/[slug].astro | 4 +- 10 files changed, 980 insertions(+), 211 deletions(-) create mode 100644 scripts/audio/content-hash.ts create mode 100644 scripts/audio/extract-text.ts create mode 100644 scripts/audio/manifest.ts create mode 100644 scripts/audio/openai-wrapper.ts create mode 100644 scripts/generate-audio.ts create mode 100644 src/components/AudioPlayer.astro delete mode 100644 src/components/TextToSpeech.astro diff --git a/.gitignore b/.gitignore index ddce69b..30ce61e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,7 @@ node_modules/ dist/ .astro/ +.DS_Store + +# Generated audio files (large, regenerate locally) +public/audio/ diff --git a/package.json b/package.json index a1da8bc..1b717e4 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,8 @@ "test": "vitest run", "test:watch": "vitest", "lint:assets": "npx tsx scripts/lint-assets.ts", + "audio:generate": "npx tsx scripts/generate-audio.ts", + "audio:clean": "rm -rf public/audio", "prepare": "husky" }, "lint-staged": { diff --git a/scripts/audio/content-hash.ts b/scripts/audio/content-hash.ts new file mode 100644 index 0000000..8da098b --- /dev/null +++ b/scripts/audio/content-hash.ts @@ -0,0 +1,37 @@ +/** + * Content hashing for smart audio regeneration. + * Hash is designed to ignore formatting changes but catch text changes. + */ + +import { createHash } from 'crypto'; + +/** + * Compute a content hash that ignores formatting differences. + * + * Changes that DON'T trigger regeneration: + * - Heading level changes (h1 -> h2) + * - Bold/italic changes + * - Whitespace differences + * - Adding images (without alt text) + * + * Changes that DO trigger regeneration: + * - Any text content changes + * - Alt text changes + * - Link text changes + */ +export function computeContentHash(speakableText: string): string { + // Normalize for hashing: + // - lowercase (case changes don't affect speech much) + // - collapse all whitespace to single space + // - trim + const normalized = speakableText + .toLowerCase() + .replace(/\s+/g, ' ') + .trim(); + + // Use SHA-256, truncated to 16 chars for readability + return createHash('sha256') + .update(normalized) + .digest('hex') + .substring(0, 16); +} diff --git a/scripts/audio/extract-text.ts b/scripts/audio/extract-text.ts new file mode 100644 index 0000000..7c545a8 --- /dev/null +++ b/scripts/audio/extract-text.ts @@ -0,0 +1,82 @@ +/** + * Extract speakable text from markdown content. + * Strips formatting while preserving text that should be read aloud. + */ + +export interface ExtractionResult { + title: string; + speakableText: string; +} + +/** + * Extract speakable text from markdown/MDX content. + * + * Includes: paragraphs, headings, lists, bold/italic text, image alt text, link text + * Excludes: frontmatter, code blocks, HTML tags, MDX imports, URLs + */ +export function extractSpeakableText(markdown: string): ExtractionResult { + let text = markdown; + + // 1. Extract and remove frontmatter, capturing title + let title = ''; + const frontmatterMatch = text.match(/^---\n([\s\S]*?)\n---/); + if (frontmatterMatch) { + const frontmatter = frontmatterMatch[1]; + const titleMatch = frontmatter.match(/^title:\s*(.+)$/m); + if (titleMatch) { + title = titleMatch[1].trim(); + } + text = text.replace(/^---\n[\s\S]*?\n---\n?/, ''); + } + + // 2. Remove MDX import statements + text = text.replace(/^import\s+.*$/gm, ''); + + // 3. Remove fenced code blocks (```...```) + text = text.replace(/```[\s\S]*?```/g, ''); + + // 4. Remove inline code (`...`) - just remove the backticks, keep the text + // Actually, let's remove inline code entirely as it's usually technical + text = text.replace(/`[^`]+`/g, ''); + + // 5. Extract image alt text: ![alt](url) -> alt + text = text.replace(/!\[([^\]]*)\]\([^)]+\)/g, '$1'); + + // 6. Extract link text: [text](url) -> text + text = text.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1'); + + // 7. Remove MDX/JSX component tags but try to keep text content + // Remove self-closing tags: + text = text.replace(/<[A-Z][a-zA-Z]*\s[^>]*\/>/g, ''); + // Remove opening/closing tags: ... + text = text.replace(/<\/?[A-Z][a-zA-Z]*[^>]*>/g, ''); + + // 8. Remove HTML comments + text = text.replace(//g, ''); + + // 9. Remove remaining HTML tags + text = text.replace(/<[^>]+>/g, ''); + + // 10. Remove markdown heading markers (##, ###, etc.) but keep text + text = text.replace(/^#{1,6}\s+/gm, ''); + + // 11. Remove bold/italic markers but keep text + text = text.replace(/\*\*([^*]+)\*\*/g, '$1'); // **bold** + text = text.replace(/\*([^*]+)\*/g, '$1'); // *italic* + text = text.replace(/__([^_]+)__/g, '$1'); // __bold__ + text = text.replace(/_([^_]+)_/g, '$1'); // _italic_ + + // 12. Remove list markers + text = text.replace(/^[\s]*[-*+]\s+/gm, ''); // Unordered lists + text = text.replace(/^[\s]*\d+\.\s+/gm, ''); // Ordered lists + + // 13. Remove blockquote markers + text = text.replace(/^>\s*/gm, ''); + + // 14. Normalize whitespace + text = text.replace(/\n{3,}/g, '\n\n'); // Max 2 newlines + text = text.replace(/[ \t]+/g, ' '); // Collapse spaces + text = text.trim(); + + return { title, speakableText: text }; +} diff --git a/scripts/audio/manifest.ts b/scripts/audio/manifest.ts new file mode 100644 index 0000000..8a77a58 --- /dev/null +++ b/scripts/audio/manifest.ts @@ -0,0 +1,116 @@ +/** + * Manifest for tracking generated audio files and their content hashes. + * Enables incremental regeneration - only regenerate when content changes. + */ + +import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs'; +import { dirname } from 'path'; +import { getVoiceName } from './openai-wrapper.js'; + +export interface ManifestEntry { + hash: string; + duration: number; // seconds + size: number; // bytes + generatedAt: string; + filename: string; // e.g., "01-context.mp3" +} + +export interface Manifest { + version: number; + voice: string; + entries: Record; +} + +const MANIFEST_PATH = 'public/audio/manifest.json'; +const CURRENT_VERSION = 1; + +/** + * Load manifest from disk, or create empty one if it doesn't exist. + */ +export function loadManifest(): Manifest { + const currentVoice = getVoiceName(); + + if (!existsSync(MANIFEST_PATH)) { + return { + version: CURRENT_VERSION, + voice: currentVoice, + entries: {}, + }; + } + + try { + const content = readFileSync(MANIFEST_PATH, 'utf-8'); + const manifest = JSON.parse(content) as Manifest; + + // Handle version migrations if needed in the future + if (manifest.version !== CURRENT_VERSION) { + console.log(`Manifest version mismatch (${manifest.version} -> ${CURRENT_VERSION}), regenerating all`); + return { + version: CURRENT_VERSION, + voice: currentVoice, + entries: {}, + }; + } + + // If voice changed, regenerate all + if (manifest.voice !== currentVoice) { + console.log(`Voice changed (${manifest.voice} -> ${currentVoice}), regenerating all`); + return { + version: CURRENT_VERSION, + voice: currentVoice, + entries: {}, + }; + } + + return manifest; + } catch (error) { + console.warn('Failed to parse manifest, starting fresh:', error); + return { + version: CURRENT_VERSION, + voice: currentVoice, + entries: {}, + }; + } +} + +/** + * Save manifest to disk. + */ +export function saveManifest(manifest: Manifest): void { + // Ensure directory exists + const dir = dirname(MANIFEST_PATH); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + + writeFileSync(MANIFEST_PATH, JSON.stringify(manifest, null, 2)); +} + +/** + * Check if content needs regeneration based on hash. + */ +export function needsRegeneration(manifest: Manifest, key: string, hash: string): boolean { + const entry = manifest.entries[key]; + if (!entry) return true; + return entry.hash !== hash; +} + +/** + * Update manifest entry after generation. + */ +export function updateEntry( + manifest: Manifest, + key: string, + hash: string, + duration: number, + size: number, + filename: string +): void { + manifest.entries[key] = { + hash, + duration, + size, + generatedAt: new Date().toISOString(), + filename, + }; +} diff --git a/scripts/audio/openai-wrapper.ts b/scripts/audio/openai-wrapper.ts new file mode 100644 index 0000000..b5618ea --- /dev/null +++ b/scripts/audio/openai-wrapper.ts @@ -0,0 +1,124 @@ +/** + * Wrapper for calling OpenAI TTS API from Node.js. + * Uses the tts-1 model with the 'alloy' voice. + * + * Requires OPENAI_API_KEY environment variable. + */ + +import { mkdirSync, statSync, existsSync, writeFileSync } from 'fs'; +import { dirname, resolve } from 'path'; + +// OpenAI TTS configuration +const OPENAI_API_URL = 'https://api.openai.com/v1/audio/speech'; +const MODEL = 'tts-1'; +const VOICE = 'alloy'; +const RESPONSE_FORMAT = 'mp3'; + +export interface AudioResult { + duration: number; // seconds (estimated) + size: number; // bytes +} + +/** + * Get the OpenAI API key from environment variable. + * Throws if not set. + */ +function getApiKey(): string { + const apiKey = process.env.OPENAI_API_KEY; + if (!apiKey) { + throw new Error( + 'OPENAI_API_KEY environment variable is not set.\n' + + 'Set it with: export OPENAI_API_KEY=your-api-key' + ); + } + return apiKey; +} + +/** + * Generate audio file from text using OpenAI TTS API. + * + * @param text - The text to convert to speech + * @param outputPath - Where to save the audio file + * @returns Audio metadata (duration estimate, size) + */ +export async function generateAudio(text: string, outputPath: string): Promise { + const apiKey = getApiKey(); + + // Ensure output directory exists + const outDir = dirname(outputPath); + if (!existsSync(outDir)) { + mkdirSync(outDir, { recursive: true }); + } + + // Resolve to absolute path for output + const absoluteOutputPath = resolve(process.cwd(), outputPath); + + // OpenAI TTS has a limit of 4096 characters per request + // For longer texts, we'd need to chunk - but for now, truncate with warning + const MAX_CHARS = 4096; + let inputText = text; + if (text.length > MAX_CHARS) { + console.warn(` Warning: Text truncated from ${text.length} to ${MAX_CHARS} chars`); + inputText = text.substring(0, MAX_CHARS); + } + + // Call OpenAI TTS API + const response = await fetch(OPENAI_API_URL, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + model: MODEL, + voice: VOICE, + input: inputText, + response_format: RESPONSE_FORMAT, + }), + }); + + if (!response.ok) { + const errorBody = await response.text(); + throw new Error(`OpenAI API error (${response.status}): ${errorBody}`); + } + + // Get the audio data as a buffer + const arrayBuffer = await response.arrayBuffer(); + const buffer = Buffer.from(arrayBuffer); + + // Write to file + writeFileSync(absoluteOutputPath, buffer); + + // Get file stats + const stats = statSync(absoluteOutputPath); + + // Estimate duration from MP3 file size + // Typical MP3 at 128kbps: duration = size / (128000 / 8) = size / 16000 + // OpenAI uses variable bitrate, but this gives a reasonable estimate + const estimatedDuration = stats.size / 16000; + + return { + duration: Math.round(estimatedDuration * 100) / 100, + size: stats.size, + }; +} + +/** + * Check if OpenAI API is available (API key is set). + */ +export function checkOpenAIAvailable(): boolean { + const apiKey = process.env.OPENAI_API_KEY; + if (!apiKey) { + console.error('OPENAI_API_KEY environment variable is not set.'); + console.error('Set it with: export OPENAI_API_KEY=your-api-key'); + return false; + } + return true; +} + +/** + * Get the current voice being used (for manifest). + */ +export function getVoiceName(): string { + return `openai-${MODEL}-${VOICE}`; +} diff --git a/scripts/generate-audio.ts b/scripts/generate-audio.ts new file mode 100644 index 0000000..fa84ca8 --- /dev/null +++ b/scripts/generate-audio.ts @@ -0,0 +1,337 @@ +#!/usr/bin/env npx tsx + +/** + * Generate audio files from content using OpenAI TTS API. + * + * Usage: npm run audio:generate + * + * Requires: OPENAI_API_KEY environment variable + * + * This script: + * 1. Reads all content files from src/content/ + * 2. Sorts them by dependency order (matching site navigation) + * 3. Extracts speakable text + * 4. Computes content hash + * 5. Skips if hash matches existing (incremental build) + * 6. Generates MP3 audio with numeric prefixes (01-, 02-, etc.) + * 7. Updates manifest + */ + +import { readFileSync, existsSync, unlinkSync } from 'fs'; +import { resolve, basename, extname } from 'path'; +import { globSync } from 'glob'; + +import { extractSpeakableText } from './audio/extract-text.js'; +import { computeContentHash } from './audio/content-hash.js'; +import { loadManifest, saveManifest, needsRegeneration, updateEntry } from './audio/manifest.js'; +import { generateAudio, checkOpenAIAvailable } from './audio/openai-wrapper.js'; + +// ANSI colors +const RESET = '\x1b[0m'; +const RED = '\x1b[31m'; +const GREEN = '\x1b[32m'; +const YELLOW = '\x1b[33m'; +const CYAN = '\x1b[36m'; +const DIM = '\x1b[2m'; + +// Collection config matching src/utils/collections-config.ts +interface CollectionConfig { + name: string; + sortMethod: 'dependency' | 'alphabetical'; +} + +const COLLECTIONS: CollectionConfig[] = [ + { name: 'concepts', sortMethod: 'dependency' }, + { name: 'prompt-engineering', sortMethod: 'alphabetical' }, + { name: 'context-management', sortMethod: 'dependency' }, + { name: 'context-expanding', sortMethod: 'dependency' }, + { name: 'workflow-guardrails', sortMethod: 'alphabetical' }, + { name: 'failure-modes', sortMethod: 'alphabetical' }, + { name: 'coding-assistants', sortMethod: 'alphabetical' }, +]; + +interface ContentFile { + collection: string; + slug: string; + filePath: string; + content: string; + title: string; + dependsOn?: string; +} + +interface OrderedContentFile extends ContentFile { + order: number; // 1-based index within collection + filename: string; // e.g., "01-context.mp3" +} + +/** + * Parse frontmatter from markdown content. + */ +function parseFrontmatter(content: string): { title: string; dependsOn?: string } { + const match = content.match(/^---\n([\s\S]*?)\n---/); + if (!match) return { title: '' }; + + const frontmatter = match[1]; + const titleMatch = frontmatter.match(/^title:\s*(.+)$/m); + const dependsOnMatch = frontmatter.match(/^dependsOn:\s*(.+)$/m); + + return { + title: titleMatch ? titleMatch[1].trim() : '', + dependsOn: dependsOnMatch ? dependsOnMatch[1].trim() : undefined, + }; +} + +/** + * Sort items by dependency chain (topological sort). + * Items with no dependsOn come first, then their dependents. + */ +function sortByDependency(files: ContentFile[]): ContentFile[] { + if (files.length <= 1) return files; + + // Build lookup maps + const bySlug = new Map(); + const dependents = new Map(); + + for (const file of files) { + bySlug.set(file.slug, file); + dependents.set(file.slug, []); + } + + // Build reverse dependency map + for (const file of files) { + if (file.dependsOn && bySlug.has(file.dependsOn)) { + dependents.get(file.dependsOn)!.push(file.slug); + } + } + + // Find heads (no dependsOn or dependsOn doesn't exist) + const heads: ContentFile[] = []; + for (const file of files) { + if (!file.dependsOn || !bySlug.has(file.dependsOn)) { + heads.push(file); + } + } + + // Sort heads alphabetically for determinism + heads.sort((a, b) => a.slug.localeCompare(b.slug)); + + // Walk chains + const result: ContentFile[] = []; + const visited = new Set(); + + function walkChain(slug: string): void { + if (visited.has(slug)) return; + visited.add(slug); + + const file = bySlug.get(slug); + if (file) { + result.push(file); + + const deps = dependents.get(slug) || []; + deps.sort((a, b) => a.localeCompare(b)); + for (const depSlug of deps) { + walkChain(depSlug); + } + } + } + + for (const head of heads) { + walkChain(head.slug); + } + + // Add any remaining (shouldn't happen with well-formed data) + for (const file of files) { + if (!visited.has(file.slug)) { + result.push(file); + } + } + + return result; +} + +/** + * Sort items alphabetically by title. + */ +function sortAlphabetically(files: ContentFile[]): ContentFile[] { + return [...files].sort((a, b) => a.title.localeCompare(b.title)); +} + +/** + * Get all content files from a collection, sorted appropriately. + */ +function getCollectionFiles(config: CollectionConfig): OrderedContentFile[] { + const collectionPath = resolve(process.cwd(), 'src/content', config.name); + + if (!existsSync(collectionPath)) { + return []; + } + + const contentFiles = globSync('*.{md,mdx}', { cwd: collectionPath }); + const files: ContentFile[] = []; + + for (const file of contentFiles) { + const filePath = resolve(collectionPath, file); + const slug = basename(file, extname(file)); + const content = readFileSync(filePath, 'utf-8'); + + // Skip placeholder/TODO content + const bodyContent = content.replace(/^---[\s\S]*?---\n?/, '').trim(); + if (bodyContent.toLowerCase() === 'todo') { + console.log(`${DIM}[SKIP] ${config.name}/${slug} - placeholder content${RESET}`); + continue; + } + + const { title, dependsOn } = parseFrontmatter(content); + files.push({ collection: config.name, slug, filePath, content, title, dependsOn }); + } + + // Sort based on collection config + const sorted = config.sortMethod === 'dependency' + ? sortByDependency(files) + : sortAlphabetically(files); + + // Add order and filename (using .mp3 for OpenAI output) + return sorted.map((file, index) => ({ + ...file, + order: index + 1, + filename: `${String(index + 1).padStart(2, '0')}-${file.slug}.mp3`, + })); +} + +/** + * Clean up orphaned audio files that no longer have corresponding content. + */ +function cleanOrphanedAudio(manifest: ReturnType, validKeys: Set): void { + const orphanedKeys = Object.keys(manifest.entries).filter(key => !validKeys.has(key)); + + for (const key of orphanedKeys) { + const entry = manifest.entries[key]; + if (entry?.filename) { + const [collection] = key.split('/'); + const audioPath = resolve(process.cwd(), 'public/audio', collection, entry.filename); + if (existsSync(audioPath)) { + console.log(`${YELLOW}[CLEAN] ${key} - content removed${RESET}`); + unlinkSync(audioPath); + } + } + delete manifest.entries[key]; + } +} + +async function main(): Promise { + console.log(`\n${CYAN}🎙️ OpenAI TTS Audio Generator${RESET}\n`); + + // Check OpenAI API is available + if (!checkOpenAIAvailable()) { + console.error(`${RED}OpenAI API not available. Aborting.${RESET}`); + process.exit(1); + } + + // Load manifest + const manifest = loadManifest(); + console.log(`Loaded manifest with ${Object.keys(manifest.entries).length} existing entries\n`); + + // Get all content files, sorted per collection + const allFiles: OrderedContentFile[] = []; + for (const config of COLLECTIONS) { + const files = getCollectionFiles(config); + allFiles.push(...files); + } + + console.log(`Found ${allFiles.length} content files to process\n`); + + if (allFiles.length === 0) { + console.log(`${YELLOW}No content files found.${RESET}`); + return; + } + + let generated = 0; + let skipped = 0; + let errors = 0; + + const validKeys = new Set(); + + for (const file of allFiles) { + const key = `${file.collection}/${file.slug}`; + validKeys.add(key); + + try { + // Extract speakable text + const { speakableText } = extractSpeakableText(file.content); + + if (!speakableText.trim()) { + console.log(`${DIM}[SKIP] ${key} - no speakable content${RESET}`); + skipped++; + continue; + } + + // Compute hash + const hash = computeContentHash(speakableText); + + // Check if regeneration needed (also check if filename changed) + const existingEntry = manifest.entries[key]; + const filenameChanged = existingEntry?.filename !== file.filename; + + if (!needsRegeneration(manifest, key, hash) && !filenameChanged) { + console.log(`${DIM}[SKIP] ${key} - unchanged${RESET}`); + skipped++; + continue; + } + + // Delete old file if filename changed + if (filenameChanged && existingEntry?.filename) { + const oldPath = resolve(process.cwd(), 'public/audio', file.collection, existingEntry.filename); + if (existsSync(oldPath)) { + unlinkSync(oldPath); + } + } + + // Generate audio + const orderStr = String(file.order).padStart(2, '0'); + console.log(`${GREEN}[GEN]${RESET} ${orderStr}. ${key}`); + + const outputPath = `public/audio/${file.collection}/${file.filename}`; + + // Prepend title for natural intro: "Context. The context window is finite..." + const fullText = file.title ? `${file.title}. ${speakableText}` : speakableText; + + const { duration, size } = await generateAudio(fullText, outputPath); + + // Update manifest with filename + updateEntry(manifest, key, hash, duration, size, file.filename); + generated++; + + console.log(` ${DIM}${duration.toFixed(1)}s, ${(size / 1024).toFixed(0)} KB${RESET}`); + + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(`${RED}[ERR] ${key}: ${message}${RESET}`); + errors++; + } + } + + // Clean up orphaned audio files + cleanOrphanedAudio(manifest, validKeys); + + // Save manifest + saveManifest(manifest); + + // Summary + console.log(`\n${CYAN}📊 Summary${RESET}`); + console.log(` Generated: ${generated}`); + console.log(` Skipped: ${skipped}`); + console.log(` Errors: ${errors}`); + + if (errors > 0) { + console.log(`\n${YELLOW}⚠️ Some files had errors. Check the output above.${RESET}`); + process.exit(1); + } + + console.log(`\n${GREEN}✅ Audio generation complete!${RESET}\n`); +} + +main().catch((error) => { + const message = error instanceof Error ? error.message : String(error); + console.error(`${RED}Fatal error: ${message}${RESET}`); + process.exit(1); +}); diff --git a/src/components/AudioPlayer.astro b/src/components/AudioPlayer.astro new file mode 100644 index 0000000..70c26c8 --- /dev/null +++ b/src/components/AudioPlayer.astro @@ -0,0 +1,276 @@ +--- +/** + * Audio player for pre-generated TTS files. + * Simple play/pause with progress bar and speed control. + * + * Reads the audio manifest at build time to get the correct filename + * (with numeric prefix like "01-context.mp3"). + */ +import { readFileSync, existsSync } from 'fs'; +import { resolve } from 'path'; + +interface Props { + collection: string; + slug: string; +} + +interface ManifestEntry { + filename: string; + duration: number; + size: number; +} + +interface Manifest { + entries: Record; +} + +const { collection, slug } = Astro.props; + +// Read manifest at build time to get the correct filename +let audioSrc = ''; +const manifestPath = resolve(process.cwd(), 'public/audio/manifest.json'); + +if (existsSync(manifestPath)) { + try { + const manifestContent = readFileSync(manifestPath, 'utf-8'); + const manifest: Manifest = JSON.parse(manifestContent); + const key = `${collection}/${slug}`; + const entry = manifest.entries[key]; + + if (entry?.filename) { + audioSrc = `/audio/${collection}/${entry.filename}`; + } + } catch { + // Manifest doesn't exist or is invalid - audioSrc stays empty + } +} +--- + +{audioSrc && ( +
+ + + + + +
+)} + + + + diff --git a/src/components/TextToSpeech.astro b/src/components/TextToSpeech.astro deleted file mode 100644 index 62063c1..0000000 --- a/src/components/TextToSpeech.astro +++ /dev/null @@ -1,209 +0,0 @@ ---- -/** - * Text-to-Speech component using the Web Speech API. - * Provides play/pause, stop, and speed controls. - */ ---- - -
- - - - - -
- - - - diff --git a/src/pages/[collection]/[slug].astro b/src/pages/[collection]/[slug].astro index 4de6d8f..09f15ad 100644 --- a/src/pages/[collection]/[slug].astro +++ b/src/pages/[collection]/[slug].astro @@ -7,7 +7,7 @@ import Layout from "../../layouts/Layout.astro"; import Breadcrumbs from "../../components/Breadcrumbs.astro"; import ContentNav from "../../components/ContentNav.astro"; import RelatedContent from "../../components/RelatedContent.astro"; -import TextToSpeech from "../../components/TextToSpeech.astro"; +import AudioPlayer from "../../components/AudioPlayer.astro"; import { getCollection } from "astro:content"; import { COLLECTION_NAMES, @@ -64,7 +64,7 @@ const config = COLLECTION_CONFIG[collectionName];

{entry.data.title}

- {!isPlaceholder && } + {!isPlaceholder && } {isPlaceholder ? (