Skip to content

Commit

Permalink
Add new TTS API (#156)
Browse files Browse the repository at this point in the history
  • Loading branch information
Pierre-Gilles authored Dec 1, 2023
1 parent b4c4202 commit 63404ee
Show file tree
Hide file tree
Showing 6 changed files with 228 additions and 0 deletions.
10 changes: 10 additions & 0 deletions core/api/routes.js
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,16 @@ module.exports.load = function Routes(app, io, controllers, middlewares) {
asyncMiddleware(controllers.openAIController.ask),
);

// TTS API
app.post(
'/tts/token',
asyncMiddleware(middlewares.accessTokenInstanceAuth),
middlewares.checkUserPlan('plus'),
middlewares.ttsRateLimit,
asyncMiddleware(controllers.ttsController.getTemporaryToken),
);
app.get('/tts/generate', asyncMiddleware(controllers.ttsController.generate));

// user
app.post('/users/signup', middlewares.rateLimiter, asyncMiddleware(controllers.userController.signup));
app.post('/users/verify', middlewares.rateLimiter, asyncMiddleware(controllers.userController.confirmEmail));
Expand Down
71 changes: 71 additions & 0 deletions core/api/tts/tts.controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
const axios = require('axios');
const uuid = require('uuid');

const { UnauthorizedError } = require('../../common/error');

const TTS_TOKEN_PREFIX = 'tts-token:';

module.exports = function TTSController(redisClient) {
/**
* @api {get} /tts/generate Generate a mp3 file from a text
* @apiName generate
* @apiGroup TTS
*
*
* @apiQuery {String} text The text to generate
* @apiQuery {String} token Temporary token to have access to
*
* @apiSuccessExample {binary} Success-Response:
* HTTP/1.1 200 OK
*/
async function generate(req, res, next) {
const instanceId = await redisClient.get(`${TTS_TOKEN_PREFIX}:${req.query.token}`);
if (!instanceId) {
throw new UnauthorizedError('Invalid TTS token.');
}
// Streaming response to client
const { data, headers } = await axios({
url: process.env.TEXT_TO_SPEECH_URL,
method: 'POST',
body: req.body,
headers: {
authorization: `Bearer ${process.env.TEXT_TO_SPEECH_API_KEY}`,
},
responseType: 'stream',
});
res.setHeader('content-type', headers['content-type']);
res.setHeader('content-length', headers['content-length']);
data.pipe(res);
}

/**
* @api {post} /tts/token Get temporary token to access TTS API
* @apiName getToken
* @apiGroup TTS
*
* @apiBody {String} text The text to generate
*
* @apiSuccessExample {binary} Success-Response:
* HTTP/1.1 200 OK
*
* {
* "token": "ac365e90-78f1-482a-8afa-af326d5647a4",
* "url": "https://url_of_the_file"
* }
*/
async function getTemporaryToken(req, res, next) {
const token = uuid.v4();
await redisClient.set(`${TTS_TOKEN_PREFIX}:${token}`, req.instance.id, {
EX: 5 * 60, // 5 minutes in seconds
});
const url = `${process.env.GLADYS_PLUS_BACKEND_URL}/tts/generate?token=${token}&text=${encodeURIComponent(
req.body.text,
)}`;
res.json({ token, url });
}

return {
generate,
getTemporaryToken,
};
};
4 changes: 4 additions & 0 deletions core/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ const AlexaController = require('./api/alexa/alexa.controller');
const EnedisController = require('./api/enedis/enedis.controller');
const EcowattController = require('./api/ecowatt/ecowatt.controller');
const CameraController = require('./api/camera/camera.controller');
const TTSController = require('./api/tts/tts.controller');

// Middlewares
const TwoFactorAuthMiddleware = require('./middleware/twoFactorTokenAuth');
Expand All @@ -69,6 +70,7 @@ const AdminApiAuth = require('./middleware/adminApiAuth');
const OpenAIAuthAndRateLimit = require('./middleware/openAIAuthAndRateLimit');
const CameraStreamAccessKeyAuth = require('./middleware/cameraStreamAccessKeyAuth');
const CheckUserPlan = require('./middleware/checkUserPlan');
const TTSRateLimit = require('./middleware/ttsRateLimit');

// Routes
const routes = require('./api/routes');
Expand Down Expand Up @@ -220,6 +222,7 @@ module.exports = async (port) => {
redisClient,
services.telegramService,
),
ttsController: TTSController(redisClient),
};

const middlewares = {
Expand All @@ -238,6 +241,7 @@ module.exports = async (port) => {
openAIAuthAndRateLimit: OpenAIAuthAndRateLimit(logger, legacyRedisClient, db),
cameraStreamAccessKeyAuth: CameraStreamAccessKeyAuth(redisClient, logger),
checkUserPlan: CheckUserPlan(models.userModel, models.instanceModel, logger),
ttsRateLimit: TTSRateLimit(logger, legacyRedisClient, db),
};

routes.load(app, io, controllers, middlewares);
Expand Down
47 changes: 47 additions & 0 deletions core/middleware/ttsRateLimit.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
const { RateLimiterRedis } = require('rate-limiter-flexible');

const { TooManyRequestsError } = require('../common/error');
const asyncMiddleware = require('./asyncMiddleware');

const MAX_REQUESTS = parseInt(process.env.TTS_MAX_REQUESTS_PER_MONTH_PER_ACCOUNT, 10);

module.exports = function TTSRateLimit(logger, redisClient, db) {
const limiter = new RateLimiterRedis({
storeClient: redisClient,
keyPrefix: 'rate_limit:tts_api',
points: MAX_REQUESTS, // max request per month
duration: 30 * 24 * 60 * 60, // 30 days
});
return asyncMiddleware(async (req, res, next) => {
const instanceWithAccount = await db.t_account
.join({
t_instance: {
type: 'INNER',
on: {
account_id: 'id',
},
},
})
.findOne({
't_instance.id': req.instance.id,
});
const uniqueIdentifier = instanceWithAccount.id;
// we check if the current account is rate limited
const limiterResult = await limiter.get(uniqueIdentifier);
if (limiterResult && limiterResult.consumedPoints > MAX_REQUESTS) {
logger.warn(`TTS Rate limit: Account ${uniqueIdentifier} has been querying too much this route`);
throw new TooManyRequestsError('Too many requests this month.');
}

// We consume one credit
try {
await limiter.consume(uniqueIdentifier);
} catch (e) {
logger.warn(`TTS Rate limit: Account ${uniqueIdentifier} has been querying too much this route`);
logger.warn(e);
throw new TooManyRequestsError('Too many requests this month.');
}

next();
});
};
96 changes: 96 additions & 0 deletions test/core/api/tts/tts.controller.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
const request = require('supertest');
const nock = require('nock');
const fs = require('fs');
const path = require('path');
const { expect } = require('chai');
const { RateLimiterRedis } = require('rate-limiter-flexible');

const configTest = require('../../../tasks/config');

const voiceFile = fs.readFileSync(path.join(__dirname, './voice.mp3'));

describe('TTS API', () => {
before(() => {
process.env.TEXT_TO_SPEECH_URL = 'https://test-tts.com';
process.env.TEXT_TO_SPEECH_API_KEY = 'my-token';
process.env.GLADYS_PLUS_BACKEND_URL = 'http://test-api.com';
});
it('should get token + get mp3', async () => {
nock(process.env.TEXT_TO_SPEECH_URL, { encodedQueryParams: true })
.post('/', (body) => true)
.reply(200, voiceFile, {
'content-type': 'audio/mpeg',
'content-length': 36362,
});
await TEST_DATABASE_INSTANCE.t_account.update(
{
id: 'b2d23f66-487d-493f-8acb-9c8adb400def',
},
{
status: 'active',
},
);
const response = await request(TEST_BACKEND_APP)
.post('/tts/token')
.set('Accept', 'application/json')
.set('Authorization', configTest.jwtAccessTokenInstance)
.send({ text: 'Bonjour, je suis Gladys' })
.expect('Content-Type', /json/)
.expect(200);
expect(response.body).to.have.property('token');
expect(response.body).to.have.property(
'url',
`http://test-api.com/tts/generate?token=${response.body.token}&text=Bonjour%2C%20je%20suis%20Gladys`,
);
const responseMp3File = await request(TEST_BACKEND_APP)
.get(`/tts/generate?token=${response.body.token}&text=bonjour`)
.set('Accept', 'application/json')
.set('Authorization', configTest.jwtAccessTokenInstance)
.send()
.expect('Content-Type', 'audio/mpeg')
.expect(200);
expect(responseMp3File.text).to.deep.equal(voiceFile.toString());
});
it('should return 401', async () => {
const response = await request(TEST_BACKEND_APP)
.get(`/tts/generate?token=toto&text=bonjour`)
.set('Accept', 'application/json')
.set('Authorization', configTest.jwtAccessTokenInstance)
.send()
.expect('Content-Type', /json/)
.expect(401);
expect(response.body).to.deep.equal({
error_code: 'UNAUTHORIZED',
status: 401,
});
});
it('should return 429, too many requests', async () => {
await TEST_DATABASE_INSTANCE.t_account.update(
{
id: 'b2d23f66-487d-493f-8acb-9c8adb400def',
},
{
status: 'active',
},
);
const limiter = new RateLimiterRedis({
storeClient: TEST_LEGACY_REDIS_CLIENT,
keyPrefix: 'rate_limit:tts_api',
points: 100, // max request per month
duration: 30 * 24 * 60 * 60, // 30 days
});
await limiter.consume('b2d23f66-487d-493f-8acb-9c8adb400def', 100);
const response = await request(TEST_BACKEND_APP)
.post('/tts/token')
.set('Accept', 'application/json')
.set('Authorization', configTest.jwtAccessTokenInstance)
.send()
.expect('Content-Type', /json/)
.expect(429);
expect(response.body).to.deep.equal({
status: 429,
error_code: 'TOO_MANY_REQUESTS',
error_message: 'Too many requests this month.',
});
});
});
Binary file added test/core/api/tts/voice.mp3
Binary file not shown.

0 comments on commit 63404ee

Please sign in to comment.