├── .editorconfig ├── .github └── workflows │ └── deploy.yml ├── .gitignore ├── README.md ├── aws-architecture.png ├── aws-cdk ├── constants.ts ├── index.ts ├── lib │ ├── apigateway.ts │ ├── cloudfront.ts │ ├── custom-resource.ts │ ├── dynamodb.ts │ ├── lambda.ts │ ├── s3.ts │ └── sns.ts ├── stack │ ├── lambda-edge-stack.ts │ └── main-stack.ts └── utils.ts ├── cdk.json ├── frontend.png ├── job-interview-example.mp3 ├── lerna.json ├── package.json ├── packages ├── backend │ ├── package.json │ ├── src │ │ ├── constants.ts │ │ ├── deploy │ │ │ ├── getCrossRegionCfn.ts │ │ │ └── modifyS3Path.ts │ │ ├── handlers │ │ │ ├── getAudios.ts │ │ │ ├── getToken.ts │ │ │ ├── newAudio.ts │ │ │ └── transcribeAudio.ts │ │ └── types.ts │ ├── tsconfig.json │ └── webpack.config.js └── frontend │ ├── .gitignore │ ├── package.json │ ├── public │ ├── favicon.ico │ ├── index.html │ └── manifest.json │ ├── src │ ├── App.css │ ├── App.test.tsx │ ├── App.tsx │ ├── components │ │ ├── AudioRecordTable.tsx │ │ ├── RecordAudio.tsx │ │ └── UploadAudio.tsx │ ├── constants.ts │ ├── index.css │ ├── index.tsx │ ├── registerServiceWorker.ts │ ├── types.ts │ └── utils.ts │ ├── tsconfig.json │ ├── tsconfig.prod.json │ ├── tsconfig.test.json │ ├── tslint.json │ └── typings.d.ts ├── tsconfig.json ├── tslint.json └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: https://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | # Unix-style newlines with a newline ending every file 7 | [*] 8 | end_of_line = lf 9 | insert_final_newline = true 10 | indent_style = space 11 | indent_size = 2 12 | 13 | [*.{js,jsx,ts,tsx}] 14 | indent_size = 4 15 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Deploy to AWS 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | paths: 8 | - "!*.md" 9 | 10 | jobs: 11 | build_deploy: 12 | runs-on: ubuntu-latest 13 | strategy: 14 | matrix: 15 | node-version: [10.x] 16 | steps: 17 | - uses: actions/checkout@master 18 | - uses: actions/setup-node@v1 19 | - name: Install yarn 20 | run: npm install -g yarn 21 | - name: Install dependencies 22 | run: yarn install 23 | - name: Build 24 | run: yarn build 25 | - name: Deploy 26 | run: | 27 | yarn bootstrap 28 | yarn deploy 29 | env: 30 | AWS_DEFAULT_REGION: ${{ secrets.AWS_DEFAULT_REGION }} 31 | AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} 32 | AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # package directories 2 | node_modules 3 | jspm_packages 4 | 5 | # yarn 6 | yarn-error.log 7 | 8 | # build 9 | dist 10 | 11 | # CDK compiled files 12 | aws-cdk/*.js 13 | aws-cdk/*.d.ts 14 | 15 | # CDK asset staging directory 16 | .cdk.staging 17 | cdk.out 18 | cfn.yml 19 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # AWS Transcribe 2 | 3 | ## Deploy to your own AWS 4 | 5 | - Setup AWS CLI and credential configuration (`aws configure`). 6 | - Run `yarn` to install all dependencies. 7 | - Run `yarn build` to build both front end and back end. 8 | - Run `yarn bootstrap` to initialize AWS CDK deployment. 9 | - Run `yarn deploy` to do the actual deployment. 10 | 11 | If the deployment is successful, the cloudfront URL will be displayed in the output like: 12 | 13 | ```bash 14 | Outputs: 15 | AwsTranscribeDemoStack.CloudFrontURL = xxx.cloudfront.net 16 | ``` 17 | 18 | ![](./frontend.png) 19 | ![](./aws-architecture.png) 20 | 21 | - Static website built by React and hosted on S3. 22 | - Upload audio file via website (click upload button): 23 | - Call `GetToken` Lambda function via API gateway to get pre-signed URL for Audio File bucket. 24 | - Use S3 JS SDK to upload audio file directly to S3 with the pre-signed URL returned above. 25 | - Newly uploaded audio file will trigger `NewAudio` Lambda function which will: 26 | - Create a record in DynamoDB 27 | - Publish the record ID to `NewAudio` Topic in SNS 28 | - SNS will trigger `TranscribeAudio` Lambda function to submit audio transcription job via Amazon Transcribe service API 29 | - Check audio transcription status via website (click search button): 30 | - Call `GetAudios` Lambda function via API gateway to get transcription status 31 | - After getting record ID from DB, check the corresponding transcription job via Amazon Transcribe service API. 32 | - If the job is ready, update the result URL to DB and return it to frontend. 33 | - If the job is still processing, do nothing. 34 | -------------------------------------------------------------------------------- /aws-architecture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/railsonsousa106/aws-transcribe/3c492ff41f86a9fc417950bde520e0aca6d3916b/aws-architecture.png -------------------------------------------------------------------------------- /aws-cdk/constants.ts: -------------------------------------------------------------------------------- 1 | export const LAMBDA_FUNCTIONS_DIST_FOLDER = './packages/backend/dist/'; 2 | export const WEBSITE_DIST_FOLDER = './packages/frontend/build'; 3 | 4 | export const API_PATH_PREFIX = 'api'; 5 | 6 | export const TAGS: { 7 | [key: string]: string; 8 | } = { 9 | appName: 'AWSTranscribeDemo', 10 | }; 11 | 12 | export const SUPPORTED_AUDIO_SUFFIX = ['.mp3', '.wav']; 13 | export const AUDIO_FILE_URL_PREFIX = 'audio-file'; 14 | export const TRANSCRIBED_TEXT_FILE_URL_PREFIX = 'transcription'; 15 | 16 | export const LAMBDA_EDGE_FUNC_PATH = `${LAMBDA_FUNCTIONS_DIST_FOLDER}/modifyS3Path/modifyS3Path.js`; 17 | 18 | export const LAMBDA_EDGE_STACK_NAME = 'LambdaEdgeStack'; 19 | export const LAMBDA_EDGE_ARN_OUTPUT_NAME = 'LambdaEdgeArnOutput'; 20 | export const LAMBDA_EDGE_VERSION_OUTPUT_NAME = 'LambdaEdgeVersionOutput'; 21 | export const LAMBDA_EDGE_REGION = 'us-east-1'; 22 | export const MAIN_STACK_NAME = 'AwsTranscribeDemoStack'; 23 | -------------------------------------------------------------------------------- /aws-cdk/index.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import cdk = require('@aws-cdk/core'); 3 | 4 | import { MainStack } from './stack/main-stack'; 5 | import { LambdaEdgeStack } from './stack/lambda-edge-stack'; 6 | import { 7 | MAIN_STACK_NAME, 8 | LAMBDA_EDGE_STACK_NAME, 9 | LAMBDA_EDGE_REGION, 10 | TAGS, 11 | } from './constants'; 12 | 13 | const app = new cdk.App(); 14 | /** 15 | * Lambda@Edge can be created only in us-east-1 region, that's why 16 | * we need to create a separate stack to handle this. 17 | */ 18 | const lambdaEdgeStack = new LambdaEdgeStack(app, LAMBDA_EDGE_STACK_NAME, { 19 | env: { 20 | region: LAMBDA_EDGE_REGION, 21 | }, 22 | tags: TAGS, 23 | }); 24 | 25 | // tslint:disable-next-line: no-unused-expression 26 | new MainStack(app, MAIN_STACK_NAME, { 27 | tags: TAGS, 28 | }).addDependency(lambdaEdgeStack, 'lambda edge'); 29 | -------------------------------------------------------------------------------- /aws-cdk/lib/apigateway.ts: -------------------------------------------------------------------------------- 1 | import cdk = require('@aws-cdk/core'); 2 | import lambda = require('@aws-cdk/aws-lambda'); 3 | import apiGateway = require('@aws-cdk/aws-apigateway'); 4 | 5 | import { API_PATH_PREFIX } from '../constants'; 6 | 7 | export interface APIGatewayProps { 8 | getUploadTokenFunc: lambda.Function; 9 | getAudiosFunc: lambda.Function; 10 | } 11 | 12 | export class APIGateways extends cdk.Construct { 13 | public readonly api: apiGateway.RestApi; 14 | 15 | constructor(scope: cdk.Construct, id: string, props: APIGatewayProps) { 16 | super(scope, id); 17 | 18 | this.api = new apiGateway.RestApi(scope, 'AWSTranscribeAPI', { 19 | deployOptions: { 20 | /** 21 | * Use "api" as stage name so it's easier for cloudfront mapping 22 | */ 23 | stageName: API_PATH_PREFIX, 24 | }, 25 | }); 26 | 27 | this.api.root 28 | .addResource('token') 29 | .addMethod( 30 | 'GET', 31 | new apiGateway.LambdaIntegration(props.getUploadTokenFunc) 32 | ); 33 | 34 | this.api.root 35 | .addResource('audios') 36 | .addMethod( 37 | 'GET', 38 | new apiGateway.LambdaIntegration(props.getAudiosFunc) 39 | ); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /aws-cdk/lib/cloudfront.ts: -------------------------------------------------------------------------------- 1 | import cdk = require('@aws-cdk/core'); 2 | import s3 = require('@aws-cdk/aws-s3'); 3 | import lambda = require('@aws-cdk/aws-lambda'); 4 | import apiGateway = require('@aws-cdk/aws-apigateway'); 5 | import cloudfront = require('@aws-cdk/aws-cloudfront'); 6 | import iam = require('@aws-cdk/aws-iam'); 7 | 8 | import { 9 | API_PATH_PREFIX, 10 | AUDIO_FILE_URL_PREFIX, 11 | TRANSCRIBED_TEXT_FILE_URL_PREFIX, 12 | } from '../constants'; 13 | 14 | export interface CloudFrontProps { 15 | staticWebsiteBucket: s3.Bucket; 16 | audioFileBucket: s3.Bucket; 17 | transcribedTextFileBucket: s3.Bucket; 18 | backendAPIGateway: apiGateway.RestApi; 19 | lambdaEdgeArn: string; 20 | lambdaEdgeVersion: string; 21 | } 22 | 23 | export class CloudFronts extends cdk.Construct { 24 | public readonly cloudfront: cloudfront.CloudFrontWebDistribution; 25 | 26 | constructor(scope: cdk.Construct, id: string, props: CloudFrontProps) { 27 | super(scope, id); 28 | 29 | const originAccessIdentity = new cloudfront.CfnCloudFrontOriginAccessIdentity( 30 | scope, 31 | 'OriginAccessIdentityForAWSTranscribeCloudFront', 32 | { 33 | cloudFrontOriginAccessIdentityConfig: { 34 | comment: 'OAI for AWSTranscribeDemo', 35 | }, 36 | } 37 | ); 38 | 39 | const modifyS3PathLambdaAssociation: cloudfront.LambdaFunctionAssociation = { 40 | eventType: cloudfront.LambdaEdgeEventType.ORIGIN_REQUEST, 41 | lambdaFunction: lambda.Version.fromVersionAttributes( 42 | scope, 43 | 'ModifyS3PathLambdaEdgeVersion', 44 | { 45 | version: props.lambdaEdgeVersion, 46 | lambda: lambda.Function.fromFunctionArn( 47 | scope, 48 | 'ModifyS3PathLambdaEdgeFunc', 49 | props.lambdaEdgeArn 50 | ), 51 | } 52 | ), 53 | }; 54 | 55 | const apiGatewayURL = props.backendAPIGateway.url; 56 | this.cloudfront = new cloudfront.CloudFrontWebDistribution( 57 | scope, 58 | 'AWSTranscribeCloudFront', 59 | { 60 | originConfigs: [ 61 | { 62 | s3OriginSource: { 63 | s3BucketSource: props.staticWebsiteBucket, 64 | originAccessIdentityId: originAccessIdentity.ref, 65 | }, 66 | behaviors: [ 67 | { 68 | isDefaultBehavior: true, 69 | forwardedValues: { queryString: true }, 70 | }, 71 | { 72 | pathPattern: '/index.html', 73 | forwardedValues: { 74 | queryString: true, 75 | }, 76 | defaultTtl: cdk.Duration.seconds(0), 77 | minTtl: cdk.Duration.seconds(0), 78 | maxTtl: cdk.Duration.seconds(0), 79 | }, 80 | ], 81 | }, 82 | { 83 | s3OriginSource: { 84 | s3BucketSource: props.audioFileBucket, 85 | originAccessIdentityId: originAccessIdentity.ref, 86 | }, 87 | behaviors: [ 88 | { 89 | pathPattern: `/${AUDIO_FILE_URL_PREFIX}/*`, 90 | lambdaFunctionAssociations: [ 91 | modifyS3PathLambdaAssociation, 92 | ], 93 | }, 94 | ], 95 | }, 96 | { 97 | s3OriginSource: { 98 | s3BucketSource: props.transcribedTextFileBucket, 99 | originAccessIdentityId: originAccessIdentity.ref, 100 | }, 101 | behaviors: [ 102 | { 103 | pathPattern: `/${TRANSCRIBED_TEXT_FILE_URL_PREFIX}/*`, 104 | lambdaFunctionAssociations: [ 105 | modifyS3PathLambdaAssociation, 106 | ], 107 | }, 108 | ], 109 | }, 110 | { 111 | customOriginSource: { 112 | /** 113 | * domainName can not have: 114 | * * "https://" 115 | * * stage name 116 | */ 117 | domainName: apiGatewayURL.substring( 118 | 'https://'.length, 119 | apiGatewayURL.indexOf('/', 'https://'.length) 120 | ), 121 | originProtocolPolicy: 122 | cloudfront.OriginProtocolPolicy.HTTPS_ONLY, 123 | }, 124 | behaviors: [ 125 | { 126 | pathPattern: `/${API_PATH_PREFIX}/*`, 127 | allowedMethods: 128 | cloudfront.CloudFrontAllowedMethods.ALL, 129 | forwardedValues: { 130 | queryString: true, 131 | }, 132 | defaultTtl: cdk.Duration.seconds(0), 133 | minTtl: cdk.Duration.seconds(0), 134 | maxTtl: cdk.Duration.seconds(0), 135 | }, 136 | ], 137 | }, 138 | ], 139 | } 140 | ); 141 | 142 | const allBuckets: s3.Bucket[] = [ 143 | props.staticWebsiteBucket, 144 | props.audioFileBucket, 145 | props.transcribedTextFileBucket, 146 | ]; 147 | // make sure cloudfront can access all 3 buckets 148 | allBuckets.forEach(sourceBucket => { 149 | const policyStatement = new iam.PolicyStatement(); 150 | policyStatement.addActions('s3:GetObject'); 151 | policyStatement.addResources(`${sourceBucket.bucketArn}/*`); 152 | policyStatement.addCanonicalUserPrincipal( 153 | originAccessIdentity.attrS3CanonicalUserId 154 | ); 155 | sourceBucket.addToResourcePolicy(policyStatement); 156 | }); 157 | // make sure cloudfront can upload file to "audioFileBucket" 158 | props.audioFileBucket.addCorsRule({ 159 | allowedMethods: [s3.HttpMethods.POST, s3.HttpMethods.PUT], 160 | /** 161 | * Use "https://*" to make sure response header will be 162 | * the requested URL. 163 | */ 164 | allowedOrigins: ['https://*'], 165 | allowedHeaders: ['*'], 166 | }); 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /aws-cdk/lib/custom-resource.ts: -------------------------------------------------------------------------------- 1 | import cdk = require('@aws-cdk/core'); 2 | import cfn = require('@aws-cdk/aws-cloudformation'); 3 | import lambda = require('@aws-cdk/aws-lambda'); 4 | import iam = require('@aws-cdk/aws-iam'); 5 | 6 | import { 7 | LAMBDA_FUNCTIONS_DIST_FOLDER, 8 | LAMBDA_EDGE_FUNC_PATH, 9 | } from '../constants'; 10 | import { getFileHash } from '../utils'; 11 | 12 | export interface CrossRegionCfnCustomResourceProps { 13 | lambdaEdgeStackName: string; 14 | lambdaEdgeArnOutputName: string; 15 | lambdaEdgeVersionOutputName: string; 16 | } 17 | 18 | // reference: https://lanwen.ru/posts/aws-cdk-edge-lambda/ 19 | export class CrossRegionCfnCustomResource extends cdk.Construct { 20 | public readonly lambdaEdgeOutput: { 21 | arn: string; 22 | version: string; 23 | }; 24 | 25 | constructor( 26 | scope: cdk.Construct, 27 | id: string, 28 | props: CrossRegionCfnCustomResourceProps 29 | ) { 30 | super(scope, id); 31 | 32 | const getCrossRegionCfnFunc = new lambda.SingletonFunction( 33 | this, 34 | 'GetCrossRegionCfnFunc', 35 | { 36 | /** 37 | * to avoid multiple lambda deployments in case we will use that 38 | * custom resource multiple times 39 | */ 40 | uuid: '9dc5bf6a-b1a3-4c37-83c2-a2fbf2323f2a', 41 | description: 'Function to get lambda@edge stack output.', 42 | code: lambda.Code.asset( 43 | `${LAMBDA_FUNCTIONS_DIST_FOLDER}/getCrossRegionCfn` 44 | ), 45 | handler: 'getCrossRegionCfn.handler', 46 | timeout: cdk.Duration.seconds(300), 47 | runtime: lambda.Runtime.NODEJS_10_X, 48 | } 49 | ); 50 | 51 | // getCrossRegionCfnFunc needs to have permission to describe stack 52 | getCrossRegionCfnFunc.addToRolePolicy( 53 | new iam.PolicyStatement({ 54 | resources: [ 55 | `arn:aws:cloudformation:*:*:stack/${props.lambdaEdgeStackName}/*`, 56 | ], 57 | actions: ['cloudformation:DescribeStacks'], 58 | }) 59 | ); 60 | 61 | const modifyS3PathFuncContentHash = getFileHash(LAMBDA_EDGE_FUNC_PATH); 62 | const crossRegionCfnCustomResource = new cfn.CustomResource( 63 | this, 64 | 'CrossRegionCfnCustomResource', 65 | { 66 | provider: cfn.CustomResourceProvider.lambda( 67 | getCrossRegionCfnFunc 68 | ), 69 | /** 70 | * Though the variable name inside `props` are beginning with lower 71 | * case, after CustomResource pass them to Lambda, it will begin with 72 | * upper case. 73 | */ 74 | properties: { 75 | ...props, 76 | // Change custom resource once lambda@edge code update 77 | lambdaHash: modifyS3PathFuncContentHash, 78 | }, 79 | } 80 | ); 81 | 82 | this.lambdaEdgeOutput = { 83 | arn: crossRegionCfnCustomResource.getAtt('arn').toString(), 84 | version: crossRegionCfnCustomResource.getAtt('version').toString(), 85 | }; 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /aws-cdk/lib/dynamodb.ts: -------------------------------------------------------------------------------- 1 | import cdk = require('@aws-cdk/core'); 2 | import dynamodb = require('@aws-cdk/aws-dynamodb'); 3 | 4 | export class DBTables extends cdk.Construct { 5 | public readonly audiosTable: dynamodb.Table; 6 | 7 | constructor(scope: cdk.Construct, id: string) { 8 | super(scope, id); 9 | 10 | this.audiosTable = new dynamodb.Table(scope, 'AudiosTable', { 11 | partitionKey: { name: 'id', type: dynamodb.AttributeType.STRING }, 12 | billingMode: dynamodb.BillingMode.PAY_PER_REQUEST, 13 | }); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /aws-cdk/lib/lambda.ts: -------------------------------------------------------------------------------- 1 | import cdk = require('@aws-cdk/core'); 2 | import lambda = require('@aws-cdk/aws-lambda'); 3 | import s3 = require('@aws-cdk/aws-s3'); 4 | import dynamodb = require('@aws-cdk/aws-dynamodb'); 5 | import sns = require('@aws-cdk/aws-sns'); 6 | import iam = require('@aws-cdk/aws-iam'); 7 | import { 8 | S3EventSource, 9 | SnsEventSource, 10 | } from '@aws-cdk/aws-lambda-event-sources'; 11 | 12 | import { 13 | SUPPORTED_AUDIO_SUFFIX, 14 | LAMBDA_FUNCTIONS_DIST_FOLDER, 15 | } from '../constants'; 16 | 17 | export interface LambdaFunctionsProps { 18 | audioFileBucket: s3.Bucket; 19 | newAudioTopic: sns.Topic; 20 | transcribedTextFileBucket: s3.Bucket; 21 | audiosTable: dynamodb.Table; 22 | } 23 | 24 | export class LambdaFunctions extends cdk.Construct { 25 | public readonly getUploadTokenFunc: lambda.Function; 26 | public readonly addAudioFunc: lambda.Function; 27 | public readonly getAudiosFunc: lambda.Function; 28 | public readonly transcribeAudioFunc: lambda.Function; 29 | 30 | private props: LambdaFunctionsProps; 31 | 32 | constructor(scope: cdk.Construct, id: string, props: LambdaFunctionsProps) { 33 | super(scope, id); 34 | this.props = props; 35 | 36 | const commonRuntimeProps = { 37 | runtime: lambda.Runtime.NODEJS_10_X, 38 | memorySize: 256, 39 | }; 40 | 41 | const commonEnv = { 42 | DB_TABLE_NAME: props.audiosTable.tableName, 43 | }; 44 | 45 | this.getUploadTokenFunc = new lambda.Function(scope, 'GetUploadToken', { 46 | ...commonRuntimeProps, 47 | description: 48 | 'Function to generate a temporary access credential for frontend to upload file to S3.', 49 | handler: 'getToken.handler', 50 | code: lambda.Code.asset(`${LAMBDA_FUNCTIONS_DIST_FOLDER}/getToken`), 51 | environment: { 52 | ...commonEnv, 53 | BUCKET_NAME: props.audioFileBucket.bucketName, 54 | }, 55 | }); 56 | 57 | this.addAudioFunc = new lambda.Function(scope, 'UploadAudio', { 58 | ...commonRuntimeProps, 59 | description: 60 | 'Function to insert audio record to DynamoDB after S3 upload.', 61 | handler: 'newAudio.handler', 62 | code: lambda.Code.asset(`${LAMBDA_FUNCTIONS_DIST_FOLDER}/newAudio`), 63 | environment: { 64 | ...commonEnv, 65 | SNS_TOPIC: props.newAudioTopic.topicArn, 66 | }, 67 | }); 68 | 69 | SUPPORTED_AUDIO_SUFFIX.forEach(suffix => { 70 | this.addAudioFunc.addEventSource( 71 | new S3EventSource(props.audioFileBucket, { 72 | events: [s3.EventType.OBJECT_CREATED], 73 | filters: [{ suffix }], 74 | }) 75 | ); 76 | }); 77 | 78 | this.getAudiosFunc = new lambda.Function(scope, 'GetAudios', { 79 | ...commonRuntimeProps, 80 | description: 'Function to get audio from DynamoDB.', 81 | handler: 'getAudios.handler', 82 | code: lambda.Code.asset( 83 | `${LAMBDA_FUNCTIONS_DIST_FOLDER}/getAudios` 84 | ), 85 | environment: { 86 | ...commonEnv, 87 | }, 88 | }); 89 | 90 | this.transcribeAudioFunc = new lambda.Function( 91 | scope, 92 | 'TranscribeAudio', 93 | { 94 | ...commonRuntimeProps, 95 | description: 96 | 'Function to transcribe audio to text using Amazon Transcribe.', 97 | handler: 'transcribeAudio.handler', 98 | code: lambda.Code.asset( 99 | `${LAMBDA_FUNCTIONS_DIST_FOLDER}/transcribeAudio` 100 | ), 101 | environment: { 102 | ...commonEnv, 103 | OUTPUT_BUCKET_NAME: 104 | props.transcribedTextFileBucket.bucketName, 105 | }, 106 | } 107 | ); 108 | this.transcribeAudioFunc.addEventSource( 109 | new SnsEventSource(props.newAudioTopic) 110 | ); 111 | } 112 | 113 | public grantPermissions( 114 | audiosTable: dynamodb.Table, 115 | newAudioTopic: sns.Topic 116 | ) { 117 | /** 118 | * "getUploadTokenFunc" needs to: 119 | * * write "audioFileBucket" (in order to create presigned URL) 120 | */ 121 | this.props.audioFileBucket.grantWrite(this.getUploadTokenFunc); 122 | 123 | /** 124 | * "addAudioFunc" needs to: 125 | * * access audio table 126 | * * publish sns 127 | */ 128 | audiosTable.grantReadWriteData(this.addAudioFunc); 129 | newAudioTopic.grantPublish(this.addAudioFunc); 130 | 131 | /** 132 | * "getAudiosFunc" needs to: 133 | * * access audio table 134 | * * AWS Transcribe service 135 | */ 136 | audiosTable.grantReadWriteData(this.getAudiosFunc); 137 | this.getAudiosFunc.addToRolePolicy( 138 | new iam.PolicyStatement({ 139 | resources: ['*'], 140 | actions: ['transcribe:*'], 141 | }) 142 | ); 143 | 144 | /** 145 | * "TranscribeAudioFunc" needs to: 146 | * * access audio table 147 | * * AWS Transcribe service 148 | * * read "audioFileBucket" 149 | * * write "transcribedTextFileBucket" 150 | */ 151 | audiosTable.grantReadWriteData(this.transcribeAudioFunc); 152 | this.props.audioFileBucket.grantRead(this.transcribeAudioFunc); 153 | this.props.transcribedTextFileBucket.grantWrite( 154 | this.transcribeAudioFunc 155 | ); 156 | this.transcribeAudioFunc.addToRolePolicy( 157 | new iam.PolicyStatement({ 158 | resources: ['*'], 159 | actions: ['transcribe:*'], 160 | }) 161 | ); 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /aws-cdk/lib/s3.ts: -------------------------------------------------------------------------------- 1 | import cdk = require('@aws-cdk/core'); 2 | import s3 = require('@aws-cdk/aws-s3'); 3 | import s3Deployment = require('@aws-cdk/aws-s3-deployment'); 4 | 5 | import { WEBSITE_DIST_FOLDER } from '../constants'; 6 | 7 | export class S3Buckets extends cdk.Construct { 8 | public readonly staticWebsiteBucket: s3.Bucket; 9 | public readonly audioFileBucket: s3.Bucket; 10 | public readonly transcribedTextFileBucket: s3.Bucket; 11 | 12 | constructor(scope: cdk.Construct, id: string) { 13 | super(scope, id); 14 | 15 | this.staticWebsiteBucket = new s3.Bucket(scope, 'StaticWebsiteBucket', { 16 | websiteIndexDocument: 'index.html', 17 | websiteErrorDocument: 'index.html', 18 | }); 19 | 20 | this.audioFileBucket = new s3.Bucket(scope, 'AudioFileBucket'); 21 | 22 | this.transcribedTextFileBucket = new s3.Bucket( 23 | scope, 24 | 'TranscribedTextFileBucket' 25 | ); 26 | 27 | // deploy frontend 28 | // tslint:disable-next-line: no-unused-expression 29 | new s3Deployment.BucketDeployment(scope, 'DeployWebsite', { 30 | source: s3Deployment.Source.asset(WEBSITE_DIST_FOLDER), 31 | destinationBucket: this.staticWebsiteBucket, 32 | }); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /aws-cdk/lib/sns.ts: -------------------------------------------------------------------------------- 1 | import cdk = require('@aws-cdk/core'); 2 | import sns = require('@aws-cdk/aws-sns'); 3 | 4 | export class SNSTopics extends cdk.Construct { 5 | public readonly newAudioTopic: sns.Topic; 6 | 7 | constructor(scope: cdk.Construct, id: string) { 8 | super(scope, id); 9 | 10 | this.newAudioTopic = new sns.Topic(scope, 'NewAudioTopic', { 11 | displayName: 'New audio upload topic', 12 | }); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /aws-cdk/stack/lambda-edge-stack.ts: -------------------------------------------------------------------------------- 1 | import cdk = require('@aws-cdk/core'); 2 | import lambda = require('@aws-cdk/aws-lambda'); 3 | import iam = require('@aws-cdk/aws-iam'); 4 | 5 | import { 6 | LAMBDA_FUNCTIONS_DIST_FOLDER, 7 | LAMBDA_EDGE_ARN_OUTPUT_NAME, 8 | LAMBDA_EDGE_VERSION_OUTPUT_NAME, 9 | LAMBDA_EDGE_FUNC_PATH, 10 | } from '../constants'; 11 | import { getFileHash } from '../utils'; 12 | 13 | export class LambdaEdgeStack extends cdk.Stack { 14 | constructor(scope: cdk.App, id: string, props?: cdk.StackProps) { 15 | super(scope, id, props); 16 | 17 | const modifyS3PathFunc = new lambda.Function(this, 'ModifyS3Path', { 18 | description: 19 | 'Lambda@Edge function to modify cloudfront path mapping.', 20 | runtime: lambda.Runtime.NODEJS_10_X, 21 | handler: 'modifyS3Path.handler', 22 | code: lambda.Code.asset( 23 | `${LAMBDA_FUNCTIONS_DIST_FOLDER}/modifyS3Path` 24 | ), 25 | role: new iam.Role(this, 'AllowLambdaServiceToAssumeRole', { 26 | assumedBy: new iam.CompositePrincipal( 27 | new iam.ServicePrincipal('lambda.amazonaws.com'), 28 | new iam.ServicePrincipal('edgelambda.amazonaws.com') 29 | ), 30 | // this is required for Lambda@Edge 31 | managedPolicies: [ 32 | { 33 | managedPolicyArn: 34 | 'arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole', 35 | }, 36 | ], 37 | }), 38 | }); 39 | 40 | // this way it updates version only in case lambda code changes 41 | const modifyS3PathFuncContentHash = getFileHash(LAMBDA_EDGE_FUNC_PATH); 42 | const modifyS3PathFuncContentVersion = modifyS3PathFunc.addVersion( 43 | `:sha256:${modifyS3PathFuncContentHash}` 44 | ); 45 | 46 | // tslint:disable-next-line: no-unused-expression 47 | new cdk.CfnOutput(this, 'ModifyS3PathFuncArn', { 48 | value: modifyS3PathFunc.functionArn, 49 | exportName: LAMBDA_EDGE_ARN_OUTPUT_NAME, 50 | }); 51 | // tslint:disable-next-line: no-unused-expression 52 | new cdk.CfnOutput(this, 'ModifyS3PathFuncVersion', { 53 | value: modifyS3PathFuncContentVersion.version, 54 | exportName: LAMBDA_EDGE_VERSION_OUTPUT_NAME, 55 | }); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /aws-cdk/stack/main-stack.ts: -------------------------------------------------------------------------------- 1 | import cdk = require('@aws-cdk/core'); 2 | 3 | import { S3Buckets } from '../lib/s3'; 4 | import { DBTables } from '../lib/dynamodb'; 5 | import { SNSTopics } from '../lib/sns'; 6 | import { LambdaFunctions } from '../lib/lambda'; 7 | import { APIGateways } from '../lib/apigateway'; 8 | import { CloudFronts } from '../lib/cloudfront'; 9 | import { CrossRegionCfnCustomResource } from '../lib/custom-resource'; 10 | import { 11 | LAMBDA_EDGE_STACK_NAME, 12 | LAMBDA_EDGE_ARN_OUTPUT_NAME, 13 | LAMBDA_EDGE_VERSION_OUTPUT_NAME, 14 | } from '../constants'; 15 | 16 | export interface MainStackProps extends cdk.StackProps {} 17 | 18 | export class MainStack extends cdk.Stack { 19 | constructor(scope: cdk.App, id: string, props: MainStackProps) { 20 | super(scope, id, props); 21 | 22 | const s3Buckets = new S3Buckets(this, 'AllS3Buckets'); 23 | const dynamoDBs = new DBTables(this, 'AllDBTables'); 24 | const snsTopics = new SNSTopics(this, 'AllSNSTopics'); 25 | 26 | const lambdaFunctions = new LambdaFunctions( 27 | this, 28 | 'AllLambdaFunctions', 29 | { 30 | audioFileBucket: s3Buckets.audioFileBucket, 31 | transcribedTextFileBucket: s3Buckets.transcribedTextFileBucket, 32 | newAudioTopic: snsTopics.newAudioTopic, 33 | audiosTable: dynamoDBs.audiosTable, 34 | } 35 | ); 36 | lambdaFunctions.grantPermissions( 37 | dynamoDBs.audiosTable, 38 | snsTopics.newAudioTopic 39 | ); 40 | 41 | const apiGateways = new APIGateways(this, 'AllAPIGateways', { 42 | getUploadTokenFunc: lambdaFunctions.getUploadTokenFunc, 43 | getAudiosFunc: lambdaFunctions.getAudiosFunc, 44 | }); 45 | 46 | const customResource = new CrossRegionCfnCustomResource( 47 | this, 48 | 'AllCustomResource', 49 | { 50 | lambdaEdgeStackName: LAMBDA_EDGE_STACK_NAME, 51 | lambdaEdgeArnOutputName: LAMBDA_EDGE_ARN_OUTPUT_NAME, 52 | lambdaEdgeVersionOutputName: LAMBDA_EDGE_VERSION_OUTPUT_NAME, 53 | } 54 | ); 55 | 56 | const cloudfronts = new CloudFronts(this, 'AllCloudFronts', { 57 | staticWebsiteBucket: s3Buckets.staticWebsiteBucket, 58 | audioFileBucket: s3Buckets.audioFileBucket, 59 | transcribedTextFileBucket: s3Buckets.transcribedTextFileBucket, 60 | backendAPIGateway: apiGateways.api, 61 | lambdaEdgeArn: customResource.lambdaEdgeOutput.arn, 62 | lambdaEdgeVersion: customResource.lambdaEdgeOutput.version, 63 | }); 64 | 65 | // tslint:disable-next-line: no-unused-expression 66 | new cdk.CfnOutput(this, 'CloudFrontURL', { 67 | value: cloudfronts.cloudfront.domainName, 68 | }); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /aws-cdk/utils.ts: -------------------------------------------------------------------------------- 1 | import crypto = require('crypto'); 2 | import fs = require('fs'); 3 | 4 | export function getFileHash(filePath: string) { 5 | const modifyS3PathFuncContent = fs.readFileSync(filePath); 6 | return crypto 7 | .createHash('sha256') 8 | .update(modifyS3PathFuncContent) 9 | .digest('base64'); 10 | } 11 | -------------------------------------------------------------------------------- /cdk.json: -------------------------------------------------------------------------------- 1 | { 2 | "app": "npx ts-node aws-cdk/index.ts" 3 | } 4 | -------------------------------------------------------------------------------- /frontend.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/railsonsousa106/aws-transcribe/3c492ff41f86a9fc417950bde520e0aca6d3916b/frontend.png -------------------------------------------------------------------------------- /job-interview-example.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/railsonsousa106/aws-transcribe/3c492ff41f86a9fc417950bde520e0aca6d3916b/job-interview-example.mp3 -------------------------------------------------------------------------------- /lerna.json: -------------------------------------------------------------------------------- 1 | { 2 | "packages": ["packages/*"], 3 | "version": "0.0.0", 4 | "npmClient": "yarn", 5 | "useWorkspaces": true 6 | } 7 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "workspaces": [ 4 | "packages/*" 5 | ], 6 | "engines": { 7 | "node": ">=10" 8 | }, 9 | "name": "aws-transcribe-demo", 10 | "version": "1.0.0", 11 | "description": "A simple AWS demo utilises Amazon Transcribe to convert audio to text and analyse.", 12 | "author": "Wenbo Jie ", 13 | "scripts": { 14 | "lerna": "lerna", 15 | "cdk": "cdk", 16 | "build": "lerna run build", 17 | "bootstrap": "cdk bootstrap", 18 | "deploy": "cdk deploy AwsTranscribeDemoStack", 19 | "debug": "ts-node aws-cdk/index.ts", 20 | "gen:cfn": "cdk synth > cfn.yml" 21 | }, 22 | "husky": { 23 | "hooks": { 24 | "pre-commit": "lint-staged" 25 | } 26 | }, 27 | "lint-staged": { 28 | "*.{js,json,css,md}": [ 29 | "prettier --write", 30 | "git add" 31 | ] 32 | }, 33 | "devDependencies": { 34 | "@aws-cdk/aws-apigateway": "^1.3.0", 35 | "@aws-cdk/aws-cloudfront": "^1.3.0", 36 | "@aws-cdk/aws-dynamodb": "^1.3.0", 37 | "@aws-cdk/aws-lambda": "^1.3.0", 38 | "@aws-cdk/aws-lambda-event-sources": "^1.3.0", 39 | "@aws-cdk/aws-iam": "^1.3.0", 40 | "@aws-cdk/aws-s3": "^1.3.0", 41 | "@aws-cdk/aws-s3-deployment": "^1.3.0", 42 | "@aws-cdk/aws-sns": "^1.3.0", 43 | "@aws-cdk/aws-sns-subscriptions": "^1.3.0", 44 | "@aws-cdk/core": "^1.3.0", 45 | "@types/node": "8.10.45", 46 | "aws-cdk": "^1.3.0", 47 | "husky": "^3.0.2", 48 | "lerna": "^3.16.4", 49 | "lint-staged": "^9.2.1", 50 | "prettier": "1.18.2", 51 | "ts-node": "^8.3.0", 52 | "tslint": "^5.18.0", 53 | "tslint-config-prettier": "^1.18.0", 54 | "typescript": "^3.5.3" 55 | }, 56 | "prettier": { 57 | "trailingComma": "es5", 58 | "singleQuote": true 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /packages/backend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "aws-transcribe-demo-backend", 3 | "version": "0.1.0", 4 | "description": "Lambda functions for AWS Transcribe Demo.", 5 | "license": "MIT", 6 | "scripts": { 7 | "build": "webpack" 8 | }, 9 | "devDependencies": { 10 | "@types/aws-lambda": "^8.10.31", 11 | "@types/cfn-response": "^1.0.0", 12 | "@types/node": "^12.6.8", 13 | "@types/uuid": "^3.4.5", 14 | "awesome-typescript-loader": "^5.2.1", 15 | "clean-webpack-plugin": "^3.0.0", 16 | "webpack": "^4.38.0", 17 | "webpack-cli": "^3.3.6" 18 | }, 19 | "dependencies": { 20 | "aws-lambda": "^1.0.6", 21 | "aws-sdk": "^2.502.0", 22 | "cfn-response": "^1.0.1", 23 | "uuid": "^3.3.2" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /packages/backend/src/constants.ts: -------------------------------------------------------------------------------- 1 | export const AUDIO_FILE_URL_PREFIX = 'audio-file'; 2 | export const TRANSCRIBED_TEXT_FILE_URL_PREFIX = 'transcription'; 3 | 4 | export const commonCORSHeader = { 5 | 'Access-Control-Allow-Origin': '*', // Required for CORS support to work 6 | 'Access-Control-Allow-Credentials': true, // Required for cookies, authorization headers with HTTPS 7 | }; 8 | -------------------------------------------------------------------------------- /packages/backend/src/deploy/getCrossRegionCfn.ts: -------------------------------------------------------------------------------- 1 | import { CloudFormation } from 'aws-sdk'; 2 | import { CloudFormationCustomResourceHandler } from 'aws-lambda'; 3 | import * as response from 'cfn-response'; 4 | 5 | export const handler: CloudFormationCustomResourceHandler = ( 6 | event, 7 | context 8 | ) => { 9 | console.log(event); 10 | const { 11 | RequestType, 12 | /** 13 | * When CustomResource pass parameter to Lambda, it will use 14 | * upper case as property name. 15 | */ 16 | ResourceProperties: { 17 | LambdaEdgeStackName, 18 | LambdaEdgeArnOutputName, 19 | LambdaEdgeVersionOutputName, 20 | }, 21 | } = event; 22 | 23 | if (RequestType === 'Delete') { 24 | return response.send(event, context, response.SUCCESS); 25 | } 26 | 27 | const cfn = new CloudFormation({ region: 'us-east-1' }); 28 | cfn.describeStacks( 29 | { StackName: LambdaEdgeStackName }, 30 | (err, { Stacks }) => { 31 | if (err) { 32 | console.log('Error during stack describe:\n', err); 33 | return response.send(event, context, response.FAILED, err); 34 | } 35 | 36 | console.log(Stacks[0].Outputs); 37 | const arnOutput = Stacks[0].Outputs.find( 38 | out => out.ExportName === LambdaEdgeArnOutputName 39 | ); 40 | if (!arnOutput) { 41 | console.log( 42 | `Can not find the ExportName: ${LambdaEdgeArnOutputName}` 43 | ); 44 | return response.send(event, context, response.FAILED); 45 | } 46 | const versionOutput = Stacks[0].Outputs.find( 47 | out => out.ExportName === LambdaEdgeVersionOutputName 48 | ); 49 | if (!versionOutput) { 50 | console.log( 51 | `Can not find the ExportName: ${LambdaEdgeVersionOutputName}` 52 | ); 53 | return response.send(event, context, response.FAILED); 54 | } 55 | 56 | response.send(event, context, response.SUCCESS, { 57 | arn: arnOutput.OutputValue, 58 | version: versionOutput.OutputValue, 59 | }); 60 | } 61 | ); 62 | }; 63 | -------------------------------------------------------------------------------- /packages/backend/src/deploy/modifyS3Path.ts: -------------------------------------------------------------------------------- 1 | import path = require('path'); 2 | import { CloudFrontRequestHandler } from 'aws-lambda'; 3 | 4 | import { 5 | AUDIO_FILE_URL_PREFIX, 6 | TRANSCRIBED_TEXT_FILE_URL_PREFIX, 7 | } from '../constants'; 8 | 9 | export const handler: CloudFrontRequestHandler = (event, _, callback) => { 10 | console.log(event); 11 | 12 | const { request } = event.Records[0].cf; 13 | console.log('Original request: ', request); 14 | 15 | const parsedPath = path.parse(request.uri); 16 | if (request.origin.s3) { 17 | if (parsedPath.dir.endsWith(AUDIO_FILE_URL_PREFIX)) { 18 | console.log( 19 | 'Target for AudioFileBucket: strip "audio-file/" prefix.' 20 | ); 21 | request.uri = request.uri.replace(`${AUDIO_FILE_URL_PREFIX}/`, ''); 22 | } else if (parsedPath.dir.endsWith(TRANSCRIBED_TEXT_FILE_URL_PREFIX)) { 23 | console.log( 24 | 'Target for TranscribedTextFileBucket: strip "transcription/" prefix.' 25 | ); 26 | request.uri = request.uri.replace( 27 | `${TRANSCRIBED_TEXT_FILE_URL_PREFIX}/`, 28 | '' 29 | ); 30 | } 31 | } 32 | 33 | console.log('Modified request: ', request); 34 | 35 | // Return to CloudFront 36 | return callback(null, request); 37 | }; 38 | -------------------------------------------------------------------------------- /packages/backend/src/handlers/getAudios.ts: -------------------------------------------------------------------------------- 1 | import { APIGatewayProxyHandler } from 'aws-lambda'; 2 | import { DynamoDB, TranscribeService } from 'aws-sdk'; 3 | 4 | import { AUDIO_PROCESS_STATUS } from '../types'; 5 | import { commonCORSHeader } from '../constants'; 6 | 7 | export const handler: APIGatewayProxyHandler = async event => { 8 | console.log(event); 9 | const { DB_TABLE_NAME } = process.env; 10 | 11 | const recordId = event.queryStringParameters 12 | ? event.queryStringParameters.recordId 13 | : '*'; 14 | 15 | // Querying records in DynamoDB table 16 | const dynamoDb = new DynamoDB.DocumentClient(); 17 | let results; 18 | if (recordId === '*') { 19 | results = await dynamoDb 20 | .scan({ 21 | TableName: DB_TABLE_NAME, 22 | }) 23 | .promise(); 24 | } else { 25 | results = await dynamoDb 26 | .query({ 27 | ExpressionAttributeValues: { 28 | ':id': recordId, 29 | }, 30 | KeyConditionExpression: 'id = :id', 31 | TableName: DB_TABLE_NAME, 32 | }) 33 | .promise(); 34 | } 35 | console.log('results:', results); 36 | 37 | // check the status for each items 38 | const transcribe = new TranscribeService(); 39 | const newResults = await Promise.all( 40 | results.Items.map( 41 | async (item: DynamoDB.DocumentClient.AttributeMap) => { 42 | console.log('item:', item); 43 | if (item.status === AUDIO_PROCESS_STATUS.PROCESSING) { 44 | const job = await transcribe 45 | .getTranscriptionJob({ 46 | TranscriptionJobName: item.id, 47 | }) 48 | .promise(); 49 | console.log('job:', job); 50 | if ( 51 | job.TranscriptionJob.TranscriptionJobStatus !== 52 | 'IN_PROGRESS' 53 | ) { 54 | const isSuccess = 55 | job.TranscriptionJob.TranscriptionJobStatus === 56 | 'COMPLETED'; 57 | let updateExpression; 58 | let expressionAttributeValues; 59 | let expressionAttributeNames; 60 | if (isSuccess) { 61 | expressionAttributeNames = { 62 | '#s': 'status', 63 | }; 64 | updateExpression = 'set #s = :s, textUrl = :t'; 65 | expressionAttributeValues = { 66 | ':s': AUDIO_PROCESS_STATUS.TRANSCRIBED, 67 | ':t': 68 | job.TranscriptionJob.Transcript 69 | .TranscriptFileUri, 70 | }; 71 | } else { 72 | expressionAttributeNames = { 73 | '#s': 'status', 74 | '#e': 'error', 75 | }; 76 | updateExpression = 'set #s = :s, #e = :e'; 77 | expressionAttributeValues = { 78 | ':s': AUDIO_PROCESS_STATUS.TRANSCRIBE_FAILED, 79 | ':e': job.TranscriptionJob.FailureReason, 80 | }; 81 | } 82 | const updatedAudio = await dynamoDb 83 | .update({ 84 | Key: { 85 | id: item.id, 86 | }, 87 | UpdateExpression: updateExpression, 88 | ExpressionAttributeNames: expressionAttributeNames, 89 | ExpressionAttributeValues: expressionAttributeValues, 90 | TableName: DB_TABLE_NAME, 91 | ReturnValues: 'ALL_NEW', 92 | }) 93 | .promise(); 94 | console.log('updateAudio:', updatedAudio); 95 | return updatedAudio.Attributes; 96 | } 97 | } 98 | return Promise.resolve(item); 99 | } 100 | ) 101 | ); 102 | 103 | return { 104 | body: JSON.stringify(newResults), 105 | headers: commonCORSHeader, 106 | statusCode: 200, 107 | }; 108 | }; 109 | -------------------------------------------------------------------------------- /packages/backend/src/handlers/getToken.ts: -------------------------------------------------------------------------------- 1 | import { APIGatewayProxyHandler } from 'aws-lambda'; 2 | import { S3 } from 'aws-sdk'; 3 | 4 | import { commonCORSHeader } from '../constants'; 5 | 6 | export const handler: APIGatewayProxyHandler = async event => { 7 | console.log(event); 8 | 9 | const s3 = new S3(); 10 | /** 11 | * In order to make sure the presigned data generated can be used 12 | * to upload file to bucket, the role for this lambda must have write 13 | * access to the bucket. 14 | */ 15 | const presignedData = await s3.createPresignedPost({ 16 | Bucket: process.env.BUCKET_NAME, 17 | Fields: { 18 | key: event.queryStringParameters.key, 19 | 'Content-Type': event.queryStringParameters.type, 20 | }, 21 | Expires: 900, 22 | }); 23 | 24 | console.log('presign:', presignedData); 25 | 26 | return { 27 | body: JSON.stringify(presignedData), 28 | headers: commonCORSHeader, 29 | statusCode: 200, 30 | }; 31 | }; 32 | -------------------------------------------------------------------------------- /packages/backend/src/handlers/newAudio.ts: -------------------------------------------------------------------------------- 1 | import * as uuid from 'uuid'; 2 | import { DynamoDB, SNS } from 'aws-sdk'; 3 | import { S3Handler } from 'aws-lambda'; 4 | 5 | import { AUDIO_PROCESS_STATUS } from '../types'; 6 | 7 | export const SPEAKER_COUNT_REGEX = /^(.*)_SPEAKER(\d+)\.(mp3|MP3|wav|WAV)$/; 8 | 9 | export const handler: S3Handler = async event => { 10 | console.log(event.Records[0].s3); 11 | const { DB_TABLE_NAME, SNS_TOPIC } = process.env; 12 | 13 | const region = event.Records[0].awsRegion; 14 | const bucketName = event.Records[0].s3.bucket.name; 15 | const fileName = event.Records[0].s3.object.key; 16 | 17 | const recordId = uuid.v1(); 18 | const timestamp = new Date().getTime(); 19 | 20 | // Creating new record in DynamoDB table 21 | const dynamoDb = new DynamoDB.DocumentClient(); 22 | await dynamoDb 23 | .put({ 24 | TableName: DB_TABLE_NAME, 25 | Item: { 26 | id: recordId, 27 | status: AUDIO_PROCESS_STATUS.UPLOADED, 28 | speakers: Number(fileName.match(SPEAKER_COUNT_REGEX)[2]), 29 | audioUrl: `https://s3-${region}.amazonaws.com/${bucketName}/${fileName}`, 30 | createdAt: timestamp, 31 | updatedAt: timestamp, 32 | }, 33 | }) 34 | .promise(); 35 | 36 | // Sending notification about new post to SNS 37 | const sns = new SNS(); 38 | await sns 39 | .publish({ 40 | Message: recordId, 41 | TopicArn: SNS_TOPIC, 42 | }) 43 | .promise(); 44 | }; 45 | -------------------------------------------------------------------------------- /packages/backend/src/handlers/transcribeAudio.ts: -------------------------------------------------------------------------------- 1 | import { SNSHandler } from 'aws-lambda'; 2 | import { DynamoDB, TranscribeService } from 'aws-sdk'; 3 | 4 | import { AUDIO_PROCESS_STATUS } from '../types'; 5 | 6 | export const handler: SNSHandler = async event => { 7 | console.log(event.Records[0].Sns); 8 | const { DB_TABLE_NAME, OUTPUT_BUCKET_NAME } = process.env; 9 | 10 | const recordId = event.Records[0].Sns.Message; 11 | 12 | // Creating new record in DynamoDB table 13 | const dynamoDb = new DynamoDB.DocumentClient(); 14 | const audioItem = await dynamoDb 15 | .get({ 16 | Key: { 17 | id: recordId, 18 | }, 19 | TableName: DB_TABLE_NAME, 20 | }) 21 | .promise(); 22 | console.log('audioItem:', audioItem); 23 | 24 | // start transcribe 25 | const transcribe = new TranscribeService(); 26 | const filePathParts = audioItem.Item.audioUrl.split('.'); 27 | const fileExt = filePathParts[filePathParts.length - 1].toLowerCase(); 28 | const job = await transcribe 29 | .startTranscriptionJob({ 30 | TranscriptionJobName: recordId, 31 | LanguageCode: 'en-US', 32 | MediaFormat: fileExt, 33 | Media: { 34 | MediaFileUri: audioItem.Item.audioUrl, 35 | }, 36 | OutputBucketName: OUTPUT_BUCKET_NAME, 37 | Settings: { 38 | ShowSpeakerLabels: true, 39 | MaxSpeakerLabels: audioItem.Item.speakers, 40 | }, 41 | }) 42 | .promise(); 43 | console.log('job:', job); 44 | 45 | // write transcription job id back to db 46 | const updatedItem = await dynamoDb 47 | .update({ 48 | Key: { 49 | id: recordId, 50 | }, 51 | UpdateExpression: 'set #s = :s', 52 | ExpressionAttributeValues: { 53 | ':s': AUDIO_PROCESS_STATUS.PROCESSING, 54 | }, 55 | ExpressionAttributeNames: { 56 | '#s': 'status', 57 | }, 58 | TableName: DB_TABLE_NAME, 59 | ReturnValues: 'ALL_NEW', 60 | }) 61 | .promise(); 62 | console.log('updatedItem:', updatedItem); 63 | }; 64 | -------------------------------------------------------------------------------- /packages/backend/src/types.ts: -------------------------------------------------------------------------------- 1 | export enum AUDIO_PROCESS_STATUS { 2 | UPLOADED = 'Uploaded', 3 | PROCESSING = 'Processing', 4 | TRANSCRIBED = 'Transcribed', 5 | TRANSCRIBE_FAILED = 'TranscribeFailed', 6 | } 7 | -------------------------------------------------------------------------------- /packages/backend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "module": "commonjs", 5 | "target": "es6", 6 | "moduleResolution": "node", 7 | "outDir": "dist", 8 | "baseUrl": "." 9 | }, 10 | "include": ["src/**/*"] 11 | } 12 | -------------------------------------------------------------------------------- /packages/backend/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const { CheckerPlugin } = require('awesome-typescript-loader'); 3 | const { CleanWebpackPlugin } = require('clean-webpack-plugin'); 4 | 5 | module.exports = { 6 | entry: { 7 | 'getAudios/getAudios': './src/handlers/getAudios.ts', 8 | 'newAudio/newAudio': './src/handlers/newAudio.ts', 9 | 'transcribeAudio/transcribeAudio': './src/handlers/transcribeAudio.ts', 10 | 'getToken/getToken': './src/handlers/getToken.ts', 11 | 'modifyS3Path/modifyS3Path': './src/deploy/modifyS3Path.ts', 12 | 'getCrossRegionCfn/getCrossRegionCfn': 13 | './src/deploy/getCrossRegionCfn.ts', 14 | }, 15 | output: { 16 | filename: '[name].js', 17 | path: path.resolve(__dirname, 'dist'), 18 | library: 'index', 19 | libraryTarget: 'commonjs2', 20 | }, 21 | resolve: { 22 | extensions: ['.ts', '.js'], 23 | }, 24 | target: 'async-node', 25 | module: { 26 | rules: [ 27 | { 28 | test: /\.ts$/, 29 | loader: 'awesome-typescript-loader', 30 | }, 31 | ], 32 | }, 33 | plugins: [new CleanWebpackPlugin(), new CheckerPlugin()], 34 | mode: 'production', 35 | }; 36 | -------------------------------------------------------------------------------- /packages/frontend/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | 6 | # testing 7 | /coverage 8 | 9 | # production 10 | /build 11 | 12 | # misc 13 | .DS_Store 14 | .env.local 15 | .env.development.local 16 | .env.test.local 17 | .env.production.local 18 | 19 | npm-debug.log* 20 | yarn-debug.log* 21 | yarn-error.log* 22 | -------------------------------------------------------------------------------- /packages/frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "aws-transcribe-demo-frontend", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "aws-sdk": "^2.502.0", 7 | "axios": "^0.19.0", 8 | "react": "^16.8.6", 9 | "react-dom": "^16.8.6", 10 | "react-scripts-ts": "3.1.0", 11 | "semantic-ui-css": "^2.4.1", 12 | "semantic-ui-react": "^0.87.3" 13 | }, 14 | "scripts": { 15 | "start": "react-scripts-ts start", 16 | "build": "react-scripts-ts build", 17 | "test": "react-scripts-ts test --env=jsdom", 18 | "eject": "react-scripts-ts eject" 19 | }, 20 | "devDependencies": { 21 | "@types/jest": "^24.0.16", 22 | "@types/node": "^12.6.8", 23 | "@types/react": "^16.8.23", 24 | "@types/react-dom": "^16.8.5", 25 | "typescript": "^3.5.3" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /packages/frontend/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/railsonsousa106/aws-transcribe/3c492ff41f86a9fc417950bde520e0aca6d3916b/packages/frontend/public/favicon.ico -------------------------------------------------------------------------------- /packages/frontend/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | 10 | 14 | 15 | 16 | 25 | AWS Transcribe Demo 26 | 27 | 28 | 29 | 32 |
33 | 43 | 44 | 45 | -------------------------------------------------------------------------------- /packages/frontend/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": "./index.html", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /packages/frontend/src/App.css: -------------------------------------------------------------------------------- 1 | .App-logo { 2 | animation: App-logo-spin infinite 20s linear; 3 | height: 80px; 4 | } 5 | 6 | .App-header { 7 | background-color: #222; 8 | padding: 20px; 9 | color: white; 10 | } 11 | 12 | .App-title { 13 | font-size: 1.5em; 14 | } 15 | 16 | .App-body { 17 | padding: 20px; 18 | } 19 | 20 | .word:hover { 21 | background-color: orange; 22 | } 23 | 24 | .error-status { 25 | text-decoration: underline; 26 | color: red; 27 | } 28 | -------------------------------------------------------------------------------- /packages/frontend/src/App.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import * as ReactDOM from 'react-dom'; 3 | import { App } from './App'; 4 | 5 | it('renders without crashing', () => { 6 | const div = document.createElement('div'); 7 | ReactDOM.render(, div); 8 | ReactDOM.unmountComponentAtNode(div); 9 | }); 10 | -------------------------------------------------------------------------------- /packages/frontend/src/App.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import './App.css'; 4 | import { UploadAudio } from './components/UploadAudio'; 5 | import { AudioRecordTable } from './components/AudioRecordTable'; 6 | 7 | export function App() { 8 | return ( 9 |
10 |
11 |

AWS Transcribe Demo

12 |
13 |
14 | 15 | 16 |
17 |
18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /packages/frontend/src/components/AudioRecordTable.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { 3 | Header, 4 | Form, 5 | Table, 6 | Segment, 7 | Container, 8 | Message, 9 | Popup, 10 | Button, 11 | Modal, 12 | } from 'semantic-ui-react'; 13 | import axios from 'axios'; 14 | 15 | import { 16 | API_PATH_PREFIX, 17 | TRANSCRIBED_TEXT_FILE_URL_PREFIX, 18 | AUDIO_FILE_URL_PREFIX, 19 | } from '../constants'; 20 | import { getAudioType, getFileName } from '../utils'; 21 | import { AudioRecord, TranscriptionJSON, AUDIO_PROCESS_STATUS } from '../types'; 22 | 23 | const SPEAKER_COLORS = [ 24 | 'teal', 25 | 'blue', 26 | 'orange', 27 | 'yellow', 28 | 'green', 29 | 'violet', 30 | 'purple', 31 | 'pink', 32 | 'brown', 33 | ]; 34 | 35 | export interface AudioRecordTableProps {} 36 | 37 | export interface AudioRecordTableState { 38 | searchText: string; 39 | searchResults: AudioRecord[]; 40 | /** the audio file whose transcription is opened */ 41 | activeAudioFile?: string; 42 | transcription?: TranscriptionJSON['results']; 43 | } 44 | 45 | export class AudioRecordTable extends React.Component< 46 | AudioRecordTableProps, 47 | AudioRecordTableState 48 | > { 49 | constructor(props: AudioRecordTableProps) { 50 | super(props); 51 | this.state = { 52 | searchText: '', 53 | searchResults: [], 54 | }; 55 | } 56 | 57 | public render() { 58 | const { 59 | searchText, 60 | searchResults, 61 | transcription, 62 | activeAudioFile, 63 | } = this.state; 64 | return ( 65 | 66 |
Check transcription status
67 |
68 | 69 | 81 | 82 | 83 |
84 | {searchResults.length > 0 && ( 85 | 86 | 87 | 88 | ID 89 | 90 | Audio file name 91 | 92 | Status 93 | Audio 94 | Text 95 | 96 | 97 | 98 | 99 | {searchResults 100 | .sort((a, b) => b.updatedAt - a.updatedAt) 101 | .map(result => this.getRow(result))} 102 | 103 |
104 | )} 105 | 111 | {`Transcription for ${activeAudioFile}`} 112 | 113 | 114 | {this.formatTranscription(transcription)} 115 | 116 | 117 | 118 |
119 | ); 120 | } 121 | 122 | private getRow(result: AudioRecord) { 123 | const audioFileName = getFileName(result.audioUrl || ''); 124 | return ( 125 | 126 | {result.id} 127 | {audioFileName} 128 | 129 | {result.status === 130 | AUDIO_PROCESS_STATUS.TRANSCRIBE_FAILED ? ( 131 | 134 | {result.status} 135 | 136 | } 137 | content={result.error} 138 | /> 139 | ) : ( 140 | result.status 141 | )} 142 | 143 | 144 | {result.audioUrl && ( 145 | 151 | )} 152 | 153 | 154 | {result.textUrl && ( 155 | 162 | )} 163 | 164 | 165 | ); 166 | } 167 | 168 | private formatTranscription(transcription: TranscriptionJSON['results']) { 169 | if (!transcription) { 170 | return null; 171 | } 172 | const words = transcription.items; 173 | let wordIndex = 0; 174 | const speakerSegments = transcription.speaker_labels.segments; 175 | return speakerSegments.map((segment, segmentIndex) => { 176 | let sentenceEndTime; 177 | if (segmentIndex < speakerSegments.length - 1) { 178 | sentenceEndTime = Number( 179 | speakerSegments[segmentIndex + 1].start_time 180 | ); 181 | } else { 182 | sentenceEndTime = Number(segment.end_time); 183 | } 184 | 185 | const contents = []; 186 | let shouldStop = false; 187 | while (!shouldStop) { 188 | const wordItem = words[wordIndex]; 189 | if ( 190 | wordItem && 191 | (!wordItem.end_time || 192 | Number(wordItem.end_time) <= sentenceEndTime) 193 | ) { 194 | wordIndex++; 195 | const confidence = wordItem.alternatives[0].confidence; 196 | const isPunctuation = confidence === null; 197 | // put a space before every word except punctuation 198 | const word = `${isPunctuation ? '' : ' '}${ 199 | wordItem.alternatives[0].content 200 | }`; 201 | if (isPunctuation) { 202 | contents.push({word}); 203 | } else { 204 | contents.push( 205 | {word}} 207 | content={`confidence: ${confidence}`} 208 | /> 209 | ); 210 | } 211 | } else { 212 | shouldStop = true; 213 | } 214 | } 215 | 216 | return ( 217 | 227 | {segment.speaker_label} 228 |

{...contents}

229 |
230 | ); 231 | }); 232 | } 233 | 234 | private handleSearch = () => { 235 | const { searchText } = this.state; 236 | axios 237 | .get( 238 | `/${API_PATH_PREFIX}/audios?recordId=${searchText || '*'}` 239 | ) 240 | .then(results => { 241 | this.setState({ searchResults: results.data }); 242 | }); 243 | }; 244 | 245 | private handleSearchTextChange = ( 246 | e: React.ChangeEvent 247 | ) => { 248 | const searchText = e.target.value; 249 | this.setState({ 250 | searchText, 251 | }); 252 | }; 253 | 254 | private handleTextOpen(textUrl: string) { 255 | if (textUrl) { 256 | const textFileName = getFileName(textUrl); 257 | axios 258 | .get( 259 | `/${TRANSCRIBED_TEXT_FILE_URL_PREFIX}/${textFileName}` 260 | ) 261 | .then(result => { 262 | this.setState({ 263 | transcription: result.data.results, 264 | }); 265 | }); 266 | } 267 | } 268 | 269 | private handleModalClose = () => { 270 | this.setState({ 271 | transcription: undefined, 272 | }); 273 | }; 274 | } 275 | -------------------------------------------------------------------------------- /packages/frontend/src/components/RecordAudio.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Form, Button, Segment } from 'semantic-ui-react'; 3 | 4 | import { RECORD_STATUS } from '../types'; 5 | 6 | export interface RecordAudioProps {} 7 | 8 | export interface RecordAudioState { 9 | recordStatus: RECORD_STATUS; 10 | recordedAudioURL: string; 11 | } 12 | 13 | export class RecordAudio extends React.Component< 14 | RecordAudioProps, 15 | RecordAudioState 16 | > { 17 | private mediaRecorder: any; 18 | 19 | constructor(props: RecordAudioProps) { 20 | super(props); 21 | this.state = { 22 | recordStatus: RECORD_STATUS.DONE, 23 | recordedAudioURL: '', 24 | }; 25 | } 26 | 27 | public render() { 28 | const { recordStatus, recordedAudioURL } = this.state; 29 | return ( 30 | 31 |
32 | 33 | 34 | 35 |