@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
text/typescript
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);
}
}