From b5f0da56e86a3f81c666c61189c74f46db85a33a Mon Sep 17 00:00:00 2001 From: Nicolas Pavie Date: Tue, 24 Oct 2023 14:08:10 +0200 Subject: [PATCH 1/2] feat(tts): differenciated engine props update TTS engines panel can now connect and disconnect the embedded pipeline from azure and google cloud tts. Properties to be saved are only updated in settings when connection or disconnection to the engine is confirmed. Also : - Made the pipeline API shared between front and back end --- src/main/data/apis/pipeline.ts | 142 +----------- src/main/data/middlewares/pipeline.ts | 5 +- src/main/data/middlewares/settings.ts | 12 +- .../components/TtsEnginesConfig/index.tsx | 217 +++++++++++++++++- src/shared/data/apis/pipeline.ts | 179 +++++++++++++++ src/shared/data/slices/pipeline.ts | 1 + 6 files changed, 404 insertions(+), 152 deletions(-) create mode 100644 src/shared/data/apis/pipeline.ts diff --git a/src/main/data/apis/pipeline.ts b/src/main/data/apis/pipeline.ts index 76592a7..d0616cb 100644 --- a/src/main/data/apis/pipeline.ts +++ b/src/main/data/apis/pipeline.ts @@ -1,139 +1,5 @@ -import { - aliveXmlToJson, - datatypesXmlToJson, - datatypeXmlToJson, - jobRequestToXml, - jobsXmlToJson, - jobXmlToJson, - scriptsXmlToJson, - scriptXmlToJson, - voicesToJson, - ttsConfigToXml, -} from 'shared/parser/pipelineXmlConverter' -import { - Datatype, - baseurl, - Job, - ResultFile, - Script, - Webservice, - NamedResult, -} from 'shared/types' +import fetch from 'node-fetch' +import { info } from 'electron-log' +import { PipelineAPI } from 'shared/data/apis/pipeline' -import fetch, { Response, RequestInit } from 'node-fetch' - -import { info, error } from 'electron-log' -import { jobResponseXmlToJson } from 'shared/parser/pipelineXmlConverter/jobResponseToJson' -import { selectTtsConfig } from 'shared/data/slices/settings' -import { store } from '../store' - -/** - * Create a fetch function on the pipeline webservice - * for which the resulting pipeline xml is parsed and converted to a js object - * @type T return type of the parser - * @param webserviceUrlBuilder method to build a url, - * optionnaly using a webservice (like ``(ws) => `${baseurl(ws)}/scripts` ``) - * @param parser method to convert pipeline xml to an object object - * @param options options to be passed to the fetch call - * (like `{method:'POST', body:whateveryoulike}`) - * @returns a customized fetch function from the webservice - * `` (ws:Webservice) => Promise> `` - */ -function createPipelineFetchFunction( - webserviceUrlBuilder: (ws: Webservice) => string, - parser: (text: string) => T, - options?: RequestInit -) { - return (ws?: Webservice) => { - info('fetching ', webserviceUrlBuilder(ws)) - return fetch(webserviceUrlBuilder(ws), options) - .then((response: Response) => response.text()) - .then((text: string) => parser(text)) - } -} - -/** - * Create a simple request on the pipeline webservice - * @param webserviceUrlBuilder method to build a url, optionnaly using a webservice (like ``(ws) => `${baseurl(ws)}/scripts` ``) - * @param options options to be passed to the fetch call (like `{method:'POST', body:whateveryoulike}`) - * @returns a customized fetch function from the webservice `` (ws:Webservice) => Promise> `` - */ -function createPipelineRequestFunction( - webserviceUrlBuilder: (ws: Webservice) => string, - options?: RequestInit -) { - return (ws?: Webservice) => { - info('request', options.method ?? '', webserviceUrlBuilder(ws)) - return fetch(webserviceUrlBuilder(ws), options) - } -} - -export const pipelineAPI = { - fetchScriptDetails: (s: Script) => - createPipelineFetchFunction( - () => s.href, - (text) => scriptXmlToJson(text) - ), - fetchScripts: () => - createPipelineFetchFunction( - (ws) => `${baseurl(ws)}/scripts`, - (text) => scriptsXmlToJson(text) - ), - fetchJobs: () => - createPipelineFetchFunction( - (ws) => `${baseurl(ws)}/jobs`, - (text) => jobsXmlToJson(text) - ), - fetchJobData: (j: Job) => - createPipelineFetchFunction( - () => j.jobData.href, - (text) => { - return jobXmlToJson(text) - } - ), - launchJob: (j: Job) => - createPipelineFetchFunction( - (ws) => `${baseurl(ws)}/jobs`, - (text) => jobResponseXmlToJson(text), - { - method: 'POST', - body: jobRequestToXml({ - ...j.jobRequest, - nicename: - j.jobRequest.nicename || j.jobData.nicename || 'Job', - }), - } - ), - deleteJob: (j: Job) => - createPipelineRequestFunction(() => j.jobData.href, { - method: 'DELETE', - }), - fetchResult: (r: ResultFile | NamedResult) => () => - fetch(r.href) - .then((response) => response.blob()) - .then((blob) => blob.arrayBuffer()), - fetchDatatypeDetails: (d: Datatype) => - createPipelineFetchFunction( - () => d.href, - (text) => datatypeXmlToJson(d.href, d.id, text) - ), - fetchDatatypes: () => - createPipelineFetchFunction( - (ws) => `${baseurl(ws)}/datatypes`, - (text) => datatypesXmlToJson(text) - ), - fetchAlive: () => - createPipelineFetchFunction( - (ws) => `${baseurl(ws)}/alive`, - (text) => aliveXmlToJson(text) - ), - fetchTtsVoices: () => - createPipelineFetchFunction( - (ws) => `${baseurl(ws)}/voices`, - (text) => voicesToJson(text), - { - method: 'POST', - body: ttsConfigToXml(selectTtsConfig(store.getState())), - } - ), -} +export const pipelineAPI = new PipelineAPI(fetch, info) diff --git a/src/main/data/middlewares/pipeline.ts b/src/main/data/middlewares/pipeline.ts index 4a7e156..8a2d9b5 100644 --- a/src/main/data/middlewares/pipeline.ts +++ b/src/main/data/middlewares/pipeline.ts @@ -46,6 +46,7 @@ import { selectDownloadPath, selectPipelineProperties, selectSettings, + selectTtsConfig, } from 'shared/data/slices/settings' import { ParserException } from 'shared/parser/pipelineXmlConverter/parser' import { PipelineInstance } from 'main/factories' @@ -316,7 +317,9 @@ export function pipelineMiddleware({ getState, dispatch }) { }) .then((datatypes) => { dispatch(setDatatypes(datatypes)) - return pipelineAPI.fetchTtsVoices()(newWebservice) + return pipelineAPI.fetchTtsVoices( + selectTtsConfig(getState()) + )(newWebservice) }) .then((voices: Array) => { // console.log('TTS Voices', voices) diff --git a/src/main/data/middlewares/settings.ts b/src/main/data/middlewares/settings.ts index a394976..0fd33c9 100644 --- a/src/main/data/middlewares/settings.ts +++ b/src/main/data/middlewares/settings.ts @@ -4,7 +4,11 @@ import { info } from 'electron-log' import { existsSync, readFileSync, writeFile } from 'fs' import { resolve } from 'path' import { ENVIRONMENT } from 'shared/constants' -import { save, setAutoCheckUpdate } from 'shared/data/slices/settings' +import { + save, + selectTtsConfig, + setAutoCheckUpdate, +} from 'shared/data/slices/settings' import { checkForUpdate } from 'shared/data/slices/update' import { ttsConfigToXml } from 'shared/parser/pipelineXmlConverter/ttsConfigToXml' import { ApplicationSettings, TtsVoice } from 'shared/types' @@ -12,7 +16,7 @@ import { RootState } from 'shared/types/store' import { resolveUnpacked } from 'shared/utils' import { fileURLToPath, pathToFileURL } from 'url' import { pipelineAPI } from '../apis/pipeline' -import { setTtsVoices } from 'shared/data/slices/pipeline' +import { selectWebservice, setTtsVoices } from 'shared/data/slices/pipeline' const settingsFile = resolve(app.getPath('userData'), 'settings.json') @@ -154,8 +158,8 @@ export function settingsMiddleware({ getState, dispatch }) { ) // re-fetch the /voices endpoint pipelineAPI - .fetchTtsVoices()( - (getState() as RootState).pipeline.webservice + .fetchTtsVoices(selectTtsConfig(getState()))( + selectWebservice(getState()) ) .then((voices: Array) => { console.log('TTS Voices', voices) diff --git a/src/renderer/components/TtsEnginesConfig/index.tsx b/src/renderer/components/TtsEnginesConfig/index.tsx index 4374e5a..02b65c8 100644 --- a/src/renderer/components/TtsEnginesConfig/index.tsx +++ b/src/renderer/components/TtsEnginesConfig/index.tsx @@ -3,7 +3,9 @@ Select a script and submit a new job */ import { useState, useEffect } from 'react' import { useWindowStore } from 'renderer/store' -import { TtsConfig, TtsVoice, TtsEngineProperty } from 'shared/types/ttsConfig' +import { PipelineAPI } from 'shared/data/apis/pipeline' +import { selectTtsVoices, setTtsVoices } from 'shared/data/slices/pipeline' +import { TtsVoice } from 'shared/types/ttsConfig' const enginePropertyKeys = [ 'org.daisy.pipeline.tts.azure.key', @@ -15,21 +17,41 @@ const engineNames = { 'org.daisy.pipeline.tts.google': 'Google', } +const pipelineAPI = new PipelineAPI( + (url, ...args) => window.fetch(url, ...args), + console.info +) + +const { App } = window + +// Clone operation to ensure the full array is copied and avoid +// having array of references to object we don't want to change +const clone = (propsArray: Array<{ key: string; value: string }>) => [ + ...propsArray.map((kv) => ({ key: kv.key, value: kv.value })), +] + export function TtsEnginesConfigPane({ ttsEngineProperties, onChangeTtsEngineProperties, }) { const { pipeline } = useWindowStore() console.log('TTS engine props', ttsEngineProperties) - const [engineProperties, setEngineProperties] = useState([ - ...ttsEngineProperties, - ]) + // Clone array and objects in it to avoid updating the oriiginal props + const [engineProperties, setEngineProperties] = useState< + Array<{ key: string; value: string }> + >(clone(ttsEngineProperties)) - // useEffect(() => { + const [engineMessage, setEngineMessage] = useState<{ + [engineKey: string]: string + }>({}) + + const [enginePropsChanged, setEnginePropsChanged] = useState<{ + [engineKey: string]: boolean + }>({}) - // }, []) let onPropertyChange = (e, propName) => { - let engineProperties_ = [...engineProperties] + e.preventDefault() + let engineProperties_ = clone(engineProperties) let prop = engineProperties_.find((prop) => prop.key == propName) if (prop) { prop.value = e.target.value @@ -40,10 +62,148 @@ export function TtsEnginesConfigPane({ } engineProperties_.push(newProp) } + // Search for updates compared to original props + let realProp = (ttsEngineProperties || []).find( + (prop) => prop.key == propName + ) + const engineKey = propName.split('.').slice(0, 5).join('.') + setEngineMessage({ + ...engineMessage, + [engineKey]: null, + }) + setEnginePropsChanged({ + ...enginePropsChanged, + [engineKey]: + realProp == undefined || + (realProp && realProp.value != prop.value), + }) setEngineProperties([...engineProperties_]) - onChangeTtsEngineProperties([...engineProperties_]) } - console.log(engineProperties) + + const isConnectedToTTSEngine = (engineKey: string) => { + return ( + selectTtsVoices(App.store.getState()).filter( + (v) => v.engine == engineKey.split('.').slice(-1)[0] + ).length > 0 + ) + } + + const connectToTTSEngine = (engineKey: string) => { + const ttsProps = [ + ...engineProperties.filter((k) => k.key.startsWith(engineKey)), + ] + // Reset message or error for the engine + setEngineMessage({ + ...engineMessage, + [engineKey]: 'Connecting ...', + }) + pipelineAPI + .fetchTtsVoices({ + preferredVoices: [], + ttsEngineProperties: ttsProps, + })(pipeline.webservice) + .then((voices: TtsVoice[]) => { + // If any voice of the engine is now available + if ( + voices.filter( + (v) => v.engine == engineKey.split('.').slice(-1)[0] + ).length > 0 + ) { + // Connected, save the tts engine settings + const updatedSettings = [ + ...ttsEngineProperties.filter( + (k) => !k.key.startsWith(engineKey) + ), + ...ttsProps, + ] + // Save back in the complete settings the new settings + onChangeTtsEngineProperties(updatedSettings) + setEnginePropsChanged({ + ...enginePropsChanged, + [engineKey]: false, + }) + // use those new settings to recompute + // the full voices list + return pipelineAPI.fetchTtsVoices({ + preferredVoices: [], + ttsEngineProperties: updatedSettings, + })(pipeline.webservice) + } else { + // could not connect + // indicate connection error + setEngineMessage({ + ...engineMessage, + [engineKey]: + 'Could not connect to engine, please check yout credentials or the service status.', + }) + // and return empty array to not update the voices array + return [] + } + }) + .then((fullVoicesList: TtsVoice[]) => { + setEngineMessage({ + ...engineMessage, + [engineKey]: 'Connected', + }) + // Update the voices array if its not empty + if (fullVoicesList.length > 0) + App.store.dispatch(setTtsVoices(fullVoicesList)) + }) + .catch((e) => { + console.error(e) + // Indicate an error + setEngineMessage({ + ...engineMessage, + [engineKey]: + 'An error occured while trying to connect : ' + e, + }) + }) + } + + const disconnectFromTTSEngine = (engineKey: string) => { + setEngineMessage({ + ...engineMessage, + [engineKey]: 'Disconnecting ...', + }) + // Let user disconnect from a TTS engine + // For now, remove the API key setting provided for the engine selected + // (not ideal but not sure how to do it for now) + const updatedSettings = [ + ...ttsEngineProperties.filter( + (kv) => + !(kv.key.startsWith(engineKey) && kv.key.endsWith('key')) + ), + ] + console.log(updatedSettings) + // Save back in the complete settings the new settings + onChangeTtsEngineProperties(updatedSettings) + // use those new settings to recompute + // the full voices list + pipelineAPI + .fetchTtsVoices({ + preferredVoices: [], + ttsEngineProperties: updatedSettings, + })(pipeline.webservice) + .then((fullVoicesList: TtsVoice[]) => { + setEngineMessage({ + ...engineMessage, + [engineKey]: 'Disconnected', + }) + // Update the voices array if its not empty + if (fullVoicesList.length > 0) + App.store.dispatch(setTtsVoices(fullVoicesList)) + }) + .catch((e) => { + console.error(e) + // Indicate an error + setEngineMessage({ + ...engineMessage, + [engineKey]: + 'An error occured while trying to disconnect : ' + e, + }) + }) + } + return ( <>

@@ -95,6 +255,45 @@ export function TtsEnginesConfigPane({ /> ))} + {engineMessage[engineKeyPrefix] && ( +

  • + {engineMessage[engineKeyPrefix]} +
  • + )} + {['azure', 'google'].includes( + engineKeyPrefix.split('.').slice(-1)[0] + ) && ( +
  • + {!isConnectedToTTSEngine(engineKeyPrefix) || + enginePropsChanged[engineKeyPrefix] ? ( + + ) : isConnectedToTTSEngine( + engineKeyPrefix + ) ? ( + + ) : ( + '' + )} +
  • + )} ))} diff --git a/src/shared/data/apis/pipeline.ts b/src/shared/data/apis/pipeline.ts new file mode 100644 index 0000000..4ecc7cc --- /dev/null +++ b/src/shared/data/apis/pipeline.ts @@ -0,0 +1,179 @@ +import { + aliveXmlToJson, + datatypesXmlToJson, + datatypeXmlToJson, + jobRequestToXml, + jobsXmlToJson, + jobXmlToJson, + scriptsXmlToJson, + scriptXmlToJson, + voicesToJson, + ttsConfigToXml, +} from 'shared/parser/pipelineXmlConverter' +import { + Datatype, + baseurl, + Job, + ResultFile, + Script, + Webservice, + NamedResult, + TtsConfig, +} from 'shared/types' + +import { jobResponseXmlToJson } from 'shared/parser/pipelineXmlConverter/jobResponseToJson' + +//import fetch, { Response, RequestInit } from 'node-fetch' +//import { info, error } from 'electron-log' + +interface Response { + text: () => Promise + blob: () => Promise<{ + arrayBuffer: () => Promise + }> + status?: number + statusText?: string +} + +interface RequestInit { + method?: string + body?: {} +} +/** + * PipelineAPI class to fetch data from the webserver. + * + * A fetch function is passed in constructor to allow the use in both + * view and application side + */ +export class PipelineAPI { + fetchFunc: (url: string, options?: RequestInit) => Promise + info: (message?: any, ...optionalParams: any[]) => void + constructor(fetchFunc, info?) { + this.fetchFunc = fetchFunc + this.info = info ?? console.info + } + /** + * Create a fetch function on the pipeline webservice + * for which the resulting pipeline xml is parsed and converted to a js object + * @type T return type of the parser + * @param webserviceUrlBuilder method to build a url, + * optionnaly using a webservice (like ``(ws) => `${baseurl(ws)}/scripts` ``) + * @param parser method to convert pipeline xml to an object object + * @param options options to be passed to the fetch call + * (like `{method:'POST', body:whateveryoulike}`) + * @returns a customized fetch function from the webservice + * `` (ws:Webservice) => Promise> `` + */ + createPipelineFetchFunction( + webserviceUrlBuilder: (ws: Webservice) => string, + parser: (text: string) => T, + options?: RequestInit + ) { + return (ws?: Webservice) => { + this.info( + 'fetching ', + webserviceUrlBuilder(ws), + JSON.stringify(options) + ) + return this.fetchFunc(webserviceUrlBuilder(ws), options) + .then((response: Response) => response.text()) + .then((text: string) => parser(text)) + } + } + + /** + * Create a simple request on the pipeline webservice + * @param webserviceUrlBuilder method to build a url, optionnaly using a webservice (like ``(ws) => `${baseurl(ws)}/scripts` ``) + * @param options options to be passed to the fetch call (like `{method:'POST', body:whateveryoulike}`) + * @returns a customized fetch function from the webservice `` (ws:Webservice) => Promise> `` + */ + createPipelineRequestFunction( + webserviceUrlBuilder: (ws: Webservice) => string, + options?: RequestInit + ) { + return (ws?: Webservice) => { + this.info('request', options.method ?? '', webserviceUrlBuilder(ws)) + return this.fetchFunc(webserviceUrlBuilder(ws), options) + } + } + + fetchScriptDetails(s: Script) { + return this.createPipelineFetchFunction( + () => s.href, + (text) => scriptXmlToJson(text) + ) + } + fetchScripts() { + return this.createPipelineFetchFunction( + (ws) => `${baseurl(ws)}/scripts`, + (text) => scriptsXmlToJson(text) + ) + } + fetchJobs() { + return this.createPipelineFetchFunction( + (ws) => `${baseurl(ws)}/jobs`, + (text) => jobsXmlToJson(text) + ) + } + fetchJobData(j: Job) { + return this.createPipelineFetchFunction( + () => j.jobData.href, + (text) => { + return jobXmlToJson(text) + } + ) + } + launchJob(j: Job) { + return this.createPipelineFetchFunction( + (ws) => `${baseurl(ws)}/jobs`, + (text) => jobResponseXmlToJson(text), + { + method: 'POST', + body: jobRequestToXml({ + ...j.jobRequest, + nicename: + j.jobRequest.nicename || j.jobData.nicename || 'Job', + }), + } + ) + } + deleteJob(j: Job) { + return this.createPipelineRequestFunction(() => j.jobData.href, { + method: 'DELETE', + }) + } + fetchResult(r: ResultFile | NamedResult): () => Promise { + return () => + this.fetchFunc(r.href) + .then((response) => response.blob()) + .then((blob) => blob.arrayBuffer()) + } + fetchDatatypeDetails(d: Datatype) { + return this.createPipelineFetchFunction( + () => d.href, + (text) => datatypeXmlToJson(d.href, d.id, text) + ) + } + fetchDatatypes() { + return this.createPipelineFetchFunction( + (ws) => `${baseurl(ws)}/datatypes`, + (text) => datatypesXmlToJson(text) + ) + } + fetchAlive() { + return this.createPipelineFetchFunction( + (ws) => `${baseurl(ws)}/alive`, + (text) => aliveXmlToJson(text) + ) + } + fetchTtsVoices(ttsConfig: TtsConfig) { + return this.createPipelineFetchFunction( + (ws) => `${baseurl(ws)}/voices`, + (text) => voicesToJson(text), + { + method: 'POST', + body: ttsConfigToXml(ttsConfig), + } + ) + } +} diff --git a/src/shared/data/slices/pipeline.ts b/src/shared/data/slices/pipeline.ts index 6860ce7..2093c50 100644 --- a/src/shared/data/slices/pipeline.ts +++ b/src/shared/data/slices/pipeline.ts @@ -393,4 +393,5 @@ export const { selectDatatypes, newJob, prepareJobRequest, + selectTtsVoices, } = selectors From 6bc7ebc7352973f9d6117aeae40c46f231bda83f Mon Sep 17 00:00:00 2001 From: Nicolas Pavie Date: Wed, 25 Oct 2023 08:13:10 +0200 Subject: [PATCH 2/2] fix(ttsengines): connected message The connected message was wrongfully sent when trying to update the voices whatever was the result. --- src/renderer/components/TtsEnginesConfig/index.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/renderer/components/TtsEnginesConfig/index.tsx b/src/renderer/components/TtsEnginesConfig/index.tsx index 02b65c8..cb5dfc8 100644 --- a/src/renderer/components/TtsEnginesConfig/index.tsx +++ b/src/renderer/components/TtsEnginesConfig/index.tsx @@ -122,6 +122,10 @@ export function TtsEnginesConfigPane({ ...enginePropsChanged, [engineKey]: false, }) + setEngineMessage({ + ...engineMessage, + [engineKey]: 'Connected', + }) // use those new settings to recompute // the full voices list return pipelineAPI.fetchTtsVoices({ @@ -141,10 +145,6 @@ export function TtsEnginesConfigPane({ } }) .then((fullVoicesList: TtsVoice[]) => { - setEngineMessage({ - ...engineMessage, - [engineKey]: 'Connected', - }) // Update the voices array if its not empty if (fullVoicesList.length > 0) App.store.dispatch(setTtsVoices(fullVoicesList))