UNPKG

@freddydrodev/artyom

Version:

Artyom is a Robust Wrapper of the Google Chrome SpeechSynthesis and SpeechRecognition that allows you to create a virtual assistent

803 lines (693 loc) 21.5 kB
import type { ArtyomCommand, ArtyomFlags, ArtyomVoice, ArtyomGlobalEvents, SayCallbacksObject, ArtyomProperties, PromptOptions, MatchedCommand, IDevice, ArtyomVoiceIdentifiers, ArtyomGarbageCollection, ArtyomWebkitSpeechRecognition, } from "./types/artyom"; /** * Artyom.js is a voice control, speech recognition and speech synthesis JavaScript library. * * @requires {webkitSpeechRecognition && speechSynthesis} * @license MIT * @version 1.0.6 * @copyright 2017 Our Code World (www.ourcodeworld.com) All Rights Reserved. * @author Carlos Delgado (https://github.com/sdkcarlos) and Sema García (https://github.com/semagarcia) * @see https://sdkcarlos.github.io/sites/artyom.html * @see http://docs.ourcodeworld.com/projects/artyom-js */ export default class Artyom { private readonly ArtyomVoicesIdentifiers: ArtyomVoiceIdentifiers; private artyomWebkitSpeechRecognition!: ArtyomWebkitSpeechRecognition; private ArtyomVoice: ArtyomVoice; private ArtyomCommands: ArtyomCommand[]; private ArtyomGarbageCollection: ArtyomGarbageCollection[]; private ArtyomFlags: ArtyomFlags; private ArtyomProperties: ArtyomProperties; private readonly ArtyomGlobalEvents: ArtyomGlobalEvents; private readonly Device: IDevice; constructor() { this.ArtyomCommands = []; this.ArtyomVoicesIdentifiers = { "de-DE": ["Google Deutsch", "de-DE", "de_DE"], "es-ES": ["Google español", "es-ES", "es_ES", "es-MX", "es_MX"], "it-IT": ["Google italiano", "it-IT", "it_IT"], "ja-JP": ["Google 日本人", "ja-JP", "ja_JP"], "en-US": ["Google US English", "en-US", "en_US"], "en-GB": [ "Google UK English Male", "Google UK English Female", "en-GB", "en_GB", ], "pt-BR": [ "Google português do Brasil", "pt-PT", "pt-BR", "pt_PT", "pt_BR", ], "pt-PT": ["Google português do Brasil", "pt-PT", "pt_PT"], "ru-RU": ["Google русский", "ru-RU", "ru_RU"], "nl-NL": ["Google Nederlands", "nl-NL", "nl_NL"], "fr-FR": ["Google français", "fr-FR", "fr_FR"], "pl-PL": ["Google polski", "pl-PL", "pl_PL"], "id-ID": ["Google Bahasa Indonesia", "id-ID", "id_ID"], "hi-IN": ["Google हिन्दी", "hi-IN", "hi_IN"], "zh-CN": ["Google 普通话(中国大陆)", "zh-CN", "zh_CN"], "zh-HK": ["Google 粤語(香港)", "zh-HK", "zh_HK"], native: ["native"], }; // Initialize speech synthesis if ("speechSynthesis" in window) { window.speechSynthesis.getVoices(); } else { console.error("Artyom.js can't speak without the Speech Synthesis API."); } // Initialize speech recognition if ("webkitSpeechRecognition" in window) { this.artyomWebkitSpeechRecognition = new ( window as any ).webkitSpeechRecognition(); } else { throw new Error( "Artyom.js can't recognize voice without the Speech Recognition API." ); } this.ArtyomProperties = { lang: "en-GB", recognizing: false, continuous: false, speed: 1, volume: 1, listen: false, mode: "normal", debug: false, helpers: { redirectRecognizedTextOutput: undefined, remoteProcessorHandler: undefined, lastSay: undefined, fatalityPromiseCallback: undefined, }, executionKeyword: undefined, obeyKeyword: undefined, speaking: false, obeying: true, soundex: false, name: undefined, }; this.ArtyomGarbageCollection = []; this.ArtyomFlags = { restartRecognition: false }; this.ArtyomGlobalEvents = { ERROR: "ERROR", SPEECH_SYNTHESIS_START: "SPEECH_SYNTHESIS_START", SPEECH_SYNTHESIS_END: "SPEECH_SYNTHESIS_END", TEXT_RECOGNIZED: "TEXT_RECOGNIZED", COMMAND_RECOGNITION_START: "COMMAND_RECOGNITION_START", COMMAND_RECOGNITION_END: "COMMAND_RECOGNITION_END", COMMAND_MATCHED: "COMMAND_MATCHED", NOT_COMMAND_MATCHED: "NOT_COMMAND_MATCHED", }; this.Device = { isMobile: /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test( navigator.userAgent ), isChrome: /Chrome/.test(navigator.userAgent), }; this.ArtyomVoice = { default: false, lang: "en-GB", localService: false, name: "Google UK English Male", voiceURI: "Google UK English Male", }; this.initializeDevice(); this.initializeSpeechRecognition(); } private initializeDevice(): void { this.Device.isChrome = /Chrome/.test(navigator.userAgent) && /Google Inc/.test(navigator.vendor); this.Device.isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test( navigator.userAgent ); } private initializeSpeechRecognition(): void { if (!window.webkitSpeechRecognition) { throw new Error("Speech Recognition API not supported in this browser"); } this.artyomWebkitSpeechRecognition = new window.webkitSpeechRecognition(); this.setupSpeechRecognitionEvents(); } private setupSpeechRecognitionEvents(): void { this.artyomWebkitSpeechRecognition.onresult = ( event: SpeechRecognitionEvent ) => { const result = event.results[event.results.length - 1]; const transcript = result[0].transcript; this.handleSpeechResult(transcript, result.isFinal); }; this.artyomWebkitSpeechRecognition.onerror = ( event: SpeechRecognitionErrorEvent ) => { console.error("Speech recognition error:", event.error); }; this.artyomWebkitSpeechRecognition.onend = () => { if (this.ArtyomProperties.continuous) { this.artyomWebkitSpeechRecognition.start(); } }; } private handleSpeechResult(transcript: string, isFinal: boolean): void { if (this.ArtyomProperties.helpers.redirectRecognizedTextOutput) { this.ArtyomProperties.helpers.redirectRecognizedTextOutput( transcript, isFinal ); } } // Modern ES6+ methods addCommands(param: ArtyomCommand | ArtyomCommand[]): boolean { const processCommand = (command: ArtyomCommand) => { if ("indexes" in command) { this.ArtyomCommands.push(command); } else { console.error( "The given command doesn't provide any index to execute." ); } }; Array.isArray(param) ? param.forEach(processCommand) : processCommand(param); return true; } clearGarbageCollection(): ArtyomGarbageCollection[] { return (this.ArtyomGarbageCollection = []); } debug(message: string, type?: "error" | "warn" | "info"): void { if (!this.ArtyomProperties.debug) return; const preMessage = `[v${this.getVersion()}] Artyom.js`; const styles = { error: "background: #C12127; color: black;", info: "background: #4285F4; color: #FFFFFF", default: "background: #005454; color: #BFF8F8", }; switch (type) { case "error": console.log( `%c${preMessage}:%c ${message}`, styles.error, "color:black;" ); break; case "warn": console.warn(message); break; case "info": console.log( `%c${preMessage}:%c ${message}`, styles.info, "color:black;" ); break; default: console.log( `%c${preMessage}:%c ${message}`, styles.default, "color:black;" ); } } detectErrors(): { code: string; message: string } | false { if (window.location.protocol === "file:") { const message = "Error: running Artyom directly from a file. The APIs require a different communication protocol like HTTP or HTTPS"; console.error(message); return { code: "artyom_error_localfile", message }; } if (!this.Device.isChrome) { const message = "Error: the Speech Recognition and Speech Synthesis APIs require the Google Chrome Browser to work."; console.error(message); return { code: "artyom_error_browser_unsupported", message }; } if (window.location.protocol !== "https:") { console.warn( `Warning: artyom is being executed using the '${window.location.protocol}' protocol. The continuous mode requires a secure protocol (HTTPS)` ); } return false; } emptyCommands(): ArtyomCommand[] { return (this.ArtyomCommands = []); } async fatality(): Promise<void> { return new Promise((resolve) => { this.ArtyomProperties.helpers.fatalityPromiseCallback = resolve; try { this.ArtyomFlags.restartRecognition = false; this.artyomWebkitSpeechRecognition.stop(); } catch (e) { console.error(e); } }); } getAvailableCommands(): ArtyomCommand[] { return this.ArtyomCommands; } getVoices(): SpeechSynthesisVoice[] { return window.speechSynthesis.getVoices(); } speechSupported(): boolean { return "speechSynthesis" in window; } recognizingSupported(): boolean { return "webkitSpeechRecognition" in window; } shutUp(): void { if ("speechSynthesis" in window) { while (window.speechSynthesis.pending) { window.speechSynthesis.cancel(); } } this.ArtyomProperties.speaking = false; this.clearGarbageCollection(); } getProperties(): ArtyomProperties { return this.ArtyomProperties; } getLanguage(): string { return this.ArtyomProperties.lang; } getVersion(): string { return "1.0.6"; } // Modern ES6+ methods continued on( indexes: string[], smart?: boolean ): { then: (action: () => void) => void } { return { then: (action: () => void) => { const command: ArtyomCommand = { indexes, action, ...(smart && { smart: true }), }; this.addCommands(command); }, }; } triggerEvent(name: string, param?: any): CustomEvent { const event = new CustomEvent(name, { detail: param }); document.dispatchEvent(event); return event; } repeatLastSay(returnObject?: boolean): { text: string; date: Date } | void { const last = this.ArtyomProperties.helpers.lastSay; if (returnObject) { return last; } if (last) { this.say(last.text); } } when(event: string, action: (detail: any) => void): void { document.addEventListener(event, ((e: Event) => { if (e instanceof CustomEvent) { action(e.detail); } }) as EventListener); } remoteProcessorService(action: (text: string) => void): boolean { this.ArtyomProperties.helpers.remoteProcessorHandler = action; return true; } voiceAvailable(languageCode: string): boolean { return typeof this.getVoice(languageCode) !== "undefined"; } isObeying(): boolean { return this.ArtyomProperties.obeying; } obey(): boolean { return (this.ArtyomProperties.obeying = true); } dontObey(): boolean { return (this.ArtyomProperties.obeying = false); } isSpeaking(): boolean { return this.ArtyomProperties.speaking; } isRecognizing(): boolean { return this.ArtyomProperties.recognizing; } getNativeApi(): ArtyomWebkitSpeechRecognition { return this.artyomWebkitSpeechRecognition; } getGarbageCollection(): ArtyomGarbageCollection[] { return this.ArtyomGarbageCollection; } getVoice(languageCode: string): SpeechSynthesisVoice | undefined { const voiceIdentifiersArray = this.ArtyomVoicesIdentifiers[languageCode] || this.ArtyomVoicesIdentifiers["en-GB"]; const voices = window.speechSynthesis.getVoices(); return voices.find((voice) => voiceIdentifiersArray.some( (identifier) => voice.name === identifier || voice.lang === identifier ) ); } newDictation(settings: { onResult?: (interim: string, final: string) => void; onStart?: () => void; onEnd?: () => void; onError?: (event: any) => void; continuous?: boolean; }): { start: () => void; stop: () => void; onError: null | ((event: any) => void); } { if (!this.recognizingSupported()) { console.error("SpeechRecognition is not supported in this browser"); return { start: () => {}, stop: () => {}, onError: null, }; } const dictation = new (window as any).webkitSpeechRecognition(); dictation.continuous = true; dictation.interimResults = true; dictation.lang = this.ArtyomProperties.lang; dictation.onresult = (event: SpeechRecognitionEvent) => { const { interimTranscript, finalTranscript } = Array.from( event.results ).reduce( (acc, result) => ({ interimTranscript: acc.interimTranscript + (result.isFinal ? "" : result[0].transcript), finalTranscript: acc.finalTranscript + (result.isFinal ? result[0].transcript : ""), }), { interimTranscript: "", finalTranscript: "" } ); settings.onResult?.(interimTranscript, finalTranscript); }; return { start: () => { let flagStartCallback = true; let flagRestart = settings.continuous ?? false; dictation.onstart = () => { if (flagStartCallback) { settings.onStart?.(); } }; dictation.onend = () => { if (flagRestart) { flagStartCallback = false; dictation.start(); } else { flagStartCallback = true; settings.onEnd?.(); } }; dictation.start(); }, stop: () => { dictation.stop(); }, onError: settings.onError || null, }; } newPrompt(config: PromptOptions): void { if (typeof config !== "object") { console.error("Expected the prompt configuration."); return; } const copyActualCommands = [...this.ArtyomCommands]; this.emptyCommands(); const promptCommand: ArtyomCommand = { description: "Setting the artyom commands only for the prompt. The commands will be restored after the prompt finishes", indexes: config.options, action: (i: number, wildcard: string) => { this.ArtyomCommands = copyActualCommands; const toExe = config.onMatch?.(i, wildcard); if (typeof toExe !== "function") { console.error( "onMatch function expects a returning function to be executed" ); return; } toExe(); }, ...(config.smart && { smart: true }), }; this.addCommands(promptCommand); config.beforePrompt?.(); this.say(config.question, { onStart: () => config.onStartPrompt?.(), onEnd: () => config.onEndPrompt?.(), }); } sayRandom(data: string[]): { text: string; index: number } | null { if (!Array.isArray(data)) { console.error("Random quotes must be in an array!"); return null; } const index = Math.floor(Math.random() * data.length); this.say(data[index]); return { text: data[index], index }; } setDebug(status: boolean): boolean { return (this.ArtyomProperties.debug = status); } simulateInstruction(sentence: string): boolean { if (!sentence || typeof sentence !== "string") { console.warn("Cannot execute a non string command"); return false; } const foundCommand = this.execute(sentence); if (foundCommand?.instruction) { this.debug( `Command matches with simulation, executing${ foundCommand.instruction.smart ? " (smart)" : "" }`, "info" ); if (foundCommand.instruction.smart) { foundCommand.instruction.action( foundCommand.index, foundCommand.wildcard?.item, foundCommand.wildcard?.full ); } else { foundCommand.instruction.action(foundCommand.index); } return true; } console.warn(`No command found trying with ${sentence}`); return false; } soundex(s: string): string { const a = s.toLowerCase().split(""); const f = a.shift() || ""; const codes: Record<string, string | number> = { a: "", e: "", i: "", o: "", u: "", b: 1, f: 1, p: 1, v: 1, c: 2, g: 2, j: 2, k: 2, q: 2, s: 2, x: 2, z: 2, d: 3, t: 3, l: 4, m: 5, n: 5, r: 6, }; const r = f + a .map((v) => codes[v as keyof typeof codes] ?? "") .filter((v, i, a) => i === 0 ? v !== codes[f as keyof typeof codes] : v !== a[i - 1] ) .join(""); return (r + "000").slice(0, 4).toUpperCase(); } splitStringByChunks( input: string = "", chunk_length: number = 100 ): string[] { const output: string[] = []; let curr = chunk_length; let prev = 0; while (input[curr]) { if (input[curr++] === " ") { output.push(input.substring(prev, curr)); prev = curr; curr += chunk_length; } } output.push(input.substr(prev)); return output; } redirectRecognizedTextOutput( action: (text: string, isFinal: boolean) => void ): boolean { if (typeof action !== "function") { console.warn("Expected function to handle the recognized text ..."); return false; } this.ArtyomProperties.helpers.redirectRecognizedTextOutput = action; return true; } async restart(): Promise<void> { const copyInit = { ...this.ArtyomProperties }; await this.fatality(); return this.initialize(copyInit); } private talk( text: string, actualChunk: number, totalChunks: number, callbacks?: SayCallbacksObject ): void { const msg = new SpeechSynthesisUtterance(text); msg.volume = this.ArtyomProperties.volume; msg.rate = this.ArtyomProperties.speed; const availableVoice = callbacks?.lang ? this.getVoice(callbacks.lang) : this.getVoice(this.ArtyomProperties.lang); if (this.Device.isMobile) { if (availableVoice) { msg.lang = availableVoice.lang; } } else { if (availableVoice) { msg.voice = availableVoice; } } if (actualChunk === 1) { msg.addEventListener("start", () => { this.ArtyomProperties.speaking = true; this.debug( `Event reached: ${this.ArtyomGlobalEvents.SPEECH_SYNTHESIS_START}` ); this.triggerEvent(this.ArtyomGlobalEvents.SPEECH_SYNTHESIS_START); callbacks?.onStart?.(); }); } if (actualChunk >= totalChunks) { msg.addEventListener("end", () => { this.ArtyomProperties.speaking = false; this.debug( `Event reached: ${this.ArtyomGlobalEvents.SPEECH_SYNTHESIS_END}` ); this.triggerEvent(this.ArtyomGlobalEvents.SPEECH_SYNTHESIS_END); callbacks?.onEnd?.(); }); } this.debug( `${actualChunk} text chunk processed successfully out of ${totalChunks}` ); this.ArtyomGarbageCollection.push(msg); window.speechSynthesis.speak(msg); } say(message: string, callbacks?: SayCallbacksObject): void { if (!this.speechSupported()) return; if (typeof message !== "string") { console.warn(`Artyom expects a string to speak ${typeof message} given`); return; } if (!message.length) { console.warn("Cannot speak empty string"); return; } const MAX_CHUNK_LENGTH = 115; let definitive: string[] = []; if (message.length > MAX_CHUNK_LENGTH) { const naturalReading = message.split(/,|:|\. |;/); definitive = this.flatMap(naturalReading, (chunk: string) => chunk.length > MAX_CHUNK_LENGTH ? this.splitStringByChunks(chunk, MAX_CHUNK_LENGTH) : [chunk] ); } else { definitive = [message]; } definitive.forEach((chunk, index) => { if (chunk) { this.talk(chunk, index + 1, definitive.length, callbacks); } }); this.ArtyomProperties.helpers.lastSay = { text: message, date: new Date(), }; } public execute(command: string | ArtyomCommand): MatchedCommand | undefined { if (typeof command === "string") { return this.findCommand(command); } else if (command.action) { command.action(0); } return undefined; } private findCommand(text: string): MatchedCommand | undefined { const cmd = this.ArtyomCommands.find((cmd) => cmd.indexes.some((index) => text.toLowerCase().includes(index.toLowerCase()) ) ); if (cmd) { return { index: 0, instruction: cmd, wildcard: undefined, }; } return undefined; } public initialize(config: Partial<ArtyomProperties>): void { Object.assign(this.ArtyomProperties, config); } public setVoice(voice: SpeechSynthesisVoice | null): void { if (voice) { this.ArtyomProperties.voice = voice; } } public flatMap<T>(array: T[], callback: (item: T) => T[]): T[] { return array.reduce((acc: T[], item: T) => [...acc, ...callback(item)], []); } public addEventListener( type: string, listener: EventListenerOrEventListenerObject ): void { document.addEventListener(type, listener); } }