1class voiceSearchInstance { 2 3 /** Timing values */ 4 initDelay = 5000; // wait this long for initial voice input before cancelling 5 continueDelay = 2000; // wait this long for pauses in voice input before searching 6 7 /** Settings assigned from options argument or resolved from DOM */ 8 elements = { 9 button: null, 10 input: null, 11 form: null 12 } 13 callbacks = { 14 onStart: undefined, 15 onEnd: undefined, 16 onError: undefined 17 } 18 19 constructor(clickEvent, options) { 20 this.setElements(clickEvent, options); 21 this.setCallbacks(options); 22 this.language = options?.language || navigator.language || navigator.userLanguage; 23 this.activeClass = options?.activeClass || 'voice-search-active'; 24 this.disableInputUpdate = options?.disableInputUpdate ?? false; 25 this.disableFormSubmit = options?.disableFormSubmit ?? false; 26 } 27 28 setElements(clickEvent, options) { 29 this.elements.button = 30 clickEvent instanceof Event ? 31 clickEvent.currentTarget || clickEvent.srcElement : 32 undefined; 33 this.elements.input = 34 options?.inputElement && 35 options.inputElement instanceof HTMLInputElement ? 36 options.inputElement : 37 this.helpers.getSiblingInput(this.elements.button); 38 this.elements.form = 39 options?.formElement && 40 options.formElement instanceof HTMLFormElement ? 41 options.formElement : 42 this.helpers.getParentForm(this.elements.button); 43 } 44 45 setCallbacks(options) { 46 if (options?.onStart) this.callbacks.onStart = options.onStart; 47 if (options?.onEnd) this.callbacks.onEnd = options.onEnd; 48 if (options?.onError) this.callbacks.onError = options.onError; 49 } 50 51 initSpeechRecognition() { 52 if (!('webkitSpeechRecognition' in window)) { 53 console.warn('Warning: voice search initialized but webkit speech recognition is not available on this browser'); 54 return; 55 } 56 if (this.elements.input.value != '') { 57 this.elements.input.value = ''; 58 } else if (this.elements.input && !this.disableInputUpdate) { 59 this.originalPlaceholder = this.elements.input.placeholder; 60 this.elements.input.value = ''; 61 } 62 63 let timeout; 64 const recognition = new window.webkitSpeechRecognition(); 65 recognition.continuous = this.helpers.isBrowserSafari(); // avoid a bug on Safari by setting continuous to true (https://developer.apple.com/forums//thread/694847?answerId=731859022#731859022) 66 recognition.interimResults = true; 67 recognition.lang = "da"; 68 console.log(this.language) 69 recognition.start(); 70 71 recognition.onstart = () => { 72 // set timeout to trigger speechend if user never starts speaking 73 timeout = window.setTimeout(() => { 74 this.endSpeechAndSearch('', recognition); 75 }, this.initDelay); 76 // set active class on voice search button 77 if (this.elements.button && !this.elements.button.classList.contains(this.activeClass)) { 78 this.elements.button.classList.add(this.activeClass); 79 } 80 // call onStart callback 81 if (this.callbacks.onStart) { 82 this.callbacks.onStart(); 83 } 84 }; 85 86 recognition.onresult = (event) => { 87 let interim_transcript = ''; 88 let final_transcript = ''; 89 90 // loop through result and keep track of words spoken so far 91 // trigger speechend when result is final or after user stops speaking 92 for (let i = event.resultIndex; i < event.results.length; ++i) { 93 if (event.results[i].isFinal) { 94 final_transcript = event.results[i][0].transcript; 95 this.endSpeechAndSearch(final_transcript, recognition); 96 } else { 97 interim_transcript += event.results[i][0].transcript; 98 if (this.elements.input && !this.disableInputUpdate) { 99 this.elements.input.placeholder = interim_transcript; 100 } 101 if (timeout) { 102 clearTimeout(timeout); 103 } 104 timeout = window.setTimeout(() => { 105 this.endSpeechAndSearch(interim_transcript, recognition); 106 }, this.continueDelay); 107 } 108 } 109 }; 110 111 recognition.onspeechend = () => { 112 if (this.elements.button) { 113 this.elements.button.classList.remove(this.activeClass); 114 } 115 if (timeout) { 116 clearTimeout(timeout); 117 } 118 }; 119 120 recognition.onerror = (event) => { 121 if (this.elements.button) { 122 this.elements.button.classList.remove(this.activeClass); 123 } 124 if (timeout) { 125 clearTimeout(timeout); 126 } 127 // suppress error for non-fatal 'abort' errors 128 if (event.error !== 'aborted') { 129 if (this.callbacks.onError) { 130 this.callbacks.onError(event); 131 } 132 console.error('Error: voice recognition failed: ', event); 133 } 134 }; 135 } 136 137 endSpeechAndSearch(query, recognition) { 138 if (query && query.length) { 139 if (!this.elements.input && !this.callbacks.onEnd) { 140 console.warn('Warning: no input or callback provided to handle voice search query'); 141 return; 142 } 143 // massage query 144 if (this.helpers.isBrowserSafari()) { 145 query = this.helpers.removeDuplicateTranscript(query); 146 } 147 query = this.helpers.stripSpecialChars(query); 148 // handle query 149 if (this.callbacks.onEnd) { 150 this.callbacks.onEnd(query); 151 } 152 if (this.elements.input && !this.disableInputUpdate) { 153 this.elements.input.placeholder = this.originalPlaceholder; 154 this.elements.input.value = query; 155 } 156 if (this.elements.form && !this.disableFormSubmit) { 157 this.elements.form.submit(); 158 } 159 160 } 161 if (this.elements.button) { 162 this.elements.button.classList.remove(this.activeClass); 163 } 164 recognition.stop(); 165 } 166 167 /** Helper functions */ 168 helpers = { 169 isBrowserSafari() { 170 const ua = window.navigator.userAgent; 171 return (ua.indexOf("Safari") > -1) && !(ua.indexOf("Chrome") > -1); 172 }, 173 getParentForm: function (target) { 174 if (target instanceof HTMLButtonElement || target instanceof HTMLInputElement) { 175 return target.form; 176 } 177 return undefined; 178 }, 179 getSiblingInput: function (target) { 180 if (target && target.parentNode) { 181 const siblingInput = target.parentNode.querySelector('input[type="search"]') || target.parentNode.querySelector('input[type="text"]'); 182 return siblingInput; 183 } 184 return undefined; 185 }, 186 removeDuplicateTranscript(final_transcript) { 187 const transcript = final_transcript.toLowerCase(); 188 const numWords = transcript.split(' ').length; 189 if ((numWords & 2) !== 0) { 190 return transcript; 191 } 192 const firstHalf = transcript.substring(0, transcript.length / 2).split(' ').join(''); 193 const secondHalf = transcript.substring(transcript.length / 2, transcript.length).split(' ').join(''); 194 if (firstHalf === secondHalf) { 195 return transcript.substring(0, transcript.length / 2); 196 } else { 197 return transcript; 198 } 199 },
200 stripSpecialChars(query) { 201 return query.replace(/[âââ,.+?:-]/g, ' ').trim(); 202 } 203 } 204} 205 206/** 207 * Initialize a voice search. 208 * @param {Object} options - An optional object of configurations for voice search. 209 * @param {Function} options.onStart - A callback that will fire when voice recognition begins. 210 * @param {Function} options.onEnd - A callback that will fire when voice recognition ends. The spoken text will be returned as an argument to the callback. 211 * @param {Function} options.onError - A callback that will fire when voice recognition fails. The error event will be returned as an argument to the callback. 212 * @param {HTMLInputElement} options.inputElement - Manually provide an input element that should be updated as the user speaks. 213 * @param {HTMLFormElement} options.formElement - Manually provide a form element that should be submitted after the user is done speaking. 214 * @param {string} options.language - The BCP 47 language code that should be used for speech recognition. Defaults to the browser's language property. 215 * @param {string} options.activeClass - A class that will be added to the voice search activation button while in listening mode. Defaults to 'voice-search-active'. 216 * @param {boolean} options.disableInputUpdate - Disable automatic updating of any input during and after speech recognition. 217 * @param {boolean} options.disableFormSubmit -Disable automatic submitting of any form after speech recognition. 218 */ 219window.initVoiceSearch = function (e, options) { 220 e.preventDefault(); 221 const instance = new voiceSearchInstance(e, options); 222 instance.initSpeechRecognition(); 223} 224
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.