Pop-up dictionary browser extension for language learning. Successor to Yomichan. (PERSONAL FORK)
1/*
2 * Copyright (C) 2023-2025 Yomitan Authors
3 * Copyright (C) 2019-2022 Yomichan Authors
4 *
5 * This program is free software: you can redistribute it and/or modify
6 * it under the terms of the GNU General Public License as published by
7 * the Free Software Foundation, either version 3 of the License, or
8 * (at your option) any later version.
9 *
10 * This program is distributed in the hope that it will be useful,
11 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 * GNU General Public License for more details.
14 *
15 * You should have received a copy of the GNU General Public License
16 * along with this program. If not, see <https://www.gnu.org/licenses/>.
17 */
18
19import {EventDispatcher} from '../core/event-dispatcher.js';
20import {TextToSpeechAudio} from './text-to-speech-audio.js';
21
22/**
23 * @augments EventDispatcher<import('audio-system').Events>
24 */
25export class AudioSystem extends EventDispatcher {
26 constructor() {
27 super();
28 /** @type {?HTMLAudioElement} */
29 this._fallbackAudio = null;
30 /** @type {?import('settings').FallbackSoundType} */
31 this._fallbackSoundType = null;
32 }
33
34 /**
35 * @returns {void}
36 */
37 prepare() {
38 // speechSynthesis.getVoices() will not be populated unless some API call is made.
39 if (
40 typeof speechSynthesis !== 'undefined' &&
41 typeof speechSynthesis.addEventListener === 'function'
42 ) {
43 speechSynthesis.addEventListener('voiceschanged', this._onVoicesChanged.bind(this), false);
44 }
45 }
46
47 /**
48 * @param {import('settings').FallbackSoundType} fallbackSoundType
49 * @returns {HTMLAudioElement}
50 */
51 getFallbackAudio(fallbackSoundType) {
52 if (this._fallbackAudio === null || this._fallbackSoundType !== fallbackSoundType) {
53 this._fallbackSoundType = fallbackSoundType;
54 switch (fallbackSoundType) {
55 case 'click':
56 this._fallbackAudio = new Audio('/data/audio/fallback-click.mp3');
57 break;
58 case 'bloop':
59 this._fallbackAudio = new Audio('/data/audio/fallback-bloop.mp3');
60 break;
61 case 'none':
62 // audio handler expects audio url to always be present, empty string must be used instead of `new Audio()`
63 this._fallbackAudio = new Audio('');
64 break;
65 }
66 }
67 return this._fallbackAudio;
68 }
69
70 /**
71 * @param {string} url
72 * @param {import('settings').AudioSourceType} sourceType
73 * @returns {Promise<HTMLAudioElement>}
74 */
75 async createAudio(url, sourceType) {
76 const audio = new Audio(url);
77 await this._waitForData(audio);
78 if (!this._isAudioValid(audio, sourceType)) {
79 throw new Error('Could not retrieve audio');
80 }
81 return audio;
82 }
83
84 /**
85 * @param {string} text
86 * @param {string} voiceUri
87 * @returns {TextToSpeechAudio}
88 * @throws {Error}
89 */
90 createTextToSpeechAudio(text, voiceUri) {
91 const voice = this._getTextToSpeechVoiceFromVoiceUri(voiceUri);
92 if (voice === null) {
93 throw new Error('Invalid text-to-speech voice');
94 }
95 return new TextToSpeechAudio(text, voice);
96 }
97
98 // Private
99
100 /**
101 * @param {Event} event
102 */
103 _onVoicesChanged(event) {
104 this.trigger('voiceschanged', event);
105 }
106
107 /**
108 * @param {HTMLAudioElement} audio
109 * @returns {Promise<void>}
110 */
111 _waitForData(audio) {
112 return new Promise((resolve, reject) => {
113 audio.addEventListener('loadeddata', () => resolve());
114 audio.addEventListener('error', () => reject(audio.error));
115 });
116 }
117
118 /**
119 * @param {HTMLAudioElement} audio
120 * @param {import('settings').AudioSourceType} sourceType
121 * @returns {boolean}
122 */
123 _isAudioValid(audio, sourceType) {
124 switch (sourceType) {
125 case 'jpod101':
126 {
127 const duration = audio.duration;
128 return (
129 duration !== 5.694694 && // Invalid audio (Chrome)
130 duration !== 5.651111 // Invalid audio (Firefox)
131 );
132 }
133 default:
134 return true;
135 }
136 }
137
138 /**
139 * @param {string} voiceUri
140 * @returns {?SpeechSynthesisVoice}
141 */
142 _getTextToSpeechVoiceFromVoiceUri(voiceUri) {
143 try {
144 for (const voice of speechSynthesis.getVoices()) {
145 if (voice.voiceURI === voiceUri) {
146 return voice;
147 }
148 }
149 } catch (e) {
150 // NOP
151 }
152 return null;
153 }
154}