2023-06-13 19:06:54 +02:00
|
|
|
import { languageCodeOnly } from '@joplin/lib/locale';
|
2023-07-27 17:05:56 +02:00
|
|
|
import Logger from '@joplin/utils/Logger';
|
2023-07-03 13:25:50 +02:00
|
|
|
import Setting from '@joplin/lib/models/Setting';
|
|
|
|
import { rtrimSlashes } from '@joplin/lib/path-utils';
|
2023-06-13 19:06:54 +02:00
|
|
|
import shim from '@joplin/lib/shim';
|
2023-05-07 18:53:19 +02:00
|
|
|
import Vosk from 'react-native-vosk';
|
2023-06-13 19:06:54 +02:00
|
|
|
import RNFetchBlob from 'rn-fetch-blob';
|
2024-10-26 22:00:56 +02:00
|
|
|
import { VoiceTypingProvider, VoiceTypingSession } from './VoiceTyping';
|
|
|
|
import { join } from 'path';
|
2023-06-13 19:06:54 +02:00
|
|
|
|
2023-05-07 13:05:41 +02:00
|
|
|
const logger = Logger.create('voiceTyping/vosk');
|
|
|
|
|
|
|
|
enum State {
|
|
|
|
Idle = 0,
|
|
|
|
Recording,
|
2023-06-13 19:06:54 +02:00
|
|
|
Completing,
|
2023-05-07 13:05:41 +02:00
|
|
|
}
|
|
|
|
|
2023-06-02 16:44:00 +02:00
|
|
|
interface StartOptions {
|
|
|
|
onResult: (text: string)=> void;
|
|
|
|
}
|
|
|
|
|
2023-06-13 19:06:54 +02:00
|
|
|
let vosk_: Record<string, Vosk> = {};
|
|
|
|
|
2023-05-07 13:05:41 +02:00
|
|
|
let state_: State = State.Idle;
|
|
|
|
|
2023-07-03 13:25:50 +02:00
|
|
|
const defaultSupportedLanguages = {
|
2023-06-13 19:06:54 +02:00
|
|
|
'en': 'https://alphacephei.com/vosk/models/vosk-model-small-en-us-0.15.zip',
|
2023-07-03 13:25:50 +02:00
|
|
|
'zh': 'https://alphacephei.com/vosk/models/vosk-model-small-cn-0.22.zip',
|
2023-06-13 19:06:54 +02:00
|
|
|
'ru': 'https://alphacephei.com/vosk/models/vosk-model-small-ru-0.22.zip',
|
|
|
|
'fr': 'https://alphacephei.com/vosk/models/vosk-model-small-fr-0.22.zip',
|
|
|
|
'de': 'https://alphacephei.com/vosk/models/vosk-model-small-de-0.15.zip',
|
|
|
|
'es': 'https://alphacephei.com/vosk/models/vosk-model-small-es-0.42.zip',
|
|
|
|
'pt': 'https://alphacephei.com/vosk/models/vosk-model-small-pt-0.3.zip',
|
|
|
|
'tr': 'https://alphacephei.com/vosk/models/vosk-model-small-tr-0.3.zip',
|
|
|
|
'vn': 'https://alphacephei.com/vosk/models/vosk-model-small-vn-0.4.zip',
|
|
|
|
'it': 'https://alphacephei.com/vosk/models/vosk-model-small-it-0.22.zip',
|
|
|
|
'nl': 'https://alphacephei.com/vosk/models/vosk-model-small-nl-0.22.zip',
|
|
|
|
'uk': 'https://alphacephei.com/vosk/models/vosk-model-small-uk-v3-small.zip',
|
|
|
|
'ja': 'https://alphacephei.com/vosk/models/vosk-model-small-ja-0.22.zip',
|
|
|
|
'hi': 'https://alphacephei.com/vosk/models/vosk-model-small-hi-0.22.zip',
|
|
|
|
'cs': 'https://alphacephei.com/vosk/models/vosk-model-small-cs-0.4-rhasspy.zip',
|
|
|
|
'pl': 'https://alphacephei.com/vosk/models/vosk-model-small-pl-0.22.zip',
|
|
|
|
'uz': 'https://alphacephei.com/vosk/models/vosk-model-small-uz-0.22.zip',
|
|
|
|
'ko': 'https://alphacephei.com/vosk/models/vosk-model-small-ko-0.22.zip',
|
|
|
|
};
|
|
|
|
|
|
|
|
export const isSupportedLanguage = (locale: string) => {
|
|
|
|
const l = languageCodeOnly(locale).toLowerCase();
|
2023-07-03 13:25:50 +02:00
|
|
|
return Object.keys(defaultSupportedLanguages).includes(l);
|
2023-06-13 19:06:54 +02:00
|
|
|
};
|
|
|
|
|
|
|
|
// Where all the models files for all the languages are
|
|
|
|
const getModelRootDir = () => {
|
|
|
|
return `${RNFetchBlob.fs.dirs.DocumentDir}/vosk-models`;
|
|
|
|
};
|
|
|
|
|
|
|
|
// Where we unzip a model after downloading it
|
|
|
|
const getUnzipDir = (locale: string) => {
|
|
|
|
return `${getModelRootDir()}/${locale}`;
|
|
|
|
};
|
|
|
|
|
|
|
|
// Where the model for a particular language is
|
|
|
|
const getModelDir = (locale: string) => {
|
|
|
|
return `${getUnzipDir(locale)}/model`;
|
|
|
|
};
|
|
|
|
|
2024-10-26 22:00:56 +02:00
|
|
|
const languageModelUrl = (locale: string): string => {
|
2023-07-03 13:25:50 +02:00
|
|
|
const lang = languageCodeOnly(locale).toLowerCase();
|
|
|
|
if (!(lang in defaultSupportedLanguages)) throw new Error(`No language file for: ${locale}`);
|
|
|
|
|
|
|
|
const urlTemplate = rtrimSlashes(Setting.value('voiceTypingBaseUrl').trim());
|
|
|
|
|
|
|
|
if (urlTemplate) {
|
|
|
|
let url = rtrimSlashes(urlTemplate);
|
|
|
|
if (!url.includes('{lang}')) url += '/{lang}.zip';
|
|
|
|
return url.replace(/\{lang\}/g, lang);
|
|
|
|
} else {
|
2024-04-05 13:16:49 +02:00
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
2023-07-03 13:25:50 +02:00
|
|
|
return (defaultSupportedLanguages as any)[lang];
|
|
|
|
}
|
2023-06-13 19:06:54 +02:00
|
|
|
};
|
|
|
|
|
|
|
|
|
2024-10-26 22:00:56 +02:00
|
|
|
export const getVosk = async (modelDir: string, locale: string) => {
|
2023-06-13 19:06:54 +02:00
|
|
|
if (vosk_[locale]) return vosk_[locale];
|
|
|
|
|
|
|
|
const vosk = new Vosk();
|
|
|
|
logger.info(`Loading model from ${modelDir}`);
|
|
|
|
await shim.fsDriver().readDirStats(modelDir);
|
|
|
|
const result = await vosk.loadModel(modelDir);
|
2023-06-10 18:02:58 +02:00
|
|
|
logger.info('getVosk:', result);
|
2023-06-13 19:06:54 +02:00
|
|
|
|
|
|
|
vosk_ = { [locale]: vosk };
|
|
|
|
|
|
|
|
return vosk;
|
|
|
|
};
|
|
|
|
|
2024-10-26 22:00:56 +02:00
|
|
|
export const startRecording = (vosk: Vosk, options: StartOptions): VoiceTypingSession => {
|
2023-05-07 13:05:41 +02:00
|
|
|
if (state_ !== State.Idle) throw new Error('Vosk is already recording');
|
|
|
|
|
|
|
|
state_ = State.Recording;
|
|
|
|
|
|
|
|
const result: string[] = [];
|
2024-04-05 13:16:49 +02:00
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
2023-05-07 13:05:41 +02:00
|
|
|
const eventHandlers: any[] = [];
|
2023-06-30 11:30:29 +02:00
|
|
|
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
|
2024-10-26 22:00:56 +02:00
|
|
|
const finalResultPromiseResolve: Function = null;
|
2023-06-30 11:30:29 +02:00
|
|
|
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
|
2024-10-26 22:00:56 +02:00
|
|
|
const finalResultPromiseReject: Function = null;
|
|
|
|
const finalResultTimeout = false;
|
2023-05-07 13:05:41 +02:00
|
|
|
|
|
|
|
const completeRecording = (finalResult: string, error: Error) => {
|
|
|
|
logger.info(`Complete recording. Final result: ${finalResult}. Error:`, error);
|
|
|
|
|
|
|
|
for (const eventHandler of eventHandlers) {
|
|
|
|
eventHandler.remove();
|
|
|
|
}
|
|
|
|
|
|
|
|
vosk.cleanup(),
|
|
|
|
|
|
|
|
state_ = State.Idle;
|
|
|
|
|
|
|
|
if (error) {
|
|
|
|
if (finalResultPromiseReject) finalResultPromiseReject(error);
|
|
|
|
} else {
|
|
|
|
if (finalResultPromiseResolve) finalResultPromiseResolve(finalResult);
|
|
|
|
}
|
|
|
|
};
|
|
|
|
|
|
|
|
eventHandlers.push(vosk.onResult(e => {
|
2023-06-02 16:44:00 +02:00
|
|
|
const text = e.data;
|
|
|
|
logger.info('Result', text);
|
|
|
|
result.push(text);
|
|
|
|
options.onResult(text);
|
2023-05-07 13:05:41 +02:00
|
|
|
}));
|
|
|
|
|
|
|
|
eventHandlers.push(vosk.onError(e => {
|
|
|
|
logger.warn('Error', e.data);
|
|
|
|
}));
|
|
|
|
|
|
|
|
eventHandlers.push(vosk.onTimeout(e => {
|
|
|
|
logger.warn('Timeout', e.data);
|
|
|
|
}));
|
|
|
|
|
|
|
|
eventHandlers.push(vosk.onFinalResult(e => {
|
|
|
|
logger.info('Final result', e.data);
|
|
|
|
|
|
|
|
if (finalResultTimeout) {
|
|
|
|
logger.warn('Got final result - but already timed out. Not doing anything.');
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
completeRecording(e.data, null);
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
2024-10-26 22:00:56 +02:00
|
|
|
start: async () => {
|
|
|
|
logger.info('Starting recording...');
|
|
|
|
await vosk.start();
|
2023-05-07 13:05:41 +02:00
|
|
|
},
|
2024-10-26 22:00:56 +02:00
|
|
|
stop: async () => {
|
2023-06-13 19:06:54 +02:00
|
|
|
if (state_ === State.Recording) {
|
2023-05-07 13:05:41 +02:00
|
|
|
logger.info('Cancelling...');
|
2023-06-13 19:06:54 +02:00
|
|
|
state_ = State.Completing;
|
2023-05-07 13:05:41 +02:00
|
|
|
vosk.stopOnly();
|
|
|
|
completeRecording('', null);
|
|
|
|
}
|
|
|
|
},
|
|
|
|
};
|
|
|
|
};
|
2024-10-26 22:00:56 +02:00
|
|
|
|
|
|
|
|
|
|
|
const vosk: VoiceTypingProvider = {
|
|
|
|
supported: () => true,
|
|
|
|
modelLocalFilepath: (locale: string) => getModelDir(locale),
|
|
|
|
getDownloadUrl: (locale) => languageModelUrl(locale),
|
|
|
|
getUuidPath: (locale: string) => join(getModelDir(locale), 'uuid'),
|
|
|
|
build: async ({ callbacks, locale, modelPath }) => {
|
|
|
|
const vosk = await getVosk(modelPath, locale);
|
|
|
|
return startRecording(vosk, { onResult: callbacks.onFinalize });
|
|
|
|
},
|
|
|
|
modelName: 'vosk',
|
|
|
|
};
|
|
|
|
|
|
|
|
export default vosk;
|