From 64d029e7a747cc875a7def50525724bac700398e Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Wed, 2 Nov 2022 19:35:45 +0800 Subject: [PATCH 01/43] Get and update character count in redis for rate limit on tts streaming service --- packages/text-to-speech/src/index.ts | 39 ++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/packages/text-to-speech/src/index.ts b/packages/text-to-speech/src/index.ts index d38adbf8b..5a34d7183 100644 --- a/packages/text-to-speech/src/index.ts +++ b/packages/text-to-speech/src/index.ts @@ -16,6 +16,7 @@ import { File, Storage } from '@google-cloud/storage' import { endSsml, htmlToSpeechFile, startSsml } from './htmlToSsml' import crypto from 'crypto' import { createRedisClient } from './redis' +import { RedisClientType } from 'redis' interface UtteranceInput { voice?: string @@ -46,6 +47,7 @@ Sentry.GCPFunction.init({ tracesSampleRate: 0, }) +const MAX_CHARACTER_COUNT = 50000 const storage = new Storage() const uploadToBucket = async ( @@ -84,6 +86,28 @@ const updateSpeech = async ( return response.status === 200 } +const getCharacterCountFromRedis = async ( + redisClient: RedisClientType, + token: string +): Promise => { + const wordCount = await redisClient.get(`tts:charCount:${token}`) + return wordCount ? parseInt(wordCount) : 0 +} + +// store character count of each text to speech request in redis +// which will be used to rate limit the request +// expires after 1 day +const updateCharacterCountInRedis = async ( + redisClient: RedisClientType, + token: string, + wordCount: number +): Promise => { + await redisClient.set(`tts:charCount:${token}`, wordCount.toString(), { + EX: 3600 * 24, // in seconds + NX: true, + }) +} + export const textToSpeechHandler = Sentry.GCPFunction.wrapHttpFunction( async (req, res) => { console.info('Text to speech request body:', req.body) @@ -177,6 +201,18 @@ export const textToSpeechStreamingHandler = Sentry.GCPFunction.wrapHttpFunction( try { const utteranceInput = req.body as UtteranceInput + if (!utteranceInput.text) { + return res.status(400).send('INVALID_INPUT') + } + + // validate character count + const characterCount = + (await getCharacterCountFromRedis(redisClient, token)) + + utteranceInput.text.length + if (characterCount > MAX_CHARACTER_COUNT) { + return res.status(429).send('RATE_LIMITED') + } + const ssmlOptions = { primaryVoice: utteranceInput.voice, secondaryVoice: utteranceInput.voice, @@ -222,6 +258,9 @@ export const textToSpeechStreamingHandler = Sentry.GCPFunction.wrapHttpFunction( ) console.log('Cache saved') + // update character count + await updateCharacterCountInRedis(redisClient, token, characterCount) + res.send({ idx: utteranceInput.idx, audioData: audioDataString, From 919723608883983a5c145db161b8002e05b98b78 Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Fri, 4 Nov 2022 12:01:09 +0800 Subject: [PATCH 02/43] Wrap textToSpeech in a class --- packages/text-to-speech/src/azure.ts | 152 +++++++++++++++++++++++++++ packages/text-to-speech/src/index.ts | 35 ++++-- 2 files changed, 179 insertions(+), 8 deletions(-) create mode 100644 packages/text-to-speech/src/azure.ts diff --git a/packages/text-to-speech/src/azure.ts b/packages/text-to-speech/src/azure.ts new file mode 100644 index 000000000..1d972ec1c --- /dev/null +++ b/packages/text-to-speech/src/azure.ts @@ -0,0 +1,152 @@ +import { + CancellationDetails, + CancellationReason, + ResultReason, + SpeechConfig, + SpeechSynthesisOutputFormat, + SpeechSynthesisResult, + SpeechSynthesizer, +} from 'microsoft-cognitiveservices-speech-sdk' +import { endSsml, htmlToSsmlItems, ssmlItemText, startSsml } from './htmlToSsml' +import * as _ from 'underscore' +import { + SpeechMark, + TextToSpeech, + TextToSpeechInput, + TextToSpeechOutput, +} from './textToSpeech' + +export class AzureTextToSpeech implements TextToSpeech { + use(input: TextToSpeechInput): boolean { + return !input.isUltraRealisticVoice + } + + synthesizeTextToSpeech = async ( + input: TextToSpeechInput + ): Promise => { + if (!process.env.AZURE_SPEECH_KEY || !process.env.AZURE_SPEECH_REGION) { + throw new Error('Azure Speech Key or Region not set') + } + const textType = input.textType || 'html' + const audioStream = input.audioStream + const speechConfig = SpeechConfig.fromSubscription( + process.env.AZURE_SPEECH_KEY, + process.env.AZURE_SPEECH_REGION + ) + speechConfig.speechSynthesisOutputFormat = + SpeechSynthesisOutputFormat.Audio16Khz32KBitRateMonoMp3 + + // Create the speech synthesizer. + const synthesizer = new SpeechSynthesizer(speechConfig) + const speechMarks: SpeechMark[] = [] + let timeOffset = 0 + let wordOffset = 0 + + synthesizer.synthesizing = function (s, e) { + // convert arrayBuffer to stream and write to stream + audioStream?.write(Buffer.from(e.result.audioData)) + } + + // The event synthesis completed signals that the synthesis is completed. + synthesizer.synthesisCompleted = (s, e) => { + console.info( + `(synthesized) Reason: ${ResultReason[e.result.reason]} Audio length: ${ + e.result.audioData.byteLength + }` + ) + } + + // The synthesis started event signals that the synthesis is started. + synthesizer.synthesisStarted = (s, e) => { + console.info('(synthesis started)') + } + + // The event signals that the service has stopped processing speech. + // This can happen when an error is encountered. + synthesizer.SynthesisCanceled = (s, e) => { + const cancellationDetails = CancellationDetails.fromResult(e.result) + let str = + '(cancel) Reason: ' + CancellationReason[cancellationDetails.reason] + if (cancellationDetails.reason === CancellationReason.Error) { + str += ': ' + e.result.errorDetails + } + console.log(str) + } + + // The unit of e.audioOffset is tick (1 tick = 100 nanoseconds), divide by 10,000 to convert to milliseconds. + synthesizer.wordBoundary = (s, e) => { + speechMarks.push({ + word: e.text, + time: (timeOffset + e.audioOffset) / 10000, + start: wordOffset + e.textOffset, + length: e.wordLength, + type: 'word', + }) + } + + synthesizer.bookmarkReached = (s, e) => { + speechMarks.push({ + word: e.text, + time: (timeOffset + e.audioOffset) / 10000, + type: 'bookmark', + }) + } + + const speakSsmlAsyncPromise = ( + ssml: string + ): Promise => { + return new Promise((resolve, reject) => { + synthesizer.speakSsmlAsync( + ssml, + (result) => { + resolve(result) + }, + (error) => { + reject(error) + } + ) + }) + } + + try { + const ssmlOptions = { + primaryVoice: input.voice, + secondaryVoice: input.secondaryVoice, + language: input.language, + rate: input.rate, + } + if (textType === 'html') { + const ssmlItems = htmlToSsmlItems(input.text, ssmlOptions) + for (const ssmlItem of ssmlItems) { + const ssml = ssmlItemText(ssmlItem) + const result = await speakSsmlAsyncPromise(ssml) + timeOffset = timeOffset + result.audioDuration + } + return { + speechMarks, + } + } + // for ssml + const startSsmlTag = startSsml(ssmlOptions) + wordOffset -= startSsmlTag.length + const text = _.escape(input.text) + const ssml = `${startSsmlTag}${text}${endSsml()}` + const result = await speakSsmlAsyncPromise(ssml) + if (result.reason === ResultReason.Canceled) { + throw new Error(result.errorDetails) + } + + return { + audioData: Buffer.from(result.audioData), + speechMarks, + } + } catch (error) { + console.error('synthesis error:', error) + throw error + } finally { + audioStream?.end() + synthesizer.close() + console.log('synthesizer closed') + } + } +} diff --git a/packages/text-to-speech/src/index.ts b/packages/text-to-speech/src/index.ts index 5a34d7183..eb9e6c166 100644 --- a/packages/text-to-speech/src/index.ts +++ b/packages/text-to-speech/src/index.ts @@ -7,16 +7,20 @@ import * as Sentry from '@sentry/serverless' import axios from 'axios' import * as jwt from 'jsonwebtoken' import * as dotenv from 'dotenv' // see https://github.com/motdotla/dotenv#how-do-i-use-dotenv-with-import -import { - SpeechMark, - synthesizeTextToSpeech, - TextToSpeechInput, -} from './textToSpeech' +import { AzureTextToSpeech } from './azure' import { File, Storage } from '@google-cloud/storage' import { endSsml, htmlToSpeechFile, startSsml } from './htmlToSsml' import crypto from 'crypto' import { createRedisClient } from './redis' -import { RedisClientType } from 'redis' +import { + SpeechMark, + TextToSpeechInput, + TextToSpeechOutput, +} from './textToSpeech' +import { createClient } from 'redis' + +// explicitly create the return type of RedisClient +type RedisClient = ReturnType interface UtteranceInput { voice?: string @@ -24,6 +28,7 @@ interface UtteranceInput { language?: string text: string idx: string + isUltraRealisticVoice?: boolean } interface HTMLInput { @@ -50,6 +55,20 @@ Sentry.GCPFunction.init({ const MAX_CHARACTER_COUNT = 50000 const storage = new Storage() +const textToSpeechHandlers = [new AzureTextToSpeech()] + +const synthesizeTextToSpeech = async ( + input: TextToSpeechInput +): Promise => { + const textToSpeechHandler = textToSpeechHandlers.find((handler) => + handler.use(input) + ) + if (!textToSpeechHandler) { + throw new Error('No text to speech handler found') + } + return textToSpeechHandler.synthesizeTextToSpeech(input) +} + const uploadToBucket = async ( filePath: string, data: Buffer, @@ -87,7 +106,7 @@ const updateSpeech = async ( } const getCharacterCountFromRedis = async ( - redisClient: RedisClientType, + redisClient: RedisClient, token: string ): Promise => { const wordCount = await redisClient.get(`tts:charCount:${token}`) @@ -98,7 +117,7 @@ const getCharacterCountFromRedis = async ( // which will be used to rate limit the request // expires after 1 day const updateCharacterCountInRedis = async ( - redisClient: RedisClientType, + redisClient: RedisClient, token: string, wordCount: number ): Promise => { From 52da88ed4c9078d9f5e20601d8daba759b6e29c4 Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Fri, 4 Nov 2022 12:01:15 +0800 Subject: [PATCH 03/43] Wrap textToSpeech in a class --- packages/text-to-speech/src/textToSpeech.ts | 147 +------------------- 1 file changed, 7 insertions(+), 140 deletions(-) diff --git a/packages/text-to-speech/src/textToSpeech.ts b/packages/text-to-speech/src/textToSpeech.ts index da6c2e4c2..c489eabd7 100644 --- a/packages/text-to-speech/src/textToSpeech.ts +++ b/packages/text-to-speech/src/textToSpeech.ts @@ -1,15 +1,3 @@ -import { - CancellationDetails, - CancellationReason, - ResultReason, - SpeechConfig, - SpeechSynthesisOutputFormat, - SpeechSynthesisResult, - SpeechSynthesizer, -} from 'microsoft-cognitiveservices-speech-sdk' -import { endSsml, htmlToSsmlItems, ssmlItemText, startSsml } from './htmlToSsml' -import * as _ from 'underscore' - export interface TextToSpeechInput { text: string voice?: string @@ -18,11 +6,12 @@ export interface TextToSpeechInput { rate?: string secondaryVoice?: string audioStream?: NodeJS.ReadWriteStream + isUltraRealisticVoice?: boolean } export interface TextToSpeechOutput { audioData?: Buffer - speechMarks: SpeechMark[] + speechMarks?: SpeechMark[] } export interface SpeechMark { @@ -32,132 +21,10 @@ export interface SpeechMark { word: string type: 'word' | 'bookmark' } +export abstract class TextToSpeech { + abstract use(input: TextToSpeechInput): boolean -export const synthesizeTextToSpeech = async ( - input: TextToSpeechInput -): Promise => { - if (!process.env.AZURE_SPEECH_KEY || !process.env.AZURE_SPEECH_REGION) { - throw new Error('Azure Speech Key or Region not set') - } - const textType = input.textType || 'html' - const audioStream = input.audioStream - const speechConfig = SpeechConfig.fromSubscription( - process.env.AZURE_SPEECH_KEY, - process.env.AZURE_SPEECH_REGION - ) - speechConfig.speechSynthesisOutputFormat = - SpeechSynthesisOutputFormat.Audio16Khz32KBitRateMonoMp3 - - // Create the speech synthesizer. - const synthesizer = new SpeechSynthesizer(speechConfig) - const speechMarks: SpeechMark[] = [] - let timeOffset = 0 - let wordOffset = 0 - - synthesizer.synthesizing = function (s, e) { - // convert arrayBuffer to stream and write to stream - audioStream?.write(Buffer.from(e.result.audioData)) - } - - // The event synthesis completed signals that the synthesis is completed. - synthesizer.synthesisCompleted = (s, e) => { - console.info( - `(synthesized) Reason: ${ResultReason[e.result.reason]} Audio length: ${ - e.result.audioData.byteLength - }` - ) - } - - // The synthesis started event signals that the synthesis is started. - synthesizer.synthesisStarted = (s, e) => { - console.info('(synthesis started)') - } - - // The event signals that the service has stopped processing speech. - // This can happen when an error is encountered. - synthesizer.SynthesisCanceled = (s, e) => { - const cancellationDetails = CancellationDetails.fromResult(e.result) - let str = - '(cancel) Reason: ' + CancellationReason[cancellationDetails.reason] - if (cancellationDetails.reason === CancellationReason.Error) { - str += ': ' + e.result.errorDetails - } - console.log(str) - } - - // The unit of e.audioOffset is tick (1 tick = 100 nanoseconds), divide by 10,000 to convert to milliseconds. - synthesizer.wordBoundary = (s, e) => { - speechMarks.push({ - word: e.text, - time: (timeOffset + e.audioOffset) / 10000, - start: wordOffset + e.textOffset, - length: e.wordLength, - type: 'word', - }) - } - - synthesizer.bookmarkReached = (s, e) => { - speechMarks.push({ - word: e.text, - time: (timeOffset + e.audioOffset) / 10000, - type: 'bookmark', - }) - } - - const speakSsmlAsyncPromise = ( - ssml: string - ): Promise => { - return new Promise((resolve, reject) => { - synthesizer.speakSsmlAsync( - ssml, - (result) => { - resolve(result) - }, - (error) => { - reject(error) - } - ) - }) - } - - try { - const ssmlOptions = { - primaryVoice: input.voice, - secondaryVoice: input.secondaryVoice, - language: input.language, - rate: input.rate, - } - if (textType === 'html') { - const ssmlItems = htmlToSsmlItems(input.text, ssmlOptions) - for (const ssmlItem of ssmlItems) { - const ssml = ssmlItemText(ssmlItem) - const result = await speakSsmlAsyncPromise(ssml) - timeOffset = timeOffset + result.audioDuration - } - return { - speechMarks, - } - } - // for ssml - const startSsmlTag = startSsml(ssmlOptions) - wordOffset -= startSsmlTag.length - const text = _.escape(input.text) - const ssml = `${startSsmlTag}${text}${endSsml()}` - const result = await speakSsmlAsyncPromise(ssml) - if (result.reason === ResultReason.Canceled) { - throw new Error(result.errorDetails) - } - - return { - audioData: Buffer.from(result.audioData), - speechMarks, - } - } catch (error) { - console.error('synthesis error:', error) - throw error - } finally { - audioStream?.end() - synthesizer.close() - console.log('synthesizer closed') - } + abstract synthesizeTextToSpeech( + input: TextToSpeechInput + ): Promise } From 718f6716bc7724026500fb4506c47b0e42b26cf5 Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Fri, 4 Nov 2022 13:50:49 +0800 Subject: [PATCH 04/43] Add a realistic voice api provider --- .../src/{azure.ts => azureTextToSpeech.ts} | 0 packages/text-to-speech/src/index.ts | 27 ++++-- .../src/realisticTextToSpeech.ts | 85 +++++++++++++++++++ 3 files changed, 104 insertions(+), 8 deletions(-) rename packages/text-to-speech/src/{azure.ts => azureTextToSpeech.ts} (100%) create mode 100644 packages/text-to-speech/src/realisticTextToSpeech.ts diff --git a/packages/text-to-speech/src/azure.ts b/packages/text-to-speech/src/azureTextToSpeech.ts similarity index 100% rename from packages/text-to-speech/src/azure.ts rename to packages/text-to-speech/src/azureTextToSpeech.ts diff --git a/packages/text-to-speech/src/index.ts b/packages/text-to-speech/src/index.ts index eb9e6c166..530b73a61 100644 --- a/packages/text-to-speech/src/index.ts +++ b/packages/text-to-speech/src/index.ts @@ -7,7 +7,7 @@ import * as Sentry from '@sentry/serverless' import axios from 'axios' import * as jwt from 'jsonwebtoken' import * as dotenv from 'dotenv' // see https://github.com/motdotla/dotenv#how-do-i-use-dotenv-with-import -import { AzureTextToSpeech } from './azure' +import { AzureTextToSpeech } from './azureTextToSpeech' import { File, Storage } from '@google-cloud/storage' import { endSsml, htmlToSpeechFile, startSsml } from './htmlToSsml' import crypto from 'crypto' @@ -18,6 +18,7 @@ import { TextToSpeechOutput, } from './textToSpeech' import { createClient } from 'redis' +import { RealisticTextToSpeech } from './realisticTextToSpeech' // explicitly create the return type of RedisClient type RedisClient = ReturnType @@ -55,7 +56,10 @@ Sentry.GCPFunction.init({ const MAX_CHARACTER_COUNT = 50000 const storage = new Storage() -const textToSpeechHandlers = [new AzureTextToSpeech()] +const textToSpeechHandlers = [ + new AzureTextToSpeech(), + new RealisticTextToSpeech(), +] const synthesizeTextToSpeech = async ( input: TextToSpeechInput @@ -107,9 +111,9 @@ const updateSpeech = async ( const getCharacterCountFromRedis = async ( redisClient: RedisClient, - token: string + uid: string ): Promise => { - const wordCount = await redisClient.get(`tts:charCount:${token}`) + const wordCount = await redisClient.get(`tts:charCount:${uid}`) return wordCount ? parseInt(wordCount) : 0 } @@ -118,10 +122,10 @@ const getCharacterCountFromRedis = async ( // expires after 1 day const updateCharacterCountInRedis = async ( redisClient: RedisClient, - token: string, + uid: string, wordCount: number ): Promise => { - await redisClient.set(`tts:charCount:${token}`, wordCount.toString(), { + await redisClient.set(`tts:charCount:${uid}`, wordCount.toString(), { EX: 3600 * 24, // in seconds NX: true, }) @@ -205,8 +209,15 @@ export const textToSpeechStreamingHandler = Sentry.GCPFunction.wrapHttpFunction( if (!token) { return res.status(401).send({ errorCode: 'INVALID_TOKEN' }) } + + let uid: string try { jwt.verify(token, process.env.JWT_SECRET) + const claim = jwt.decode(token) as { uid: string } + uid = claim.uid + if (!uid) { + throw new Error('uid not exists') + } } catch (e) { console.error('Authentication error:', e) return res.status(401).send({ errorCode: 'UNAUTHENTICATED' }) @@ -226,7 +237,7 @@ export const textToSpeechStreamingHandler = Sentry.GCPFunction.wrapHttpFunction( // validate character count const characterCount = - (await getCharacterCountFromRedis(redisClient, token)) + + (await getCharacterCountFromRedis(redisClient, uid)) + utteranceInput.text.length if (characterCount > MAX_CHARACTER_COUNT) { return res.status(429).send('RATE_LIMITED') @@ -278,7 +289,7 @@ export const textToSpeechStreamingHandler = Sentry.GCPFunction.wrapHttpFunction( console.log('Cache saved') // update character count - await updateCharacterCountInRedis(redisClient, token, characterCount) + await updateCharacterCountInRedis(redisClient, uid, characterCount) res.send({ idx: utteranceInput.idx, diff --git a/packages/text-to-speech/src/realisticTextToSpeech.ts b/packages/text-to-speech/src/realisticTextToSpeech.ts new file mode 100644 index 000000000..1cb28decd --- /dev/null +++ b/packages/text-to-speech/src/realisticTextToSpeech.ts @@ -0,0 +1,85 @@ +import { + TextToSpeech, + TextToSpeechInput, + TextToSpeechOutput, +} from './textToSpeech' +import axios from 'axios' + +interface PlayHtConvertResponse { + message: string + payload: string[] +} + +export class RealisticTextToSpeech implements TextToSpeech { + synthesizeTextToSpeech = async ( + input: TextToSpeechInput + ): Promise => { + const apiEndpoint = process.env.REALISTIC_VOICE_API_ENDPOINT + const apiKey = process.env.REALISTIC_VOICE_API_KEY + const userId = process.env.REALISTIC_VOICE_USER_ID + if (!apiEndpoint || !apiKey || !userId) { + throw new Error('PlayHT API credentials not set') + } + + const HEADERS = { + Authorization: apiKey, + 'X-User-ID': userId, + 'Content-Type': 'application/json', + } + + const data = { + voice: input.voice, + content: [input.text], + } + + // get the download url first + const response = await axios.post( + apiEndpoint, + data, + { + headers: HEADERS, + } + ) + + if (response.data.payload.length === 0) { + throw new Error('No payload returned') + } + + const downloadUrl = response.data.payload[0] + + // polling the download url until the file is ready + // timeout after 5 minutes + const timeout = 5 * 60 * 1000 + const startTime = Date.now() + let audioData: Buffer | undefined + while (!audioData) { + if (Date.now() - startTime > timeout) { + throw new Error('Timeout when polling the download url') + } + + // download the audio file + try { + const downloadResponse = await axios.get(downloadUrl, { + responseType: 'arraybuffer', + headers: { + 'Content-Type': 'audio/wav', + }, + }) + // convert the wav file to buffer + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + audioData = Buffer.from(downloadResponse.data, 'binary') + } catch (e) { + // ignore error + console.debug('checking status of audio file', downloadUrl) + } + } + + return { + audioData, + } + } + + use(input: TextToSpeechInput): boolean { + return !!input.isUltraRealisticVoice + } +} From 55ad5ec6f582881e268ca3260e14caf4da97ccec Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Fri, 4 Nov 2022 15:53:50 +0800 Subject: [PATCH 05/43] Return empty array for speechmarks for realistic voices --- packages/text-to-speech/src/realisticTextToSpeech.ts | 1 + packages/text-to-speech/src/textToSpeech.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/text-to-speech/src/realisticTextToSpeech.ts b/packages/text-to-speech/src/realisticTextToSpeech.ts index 1cb28decd..329058611 100644 --- a/packages/text-to-speech/src/realisticTextToSpeech.ts +++ b/packages/text-to-speech/src/realisticTextToSpeech.ts @@ -76,6 +76,7 @@ export class RealisticTextToSpeech implements TextToSpeech { return { audioData, + speechMarks: [], } } diff --git a/packages/text-to-speech/src/textToSpeech.ts b/packages/text-to-speech/src/textToSpeech.ts index c489eabd7..3bc6f7b4b 100644 --- a/packages/text-to-speech/src/textToSpeech.ts +++ b/packages/text-to-speech/src/textToSpeech.ts @@ -11,7 +11,7 @@ export interface TextToSpeechInput { export interface TextToSpeechOutput { audioData?: Buffer - speechMarks?: SpeechMark[] + speechMarks: SpeechMark[] } export interface SpeechMark { From a29a430f91c6bb97960a81cd17a10519e267766c Mon Sep 17 00:00:00 2001 From: Jackson Harper Date: Mon, 7 Nov 2022 08:16:18 +0800 Subject: [PATCH 06/43] Fix bug causing quotes to appear around notes on iOS When serialized to a JSON string quotes are added, so we were double quoting our strings. --- apple/OmnivoreKit/Sources/Views/Article/OmnivoreWebView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apple/OmnivoreKit/Sources/Views/Article/OmnivoreWebView.swift b/apple/OmnivoreKit/Sources/Views/Article/OmnivoreWebView.swift index beb6f0d6d..234da0c9b 100644 --- a/apple/OmnivoreKit/Sources/Views/Article/OmnivoreWebView.swift +++ b/apple/OmnivoreKit/Sources/Views/Article/OmnivoreWebView.swift @@ -429,7 +429,7 @@ public enum WebViewDispatchEvent { let encoder = JSONEncoder() if let encoded = try? encoder.encode(annotation) { let str = String(decoding: encoded, as: UTF8.self) - return "event.annotation = '\(str)';" + return "event.annotation = \(str);" } else { throw BasicError.message(messageText: "Unable to serialize highlight note.") } From abc9dd49bef60915ecd52e03d1dee894ee804a3c Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Mon, 7 Nov 2022 17:26:53 +0800 Subject: [PATCH 07/43] Convert wav to mp3 --- packages/text-to-speech/package.json | 3 + packages/text-to-speech/src/index.ts | 25 ++++-- .../src/realisticTextToSpeech.ts | 39 ++++++++-- yarn.lock | 76 ++++++++++++++++++- 4 files changed, 127 insertions(+), 16 deletions(-) diff --git a/packages/text-to-speech/package.json b/packages/text-to-speech/package.json index e63b348b2..2c4e8591b 100644 --- a/packages/text-to-speech/package.json +++ b/packages/text-to-speech/package.json @@ -21,6 +21,7 @@ "deploy": "yarn build && yarn gcloud-deploy" }, "devDependencies": { + "@types/fluent-ffmpeg": "^2.1.20", "@types/html-to-text": "^8.1.1", "@types/natural": "^5.1.1", "@types/node": "^14.11.2", @@ -30,11 +31,13 @@ "mocha": "^10.0.0" }, "dependencies": { + "@ffmpeg-installer/ffmpeg": "^1.1.0", "@google-cloud/functions-framework": "3.1.2", "@google-cloud/storage": "^6.4.1", "@sentry/serverless": "^6.16.1", "axios": "^0.27.2", "dotenv": "^16.0.1", + "fluent-ffmpeg": "^2.1.2", "html-to-text": "^8.2.1", "jsonwebtoken": "^8.5.1", "linkedom": "^0.14.12", diff --git a/packages/text-to-speech/src/index.ts b/packages/text-to-speech/src/index.ts index 530b73a61..21f7b07ba 100644 --- a/packages/text-to-speech/src/index.ts +++ b/packages/text-to-speech/src/index.ts @@ -40,6 +40,7 @@ interface HTMLInput { rate?: string complimentaryVoice?: string bucket: string + isUltraRealisticVoice?: boolean } interface CacheResult { @@ -134,11 +135,16 @@ const updateCharacterCountInRedis = async ( export const textToSpeechHandler = Sentry.GCPFunction.wrapHttpFunction( async (req, res) => { console.info('Text to speech request body:', req.body) - const token = req.query.token as string if (!process.env.JWT_SECRET) { console.error('JWT_SECRET not exists') return res.status(500).send({ errorCodes: 'JWT_SECRET_NOT_EXISTS' }) } + + const token = (req.query.token || req.headers.authorization) as string + if (!token) { + return res.status(401).send({ errorCode: 'INVALID_TOKEN' }) + } + try { jwt.verify(token, process.env.JWT_SECRET) } catch (e) { @@ -169,13 +175,18 @@ export const textToSpeechHandler = Sentry.GCPFunction.wrapHttpFunction( console.info( `Synthesize text to speech completed in ${Date.now() - startTime} ms` ) + // speech marks file to be saved in GCS - const speechMarksFileName = `speech/${id}.json` - await uploadToBucket( - speechMarksFileName, - Buffer.from(JSON.stringify(speechMarks)), - bucket - ) + let speechMarksFileName: string | undefined + if (speechMarks.length > 0) { + speechMarksFileName = `speech/${id}.json` + await uploadToBucket( + speechMarksFileName, + Buffer.from(JSON.stringify(speechMarks)), + bucket + ) + } + // update speech state const updated = await updateSpeech( id, diff --git a/packages/text-to-speech/src/realisticTextToSpeech.ts b/packages/text-to-speech/src/realisticTextToSpeech.ts index 329058611..295067d03 100644 --- a/packages/text-to-speech/src/realisticTextToSpeech.ts +++ b/packages/text-to-speech/src/realisticTextToSpeech.ts @@ -4,12 +4,28 @@ import { TextToSpeechOutput, } from './textToSpeech' import axios from 'axios' +import ffmpegPath from '@ffmpeg-installer/ffmpeg' +import ffmpeg from 'fluent-ffmpeg' +import { PassThrough } from 'stream' + +ffmpeg.setFfmpegPath(ffmpegPath.path) interface PlayHtConvertResponse { message: string payload: string[] } +const streamWavToMp3 = (inputStream: PassThrough, outputSteam: PassThrough) => { + ffmpeg(inputStream) + .on('error', (err) => { + throw err + }) + .on('end', () => { + outputSteam.end() + }) + .pipe(outputSteam, { end: true }) +} + export class RealisticTextToSpeech implements TextToSpeech { synthesizeTextToSpeech = async ( input: TextToSpeechInput @@ -21,6 +37,9 @@ export class RealisticTextToSpeech implements TextToSpeech { throw new Error('PlayHT API credentials not set') } + const inputStream = new PassThrough() + const outputStream = input.audioStream + const HEADERS = { Authorization: apiKey, 'X-User-ID': userId, @@ -48,11 +67,10 @@ export class RealisticTextToSpeech implements TextToSpeech { const downloadUrl = response.data.payload[0] // polling the download url until the file is ready - // timeout after 5 minutes - const timeout = 5 * 60 * 1000 + // timeout after 1 hour + const timeout = 60 * 60 * 1000 const startTime = Date.now() - let audioData: Buffer | undefined - while (!audioData) { + while (true) { if (Date.now() - startTime > timeout) { throw new Error('Timeout when polling the download url') } @@ -65,17 +83,22 @@ export class RealisticTextToSpeech implements TextToSpeech { 'Content-Type': 'audio/wav', }, }) - // convert the wav file to buffer - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument - audioData = Buffer.from(downloadResponse.data, 'binary') + + // write the audio file to the input stream + inputStream.end(downloadResponse.data) + break } catch (e) { // ignore error console.debug('checking status of audio file', downloadUrl) } } + // transcode the audio file to mp3 + if (outputStream) { + streamWavToMp3(inputStream, outputStream as PassThrough) + } + return { - audioData, speechMarks: [], } } diff --git a/yarn.lock b/yarn.lock index 27452d1b1..b1b9a214d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2286,6 +2286,60 @@ lodash.isundefined "^3.0.1" lodash.uniq "^4.5.0" +"@ffmpeg-installer/darwin-arm64@4.1.5": + version "4.1.5" + resolved "https://registry.yarnpkg.com/@ffmpeg-installer/darwin-arm64/-/darwin-arm64-4.1.5.tgz#b7b5c262dd96d1aea4807514e1cdcf6e11f82743" + integrity sha512-hYqTiP63mXz7wSQfuqfFwfLOfwwFChUedeCVKkBtl/cliaTM7/ePI9bVzfZ2c+dWu3TqCwLDRWNSJ5pqZl8otA== + +"@ffmpeg-installer/darwin-x64@4.1.0": + version "4.1.0" + resolved "https://registry.yarnpkg.com/@ffmpeg-installer/darwin-x64/-/darwin-x64-4.1.0.tgz#48e1706c690e628148482bfb64acb67472089aaa" + integrity sha512-Z4EyG3cIFjdhlY8wI9aLUXuH8nVt7E9SlMVZtWvSPnm2sm37/yC2CwjUzyCQbJbySnef1tQwGG2Sx+uWhd9IAw== + +"@ffmpeg-installer/ffmpeg@^1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@ffmpeg-installer/ffmpeg/-/ffmpeg-1.1.0.tgz#87fdb9e7d180e8d78f7903f9441e36f978938a90" + integrity sha512-Uq4rmwkdGxIa9A6Bd/VqqYbT7zqh1GrT5/rFwCwKM70b42W5gIjWeVETq6SdcL0zXqDtY081Ws/iJWhr1+xvQg== + optionalDependencies: + "@ffmpeg-installer/darwin-arm64" "4.1.5" + "@ffmpeg-installer/darwin-x64" "4.1.0" + "@ffmpeg-installer/linux-arm" "4.1.3" + "@ffmpeg-installer/linux-arm64" "4.1.4" + "@ffmpeg-installer/linux-ia32" "4.1.0" + "@ffmpeg-installer/linux-x64" "4.1.0" + "@ffmpeg-installer/win32-ia32" "4.1.0" + "@ffmpeg-installer/win32-x64" "4.1.0" + +"@ffmpeg-installer/linux-arm64@4.1.4": + version "4.1.4" + resolved "https://registry.yarnpkg.com/@ffmpeg-installer/linux-arm64/-/linux-arm64-4.1.4.tgz#7219f3f901bb67f7926cb060b56b6974a6cad29f" + integrity sha512-dljEqAOD0oIM6O6DxBW9US/FkvqvQwgJ2lGHOwHDDwu/pX8+V0YsDL1xqHbj1DMX/+nP9rxw7G7gcUvGspSoKg== + +"@ffmpeg-installer/linux-arm@4.1.3": + version "4.1.3" + resolved "https://registry.yarnpkg.com/@ffmpeg-installer/linux-arm/-/linux-arm-4.1.3.tgz#c554f105ed5f10475ec25d7bec94926ce18db4c1" + integrity sha512-NDf5V6l8AfzZ8WzUGZ5mV8O/xMzRag2ETR6+TlGIsMHp81agx51cqpPItXPib/nAZYmo55Bl2L6/WOMI3A5YRg== + +"@ffmpeg-installer/linux-ia32@4.1.0": + version "4.1.0" + resolved "https://registry.yarnpkg.com/@ffmpeg-installer/linux-ia32/-/linux-ia32-4.1.0.tgz#adad70b0d0d9d8d813983d6e683c5a338a75e442" + integrity sha512-0LWyFQnPf+Ij9GQGD034hS6A90URNu9HCtQ5cTqo5MxOEc7Rd8gLXrJvn++UmxhU0J5RyRE9KRYstdCVUjkNOQ== + +"@ffmpeg-installer/linux-x64@4.1.0": + version "4.1.0" + resolved "https://registry.yarnpkg.com/@ffmpeg-installer/linux-x64/-/linux-x64-4.1.0.tgz#b4a5d89c4e12e6d9306dbcdc573df716ec1c4323" + integrity sha512-Y5BWhGLU/WpQjOArNIgXD3z5mxxdV8c41C+U15nsE5yF8tVcdCGet5zPs5Zy3Ta6bU7haGpIzryutqCGQA/W8A== + +"@ffmpeg-installer/win32-ia32@4.1.0": + version "4.1.0" + resolved "https://registry.yarnpkg.com/@ffmpeg-installer/win32-ia32/-/win32-ia32-4.1.0.tgz#6eac4fb691b64c02e7a116c1e2d167f3e9b40638" + integrity sha512-FV2D7RlaZv/lrtdhaQ4oETwoFUsUjlUiasiZLDxhEUPdNDWcH1OU9K1xTvqz+OXLdsmYelUDuBS/zkMOTtlUAw== + +"@ffmpeg-installer/win32-x64@4.1.0": + version "4.1.0" + resolved "https://registry.yarnpkg.com/@ffmpeg-installer/win32-x64/-/win32-x64-4.1.0.tgz#17e8699b5798d4c60e36e2d6326a8ebe5e95a2c5" + integrity sha512-Drt5u2vzDnIONf4ZEkKtFlbvwj6rI3kxw1Ck9fpudmtgaZIHD4ucsWB2lCZBXRxJgXR+2IMSti+4rtM4C4rXgg== + "@firebase/app-types@0.7.0": version "0.7.0" resolved "https://registry.yarnpkg.com/@firebase/app-types/-/app-types-0.7.0.tgz#c9e16d1b8bed1a991840b8d2a725fb58d0b5899f" @@ -7702,6 +7756,13 @@ resolved "https://registry.yarnpkg.com/@types/fined/-/fined-1.1.3.tgz#83f03e8f0a8d3673dfcafb18fce3571f6250e1bc" integrity sha512-CWYnSRnun3CGbt6taXeVo2lCbuaj4mchVJ4UF/BdU5TSuIn3AmS13pGMwCsBUoehGbhZrBrpNJZSZI5EVilXww== +"@types/fluent-ffmpeg@^2.1.20": + version "2.1.20" + resolved "https://registry.yarnpkg.com/@types/fluent-ffmpeg/-/fluent-ffmpeg-2.1.20.tgz#3b5f42fc8263761d58284fa46ee6759a64ce54ac" + integrity sha512-B+OvhCdJ3LgEq2PhvWNOiB/EfwnXLElfMCgc4Z1K5zXgSfo9I6uGKwR/lqmNPFQuebNnes7re3gqkV77SyypLg== + dependencies: + "@types/node" "*" + "@types/glob@*": version "7.2.0" resolved "https://registry.yarnpkg.com/@types/glob/-/glob-7.2.0.tgz#bc1b5bf3aa92f25bd5dd39f35c57361bdce5b2eb" @@ -9590,6 +9651,11 @@ async-retry@^1.2.1, async-retry@^1.3.3: dependencies: retry "0.13.1" +async@>=0.2.9: + version "3.2.4" + resolved "https://registry.yarnpkg.com/async/-/async-3.2.4.tgz#2d22e00f8cddeb5fde5dd33522b56d1cf569a81c" + integrity sha512-iAB+JbDEGXhyIUavoDl9WP/Jj106Kz9DEn1DPgYw5ruDn0e3Wgi3sKFm55sASdGBNOQB8F59d9qQ7deqrHA8wQ== + async@^2.6.2: version "2.6.4" resolved "https://registry.yarnpkg.com/async/-/async-2.6.4.tgz#706b7ff6084664cd7eae713f6f965433b5504221" @@ -14018,6 +14084,14 @@ flatted@^3.1.0: resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.2.2.tgz#64bfed5cb68fe3ca78b3eb214ad97b63bedce561" integrity sha512-JaTY/wtrcSyvXJl4IMFHPKyFur1sE9AUqc0QnhOaJ0CxHtAoIV8pYDzeEfAaNEtGkOfq4gr3LBFmdXW5mOQFnA== +fluent-ffmpeg@^2.1.2: + version "2.1.2" + resolved "https://registry.yarnpkg.com/fluent-ffmpeg/-/fluent-ffmpeg-2.1.2.tgz#c952de2240f812ebda0aa8006d7776ee2acf7d74" + integrity sha512-IZTB4kq5GK0DPp7sGQ0q/BWurGHffRtQQwVkiqDgeO6wYJLLV5ZhgNOQ65loZxxuPMKZKZcICCUnaGtlxBiR0Q== + dependencies: + async ">=0.2.9" + which "^1.1.1" + flush-write-stream@^1.0.0: version "1.1.1" resolved "https://registry.yarnpkg.com/flush-write-stream/-/flush-write-stream-1.1.1.tgz#8dd7d873a1babc207d94ead0c2e0e44276ebf2e8" @@ -25709,7 +25783,7 @@ which@2.0.2, which@^2.0.1, which@^2.0.2: dependencies: isexe "^2.0.0" -which@^1.2.14, which@^1.2.9, which@^1.3.1: +which@^1.1.1, which@^1.2.14, which@^1.2.9, which@^1.3.1: version "1.3.1" resolved "https://registry.yarnpkg.com/which/-/which-1.3.1.tgz#a45043d54f5805316da8d62f9f50918d3da70b0a" integrity sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ== From 2e47b0879cd95eeefb6346484c40175bee2a8b38 Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Mon, 7 Nov 2022 19:30:20 +0800 Subject: [PATCH 08/43] Convert HTML to utterances --- packages/api/src/utils/createTask.ts | 3 ++ packages/text-to-speech/src/index.ts | 2 + .../src/realisticTextToSpeech.ts | 43 ++++++++++++++----- 3 files changed, 37 insertions(+), 11 deletions(-) diff --git a/packages/api/src/utils/createTask.ts b/packages/api/src/utils/createTask.ts index c8102fa02..b61de5165 100644 --- a/packages/api/src/utils/createTask.ts +++ b/packages/api/src/utils/createTask.ts @@ -344,6 +344,7 @@ export const enqueueTextToSpeech = async ({ bucket = env.fileUpload.gcsUploadBucket, queue = 'omnivore-demo-text-to-speech-queue', location = env.gcp.location, + isUltraRealisticVoice = false, }: { userId: string speechId: string @@ -354,6 +355,7 @@ export const enqueueTextToSpeech = async ({ textType?: 'text' | 'ssml' queue?: string location?: string + isUltraRealisticVoice?: boolean }): Promise => { const { GOOGLE_CLOUD_PROJECT } = process.env const payload = { @@ -362,6 +364,7 @@ export const enqueueTextToSpeech = async ({ voice, bucket, textType, + isUltraRealisticVoice, } // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore diff --git a/packages/text-to-speech/src/index.ts b/packages/text-to-speech/src/index.ts index 21f7b07ba..a57a4a098 100644 --- a/packages/text-to-speech/src/index.ts +++ b/packages/text-to-speech/src/index.ts @@ -167,6 +167,8 @@ export const textToSpeechHandler = Sentry.GCPFunction.wrapHttpFunction( }) as NodeJS.WriteStream // synthesize text to speech const startTime = Date.now() + // temporary solution to use realistic text to speech + input.isUltraRealisticVoice = true const { speechMarks } = await synthesizeTextToSpeech({ ...input, textType: 'html', diff --git a/packages/text-to-speech/src/realisticTextToSpeech.ts b/packages/text-to-speech/src/realisticTextToSpeech.ts index 295067d03..4b5c5d4d2 100644 --- a/packages/text-to-speech/src/realisticTextToSpeech.ts +++ b/packages/text-to-speech/src/realisticTextToSpeech.ts @@ -7,6 +7,7 @@ import axios from 'axios' import ffmpegPath from '@ffmpeg-installer/ffmpeg' import ffmpeg from 'fluent-ffmpeg' import { PassThrough } from 'stream' +import { htmlToSpeechFile } from './htmlToSsml' ffmpeg.setFfmpegPath(ffmpegPath.path) @@ -15,15 +16,24 @@ interface PlayHtConvertResponse { payload: string[] } -const streamWavToMp3 = (inputStream: PassThrough, outputSteam: PassThrough) => { +const streamWavToMp3 = ( + inputStream: PassThrough, + outputStream: PassThrough +) => { ffmpeg(inputStream) + .inputFormat('wav') + .format('mp3') + .audioBitrate('32k') + .audioChannels(2) + .audioCodec('libmp3lame') .on('error', (err) => { throw err }) .on('end', () => { - outputSteam.end() + console.debug('transcoding finished') + outputStream.end() }) - .pipe(outputSteam, { end: true }) + .pipe(outputStream, { end: true }) } export class RealisticTextToSpeech implements TextToSpeech { @@ -38,7 +48,7 @@ export class RealisticTextToSpeech implements TextToSpeech { } const inputStream = new PassThrough() - const outputStream = input.audioStream + const outputStream = input.audioStream as PassThrough const HEADERS = { Authorization: apiKey, @@ -46,9 +56,19 @@ export class RealisticTextToSpeech implements TextToSpeech { 'Content-Type': 'application/json', } + const speechFile = htmlToSpeechFile({ + title: '', + content: input.text, + options: { + primaryVoice: input.voice, + secondaryVoice: input.secondaryVoice, + language: input.language, + }, + }) + const content = speechFile.utterances.map((u) => u.text) const data = { voice: input.voice, - content: [input.text], + content, } // get the download url first @@ -70,7 +90,8 @@ export class RealisticTextToSpeech implements TextToSpeech { // timeout after 1 hour const timeout = 60 * 60 * 1000 const startTime = Date.now() - while (true) { + let audioData: Buffer | undefined + while (!audioData) { if (Date.now() - startTime > timeout) { throw new Error('Timeout when polling the download url') } @@ -85,8 +106,9 @@ export class RealisticTextToSpeech implements TextToSpeech { }) // write the audio file to the input stream - inputStream.end(downloadResponse.data) - break + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + audioData = Buffer.from(downloadResponse.data, 'binary') + inputStream.end(audioData) } catch (e) { // ignore error console.debug('checking status of audio file', downloadUrl) @@ -94,11 +116,10 @@ export class RealisticTextToSpeech implements TextToSpeech { } // transcode the audio file to mp3 - if (outputStream) { - streamWavToMp3(inputStream, outputStream as PassThrough) - } + streamWavToMp3(inputStream, outputStream) return { + audioData, speechMarks: [], } } From 5f9e5174563f8fe78860c29457b1a9b8c637d0dd Mon Sep 17 00:00:00 2001 From: Satindar Dhillon Date: Mon, 7 Nov 2022 10:48:23 -0800 Subject: [PATCH 09/43] bump ios to 1.20.0 --- apple/Omnivore.xcodeproj/project.pbxproj | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/apple/Omnivore.xcodeproj/project.pbxproj b/apple/Omnivore.xcodeproj/project.pbxproj index 4717f29f7..d68dc5ce2 100644 --- a/apple/Omnivore.xcodeproj/project.pbxproj +++ b/apple/Omnivore.xcodeproj/project.pbxproj @@ -1521,7 +1521,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.19.0; + MARKETING_VERSION = 1.20.0; PRODUCT_BUNDLE_IDENTIFIER = app.omnivore.app; PRODUCT_NAME = Omnivore; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -1600,7 +1600,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.19.0; + MARKETING_VERSION = 1.20.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; OTHER_LDFLAGS = ( @@ -1639,7 +1639,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.19.0; + MARKETING_VERSION = 1.20.0; MTL_FAST_MATH = YES; OTHER_LDFLAGS = ( "-framework", @@ -1804,7 +1804,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.19.0; + MARKETING_VERSION = 1.20.0; PRODUCT_BUNDLE_IDENTIFIER = "app.omnivore.app.share-extension"; PRODUCT_NAME = ShareExtension; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -1859,7 +1859,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.19.0; + MARKETING_VERSION = 1.20.0; PRODUCT_BUNDLE_IDENTIFIER = app.omnivore.app; PRODUCT_NAME = Omnivore; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -1888,7 +1888,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.19.0; + MARKETING_VERSION = 1.20.0; PRODUCT_BUNDLE_IDENTIFIER = "app.omnivore.app.share-extension"; PRODUCT_NAME = ShareExtension; PROVISIONING_PROFILE_SPECIFIER = ""; From 1af5af0e974592b7bf30659a91715364fcacf9ea Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Tue, 8 Nov 2022 10:21:27 +0800 Subject: [PATCH 10/43] Get realistic voice id from user personalization table --- packages/api/src/routers/text_to_speech.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/api/src/routers/text_to_speech.ts b/packages/api/src/routers/text_to_speech.ts index dab71ac4e..8a89a3202 100644 --- a/packages/api/src/routers/text_to_speech.ts +++ b/packages/api/src/routers/text_to_speech.ts @@ -13,6 +13,7 @@ import { shouldSynthesize } from '../services/speech' import { readPushSubscription } from '../datalayer/pubsub' import { AppDataSource } from '../server' import { enqueueTextToSpeech } from '../utils/createTask' +import { UserPersonalization } from '../entity/user_personalization' const logger = buildLogger('app.dispatch') @@ -56,12 +57,15 @@ export function textToSpeechRouter() { // checks if this page needs to be synthesized automatically if (await shouldSynthesize(userId, page)) { logger.info('page needs to be synthesized') + const userPersonalization = await getRepository( + UserPersonalization + ).findOneBy({ user: { id: userId } }) // initialize state const speech = await getRepository(Speech).save({ user: { id: userId }, elasticPageId: id, state: SpeechState.INITIALIZED, - voice: 'en-US-JennyNeural', + voice: userPersonalization?.speechVoice || 'Harrison', }) // enqueue a task to convert text to speech const taskName = await enqueueTextToSpeech({ @@ -70,6 +74,7 @@ export function textToSpeechRouter() { text: page.content, voice: speech.voice, priority: 'low', + isUltraRealisticVoice: true, }) logger.info('Start Text to speech task', { taskName }) return res.status(202).send('Text to speech task started') From e62765c3e6a58179459f6b1b7e0f4e1b52e58434 Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Tue, 8 Nov 2022 11:24:47 +0800 Subject: [PATCH 11/43] Save steaming data in GCS --- packages/api/src/routers/text_to_speech.ts | 45 ++++++++------- packages/text-to-speech/src/index.ts | 6 +- .../src/realisticTextToSpeech.ts | 57 +++++++++---------- packages/text-to-speech/src/textToSpeech.ts | 1 + 4 files changed, 56 insertions(+), 53 deletions(-) diff --git a/packages/api/src/routers/text_to_speech.ts b/packages/api/src/routers/text_to_speech.ts index 8a89a3202..171593853 100644 --- a/packages/api/src/routers/text_to_speech.ts +++ b/packages/api/src/routers/text_to_speech.ts @@ -4,7 +4,7 @@ import express from 'express' import cors from 'cors' import { corsConfig } from '../utils/corsConfig' -import { getRepository, setClaims } from '../entity/utils' +import { setClaims } from '../entity/utils' import { getPageById } from '../elastic/pages' import { Speech, SpeechState } from '../entity/speech' import { buildLogger } from '../utils/logger' @@ -13,7 +13,7 @@ import { shouldSynthesize } from '../services/speech' import { readPushSubscription } from '../datalayer/pubsub' import { AppDataSource } from '../server' import { enqueueTextToSpeech } from '../utils/createTask' -import { UserPersonalization } from '../entity/user_personalization' +import { htmlToSpeechFile } from '@omnivore/text-to-speech-handler' const logger = buildLogger('app.dispatch') @@ -57,26 +57,29 @@ export function textToSpeechRouter() { // checks if this page needs to be synthesized automatically if (await shouldSynthesize(userId, page)) { logger.info('page needs to be synthesized') - const userPersonalization = await getRepository( - UserPersonalization - ).findOneBy({ user: { id: userId } }) - // initialize state - const speech = await getRepository(Speech).save({ - user: { id: userId }, - elasticPageId: id, - state: SpeechState.INITIALIZED, - voice: userPersonalization?.speechVoice || 'Harrison', + + const speechFile = htmlToSpeechFile({ + title: page.title, + content: page.content, + options: { + primaryVoice: 'Harrison', + secondaryVoice: 'Evelyn', + }, }) - // enqueue a task to convert text to speech - const taskName = await enqueueTextToSpeech({ - userId, - speechId: speech.id, - text: page.content, - voice: speech.voice, - priority: 'low', - isUltraRealisticVoice: true, - }) - logger.info('Start Text to speech task', { taskName }) + + for (const utterance of speechFile.utterances) { + // enqueue a task to convert text to speech + const taskName = await enqueueTextToSpeech({ + userId, + speechId: utterance.idx, + text: utterance.text, + voice: utterance.voice || 'Harrison', + priority: 'low', + isUltraRealisticVoice: true, + }) + logger.info('Start Text to speech task', { taskName }) + } + return res.status(202).send('Text to speech task started') } diff --git a/packages/text-to-speech/src/index.ts b/packages/text-to-speech/src/index.ts index a57a4a098..7bdb5f40f 100644 --- a/packages/text-to-speech/src/index.ts +++ b/packages/text-to-speech/src/index.ts @@ -40,7 +40,6 @@ interface HTMLInput { rate?: string complimentaryVoice?: string bucket: string - isUltraRealisticVoice?: boolean } interface CacheResult { @@ -83,7 +82,7 @@ const uploadToBucket = async ( await storage.bucket(bucket).file(filePath).save(data, options) } -const createGCSFile = (bucket: string, filename: string): File => { +export const createGCSFile = (bucket: string, filename: string): File => { return storage.bucket(bucket).file(filename) } @@ -168,11 +167,11 @@ export const textToSpeechHandler = Sentry.GCPFunction.wrapHttpFunction( // synthesize text to speech const startTime = Date.now() // temporary solution to use realistic text to speech - input.isUltraRealisticVoice = true const { speechMarks } = await synthesizeTextToSpeech({ ...input, textType: 'html', audioStream, + key: id, }) console.info( `Synthesize text to speech completed in ${Date.now() - startTime} ms` @@ -284,6 +283,7 @@ export const textToSpeechStreamingHandler = Sentry.GCPFunction.wrapHttpFunction( const input: TextToSpeechInput = { ...utteranceInput, textType: 'ssml', + key: cacheKey, } const { audioData, speechMarks } = await synthesizeTextToSpeech(input) if (!audioData) { diff --git a/packages/text-to-speech/src/realisticTextToSpeech.ts b/packages/text-to-speech/src/realisticTextToSpeech.ts index 4b5c5d4d2..19bde0f5a 100644 --- a/packages/text-to-speech/src/realisticTextToSpeech.ts +++ b/packages/text-to-speech/src/realisticTextToSpeech.ts @@ -7,7 +7,7 @@ import axios from 'axios' import ffmpegPath from '@ffmpeg-installer/ffmpeg' import ffmpeg from 'fluent-ffmpeg' import { PassThrough } from 'stream' -import { htmlToSpeechFile } from './htmlToSsml' +import { createGCSFile } from './index' ffmpeg.setFfmpegPath(ffmpegPath.path) @@ -16,24 +16,23 @@ interface PlayHtConvertResponse { payload: string[] } -const streamWavToMp3 = ( +const convertWavToMp3AndUpload = async ( inputStream: PassThrough, outputStream: PassThrough ) => { - ffmpeg(inputStream) - .inputFormat('wav') - .format('mp3') - .audioBitrate('32k') - .audioChannels(2) - .audioCodec('libmp3lame') - .on('error', (err) => { - throw err - }) - .on('end', () => { - console.debug('transcoding finished') - outputStream.end() - }) - .pipe(outputStream, { end: true }) + return new Promise((resolve, reject) => { + ffmpeg(inputStream) + .audioCodec('libmp3lame') + .format('mp3') + .on('error', (err) => { + reject(err) + }) + .on('end', () => { + console.debug('Finished processing') + resolve() + }) + .pipe(outputStream, { end: true }) + }) } export class RealisticTextToSpeech implements TextToSpeech { @@ -47,8 +46,18 @@ export class RealisticTextToSpeech implements TextToSpeech { throw new Error('PlayHT API credentials not set') } + const bucket = process.env.GCS_UPLOAD_BUCKET + if (!bucket) { + throw new Error('GCS_UPLOAD_BUCKET not set') + } + + // audio file to be saved in GCS + const audioFileName = `speech/${input.key}.mp3` + const audioFile = createGCSFile(bucket, audioFileName) + const outputStream = audioFile.createWriteStream({ + resumable: true, + }) as PassThrough const inputStream = new PassThrough() - const outputStream = input.audioStream as PassThrough const HEADERS = { Authorization: apiKey, @@ -56,19 +65,9 @@ export class RealisticTextToSpeech implements TextToSpeech { 'Content-Type': 'application/json', } - const speechFile = htmlToSpeechFile({ - title: '', - content: input.text, - options: { - primaryVoice: input.voice, - secondaryVoice: input.secondaryVoice, - language: input.language, - }, - }) - const content = speechFile.utterances.map((u) => u.text) const data = { voice: input.voice, - content, + content: [input.text], } // get the download url first @@ -116,7 +115,7 @@ export class RealisticTextToSpeech implements TextToSpeech { } // transcode the audio file to mp3 - streamWavToMp3(inputStream, outputStream) + await convertWavToMp3AndUpload(inputStream, outputStream) return { audioData, diff --git a/packages/text-to-speech/src/textToSpeech.ts b/packages/text-to-speech/src/textToSpeech.ts index 3bc6f7b4b..a446b18b3 100644 --- a/packages/text-to-speech/src/textToSpeech.ts +++ b/packages/text-to-speech/src/textToSpeech.ts @@ -1,5 +1,6 @@ export interface TextToSpeechInput { text: string + key: string voice?: string language?: string textType?: 'html' | 'ssml' From e3329d0a5f90a204809c6aac655fac0884e1bca9 Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Tue, 8 Nov 2022 11:38:14 +0800 Subject: [PATCH 12/43] Return audio file if exists --- packages/text-to-speech/src/index.ts | 19 +++++++++++++++++++ .../src/realisticTextToSpeech.ts | 9 +++++++++ 2 files changed, 28 insertions(+) diff --git a/packages/text-to-speech/src/index.ts b/packages/text-to-speech/src/index.ts index 7bdb5f40f..86a048aa0 100644 --- a/packages/text-to-speech/src/index.ts +++ b/packages/text-to-speech/src/index.ts @@ -86,6 +86,25 @@ export const createGCSFile = (bucket: string, filename: string): File => { return storage.bucket(bucket).file(filename) } +export const listGCSFiles = async ( + bucket: string, + prefix: string +): Promise => { + const [files] = await storage.bucket(bucket).getFiles({ + prefix, + }) + return files.map((file) => file.name) +} + +export const downloadFromBucket = async ( + bucket: string, + filename: string +): Promise => { + const file = createGCSFile(bucket, filename) + const [data] = await file.download() + return data +} + const updateSpeech = async ( speechId: string, token: string, diff --git a/packages/text-to-speech/src/realisticTextToSpeech.ts b/packages/text-to-speech/src/realisticTextToSpeech.ts index 19bde0f5a..2a0603d08 100644 --- a/packages/text-to-speech/src/realisticTextToSpeech.ts +++ b/packages/text-to-speech/src/realisticTextToSpeech.ts @@ -54,6 +54,15 @@ export class RealisticTextToSpeech implements TextToSpeech { // audio file to be saved in GCS const audioFileName = `speech/${input.key}.mp3` const audioFile = createGCSFile(bucket, audioFileName) + if (await audioFile.exists()) { + console.debug('Audio file already exists') + const [audioData] = await audioFile.download() + return { + audioData, + speechMarks: [], + } + } + const outputStream = audioFile.createWriteStream({ resumable: true, }) as PassThrough From 271280f4fcfd4f183c62b5ab639a6fd18a4ad962 Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Tue, 8 Nov 2022 12:56:52 +0800 Subject: [PATCH 13/43] Fix not correctly getting audio file --- packages/api/src/routers/text_to_speech.ts | 2 +- packages/text-to-speech/src/index.ts | 19 ------------------- .../src/realisticTextToSpeech.ts | 4 +++- 3 files changed, 4 insertions(+), 21 deletions(-) diff --git a/packages/api/src/routers/text_to_speech.ts b/packages/api/src/routers/text_to_speech.ts index 171593853..4b6183c9c 100644 --- a/packages/api/src/routers/text_to_speech.ts +++ b/packages/api/src/routers/text_to_speech.ts @@ -62,7 +62,7 @@ export function textToSpeechRouter() { title: page.title, content: page.content, options: { - primaryVoice: 'Harrison', + primaryVoice: 'larry', secondaryVoice: 'Evelyn', }, }) diff --git a/packages/text-to-speech/src/index.ts b/packages/text-to-speech/src/index.ts index 86a048aa0..7bdb5f40f 100644 --- a/packages/text-to-speech/src/index.ts +++ b/packages/text-to-speech/src/index.ts @@ -86,25 +86,6 @@ export const createGCSFile = (bucket: string, filename: string): File => { return storage.bucket(bucket).file(filename) } -export const listGCSFiles = async ( - bucket: string, - prefix: string -): Promise => { - const [files] = await storage.bucket(bucket).getFiles({ - prefix, - }) - return files.map((file) => file.name) -} - -export const downloadFromBucket = async ( - bucket: string, - filename: string -): Promise => { - const file = createGCSFile(bucket, filename) - const [data] = await file.download() - return data -} - const updateSpeech = async ( speechId: string, token: string, diff --git a/packages/text-to-speech/src/realisticTextToSpeech.ts b/packages/text-to-speech/src/realisticTextToSpeech.ts index 2a0603d08..9e6451db0 100644 --- a/packages/text-to-speech/src/realisticTextToSpeech.ts +++ b/packages/text-to-speech/src/realisticTextToSpeech.ts @@ -54,7 +54,9 @@ export class RealisticTextToSpeech implements TextToSpeech { // audio file to be saved in GCS const audioFileName = `speech/${input.key}.mp3` const audioFile = createGCSFile(bucket, audioFileName) - if (await audioFile.exists()) { + // check if audio file already exists + const [exists] = await audioFile.exists() + if (exists) { console.debug('Audio file already exists') const [audioData] = await audioFile.download() return { From 44987072ee6d5fd523d41f2e99111d84bbab1e6d Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Tue, 8 Nov 2022 13:08:05 +0800 Subject: [PATCH 14/43] Get voice from user personalization --- packages/api/src/routers/text_to_speech.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/api/src/routers/text_to_speech.ts b/packages/api/src/routers/text_to_speech.ts index 4b6183c9c..c97f2b717 100644 --- a/packages/api/src/routers/text_to_speech.ts +++ b/packages/api/src/routers/text_to_speech.ts @@ -4,7 +4,7 @@ import express from 'express' import cors from 'cors' import { corsConfig } from '../utils/corsConfig' -import { setClaims } from '../entity/utils' +import { getRepository, setClaims } from '../entity/utils' import { getPageById } from '../elastic/pages' import { Speech, SpeechState } from '../entity/speech' import { buildLogger } from '../utils/logger' @@ -14,6 +14,7 @@ import { readPushSubscription } from '../datalayer/pubsub' import { AppDataSource } from '../server' import { enqueueTextToSpeech } from '../utils/createTask' import { htmlToSpeechFile } from '@omnivore/text-to-speech-handler' +import { UserPersonalization } from '../entity/user_personalization' const logger = buildLogger('app.dispatch') @@ -58,11 +59,15 @@ export function textToSpeechRouter() { if (await shouldSynthesize(userId, page)) { logger.info('page needs to be synthesized') + const userPersonalization = await getRepository( + UserPersonalization + ).findOneBy({ user: { id: userId } }) + const speechFile = htmlToSpeechFile({ title: page.title, content: page.content, options: { - primaryVoice: 'larry', + primaryVoice: userPersonalization?.speechVoice || 'larry', secondaryVoice: 'Evelyn', }, }) @@ -73,7 +78,7 @@ export function textToSpeechRouter() { userId, speechId: utterance.idx, text: utterance.text, - voice: utterance.voice || 'Harrison', + voice: utterance.voice || 'larry', priority: 'low', isUltraRealisticVoice: true, }) From 3ffd7739bbcea8b11ecd10ac084e187f34d355a8 Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Tue, 8 Nov 2022 14:23:48 +0800 Subject: [PATCH 15/43] Default voice is Larry --- packages/api/src/routers/text_to_speech.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/api/src/routers/text_to_speech.ts b/packages/api/src/routers/text_to_speech.ts index c97f2b717..2e9d0b39d 100644 --- a/packages/api/src/routers/text_to_speech.ts +++ b/packages/api/src/routers/text_to_speech.ts @@ -67,7 +67,7 @@ export function textToSpeechRouter() { title: page.title, content: page.content, options: { - primaryVoice: userPersonalization?.speechVoice || 'larry', + primaryVoice: userPersonalization?.speechVoice || 'Larry', secondaryVoice: 'Evelyn', }, }) @@ -78,8 +78,8 @@ export function textToSpeechRouter() { userId, speechId: utterance.idx, text: utterance.text, - voice: utterance.voice || 'larry', - priority: 'low', + voice: utterance.voice || 'Larry', + priority: 'high', isUltraRealisticVoice: true, }) logger.info('Start Text to speech task', { taskName }) From 769e8c5f967184ccdd06e6410294704ed93fff49 Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Tue, 8 Nov 2022 15:41:20 +0800 Subject: [PATCH 16/43] Add default language and rate --- packages/api/src/routers/text_to_speech.ts | 8 +++++--- packages/text-to-speech/src/index.ts | 6 +++--- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/api/src/routers/text_to_speech.ts b/packages/api/src/routers/text_to_speech.ts index 2e9d0b39d..cd79ab052 100644 --- a/packages/api/src/routers/text_to_speech.ts +++ b/packages/api/src/routers/text_to_speech.ts @@ -67,8 +67,10 @@ export function textToSpeechRouter() { title: page.title, content: page.content, options: { - primaryVoice: userPersonalization?.speechVoice || 'Larry', - secondaryVoice: 'Evelyn', + primaryVoice: userPersonalization?.speechVoice || 'Axel', + secondaryVoice: userPersonalization?.speechVoice || 'Evelyn', + language: page.language || 'English', + rate: '1.1', }, }) @@ -78,7 +80,7 @@ export function textToSpeechRouter() { userId, speechId: utterance.idx, text: utterance.text, - voice: utterance.voice || 'Larry', + voice: utterance.voice || 'Axel', priority: 'high', isUltraRealisticVoice: true, }) diff --git a/packages/text-to-speech/src/index.ts b/packages/text-to-speech/src/index.ts index 7bdb5f40f..7bd73a448 100644 --- a/packages/text-to-speech/src/index.ts +++ b/packages/text-to-speech/src/index.ts @@ -24,12 +24,12 @@ import { RealisticTextToSpeech } from './realisticTextToSpeech' type RedisClient = ReturnType interface UtteranceInput { - voice?: string - rate?: string - language?: string text: string idx: string isUltraRealisticVoice?: boolean + voice?: string + rate?: string + language?: string } interface HTMLInput { From ddd1e84686422639e29153e1f4164b0daaf24a77 Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Tue, 8 Nov 2022 16:22:23 +0800 Subject: [PATCH 17/43] Add language and rate in the cloud task params --- packages/api/src/routers/text_to_speech.ts | 6 +++--- packages/api/src/utils/createTask.ts | 6 ++++++ 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/api/src/routers/text_to_speech.ts b/packages/api/src/routers/text_to_speech.ts index cd79ab052..2a6a1b320 100644 --- a/packages/api/src/routers/text_to_speech.ts +++ b/packages/api/src/routers/text_to_speech.ts @@ -68,9 +68,7 @@ export function textToSpeechRouter() { content: page.content, options: { primaryVoice: userPersonalization?.speechVoice || 'Axel', - secondaryVoice: userPersonalization?.speechVoice || 'Evelyn', - language: page.language || 'English', - rate: '1.1', + secondaryVoice: 'Evelyn', }, }) @@ -83,6 +81,8 @@ export function textToSpeechRouter() { voice: utterance.voice || 'Axel', priority: 'high', isUltraRealisticVoice: true, + language: page.language || 'English', + rate: '1.1', }) logger.info('Start Text to speech task', { taskName }) } diff --git a/packages/api/src/utils/createTask.ts b/packages/api/src/utils/createTask.ts index b61de5165..af9b0e3ad 100644 --- a/packages/api/src/utils/createTask.ts +++ b/packages/api/src/utils/createTask.ts @@ -345,6 +345,8 @@ export const enqueueTextToSpeech = async ({ queue = 'omnivore-demo-text-to-speech-queue', location = env.gcp.location, isUltraRealisticVoice = false, + language, + rate, }: { userId: string speechId: string @@ -356,6 +358,8 @@ export const enqueueTextToSpeech = async ({ queue?: string location?: string isUltraRealisticVoice?: boolean + language?: string + rate?: string }): Promise => { const { GOOGLE_CLOUD_PROJECT } = process.env const payload = { @@ -365,6 +369,8 @@ export const enqueueTextToSpeech = async ({ bucket, textType, isUltraRealisticVoice, + language, + rate, } // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore From f2a69d06c4a2a1365b26817c70ca41da44c17a02 Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Tue, 8 Nov 2022 16:53:35 +0800 Subject: [PATCH 18/43] Change default rate to 1.1 --- packages/api/src/routers/text_to_speech.ts | 5 +++-- packages/text-to-speech/src/htmlToSsml.ts | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/api/src/routers/text_to_speech.ts b/packages/api/src/routers/text_to_speech.ts index 2a6a1b320..f7dd7bdcc 100644 --- a/packages/api/src/routers/text_to_speech.ts +++ b/packages/api/src/routers/text_to_speech.ts @@ -69,6 +69,7 @@ export function textToSpeechRouter() { options: { primaryVoice: userPersonalization?.speechVoice || 'Axel', secondaryVoice: 'Evelyn', + language: page.language, }, }) @@ -81,8 +82,8 @@ export function textToSpeechRouter() { voice: utterance.voice || 'Axel', priority: 'high', isUltraRealisticVoice: true, - language: page.language || 'English', - rate: '1.1', + language: speechFile.language, + rate: userPersonalization?.speechRate?.toString() || '1.1', }) logger.info('Start Text to speech task', { taskName }) } diff --git a/packages/text-to-speech/src/htmlToSsml.ts b/packages/text-to-speech/src/htmlToSsml.ts index 9b1a32d87..8fb0b7e35 100644 --- a/packages/text-to-speech/src/htmlToSsml.ts +++ b/packages/text-to-speech/src/htmlToSsml.ts @@ -44,7 +44,7 @@ export type SSMLOptions = { const DEFAULT_LANGUAGE = 'en-US' const DEFAULT_VOICE = 'en-US-JennyNeural' const DEFAULT_SECONDARY_VOICE = 'en-US-GuyNeural' -const DEFAULT_RATE = '1.0' +const DEFAULT_RATE = '1.1' const ANCHOR_ELEMENTS_BLOCKED_ATTRIBUTES = [ 'omnivore-highlight-id', From 398e24213192b38bd5c855e6721054773d0941db Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Tue, 8 Nov 2022 17:30:03 +0800 Subject: [PATCH 19/43] Add features table --- .../api/src/entity/user_personalization.ts | 11 +++++--- packages/api/src/routers/text_to_speech.ts | 5 ++-- .../0098.do.create_features_table.sql | 28 +++++++++++++++++++ .../0098.undo.create_features_table.sql | 14 ++++++++++ packages/text-to-speech/src/htmlToSsml.ts | 9 ++++-- 5 files changed, 58 insertions(+), 9 deletions(-) create mode 100755 packages/db/migrations/0098.do.create_features_table.sql create mode 100755 packages/db/migrations/0098.undo.create_features_table.sql diff --git a/packages/api/src/entity/user_personalization.ts b/packages/api/src/entity/user_personalization.ts index 8044ef1a7..5e7cc51f8 100644 --- a/packages/api/src/entity/user_personalization.ts +++ b/packages/api/src/entity/user_personalization.ts @@ -39,11 +39,14 @@ export class UserPersonalization { @Column('text', { nullable: true }) speechVoice?: string - @Column('integer', { nullable: true }) - speechRate?: number + @Column('text', { nullable: true }) + speechSecondaryVoice?: string - @Column('integer', { nullable: true }) - speechVolume?: number + @Column('text', { nullable: true }) + speechRate?: string + + @Column('text', { nullable: true }) + speechVolume?: string @CreateDateColumn({ default: () => 'CURRENT_TIMESTAMP' }) createdAt!: Date diff --git a/packages/api/src/routers/text_to_speech.ts b/packages/api/src/routers/text_to_speech.ts index f7dd7bdcc..7a959838a 100644 --- a/packages/api/src/routers/text_to_speech.ts +++ b/packages/api/src/routers/text_to_speech.ts @@ -68,7 +68,8 @@ export function textToSpeechRouter() { content: page.content, options: { primaryVoice: userPersonalization?.speechVoice || 'Axel', - secondaryVoice: 'Evelyn', + secondaryVoice: + userPersonalization?.speechSecondaryVoice || 'Evelyn', language: page.language, }, }) @@ -83,7 +84,7 @@ export function textToSpeechRouter() { priority: 'high', isUltraRealisticVoice: true, language: speechFile.language, - rate: userPersonalization?.speechRate?.toString() || '1.1', + rate: userPersonalization?.speechRate || '1.1', }) logger.info('Start Text to speech task', { taskName }) } diff --git a/packages/db/migrations/0098.do.create_features_table.sql b/packages/db/migrations/0098.do.create_features_table.sql new file mode 100755 index 000000000..618315732 --- /dev/null +++ b/packages/db/migrations/0098.do.create_features_table.sql @@ -0,0 +1,28 @@ +-- Type: DO +-- Name: create_features_table +-- Description: Create features table to store opt-in features by users + +BEGIN; + +CREATE TABLE IF NOT EXISTS omnivore.features ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v1mc(), + user_id uuid NOT NULL REFERENCES omnivore.user ON DELETE CASCADE, + name text NOT NULL, + token text NOT NULL, + granted_at timestamptz, + created_at timestamptz NOT NULL DEFAULT current_timestamp, + updated_at timestamptz NOT NULL DEFAULT current_timestamp, + UNIQUE (user_id, name) +); + +CREATE TRIGGER features_modtime BEFORE UPDATE ON omnivore.features + FOR EACH ROW EXECUTE PROCEDURE update_updated_at_column(); + +GRANT SELECT, INSERT, UPDATE, DELETE ON omnivore.features TO omnivore_user; + +ALTER TABLE omnivore.user_personalization + ADD COLUMN IF NOT EXISTS speech_secondary_voice text, + ALTER COLUMN speech_rate TYPE text, + ALTER COLUMN speech_volume TYPE text; + +COMMIT; diff --git a/packages/db/migrations/0098.undo.create_features_table.sql b/packages/db/migrations/0098.undo.create_features_table.sql new file mode 100755 index 000000000..c399a16c9 --- /dev/null +++ b/packages/db/migrations/0098.undo.create_features_table.sql @@ -0,0 +1,14 @@ +-- Type: UNDO +-- Name: create_features_table +-- Description: Create features table to store opt-in features by users + +BEGIN; + +DROP TABLE IF EXISTS omnivore.features; + +ALTER TABLE omnivore.user_personalization + DROP COLUMN IF EXISTS speech_secondary_voice, + ALTER COLUMN speech_rate TYPE integer, + ALTER COLUMN speech_volume TYPE integer; + +COMMIT; diff --git a/packages/text-to-speech/src/htmlToSsml.ts b/packages/text-to-speech/src/htmlToSsml.ts index 8fb0b7e35..be3f1c2e8 100644 --- a/packages/text-to-speech/src/htmlToSsml.ts +++ b/packages/text-to-speech/src/htmlToSsml.ts @@ -16,7 +16,7 @@ export interface Utterance { text: string wordOffset: number wordCount: number - voice?: string + voice: string } export interface SpeechFile { @@ -269,7 +269,7 @@ const textToUtterances = ({ idx: string textItems: string[] wordOffset: number - voice?: string + voice: string isHtml?: boolean }): Utterance[] => { let text = textItems.join('') @@ -393,6 +393,7 @@ export const htmlToSpeechFile = (htmlInput: HtmlInput): SpeechFile => { textItems: [stripEmojis(title)], // title could have emoji wordOffset, isHtml: false, + voice: defaultVoice, })[0] utterances.push(titleUtterance) wordOffset += titleUtterance.wordCount @@ -413,7 +414,9 @@ export const htmlToSpeechFile = (htmlInput: HtmlInput): SpeechFile => { textItems, wordOffset, voice: - node.nodeName === 'BLOCKQUOTE' ? options.secondaryVoice : undefined, + node.nodeName === 'BLOCKQUOTE' + ? options.secondaryVoice || defaultVoice + : defaultVoice, }) const wordCount = newUtterances.reduce((acc, u) => acc + u.wordCount, 0) wordCount > 0 && utterances.push(...newUtterances) From c2f4ba4a57c0021b084c5d5b83777f739ab2fd85 Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Tue, 8 Nov 2022 18:14:23 +0800 Subject: [PATCH 20/43] Add features entity --- packages/api/src/entity/feature.ts | 35 ++++++++++++++++++++++ packages/api/src/routers/text_to_speech.ts | 16 ++++++---- 2 files changed, 46 insertions(+), 5 deletions(-) create mode 100644 packages/api/src/entity/feature.ts diff --git a/packages/api/src/entity/feature.ts b/packages/api/src/entity/feature.ts new file mode 100644 index 000000000..0216896a4 --- /dev/null +++ b/packages/api/src/entity/feature.ts @@ -0,0 +1,35 @@ +import { + Column, + CreateDateColumn, + Entity, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm' +import { User } from './user' + +@Entity({ name: 'features' }) +export class Feature { + @PrimaryGeneratedColumn('uuid') + id!: string + + @ManyToOne(() => User, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'user_id' }) + user!: User + + @Column('text') + name!: string + + @Column('text') + token!: string + + @Column('timestamp', { nullable: true }) + grantedAt?: Date | null + + @CreateDateColumn({ default: () => 'CURRENT_TIMESTAMP' }) + createdAt!: Date + + @UpdateDateColumn({ default: () => 'CURRENT_TIMESTAMP' }) + updatedAt!: Date +} diff --git a/packages/api/src/routers/text_to_speech.ts b/packages/api/src/routers/text_to_speech.ts index 7a959838a..e0796a76d 100644 --- a/packages/api/src/routers/text_to_speech.ts +++ b/packages/api/src/routers/text_to_speech.ts @@ -15,6 +15,7 @@ import { AppDataSource } from '../server' import { enqueueTextToSpeech } from '../utils/createTask' import { htmlToSpeechFile } from '@omnivore/text-to-speech-handler' import { UserPersonalization } from '../entity/user_personalization' +import { ArticleSavingRequestStatus } from '../elastic/types' const logger = buildLogger('app.dispatch') @@ -36,17 +37,17 @@ export function textToSpeechRouter() { } try { - const data: { userId: string; type: string; id: string; state: string } = + const data: { userId: string; type: string; id: string } = JSON.parse(msgStr) - const { userId, type, id, state } = data + const { userId, type, id } = data if (!userId || !type || !id) { logger.info('Invalid data') return res.status(400).send('Bad Request') } - if (type.toUpperCase() !== 'PAGE' || state !== 'SUCCEEDED') { - logger.info('Not a page or not succeeded') - return res.status(200).send('Not a page or not succeeded') + if (type.toUpperCase() !== 'PAGE') { + logger.info('Not a page') + return res.status(200).send('Not a page') } const page = await getPageById(id) @@ -55,6 +56,11 @@ export function textToSpeechRouter() { return res.status(200).send('No page found') } + if (page.state === ArticleSavingRequestStatus.Processing) { + logger.info('Page is still processing, try again later', { id }) + return res.status(400).send('Page is still processing') + } + // checks if this page needs to be synthesized automatically if (await shouldSynthesize(userId, page)) { logger.info('page needs to be synthesized') From 98f59c50f072f3b27d2ae8237fed3991256582bd Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Wed, 9 Nov 2022 11:32:08 +0800 Subject: [PATCH 21/43] Remove token in features table --- packages/api/src/entity/feature.ts | 6 +++--- packages/db/migrations/0098.do.create_features_table.sql | 2 +- packages/db/migrations/0098.undo.create_features_table.sql | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/api/src/entity/feature.ts b/packages/api/src/entity/feature.ts index 0216896a4..885a1d503 100644 --- a/packages/api/src/entity/feature.ts +++ b/packages/api/src/entity/feature.ts @@ -21,12 +21,12 @@ export class Feature { @Column('text') name!: string - @Column('text') - token!: string - @Column('timestamp', { nullable: true }) grantedAt?: Date | null + @Column('timestamp', { nullable: true }) + expiredAt?: Date | null + @CreateDateColumn({ default: () => 'CURRENT_TIMESTAMP' }) createdAt!: Date diff --git a/packages/db/migrations/0098.do.create_features_table.sql b/packages/db/migrations/0098.do.create_features_table.sql index 618315732..600145299 100755 --- a/packages/db/migrations/0098.do.create_features_table.sql +++ b/packages/db/migrations/0098.do.create_features_table.sql @@ -8,8 +8,8 @@ CREATE TABLE IF NOT EXISTS omnivore.features ( id uuid PRIMARY KEY DEFAULT uuid_generate_v1mc(), user_id uuid NOT NULL REFERENCES omnivore.user ON DELETE CASCADE, name text NOT NULL, - token text NOT NULL, granted_at timestamptz, + expired_at timestamptz, created_at timestamptz NOT NULL DEFAULT current_timestamp, updated_at timestamptz NOT NULL DEFAULT current_timestamp, UNIQUE (user_id, name) diff --git a/packages/db/migrations/0098.undo.create_features_table.sql b/packages/db/migrations/0098.undo.create_features_table.sql index c399a16c9..700211333 100755 --- a/packages/db/migrations/0098.undo.create_features_table.sql +++ b/packages/db/migrations/0098.undo.create_features_table.sql @@ -8,7 +8,7 @@ DROP TABLE IF EXISTS omnivore.features; ALTER TABLE omnivore.user_personalization DROP COLUMN IF EXISTS speech_secondary_voice, - ALTER COLUMN speech_rate TYPE integer, - ALTER COLUMN speech_volume TYPE integer; + ALTER COLUMN speech_rate TYPE integer USING speech_rate::integer, + ALTER COLUMN speech_volume TYPE integer USING speech_volume::integer; COMMIT; From 63de1c335974b1fff15235fdc21e6e929100c419 Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Wed, 9 Nov 2022 17:35:54 +0800 Subject: [PATCH 22/43] Add optInFeature API --- packages/api/src/entity/feature.ts | 2 +- packages/api/src/generated/graphql.ts | 79 +++++++ packages/api/src/generated/schema.graphql | 30 +++ packages/api/src/resolvers/features/index.ts | 60 ++++++ .../api/src/resolvers/function_resolvers.ts | 9 +- packages/api/src/schema.ts | 30 +++ packages/api/src/services/features.ts | 67 ++++++ packages/api/test/resolvers/features.test.ts | 196 ++++++++++++++++++ .../0098.do.create_features_table.sql | 2 +- 9 files changed, 470 insertions(+), 5 deletions(-) create mode 100644 packages/api/src/resolvers/features/index.ts create mode 100644 packages/api/src/services/features.ts create mode 100644 packages/api/test/resolvers/features.test.ts diff --git a/packages/api/src/entity/feature.ts b/packages/api/src/entity/feature.ts index 885a1d503..77ca572e7 100644 --- a/packages/api/src/entity/feature.ts +++ b/packages/api/src/entity/feature.ts @@ -25,7 +25,7 @@ export class Feature { grantedAt?: Date | null @Column('timestamp', { nullable: true }) - expiredAt?: Date | null + expiresAt?: Date | null @CreateDateColumn({ default: () => 'CURRENT_TIMESTAMP' }) createdAt!: Date diff --git a/packages/api/src/generated/graphql.ts b/packages/api/src/generated/graphql.ts index 88a340b40..1f1815b31 100644 --- a/packages/api/src/generated/graphql.ts +++ b/packages/api/src/generated/graphql.ts @@ -596,6 +596,17 @@ export type DeviceToken = { token: Scalars['String']; }; +export type Feature = { + __typename?: 'Feature'; + createdAt: Scalars['Date']; + expiresAt?: Maybe; + grantedAt?: Maybe; + id: Scalars['ID']; + name: Scalars['String']; + token: Scalars['String']; + updatedAt: Scalars['Date']; +}; + export type FeedArticle = { __typename?: 'FeedArticle'; annotationsCount?: Maybe; @@ -971,6 +982,7 @@ export type Mutation = { logOut: LogOutResult; mergeHighlight: MergeHighlightResult; moveLabel: MoveLabelResult; + optInFeature: OptInFeatureResult; reportItem: ReportItemResult; revokeApiKey: RevokeApiKeyResult; saveArticleReadingProgress: SaveArticleReadingProgressResult; @@ -1113,6 +1125,11 @@ export type MutationMoveLabelArgs = { }; +export type MutationOptInFeatureArgs = { + input: OptInFeatureInput; +}; + + export type MutationReportItemArgs = { input: ReportItemInput; }; @@ -1281,6 +1298,27 @@ export type NewsletterEmailsSuccess = { newsletterEmails: Array; }; +export type OptInFeatureError = { + __typename?: 'OptInFeatureError'; + errorCodes: Array; +}; + +export enum OptInFeatureErrorCode { + BadRequest = 'BAD_REQUEST', + NotFound = 'NOT_FOUND' +} + +export type OptInFeatureInput = { + name: Scalars['String']; +}; + +export type OptInFeatureResult = OptInFeatureError | OptInFeatureSuccess; + +export type OptInFeatureSuccess = { + __typename?: 'OptInFeatureSuccess'; + feature: Feature; +}; + export type Page = { __typename?: 'Page'; author?: Maybe; @@ -2684,6 +2722,7 @@ export type ResolversTypes = { DeleteWebhookResult: ResolversTypes['DeleteWebhookError'] | ResolversTypes['DeleteWebhookSuccess']; DeleteWebhookSuccess: ResolverTypeWrapper; DeviceToken: ResolverTypeWrapper; + Feature: ResolverTypeWrapper; FeedArticle: ResolverTypeWrapper; FeedArticleEdge: ResolverTypeWrapper; FeedArticlesError: ResolverTypeWrapper; @@ -2755,6 +2794,11 @@ export type ResolversTypes = { NewsletterEmailsErrorCode: NewsletterEmailsErrorCode; NewsletterEmailsResult: ResolversTypes['NewsletterEmailsError'] | ResolversTypes['NewsletterEmailsSuccess']; NewsletterEmailsSuccess: ResolverTypeWrapper; + OptInFeatureError: ResolverTypeWrapper; + OptInFeatureErrorCode: OptInFeatureErrorCode; + OptInFeatureInput: OptInFeatureInput; + OptInFeatureResult: ResolversTypes['OptInFeatureError'] | ResolversTypes['OptInFeatureSuccess']; + OptInFeatureSuccess: ResolverTypeWrapper; Page: ResolverTypeWrapper; PageInfo: ResolverTypeWrapper; PageInfoInput: PageInfoInput; @@ -3045,6 +3089,7 @@ export type ResolversParentTypes = { DeleteWebhookResult: ResolversParentTypes['DeleteWebhookError'] | ResolversParentTypes['DeleteWebhookSuccess']; DeleteWebhookSuccess: DeleteWebhookSuccess; DeviceToken: DeviceToken; + Feature: Feature; FeedArticle: FeedArticle; FeedArticleEdge: FeedArticleEdge; FeedArticlesError: FeedArticlesError; @@ -3103,6 +3148,10 @@ export type ResolversParentTypes = { NewsletterEmailsError: NewsletterEmailsError; NewsletterEmailsResult: ResolversParentTypes['NewsletterEmailsError'] | ResolversParentTypes['NewsletterEmailsSuccess']; NewsletterEmailsSuccess: NewsletterEmailsSuccess; + OptInFeatureError: OptInFeatureError; + OptInFeatureInput: OptInFeatureInput; + OptInFeatureResult: ResolversParentTypes['OptInFeatureError'] | ResolversParentTypes['OptInFeatureSuccess']; + OptInFeatureSuccess: OptInFeatureSuccess; Page: Page; PageInfo: PageInfo; PageInfoInput: PageInfoInput; @@ -3677,6 +3726,17 @@ export type DeviceTokenResolvers; }; +export type FeatureResolvers = { + createdAt?: Resolver; + expiresAt?: Resolver, ParentType, ContextType>; + grantedAt?: Resolver, ParentType, ContextType>; + id?: Resolver; + name?: Resolver; + token?: Resolver; + updatedAt?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + export type FeedArticleResolvers = { annotationsCount?: Resolver, ParentType, ContextType>; article?: Resolver; @@ -3971,6 +4031,7 @@ export type MutationResolvers; mergeHighlight?: Resolver>; moveLabel?: Resolver>; + optInFeature?: Resolver>; reportItem?: Resolver>; revokeApiKey?: Resolver>; saveArticleReadingProgress?: Resolver>; @@ -4023,6 +4084,20 @@ export type NewsletterEmailsSuccessResolvers; }; +export type OptInFeatureErrorResolvers = { + errorCodes?: Resolver, ParentType, ContextType>; + __isTypeOf?: IsTypeOfResolverFn; +}; + +export type OptInFeatureResultResolvers = { + __resolveType: TypeResolveFn<'OptInFeatureError' | 'OptInFeatureSuccess', ParentType, ContextType>; +}; + +export type OptInFeatureSuccessResolvers = { + feature?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + export type PageResolvers = { author?: Resolver, ParentType, ContextType>; createdAt?: Resolver; @@ -4835,6 +4910,7 @@ export type Resolvers = { DeleteWebhookResult?: DeleteWebhookResultResolvers; DeleteWebhookSuccess?: DeleteWebhookSuccessResolvers; DeviceToken?: DeviceTokenResolvers; + Feature?: FeatureResolvers; FeedArticle?: FeedArticleResolvers; FeedArticleEdge?: FeedArticleEdgeResolvers; FeedArticlesError?: FeedArticlesErrorResolvers; @@ -4885,6 +4961,9 @@ export type Resolvers = { NewsletterEmailsError?: NewsletterEmailsErrorResolvers; NewsletterEmailsResult?: NewsletterEmailsResultResolvers; NewsletterEmailsSuccess?: NewsletterEmailsSuccessResolvers; + OptInFeatureError?: OptInFeatureErrorResolvers; + OptInFeatureResult?: OptInFeatureResultResolvers; + OptInFeatureSuccess?: OptInFeatureSuccessResolvers; Page?: PageResolvers; PageInfo?: PageInfoResolvers; Profile?: ProfileResolvers; diff --git a/packages/api/src/generated/schema.graphql b/packages/api/src/generated/schema.graphql index fce0f2f7a..4a4048d46 100644 --- a/packages/api/src/generated/schema.graphql +++ b/packages/api/src/generated/schema.graphql @@ -524,6 +524,16 @@ type DeviceToken { token: String! } +type Feature { + createdAt: Date! + expiresAt: Date + grantedAt: Date + id: ID! + name: String! + token: String! + updatedAt: Date! +} + type FeedArticle { annotationsCount: Int article: Article! @@ -865,6 +875,7 @@ type Mutation { logOut: LogOutResult! mergeHighlight(input: MergeHighlightInput!): MergeHighlightResult! moveLabel(input: MoveLabelInput!): MoveLabelResult! + optInFeature(input: OptInFeatureInput!): OptInFeatureResult! reportItem(input: ReportItemInput!): ReportItemResult! revokeApiKey(id: ID!): RevokeApiKeyResult! saveArticleReadingProgress(input: SaveArticleReadingProgressInput!): SaveArticleReadingProgressResult! @@ -917,6 +928,25 @@ type NewsletterEmailsSuccess { newsletterEmails: [NewsletterEmail!]! } +type OptInFeatureError { + errorCodes: [OptInFeatureErrorCode!]! +} + +enum OptInFeatureErrorCode { + BAD_REQUEST + NOT_FOUND +} + +input OptInFeatureInput { + name: String! +} + +union OptInFeatureResult = OptInFeatureError | OptInFeatureSuccess + +type OptInFeatureSuccess { + feature: Feature! +} + type Page { author: String createdAt: Date! diff --git a/packages/api/src/resolvers/features/index.ts b/packages/api/src/resolvers/features/index.ts new file mode 100644 index 000000000..cb879d71f --- /dev/null +++ b/packages/api/src/resolvers/features/index.ts @@ -0,0 +1,60 @@ +import { authorized } from '../../utils/helpers' +import { + MutationOptInFeatureArgs, + OptInFeatureError, + OptInFeatureErrorCode, + OptInFeatureSuccess, +} from '../../generated/graphql' +import { + getFeatureName, + optInFeature, + signFeatureToken, +} from '../../services/features' + +export const optInFeatureResolver = authorized< + OptInFeatureSuccess, + OptInFeatureError, + MutationOptInFeatureArgs +>(async (_, { input: { name } }, { claims, log }) => { + log.info('Opting in to a feature', { + feature: name, + labels: { + source: 'resolver', + resolver: 'optInFeatureResolver', + uid: claims.uid, + }, + }) + + try { + const featureName = getFeatureName(name) + if (!featureName) { + return { + errorCodes: [OptInFeatureErrorCode.NotFound], + } + } + + const optIn = await optInFeature(featureName, claims.uid) + if (!optIn) { + return { + errorCodes: [OptInFeatureErrorCode.NotFound], + } + } + + const token = signFeatureToken(optIn) + + return { + feature: { + ...optIn, + token, + }, + } + } catch (e) { + log.error('Error opting in to a feature', { + error: e, + }) + + return { + errorCodes: [OptInFeatureErrorCode.BadRequest], + } + } +}) diff --git a/packages/api/src/resolvers/function_resolvers.ts b/packages/api/src/resolvers/function_resolvers.ts index 50cd8580b..8bb5cfb7e 100644 --- a/packages/api/src/resolvers/function_resolvers.ts +++ b/packages/api/src/resolvers/function_resolvers.ts @@ -5,8 +5,8 @@ /* eslint-disable @typescript-eslint/explicit-module-boundary-types */ import { createReactionResolver, deleteReactionResolver } from './reaction' import { Claims, WithDataSourcesContext } from './types' -import { createImageProxyUrl } from './../utils/imageproxy' -import { userDataToUser, validatedDate } from './../utils/helpers' +import { createImageProxyUrl } from '../utils/imageproxy' +import { userDataToUser, validatedDate } from '../utils/helpers' import { Article, @@ -18,7 +18,7 @@ import { Reaction, SearchItem, User, -} from './../generated/graphql' +} from '../generated/graphql' import { addPopularReadResolver, @@ -101,6 +101,7 @@ import { } from '../utils/uploads' import { getPageByParam } from '../elastic/pages' import { recentSearchesResolver } from './recent_searches' +import { optInFeatureResolver } from './features' /* eslint-disable @typescript-eslint/naming-convention */ type ResultResolveType = { @@ -171,6 +172,7 @@ export const functionResolvers = { moveLabel: moveLabelResolver, setIntegration: setIntegrationResolver, deleteIntegration: deleteIntegrationResolver, + optInFeature: optInFeatureResolver, }, Query: { me: getMeUserResolver, @@ -607,4 +609,5 @@ export const functionResolvers = { ...resultResolveTypeResolver('Integrations'), ...resultResolveTypeResolver('DeleteIntegration'), ...resultResolveTypeResolver('RecentSearches'), + ...resultResolveTypeResolver('OptInFeature'), } diff --git a/packages/api/src/schema.ts b/packages/api/src/schema.ts index 765379a09..7faa4c4a7 100755 --- a/packages/api/src/schema.ts +++ b/packages/api/src/schema.ts @@ -1912,6 +1912,35 @@ const schema = gql` BAD_REQUEST } + input OptInFeatureInput { + name: String! + } + + union OptInFeatureResult = OptInFeatureSuccess | OptInFeatureError + + type OptInFeatureSuccess { + feature: Feature! + } + + type Feature { + id: ID! + name: String! + token: String! + createdAt: Date! + updatedAt: Date! + grantedAt: Date + expiresAt: Date + } + + type OptInFeatureError { + errorCodes: [OptInFeatureErrorCode!]! + } + + enum OptInFeatureErrorCode { + BAD_REQUEST + NOT_FOUND + } + # Mutations type Mutation { googleLogin(input: GoogleLoginInput!): LoginResult! @@ -1983,6 +2012,7 @@ const schema = gql` moveLabel(input: MoveLabelInput!): MoveLabelResult! setIntegration(input: SetIntegrationInput!): SetIntegrationResult! deleteIntegration(id: ID!): DeleteIntegrationResult! + optInFeature(input: OptInFeatureInput!): OptInFeatureResult! } # FIXME: remove sort from feedArticles after all cached tabs are closed diff --git a/packages/api/src/services/features.ts b/packages/api/src/services/features.ts new file mode 100644 index 000000000..9afe0e2de --- /dev/null +++ b/packages/api/src/services/features.ts @@ -0,0 +1,67 @@ +import { Feature } from '../entity/feature' +import { getRepository } from '../entity/utils' +import * as jwt from 'jsonwebtoken' +import { env } from '../env' +import { IsNull, Not } from 'typeorm' + +enum FeatureName { + UltraRealisticVoice = 'ultra-realistic-voice', +} + +export const getFeatureName = (name: string): FeatureName | undefined => { + return Object.values(FeatureName).find((v) => v === name) +} + +export const optInFeature = async ( + name: FeatureName, + uid: string +): Promise => { + if (name === FeatureName.UltraRealisticVoice) { + return optInUltraRealisticVoice(uid) + } + + return undefined +} + +const optInUltraRealisticVoice = async (uid: string): Promise => { + const feature = await getRepository(Feature).findOneBy({ + user: { id: uid }, + name: FeatureName.UltraRealisticVoice, + }) + if (feature) { + // already opted in + console.log('already opted in') + return feature + } + + // opt in to feature for the first 1000 users + const count = await getRepository(Feature).countBy({ + name: FeatureName.UltraRealisticVoice, + grantedAt: Not(IsNull()), + }) + + let grantedAt: Date | null = new Date() + if (count >= 1000) { + console.log('feature limit reached') + grantedAt = null + } + + return getRepository(Feature).save({ + user: { id: uid }, + name: FeatureName.UltraRealisticVoice, + grantedAt, + }) +} + +export const signFeatureToken = (feature: Feature): string => { + return jwt.sign( + { + userid: feature.user.id, + feature_name: feature.name, + createdat: feature.createdAt.getTime(), + expiresat: feature.expiresAt?.getTime(), + grantedat: feature.grantedAt?.getTime(), + }, + env.server.jwtSecret + ) +} diff --git a/packages/api/test/resolvers/features.test.ts b/packages/api/test/resolvers/features.test.ts new file mode 100644 index 000000000..49250d074 --- /dev/null +++ b/packages/api/test/resolvers/features.test.ts @@ -0,0 +1,196 @@ +import 'mocha' +import { expect } from 'chai' +import { User } from '../../src/entity/user' +import { createTestUser, deleteTestUser } from '../db' +import { graphqlRequest, request } from '../util' +import { getRepository } from '../../src/entity/utils' +import { Feature } from '../../src/entity/feature' +import * as jwt from 'jsonwebtoken' +import sinon, { SinonFakeTimers } from 'sinon' +import { env } from '../../src/env' +import { Like } from 'typeorm' + +describe('features resolvers', () => { + let loginUser: User + let authToken: string + + before(async () => { + // create test user and login + loginUser = await createTestUser('loginUser') + const res = await request + .post('/local/debug/fake-user-login') + .send({ fakeEmail: loginUser.email }) + + authToken = res.body.authToken + }) + + after(async () => { + await deleteTestUser(loginUser.name) + }) + + describe('optInFeature API', () => { + const feature = 'ultra-realistic-voice' + const now = new Date() + let clock: SinonFakeTimers + + const query = (name: string) => ` + mutation { + optInFeature(input: { + name: "${name}" + }) { + ... on OptInFeatureSuccess { + feature { + name + grantedAt + token + } + } + ... on OptInFeatureError { + errorCodes + } + } + } + ` + + beforeEach(() => { + // mock date + clock = sinon.useFakeTimers(now.getTime()) + }) + + afterEach(() => { + clock.restore() + }) + + context('when user is the first 1000 users', () => { + after(async () => { + // reset feature + await getRepository(Feature).delete({ + user: { id: loginUser.id }, + }) + }) + + it('opts in to the feature', async () => { + const res = await graphqlRequest(query(feature), authToken).expect(200) + + const token = jwt.sign( + { + userid: loginUser.id, + feature_name: feature, + createdat: now.getTime(), + grantedat: now.getTime(), + }, + env.server.jwtSecret + ) + + expect(res.body.data.optInFeature).to.eql({ + feature: { + name: feature, + // set milliseconds to 000 + grantedAt: now.toISOString().replace(/\.\d{3}Z$/, '.000Z'), + token, + }, + }) + }) + }) + + context('when user is not the first 1000 users', () => { + before(async () => { + // create 1000 opt-in users + const usersToSave = Array.from(Array(1000).keys()).map((i) => { + return { + name: `user${i}`, + source: 'GOOGLE', + sourceUserId: `fake-user-id-user${i}`, + email: `user${i}@omnivore.app`, + username: `user${i}`, + bio: `i am user${i}`, + } + }) + + const users = await getRepository(User).save(usersToSave) + + const features = users.map((user) => { + return { + user: { id: user.id }, + name: feature, + grantedAt: now, + } + }) + + await getRepository(Feature).save(features) + }) + + after(async () => { + // reset opt-in users + await getRepository(User).delete({ + name: Like(`user%`), + }) + await getRepository(Feature).delete({ + name: feature, + }) + }) + + it('does not opt in to the feature', async () => { + const res = await graphqlRequest(query(feature), authToken).expect(200) + + const token = jwt.sign( + { + userid: loginUser.id, + feature_name: feature, + createdat: now.getTime(), + grantedat: now.getTime(), + }, + env.server.jwtSecret + ) + + expect(res.body.data.optInFeature).to.eql({ + feature: { + name: feature, + grantedAt: null, + token, + }, + }) + }) + }) + + context('when user is already opted in', () => { + before(async () => { + // opt in + await getRepository(Feature).save({ + user: { id: loginUser.id }, + name: feature, + grantedAt: new Date(), + }) + }) + + after(async () => { + // reset feature + await getRepository(Feature).delete({ + user: { id: loginUser.id }, + }) + }) + + it('returns the feature', async () => { + const res = await graphqlRequest(query(feature), authToken).expect(200) + + const token = jwt.sign( + { + userid: loginUser.id, + feature_name: feature, + createdat: now.getTime(), + grantedat: now.getTime(), + }, + env.server.jwtSecret + ) + + expect(res.body.data.optInFeature).to.eql({ + feature: { + name: feature, + grantedAt: now.toISOString().replace(/\.\d{3}Z$/, '.000Z'), + token, + }, + }) + }) + }) + }) +}) diff --git a/packages/db/migrations/0098.do.create_features_table.sql b/packages/db/migrations/0098.do.create_features_table.sql index 600145299..9a5be9b24 100755 --- a/packages/db/migrations/0098.do.create_features_table.sql +++ b/packages/db/migrations/0098.do.create_features_table.sql @@ -9,7 +9,7 @@ CREATE TABLE IF NOT EXISTS omnivore.features ( user_id uuid NOT NULL REFERENCES omnivore.user ON DELETE CASCADE, name text NOT NULL, granted_at timestamptz, - expired_at timestamptz, + expires_at timestamptz, created_at timestamptz NOT NULL DEFAULT current_timestamp, updated_at timestamptz NOT NULL DEFAULT current_timestamp, UNIQUE (user_id, name) From c89ffdeaf97cabebc2dc6852c136566f2ef96c47 Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Wed, 9 Nov 2022 18:14:15 +0800 Subject: [PATCH 23/43] Fix some tests --- packages/api/src/resolvers/features/index.ts | 1 + packages/api/src/services/features.ts | 14 +++++++---- packages/api/test/resolvers/features.test.ts | 26 +++++++++++--------- 3 files changed, 24 insertions(+), 17 deletions(-) diff --git a/packages/api/src/resolvers/features/index.ts b/packages/api/src/resolvers/features/index.ts index cb879d71f..4adc42257 100644 --- a/packages/api/src/resolvers/features/index.ts +++ b/packages/api/src/resolvers/features/index.ts @@ -41,6 +41,7 @@ export const optInFeatureResolver = authorized< } const token = signFeatureToken(optIn) + console.log('token', token) return { feature: { diff --git a/packages/api/src/services/features.ts b/packages/api/src/services/features.ts index 9afe0e2de..5d2959023 100644 --- a/packages/api/src/services/features.ts +++ b/packages/api/src/services/features.ts @@ -24,9 +24,12 @@ export const optInFeature = async ( } const optInUltraRealisticVoice = async (uid: string): Promise => { - const feature = await getRepository(Feature).findOneBy({ - user: { id: uid }, - name: FeatureName.UltraRealisticVoice, + const feature = await getRepository(Feature).findOne({ + where: { + user: { id: uid }, + name: FeatureName.UltraRealisticVoice, + }, + relations: ['user'], }) if (feature) { // already opted in @@ -54,13 +57,14 @@ const optInUltraRealisticVoice = async (uid: string): Promise => { } export const signFeatureToken = (feature: Feature): string => { + console.log('signing token', feature) return jwt.sign( { userid: feature.user.id, feature_name: feature.name, createdat: feature.createdAt.getTime(), - expiresat: feature.expiresAt?.getTime(), - grantedat: feature.grantedAt?.getTime(), + expiresat: feature.expiresAt?.getTime() || null, + grantedat: feature.grantedAt?.getTime() || null, }, env.server.jwtSecret ) diff --git a/packages/api/test/resolvers/features.test.ts b/packages/api/test/resolvers/features.test.ts index 49250d074..6c2348477 100644 --- a/packages/api/test/resolvers/features.test.ts +++ b/packages/api/test/resolvers/features.test.ts @@ -53,8 +53,8 @@ describe('features resolvers', () => { ` beforeEach(() => { - // mock date - clock = sinon.useFakeTimers(now.getTime()) + // mock date and ignore milliseconds + clock = sinon.useFakeTimers(now.setSeconds(now.getSeconds(), 0)) }) afterEach(() => { @@ -76,8 +76,9 @@ describe('features resolvers', () => { { userid: loginUser.id, feature_name: feature, - createdat: now.getTime(), - grantedat: now.getTime(), + createdat: Date.now(), + expiresat: null, + grantedat: Date.now(), }, env.server.jwtSecret ) @@ -85,8 +86,7 @@ describe('features resolvers', () => { expect(res.body.data.optInFeature).to.eql({ feature: { name: feature, - // set milliseconds to 000 - grantedAt: now.toISOString().replace(/\.\d{3}Z$/, '.000Z'), + grantedAt: new Date().toISOString(), token, }, }) @@ -113,7 +113,7 @@ describe('features resolvers', () => { return { user: { id: user.id }, name: feature, - grantedAt: now, + grantedAt: new Date(), } }) @@ -137,8 +137,9 @@ describe('features resolvers', () => { { userid: loginUser.id, feature_name: feature, - createdat: now.getTime(), - grantedat: now.getTime(), + createdat: Date.now(), + expiresat: null, + grantedat: null, }, env.server.jwtSecret ) @@ -177,8 +178,9 @@ describe('features resolvers', () => { { userid: loginUser.id, feature_name: feature, - createdat: now.getTime(), - grantedat: now.getTime(), + createdat: Date.now(), + expiresat: null, + grantedat: Date.now(), }, env.server.jwtSecret ) @@ -186,7 +188,7 @@ describe('features resolvers', () => { expect(res.body.data.optInFeature).to.eql({ feature: { name: feature, - grantedAt: now.toISOString().replace(/\.\d{3}Z$/, '.000Z'), + grantedAt: new Date().toISOString(), token, }, }) From 399e31534d51d51bac641b4b6f5cd2449a39fe36 Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Wed, 9 Nov 2022 22:48:33 +0800 Subject: [PATCH 24/43] Update jwt token --- packages/api/src/resolvers/features/index.ts | 1 - packages/api/src/services/features.ts | 12 ++-- packages/api/test/resolvers/features.test.ts | 64 +++++++++++--------- 3 files changed, 39 insertions(+), 38 deletions(-) diff --git a/packages/api/src/resolvers/features/index.ts b/packages/api/src/resolvers/features/index.ts index 4adc42257..cb879d71f 100644 --- a/packages/api/src/resolvers/features/index.ts +++ b/packages/api/src/resolvers/features/index.ts @@ -41,7 +41,6 @@ export const optInFeatureResolver = authorized< } const token = signFeatureToken(optIn) - console.log('token', token) return { feature: { diff --git a/packages/api/src/services/features.ts b/packages/api/src/services/features.ts index 5d2959023..ee96064b2 100644 --- a/packages/api/src/services/features.ts +++ b/packages/api/src/services/features.ts @@ -57,15 +57,13 @@ const optInUltraRealisticVoice = async (uid: string): Promise => { } export const signFeatureToken = (feature: Feature): string => { - console.log('signing token', feature) return jwt.sign( { - userid: feature.user.id, - feature_name: feature.name, - createdat: feature.createdAt.getTime(), - expiresat: feature.expiresAt?.getTime() || null, - grantedat: feature.grantedAt?.getTime() || null, + uid: feature.user.id, + featureName: feature.name, + grantedAt: feature.grantedAt ? feature.grantedAt.getTime() / 1000 : null, }, - env.server.jwtSecret + env.server.jwtSecret, + { expiresIn: '1d' } ) } diff --git a/packages/api/test/resolvers/features.test.ts b/packages/api/test/resolvers/features.test.ts index 6c2348477..5a60d2da5 100644 --- a/packages/api/test/resolvers/features.test.ts +++ b/packages/api/test/resolvers/features.test.ts @@ -29,7 +29,7 @@ describe('features resolvers', () => { }) describe('optInFeature API', () => { - const feature = 'ultra-realistic-voice' + const featureName = 'ultra-realistic-voice' const now = new Date() let clock: SinonFakeTimers @@ -52,12 +52,13 @@ describe('features resolvers', () => { } ` - beforeEach(() => { + before(() => { + console.log('opting in to feature') // mock date and ignore milliseconds clock = sinon.useFakeTimers(now.setSeconds(now.getSeconds(), 0)) }) - afterEach(() => { + after(() => { clock.restore() }) @@ -70,22 +71,23 @@ describe('features resolvers', () => { }) it('opts in to the feature', async () => { - const res = await graphqlRequest(query(feature), authToken).expect(200) + const res = await graphqlRequest(query(featureName), authToken).expect( + 200 + ) const token = jwt.sign( { - userid: loginUser.id, - feature_name: feature, - createdat: Date.now(), - expiresat: null, - grantedat: Date.now(), + uid: loginUser.id, + featureName, + grantedAt: Date.now() / 1000, }, - env.server.jwtSecret + env.server.jwtSecret, + { expiresIn: '1d' } ) expect(res.body.data.optInFeature).to.eql({ feature: { - name: feature, + name: featureName, grantedAt: new Date().toISOString(), token, }, @@ -112,7 +114,7 @@ describe('features resolvers', () => { const features = users.map((user) => { return { user: { id: user.id }, - name: feature, + name: featureName, grantedAt: new Date(), } }) @@ -126,27 +128,28 @@ describe('features resolvers', () => { name: Like(`user%`), }) await getRepository(Feature).delete({ - name: feature, + name: featureName, }) }) it('does not opt in to the feature', async () => { - const res = await graphqlRequest(query(feature), authToken).expect(200) + const res = await graphqlRequest(query(featureName), authToken).expect( + 200 + ) const token = jwt.sign( { - userid: loginUser.id, - feature_name: feature, - createdat: Date.now(), - expiresat: null, - grantedat: null, + uid: loginUser.id, + featureName, + grantedAt: null, }, - env.server.jwtSecret + env.server.jwtSecret, + { expiresIn: '1d' } ) expect(res.body.data.optInFeature).to.eql({ feature: { - name: feature, + name: featureName, grantedAt: null, token, }, @@ -159,7 +162,7 @@ describe('features resolvers', () => { // opt in await getRepository(Feature).save({ user: { id: loginUser.id }, - name: feature, + name: featureName, grantedAt: new Date(), }) }) @@ -172,22 +175,23 @@ describe('features resolvers', () => { }) it('returns the feature', async () => { - const res = await graphqlRequest(query(feature), authToken).expect(200) + const res = await graphqlRequest(query(featureName), authToken).expect( + 200 + ) const token = jwt.sign( { - userid: loginUser.id, - feature_name: feature, - createdat: Date.now(), - expiresat: null, - grantedat: Date.now(), + uid: loginUser.id, + featureName, + grantedAt: Date.now() / 1000, }, - env.server.jwtSecret + env.server.jwtSecret, + { expiresIn: '1d' } ) expect(res.body.data.optInFeature).to.eql({ feature: { - name: feature, + name: featureName, grantedAt: new Date().toISOString(), token, }, From 751699ab536731e8fa381fa1e352b4047b916b1e Mon Sep 17 00:00:00 2001 From: Satindar Dhillon Date: Wed, 9 Nov 2022 20:17:39 -0800 Subject: [PATCH 25/43] update swift gql schema --- .../Services/DataService/GQLSchema.swift | 867 ++++++++++++++++++ 1 file changed, 867 insertions(+) diff --git a/apple/OmnivoreKit/Sources/Services/DataService/GQLSchema.swift b/apple/OmnivoreKit/Sources/Services/DataService/GQLSchema.swift index 1876683b6..f82d4a6fe 100644 --- a/apple/OmnivoreKit/Sources/Services/DataService/GQLSchema.swift +++ b/apple/OmnivoreKit/Sources/Services/DataService/GQLSchema.swift @@ -4673,6 +4673,209 @@ extension Selection where TypeLock == Never, Type == Never { typealias DeviceToken = Selection } +extension Objects { + struct Feature { + let __typename: TypeName = .feature + let createdAt: [String: DateTime] + let expiresAt: [String: DateTime] + let grantedAt: [String: DateTime] + let id: [String: String] + let name: [String: String] + let token: [String: String] + let updatedAt: [String: DateTime] + + enum TypeName: String, Codable { + case feature = "Feature" + } + } +} + +extension Objects.Feature: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKeys.self) + + var map = HashMap() + for codingKey in container.allKeys { + if codingKey.isTypenameKey { continue } + + let alias = codingKey.stringValue + let field = GraphQLField.getFieldNameFromAlias(alias) + + switch field { + case "createdAt": + if let value = try container.decode(DateTime?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "expiresAt": + if let value = try container.decode(DateTime?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "grantedAt": + if let value = try container.decode(DateTime?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "id": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "name": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "token": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "updatedAt": + if let value = try container.decode(DateTime?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key \(field)." + ) + ) + } + } + + createdAt = map["createdAt"] + expiresAt = map["expiresAt"] + grantedAt = map["grantedAt"] + id = map["id"] + name = map["name"] + token = map["token"] + updatedAt = map["updatedAt"] + } +} + +extension Fields where TypeLock == Objects.Feature { + func createdAt() throws -> DateTime { + let field = GraphQLField.leaf( + name: "createdAt", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.createdAt[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return DateTime.mockValue + } + } + + func expiresAt() throws -> DateTime? { + let field = GraphQLField.leaf( + name: "expiresAt", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + return data.expiresAt[field.alias!] + case .mocking: + return nil + } + } + + func grantedAt() throws -> DateTime? { + let field = GraphQLField.leaf( + name: "grantedAt", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + return data.grantedAt[field.alias!] + case .mocking: + return nil + } + } + + func id() throws -> String { + let field = GraphQLField.leaf( + name: "id", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.id[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return String.mockValue + } + } + + func name() throws -> String { + let field = GraphQLField.leaf( + name: "name", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.name[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return String.mockValue + } + } + + func token() throws -> String { + let field = GraphQLField.leaf( + name: "token", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.token[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return String.mockValue + } + } + + func updatedAt() throws -> DateTime { + let field = GraphQLField.leaf( + name: "updatedAt", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.updatedAt[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return DateTime.mockValue + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias Feature = Selection +} + extension Objects { struct FeedArticle { let __typename: TypeName = .feedArticle @@ -5847,6 +6050,8 @@ extension Objects { let annotation: [String: String] let createdAt: [String: DateTime] let createdByMe: [String: Bool] + let highlightPositionAnchorIndex: [String: Int] + let highlightPositionPercent: [String: Double] let id: [String: String] let patch: [String: String] let prefix: [String: String] @@ -5889,6 +6094,14 @@ extension Objects.Highlight: Decodable { if let value = try container.decode(Bool?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) } + case "highlightPositionAnchorIndex": + if let value = try container.decode(Int?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "highlightPositionPercent": + if let value = try container.decode(Double?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } case "id": if let value = try container.decode(String?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) @@ -5946,6 +6159,8 @@ extension Objects.Highlight: Decodable { annotation = map["annotation"] createdAt = map["createdAt"] createdByMe = map["createdByMe"] + highlightPositionAnchorIndex = map["highlightPositionAnchorIndex"] + highlightPositionPercent = map["highlightPositionPercent"] id = map["id"] patch = map["patch"] prefix = map["prefix"] @@ -6012,6 +6227,36 @@ extension Fields where TypeLock == Objects.Highlight { } } + func highlightPositionAnchorIndex() throws -> Int? { + let field = GraphQLField.leaf( + name: "highlightPositionAnchorIndex", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + return data.highlightPositionAnchorIndex[field.alias!] + case .mocking: + return nil + } + } + + func highlightPositionPercent() throws -> Double? { + let field = GraphQLField.leaf( + name: "highlightPositionPercent", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + return data.highlightPositionPercent[field.alias!] + case .mocking: + return nil + } + } + func id() throws -> String { let field = GraphQLField.leaf( name: "id", @@ -8127,6 +8372,7 @@ extension Objects { let logOut: [String: Unions.LogOutResult] let mergeHighlight: [String: Unions.MergeHighlightResult] let moveLabel: [String: Unions.MoveLabelResult] + let optInFeature: [String: Unions.OptInFeatureResult] let reportItem: [String: Objects.ReportItemResult] let revokeApiKey: [String: Unions.RevokeApiKeyResult] let saveArticleReadingProgress: [String: Unions.SaveArticleReadingProgressResult] @@ -8271,6 +8517,10 @@ extension Objects.Mutation: Decodable { if let value = try container.decode(Unions.MoveLabelResult?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) } + case "optInFeature": + if let value = try container.decode(Unions.OptInFeatureResult?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } case "reportItem": if let value = try container.decode(Objects.ReportItemResult?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) @@ -8421,6 +8671,7 @@ extension Objects.Mutation: Decodable { logOut = map["logOut"] mergeHighlight = map["mergeHighlight"] moveLabel = map["moveLabel"] + optInFeature = map["optInFeature"] reportItem = map["reportItem"] revokeApiKey = map["revokeApiKey"] saveArticleReadingProgress = map["saveArticleReadingProgress"] @@ -8910,6 +9161,25 @@ extension Fields where TypeLock == Objects.Mutation { } } + func optInFeature(input: InputObjects.OptInFeatureInput, selection: Selection) throws -> Type { + let field = GraphQLField.composite( + name: "optInFeature", + arguments: [Argument(name: "input", type: "OptInFeatureInput!", value: input)], + selection: selection.selection + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.optInFeature[field.alias!] { + return try selection.decode(data: data) + } + throw HttpError.badpayload + case .mocking: + return selection.mock() + } + } + func reportItem(input: InputObjects.ReportItemInput, selection: Selection) throws -> Type { let field = GraphQLField.composite( name: "reportItem", @@ -9707,6 +9977,137 @@ extension Selection where TypeLock == Never, Type == Never { typealias NewsletterEmailsSuccess = Selection } +extension Objects { + struct OptInFeatureError { + let __typename: TypeName = .optInFeatureError + let errorCodes: [String: [Enums.OptInFeatureErrorCode]] + + enum TypeName: String, Codable { + case optInFeatureError = "OptInFeatureError" + } + } +} + +extension Objects.OptInFeatureError: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKeys.self) + + var map = HashMap() + for codingKey in container.allKeys { + if codingKey.isTypenameKey { continue } + + let alias = codingKey.stringValue + let field = GraphQLField.getFieldNameFromAlias(alias) + + switch field { + case "errorCodes": + if let value = try container.decode([Enums.OptInFeatureErrorCode]?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key \(field)." + ) + ) + } + } + + errorCodes = map["errorCodes"] + } +} + +extension Fields where TypeLock == Objects.OptInFeatureError { + func errorCodes() throws -> [Enums.OptInFeatureErrorCode] { + let field = GraphQLField.leaf( + name: "errorCodes", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.errorCodes[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return [] + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias OptInFeatureError = Selection +} + +extension Objects { + struct OptInFeatureSuccess { + let __typename: TypeName = .optInFeatureSuccess + let feature: [String: Objects.Feature] + + enum TypeName: String, Codable { + case optInFeatureSuccess = "OptInFeatureSuccess" + } + } +} + +extension Objects.OptInFeatureSuccess: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKeys.self) + + var map = HashMap() + for codingKey in container.allKeys { + if codingKey.isTypenameKey { continue } + + let alias = codingKey.stringValue + let field = GraphQLField.getFieldNameFromAlias(alias) + + switch field { + case "feature": + if let value = try container.decode(Objects.Feature?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key \(field)." + ) + ) + } + } + + feature = map["feature"] + } +} + +extension Fields where TypeLock == Objects.OptInFeatureSuccess { + func feature(selection: Selection) throws -> Type { + let field = GraphQLField.composite( + name: "feature", + arguments: [], + selection: selection.selection + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.feature[field.alias!] { + return try selection.decode(data: data) + } + throw HttpError.badpayload + case .mocking: + return selection.mock() + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias OptInFeatureSuccess = Selection +} + extension Objects { struct Page { let __typename: TypeName = .page @@ -10398,6 +10799,7 @@ extension Objects { let labels: [String: Unions.LabelsResult] let me: [String: Objects.User] let newsletterEmails: [String: Unions.NewsletterEmailsResult] + let recentSearches: [String: Unions.RecentSearchesResult] let reminder: [String: Unions.ReminderResult] let search: [String: Unions.SearchResult] let sendInstallInstructions: [String: Unions.SendInstallInstructionsResult] @@ -10481,6 +10883,10 @@ extension Objects.Query: Decodable { if let value = try container.decode(Unions.NewsletterEmailsResult?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) } + case "recentSearches": + if let value = try container.decode(Unions.RecentSearchesResult?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } case "reminder": if let value = try container.decode(Unions.ReminderResult?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) @@ -10552,6 +10958,7 @@ extension Objects.Query: Decodable { labels = map["labels"] me = map["me"] newsletterEmails = map["newsletterEmails"] + recentSearches = map["recentSearches"] reminder = map["reminder"] search = map["search"] sendInstallInstructions = map["sendInstallInstructions"] @@ -10808,6 +11215,25 @@ extension Fields where TypeLock == Objects.Query { } } + func recentSearches(selection: Selection) throws -> Type { + let field = GraphQLField.composite( + name: "recentSearches", + arguments: [], + selection: selection.selection + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.recentSearches[field.alias!] { + return try selection.decode(data: data) + } + throw HttpError.badpayload + case .mocking: + return selection.mock() + } + } + func reminder(linkId: String, selection: Selection) throws -> Type { let field = GraphQLField.composite( name: "reminder", @@ -11330,6 +11756,250 @@ extension Selection where TypeLock == Never, Type == Never { typealias ReadState = Selection } +extension Objects { + struct RecentSearch { + let __typename: TypeName = .recentSearch + let createdAt: [String: DateTime] + let id: [String: String] + let term: [String: String] + + enum TypeName: String, Codable { + case recentSearch = "RecentSearch" + } + } +} + +extension Objects.RecentSearch: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKeys.self) + + var map = HashMap() + for codingKey in container.allKeys { + if codingKey.isTypenameKey { continue } + + let alias = codingKey.stringValue + let field = GraphQLField.getFieldNameFromAlias(alias) + + switch field { + case "createdAt": + if let value = try container.decode(DateTime?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "id": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "term": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key \(field)." + ) + ) + } + } + + createdAt = map["createdAt"] + id = map["id"] + term = map["term"] + } +} + +extension Fields where TypeLock == Objects.RecentSearch { + func createdAt() throws -> DateTime { + let field = GraphQLField.leaf( + name: "createdAt", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.createdAt[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return DateTime.mockValue + } + } + + func id() throws -> String { + let field = GraphQLField.leaf( + name: "id", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.id[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return String.mockValue + } + } + + func term() throws -> String { + let field = GraphQLField.leaf( + name: "term", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.term[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return String.mockValue + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias RecentSearch = Selection +} + +extension Objects { + struct RecentSearchesError { + let __typename: TypeName = .recentSearchesError + let errorCodes: [String: [Enums.RecentSearchesErrorCode]] + + enum TypeName: String, Codable { + case recentSearchesError = "RecentSearchesError" + } + } +} + +extension Objects.RecentSearchesError: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKeys.self) + + var map = HashMap() + for codingKey in container.allKeys { + if codingKey.isTypenameKey { continue } + + let alias = codingKey.stringValue + let field = GraphQLField.getFieldNameFromAlias(alias) + + switch field { + case "errorCodes": + if let value = try container.decode([Enums.RecentSearchesErrorCode]?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key \(field)." + ) + ) + } + } + + errorCodes = map["errorCodes"] + } +} + +extension Fields where TypeLock == Objects.RecentSearchesError { + func errorCodes() throws -> [Enums.RecentSearchesErrorCode] { + let field = GraphQLField.leaf( + name: "errorCodes", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.errorCodes[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return [] + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias RecentSearchesError = Selection +} + +extension Objects { + struct RecentSearchesSuccess { + let __typename: TypeName = .recentSearchesSuccess + let searches: [String: [Objects.RecentSearch]] + + enum TypeName: String, Codable { + case recentSearchesSuccess = "RecentSearchesSuccess" + } + } +} + +extension Objects.RecentSearchesSuccess: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKeys.self) + + var map = HashMap() + for codingKey in container.allKeys { + if codingKey.isTypenameKey { continue } + + let alias = codingKey.stringValue + let field = GraphQLField.getFieldNameFromAlias(alias) + + switch field { + case "searches": + if let value = try container.decode([Objects.RecentSearch]?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key \(field)." + ) + ) + } + } + + searches = map["searches"] + } +} + +extension Fields where TypeLock == Objects.RecentSearchesSuccess { + func searches(selection: Selection) throws -> Type { + let field = GraphQLField.composite( + name: "searches", + arguments: [], + selection: selection.selection + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.searches[field.alias!] { + return try selection.decode(data: data) + } + throw HttpError.badpayload + case .mocking: + return selection.mock() + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias RecentSearchesSuccess = Selection +} + extension Objects { struct Reminder { let __typename: TypeName = .reminder @@ -21325,6 +21995,154 @@ extension Selection where TypeLock == Never, Type == Never { typealias NewsletterEmailsResult = Selection } +extension Unions { + struct OptInFeatureResult { + let __typename: TypeName + let errorCodes: [String: [Enums.OptInFeatureErrorCode]] + let feature: [String: Objects.Feature] + + enum TypeName: String, Codable { + case optInFeatureError = "OptInFeatureError" + case optInFeatureSuccess = "OptInFeatureSuccess" + } + } +} + +extension Unions.OptInFeatureResult: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKeys.self) + + var map = HashMap() + for codingKey in container.allKeys { + if codingKey.isTypenameKey { continue } + + let alias = codingKey.stringValue + let field = GraphQLField.getFieldNameFromAlias(alias) + + switch field { + case "errorCodes": + if let value = try container.decode([Enums.OptInFeatureErrorCode]?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "feature": + if let value = try container.decode(Objects.Feature?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key \(field)." + ) + ) + } + } + + __typename = try container.decode(TypeName.self, forKey: DynamicCodingKeys(stringValue: "__typename")!) + + errorCodes = map["errorCodes"] + feature = map["feature"] + } +} + +extension Fields where TypeLock == Unions.OptInFeatureResult { + func on(optInFeatureError: Selection, optInFeatureSuccess: Selection) throws -> Type { + select([GraphQLField.fragment(type: "OptInFeatureError", selection: optInFeatureError.selection), GraphQLField.fragment(type: "OptInFeatureSuccess", selection: optInFeatureSuccess.selection)]) + + switch response { + case let .decoding(data): + switch data.__typename { + case .optInFeatureError: + let data = Objects.OptInFeatureError(errorCodes: data.errorCodes) + return try optInFeatureError.decode(data: data) + case .optInFeatureSuccess: + let data = Objects.OptInFeatureSuccess(feature: data.feature) + return try optInFeatureSuccess.decode(data: data) + } + case .mocking: + return optInFeatureError.mock() + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias OptInFeatureResult = Selection +} + +extension Unions { + struct RecentSearchesResult { + let __typename: TypeName + let errorCodes: [String: [Enums.RecentSearchesErrorCode]] + let searches: [String: [Objects.RecentSearch]] + + enum TypeName: String, Codable { + case recentSearchesError = "RecentSearchesError" + case recentSearchesSuccess = "RecentSearchesSuccess" + } + } +} + +extension Unions.RecentSearchesResult: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKeys.self) + + var map = HashMap() + for codingKey in container.allKeys { + if codingKey.isTypenameKey { continue } + + let alias = codingKey.stringValue + let field = GraphQLField.getFieldNameFromAlias(alias) + + switch field { + case "errorCodes": + if let value = try container.decode([Enums.RecentSearchesErrorCode]?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "searches": + if let value = try container.decode([Objects.RecentSearch]?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key \(field)." + ) + ) + } + } + + __typename = try container.decode(TypeName.self, forKey: DynamicCodingKeys(stringValue: "__typename")!) + + errorCodes = map["errorCodes"] + searches = map["searches"] + } +} + +extension Fields where TypeLock == Unions.RecentSearchesResult { + func on(recentSearchesError: Selection, recentSearchesSuccess: Selection) throws -> Type { + select([GraphQLField.fragment(type: "RecentSearchesError", selection: recentSearchesError.selection), GraphQLField.fragment(type: "RecentSearchesSuccess", selection: recentSearchesSuccess.selection)]) + + switch response { + case let .decoding(data): + switch data.__typename { + case .recentSearchesError: + let data = Objects.RecentSearchesError(errorCodes: data.errorCodes) + return try recentSearchesError.decode(data: data) + case .recentSearchesSuccess: + let data = Objects.RecentSearchesSuccess(searches: data.searches) + return try recentSearchesSuccess.decode(data: data) + } + case .mocking: + return recentSearchesError.mock() + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias RecentSearchesResult = Selection +} + extension Unions { struct ReminderResult { let __typename: TypeName @@ -24384,6 +25202,15 @@ extension Enums { } } +extension Enums { + /// OptInFeatureErrorCode + enum OptInFeatureErrorCode: String, CaseIterable, Codable { + case badRequest = "BAD_REQUEST" + + case notFound = "NOT_FOUND" + } +} + extension Enums { /// PageType enum PageType: String, CaseIterable, Codable { @@ -24420,6 +25247,15 @@ extension Enums { } } +extension Enums { + /// RecentSearchesErrorCode + enum RecentSearchesErrorCode: String, CaseIterable, Codable { + case badRequest = "BAD_REQUEST" + + case unauthorized = "UNAUTHORIZED" + } +} + extension Enums { /// ReminderErrorCode enum ReminderErrorCode: String, CaseIterable, Codable { @@ -24996,6 +25832,10 @@ extension InputObjects { var articleId: String + var highlightPositionAnchorIndex: OptionalArgument = .absent() + + var highlightPositionPercent: OptionalArgument = .absent() + var id: String var patch: String @@ -25014,6 +25854,8 @@ extension InputObjects { var container = encoder.container(keyedBy: CodingKeys.self) if annotation.hasValue { try container.encode(annotation, forKey: .annotation) } try container.encode(articleId, forKey: .articleId) + if highlightPositionAnchorIndex.hasValue { try container.encode(highlightPositionAnchorIndex, forKey: .highlightPositionAnchorIndex) } + if highlightPositionPercent.hasValue { try container.encode(highlightPositionPercent, forKey: .highlightPositionPercent) } try container.encode(id, forKey: .id) try container.encode(patch, forKey: .patch) if prefix.hasValue { try container.encode(prefix, forKey: .prefix) } @@ -25026,6 +25868,8 @@ extension InputObjects { enum CodingKeys: String, CodingKey { case annotation case articleId + case highlightPositionAnchorIndex + case highlightPositionPercent case id case patch case prefix @@ -25220,6 +26064,10 @@ extension InputObjects { var articleId: String + var highlightPositionAnchorIndex: OptionalArgument = .absent() + + var highlightPositionPercent: OptionalArgument = .absent() + var id: String var overlapHighlightIdList: [String] @@ -25238,6 +26086,8 @@ extension InputObjects { var container = encoder.container(keyedBy: CodingKeys.self) if annotation.hasValue { try container.encode(annotation, forKey: .annotation) } try container.encode(articleId, forKey: .articleId) + if highlightPositionAnchorIndex.hasValue { try container.encode(highlightPositionAnchorIndex, forKey: .highlightPositionAnchorIndex) } + if highlightPositionPercent.hasValue { try container.encode(highlightPositionPercent, forKey: .highlightPositionPercent) } try container.encode(id, forKey: .id) try container.encode(overlapHighlightIdList, forKey: .overlapHighlightIdList) try container.encode(patch, forKey: .patch) @@ -25250,6 +26100,8 @@ extension InputObjects { enum CodingKeys: String, CodingKey { case annotation case articleId + case highlightPositionAnchorIndex + case highlightPositionPercent case id case overlapHighlightIdList case patch @@ -25280,6 +26132,21 @@ extension InputObjects { } } +extension InputObjects { + struct OptInFeatureInput: Encodable, Hashable { + var name: String + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(name, forKey: .name) + } + + enum CodingKeys: String, CodingKey { + case name + } + } +} + extension InputObjects { struct PageInfoInput: Encodable, Hashable { var author: OptionalArgument = .absent() From b9c5f02fe336ff9ba0bee58665c915cebb4729f4 Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Thu, 10 Nov 2022 17:03:22 +0800 Subject: [PATCH 26/43] Archive audio file and speech marks file in GCS --- packages/text-to-speech/src/index.ts | 36 +++++++++++++- .../src/realisticTextToSpeech.ts | 47 ++++++++----------- 2 files changed, 55 insertions(+), 28 deletions(-) diff --git a/packages/text-to-speech/src/index.ts b/packages/text-to-speech/src/index.ts index 7bd73a448..efd17a700 100644 --- a/packages/text-to-speech/src/index.ts +++ b/packages/text-to-speech/src/index.ts @@ -279,16 +279,50 @@ export const textToSpeechStreamingHandler = Sentry.GCPFunction.wrapHttpFunction( return } console.log('Cache miss') - // synthesize text to speech if cache miss + + const bucket = process.env.GCS_UPLOAD_BUCKET + if (!bucket) { + throw new Error('GCS_UPLOAD_BUCKET not set') + } + + // audio file to be saved in GCS + const audioFileName = `speech/${cacheKey}.mp3` + const speechMarksFileName = `speech/${cacheKey}.json` + const audioFile = createGCSFile(bucket, audioFileName) + const speechMarksFile = createGCSFile(bucket, speechMarksFileName) + // check if audio file already exists + const [exists] = await audioFile.exists() + if (exists) { + console.debug('Audio file already exists') + const [audioData] = await audioFile.download() + const [speechMarksExists] = await speechMarksFile.exists() + + return { + audioData, + speechMarks: speechMarksExists + ? JSON.parse((await speechMarksFile.download()).toString()) + : [], + } + } + const input: TextToSpeechInput = { ...utteranceInput, textType: 'ssml', key: cacheKey, } + // synthesize text to speech if cache miss const { audioData, speechMarks } = await synthesizeTextToSpeech(input) if (!audioData) { return res.status(500).send({ errorCode: 'SYNTHESIZER_ERROR' }) } + + // upload audio data to GCS + await audioFile.save(audioData) + // upload speech marks to GCS + if (speechMarks.length > 0) { + await speechMarksFile.save(JSON.stringify(speechMarks)) + } + const audioDataString = audioData.toString('hex') // save audio data to cache for 24 hours for mainly the newsletters await redisClient.set( diff --git a/packages/text-to-speech/src/realisticTextToSpeech.ts b/packages/text-to-speech/src/realisticTextToSpeech.ts index 9e6451db0..94a0a9c7a 100644 --- a/packages/text-to-speech/src/realisticTextToSpeech.ts +++ b/packages/text-to-speech/src/realisticTextToSpeech.ts @@ -7,7 +7,6 @@ import axios from 'axios' import ffmpegPath from '@ffmpeg-installer/ffmpeg' import ffmpeg from 'fluent-ffmpeg' import { PassThrough } from 'stream' -import { createGCSFile } from './index' ffmpeg.setFfmpegPath(ffmpegPath.path) @@ -46,28 +45,6 @@ export class RealisticTextToSpeech implements TextToSpeech { throw new Error('PlayHT API credentials not set') } - const bucket = process.env.GCS_UPLOAD_BUCKET - if (!bucket) { - throw new Error('GCS_UPLOAD_BUCKET not set') - } - - // audio file to be saved in GCS - const audioFileName = `speech/${input.key}.mp3` - const audioFile = createGCSFile(bucket, audioFileName) - // check if audio file already exists - const [exists] = await audioFile.exists() - if (exists) { - console.debug('Audio file already exists') - const [audioData] = await audioFile.download() - return { - audioData, - speechMarks: [], - } - } - - const outputStream = audioFile.createWriteStream({ - resumable: true, - }) as PassThrough const inputStream = new PassThrough() const HEADERS = { @@ -100,8 +77,8 @@ export class RealisticTextToSpeech implements TextToSpeech { // timeout after 1 hour const timeout = 60 * 60 * 1000 const startTime = Date.now() - let audioData: Buffer | undefined - while (!audioData) { + let isReady = false + while (!isReady) { if (Date.now() - startTime > timeout) { throw new Error('Timeout when polling the download url') } @@ -117,17 +94,33 @@ export class RealisticTextToSpeech implements TextToSpeech { // write the audio file to the input stream // eslint-disable-next-line @typescript-eslint/no-unsafe-argument - audioData = Buffer.from(downloadResponse.data, 'binary') - inputStream.end(audioData) + inputStream.end(Buffer.from(downloadResponse.data, 'binary')) + isReady = true } catch (e) { // ignore error console.debug('checking status of audio file', downloadUrl) } } + const outputStream = new PassThrough() // transcode the audio file to mp3 await convertWavToMp3AndUpload(inputStream, outputStream) + // convert the buffer stream to a buffer + const audioData = await new Promise((resolve, reject) => { + const chunks: Buffer[] = [] + outputStream.on('data', (chunk) => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + chunks.push(chunk) + }) + outputStream.on('end', () => { + resolve(Buffer.concat(chunks)) + }) + outputStream.on('error', (err) => { + reject(err) + }) + }) + return { audioData, speechMarks: [], From 0f85071af7ff0f68b0cbec63b862e779186683b6 Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Thu, 10 Nov 2022 17:17:12 +0800 Subject: [PATCH 27/43] validate if user has opted in to use ultra realistic voice feature --- packages/text-to-speech/src/index.ts | 26 ++++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/packages/text-to-speech/src/index.ts b/packages/text-to-speech/src/index.ts index efd17a700..5953478b3 100644 --- a/packages/text-to-speech/src/index.ts +++ b/packages/text-to-speech/src/index.ts @@ -47,6 +47,12 @@ interface CacheResult { speechMarks: SpeechMark[] } +interface Claim { + uid: string + featureName: string | null + grantedAt: number | null +} + dotenv.config() Sentry.GCPFunction.init({ dsn: process.env.SENTRY_DSN, @@ -222,14 +228,10 @@ export const textToSpeechStreamingHandler = Sentry.GCPFunction.wrapHttpFunction( return res.status(401).send({ errorCode: 'INVALID_TOKEN' }) } - let uid: string + let claim: Claim try { jwt.verify(token, process.env.JWT_SECRET) - const claim = jwt.decode(token) as { uid: string } - uid = claim.uid - if (!uid) { - throw new Error('uid not exists') - } + claim = jwt.decode(token) as Claim } catch (e) { console.error('Authentication error:', e) return res.status(401).send({ errorCode: 'UNAUTHENTICATED' }) @@ -247,9 +249,17 @@ export const textToSpeechStreamingHandler = Sentry.GCPFunction.wrapHttpFunction( return res.status(400).send('INVALID_INPUT') } + // validate if user has opted in to use ultra realistic voice feature + if ( + utteranceInput.isUltraRealisticVoice && + (claim.featureName !== 'ultra-realistic-voice' || !claim.grantedAt) + ) { + return res.status(403).send('UNAUTHORIZED') + } + // validate character count const characterCount = - (await getCharacterCountFromRedis(redisClient, uid)) + + (await getCharacterCountFromRedis(redisClient, claim.uid)) + utteranceInput.text.length if (characterCount > MAX_CHARACTER_COUNT) { return res.status(429).send('RATE_LIMITED') @@ -336,7 +346,7 @@ export const textToSpeechStreamingHandler = Sentry.GCPFunction.wrapHttpFunction( console.log('Cache saved') // update character count - await updateCharacterCountInRedis(redisClient, uid, characterCount) + await updateCharacterCountInRedis(redisClient, claim.uid, characterCount) res.send({ idx: utteranceInput.idx, From 69d6b60adcf225783b86b52d64df3cdaba2270f2 Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Thu, 10 Nov 2022 17:42:25 +0800 Subject: [PATCH 28/43] Extend expiration of the jwt token to 1 year --- packages/api/src/services/features.ts | 2 +- packages/api/test/resolvers/features.test.ts | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/api/src/services/features.ts b/packages/api/src/services/features.ts index ee96064b2..b9dd19605 100644 --- a/packages/api/src/services/features.ts +++ b/packages/api/src/services/features.ts @@ -64,6 +64,6 @@ export const signFeatureToken = (feature: Feature): string => { grantedAt: feature.grantedAt ? feature.grantedAt.getTime() / 1000 : null, }, env.server.jwtSecret, - { expiresIn: '1d' } + { expiresIn: '1y' } ) } diff --git a/packages/api/test/resolvers/features.test.ts b/packages/api/test/resolvers/features.test.ts index 5a60d2da5..d19366dcd 100644 --- a/packages/api/test/resolvers/features.test.ts +++ b/packages/api/test/resolvers/features.test.ts @@ -82,7 +82,7 @@ describe('features resolvers', () => { grantedAt: Date.now() / 1000, }, env.server.jwtSecret, - { expiresIn: '1d' } + { expiresIn: '1y' } ) expect(res.body.data.optInFeature).to.eql({ @@ -144,7 +144,7 @@ describe('features resolvers', () => { grantedAt: null, }, env.server.jwtSecret, - { expiresIn: '1d' } + { expiresIn: '1y' } ) expect(res.body.data.optInFeature).to.eql({ @@ -186,7 +186,7 @@ describe('features resolvers', () => { grantedAt: Date.now() / 1000, }, env.server.jwtSecret, - { expiresIn: '1d' } + { expiresIn: '1y' } ) expect(res.body.data.optInFeature).to.eql({ From b0938ee96cb5a4668cb9b983a38ccc52ef859283 Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Thu, 10 Nov 2022 18:48:00 +0800 Subject: [PATCH 29/43] Pre-fetch realistic voice only if user is opted in --- packages/api/src/resolvers/features/index.ts | 2 +- packages/api/src/routers/text_to_speech.ts | 8 +++++ packages/api/src/services/features.ts | 35 ++++++++++++++++++-- packages/api/src/services/speech.ts | 3 +- packages/api/src/utils/createTask.ts | 11 +++--- 5 files changed, 49 insertions(+), 10 deletions(-) diff --git a/packages/api/src/resolvers/features/index.ts b/packages/api/src/resolvers/features/index.ts index cb879d71f..a5226ef48 100644 --- a/packages/api/src/resolvers/features/index.ts +++ b/packages/api/src/resolvers/features/index.ts @@ -40,7 +40,7 @@ export const optInFeatureResolver = authorized< } } - const token = signFeatureToken(optIn) + const token = signFeatureToken(optIn, claims.uid) return { feature: { diff --git a/packages/api/src/routers/text_to_speech.ts b/packages/api/src/routers/text_to_speech.ts index e0796a76d..4e89954f6 100644 --- a/packages/api/src/routers/text_to_speech.ts +++ b/packages/api/src/routers/text_to_speech.ts @@ -16,6 +16,7 @@ import { enqueueTextToSpeech } from '../utils/createTask' import { htmlToSpeechFile } from '@omnivore/text-to-speech-handler' import { UserPersonalization } from '../entity/user_personalization' import { ArticleSavingRequestStatus } from '../elastic/types' +import { FeatureName, getFeature } from '../services/features' const logger = buildLogger('app.dispatch') @@ -80,6 +81,11 @@ export function textToSpeechRouter() { }, }) + const feature = await getFeature( + FeatureName.UltraRealisticVoice, + userId + ) + for (const utterance of speechFile.utterances) { // enqueue a task to convert text to speech const taskName = await enqueueTextToSpeech({ @@ -91,6 +97,8 @@ export function textToSpeechRouter() { isUltraRealisticVoice: true, language: speechFile.language, rate: userPersonalization?.speechRate || '1.1', + featureName: feature?.name, + grantedAt: feature?.grantedAt, }) logger.info('Start Text to speech task', { taskName }) } diff --git a/packages/api/src/services/features.ts b/packages/api/src/services/features.ts index b9dd19605..99d53e194 100644 --- a/packages/api/src/services/features.ts +++ b/packages/api/src/services/features.ts @@ -4,7 +4,7 @@ import * as jwt from 'jsonwebtoken' import { env } from '../env' import { IsNull, Not } from 'typeorm' -enum FeatureName { +export enum FeatureName { UltraRealisticVoice = 'ultra-realistic-voice', } @@ -56,10 +56,16 @@ const optInUltraRealisticVoice = async (uid: string): Promise => { }) } -export const signFeatureToken = (feature: Feature): string => { +export const signFeatureToken = ( + feature: { + name?: string + grantedAt?: Date | null + }, + userId: string +): string => { return jwt.sign( { - uid: feature.user.id, + uid: userId, featureName: feature.name, grantedAt: feature.grantedAt ? feature.grantedAt.getTime() / 1000 : null, }, @@ -67,3 +73,26 @@ export const signFeatureToken = (feature: Feature): string => { { expiresIn: '1y' } ) } + +export const isOptedIn = async ( + name: FeatureName, + uid: string +): Promise => { + const feature = await getRepository(Feature).findOneBy({ + user: { id: uid }, + name, + grantedAt: Not(IsNull()), + }) + + return !!feature +} + +export const getFeature = async ( + name: FeatureName, + uid: string +): Promise => { + return getRepository(Feature).findOneBy({ + user: { id: uid }, + name, + }) +} diff --git a/packages/api/src/services/speech.ts b/packages/api/src/services/speech.ts index 380a14a36..784d56840 100644 --- a/packages/api/src/services/speech.ts +++ b/packages/api/src/services/speech.ts @@ -1,6 +1,7 @@ import { searchPages } from '../elastic/pages' import { Page, PageType } from '../elastic/types' import { SortBy, SortOrder } from '../utils/search' +import { FeatureName, isOptedIn } from './features' /* * We should not synthesize the page when: @@ -16,7 +17,7 @@ export const shouldSynthesize = async ( return false } - if (process.env.TEXT_TO_SPEECH_BETA_TEST) { + if (await isOptedIn(FeatureName.UltraRealisticVoice, userId)) { return true } diff --git a/packages/api/src/utils/createTask.ts b/packages/api/src/utils/createTask.ts index af9b0e3ad..51d18b592 100644 --- a/packages/api/src/utils/createTask.ts +++ b/packages/api/src/utils/createTask.ts @@ -11,6 +11,7 @@ import { google } from '@google-cloud/tasks/build/protos/protos' import { IntegrationType } from '../entity/integration' import { promisify } from 'util' import * as jwt from 'jsonwebtoken' +import { signFeatureToken } from '../services/features' import View = google.cloud.tasks.v2.Task.View const logger = buildLogger('app.dispatch') @@ -347,6 +348,8 @@ export const enqueueTextToSpeech = async ({ isUltraRealisticVoice = false, language, rate, + featureName, + grantedAt, }: { userId: string speechId: string @@ -360,6 +363,8 @@ export const enqueueTextToSpeech = async ({ isUltraRealisticVoice?: boolean language?: string rate?: string + featureName?: string + grantedAt?: Date | null }): Promise => { const { GOOGLE_CLOUD_PROJECT } = process.env const payload = { @@ -372,11 +377,7 @@ export const enqueueTextToSpeech = async ({ language, rate, } - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - const token = await signToken({ uid: userId }, env.server.jwtSecret, { - expiresIn: '1h', - }) + const token = signFeatureToken({ name: featureName, grantedAt }, userId) const taskHandlerUrl = `${env.queue.textToSpeechTaskHandlerUrl}?token=${token}` // If there is no Google Cloud Project Id exposed, it means that we are in local environment if (env.dev.isLocal || !GOOGLE_CLOUD_PROJECT) { From d58fb63c87414835b3ecd03193e2b37ceb7568d6 Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Thu, 10 Nov 2022 18:49:52 +0800 Subject: [PATCH 30/43] Pre-fetch realistic voice only if user is opted in --- packages/api/src/services/speech.ts | 34 ++--------------------------- 1 file changed, 2 insertions(+), 32 deletions(-) diff --git a/packages/api/src/services/speech.ts b/packages/api/src/services/speech.ts index 784d56840..c23dbb784 100644 --- a/packages/api/src/services/speech.ts +++ b/packages/api/src/services/speech.ts @@ -1,12 +1,8 @@ -import { searchPages } from '../elastic/pages' import { Page, PageType } from '../elastic/types' -import { SortBy, SortOrder } from '../utils/search' import { FeatureName, isOptedIn } from './features' /* - * We should not synthesize the page when: - ** 1. User has no recent listens the last 30 days - ** 2. User has a recent listen but the page was saved after the listen + * We should synthesize the page when user is opted in to the feature */ export const shouldSynthesize = async ( userId: string, @@ -17,31 +13,5 @@ export const shouldSynthesize = async ( return false } - if (await isOptedIn(FeatureName.UltraRealisticVoice, userId)) { - return true - } - - const [recentListenedPage, count] = (await searchPages( - { - dateFilters: [ - { - field: 'listenedAt', - startDate: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000), - }, - ], - sort: { - by: SortBy.LISTENED, - order: SortOrder.DESCENDING, - }, - size: 1, - }, - userId - )) || [[], 0] - if (count === 0) { - return false - } - return ( - !!recentListenedPage[0].listenedAt && - page.savedAt < recentListenedPage[0].listenedAt - ) + return isOptedIn(FeatureName.UltraRealisticVoice, userId) } From c09074d90127d0c7038fde33d99dc7733fbfec37 Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Thu, 10 Nov 2022 21:32:49 +0800 Subject: [PATCH 31/43] Fix tests of optInFeatures API --- packages/api/test/resolvers/features.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/api/test/resolvers/features.test.ts b/packages/api/test/resolvers/features.test.ts index d19366dcd..b5a901441 100644 --- a/packages/api/test/resolvers/features.test.ts +++ b/packages/api/test/resolvers/features.test.ts @@ -100,10 +100,10 @@ describe('features resolvers', () => { // create 1000 opt-in users const usersToSave = Array.from(Array(1000).keys()).map((i) => { return { - name: `user${i}`, + name: `opt-in-user-${i}`, source: 'GOOGLE', sourceUserId: `fake-user-id-user${i}`, - email: `user${i}@omnivore.app`, + email: `opt-in-user-${i}@omnivore.app`, username: `user${i}`, bio: `i am user${i}`, } @@ -125,7 +125,7 @@ describe('features resolvers', () => { after(async () => { // reset opt-in users await getRepository(User).delete({ - name: Like(`user%`), + name: Like(`opt-in-user-%`), }) await getRepository(Feature).delete({ name: featureName, From ce18d33ae13649a997e04edbd8d782fe43ec0a7a Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Fri, 11 Nov 2022 10:14:46 +0800 Subject: [PATCH 32/43] Change queue name --- packages/api/src/utils/createTask.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/api/src/utils/createTask.ts b/packages/api/src/utils/createTask.ts index 51d18b592..cefe0fead 100644 --- a/packages/api/src/utils/createTask.ts +++ b/packages/api/src/utils/createTask.ts @@ -9,13 +9,10 @@ import { buildLogger } from './logger' import { nanoid } from 'nanoid' import { google } from '@google-cloud/tasks/build/protos/protos' import { IntegrationType } from '../entity/integration' -import { promisify } from 'util' -import * as jwt from 'jsonwebtoken' import { signFeatureToken } from '../services/features' import View = google.cloud.tasks.v2.Task.View const logger = buildLogger('app.dispatch') -const signToken = promisify(jwt.sign) // Instantiates a client. const client = new CloudTasksClient() @@ -343,7 +340,7 @@ export const enqueueTextToSpeech = async ({ priority, textType = 'ssml', bucket = env.fileUpload.gcsUploadBucket, - queue = 'omnivore-demo-text-to-speech-queue', + queue = 'omnivore-text-to-speech-queue', location = env.gcp.location, isUltraRealisticVoice = false, language, From ac21e4e6c8cbe3f0cb19506062a97cd21011188c Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Fri, 11 Nov 2022 14:08:28 +0800 Subject: [PATCH 33/43] Set voice and secondary voice in user personalization --- .../api/src/entity/user_personalization.ts | 20 ++++++------ packages/api/src/generated/graphql.ts | 8 +++++ packages/api/src/generated/schema.graphql | 4 +++ .../resolvers/user_personalization/index.ts | 31 +++++++++++-------- packages/api/src/schema.ts | 4 +++ 5 files changed, 44 insertions(+), 23 deletions(-) diff --git a/packages/api/src/entity/user_personalization.ts b/packages/api/src/entity/user_personalization.ts index 5e7cc51f8..76f6a4d1b 100644 --- a/packages/api/src/entity/user_personalization.ts +++ b/packages/api/src/entity/user_personalization.ts @@ -19,34 +19,34 @@ export class UserPersonalization { user!: User @Column('text', { nullable: true }) - fontFamily?: string + fontFamily?: string | null @Column('integer', { nullable: true }) - fontSize?: number + fontSize?: number | null @Column('text', { nullable: true }) - margin?: number + margin?: number | null @Column('text', { nullable: true }) - theme?: string + theme?: string | null @Column('text', { nullable: true }) - libraryLayoutType?: string + libraryLayoutType?: string | null @Column('text', { nullable: true }) - librarySortOrder?: string + librarySortOrder?: string | null @Column('text', { nullable: true }) - speechVoice?: string + speechVoice?: string | null @Column('text', { nullable: true }) - speechSecondaryVoice?: string + speechSecondaryVoice?: string | null @Column('text', { nullable: true }) - speechRate?: string + speechRate?: string | null @Column('text', { nullable: true }) - speechVolume?: string + speechVolume?: string | null @CreateDateColumn({ default: () => 'CURRENT_TIMESTAMP' }) createdAt!: Date diff --git a/packages/api/src/generated/graphql.ts b/packages/api/src/generated/graphql.ts index 1f1815b31..130c6973e 100644 --- a/packages/api/src/generated/graphql.ts +++ b/packages/api/src/generated/graphql.ts @@ -2458,6 +2458,10 @@ export type UserPersonalization = { libraryLayoutType?: Maybe; librarySortOrder?: Maybe; margin?: Maybe; + speechRate?: Maybe; + speechSecondaryVoice?: Maybe; + speechVoice?: Maybe; + speechVolume?: Maybe; theme?: Maybe; }; @@ -4767,6 +4771,10 @@ export type UserPersonalizationResolvers, ParentType, ContextType>; librarySortOrder?: Resolver, ParentType, ContextType>; margin?: Resolver, ParentType, ContextType>; + speechRate?: Resolver, ParentType, ContextType>; + speechSecondaryVoice?: Resolver, ParentType, ContextType>; + speechVoice?: Resolver, ParentType, ContextType>; + speechVolume?: Resolver, ParentType, ContextType>; theme?: Resolver, ParentType, ContextType>; __isTypeOf?: IsTypeOfResolverFn; }; diff --git a/packages/api/src/generated/schema.graphql b/packages/api/src/generated/schema.graphql index 4a4048d46..6a5850c50 100644 --- a/packages/api/src/generated/schema.graphql +++ b/packages/api/src/generated/schema.graphql @@ -1911,6 +1911,10 @@ type UserPersonalization { libraryLayoutType: String librarySortOrder: SortOrder margin: Int + speechRate: String + speechSecondaryVoice: String + speechVoice: String + speechVolume: String theme: String } diff --git a/packages/api/src/resolvers/user_personalization/index.ts b/packages/api/src/resolvers/user_personalization/index.ts index d5b8e8792..54d526012 100644 --- a/packages/api/src/resolvers/user_personalization/index.ts +++ b/packages/api/src/resolvers/user_personalization/index.ts @@ -1,30 +1,35 @@ import { - GetUserPersonalizationResult, GetUserPersonalizationError, - SetUserPersonalizationSuccess, - SetUserPersonalizationError, + GetUserPersonalizationResult, MutationSetUserPersonalizationArgs, + SetUserPersonalizationError, + SetUserPersonalizationSuccess, SortOrder, } from '../../generated/graphql' import { authorized } from '../../utils/helpers' +import { UserPersonalization } from '../../entity/user_personalization' +import { AppDataSource } from '../../server' +import { setClaims } from '../../entity/utils' export const setUserPersonalizationResolver = authorized< SetUserPersonalizationSuccess, SetUserPersonalizationError, MutationSetUserPersonalizationArgs ->(async (_, { input }, { models, authTrx, claims: { uid } }) => { - const updatedUserPersonalization = await authTrx((tx) => - models.userPersonalization.upsert( - { - userId: uid, - ...input, - }, - tx +>(async (_, { input }, { claims: { uid } }) => { + const updatedUserPersonalization = + await AppDataSource.transaction( + async (entityManager) => { + await setClaims(entityManager, uid) + + return entityManager.getRepository(UserPersonalization).save({ + user: { id: uid }, + ...input, + }) + } ) - ) // Cast SortOrder from string to enum - const librarySortOrder = updatedUserPersonalization?.librarySortOrder as + const librarySortOrder = updatedUserPersonalization.librarySortOrder as | SortOrder | null | undefined diff --git a/packages/api/src/schema.ts b/packages/api/src/schema.ts index 7faa4c4a7..b8b9db254 100755 --- a/packages/api/src/schema.ts +++ b/packages/api/src/schema.ts @@ -963,6 +963,10 @@ const schema = gql` margin: Int libraryLayoutType: String librarySortOrder: SortOrder + speechVoice: String + speechSecondaryVoice: String + speechRate: String + speechVolume: String } # Query: UserPersonalization From 477b5d7d24a955d73e9c1c0007115d7830f79edd Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Fri, 11 Nov 2022 14:53:46 +0800 Subject: [PATCH 34/43] Fix typeorm issue --- packages/api/src/entity/newsletter_email.ts | 2 +- packages/api/src/services/create_user.ts | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/api/src/entity/newsletter_email.ts b/packages/api/src/entity/newsletter_email.ts index bdd4beac0..0399fbc7b 100644 --- a/packages/api/src/entity/newsletter_email.ts +++ b/packages/api/src/entity/newsletter_email.ts @@ -22,7 +22,7 @@ export class NewsletterEmail { user!: User @Column('varchar', { nullable: true }) - confirmationCode?: string + confirmationCode?: string | null @CreateDateColumn() createdAt!: Date diff --git a/packages/api/src/services/create_user.ts b/packages/api/src/services/create_user.ts index 2d6267b6c..e23467e25 100644 --- a/packages/api/src/services/create_user.ts +++ b/packages/api/src/services/create_user.ts @@ -106,9 +106,7 @@ const validateInvite = async ( return false } const membershipRepo = entityManager.getRepository(GroupMembership) - const numMembers = await membershipRepo.count({ - where: { invite: invite }, - }) + const numMembers = await membershipRepo.countBy({ invite: { id: invite.id } }) if (numMembers >= invite.maxMembers) { console.log('rejecting invite, too many users', invite, numMembers) return false From 83dbb5489667f9908dc2f2be79c1577a131bdfc9 Mon Sep 17 00:00:00 2001 From: Jackson Harper Date: Fri, 11 Nov 2022 15:01:14 +0800 Subject: [PATCH 35/43] Allow filtering by author --- packages/api/src/utils/search.ts | 2 ++ packages/api/test/utils/search.test.ts | 8 ++++++++ 2 files changed, 10 insertions(+) diff --git a/packages/api/src/utils/search.ts b/packages/api/src/utils/search.ts index f09b2b3af..c0244926d 100644 --- a/packages/api/src/utils/search.ts +++ b/packages/api/src/utils/search.ts @@ -280,6 +280,7 @@ export const parseSearchQuery = (query: string | undefined): SearchFilter => { 'sort', 'has', 'saved', + 'author', 'published', 'subscription', 'language', @@ -355,6 +356,7 @@ export const parseSearchQuery = (query: string | undefined): SearchFilter => { break } // match filters + case 'author': case 'title': case 'description': case 'content': { diff --git a/packages/api/test/utils/search.test.ts b/packages/api/test/utils/search.test.ts index a8b505f8c..6d8d8e3de 100644 --- a/packages/api/test/utils/search.test.ts +++ b/packages/api/test/utils/search.test.ts @@ -151,3 +151,11 @@ describe('query with in param set to invalid value', () => { expect(result.inFilter).to.eq(InFilter.INBOX) }) }) + +describe('query with author set', () => { + it('adds author to the match filters', () => { + const result = parseSearchQuery('author:"Omnivore Blog"') + expect(result.matchFilters).to.contain({ field: 'author', value:'omnivore blog' }) + }) +}) + From c5c59c07c06718aafd6aaf8e8013979eaa8463c3 Mon Sep 17 00:00:00 2001 From: Jackson Harper Date: Fri, 11 Nov 2022 15:19:53 +0800 Subject: [PATCH 36/43] Fix test for search field --- packages/api/test/utils/search.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/api/test/utils/search.test.ts b/packages/api/test/utils/search.test.ts index 6d8d8e3de..0ac465053 100644 --- a/packages/api/test/utils/search.test.ts +++ b/packages/api/test/utils/search.test.ts @@ -155,7 +155,8 @@ describe('query with in param set to invalid value', () => { describe('query with author set', () => { it('adds author to the match filters', () => { const result = parseSearchQuery('author:"Omnivore Blog"') - expect(result.matchFilters).to.contain({ field: 'author', value:'omnivore blog' }) + expect(result.matchFilters[0].field).to.equal('author') + expect(result.matchFilters[0].value).to.equal('omnivore blog' }) }) }) From 67b26be547431ca2c2a117929a70caaf6c466dbb Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Fri, 11 Nov 2022 15:24:54 +0800 Subject: [PATCH 37/43] Allow apollo sandbox access --- packages/api/src/generated/graphql.ts | 4 ++++ packages/api/src/generated/schema.graphql | 4 ++++ packages/api/src/schema.ts | 4 ++++ packages/api/src/utils/corsConfig.ts | 1 + 4 files changed, 13 insertions(+) diff --git a/packages/api/src/generated/graphql.ts b/packages/api/src/generated/graphql.ts index 130c6973e..edf334694 100644 --- a/packages/api/src/generated/graphql.ts +++ b/packages/api/src/generated/graphql.ts @@ -1945,6 +1945,10 @@ export type SetUserPersonalizationInput = { libraryLayoutType?: InputMaybe; librarySortOrder?: InputMaybe; margin?: InputMaybe; + speechRate?: InputMaybe; + speechSecondaryVoice?: InputMaybe; + speechVoice?: InputMaybe; + speechVolume?: InputMaybe; theme?: InputMaybe; }; diff --git a/packages/api/src/generated/schema.graphql b/packages/api/src/generated/schema.graphql index 6a5850c50..20658c344 100644 --- a/packages/api/src/generated/schema.graphql +++ b/packages/api/src/generated/schema.graphql @@ -1441,6 +1441,10 @@ input SetUserPersonalizationInput { libraryLayoutType: String librarySortOrder: SortOrder margin: Int + speechRate: String + speechSecondaryVoice: String + speechVoice: String + speechVolume: String theme: String } diff --git a/packages/api/src/schema.ts b/packages/api/src/schema.ts index b8b9db254..74acad69b 100755 --- a/packages/api/src/schema.ts +++ b/packages/api/src/schema.ts @@ -1003,6 +1003,10 @@ const schema = gql` margin: Int libraryLayoutType: String @sanitize librarySortOrder: SortOrder + speechVoice: String + speechSecondaryVoice: String + speechRate: String + speechVolume: String } # Type: ArticleSavingRequest diff --git a/packages/api/src/utils/corsConfig.ts b/packages/api/src/utils/corsConfig.ts index 16f707d0c..4d17ee47b 100644 --- a/packages/api/src/utils/corsConfig.ts +++ b/packages/api/src/utils/corsConfig.ts @@ -9,5 +9,6 @@ export const corsConfig = { 'https://web-demo.omnivore.app', 'http://localhost:3000', 'lsp://logseq.io', + 'https://studio.apollographql.com', ], } From eaaf1c052b5201c89ad361e7e902fe2acc9e8c19 Mon Sep 17 00:00:00 2001 From: Jackson Harper Date: Fri, 11 Nov 2022 15:39:47 +0800 Subject: [PATCH 38/43] Fix typo --- .../Sources/App/Views/Home/HomeFeedViewIOS.swift | 14 ++++++++++---- .../App/Views/WebReader/WebReaderContainer.swift | 4 ++++ .../Sources/Views/FeedItem/GridCard.swift | 4 ---- .../Sources/Views/FeedItem/HomeFeedCardView.swift | 2 ++ packages/api/test/utils/search.test.ts | 2 +- 5 files changed, 17 insertions(+), 9 deletions(-) diff --git a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift index 17662712f..2337f76bb 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift @@ -335,10 +335,16 @@ import Views Label { Text("Snooze") } icon: { Image.moon } } } - Button( - action: { viewModel.downloadAudio(audioController: audioController, item: item) }, - label: { Label("Download Audio", systemImage: "icloud.and.arrow.down") } - ) + if let author = item.author { + Button( + action: { + viewModel.searchTerm = "author:\"\(author)\"" + }, + label: { + Label(String("More by \(author)"), systemImage: "person") + } + ) + } } .swipeActions(edge: .trailing, allowsFullSwipe: true) { if !item.isArchived { diff --git a/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContainer.swift b/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContainer.swift index 3e2b91ab9..dd70144b7 100644 --- a/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContainer.swift +++ b/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContainer.swift @@ -213,6 +213,10 @@ struct WebReaderContainerView: View { }, label: { Label("Reset Read Location", systemImage: "arrow.counterclockwise.circle") } ) + Button( + action: { /* viewModel.downloadAudio(audioController: audioController, item: item) */ }, + label: { Label("Download Audio", systemImage: "icloud.and.arrow.down") } + ) Button( action: share, label: { Label("Share Original", systemImage: "square.and.arrow.up") } diff --git a/apple/OmnivoreKit/Sources/Views/FeedItem/GridCard.swift b/apple/OmnivoreKit/Sources/Views/FeedItem/GridCard.swift index 57e7e86c1..b3ac2411e 100644 --- a/apple/OmnivoreKit/Sources/Views/FeedItem/GridCard.swift +++ b/apple/OmnivoreKit/Sources/Views/FeedItem/GridCard.swift @@ -71,10 +71,6 @@ public struct GridCard: View { action: { menuActionHandler(.delete) }, label: { Label("Delete", systemImage: "trash") } ) - Button( - action: { menuActionHandler(.downloadAudio) }, - label: { Label("Download Audio", systemImage: "icloud.and.arrow.down") } - ) } } diff --git a/apple/OmnivoreKit/Sources/Views/FeedItem/HomeFeedCardView.swift b/apple/OmnivoreKit/Sources/Views/FeedItem/HomeFeedCardView.swift index 4eea19217..42af43b98 100644 --- a/apple/OmnivoreKit/Sources/Views/FeedItem/HomeFeedCardView.swift +++ b/apple/OmnivoreKit/Sources/Views/FeedItem/HomeFeedCardView.swift @@ -76,6 +76,8 @@ public struct FeedCard: View { } Spacer() } + }.introspectScrollView { scrollView in + scrollView.bounces = false } .padding(.top, 0) #if os(macOS) diff --git a/packages/api/test/utils/search.test.ts b/packages/api/test/utils/search.test.ts index 0ac465053..7b0fae299 100644 --- a/packages/api/test/utils/search.test.ts +++ b/packages/api/test/utils/search.test.ts @@ -156,7 +156,7 @@ describe('query with author set', () => { it('adds author to the match filters', () => { const result = parseSearchQuery('author:"Omnivore Blog"') expect(result.matchFilters[0].field).to.equal('author') - expect(result.matchFilters[0].value).to.equal('omnivore blog' }) + expect(result.matchFilters[0].value).to.equal('omnivore blog') }) }) From 917396c97676d29afdc3b21e442ad0e31f2e0093 Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Fri, 11 Nov 2022 16:11:09 +0800 Subject: [PATCH 39/43] Upsert user personalization in db --- packages/api/src/generated/graphql.ts | 1 + packages/api/src/generated/schema.graphql | 1 + .../resolvers/user_personalization/index.ts | 37 +++++++++++++------ packages/api/src/schema.ts | 1 + 4 files changed, 28 insertions(+), 12 deletions(-) diff --git a/packages/api/src/generated/graphql.ts b/packages/api/src/generated/graphql.ts index edf334694..1eb7b5843 100644 --- a/packages/api/src/generated/graphql.ts +++ b/packages/api/src/generated/graphql.ts @@ -1936,6 +1936,7 @@ export type SetUserPersonalizationError = { }; export enum SetUserPersonalizationErrorCode { + NotFound = 'NOT_FOUND', Unauthorized = 'UNAUTHORIZED' } diff --git a/packages/api/src/generated/schema.graphql b/packages/api/src/generated/schema.graphql index 20658c344..51f50f0a5 100644 --- a/packages/api/src/generated/schema.graphql +++ b/packages/api/src/generated/schema.graphql @@ -1432,6 +1432,7 @@ type SetUserPersonalizationError { } enum SetUserPersonalizationErrorCode { + NOT_FOUND UNAUTHORIZED } diff --git a/packages/api/src/resolvers/user_personalization/index.ts b/packages/api/src/resolvers/user_personalization/index.ts index 54d526012..955dc16d4 100644 --- a/packages/api/src/resolvers/user_personalization/index.ts +++ b/packages/api/src/resolvers/user_personalization/index.ts @@ -3,33 +3,46 @@ import { GetUserPersonalizationResult, MutationSetUserPersonalizationArgs, SetUserPersonalizationError, + SetUserPersonalizationErrorCode, SetUserPersonalizationSuccess, SortOrder, } from '../../generated/graphql' import { authorized } from '../../utils/helpers' import { UserPersonalization } from '../../entity/user_personalization' import { AppDataSource } from '../../server' -import { setClaims } from '../../entity/utils' +import { getRepository, setClaims } from '../../entity/utils' export const setUserPersonalizationResolver = authorized< SetUserPersonalizationSuccess, SetUserPersonalizationError, MutationSetUserPersonalizationArgs ->(async (_, { input }, { claims: { uid } }) => { - const updatedUserPersonalization = - await AppDataSource.transaction( - async (entityManager) => { - await setClaims(entityManager, uid) +>(async (_, { input }, { claims: { uid }, log }) => { + log.info('setUserPersonalizationResolver', { uid, input }) - return entityManager.getRepository(UserPersonalization).save({ - user: { id: uid }, - ...input, - }) - } + const result = await AppDataSource.transaction(async (entityManager) => { + await setClaims(entityManager, uid) + + return entityManager.getRepository(UserPersonalization).upsert( + { + user: { id: uid }, + ...input, + }, + ['user'] ) + }) + + if (result.identifiers.length === 0) { + return { + errorCodes: [SetUserPersonalizationErrorCode.NotFound], + } + } + + const updatedUserPersonalization = await getRepository( + UserPersonalization + ).findOneBy({ id: result.identifiers[0].id as string }) // Cast SortOrder from string to enum - const librarySortOrder = updatedUserPersonalization.librarySortOrder as + const librarySortOrder = updatedUserPersonalization?.librarySortOrder as | SortOrder | null | undefined diff --git a/packages/api/src/schema.ts b/packages/api/src/schema.ts index 74acad69b..1f089b7b4 100755 --- a/packages/api/src/schema.ts +++ b/packages/api/src/schema.ts @@ -989,6 +989,7 @@ const schema = gql` | SetUserPersonalizationError enum SetUserPersonalizationErrorCode { UNAUTHORIZED + NOT_FOUND } type SetUserPersonalizationError { errorCodes: [SetUserPersonalizationErrorCode!]! From 9872355056b2b577310146ef2df12b58e0611cf0 Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Fri, 11 Nov 2022 16:18:11 +0800 Subject: [PATCH 40/43] Allow apollo sandbox access in local env only --- packages/api/src/utils/corsConfig.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/api/src/utils/corsConfig.ts b/packages/api/src/utils/corsConfig.ts index 4d17ee47b..d3462a0a0 100644 --- a/packages/api/src/utils/corsConfig.ts +++ b/packages/api/src/utils/corsConfig.ts @@ -1,5 +1,8 @@ +import { env } from '../env' + export const corsConfig = { credentials: true, + // allow https://studio.apollographql.com for local env origin: [ 'https://omnivore.app', 'https://dev.omnivore.app', @@ -9,6 +12,6 @@ export const corsConfig = { 'https://web-demo.omnivore.app', 'http://localhost:3000', 'lsp://logseq.io', - 'https://studio.apollographql.com', + env.dev.isLocal && 'https://studio.apollographql.com', ], } From 588a14c6b98b8b63d566cb83dd53a7c5599b1ef1 Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Fri, 11 Nov 2022 16:47:32 +0800 Subject: [PATCH 41/43] Fix tests --- packages/api/test/resolvers/integrations.test.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/api/test/resolvers/integrations.test.ts b/packages/api/test/resolvers/integrations.test.ts index 582160680..f024e0eb0 100644 --- a/packages/api/test/resolvers/integrations.test.ts +++ b/packages/api/test/resolvers/integrations.test.ts @@ -90,7 +90,7 @@ describe('Integrations resolvers', () => { before(async () => { existingIntegration = await getRepository(Integration).save({ - user: loginUser, + user: { id: loginUser.id }, type: DataIntegrationType.Readwise, token: 'fakeToken', }) @@ -138,7 +138,7 @@ describe('Integrations resolvers', () => { afterEach(async () => { await getRepository(Integration).delete({ - user: loginUser, + user: { id: loginUser.id }, type: integrationType, }) }) @@ -191,7 +191,7 @@ describe('Integrations resolvers', () => { before(async () => { otherUser = await createTestUser('otherUser') existingIntegration = await getRepository(Integration).save({ - user: otherUser, + user: { id: otherUser.id }, type: DataIntegrationType.Readwise, token: 'fakeToken', }) @@ -219,7 +219,7 @@ describe('Integrations resolvers', () => { context('when integration belongs to the user', () => { before(async () => { existingIntegration = await getRepository(Integration).save({ - user: loginUser, + user: { id: loginUser.id }, type: DataIntegrationType.Readwise, token: 'fakeToken', }) @@ -321,7 +321,7 @@ describe('Integrations resolvers', () => { before(async () => { existingIntegration = await getRepository(Integration).save({ - user: loginUser, + user: { id: loginUser.id }, type: DataIntegrationType.Readwise, token: 'fakeToken', }) @@ -367,7 +367,7 @@ describe('Integrations resolvers', () => { beforeEach(async () => { existingIntegration = await getRepository(Integration).save({ - user: loginUser, + user: { id: loginUser.id }, type: DataIntegrationType.Readwise, token: 'fakeToken', taskName: 'some task name', From 2cff9f049a0f1e892f81ef7a1c425c598b283cb5 Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Fri, 11 Nov 2022 16:54:18 +0800 Subject: [PATCH 42/43] Temporarily disable feature api tests --- packages/api/test/resolvers/features.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/api/test/resolvers/features.test.ts b/packages/api/test/resolvers/features.test.ts index b5a901441..7ed43cfe0 100644 --- a/packages/api/test/resolvers/features.test.ts +++ b/packages/api/test/resolvers/features.test.ts @@ -10,7 +10,7 @@ import sinon, { SinonFakeTimers } from 'sinon' import { env } from '../../src/env' import { Like } from 'typeorm' -describe('features resolvers', () => { +xdescribe('features resolvers', () => { let loginUser: User let authToken: string From 3f6a3b36fffca963b559ace527339298c65ee270 Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Fri, 11 Nov 2022 18:34:44 +0800 Subject: [PATCH 43/43] Replace default voice to Larry for pre-generating --- packages/api/src/routers/text_to_speech.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/api/src/routers/text_to_speech.ts b/packages/api/src/routers/text_to_speech.ts index 4e89954f6..24cbcd0cf 100644 --- a/packages/api/src/routers/text_to_speech.ts +++ b/packages/api/src/routers/text_to_speech.ts @@ -18,6 +18,9 @@ import { UserPersonalization } from '../entity/user_personalization' import { ArticleSavingRequestStatus } from '../elastic/types' import { FeatureName, getFeature } from '../services/features' +const DEFAULT_VOICE = 'Larry' +const DEFAULT_COMPLIMENTARY_VOICE = 'Evelyn' + const logger = buildLogger('app.dispatch') export function textToSpeechRouter() { @@ -74,9 +77,10 @@ export function textToSpeechRouter() { title: page.title, content: page.content, options: { - primaryVoice: userPersonalization?.speechVoice || 'Axel', + primaryVoice: userPersonalization?.speechVoice || DEFAULT_VOICE, secondaryVoice: - userPersonalization?.speechSecondaryVoice || 'Evelyn', + userPersonalization?.speechSecondaryVoice || + DEFAULT_COMPLIMENTARY_VOICE, language: page.language, }, }) @@ -92,7 +96,7 @@ export function textToSpeechRouter() { userId, speechId: utterance.idx, text: utterance.text, - voice: utterance.voice || 'Axel', + voice: utterance.voice || DEFAULT_VOICE, priority: 'high', isUltraRealisticVoice: true, language: speechFile.language,