Skip to content

Instantly share code, notes, and snippets.

@misterburton
Created January 5, 2026 22:25
Show Gist options
  • Select an option

  • Save misterburton/69a62b2a3aca2d2f899ea271cc02d454 to your computer and use it in GitHub Desktop.

Select an option

Save misterburton/69a62b2a3aca2d2f899ea271cc02d454 to your computer and use it in GitHub Desktop.
AI-Powered Localization - Client-Side Narration Player
/**
* 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