Created
January 5, 2026 22:25
-
-
Save misterburton/69a62b2a3aca2d2f899ea271cc02d454 to your computer and use it in GitHub Desktop.
AI-Powered Localization - Client-Side Narration Player
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /** | |
| * ArticleSpeech - Client-Side Narration Player | |
| * | |
| * A lightweight audio narration system that plays pre-generated MP3 files | |
| * while highlighting corresponding content on the page. Supports multiple | |
| * languages, keyboard shortcuts, and accessibility features. | |
| * | |
| * REQUIREMENTS: | |
| * - Pre-generated audio files in /audio/{lang}/{page-name}/p0.mp3, p1.mp3, etc. | |
| * - HTML elements with data-narration="0", data-narration="1", etc. | |
| * - Optional: CSS classes for styling (.is-playing, .reading-active, .narration-highlight) | |
| * | |
| * USAGE: | |
| * 1. Add data-narration attributes to elements you want narrated: | |
| * <p data-narration="0">First paragraph to be read aloud.</p> | |
| * <p data-narration="1">Second paragraph.</p> | |
| * | |
| * 2. Import and instantiate: | |
| * import { ArticleSpeech } from './speech.js'; | |
| * new ArticleSpeech(); | |
| * | |
| * 3. Optionally add a button with id="listen-button" or let the class create one. | |
| * | |
| * KEYBOARD SHORTCUT: | |
| * - Alt+P (Option+P on Mac) toggles playback | |
| * | |
| * LANGUAGE SUPPORT: | |
| * - Call articleSpeech.updateLanguage('es') to switch audio language | |
| * - Requires audio files in /audio/es/{page-name}/ directory | |
| * | |
| * @license MIT | |
| */ | |
| class ArticleSpeech { | |
| constructor() { | |
| // ============================================ | |
| // SINGLETON PATTERN | |
| // Prevents multiple instances from conflicting | |
| // ============================================ | |
| if (window.articleSpeech) { | |
| console.warn('ArticleSpeech instance already exists. Use existing instance.'); | |
| return window.articleSpeech; | |
| } | |
| // ============================================ | |
| // COLLECT NARRATION ELEMENTS | |
| // Elements must have data-narration="N" attribute | |
| // where N is the playback order (0, 1, 2, ...) | |
| // ============================================ | |
| this.paragraphs = Array.from(document.querySelectorAll('[data-narration]')) | |
| .sort((a, b) => parseInt(a.dataset.narration) - parseInt(b.dataset.narration)); | |
| // Exit if no narration elements found | |
| if (this.paragraphs.length === 0) { | |
| return; | |
| } | |
| // ============================================ | |
| // OPTIONAL: Language Check | |
| // Uncomment to restrict narration to specific languages | |
| // ============================================ | |
| // if (!document.documentElement.classList.contains('lang-narration')) { | |
| // return; | |
| // } | |
| // ============================================ | |
| // CORE STATE | |
| // ============================================ | |
| this.isMac = /Mac|iPod|iPhone|iPad/.test(navigator.platform); | |
| this.shortcutKey = this.isMac ? '⌥' : 'Alt'; | |
| this.isPlaying = false; | |
| this.isActive = false; // Tracks if narration session is active (even if paused) | |
| this.currentIndex = 0; | |
| this.audio = null; | |
| // Get current language from localStorage or default to 'en' | |
| this.currentLanguage = localStorage.getItem('currentLanguage') || 'en'; | |
| // ============================================ | |
| // AUDIO PATH CONFIGURATION | |
| // Customize getAudioBasePath() for your file structure | |
| // ============================================ | |
| this.pageName = this.getPageName(); | |
| this.audioBasePath = this.getAudioBasePath(); | |
| this.audioFiles = this.paragraphs.map((_, index) => `${this.audioBasePath}p${index}.mp3`); | |
| // ============================================ | |
| // UI SETUP | |
| // ============================================ | |
| this.ensureUI(); | |
| // Get UI elements | |
| this.listenButton = document.getElementById('listen-button'); | |
| this.fixedToggle = document.getElementById('fixed-toggle'); | |
| this.fixedControls = document.getElementById('fixed-audio-controls'); | |
| this.announcer = document.getElementById('live-announcer'); | |
| // Mark this as the active instance | |
| window.articleSpeech = this; | |
| // Update button titles with OS-specific instruction | |
| [this.listenButton, this.fixedToggle].forEach(button => { | |
| if (button) { | |
| button.title = `Press ${this.shortcutKey} + P to toggle narration`; | |
| } | |
| }); | |
| // Setup event listeners | |
| this.setupEventListeners(); | |
| this.setupScrollObserver(); | |
| // Calculate and display reading time | |
| this.calculateReadingTime(); | |
| // Pre-load first audio file for faster start | |
| this.loadAudio(0); | |
| // Remove initial state class after animation (if using entrance animations) | |
| setTimeout(() => { | |
| this.fixedControls?.classList.remove('initial-state'); | |
| }, 4000); | |
| // Update button text based on viewport | |
| this.updateButtonText(); | |
| } | |
| /** | |
| * Extracts page name from URL path for audio file organization | |
| * Customize this for your URL structure | |
| */ | |
| getPageName() { | |
| const path = window.location.pathname; | |
| if (path === '/' || path === '/index.html') return 'home'; | |
| return path.split('/').filter(Boolean).pop() || 'home'; | |
| } | |
| /** | |
| * Constructs the base path for audio files | |
| * Structure: /audio/{language-code}/{page-name}/ | |
| */ | |
| getAudioBasePath() { | |
| const lang = this.currentLanguage || 'en'; | |
| if (this.pageName === 'home') return `/audio/${lang}/`; | |
| return `/audio/${lang}/${this.pageName}/`; | |
| } | |
| /** | |
| * Creates required UI elements if they don't exist in the DOM | |
| * - Live announcer for screen readers | |
| * - Fixed playback controls (visible when scrolled past main button) | |
| * - Main listen button | |
| */ | |
| ensureUI() { | |
| // ============================================ | |
| // ACCESSIBILITY: Live Announcer | |
| // Screen reader announcements for playback state | |
| // ============================================ | |
| if (!document.getElementById('live-announcer')) { | |
| const announcer = document.createElement('div'); | |
| announcer.id = 'live-announcer'; | |
| announcer.className = 'sr-only'; | |
| announcer.setAttribute('aria-live', 'polite'); | |
| announcer.setAttribute('role', 'status'); | |
| document.body.appendChild(announcer); | |
| } | |
| // ============================================ | |
| // FIXED CONTROLS | |
| // Floating play/pause button visible when main button scrolls out of view | |
| // ============================================ | |
| if (!document.getElementById('fixed-audio-controls')) { | |
| const wrapper = document.createElement('div'); | |
| wrapper.className = 'ios-bottom-anchor'; | |
| const fixedControls = document.createElement('div'); | |
| fixedControls.id = 'fixed-audio-controls'; | |
| fixedControls.className = 'hidden initial-state'; | |
| fixedControls.setAttribute('aria-label', 'Audio playback controls'); | |
| fixedControls.innerHTML = ` | |
| <button id="fixed-toggle" class="fixed-audio-button" aria-label="Play/Pause page narration" aria-keyshortcuts="Alt+P"> | |
| <div class="button-icon"> | |
| <svg class="play-svg" viewbox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"></path></svg> | |
| <svg class="pause-svg" viewbox="0 0 24 24" fill="currentColor"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"></path></svg> | |
| </div> | |
| </button> | |
| `; | |
| wrapper.appendChild(fixedControls); | |
| document.body.appendChild(wrapper); | |
| } | |
| // Skip button creation if it already exists | |
| if (document.getElementById('listen-button')) { | |
| return; | |
| } | |
| // ============================================ | |
| // MAIN LISTEN BUTTON | |
| // Find appropriate container or create one | |
| // ============================================ | |
| const target = document.getElementById('listen-container') || | |
| document.querySelector('.content-section') || | |
| document.querySelector('.container') || | |
| this.paragraphs[0]?.parentElement; | |
| if (target) { | |
| const listenButton = document.createElement('button'); | |
| listenButton.id = 'listen-button'; | |
| listenButton.className = 'listen-button'; | |
| listenButton.setAttribute('aria-label', 'Listen to this page'); | |
| listenButton.setAttribute('aria-keyshortcuts', 'Alt+P'); | |
| listenButton.innerHTML = ` | |
| <div class="button-icon-container"> | |
| <div class="button-icon"> | |
| <svg class="play-svg" viewbox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"></path></svg> | |
| <svg class="pause-svg" viewbox="0 0 24 24" fill="currentColor"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"></path></svg> | |
| </div> | |
| </div> | |
| <div class="button-text-wrapper"> | |
| <span class="button-text">Narrate this page</span> | |
| <span class="button-meta"></span> | |
| </div> | |
| `; | |
| if (target.id !== 'listen-container') { | |
| const wrapper = document.createElement('div'); | |
| wrapper.id = 'listen-container'; | |
| wrapper.appendChild(listenButton); | |
| const firstP = target.querySelector('[data-narration="0"]') || target.querySelector('p'); | |
| if (firstP) { | |
| target.insertBefore(wrapper, firstP); | |
| } else { | |
| target.prepend(wrapper); | |
| } | |
| } else { | |
| target.appendChild(listenButton); | |
| } | |
| } | |
| } | |
| /** | |
| * Sets up all event listeners for playback control | |
| */ | |
| setupEventListeners() { | |
| // Button click handlers | |
| this.toggleHandler = () => this.toggle(); | |
| this.keydownHandler = (e) => { | |
| if (e.key === 'Enter' || e.key === ' ') { | |
| e.preventDefault(); | |
| this.toggle(); | |
| } | |
| }; | |
| [this.listenButton, this.fixedToggle].forEach(button => { | |
| if (button) { | |
| button.addEventListener('click', this.toggleHandler); | |
| button.addEventListener('keydown', this.keydownHandler); | |
| } | |
| }); | |
| // ============================================ | |
| // KEYBOARD SHORTCUT: Alt+P / Option+P | |
| // ============================================ | |
| this.shortcutHandler = (e) => { | |
| // 'π' is what macOS sends for Option+P | |
| if (e.altKey && (e.key.toLowerCase() === 'p' || e.key === 'π')) { | |
| e.preventDefault(); | |
| this.toggle(); | |
| this.showShortcutTooltip(); | |
| } | |
| }; | |
| document.addEventListener('keydown', this.shortcutHandler); | |
| // Pause when tab becomes hidden | |
| this.visibilityHandler = () => { | |
| if (document.hidden && this.isPlaying) { | |
| this.pause(); | |
| } | |
| }; | |
| document.addEventListener('visibilitychange', this.visibilityHandler); | |
| // Update button text on resize | |
| this.resizeHandler = () => this.updateButtonText(); | |
| window.addEventListener('resize', this.resizeHandler); | |
| } | |
| /** | |
| * Loads an audio file by index | |
| * @param {number} index - Index of the paragraph/audio file to load | |
| */ | |
| loadAudio(index) { | |
| // Clean up previous audio | |
| if (this.audio) { | |
| this.audio.pause(); | |
| this.audio.removeEventListener('ended', this.audioEndHandler); | |
| this.audio.removeEventListener('pause', this.handleSystemPause); | |
| this.audio.src = ''; | |
| this.audio.load(); | |
| this.audio = null; | |
| } | |
| // Add cache-buster when language changes to force browser to reload | |
| const audioUrl = this.audioFiles[index] + (this.cacheBuster ? `?v=${this.cacheBuster}` : ''); | |
| this.audio = new Audio(audioUrl); | |
| // Handle system-initiated pause (e.g., phone call) | |
| this.handleSystemPause = () => { | |
| if (this.isPlaying && !this.audio.ended) { | |
| this.pause(); | |
| } | |
| }; | |
| this.audio.addEventListener('pause', this.handleSystemPause); | |
| // Handle audio completion - advance to next paragraph | |
| this.audioEndHandler = () => { | |
| const currentPara = this.paragraphs[this.currentIndex]; | |
| // ============================================ | |
| // OPTIONAL: Checkpoint Support | |
| // Add data-narration-checkpoint to create pause points | |
| // ============================================ | |
| if (currentPara && currentPara.hasAttribute('data-narration-checkpoint')) { | |
| this.handleCheckpoint(); | |
| return; | |
| } | |
| this.currentIndex++; | |
| if (this.currentIndex < this.paragraphs.length) { | |
| this.loadAudio(this.currentIndex); | |
| this.playAudio(); | |
| } else { | |
| this.stop(); | |
| } | |
| }; | |
| this.audio.addEventListener('ended', this.audioEndHandler); | |
| } | |
| /** | |
| * Starts playing the current audio file | |
| */ | |
| playAudio() { | |
| if (this.audio) { | |
| this.updateActiveParagraph(this.paragraphs[this.currentIndex]); | |
| this.audio.play(); | |
| this.isPlaying = true; | |
| this.isActive = true; | |
| this.updateUI(true); | |
| if (this.announcer) { | |
| this.announcer.textContent = 'Starting narration'; | |
| } | |
| } | |
| } | |
| /** | |
| * Toggles between play and pause | |
| */ | |
| toggle() { | |
| if (this.isPlaying) { | |
| this.pause(); | |
| } else { | |
| this.resume(); | |
| } | |
| } | |
| /** | |
| * Pauses playback | |
| */ | |
| pause() { | |
| if (this.audio) { | |
| this.audio.pause(); | |
| this.isPlaying = false; | |
| this.updateUI(false); | |
| if (this.announcer) { | |
| this.announcer.textContent = 'Paused narration'; | |
| } | |
| } | |
| } | |
| /** | |
| * Handles checkpoint pause points | |
| * Creates a UI prompt to continue listening | |
| */ | |
| handleCheckpoint() { | |
| this.pause(); | |
| document.body.classList.add('checkpoint-active'); | |
| if (this.announcer) { | |
| this.announcer.textContent = 'You have reached a checkpoint. Would you like to continue listening?'; | |
| } | |
| const currentPara = this.paragraphs[this.currentIndex]; | |
| if (!currentPara) return; | |
| let checkpointUI = document.getElementById('narration-checkpoint-ui'); | |
| if (!checkpointUI) { | |
| checkpointUI = document.createElement('div'); | |
| checkpointUI.id = 'narration-checkpoint-ui'; | |
| checkpointUI.className = 'narration-checkpoint-container'; | |
| checkpointUI.innerHTML = ` | |
| <div class="checkpoint-content"> | |
| <p>Continue listening?</p> | |
| <button id="checkpoint-continue" class="checkpoint-button">Continue</button> | |
| </div> | |
| `; | |
| checkpointUI.querySelector('#checkpoint-continue').addEventListener('click', () => { | |
| this.resumeFromCheckpoint(); | |
| }); | |
| } | |
| currentPara.after(checkpointUI); | |
| checkpointUI.classList.add('visible'); | |
| checkpointUI.scrollIntoView({ behavior: 'smooth', block: 'center' }); | |
| } | |
| /** | |
| * Resumes playback from a checkpoint | |
| */ | |
| resumeFromCheckpoint() { | |
| document.body.classList.remove('checkpoint-active'); | |
| const checkpointUI = document.getElementById('narration-checkpoint-ui'); | |
| if (checkpointUI) { | |
| checkpointUI.classList.remove('visible'); | |
| setTimeout(() => checkpointUI.remove(), 300); | |
| } | |
| this.currentIndex++; | |
| if (this.currentIndex < this.paragraphs.length) { | |
| this.loadAudio(this.currentIndex); | |
| this.playAudio(); | |
| } else { | |
| this.stop(); | |
| } | |
| } | |
| /** | |
| * Resumes playback from current position or starts from beginning | |
| */ | |
| resume() { | |
| // Check if we have a valid audio element with a proper source | |
| if (this.audio?.paused && this.audio.src && !this.audio.src.endsWith('/')) { | |
| this.updateActiveParagraph(this.paragraphs[this.currentIndex]); | |
| this.audio.play(); | |
| this.isPlaying = true; | |
| this.updateUI(true); | |
| if (this.announcer) { | |
| this.announcer.textContent = 'Resuming narration'; | |
| } | |
| } else { | |
| // No valid audio - start fresh | |
| this.currentIndex = 0; | |
| this.loadAudio(0); | |
| this.playAudio(); | |
| } | |
| } | |
| /** | |
| * Stops playback and resets to beginning | |
| * @param {boolean} skipPreload - If true, don't preload first audio (used during language switch) | |
| */ | |
| stop(skipPreload = false) { | |
| if (this.audio) { | |
| this.audio.pause(); | |
| this.audio.currentTime = 0; | |
| this.audio.src = ''; | |
| this.audio.load(); | |
| } | |
| // Clear checkpoint UI | |
| document.body.classList.remove('checkpoint-active'); | |
| const checkpointUI = document.getElementById('narration-checkpoint-ui'); | |
| if (checkpointUI) checkpointUI.remove(); | |
| this.isPlaying = false; | |
| this.isActive = false; | |
| this.currentIndex = 0; | |
| this.updateActiveParagraph(null); | |
| document.body.classList.remove('is-playing'); | |
| this.updateUI(false); | |
| // Preload first audio for next play (unless switching languages) | |
| if (!skipPreload) { | |
| this.loadAudio(0); | |
| } | |
| if (this.fixedControls) { | |
| this.fixedControls.classList.add('hidden'); | |
| } | |
| } | |
| /** | |
| * Updates audio paths for a new language | |
| * Call this when user switches languages | |
| * @param {string} langCode - Language code (e.g., 'es', 'fr', 'zh') | |
| */ | |
| updateLanguage(langCode) { | |
| this.currentLanguage = langCode; | |
| this.audioBasePath = this.getAudioBasePath(); | |
| this.audioFiles = this.paragraphs.map((_, index) => `${this.audioBasePath}p${index}.mp3`); | |
| // Force browser to reload audio files for new language | |
| this.cacheBuster = Date.now(); | |
| // Reset to first audio with new language | |
| this.currentIndex = 0; | |
| this.loadAudio(0); | |
| // Recalculate reading time for new language audio | |
| this.calculateReadingTime(); | |
| } | |
| /** | |
| * Highlights the current paragraph and related elements | |
| * @param {HTMLElement|null} newParagraph - Element to highlight, or null to clear | |
| */ | |
| updateActiveParagraph(newParagraph) { | |
| // Clear previous highlights | |
| document.querySelectorAll('.narration-highlight').forEach(el => el.classList.remove('narration-highlight')); | |
| document.querySelector('.reading-active')?.classList.remove('reading-active'); | |
| if (newParagraph) { | |
| newParagraph.classList.add('reading-active'); | |
| // ============================================ | |
| // HIGHLIGHT RELATED ELEMENTS | |
| // Highlights siblings until next narration element | |
| // ============================================ | |
| // Elements BELOW current paragraph | |
| let next = newParagraph.nextElementSibling; | |
| while (next && !next.hasAttribute('data-narration')) { | |
| // Stop at major section headers | |
| if (next.matches('h1, h2, h3')) { | |
| break; | |
| } | |
| next.classList.add('narration-highlight'); | |
| next = next.nextElementSibling; | |
| } | |
| // Elements ABOVE current paragraph (headers, labels) | |
| let prev = newParagraph.previousElementSibling; | |
| while (prev && !prev.hasAttribute('data-narration')) { | |
| if (prev.matches('h1, h2, h3, h4, label')) { | |
| prev.classList.add('narration-highlight'); | |
| } else { | |
| break; | |
| } | |
| prev = prev.previousElementSibling; | |
| } | |
| // Scroll into view | |
| if (this.currentIndex === this.paragraphs.length - 1) { | |
| // Scroll to bottom for last paragraph | |
| window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' }); | |
| } else { | |
| newParagraph.scrollIntoView({ behavior: 'smooth', block: 'center' }); | |
| } | |
| } | |
| } | |
| /** | |
| * Updates UI state (button appearance, body class) | |
| * @param {boolean} isPlaying - Current playback state | |
| */ | |
| updateUI(isPlaying) { | |
| document.body.classList.toggle('is-playing', isPlaying); | |
| [this.listenButton, this.fixedToggle].forEach(button => { | |
| if (button) button.classList.toggle('is-playing', isPlaying); | |
| }); | |
| this.updateButtonText(); | |
| } | |
| /** | |
| * Shows/hides fixed controls based on main button visibility | |
| */ | |
| setupScrollObserver() { | |
| const observer = new IntersectionObserver((entries) => { | |
| const isVisible = entries[0].isIntersecting; | |
| // Show fixed controls when main button is NOT visible AND narration is active | |
| this.fixedControls?.classList.toggle('hidden', isVisible || !this.isActive); | |
| }); | |
| if (this.listenButton) observer.observe(this.listenButton); | |
| } | |
| /** | |
| * Calculates total audio duration and displays as reading time | |
| */ | |
| async calculateReadingTime() { | |
| try { | |
| const durations = await Promise.all( | |
| this.audioFiles.map(file => | |
| new Promise(resolve => { | |
| const audioUrl = file + (this.cacheBuster ? `?v=${this.cacheBuster}` : ''); | |
| const audio = new Audio(audioUrl); | |
| const handleMetadata = () => { | |
| resolve(audio.duration); | |
| audio.removeEventListener('loadedmetadata', handleMetadata); | |
| audio.src = ''; | |
| }; | |
| const handleError = () => { | |
| resolve(0); // File doesn't exist yet | |
| audio.removeEventListener('error', handleError); | |
| }; | |
| audio.addEventListener('loadedmetadata', handleMetadata, { once: true }); | |
| audio.addEventListener('error', handleError, { once: true }); | |
| }) | |
| ) | |
| ); | |
| const totalSeconds = durations.reduce((total, duration) => total + duration, 0); | |
| if (totalSeconds === 0) return; | |
| const mins = Math.floor(totalSeconds / 60); | |
| const secs = Math.round(totalSeconds % 60); | |
| const timeString = `${mins}:${secs.toString().padStart(2, '0')} min`; | |
| const metaElement = document.querySelector('.button-meta'); | |
| if (metaElement) metaElement.textContent = timeString; | |
| } catch (e) { | |
| console.warn('Could not calculate reading time', e); | |
| } | |
| } | |
| /** | |
| * Shows a tooltip when keyboard shortcut is used | |
| */ | |
| showShortcutTooltip() { | |
| const tooltip = document.createElement('div'); | |
| tooltip.className = 'shortcut-tooltip'; | |
| tooltip.setAttribute('role', 'status'); | |
| tooltip.setAttribute('aria-live', 'polite'); | |
| tooltip.textContent = `${this.isPlaying ? 'Playing' : 'Paused'} narration (${this.shortcutKey} + P)`; | |
| document.body.appendChild(tooltip); | |
| setTimeout(() => { | |
| tooltip.remove(); | |
| }, 2000); | |
| } | |
| /** | |
| * Updates button text based on state and viewport | |
| */ | |
| updateButtonText() { | |
| const buttonText = this.listenButton?.querySelector('.button-text'); | |
| if (!buttonText) return; | |
| const isMobile = window.innerWidth < 768; | |
| const baseText = this.isPlaying ? 'Pause narration' : 'Narrate this page'; | |
| if (!isMobile) { | |
| buttonText.innerHTML = `${baseText} <span class="shortcut-text">(${this.shortcutKey} + P)</span>`; | |
| } else { | |
| buttonText.textContent = baseText; | |
| } | |
| } | |
| /** | |
| * Cleans up all event listeners and state | |
| * Call this when removing the narration feature | |
| */ | |
| cleanup() { | |
| [this.listenButton, this.fixedToggle].forEach(button => { | |
| if (button) { | |
| button.removeEventListener('click', this.toggleHandler); | |
| button.removeEventListener('keydown', this.keydownHandler); | |
| } | |
| }); | |
| if (this.shortcutHandler) { | |
| document.removeEventListener('keydown', this.shortcutHandler); | |
| } | |
| if (this.visibilityHandler) { | |
| document.removeEventListener('visibilitychange', this.visibilityHandler); | |
| } | |
| if (this.resizeHandler) { | |
| window.removeEventListener('resize', this.resizeHandler); | |
| } | |
| this.stop(); | |
| if (this.audio) { | |
| this.audio.removeEventListener('ended', this.audioEndHandler); | |
| this.audio.removeEventListener('pause', this.handleSystemPause); | |
| this.audio = null; | |
| } | |
| if (window.articleSpeech === this) { | |
| window.articleSpeech = null; | |
| } | |
| } | |
| } | |
| // ============================================ | |
| // EXPORT | |
| // ============================================ | |
| export { ArticleSpeech }; | |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment