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.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>}
|
{w.s && <div className="mt-0.5 text-[10px] text-[var(--r-sub)]">{w.s}</div>}
|
||||||
{canSpeak && (
|
{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"
|
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>
|
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>
|
<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>}
|
{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>}
|
{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>
|
<button type="button" onClick={() => removeGreekWord(chunkId, word.id)} className={`${btn} ml-auto text-[var(--r-danger,#b42318)]`}>Delete</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<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>}
|
{word.transliteration && <p className="mt-1 text-base italic text-[var(--r-sub)]">{word.transliteration}</p>}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 flex-col items-end gap-2">
|
<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>}
|
{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>
|
||||||
</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}`;
|
return `${bookName} ${ref.chapter}:${verseRange}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const speakOriginalWord = (word, strongsNum) => {
|
// 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
|
||||||
if (!window.speechSynthesis) return;
|
// Strong's prefix. Returns 'he', 'el', or null.
|
||||||
if (window.speechSynthesis.speaking) window.speechSynthesis.cancel();
|
export const originalLanguage = (word, strongsNum) => {
|
||||||
const lang = strongsNum?.startsWith('H') ? 'he-IL' : 'el-GR';
|
if (/[\u0590-\u05FF]/.test(word ?? '')) return 'he';
|
||||||
const voices = window.speechSynthesis.getVoices();
|
if (/[\u0370-\u03FF\u1F00-\u1FFF]/.test(word ?? '')) return 'el';
|
||||||
const match = voices.find((v) => v.lang.startsWith(lang.split('-')[0]));
|
const prefix = String(strongsNum ?? '').trim().charAt(0).toUpperCase();
|
||||||
const utt = new SpeechSynthesisUtterance(word + '.');
|
return prefix === 'H' ? 'he' : prefix === 'G' ? 'el' : null;
|
||||||
utt.lang = lang;
|
};
|
||||||
|
|
||||||
|
// 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;
|
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
|
// Chain a silent tail so the audio session fades rather than clicks shut
|
||||||
utt.onend = () => {
|
utt.onend = () => {
|
||||||
const tail = new SpeechSynthesisUtterance(' ');
|
const tail = new SpeechSynthesisUtterance(' ');
|
||||||
tail.volume = 0;
|
tail.volume = 0;
|
||||||
tail.lang = lang;
|
synth.speak(tail);
|
||||||
if (match) tail.voice = match;
|
|
||||||
window.speechSynthesis.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