From 0aa5447b9bf41b0857929cae9f07fbba1785c8b9 Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Wed, 31 Aug 2022 17:26:49 +0800 Subject: [PATCH] Read SSML file to stream --- packages/text-to-speech/data/ssml.xml | 8 ++ packages/text-to-speech/data/test.ssml | 0 packages/text-to-speech/package.json | 3 +- packages/text-to-speech/src/index.ts | 50 ++++++------ packages/text-to-speech/src/textToSpeech.ts | 87 +++++++++++---------- 5 files changed, 84 insertions(+), 64 deletions(-) create mode 100644 packages/text-to-speech/data/ssml.xml delete mode 100644 packages/text-to-speech/data/test.ssml diff --git a/packages/text-to-speech/data/ssml.xml b/packages/text-to-speech/data/ssml.xml new file mode 100644 index 000000000..2e8e27e77 --- /dev/null +++ b/packages/text-to-speech/data/ssml.xml @@ -0,0 +1,8 @@ + + + Good morning! + + + Good morning to you too Jenny! + + diff --git a/packages/text-to-speech/data/test.ssml b/packages/text-to-speech/data/test.ssml deleted file mode 100644 index e69de29bb..000000000 diff --git a/packages/text-to-speech/package.json b/packages/text-to-speech/package.json index 6b21521bb..5416fd83e 100644 --- a/packages/text-to-speech/package.json +++ b/packages/text-to-speech/package.json @@ -14,7 +14,8 @@ "lint": "eslint src --ext ts,js,tsx,jsx", "compile": "tsc", "build": "tsc", - "start": "functions-framework --source=build/src/ --target=textToSpeechHandler", + "start": "functions-framework --target=textToSpeechHandler", + "start_streaming": "functions-framework --target=textToSpeechStreamingHandler", "dev": "concurrently \"tsc -w\" \"nodemon --watch ./build/ --exec npm run start\"", "gcloud-deploy": "gcloud functions deploy text-to-speech --gen2 --entry-point=textToSpeechHandler --trigger-http --allow-unauthenticated --region=us-west2 --runtime nodejs14", "deploy": "yarn build && yarn gcloud-deploy" diff --git a/packages/text-to-speech/src/index.ts b/packages/text-to-speech/src/index.ts index 8c51640aa..0cff52eba 100644 --- a/packages/text-to-speech/src/index.ts +++ b/packages/text-to-speech/src/index.ts @@ -9,9 +9,14 @@ import * as jwt from 'jsonwebtoken' import * as dotenv from 'dotenv' // see https://github.com/motdotla/dotenv#how-do-i-use-dotenv-with-import import { synthesizeTextToSpeech, TextToSpeechInput } from './textToSpeech' import { File, Storage } from '@google-cloud/storage' -import { createWriteStream } from 'fs' +import { PassThrough } from 'stream' +import * as fs from 'fs' dotenv.config() +Sentry.GCPFunction.init({ + dsn: process.env.SENTRY_DSN, + tracesSampleRate: 0, +}) const storage = new Storage() @@ -51,18 +56,13 @@ const updateSpeech = async ( return response.status === 200 } -Sentry.GCPFunction.init({ - dsn: process.env.SENTRY_DSN, - tracesSampleRate: 0, -}) - export const textToSpeechHandler = Sentry.GCPFunction.wrapHttpFunction( async (req, res) => { console.debug('New text to speech request', req) const token = req.query.token as string if (!process.env.JWT_SECRET) { console.error('JWT_SECRET not exists') - return res.status(500).send('JWT_SECRET not exists') + return res.status(500).send({ errorCodes: 'JWT_SECRET_NOT_EXISTS' }) } try { jwt.verify(token, process.env.JWT_SECRET) @@ -71,25 +71,31 @@ export const textToSpeechHandler = Sentry.GCPFunction.wrapHttpFunction( return res.status(200).send('UNAUTHENTICATED') } const input = req.body as TextToSpeechInput + const id = input.id + const bucket = input.bucket + if (!id || !bucket) { + return res.status(200).send('Invalid data') + } try { - const audioFileName = `speech/${input.id}.mp3` - const audioFile = createGCSFile(input.bucket, audioFileName) + const audioFileName = `speech/${id}.mp3` + const audioFile = createGCSFile(bucket, audioFileName) const writeStream = audioFile.createWriteStream({ resumable: true, }) const { speechMarks } = await synthesizeTextToSpeech({ ...input, + textType: 'html', writeStream, }) // upload Speech Marks file to GCS - const speechMarksFileName = `speech/${input.id}.json` + const speechMarksFileName = `speech/${id}.json` await uploadToBucket( speechMarksFileName, Buffer.from(JSON.stringify(speechMarks)), - input.bucket + bucket ) const updated = await updateSpeech( - input.id, + id, token, 'COMPLETED', audioFileName, @@ -97,12 +103,12 @@ export const textToSpeechHandler = Sentry.GCPFunction.wrapHttpFunction( ) if (!updated) { - return res.status(500).send('Failed to update speech') + return res.status(500).send({ errorCodes: 'DB_ERROR' }) } } catch (e) { - console.error(e) - await updateSpeech(input.id, token, 'FAILED') - return res.status(500).send('Failed to synthesize') + console.error('Text to speech cloud function error', e) + await updateSpeech(id, token, 'FAILED') + return res.status(500).send({ errorCodes: 'SYNTHESIZER_ERROR' }) } res.send('OK') @@ -125,12 +131,10 @@ export const textToSpeechStreamingHandler = Sentry.GCPFunction.wrapHttpFunction( } try { - const audioFileName = `./tmp/speech-${Date.now()}.mp3` - const writeStream = createWriteStream(audioFileName) + const ssml = fs.readFileSync('./data/ssml.xml', 'utf8') + const writeStream = new PassThrough() const input: TextToSpeechInput = { - id: req.query.id as string, - text: 'text', - bucket: req.query.bucket as string, + text: ssml, textType: 'ssml', writeStream, } @@ -142,8 +146,8 @@ export const textToSpeechStreamingHandler = Sentry.GCPFunction.wrapHttpFunction( }) writeStream.pipe(res) } catch (e) { - console.error(e) - return res.status(500).send('Failed to synthesize') + console.error('Text to speech streaming error', e) + return res.status(500).send({ errorCodes: 'SYNTHESIZER_ERROR' }) } } ) diff --git a/packages/text-to-speech/src/textToSpeech.ts b/packages/text-to-speech/src/textToSpeech.ts index 77eb706f7..e007e343d 100644 --- a/packages/text-to-speech/src/textToSpeech.ts +++ b/packages/text-to-speech/src/textToSpeech.ts @@ -10,15 +10,15 @@ import { import { htmlToSsml, ssmlItemText } from './htmlToSsml' export interface TextToSpeechInput { - id: string + id?: string text: string voice?: string languageCode?: string - textType?: 'text' | 'ssml' + textType?: 'html' | 'ssml' rate?: number volume?: number complimentaryVoice?: string - bucket: string + bucket?: string writeStream: NodeJS.WritableStream } @@ -45,7 +45,7 @@ export const synthesizeTextToSpeech = async ( process.env.AZURE_SPEECH_KEY, process.env.AZURE_SPEECH_REGION ) - const textType = input.textType || 'text' + const textType = input.textType || 'html' speechConfig.speechSynthesisOutputFormat = SpeechSynthesisOutputFormat.Audio16Khz32KBitRateMonoMp3 @@ -53,12 +53,14 @@ export const synthesizeTextToSpeech = async ( const synthesizer = new SpeechSynthesizer(speechConfig) const speechMarks: SpeechMark[] = [] let timeOffset = 0 - const characterOffset = 0 - synthesizer.synthesizing = function (s, e) { - // convert arrayBuffer to stream and write to gcs file - writeStream.write(Buffer.from(e.result.audioData)) - } + // synthesizer.synthesizing = function (s, e) { + // // convert arrayBuffer to stream and write to stream + // console.debug( + // `(synthesizing): Audio length: ${e.result.audioData.byteLength}` + // ) + // writeStream.write(Buffer.from(e.result.audioData)) + // } // The event synthesis completed signals that the synthesis is completed. synthesizer.synthesisCompleted = (s, e) => { @@ -83,15 +85,20 @@ export const synthesizeTextToSpeech = async ( if (cancellationDetails.reason === CancellationReason.Error) { str += ': ' + e.result.errorDetails } - console.info(str) + console.error(str) } // The unit of e.audioOffset is tick (1 tick = 100 nanoseconds), divide by 10,000 to convert to milliseconds. synthesizer.wordBoundary = (s, e) => { + console.debug( + `(word boundary) Audio offset: ${e.audioOffset / 10000}ms, text: ${ + e.text + }` + ) speechMarks.push({ word: e.text, time: (timeOffset + e.audioOffset) / 10000, - start: characterOffset + e.textOffset, + start: e.textOffset, length: e.wordLength, type: 'word', }) @@ -99,7 +106,7 @@ export const synthesizeTextToSpeech = async ( synthesizer.bookmarkReached = (s, e) => { console.debug( - `(Bookmark reached), Audio offset: ${ + `(bookmark reached) Audio offset: ${ e.audioOffset / 10000 }ms, bookmark text: ${e.text}` ) @@ -111,12 +118,14 @@ export const synthesizeTextToSpeech = async ( } const speakSsmlAsyncPromise = ( - text: string + ssml: string, + writeStream: NodeJS.WritableStream ): Promise => { return new Promise((resolve, reject) => { synthesizer.speakSsmlAsync( - text, + ssml, (result) => { + writeStream.write(Buffer.from(result.audioData)) resolve(result) }, (error) => { @@ -126,36 +135,34 @@ export const synthesizeTextToSpeech = async ( }) } - if (textType === 'text') { - const ssmlItems = htmlToSsml(input.text, { - primaryVoice: input.voice || 'en-US-JennyNeural', - secondaryVoice: 'en-US-GuyNeural', - language: input.languageCode || 'en-US', - rate: '1', - }) + try { + if (textType === 'html') { + const ssmlItems = htmlToSsml(input.text, { + primaryVoice: input.voice || 'en-US-JennyNeural', + secondaryVoice: input.complimentaryVoice || 'en-US-GuyNeural', + language: input.languageCode || 'en-US', + rate: '1', + }) - for (const ssmlItem of Array.from(ssmlItems)) { - const ssml = ssmlItemText(ssmlItem) - console.debug(`synthesizing ${ssml}`) - const result = await speakSsmlAsyncPromise(ssml) - if (result.reason === ResultReason.Canceled) { - writeStream.end() - synthesizer.close() - throw new Error(result.errorDetails) + for (const ssmlItem of Array.from(ssmlItems)) { + const ssml = ssmlItemText(ssmlItem) + console.debug('start synthesizing', ssml) + const result = await speakSsmlAsyncPromise(ssml, writeStream) + timeOffset = timeOffset + result.audioDuration } - timeOffset = timeOffset + result.audioDuration - // characterOffset = characterOffset + htmlElement.innerText.length - } - } else { - const result = await speakSsmlAsyncPromise(input.text) - if (result.reason === ResultReason.Canceled) { - writeStream.end() - synthesizer.close() - throw new Error(result.errorDetails) + } else { + console.debug('start synthesizing', input.text) + await speakSsmlAsyncPromise(input.text, writeStream) } + } catch (error) { + console.error('synthesis error', error) + throw error + } finally { + console.debug('closing synthesizer') + writeStream.end() + synthesizer.close() + console.debug('synthesizer closed') } - writeStream.end() - synthesizer.close() return { speechMarks,