Na primeira parte deste artigo confira aqui, vimos uma visão geral da solução. Agora, vamos nos aprofundar no código e explicar como construir a solução do zero. Destacarei os aspectos-chave do código-base que criei para explorar essa ferramenta e construir a solução serverless.

Se você ainda não leu a primeira parte, recomendo fazer isso antes de prosseguir. No entanto, se você está ansioso para colocar as mãos na massa e construir essa solução passo a passo, pode continuar lendo.

Alternativamente, se você simplesmente quer implementar e testar como esta solução funciona, você pode seguir as instruções fornecidas no readme do repositório GitHub

Construir do zero

Para criar este aplicativo, você precisará ter Node.js (>= 16.3.0), AWS CDK e AWS CLI instalados. Você também precisará de uma conta AWS. O AWS CLI deve estar configurado com suas credenciais AWS.

Se você tentar executar o CDK com uma versão do Node abaixo da indicada acima, verá a mensagem de aviso: “v16.2.0 não é suportado e tem conhecidas incompatibilidades com este software.” Atualize sua versão do Node, e tudo deve ficar bem.

Aqui estão os links relevantes para a documentação oficial da AWS que irão ajudá-lo com os pré-requisitos:

  1. AWS CDK: O Guia do Desenvolvedor AWS CDK fornece informações detalhadas sobre como instalar e configurar o AWS CDK. O AWS CDK (Cloud Development Kit) pode ser instalado via npm (Node Package Manager) usando o comando: npm install -g aws-cdk

  2. AWS CLI: Você pode instalar o AWS CLI (Command Line Interface) seguindo as instruções no Guia do Usuário AWS CLI. Você pode instalar o AWS CLI usando pip, que é um gerenciador de pacotes para Python. O comando para instalar o AWS CLI é: pip install awscli

  3. Configurando o AWS CLI: Uma vez que o AWS CLI está instalado, você precisará configurá-lo com suas credenciais AWS. Isso inclui seu ID de chave de acesso e chave de acesso secreta. As instruções para isso podem ser encontradas no Guia do Usuário AWS CLI. O comando para configurar o AWS CLI é: aws configure

Passo 1: Crie um novo projeto CDK

Crie um novo diretório para o projeto:

mkdir text-to-speech-app
cd text-to-speech-app

Inicialize um novo projeto CDK:

cdk init --language=typescript

Passo 2: Instale os pacotes AWS CDK

Você precisará de vários pacotes para esta aplicação. Instale-os da seguinte maneira:

npm install @aws-cdk/aws-s3 @aws-cdk/aws-dynamodb @aws-cdk/aws-sqs @aws-cdk/aws-lambda @aws-cdk/aws-lambda-event-sources @aws-cdk/aws-iam --save

Depois disso, seu arquivo package.json deve ficar assim:

"dependencies": {
    "@aws-cdk/aws-dynamodb": "^1.204.0",
    "@aws-cdk/aws-iam": "^1.204.0",
    "@aws-cdk/aws-lambda": "^1.204.0",
    "@aws-cdk/aws-lambda-event-sources": "^1.204.0",
    "@aws-cdk/aws-s3": "^1.204.0",
    "@aws-cdk/aws-s3-notifications": "^1.204.0",
    "@aws-cdk/aws-sqs": "^1.204.0",
    "aws-cdk-lib": "2.87.0",
    "constructs": "^10.0.0",
    "source-map-support": "^0.5.21"
  }
  

Passo 3: Crie os Buckets S3

Abra o arquivo lib/text-to-speech-app-stack.ts e comece a criar os buckets S3:

import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';

import * as s3 from 'aws-cdk-lib/aws-s3';

export class TextToSpeechAppStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    const textBucket = new s3.Bucket(this, 'TextBucket', {
      versioned: false,
      encryption: s3.BucketEncryption.S3_MANAGED,
      blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
    });

    const audioBucket = new s3.Bucket(this, 'AudioBucket', {
      versioned: false,
      encryption: s3.BucketEncryption.S3_MANAGED,
      blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
    });
  }
}

Passo 3: Crie os Buckets S3

Abra o arquivo lib/text-to-speech-app-stack.ts e comece a criar os buckets S3:

import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';

import * as s3 from 'aws-cdk-lib/aws-s3';

export class TextToSpeechAppStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    const textBucket = new s3.Bucket(this, 'TextBucket', {
      versioned: false,
      encryption: s3.BucketEncryption.S3_MANAGED,
      blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
    });

    const audioBucket = new s3.Bucket(this, 'AudioBucket', {
      versioned: false,
      encryption: s3.BucketEncryption.S3_MANAGED,
      blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
    });
  }
}

Passo 4: Crie uma Tabela DynamoDB

Adicione as seguintes linhas para criar a tabela MetadataTable no DynamoDB:

import * as dynamodb from 'aws-cdk-lib/aws-dynamodb';

// dentro do construtor abaixo da criação do audioBucket

const metadataTable = new dynamodb.Table(this, 'MetadataTable', {
      partitionKey: { name: 'uuid', type: dynamodb.AttributeType.STRING },
      sortKey: { name: 'submissionTime', type: dynamodb.AttributeType.STRING },
      billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
    });

Passo 5: Crie uma Fila SQS

Em seguida, crie a fila ProcessingQueue SQS:

import * as sqs from 'aws-cdk-lib/aws-sqs';

// dentro do construtor abaixo da Metadatatable

  const processingQueue = new sqs.Queue(this, 'ProcessingQueue', {
      visibilityTimeout: cdk.Duration.seconds(300), // 5 minutos
    });

Passo 6: Crie uma Notificação de Evento S3 com SQS como alvo

Como já temos nosso textBucket e processingQueue agora podemos criar uma Notificação de Evento S3 para enviar uma mensagem para nossa fila de processamento quando um arquivo é carregado no caminho /upload.

//
import * as s3n from 'aws-cdk-lib/aws-s3-notifications';

// dentro do construtor abaixo da fila de processamento, adicione uma notificação de evento ao textBucket    
textBucket.addEventNotification(s3.EventType.OBJECT_CREATED, new s3n.SqsDestination(processingQueue), { prefix: 'upload/' });

Criamos a principal infraestrutura a ser utilizada em nossa solução, e o destaque aqui é que escrevemos isso em apenas 38 linhas de código TypeScript.

Você pode executar o comando cdk deploy para implantar o estado atual de nossa Stack antes de adicionarmos a função como construtores a serem utilizados nesta pilha.

O CDK mostra o progresso do deploy diretamente no seu terminal:

Descrição da imagem

E após este processo ter terminado, você pode ir ao seu console para verificar o status da nossa infraestrutura.

Descrição da imagem

Agora podemos seguir em frente e criar a função lambda que realmente fará a mágica para nós.

Passo 7: Crie o Construct TextToSpeech

Nossa função lambda será criada como um novo construtor em nosso projeto CDK, para isso vamos criar uma nova pasta no /lib chamada TextToSpeech e dentro desta pasta vamos criar dois novos arquivos TextToSpeech.ts e TextToSpeech.lambda.ts, nossa estrutura de projeto será assim:

Descrição da imagem

Agora podemos ir ao arquivo TextToSpeech.ts e criar nosso construtor lambda, aqui adicionaremos todas as configurações da função lambda:

import { Construct } from "constructs";

import { IBucket } from "aws-cdk-lib/aws-s3";
import { ITable } from "aws-cdk-lib/aws-dynamodb";
import { IQueue } from "aws-cdk-lib/aws-sqs";

export class TextToSpeech extends Construct {
    constructor(scope: Construct, id: string, props: {
        textBucket: IBucket,
        audioBucket: IBucket,
        metadataTable: ITable,
        processingQueue: IQueue

    }) {
        super(scope, id)

       }
}

Este é nosso construtor base onde definiremos todos os recursos lambda, o destaque aqui são as ‘props’, onde declaramos todos os recursos necessários com os quais a lambda interagirá.

Step 8: Agora finalmente podemos criar o código da lambda

import path = require("path");
import * as cdk from 'aws-cdk-lib';

import { NodejsFunction } from "aws-cdk-lib/aws-lambda-nodejs";
import * as iam from 'aws-cdk-lib/aws-iam';
import { IBucket } from "aws-cdk-lib/aws-s3";
import { ITable } from "aws-cdk-lib/aws-dynamodb";
import { SqsEventSource } from 'aws-cdk-lib/aws-lambda-event-sources';
import { IQueue } from "aws-cdk-lib/aws-sqs";

// Inside the constructor
const textToSpeechFunction = new NodejsFunction(this, "NodejsFunction", {
            entry: path.resolve(__dirname, "TextToSpeech.lambda.ts"),
            bundling: {
                nodeModules: ['aws-sdk'],
            },
            environment: {
                TEXT_BUCKET_NAME: props.textBucket.bucketName,
                AUDIO_BUCKET_NAME: props.audioBucket.bucketName,
                METADATA_TABLE_NAME: props.metadataTable.tableName
            },
            timeout: cdk.Duration.seconds(60) // 1 min
        })
O destaque aqui é que usamos o caminho para definir onde o código lambda está na propriedade entry e a partir de agora apenas passamos na propriedade bundling a dependência aws-sdk para ser aplicada no nosso pacote lambda.

O tempo limite em nossos testes foi suficiente para usar esta API que tem um tempo de resposta acessível.

Passo 9: Conceder Permissões aos Recursos

Agora precisamos conceder as permissões necessárias aos nossos recursos.

props.textBucket.grantRead(textToSpeechFunction);
        props.metadataTable.grantReadWriteData(textToSpeechFunction);
        props.audioBucket.grantWrite(textToSpeechFunction);
        textToSpeechFunction.addToRolePolicy(
            new iam.PolicyStatement({
                actions: ['polly:SynthesizeSpeech'],
                resources: ['*'],
            })
        );

        textToSpeechFunction.addEventSource(
            new SqsEventSource(props.processingQueue)
        )
);
Nesta instância, utilizamos os recursos que declaramos nas props do construtor para configurar as permissões necessárias. Este trecho de código simplifica a criação de funções e políticas usando as funções grantRead e grantReadWriteData. Seguindo isso, usamos a Declaração de Política IAM para adicionar uma política personalizada à nossa função Lambda, definindo especificamente a ação ‘synthesizeSpeech’.

O arquivo completo pode ser encontrado aqui no github

Passo 10: Criar o Código da Função Lambda

Agora vá para o arquivo TextToSpeech.lambda.ts para definir nosso código lambda como a seguir:

import { S3, DynamoDB, Polly } from 'aws-sdk';
import { SQSEvent } from 'aws-lambda';
import { GenerateUUID } from './utils/UUIDGenerator';

const s3 = new S3();
const dynamodb = new DynamoDB.DocumentClient();
const polly = new Polly();

exports.handler = async (event: SQSEvent) => {
    const textBucketName = process.env.TEXT_BUCKET_NAME || '';
    const audioBucketName = process.env.AUDIO_BUCKET_NAME || '';
    const metadataTableName = process.env.METADATA_TABLE_NAME || '';

    for (const record of event.Records) {
        const body = JSON.parse(record.body);
        const textKey = body.Records[0].s3.object.key;

        // Read the text file from the text bucket
        const textObject = await s3.getObject({
            Bucket: textBucketName,
            Key: textKey
        }).promise();
        if (!textObject.Body) {
            throw new Error(`Failed to get text body from S3 object: ${textKey}`);
        }
        const text = textObject.Body.toString();

        const pollyResponse = await polly.synthesizeSpeech({
            OutputFormat: 'mp3',
            Text: text,
            VoiceId: 'Joanna',
        }).promise();

        if (pollyResponse.AudioStream) {
            const date = new Date();
            const year = date.getFullYear();
            const month = date.getMonth() + 1;
            const day = date.getDate();
            const timestamp = Date.now();

            const uuid = GenerateUUID();

            const audioKey = `synthesized/${uuid}/year=${year}/month=${month}/day=${day}/${timestamp}.mp3`;
            await s3.putObject({
                Bucket: audioBucketName,
                Key: audioKey,
                Body: pollyResponse.AudioStream
            }).promise();

            await dynamodb.put({
                TableName: metadataTableName,
                Item: {
                    'uuid': uuid,
                    'submissionTime': textObject.LastModified?.toISOString(),
                    'textKey': textKey,
                    'audioKey': audioKey,
                    'characters': pollyResponse.RequestCharacters,
                    'status': 'completed'
                }
            }).promise();

            console.log(`Process ${uuid} synthesized file ${textKey} with ${pollyResponse.RequestCharacters}, saved at: ${audioKey}`)
        }
    }

    return {
        statusCode: 200,
        body: JSON.stringify('Text to speech conversion completed successfully!'),
    };
};
Nada de novo aqui, é apenas nossa implementação para lidar com o evento sqs, pegar a chave do objeto s3 e então usar o Amazon Polly do aws-sdk para sintetizar o texto do arquivo em áudio. Ele salva os dados na tabela de metadados quando termina.

Passo 11: Utilize o construtor TextToSpeech

Agora, só precisamos importar nosso construtor recém-criado para o nosso arquivo de stack text-to-speech-app-stack.ts.

import { TextToSpeech } from './TextToSpeech/TextToSpeech';

// Bellow the addEventNotification 

    new TextToSpeech(this, 'TextToSpeech', {
      textBucket: textBucket,
      audioBucket: audioBucket,
      metadataTable: metadataTable,
      processingQueue: processingQueue
    });
Após esta configuração, podemos apenas implantar nossa Stack e testar se tudo está funcionando.

Passo 12: Implantar a Aplicação

Finalmente, construa e implante a aplicação:

npm run build
cdk deploy

Isso irá construir sua aplicação e implantá-la em sua conta AWS.

Testando nosso Aplicativo

Uma vez que o processo de implantação esteja completo, acesse sua conta e localize o bucket de texto. Coloque um arquivo de texto no bucket. Após um curto período de tempo, você poderá visualizar seu arquivo de áudio no bucket de áudio.

Para visualizar informações detalhadas sobre cada arquivo processado e o número de caracteres usados para sintetizar os textos que você enviou, consulte a tabela de metadados.

Se preferir, você pode baixar diretamente os arquivos de áudio da plataforma S3. Alternativamente, você tem a opção de explorar o caminho e integrar uma API com o CDK. Eu recomendo muito essa opção, pois é simples de implementar.

Considerações Finais e Exploração Adicional

Depois de explorar essa solução de texto para fala, estou animado com o potencial para criar mais soluções usando CDK e TypeScript. TypeScript é particularmente útil porque é uma linguagem tipada, o que se alinha bem com o desenvolvimento de backend e frontend e agora também com o desenvolvimento de infraestrutura. O CDK também oferece a oportunidade de usar a mesma linguagem para o código de infraestrutura. Além disso, estou interessado em explorar o CDK em outras linguagens de programação. Na minha opinião, o CDK simplifica muitos dos desafios anteriores que enfrentamos com ferramentas como terraform ou cloudFormation, que o CDK utiliza nos bastidores.

Se você tiver alguma ideia ou quiser discutir mais, por favor, me avise. Também agradeço qualquer feedback. Obrigado por se juntar a mim nesta jornada, e fique ligado para possíveis novas postagens em minhas páginas.