diff --git a/src/components/reader/VerseSheet.jsx b/src/components/reader/VerseSheet.jsx index a3b51d3..4e48203 100644 --- a/src/components/reader/VerseSheet.jsx +++ b/src/components/reader/VerseSheet.jsx @@ -36,7 +36,7 @@ function OriginalTab({ words }) { {w.p &&
{w.p}
} {w.s &&
{w.s}
} {canSpeak && ( - )} diff --git a/src/components/study/WordStudyTab.jsx b/src/components/study/WordStudyTab.jsx index 2130145..f61efd2 100644 --- a/src/components/study/WordStudyTab.jsx +++ b/src/components/study/WordStudyTab.jsx @@ -47,7 +47,7 @@ function WordCard({ word, open, onToggle, chunkId, wordStudy }) { {iq && strongs && } {strongs && } - {word.lexeme && } + {word.lexeme && }
diff --git a/src/pages/PronunciationPage.jsx b/src/pages/PronunciationPage.jsx index d918f91..84fcefd 100644 --- a/src/pages/PronunciationPage.jsx +++ b/src/pages/PronunciationPage.jsx @@ -100,7 +100,7 @@ function WordCard({ word, chapterRef, onSpeak }) { {word.transliteration &&

{word.transliteration}

}
- {word.lexeme && } + {word.lexeme && } {word.strongNumber && {word.strongNumber}}
diff --git a/src/utils/speakOriginalWord.test.js b/src/utils/speakOriginalWord.test.js new file mode 100644 index 0000000..e0b83fd --- /dev/null +++ b/src/utils/speakOriginalWord.test.js @@ -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'); +}); diff --git a/src/utils/wordStudy.js b/src/utils/wordStudy.js index f40eab0..a5c48f5 100644 --- a/src/utils/wordStudy.js +++ b/src/utils/wordStudy.js @@ -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'; };