Make word-study Speak work without a Greek or Hebrew voice
Build and Push Docker Image / build-and-push (push) Successful in 5m27s
Build and Push Docker Image / build-and-push (push) Successful in 5m27s
- Pick the language from the word's own letters, then the Strong's prefix - Fall back to reading the transliteration when the device has no matching voice - Wait for the browser to load its voice list; clear a stuck queue first - Strip Hebrew cantillation marks before speaking - Pass the transliteration from the word study, verse sheet and pronunciation page Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a6af9bd1c6
commit
ed22967d16
5 files changed
+104
-17
No files matched your search
@@ -36,7 +36,7 @@ function OriginalTab({ words }) {
|
||||
{w.p && <div className="mt-0.5 text-[10px] leading-tight text-[var(--r-sub)]">{w.p}</div>}
|
||||
{w.s && <div className="mt-0.5 text-[10px] text-[var(--r-sub)]">{w.s}</div>}
|
||||
{canSpeak && (
|
||||
<button type="button" onClick={() => speakOriginalWord(w.o, w.s)}
|
||||
<button type="button" onClick={() => speakOriginalWord(w.o, w.s, w.t)}
|
||||
className="mt-1.5 rounded-lg bg-[var(--r-bar)] px-2 py-0.5 text-[11px] hover:bg-black/5"
|
||||
title={`Pronounce in ${isHebrew ? 'Hebrew' : 'Greek'}`}>Speak</button>
|
||||
)}
|
||||
|
||||
@@ -47,7 +47,7 @@ function WordCard({ word, open, onToggle, chunkId, wordStudy }) {
|
||||
<button type="button" onClick={() => lookupWord(chunkId, word.id, 'hebrew')} className={btn}>{word.loading ? 'Looking up...' : 'Look up Hebrew'}</button>
|
||||
{iq && strongs && <button type="button" onClick={() => setLexicon((o) => !o)} aria-pressed={lexicon} className={btn} title="Strong's, BDB, LSJ and Abbott-Smith entries">Lexicons</button>}
|
||||
{strongs && <button type="button" onClick={() => setWhere((o) => !o)} aria-pressed={where} className={btn} title="Every verse that uses this word, by book">Where else</button>}
|
||||
{word.lexeme && <button type="button" onClick={() => speakOriginalWord(word.lexeme, word.strongNumber)} className={btn} title="Hear pronunciation">Speak</button>}
|
||||
{word.lexeme && <button type="button" onClick={() => speakOriginalWord(word.lexeme, word.strongNumber, word.transliteration)} className={btn} title="Hear pronunciation">Speak</button>}
|
||||
<button type="button" onClick={() => removeGreekWord(chunkId, word.id)} className={`${btn} ml-auto text-[var(--r-danger,#b42318)]`}>Delete</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
|
||||
@@ -100,7 +100,7 @@ function WordCard({ word, chapterRef, onSpeak }) {
|
||||
{word.transliteration && <p className="mt-1 text-base italic text-[var(--r-sub)]">{word.transliteration}</p>}
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-col items-end gap-2">
|
||||
{word.lexeme && <button type="button" onClick={() => onSpeak(word.lexeme, word.strongNumber)} className="rounded-lg border border-[var(--r-line)] px-3 py-1.5 text-xs hover:bg-black/5" title="Hear pronunciation">Speak</button>}
|
||||
{word.lexeme && <button type="button" onClick={() => onSpeak(word.lexeme, word.strongNumber, word.transliteration)} className="rounded-lg border border-[var(--r-line)] px-3 py-1.5 text-xs hover:bg-black/5" title="Hear pronunciation">Speak</button>}
|
||||
{word.strongNumber && <span className="rounded-full border border-[var(--r-line)] px-2.5 py-0.5 text-xs text-[var(--r-sub)]">{word.strongNumber}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { test, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { originalLanguage, speakableText, speakOriginalWord } from './wordStudy.js';
|
||||
|
||||
let synth;
|
||||
beforeEach(() => {
|
||||
synth = { voices: [], cancel: vi.fn(), resume: vi.fn(), speak: vi.fn(), getVoices: () => synth.voices, addEventListener: vi.fn(), removeEventListener: vi.fn() };
|
||||
window.speechSynthesis = synth;
|
||||
globalThis.SpeechSynthesisUtterance = class { constructor(text) { this.text = text; } };
|
||||
});
|
||||
afterEach(() => { delete window.speechSynthesis; delete globalThis.SpeechSynthesisUtterance; });
|
||||
|
||||
test('the language comes from the word itself, then from the Strong\'s prefix', () => {
|
||||
expect(originalLanguage('אָדָם', '')).toBe('he');
|
||||
expect(originalLanguage('λόγος', 'H1')).toBe('el');
|
||||
expect(originalLanguage('word', 'H430')).toBe('he');
|
||||
expect(originalLanguage('word', 'g25')).toBe('el');
|
||||
expect(originalLanguage('word', '')).toBeNull();
|
||||
});
|
||||
|
||||
test('cantillation marks and punctuation are removed before speaking', () => {
|
||||
expect(speakableText('בְּרֵאשִׁ֖ית׃')).toBe('בְּרֵאשִׁית');
|
||||
expect(speakableText(' λόγος. ')).toBe('λόγος');
|
||||
});
|
||||
|
||||
test('uses a Greek voice when the device has one', async () => {
|
||||
const voice = { lang: 'el-GR', name: 'Melina' };
|
||||
synth.voices = [{ lang: 'en-US' }, voice];
|
||||
expect(await speakOriginalWord('λόγος', 'G3056', 'lógos')).toBe('original');
|
||||
const utt = synth.speak.mock.calls[0][0];
|
||||
expect(utt.text).toBe('λόγος');
|
||||
expect(utt.voice).toBe(voice);
|
||||
expect(synth.cancel).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('reads the transliteration in the default voice when there is no Greek or Hebrew voice', async () => {
|
||||
synth.voices = [{ lang: 'en-US' }];
|
||||
expect(await speakOriginalWord('אָדָם', 'H121', '’ā·ḏām')).toBe('transliteration');
|
||||
const utt = synth.speak.mock.calls[0][0];
|
||||
expect(utt.text).toBe('’ā ḏām');
|
||||
expect(utt.lang).toBe('en-US');
|
||||
});
|
||||
|
||||
test('says it is unavailable when there is no voice and no transliteration, or no speech support', async () => {
|
||||
synth.voices = [{ lang: 'en-US' }];
|
||||
expect(await speakOriginalWord('λόγος', 'G3056')).toBe('unavailable');
|
||||
expect(synth.speak).not.toHaveBeenCalled();
|
||||
delete window.speechSynthesis;
|
||||
expect(await speakOriginalWord('λόγος', 'G3056', 'logos')).toBe('unavailable');
|
||||
});
|
||||
|
||||
test('waits for the voice list when the browser has not loaded it yet', async () => {
|
||||
const voice = { lang: 'he-IL' };
|
||||
synth.addEventListener = vi.fn((_, cb) => { synth.voices = [voice]; cb(); });
|
||||
expect(await speakOriginalWord('אָדָם', 'H121', 'adam')).toBe('original');
|
||||
});
|
||||
+46
-14
@@ -175,23 +175,55 @@ export const formatCrossRef = (ref) => {
|
||||
return `${bookName} ${ref.chapter}:${verseRange}`;
|
||||
};
|
||||
|
||||
export const speakOriginalWord = (word, strongsNum) => {
|
||||
if (!window.speechSynthesis) return;
|
||||
if (window.speechSynthesis.speaking) window.speechSynthesis.cancel();
|
||||
const lang = strongsNum?.startsWith('H') ? 'he-IL' : 'el-GR';
|
||||
const voices = window.speechSynthesis.getVoices();
|
||||
const match = voices.find((v) => v.lang.startsWith(lang.split('-')[0]));
|
||||
const utt = new SpeechSynthesisUtterance(word + '.');
|
||||
utt.lang = lang;
|
||||
// Which language a word is in: its own letters first (a word looked up by a Strong's number may not have one), then the
|
||||
// Strong's prefix. Returns 'he', 'el', or null.
|
||||
export const originalLanguage = (word, strongsNum) => {
|
||||
if (/[\u0590-\u05FF]/.test(word ?? '')) return 'he';
|
||||
if (/[\u0370-\u03FF\u1F00-\u1FFF]/.test(word ?? '')) return 'el';
|
||||
const prefix = String(strongsNum ?? '').trim().charAt(0).toUpperCase();
|
||||
return prefix === 'H' ? 'he' : prefix === 'G' ? 'el' : null;
|
||||
};
|
||||
|
||||
// What to hand the voice: no cantillation marks (they can stop a voice reading the word), no Hebrew maqaf or
|
||||
// sof pasuq, no stray punctuation.
|
||||
export const speakableText = (word) => String(word ?? '').replace(/[\u0591-\u05AF\u05BD\u05C0\u05C3]/g, '').replace(/\u05BE/g, ' ').replace(/[.,;:·׃]/g, '').replace(/\s+/g, ' ').trim();
|
||||
|
||||
// Browsers fill the voice list in after the page loads, so the first call can see none. Waits briefly for them.
|
||||
const loadVoices = () => new Promise((resolve) => {
|
||||
const synth = window.speechSynthesis;
|
||||
const now = synth.getVoices();
|
||||
if (now.length) { resolve(now); return; }
|
||||
const done = () => { synth.removeEventListener?.('voiceschanged', done); resolve(synth.getVoices()); };
|
||||
synth.addEventListener?.('voiceschanged', done);
|
||||
window.setTimeout(done, 800);
|
||||
});
|
||||
|
||||
// Says the original word in a Greek or Hebrew voice. Many phones and computers have no such voice installed, so
|
||||
// without one it reads the transliteration in the default voice instead of staying silent.
|
||||
// Resolves to 'original', 'transliteration' or 'unavailable' (nothing could be spoken).
|
||||
export const speakOriginalWord = async (word, strongsNum, transliteration = '') => {
|
||||
const synth = typeof window !== 'undefined' ? window.speechSynthesis : null;
|
||||
if (!synth || typeof SpeechSynthesisUtterance === 'undefined') return 'unavailable';
|
||||
const language = originalLanguage(word, strongsNum);
|
||||
const text = speakableText(word);
|
||||
const voices = await loadVoices();
|
||||
const voice = language && voices.find((v) => v.lang?.toLowerCase().startsWith(language));
|
||||
const fallback = String(transliteration ?? '').replace(/[·.]/g, ' ').replace(/\s+/g, ' ').trim();
|
||||
const useOriginal = !!(voice && text);
|
||||
const say = useOriginal ? text : fallback;
|
||||
if (!say) return 'unavailable';
|
||||
|
||||
synth.cancel(); // also clears a stuck queue, which some browsers get into
|
||||
const utt = new SpeechSynthesisUtterance(say);
|
||||
utt.rate = 0.85;
|
||||
if (match) utt.voice = match;
|
||||
if (useOriginal) { utt.voice = voice; utt.lang = voice.lang; } else utt.lang = 'en-US';
|
||||
// Chain a silent tail so the audio session fades rather than clicks shut
|
||||
utt.onend = () => {
|
||||
const tail = new SpeechSynthesisUtterance(' ');
|
||||
const tail = new SpeechSynthesisUtterance(' ');
|
||||
tail.volume = 0;
|
||||
tail.lang = lang;
|
||||
if (match) tail.voice = match;
|
||||
window.speechSynthesis.speak(tail);
|
||||
synth.speak(tail);
|
||||
};
|
||||
window.speechSynthesis.speak(utt);
|
||||
synth.resume?.(); // Chrome can leave the engine paused
|
||||
synth.speak(utt);
|
||||
return useOriginal ? 'original' : 'transliteration';
|
||||
};
|
||||
Reference in new issue
Block a user