├── .gitignore ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── DEMO.md ├── LICENSE ├── README.md ├── SETUP.md ├── cdk-stacks ├── .gitignore ├── .npmignore ├── README.md ├── bin │ └── cdk-stacks.ts ├── cdk.json ├── config │ ├── config.params.json │ ├── configure.js │ └── ssm-params-util.ts ├── jest.config.js ├── lambdas │ └── custom-resources │ │ └── frontend-config │ │ └── index.py ├── lib │ ├── cdk-backend-stack.ts │ ├── cdk-frontend-stack.ts │ ├── frontend │ │ ├── frontend-config-stack.ts │ │ └── frontend-s3-deployment-stack.ts │ └── infrastructure │ │ └── cognito-stack.ts ├── package-lock.json ├── package.json ├── test │ └── cdk-stacks.test.ts └── tsconfig.json ├── diagrams ├── AmazonConnectV2V-AmazonConnectV2VArchitecture.png ├── AmazonConnectV2V-EmbeddedCCP.png ├── AmazonConnectV2V-NoStreamingAddOns.png ├── AmazonConnectV2V-WithStreamingAddOns.png ├── AmazonConnectV2V.drawio └── AmazonConnectV2VScreenshot.png └── webapp ├── .gitignore ├── adapters ├── pollyAdapter.js ├── transcribeAdapter.js └── translateAdapter.js ├── assets ├── background_noise.wav ├── chime-sound-7143.mp3 ├── javascript.svg └── speech_20241113001759828.mp3 ├── config.js ├── constants.js ├── index.html ├── lib └── connect-rtc-1.1.26.min.js ├── main.js ├── managers ├── AudioContextManager.js ├── AudioStreamManager.js ├── InputTestManager.js └── SessionTrackManager.js ├── package-lock.json ├── package.json ├── public └── vite.svg ├── style.css ├── utils ├── authUtility.js ├── commonUtility.js └── transcribeUtils.js └── vite.config.js /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | #ignore cdk-exports.json, as it's generated by CDK 9 | cdk-exports.json 10 | 11 | # testing 12 | /coverage 13 | 14 | # production 15 | /build 16 | 17 | # misc 18 | .DS_Store 19 | .env.local 20 | .env.development.local 21 | .env.test.local 22 | .env.production.local 23 | .eslintcache 24 | /.vscode 25 | .vscode 26 | 27 | npm-debug.log* 28 | yarn-debug.log* 29 | yarn-error.log* 30 | 31 | # General 32 | .DS_Store 33 | .AppleDouble 34 | .LSOverride 35 | 36 | # Icon must end with two \r 37 | Icon 38 | 39 | 40 | # Thumbnails 41 | ._* 42 | 43 | # Files that might appear in the root of a volume 44 | .DocumentRevisions-V100 45 | .fseventsd 46 | .Spotlight-V100 47 | .TemporaryItems 48 | .Trashes 49 | .VolumeIcon.icns 50 | .com.apple.timemachine.donotpresent 51 | 52 | # Directories potentially created on remote AFP share 53 | .AppleDB 54 | .AppleDesktop 55 | Network Trash Folder 56 | Temporary Items 57 | .apdisk -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | ## Code of Conduct 2 | This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct). 3 | For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact 4 | opensource-codeofconduct@amazon.com with any additional questions or comments. 5 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing Guidelines 2 | 3 | Thank you for your interest in contributing to our project. Whether it's a bug report, new feature, correction, or additional 4 | documentation, we greatly value feedback and contributions from our community. 5 | 6 | Please read through this document before submitting any issues or pull requests to ensure we have all the necessary 7 | information to effectively respond to your bug report or contribution. 8 | 9 | 10 | ## Reporting Bugs/Feature Requests 11 | 12 | We welcome you to use the GitHub issue tracker to report bugs or suggest features. 13 | 14 | When filing an issue, please check existing open, or recently closed, issues to make sure somebody else hasn't already 15 | reported the issue. Please try to include as much information as you can. Details like these are incredibly useful: 16 | 17 | * A reproducible test case or series of steps 18 | * The version of our code being used 19 | * Any modifications you've made relevant to the bug 20 | * Anything unusual about your environment or deployment 21 | 22 | 23 | ## Contributing via Pull Requests 24 | Contributions via pull requests are much appreciated. Before sending us a pull request, please ensure that: 25 | 26 | 1. You are working against the latest source on the *main* branch. 27 | 2. You check existing open, and recently merged, pull requests to make sure someone else hasn't addressed the problem already. 28 | 3. You open an issue to discuss any significant work - we would hate for your time to be wasted. 29 | 30 | To send us a pull request, please: 31 | 32 | 1. Fork the repository. 33 | 2. Modify the source; please focus on the specific change you are contributing. If you also reformat all the code, it will be hard for us to focus on your change. 34 | 3. Ensure local tests pass. 35 | 4. Commit to your fork using clear commit messages. 36 | 5. Send us a pull request, answering any default questions in the pull request interface. 37 | 6. Pay attention to any automated CI failures reported in the pull request, and stay involved in the conversation. 38 | 39 | GitHub provides additional document on [forking a repository](https://help.github.com/articles/fork-a-repo/) and 40 | [creating a pull request](https://help.github.com/articles/creating-a-pull-request/). 41 | 42 | 43 | ## Finding contributions to work on 44 | Looking at the existing issues is a great way to find something to contribute on. As our projects, by default, use the default GitHub issue labels (enhancement/bug/duplicate/help wanted/invalid/question/wontfix), looking at any 'help wanted' issues is a great place to start. 45 | 46 | 47 | ## Code of Conduct 48 | This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct). 49 | For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact 50 | opensource-codeofconduct@amazon.com with any additional questions or comments. 51 | 52 | 53 | ## Security issue notifications 54 | If you discover a potential security issue in this project we ask that you notify AWS/Amazon Security via our [vulnerability reporting page](http://aws.amazon.com/security/vulnerability-reporting/). Please do **not** create a public github issue. 55 | 56 | 57 | ## Licensing 58 | 59 | See the [LICENSE](LICENSE) file for our project's licensing. We will ask you to confirm the licensing of your contribution. 60 | -------------------------------------------------------------------------------- /DEMO.md: -------------------------------------------------------------------------------- 1 | # Amazon Connect Voice to Voice (V2V) Translation Demo Guide 2 | 3 | ## Table of Contents 4 | 5 | - [Demo UI Guide](#demo-ui-guide) 6 | - [Audio Streaming Add-Ons](#audio-streaming-add-ons) 7 | - [Amazon Transcribe Partial Results Stability](#amazon-transcribe-partial-results-stability) 8 | 9 | ## Demo UI Guide 10 | 11 | # Amazon Connect Voice to Voice (V2V) Translation Demo Guide 12 | 13 | **Important:** In the Audio Controls panel, select the microphone and speaker device that you use to engage with customer voice calls in Amazon Connect. 14 | 15 | **Note:** For demo purposes, you can also test different streaming behaviors that influence the customer experience: 16 | 17 | - **Stream File** - only streams a pre-recorded file to the customer (could be used if there was a technical issue, or to greet the customer while the agent gets ready) 18 | - **Stream Mic** - only streams the actual agent microphone to the customer (the default behaviour) 19 | - **Remove Audio Stream** - acts as a mute button, by removing the current audio stream (file / mic / translated voice) 20 | 21 | **The user interface consists of four sections:** 22 | 23 | 1. The Amazon Connect CCP (framed softphone client) 24 | 2. The Customer to Agent interaction controls (Transcribe Customer Voice, Translate Customer Voice, Synthesize Customer Voice) 25 | 3. The Agent to Customer interaction controls (Transcribe Agent Voice, Translate Agent Voice, Synthesize Agent Voice) 26 | 4. The Transcription panel displays real-time transcribed and translated text during the call, to enable monitoring of transcription/translation accuracy, and identifying where Amazon Transcribe Custom vocabularies and/or Amazon Translate custom terminologies might improve accuracy (for the demo purposes, transcripts are ephemeral and reset with each new call). 27 | 28 | ![Amazon Connect V2V Screenshot](diagrams/AmazonConnectV2VScreenshot.png) 29 | 30 | Before placing an incoming call, configure the following: 31 | 32 | - In the **Customer Controls** panel, under **Transcribe Customer Voice**: 33 | 34 | - Select the language being spoken by the customer (can be pre-configured for demo purposes) 35 | - Select Amazon Transcribe Partial Results Stability (see **Amazon Transcribe Partial Results Stability** section) 36 | - Click "Save" for each selection 37 | - Check "Stream Customer Mic to Agent" to allow agent to hear customer's original voice 38 | - Check "Stream Customer Translation to Customer" to allow customer to hear their translated speech 39 | - Check "Enable Audio Feedback" to enable streaming of pre-recorded "contact center background noise" (at a very low volume, and automatically muted when agent reply is being delivered to customer). 40 | - These options are recommended for a more natural conversation experience (see **Audio Streaming Add-Ons** section) 41 | 42 | - In the **Customer Controls** panel, under **Translate Customer Voice**: 43 | 44 | - Select Amazon Translate **From** Language: the customer's native language 45 | - Select Amazon Translate **To** Language: the agent's native language 46 | - Click "Save" for each selection 47 | 48 | - In the **Customer Controls** panel, under **Synthesize Customer Voice**: 49 | 50 | - Select Amazon Polly Language: the agent's native language 51 | - Select Amazon Polly Engine: standard, neural, or generative 52 | - Select Amazon Polly Voice that is going to be used to synthesize customer's translated speech 53 | - Click "Save" for each selection 54 | 55 | - In the **Agent Controls** panel, under: **Transcribe Agent Voice**: 56 | 57 | - Select the language being spoken by the agent (can be pre-configured and saved based on the agent's preference) 58 | - Select Amazon Transcribe Partial Results Stability (see **Amazon Transcribe Partial Results Stability** section) 59 | - Click "Save" for each selection 60 | - Check "Stream Agent Translation to Agent" to allow agent to hear their translated speech 61 | - Check "Stream Agent Mic to Customer" to allow customer to hear agent's original voice (use the slider to adjust Microphone Volume) 62 | - These options are recommended for a more natural conversation experience (see **Audio Streaming Add-Ons** section) 63 | 64 | - In the **Agent Controls** panel, under: **Translate Agent Voice**: 65 | 66 | - Select Amazon Translate **From** Language: the agent's native language 67 | - Select Amazon Translate **To** Language: the customer's native language 68 | - Click "Save" for each selection 69 | 70 | - In the **Agent Controls** panel, under: **Synthesize Agent Voice**: 71 | - Select Amazon Polly Language: the customer's native language 72 | - Select Amazon Polly Engine: standard, neural, or generative 73 | - Select Amazon Polly Voice that is going to be used to synthesize agents's translated speech 74 | - Click "Save" for each selection 75 | 76 | Once all parameters are configured, place an incoming call to your Amazon Connect instance, and follow the steps: 77 | 78 | - Answer the incoming customer call using the embedded softphone client (Amazon Connect CCP) 79 | - In the **Customer Controls** panel, under **Transcribe Customer Voice**, click "Start Transcription" to start transcribing customer's voice 80 | - In the **Agent Controls** panel, under: **Transcribe Agent Voice**, click "Start Transcription" to start transcribing agent's voice 81 | 82 | **Note:** 83 | For demo purposes, at the bottom of the screen, there are 3 Audio elements. Those audio elements would normally be hidden in a production environment. 84 | 85 | 1. **From Customer** - customer's actual voice, at a lower volume (if "Stream Customer Mic to Agent" checked) 86 | 2. **To Customer** - audio stream that customer hears (muted for the agent), which includes: 87 | - translated and synthesized agent's speech 88 | - agent's actual voice, at a lower volume (if "Stream Agent Mic to Customer" checked) 89 | - translated and synthesized customer's speech, at a lower volume (if "Stream Customer Translation to Customer" checked) 90 | - contact center background noise (if "Enable Audio Feedback" checked) 91 | 3. **To Agent** - audio stream that agent hears, which includes: 92 | - translated and synthesized customer's speech 93 | - translated and synthesized agent's speech, at a lower volume (if "Stream Agent Translation to Agent" checked) 94 | - contact center background noise (if "Enable Audio Feedback" checked) 95 | 96 | ## Audio Streaming Add-Ons 97 | 98 | Amazon Connect V2V sample project was designed to minimise the audio processing time from the moment customer/agent finishes speaking until the translated audio stream is started. However, the customer/agent experience still doesn't match the experience of a real conversation when both are speaking the same language. This is due to the specific pattern of customer only hearing agent's translated speech, while agent only hearing customer's translated speech. The following diagram displays that pattern: 99 | 100 | ![Amazon Connect V2V No Streaming Add-Ons](diagrams/AmazonConnectV2V-NoStreamingAddOns.png) 101 | 102 | As displayed on the diagram: 103 | 104 | - Customer starts speaking in their own language, and speak for 10 seconds 105 | - Because agent would only hear customer's translated speech, the agent "hears" 10 seconds of silence 106 | - Once customer finishes (their sentence), the audio processing time takes between 1 and 2 seconds, during which both customer and agent "hears" silence 107 | - The customer's translated speech is then streamed to the agent. During that time, the customer "hears" silence 108 | - Once customer's translated speech playback is completed, the agent starts speaking, and speaks for 10 seconds 109 | - Because customer would only hear agent's translated speech, the customer "hears" 10 seconds of silence 110 | - Once agent finishes (their sentence), the audio processing time takes between 1 and 2 seconds, during which both customer and agent "hears" silence 111 | - The agent's translated speech is then streamed to the agent. During that time, the agent "hears" silence 112 | 113 | In this scenario, the customer "hears" a single block of 22 to 24 seconds of a complete silence, from the moment the customer finished speaking, until the customer hears the agent's translated voice. This creates a suboptimal experience, since the customer is not really sure what is happening during these 22 to 24 seconds, for instance, have the agent heard them or if there was a technical issue. 114 | 115 | To optimise the customer/agent experience, Amazon Connect V2V sample project implements "Audio Streaming Add-Ons", to simulate a more natural conversation experience. In a face-to-face conversation scenario, between two persons who do not speak the same language, we normally have another person "in the middle", a Translator. In this scenario: 116 | 117 | - Person_A speaks in their own language, which is heard both by Person_B and the Translator 118 | - The Translator then translates to the Person_B's language. The translation is heard by both Person_B and Person_A 119 | - Essentially, Person_A and Person_B both hear each other speaking their own language, and they hear the translation (from the Translator). There's no "waiting in silence" moment, which is even more important in non face-to-face conversation. 120 | 121 | Amazon Connect V2V sample project implementation of the above is displayed on the following diagram: 122 | 123 | ![Amazon Connect V2V With Streaming Add-Ons](diagrams/AmazonConnectV2V-WithStreamingAddOns.png) 124 | 125 | As displayed on the diagram: 126 | 127 | - Customer starts speaking in their own language, and speaks for 10 seconds 128 | - The agent hears the customer's original voice, at a lower volume ("Stream Customer Mic to Agent" checked) 129 | - Once customer finishes (their sentence), the audio processing time takes between 1 and 2 seconds. During that time, both customer and agent hear a subtle audio feedback - "contact center background noise", at a very low volume ("Enable Audio Feedback" checked) 130 | - The customer's translated speech is then streamed to the agent. During that time, the customer hears their translated speech, at a lower volume ("Stream Customer Translation to Customer" checked) 131 | - Once customer's translated speech playback is completed, the agent starts speaking, and speaks for 10 seconds 132 | - The customer hears the agent's original voice, at a lower volume ("Stream Agent Mic to Customer" checked) 133 | - Once agent finishes (their sentence), the audio processing time takes between 1 and 2 seconds. During that time both customer and agent hear a subtle audio feedback - "contact center background noise", at a very low volume ("Enable Audio Feedback" checked) 134 | - The agent's translated speech is then streamed to the agent. During that time, the agent hears their translated speech, at a lower volume ("Stream Agent Translation to Agent" checked) 135 | 136 | In this scenario, the customer hears 2 short blocks (1 to 2 seconds) of a subtle audio feedback, instead of 1 single block of 22 to 24 seconds of a complete silence. This pattern is much closed to the real face-to-face conversation that includes a translator. Besides that, there is a couple of more benefits of using the "Audio Streaming Add-Ons" feature: 137 | 138 | - In case when the agent/customer only hear their translated and synthesized speech, the actual voice characteristics are lost. For instance, the agent cannot hear if the customer was talking slow, or fast, if the customer was upset, or calm etc. The translated and synthesized speech does not carry over that information. 139 | - In case when call recording is enabled, only customer's original voice and only agent's synthesized speech would be recorded, since the translation and the synthetization is done on the agent (client) side. This makes it difficult for QA teams to properly evaluate and/or audit the conversations, including a lot of silent blocks within it. Instead, when "Audio Streaming Add-Ons" are enabled, there are no silent blocks, and the QA team could hear agent's original voice, customer's original voice, and their respective translated and synthesized speech, all in a single audio file. 140 | - Having both original and translated speech are available in the call recording, makes it easier to detect specific words that would benefit of improve transcription accuracy (by using Amazon Transcribe Custom vocabularies), and/or translation accuracy (using Amazon Translate custom terminologies), to make sure that your brand names, character names, model names, and other unique content get transcribed and translated to the desired result. 141 | 142 | ## Amazon Transcribe Partial Results Stability 143 | 144 | ### How Real-Time Transcription Works 145 | 146 | The Amazon Connect Voice to Voice translation sample project leverages Amazon Transcribe real-time streaming, using 2 individual audio streams for transcription. It streams agent's voice and customer's voice each in its own (websocket) connection. This enables setting separate languages for the agent and customer to achieve the best possible transcription quality. Streaming media (voice) is delivered to Amazon Transcribe in real-time, and Amazon Transcribe returns both agent's and customer's transcripts, also in real-time. 147 | 148 | Because streaming works in real time, transcripts are produced as partial results. Amazon Transcribe breaks up the incoming audio stream based on natural speech segments, such as pauses in the audio: natural breaks between sentences or thoughts during speech. Amazon Transcribe continues outputting partial results until it generates the final transcription result for a speech segment. The Demo UI updates in real-time using these "partial results" as soon as a transcription event (transcribed word) is received. 149 | 150 | Since speech recognition may revise words as it gains more context, streaming transcriptions can change slightly with each new partial result output. For this reason, the "partial results" are not instantly sent to Amazon Translate. Additionally, translating "word by word" typically doesn't yield the best results, as better translations occur when there's more context (i.e., more words or a complete sentence). 151 | 152 | To improve translation quality, only "final segments" are sent to Amazon Translate, allowing the speaker to finish their sentence (make a pause), providing more context for better translation. This doesn't mean the speaker needs to wait for their voice to be translated and streamed to the listener (half-duplex). Instead, the speaker can continue speaking, and each time they pause or finish a sentence, that "final segment" is sent to Amazon Translate and Amazon Polly, with playback to the listener starting instantly. Even while the listener is hearing the current "final segment," the speaker can continue speaking. The current segment playback won't be interrupted, thanks to a "queueing" mechanism that ensures one audio segment isn't interrupted by another incoming segment. This means the "translator" is effectively "listening" and "speaking" simultaneously, similar to a regular face-to-face conversation. 153 | 154 | ### Balancing Segment Length 155 | 156 | When using real-time voice-to-voice translation, finding a good balance of segment length is crucial. Longer segments result in better translation (since there's more context), but shorter segments provide quicker turnaround between the original and translated voice. This is where Amazon Transcribe Partial Results Stability can help. 157 | 158 | When partial-result stabilization is enabled, Amazon Transcribe still starts returning "partial results" as soon as possible (which update the UI), but it changes how Amazon Transcribe produces the final transcription result for each complete segment, in a way where only the last few words from the partial results can change. The transcript is returned faster than without partial-result stabilization, though transcription accuracy may be affected. 159 | 160 | ### Optimizing Translation with Logical Speech Segments 161 | 162 | Using the **Amazon Transcribe Partial Results Stability** selection, you can individually set the stability level for both Customer and Agent transcription to low, medium, or high (none by default): 163 | 164 | - Low stability provides the highest accuracy 165 | - High stability transcribes faster, but with slightly lower accuracy 166 | 167 | Items (words) flagged as not stable are more likely to change as the segment is transcribed, while items flagged as stable won't change. More information can be found in the Amazon Transcribe Official documentation. The UI renders both stable and non-stable words, so the UI updates align with speech. 168 | 169 | As mentioned above, translating "word by word" would not be optimal. Therefore, despite some words being flagged as stable, they aren't sent to Amazon Translate instantly. Instead, we look for logical speech segments to send to Amazon Translate. During partial transcript processing, we identify punctuations (`[",", ".", "!", "?"]`) to determine logical speech segments and only send stable logical segments to Amazon Translate. 170 | 171 | For example: 172 | 173 | - A sentence "Today is a sunny day." is sent as a single request to Amazon Translate 174 | - A longer sentence "Today is a sunny day, but I'm not sure if it's going to rain." is sent in 2 requests: 175 | 1. "Today is a sunny day," (sent as soon as all the words before the punctuation are flagged as stable) 176 | 2. "but I'm not sure if it's going to rain." (sent while the first part is playback, so the whole sentence is translated faster) 177 | 178 | This approach ensures that longer speech without pauses is chunked into smaller logical segments and processed as soon as they are flagged as stable. 179 | You can experiment with different Amazon Transcribe Partial Results Stability levels (none/low/medium/high) to select the one that best fits your use case. 180 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT No Attribution 2 | 3 | Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so. 10 | 11 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 12 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 13 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 14 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 15 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 16 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 17 | 18 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Amazon Connect Voice to Voice (V2V) Translation 2 | 3 | **BE AWARE:** This code base is a sample project designed to provide a demonstration and a base to start from for specific use cases. It's intent is to enable users to define the appropriate customer experience relevant to their business requirements and customer base by providing options to turn on and off specific experiences or adjust volumes independently. It should not be considered Production-ready. 4 | 5 | ## Use-case scenario 6 | 7 | Businesses today face the challenge of providing effective customer support to a diverse global customer base with varying language preferences. Traditional approaches, such as text-based support, third party translation services, and multilingual contact centre agents, often result in suboptimal, inconsistent customer experiences and increased operational costs. 8 | 9 | Amazon Connect is an AI-powered application that provides one seamless experience for your contact center customers and users. Customers can leverage the extensibility of Amazon Connect APIs to integrate with other AWS services, such as Amazon Translate, to enable an optimized agent and unified customer experience across text based communications, such as chat and SMS, with easy extensibility to the email channel. The final frontier in true omni-channel translation capabilities has been voice. 10 | 11 | The Voice to Voice translation sample project leverages advanced speech recognition and machine translation technologies to enable near real-time translation of voice conversations between agents and customers. By using this sample project as a starter, businesses can develop an Amazon Connect powered solution allowing agents to communicate with customers in their preferred languages without the need for language proficiency or additional staffing. 12 | 13 | ## Here's a high-level overview of how the solution works: 14 | 15 | 1. Speech Recognition: The customer's spoken language is captured and converted into text using speech recognition technology. This text is then fed into the machine translation engine. 16 | 2. Machine Translation: The machine translation engine translates the customer's text into the agent's preferred language in near real-time. The translated text is then converted back into speech using text-to-speech synthesis. 17 | 3. Bidirectional Translation: The process is reversed for the agent's response, translating their speech into the customer's language and delivering the translated audio to the customer. 18 | 4. Seamless Integration: The Voice to Voice translation sample project integrates with Amazon Connect, enabling agents to handle customer interactions in multiple languages without any additional effort or training, using the below libraries: 19 | - [**Amazon Connect Streams JS**](https://github.com/amazon-connect/amazon-connect-streams): 20 | - Integrate your existing web applications with Amazon Connect 21 | - Embed Contact Control Panel (CCP) into a web page 22 | - Use the default built-in interface, or build your own from scratch 23 | - [**Amazon Connect RTC JS**](https://github.com/aws/connect-rtc-js): 24 | - Provides softphone support to Amazon Connect 25 | - Implements Amazon Connect WebRTC protocol and integrates with browser WebRTC APIs 26 | - Simple contact session interface which can be integrated with Amazon Connect Streams JS 27 | - In a typical Amazon Connect Streams JS integration, Amazon Connect RTC JS is not required 28 | - In this sample project, Amazon Connect RTC JS provides access to Amazon Connect WebRTC Media Streams 29 | - These 2 libraries are imported into Demo Webapp, without any modifications/customisations. 30 | 31 | ### Key limitations 32 | 33 | - This is a sample project and it's not meant to be used in production environment(s) 34 | - Webapp Authentication is implemented via simple redirect to Amazon Cognito Managed Login Page(s) 35 | - For demo purposes, SSO/SAML federation with Amazon Cognito is not supported 36 | - For demo purposes, Amazon Cognito and Amazon Connect are not integrated 37 | - For demo purposes, Amazon Cognito User Pool and Identity Pool credentials are stored in browser's local storage (can be vulnerable to cross-site scripting (XSS) attacks) 38 | - Both Agent Audio and Customer Audio are transcribed locally (agent's browser opening 2 websocket connections to Amazon Transcribe), therefore agent PC performance and network bandwidth need to be checked 39 | - The demo Webapp provides a full control on Voice to Voice setup (i.e. selecting From and To languages, Amazon Polly voices, etc). These parameters would normally be set based on Amazon Connect Contact Attributes 40 | - The sample project has not been tested with outbound calls, conference or transfers 41 | - The sample project has not been tested in combination with other channels, such as chat, tasks, email 42 | - When configuring regions for Amazon Transcribe/Translate/Polly, select a region that is closer to your agents (Amazon Connect instance can be in a different region) 43 | 44 | ## Solution architecture: 45 | 46 | ### Typical Amazon Connect CCP embedded to a custom webapp 47 | 48 | ![Amazon Connect CCP embedded](diagrams/AmazonConnectV2V-EmbeddedCCP.png) 49 | 50 | ### Amazon Connect Voice 2 Voice architecture: 51 | 52 | ![Amazon Connect V2V](diagrams/AmazonConnectV2V-AmazonConnectV2VArchitecture.png) 53 | 54 | ## Solution setup 55 | 56 | For detailed deployment instructions, solution components, and configuration details, please refer to the [Setup Guide](SETUP.md). 57 | 58 | ## Demo UI Guide 59 | 60 | For detailed instructions on navigating the demo web application, configuring transcription/translation settings, and understanding the user interface, please refer to the [Demo Guide](DEMO.md). 61 | 62 | ## Security 63 | 64 | See [CONTRIBUTING](CONTRIBUTING.md#security-issue-notifications) for more information. 65 | 66 | ## License 67 | 68 | This library is licensed under the MIT-0 License. See the LICENSE file. 69 | -------------------------------------------------------------------------------- /SETUP.md: -------------------------------------------------------------------------------- 1 | # Amazon Connect Voice to Voice (V2V) Translation Setup Guide 2 | 3 | ## Table of Contents 4 | 5 | - [Solution components](#solution-components) 6 | - [Solution prerequisites](#solution-prerequisites) 7 | - [Solution setup](#solution-setup) 8 | - [Test Webapp locally](#test-webapp-locally) 9 | - [Clean up](#clean-up) 10 | - [Demo Webapp key components](#demo-webapp-key-components) 11 | 12 | ## Solution components 13 | 14 | On a high-level, the solution consists of the following components, each contained in these folders: 15 | 16 | - **webapp** - Demo Web Application 17 | - **cdk-stacks** - AWS CDK stacks: 18 | - `cdk-backend-stack` with all the backend resources needed for the solution (Amazon Cognito, etc) 19 | - `cdk-front-end-stack` with front-end resources for hosting the webapp (Amazon S3, Amazon CloudFront distribution) 20 | 21 | ## Solution prerequisites 22 | 23 | - AWS Account 24 | - [AWS IAM user](https://docs.aws.amazon.com/IAM/latest/UserGuide/id_users_create.html) with Administrator permissions 25 | - Amazon Connect instance 26 | - [Node](https://nodejs.org/) (v20) and [NPM](https://docs.npmjs.com/downloading-and-installing-node-js-and-npm) (v10) installed and configured on your computer 27 | - [AWS CLI](https://docs.aws.amazon.com/cli/latest/userguide/cli-chap-getting-started.html) (v2) installed and configured on your computer 28 | - [AWS CDK](https://docs.aws.amazon.com/cdk/v2/guide/getting_started.html) (v2) installed and configured on your computer 29 | 30 | ## Solution setup 31 | 32 | The below instructions show how to deploy the solution using AWS CDK CLI. If you are using a Windows device please use the [Git BASH](https://gitforwindows.org/) terminal and use alternative commands where highlighted. 33 | 34 | These instructions assume you have completed all the prerequisites, and you have an existing Amazon Connect instance. 35 | 36 | 1. Clone the solution to your computer (using `git clone`) 37 | 38 | 2. Check AWS CLI 39 | 40 | - AWS CDK will use AWS CLI local credentials and region 41 | - check your AWS CLI configuration by running an AWS CLI command (e.g. `aws s3 ls`) 42 | - you can also use profiles (i.e. `export AWS_PROFILE=<>`) 43 | - you can confirm the configured region with 44 | `aws ec2 describe-availability-zones --output text --query 'AvailabilityZones[0].[RegionName]'` 45 | 46 | 3. Install NPM packages 47 | 48 | - Open your Terminal and navigate to `connect-v2v-translation-with-cx-options/cdk-stacks` 49 | - Run `npm run install:all` 50 | - This script goes through all packages of the solution and installs necessary modules (webapp, cdk-stacks) 51 | 52 | 4. Configure CDK stacks 53 | 54 | - In your terminal, navigate to `connect-v2v-translation-with-cx-options/cdk-stacks` 55 | - To see the full instructions for the configuration script, run 56 | `npm run configure:help` 57 | - For the purpose of this guide, start the configuration script in interactive mode which will guide you through each input one at a time. 58 | (Note, it is possible to configure it via single command, by directly providing parameters, as described in the script help instructions) 59 | 60 | `npm run configure` 61 | 62 | - When prompted, provide the following parameters: 63 | - `cognito-domain-prefix`: Amazon Cognito hosted UI domain prefix, where users will be redirected during the login process. The domain prefix has to be unique, between 1 and 63 characters long, contains no special characters, and no keywords: `aws`, `amazon`, or `cognito` (RegEx pattern: `^[a-z0-9](?:[a-z0-9\-]{0,61}[a-z0-9])?$`). You could put your Amazon Connect Instance Alias to it, for example: connect-v2v-instance-alias 64 | - `cognito-callback-urls`: Please provide a callback URL for the Amazon Cognito authorization server to call after users are authenticated. For now, set it as `https://localhost:5173`, we will come back to it once our front-end is deployed. 65 | - `cognito-logout-urls`: Please provide a logout URL where user is to be redirected after logging out. For now, set it as `https://localhost:5173`, we will come back to it once our front-end is deployed. 66 | - `connect-instance-url`: Amazon Connect instance URL that solution will use. For example: `https://connect-instance-alias.my.connect.aws` (or `https://connect-instance-alias.awsapps.com`) 67 | - `connect-instance-region`: Amazon Connect instance Region that solution will use. For example: us-east-1 68 | - `transcribe-region`: Amazon Transcribe Region that solution will use. For example: us-east-1 69 | - `translate-region`: Amazon Translate Region that solution will use. For example: us-east-1 70 | - `translate-proxy-enabled`: When enabled, webapp requests to Amazon Translate are proxied through Amazon Cloudfront (recommended to avoid CORS) 71 | - `polly-region`: Amazon Polly Region that solution will use. For example: us-east-1 72 | - `polly-proxy-enabled`: When enabled, webapp requests to Amazon Polly are proxied through Amazon Cloudfront (recommended to avoid CORS) 73 | 74 | 5. Deploy CDK stacks 75 | 76 | - In your terminal, navigate to navigate to `connect-v2v-translation-with-cx-options/cdk-stacks` 77 | - Run the script: `npm run build:webapp` (remember to complete this step whenever you want to deploy new front end changes) 78 | - **On Windows devices use `npm run build:webapp:gitbash`**. 79 | - This script builds frontend applications (webapp) 80 | - If you have started with a new environment, please bootstrap CDK: `cdk bootstrap` 81 | - Run the script: `npm run cdk:deploy` 82 | - **On Windows devices use `npm run cdk:deploy:gitbash`**. 83 | - This script deploys CDK stacks 84 | - Wait for all resources to be provisioned before continuing to the next step 85 | - AWS CDK output will be provided in your Terminal. You should see the Amazon Cognito User Pool Id as `userPoolId` from your Backend stack, 86 | and Amazon CloudFront Distribution URL as `webAppURL` from your Frontend stack. 87 | **Save these values as you will be using them in the next few steps.** 88 | 89 | 6. Configure Amazon Connect Approved Origins 90 | 91 | - Login into your AWS Console 92 | - Navigate to Amazon Connect -> Your instance alias -> Approved origins 93 | - Click **Add Domain** 94 | - Enter the domain of your web application, in this case Amazon CloudFront Distribution URL. For instance: `https://aaaabbbbcccc.cloudfront.net` 95 | - Click **Add Domain** 96 | 97 | 7. Create Cognito User 98 | 99 | - To create an Amazon Cognito user, you'll need Cognito User Pool Id (created in step 5 - check for the AWS CDK Output, or check it in your AWS Console > Cognito User Pools) 100 | - Create an Amazon Cognito user either user directly in the [Cognito Console](https://docs.aws.amazon.com/cognito/latest/developerguide/how-to-create-user-accounts.html#creating-a-new-user-using-the-users-tab) or by executing: 101 | `aws cognito-idp admin-create-user --region <> --user-pool-id <> --username <> --user-attributes "Name=name,Value=<>" --desired-delivery-mediums EMAIL` 102 | - You will receive an email, with a temporary password, which you will need in step 7 103 | **You can repeat this step for each person you want to give access to either now or at a later date.** 104 | 105 | 8. Configure Cognito Callback and Logout URLs 106 | 107 | - In your terminal, navigate to `connect-v2v-translation-with-cx-options/cdk-stacks` 108 | - Start the configuration script in interactive mode 109 | `npm run configure` 110 | - The script loads all the existing parameters, and prompts for new parameters to be provided 111 | - Accept all the existing parameters, but provide a new value for: 112 | - `cognito-callback-urls`: Domain of your web application, in this case Amazon CloudFront Distribution URL. For instance: `https://aaaabbbbcccc.cloudfront.net` 113 | - `cognito-logout-urls`: Domain of your web application, in this case Amazon CloudFront Distribution URL. For instance: `https://aaaabbbbcccc.cloudfront.net` 114 | - For the Demo / Development purposes, you can configure both the previously entered `https://localhost:5173` and Amazon CloudFront Distribution URL (comma separated) 115 | - The script stores the deployment parameters to AWS System Manager Parameter Store 116 | - While in `connect-v2v-translation-with-cx-options/cdk-stacks`, run the deploy script: `npm run cdk:deploy` 117 | - **On Windows devices use `npm run cdk:deploy:gitbash`**. 118 | - Wait for the CDK stacks to be updated 119 | 120 | 9. Test the solution 121 | - Open your browser and navigate to Amazon CloudFront Distribution URL (Output to the console and also available in the Outputs of the Frontend Cloudformation Stack) 122 | - On the Cognito Login screen, provide your email address and temporary password you received via email 123 | - If logging in the first time you will be prompted to reset your password. 124 | - If not already logged in Amazon Connect CCP, you will need to provide your Amazon Connect Agent username and password (For Demo purposes, Amazon Cognito and Amazon Connect are not integrated) 125 | - You should now see Amazon Connect CCP and Voice to Voice (V2V) controls 126 | - To proceed with the demo, please check the **Custom UI Demo Guide** section 127 | 128 | ## Test Webapp locally 129 | 130 | To be able to make changes in the Webapp and test them locally, without re-deploying the Webapp to Amazon CloudFront, please follow these steps: 131 | 132 | 1. In your terminal, navigate to `connect-v2v-translation-with-cx-options/cdk-stacks` 133 | 2. Synchronise the Webapp config parameters: `npm run sync-config` 134 | 3. This script will download `frontend-config.js` to the `webapp` folder 135 | 4. In your terminal, navigate to `connect-v2v-translation-with-cx-options/webapp` 136 | 5. To start the Webapp: `npm run dev` 137 | 6. This script starts a local Vite server on port 5173 138 | 7. Open your browser and navigate to `https://localhost:5173` 139 | 8. You can make changes and customize Webapp files, with browser automatically reloading the Webapp 140 | 9. Please make sure you add `https://localhost:5173` as Amazon Connect Approved Origin (see Step 6 in **Solution setup** -> **Configure Amazon Connect Approved Origins**) 141 | 10. Once happy with the changes, navigate to `connect-v2v-translation-with-cx-options/cdk-stacks` and `npm run build:deploy:all` (On Windows devices use `npm run build:deploy:all:gitbash`) 142 | 143 | ## Clean up 144 | 145 | To remove the solution from your account, please follow these steps: 146 | 147 | 1. Remove CDK Stacks 148 | 149 | - Run `cdk destroy --all` 150 | 151 | 2. Remove deployment parameters from AWS System Manager Parameter Store 152 | - Run `npm run configure:delete` 153 | 154 | ## Demo Webapp key components 155 | 156 | - **Adapters** - allow communication with AWS Services, abstracting AWS SDK specifics from the application business logic: 157 | - **Transcribe Adapter** - allows Amazon Transcribe client to be reused across requests, and provides provides separate Amazon Transcribe clients for agent's and customer's audio transcription 158 | - **Polly Adapter** - allows Amazon Polly client to be reused across requests, and allows Amazon CloudFront to act as a reverse proxy for Amazon Polly 159 | - **Translate Adapter** - allows Amazon Translate client to be reused across requests and allows Amazon CloudFront to act as a reverse proxy for Amazon Translate 160 | - **Managers** - abstracts audio streaming specifics from the application business logic: 161 | - **Audio Stream Manager** - allows simple management and mixing of different audio streams, such as file, mic, translated voice etc. 162 | - `ToCustomerAudioStreamManager` is attached to **To Customer** audio element and controls what customer hears 163 | - `ToAgentAudioStreamManager` is attached to **To Agent** audio element and controls what agent hears 164 | - **Session Track Manager** - abstracts Amazon Connect WebRTC Media Streaming management 165 | - uses Amazon Connect SoftphoneManager (from Amazon Connect Streams JS / Amazon Connect RTC JS) 166 | - to set/replace current audio track in the currently active WebRTC PeerConnection 167 | -------------------------------------------------------------------------------- /cdk-stacks/.gitignore: -------------------------------------------------------------------------------- 1 | *.js 2 | !configure.js 3 | !jest.config.js 4 | *.d.ts 5 | node_modules 6 | 7 | # CDK asset staging directory 8 | .cdk.staging 9 | cdk.out 10 | 11 | # Parcel default cache directory 12 | .parcel-cache 13 | 14 | # CDK js 15 | cdk-backend*.js 16 | 17 | # CDK config, produced by configure.sh 18 | config.cache.json 19 | 20 | # Local template file 21 | template.yaml 22 | 23 | # CDK context - auto-generated 24 | cdk.context.json 25 | 26 | #build folder 27 | build -------------------------------------------------------------------------------- /cdk-stacks/.npmignore: -------------------------------------------------------------------------------- 1 | *.ts 2 | !*.d.ts 3 | 4 | # CDK asset staging directory 5 | .cdk.staging 6 | cdk.out 7 | -------------------------------------------------------------------------------- /cdk-stacks/README.md: -------------------------------------------------------------------------------- 1 | # AWS CDK stacks with all the backend and frontend resources 2 | 3 | ## Useful commands 4 | 5 | - `npm run install:all` - install all necessary modules 6 | - `npm run configure` - start the configuration script 7 | - `npm run sync-config` - download frontend-config.js for local frontend testing (webapp) 8 | - `npm run build:webapp` - build frontend application (webapp) 9 | - `npm run cdk:deploy` - deploy backend and frontend stacks to your default AWS account/region 10 | - `npm run build:deploy:all` - build frontend application and deploy stacks to your default AWS account/region 11 | -------------------------------------------------------------------------------- /cdk-stacks/bin/cdk-stacks.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import * as cdk from "aws-cdk-lib"; 3 | import { CdkBackendStack } from "../lib/cdk-backend-stack"; 4 | import { CdkFrontendStack } from "../lib/cdk-frontend-stack"; 5 | 6 | const configParams = require("../config/config.params.json"); 7 | 8 | const app = new cdk.App(); 9 | 10 | console.info("Running in stack mode..."); 11 | const cdkBackendStack = new CdkBackendStack(app, configParams["CdkBackendStack"], { 12 | env: { account: process.env.CDK_DEFAULT_ACCOUNT, region: process.env.CDK_DEFAULT_REGION }, 13 | description: "Amazon Connect V2V Sample (uksb-9ktxkll9ec) (version:v1.0)", 14 | }); 15 | 16 | const cdkFrontendStack = new CdkFrontendStack(app, configParams["CdkFrontendStack"], { 17 | env: { account: process.env.CDK_DEFAULT_ACCOUNT, region: process.env.CDK_DEFAULT_REGION }, 18 | backendStackOutputs: cdkBackendStack.backendStackOutputs, 19 | description: "Amazon Connect V2V Sample Frontend", 20 | }); 21 | cdkFrontendStack.addDependency(cdkBackendStack); 22 | -------------------------------------------------------------------------------- /cdk-stacks/cdk.json: -------------------------------------------------------------------------------- 1 | { 2 | "app": "npx ts-node --prefer-ts-exts bin/cdk-stacks.ts", 3 | "watch": { 4 | "include": [ 5 | "**" 6 | ], 7 | "exclude": [ 8 | "README.md", 9 | "cdk*.json", 10 | "**/*.d.ts", 11 | "**/*.js", 12 | "tsconfig.json", 13 | "package*.json", 14 | "yarn.lock", 15 | "node_modules", 16 | "test" 17 | ] 18 | }, 19 | "context": { 20 | "@aws-cdk/aws-lambda:recognizeLayerVersion": true, 21 | "@aws-cdk/core:checkSecretUsage": true, 22 | "@aws-cdk/core:target-partitions": [ 23 | "aws", 24 | "aws-cn" 25 | ], 26 | "@aws-cdk-containers/ecs-service-extensions:enableDefaultLogDriver": true, 27 | "@aws-cdk/aws-ec2:uniqueImdsv2TemplateName": true, 28 | "@aws-cdk/aws-ecs:arnFormatIncludesClusterName": true, 29 | "@aws-cdk/aws-iam:minimizePolicies": true, 30 | "@aws-cdk/core:validateSnapshotRemovalPolicy": true, 31 | "@aws-cdk/aws-codepipeline:crossAccountKeyAliasStackSafeResourceName": true, 32 | "@aws-cdk/aws-s3:createDefaultLoggingPolicy": true, 33 | "@aws-cdk/aws-sns-subscriptions:restrictSqsDescryption": true, 34 | "@aws-cdk/aws-apigateway:disableCloudWatchRole": true, 35 | "@aws-cdk/core:enablePartitionLiterals": true, 36 | "@aws-cdk/aws-events:eventsTargetQueueSameAccount": true, 37 | "@aws-cdk/aws-ecs:disableExplicitDeploymentControllerForCircuitBreaker": true, 38 | "@aws-cdk/aws-iam:importedRoleStackSafeDefaultPolicyName": true, 39 | "@aws-cdk/aws-s3:serverAccessLogsUseBucketPolicy": true, 40 | "@aws-cdk/aws-route53-patters:useCertificate": true, 41 | "@aws-cdk/customresources:installLatestAwsSdkDefault": false, 42 | "@aws-cdk/aws-rds:databaseProxyUniqueResourceName": true, 43 | "@aws-cdk/aws-codedeploy:removeAlarmsFromDeploymentGroup": true, 44 | "@aws-cdk/aws-apigateway:authorizerChangeDeploymentLogicalId": true, 45 | "@aws-cdk/aws-ec2:launchTemplateDefaultUserData": true, 46 | "@aws-cdk/aws-secretsmanager:useAttachedSecretResourcePolicyForSecretTargetAttachments": true, 47 | "@aws-cdk/aws-redshift:columnId": true, 48 | "@aws-cdk/aws-stepfunctions-tasks:enableEmrServicePolicyV2": true, 49 | "@aws-cdk/aws-ec2:restrictDefaultSecurityGroup": true, 50 | "@aws-cdk/aws-apigateway:requestValidatorUniqueId": true, 51 | "@aws-cdk/aws-kms:aliasNameRef": true, 52 | "@aws-cdk/aws-autoscaling:generateLaunchTemplateInsteadOfLaunchConfig": true, 53 | "@aws-cdk/core:includePrefixInUniqueNameGeneration": true, 54 | "@aws-cdk/aws-efs:denyAnonymousAccess": true, 55 | "@aws-cdk/aws-opensearchservice:enableOpensearchMultiAzWithStandby": true, 56 | "@aws-cdk/aws-lambda-nodejs:useLatestRuntimeVersion": true, 57 | "@aws-cdk/aws-efs:mountTargetOrderInsensitiveLogicalId": true, 58 | "@aws-cdk/aws-rds:auroraClusterChangeScopeOfInstanceParameterGroupWithEachParameters": true, 59 | "@aws-cdk/aws-appsync:useArnForSourceApiAssociationIdentifier": true, 60 | "@aws-cdk/aws-rds:preventRenderingDeprecatedCredentials": true, 61 | "@aws-cdk/aws-codepipeline-actions:useNewDefaultBranchForCodeCommitSource": true, 62 | "@aws-cdk/aws-cloudwatch-actions:changeLambdaPermissionLogicalIdForLambdaAction": true, 63 | "@aws-cdk/aws-codepipeline:crossAccountKeysDefaultValueToFalse": true, 64 | "@aws-cdk/aws-codepipeline:defaultPipelineTypeToV2": true, 65 | "@aws-cdk/aws-kms:reduceCrossAccountRegionPolicyScope": true, 66 | "@aws-cdk/aws-eks:nodegroupNameAttribute": true, 67 | "@aws-cdk/aws-ec2:ebsDefaultGp3Volume": true, 68 | "@aws-cdk/aws-ecs:removeDefaultDeploymentAlarm": true, 69 | "@aws-cdk/custom-resources:logApiResponseDataPropertyTrueDefault": false, 70 | "@aws-cdk/aws-s3:keepNotificationInImportedBucket": false, 71 | "@aws-cdk/aws-ecs:reduceEc2FargateCloudWatchPermissions": true, 72 | "@aws-cdk/aws-dynamodb:resourcePolicyPerReplica": true, 73 | "@aws-cdk/aws-ec2:ec2SumTImeoutEnabled": true, 74 | "@aws-cdk/aws-appsync:appSyncGraphQLAPIScopeLambdaPermission": true, 75 | "@aws-cdk/aws-rds:setCorrectValueForDatabaseInstanceReadReplicaInstanceResourceId": true, 76 | "@aws-cdk/core:cfnIncludeRejectComplexResourceUpdateCreatePolicyIntrinsics": true, 77 | "@aws-cdk/aws-lambda-nodejs:sdkV3ExcludeSmithyPackages": true, 78 | "@aws-cdk/aws-stepfunctions-tasks:fixRunEcsTaskPolicy": true 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /cdk-stacks/config/config.params.json: -------------------------------------------------------------------------------- 1 | { 2 | "CdkAppName": "AmazonConnectV2V", 3 | "CdkBackendStack": "AmazonConnectV2VBackend", 4 | "CdkFrontendStack": "AmazonConnectV2VFrontend", 5 | "WebAppRootPrefix": "WebAppRoot/", 6 | "WebAppStagingPrefix": "WebAppStaging/", 7 | "hierarchy": "/AmazonConnectV2V/", 8 | "parameters": [ 9 | { 10 | "name": "cognitoDomainPrefix", 11 | "cliFormat": "cognito-domain-prefix", 12 | "description": "Amazon Cognito hosted UI domain prefix, where users will be redirected during the login process. The domain prefix has to be unique, between 1 and 63 characters long, contains no special characters, and no keywords: aws, amazon, or cognito. You could put your Amazon Connect Instance Alias to it, for example: connect-v2v-instance-alias", 13 | "required": true 14 | }, 15 | { 16 | "name": "cognitoCallbackUrls", 17 | "cliFormat": "cognito-callback-urls", 18 | "description": "Please provide a callback URL for the Amazon Cognito authorization server to call after users are authenticated. This should be set to your application root URL. For example: https://aaaabbbbcccc.cloudfront.net", 19 | "required": true 20 | }, 21 | { 22 | "name": "cognitoLogoutUrls", 23 | "cliFormat": "cognito-logout-urls", 24 | "description": "Please provide a logout URL where user is to be redirected after logging out.", 25 | "required": true 26 | }, 27 | { 28 | "name": "connectInstanceURL", 29 | "cliFormat": "connect-instance-url", 30 | "description": "Amazon Connect instance URL that solution will use. For example: https://connect-instance-alias.my.connect.aws (or https://connect-instance-alias.awsapps.com)", 31 | "required": true 32 | }, 33 | { 34 | "name": "connectInstanceRegion", 35 | "cliFormat": "connect-instance-region", 36 | "description": "Amazon Connect instance Region that solution will use. For example: us-east-1", 37 | "required": true 38 | }, 39 | { 40 | "name": "transcribeRegion", 41 | "cliFormat": "transcribe-region", 42 | "description": "Amazon Transcribe Region that solution will use. For example: us-east-1", 43 | "defaultValue": "us-east-1", 44 | "required": true 45 | }, 46 | { 47 | "name": "translateRegion", 48 | "cliFormat": "translate-region", 49 | "description": "Amazon Translate Region that solution will use. For example: us-east-1", 50 | "defaultValue": "us-east-1", 51 | "required": true 52 | }, 53 | { 54 | "name": "translateProxyEnabled", 55 | "cliFormat": "translate-proxy-enabled", 56 | "description": "When enabled, webapp requests to Amazon Translate are proxied through Amazon Cloudfront (recommended to avoid CORS)", 57 | "defaultValue": true, 58 | "required": true, 59 | "boolean": true 60 | }, 61 | { 62 | "name": "pollyRegion", 63 | "cliFormat": "polly-region", 64 | "description": "Amazon Polly Region that solution will use. For example: us-east-1", 65 | "defaultValue": "us-east-1", 66 | "required": true 67 | }, 68 | { 69 | "name": "pollyProxyEnabled", 70 | "cliFormat": "polly-proxy-enabled", 71 | "description": "When enabled, webapp requests to Amazon Polly are proxied through Amazon Cloudfront (recommended to avoid CORS)", 72 | "defaultValue": true, 73 | "required": true, 74 | "boolean": true 75 | } 76 | ] 77 | } 78 | -------------------------------------------------------------------------------- /cdk-stacks/config/configure.js: -------------------------------------------------------------------------------- 1 | // Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | const configParams = require("./config.params.json"); 5 | const { SSMClient, PutParameterCommand, GetParametersCommand, DeleteParametersCommand } = require("@aws-sdk/client-ssm"); 6 | const ssmClient = new SSMClient(); 7 | const fs = require("fs"); 8 | const readline = require("readline"); 9 | 10 | const SSM_NOT_DEFINED = "not-defined"; 11 | let VERBOSE = false; 12 | 13 | function displayHelp() { 14 | console.log(`\nThis script gets deployment parameters and stores the parameters to AWS System Manager Parameter Store \n`); 15 | 16 | console.log(`Usage:\n`); 17 | console.log(`-i \t Run in interactive mode`); 18 | console.log(`-l \t When running in interactive mode, load the current parameters from AWS System Manager Parameter Store`); 19 | console.log(`-t \t Run Test mode (only creates config.cache.json, but it does not store parameters to AWS System Manager Parameter Store)`); 20 | console.log(`-d \t Delete all AWS SSM Parameters (after CDK stack was destroyed)`); 21 | displayParametersHelp(); 22 | process.exit(0); 23 | } 24 | 25 | function displayParametersHelp() { 26 | console.log(`\nParameters: \n`); 27 | configParams.parameters.forEach((param) => { 28 | console.log( 29 | `--${param.cliFormat} [${param.required ? "required" : "optional"}${param.parent ? " when " + getParentObject(param).cliFormat : ""}] \n\t\t${wrapText( 30 | param.description, 31 | 80 32 | )}\n` 33 | ); 34 | }); 35 | } 36 | 37 | function wrapText(s, w) { 38 | return s.replace(new RegExp(`(?![^\\n]{1,${w}}$)([^\\n]{1,${w}})\\s`, "g"), "$1\n\t\t"); 39 | } 40 | 41 | function getParentObject(param) { 42 | return configParams.parameters.find((parent) => parent.name === param.parent); 43 | } 44 | 45 | function isParentEnabled(param) { 46 | return configParams.parameters.find((parent) => parent.name === param.parent).value === true; 47 | } 48 | 49 | function isNotDefined(param) { 50 | return param.value === SSM_NOT_DEFINED; 51 | } 52 | 53 | function isUndefinedNullEmpty(value) { 54 | return value === undefined || value === null || (typeof value === "string" && value.trim() === ""); 55 | } 56 | 57 | function throwParameterRequired(param) { 58 | throw new Error(`Required parameter not provided: [${param.cliFormat}]`); 59 | } 60 | 61 | async function loadParametersSSM() { 62 | console.log(`\nLoading current parameters from AWS System Manager Parameter Store\n`); 63 | 64 | const chunkedParameters = chunkArray(configParams.parameters, 10); 65 | for (let i = 0; i < chunkedParameters.length; i++) { 66 | const getParametersResult = await getParametersSSMBatch(chunkedParameters[i]).catch((error) => { 67 | console.log(`ERROR: getParametersSSMBatch: ${error.message}`); 68 | return undefined; 69 | }); 70 | 71 | getParametersResult?.forEach((loadedParam) => { 72 | if (loadedParam.Value && loadedParam.Name) { 73 | const configParam = configParams.parameters.find((configParam) => configParam.name === /[^/]*$/.exec(loadedParam.Name)[0]); 74 | configParam.value = parseParam(loadedParam.Value); 75 | } 76 | }); 77 | 78 | if (i !== 0 && i % 2 === 0) await wait(1000); 79 | } 80 | 81 | console.log(`\nLoad completed\n`); 82 | } 83 | 84 | async function getParametersSSMBatch(parametersArray) { 85 | if (parametersArray?.length < 1 || parametersArray?.length > 10) 86 | throw new Error(`getParametersSSMBatch -> parametersArray -> Minimum number of 1 item. Maximum number of 10 items`); 87 | 88 | const paramNamesArray = parametersArray.map((param) => `${configParams.hierarchy}${param.name}`); 89 | 90 | const getParametersResult = await ssmClient.send(new GetParametersCommand({ Names: paramNamesArray })); 91 | 92 | getParametersResult?.InvalidParameters?.forEach((invalidParam) => { 93 | console.log(`Error loading parameter: ${invalidParam}`); 94 | }); 95 | 96 | return getParametersResult?.Parameters; 97 | } 98 | 99 | async function storeParametersSSM() { 100 | console.log(`\nStoring parameters to AWS System Manager Parameter Store\n`); 101 | 102 | const chunkedParameters = chunkArray(configParams.parameters, 5); 103 | for (let i = 0; i < chunkedParameters.length; i++) { 104 | await putParametersSSMBatch(chunkedParameters[i]).catch((error) => { 105 | console.log(`ERROR: putParametersSSMBatch: ${error.message}`); 106 | }); 107 | await wait(1000); 108 | } 109 | 110 | console.log(`\nStore completed\n`); 111 | } 112 | 113 | async function putParametersSSMBatch(parametersArray) { 114 | if (parametersArray?.length < 1 || parametersArray?.length > 5) 115 | throw new Error(`putParametersSSMBatch -> parametersArray -> Minimum number of 1 item. Maximum number of 5 items`); 116 | 117 | for (const param of parametersArray) { 118 | console.log(`\nAWS SSM put ${configParams.hierarchy}${param.name} = ${param.value}`); 119 | //supports only String parameters 120 | const putParameterResult = await ssmClient.send( 121 | new PutParameterCommand({ 122 | Type: "String", 123 | Name: `${configParams.hierarchy}${param.name}`, 124 | Value: param.boolean ? param.value.toString() : param.value, 125 | Overwrite: true, 126 | }) 127 | ); 128 | console.log(`Stored param: ${configParams.hierarchy}${param.name} | tier: ${putParameterResult.Tier} | version: ${putParameterResult.Version}\n`); 129 | } 130 | } 131 | 132 | async function deleteParametersSSM() { 133 | console.log(`\nDeleting parameters to AWS System Manager Parameter Store\n`); 134 | 135 | const chunkedParameters = chunkArray(configParams.parameters, 10); 136 | for (let i = 0; i < chunkedParameters.length; i++) { 137 | await deleteParametersSSMBatch(chunkedParameters[i]).catch((error) => { 138 | console.log(`ERROR: deleteParametersSSMBatch: ${error.message}`); 139 | }); 140 | await wait(1000); 141 | } 142 | 143 | console.log(`\nDelete completed\n`); 144 | process.exit(0); 145 | } 146 | 147 | async function deleteParametersSSMBatch(parametersArray) { 148 | if (parametersArray?.length < 1 || parametersArray?.length > 10) 149 | throw new Error(`deleteParametersSSMBatch -> parametersArray -> Minimum number of 1 item. Maximum number of 10 items`); 150 | 151 | const paramNamesArray = parametersArray.map((param) => `${configParams.hierarchy}${param.name}`); 152 | const deleteParametersResult = await ssmClient.send(new DeleteParametersCommand({ Names: paramNamesArray })); 153 | 154 | deleteParametersResult?.InvalidParameters?.forEach((invalidParam) => { 155 | console.log(`Error deleting parameter: ${invalidParam}`); 156 | }); 157 | 158 | deleteParametersResult?.DeletedParameters?.forEach((deletedParam) => { 159 | console.log(`Deleted param: ${deletedParam}`); 160 | }); 161 | } 162 | 163 | function chunkArray(inputArray, chunkSize) { 164 | let index = 0; 165 | const arrayLength = inputArray.length; 166 | let resultArray = []; 167 | 168 | for (index = 0; index < arrayLength; index += chunkSize) { 169 | let chunkItem = inputArray.slice(index, index + chunkSize); 170 | resultArray.push(chunkItem); 171 | } 172 | 173 | return resultArray; 174 | } 175 | 176 | function wait(time) { 177 | return new Promise((resolve) => { 178 | setTimeout(() => resolve(), time); 179 | }); 180 | } 181 | 182 | async function writeConfigCacheJSON() { 183 | console.log(`\nWriting current parameters to config.cache.json\n`); 184 | 185 | const configCache = {}; 186 | for (const param of configParams.parameters) { 187 | configCache[`${configParams.hierarchy}${param.name}`] = param.value; 188 | } 189 | 190 | fs.writeFileSync("config.cache.json", JSON.stringify(configCache, null, "\t")); 191 | 192 | console.log(`\nWrite completed\n`); 193 | } 194 | 195 | function checkRequiredParameters() { 196 | for (const param of configParams.parameters) { 197 | if (param.required && !param.parent && isNotDefined(param)) { 198 | throwParameterRequired(param); 199 | } 200 | 201 | if (param.required && param.parent && isParentEnabled(param) && isNotDefined(param)) { 202 | throwParameterRequired(param); 203 | } 204 | } 205 | } 206 | 207 | function initParameters() { 208 | for (const param of configParams.parameters) { 209 | param.value = isUndefinedNullEmpty(param.defaultValue) ? SSM_NOT_DEFINED : param.defaultValue; 210 | } 211 | } 212 | 213 | function displayInputParameters() { 214 | console.log(`\nInput parameters:\n`); 215 | 216 | for (const param of configParams.parameters) { 217 | console.log(`${param.cliFormat} = ${param.value}`); 218 | } 219 | } 220 | 221 | function parseParam(value) { 222 | let tValue = value.trim(); 223 | if (typeof tValue === "string" && tValue.toLocaleLowerCase() === "true") return true; 224 | if (typeof tValue === "string" && tValue.toLowerCase() === "false") return false; 225 | return tValue; 226 | } 227 | 228 | function getArgs() { 229 | const argFlags = {}; 230 | const argParams = {}; 231 | 232 | process.argv.slice(2, process.argv.length).forEach((arg) => { 233 | // long args 234 | if (arg.slice(0, 2) === "--") { 235 | const longArg = arg.split("="); 236 | const longArgFlag = longArg[0].slice(2, longArg[0].length); 237 | const longArgValue = longArg.length > 1 ? parseParam(longArg[1]) : true; 238 | argParams[longArgFlag] = longArgValue; 239 | } 240 | // flags 241 | else if (arg[0] === "-") { 242 | const flags = arg.slice(1, arg.length).split(""); 243 | flags.forEach((flag) => { 244 | argFlags[flag] = true; 245 | }); 246 | } 247 | }); 248 | return { argFlags: argFlags, argParams: argParams }; 249 | } 250 | 251 | async function runInteractive(loadSSM = false) { 252 | if (loadSSM) { 253 | await loadParametersSSM(); 254 | } 255 | await promptForParameters(); 256 | } 257 | 258 | function buildQuestion(question, rl) { 259 | return new Promise((res, rej) => { 260 | rl.question(question, (input) => { 261 | res(input); 262 | }); 263 | }); 264 | } 265 | 266 | async function promptForParameters() { 267 | console.log(`\nPlease provide your parameters:\n`); 268 | 269 | const rl = readline.createInterface({ 270 | input: process.stdin, 271 | output: process.stdout, 272 | }); 273 | 274 | for (const param of configParams.parameters) { 275 | if (!param.parent || (param.parent && isParentEnabled(param))) { 276 | const input = await buildQuestion(`${param.cliFormat} [${param.value}]`, rl); 277 | if (input.trim() !== "") { 278 | param.value = parseParam(input); 279 | } 280 | } 281 | } 282 | 283 | rl.close(); 284 | } 285 | 286 | function processArgParams(argParams) { 287 | for (const param of configParams.parameters) { 288 | const argValue = argParams[param.cliFormat]; 289 | if (argValue !== undefined) { 290 | param.value = argValue; 291 | } 292 | } 293 | } 294 | 295 | async function run() { 296 | try { 297 | const { argFlags, argParams } = getArgs(); 298 | 299 | if (argFlags["v"] === true) { 300 | VERBOSE = true; 301 | } 302 | 303 | if (argFlags["h"] === true) { 304 | return displayHelp(); 305 | } 306 | 307 | if (argFlags["d"] === true) { 308 | return await deleteParametersSSM(); 309 | } 310 | 311 | if (argFlags["t"] === true) { 312 | console.log(`\nRunning in test mode\n`); 313 | } 314 | 315 | initParameters(); 316 | 317 | if (argFlags["i"] === true) { 318 | console.log(`\nRunning in interactive mode\n`); 319 | await runInteractive(argFlags["l"]); 320 | } else { 321 | processArgParams(argParams); 322 | } 323 | 324 | displayInputParameters(); 325 | 326 | checkRequiredParameters(); 327 | 328 | writeConfigCacheJSON(); 329 | 330 | if (argFlags["t"] !== true) { 331 | await storeParametersSSM(); 332 | } 333 | 334 | console.log(`\nConfiguration complete, review your parameters in config.cache.json\n`); 335 | process.exit(0); 336 | } catch (error) { 337 | console.error(`\nError: ${error.message}\n`); 338 | if (VERBOSE) console.log(error); 339 | process.exit(1); 340 | } 341 | } 342 | 343 | run(); 344 | -------------------------------------------------------------------------------- /cdk-stacks/config/ssm-params-util.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | const configParams = require("./config.params.json"); 4 | 5 | import { Construct } from "constructs"; 6 | import * as ssm from "aws-cdk-lib/aws-ssm"; 7 | 8 | export const loadSSMParams = (scope: Construct) => { 9 | const params: any = {}; 10 | const SSM_NOT_DEFINED = "not-defined"; 11 | for (const param of configParams.parameters) { 12 | if (param.boolean) { 13 | params[param.name] = ssm.StringParameter.valueFromLookup(scope, `${configParams.hierarchy}${param.name}`).toLowerCase() === "true"; 14 | } else { 15 | params[param.name] = ssm.StringParameter.valueFromLookup(scope, `${configParams.hierarchy}${param.name}`); 16 | } 17 | } 18 | return { ...params, SSM_NOT_DEFINED }; 19 | }; 20 | 21 | export const fixDummyValueString = (value: string): string => { 22 | if (value.includes("dummy-value-for-")) return value.replace(/\//g, "-"); 23 | else return value; 24 | }; 25 | -------------------------------------------------------------------------------- /cdk-stacks/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | testEnvironment: 'node', 3 | roots: ['/test'], 4 | testMatch: ['**/*.test.ts'], 5 | transform: { 6 | '^.+\\.tsx?$': 'ts-jest' 7 | } 8 | }; 9 | -------------------------------------------------------------------------------- /cdk-stacks/lambdas/custom-resources/frontend-config/index.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import boto3 4 | import logging 5 | import json 6 | import contextlib 7 | from urllib.request import Request, urlopen 8 | from uuid import uuid4 9 | import tempfile 10 | import os 11 | from zipfile import ZipFile 12 | import shutil 13 | 14 | logger = logging.getLogger() 15 | logger.setLevel(logging.INFO) 16 | 17 | CFN_SUCCESS = "SUCCESS" 18 | CFN_FAILED = "FAILED" 19 | 20 | s3 = boto3.client("s3") 21 | 22 | 23 | def create(bucket_name, web_app_staging_object_prefix, web_app_root_object_prefix, object_key, object_content, object_content_type): 24 | 25 | workdir = tempfile.mkdtemp() 26 | logger.info("| workdir: %s" % workdir) 27 | 28 | raw_file_complete = os.path.join(workdir, object_key) 29 | logger.info("| write file: %s" % raw_file_complete) 30 | raw_file = open(raw_file_complete, 'w') 31 | raw_file.write(object_content) 32 | raw_file.close() 33 | 34 | zip_file_name = f"{os.path.splitext(object_key)[0]}.zip" 35 | zip_file_complete = os.path.join(workdir, zip_file_name) 36 | logger.info("| zip into file: %s" % zip_file_complete) 37 | ZipFile(zip_file_complete, mode='w').write( 38 | raw_file_complete, arcname=object_key) 39 | 40 | # upload to WebAppStaging 41 | staging_object_url = f"s3://{bucket_name}/{web_app_staging_object_prefix}{zip_file_name}" 42 | logger.info(f"Uploading frontend config to {staging_object_url}") 43 | s3.upload_file(zip_file_complete, bucket_name, 44 | f"{web_app_staging_object_prefix}{zip_file_name}") 45 | 46 | # upload to WebAppRoot 47 | root_object_url = f"s3://{bucket_name}/{web_app_root_object_prefix}{object_key}" 48 | logger.info(f"Uploading frontend config to {root_object_url}") 49 | s3.upload_file(raw_file_complete, bucket_name, 50 | f"{web_app_root_object_prefix}{object_key}", ExtraArgs={'Metadata': {'ContentType': object_content_type}}) 51 | 52 | shutil.rmtree(workdir) 53 | 54 | 55 | def delete(bucket_name, web_app_staging_object_prefix, object_key): 56 | 57 | zip_file_name = f"{os.path.splitext(object_key)[0]}.zip" 58 | 59 | object_url = f"s3://{bucket_name}/{web_app_staging_object_prefix}{zip_file_name}" 60 | logger.info(f"Removing frontend config from {object_url}") 61 | 62 | s3.delete_object( 63 | Bucket=bucket_name, 64 | Key=f"{web_app_staging_object_prefix}{zip_file_name}" 65 | ) 66 | 67 | 68 | def handler(event, context): 69 | 70 | def cfn_error(message=None): 71 | logger.error("| cfn_error: %s" % message) 72 | cfn_send(event, context, CFN_FAILED, reason=message) 73 | 74 | try: 75 | logger.info(event) 76 | 77 | # cloudformation request type (create/update/delete) 78 | request_type = event['RequestType'] 79 | 80 | # extract resource properties 81 | props = event['ResourceProperties'] 82 | old_props = event.get('OldResourceProperties', {}) 83 | physical_id = event.get('PhysicalResourceId', None) 84 | 85 | bucket_name = props["BucketName"] 86 | object_key = props["ObjectKey"] 87 | 88 | # if we are creating a new resource, allocate a physical id for it 89 | # otherwise, we expect physical id to be relayed by cloudformation 90 | if request_type == "Create": 91 | physical_id = "vce.frontend-config.%s" % str(uuid4()) 92 | else: 93 | if not physical_id: 94 | cfn_error( 95 | "invalid request: request type is '%s' but 'PhysicalResourceId' is not defined" % request_type) 96 | return 97 | 98 | if request_type == "Create" or request_type == "Update": 99 | web_app_staging_object_prefix = props["WebAppStagingObjectPrefix"] 100 | web_app_root_object_prefix = props["WebAppRootObjectPrefix"] 101 | object_content = props.get("Content") 102 | object_content_type = props.get("ContentType") 103 | create(bucket_name, web_app_staging_object_prefix, web_app_root_object_prefix, object_key, 104 | object_content, object_content_type) 105 | 106 | if request_type == "Delete": 107 | web_app_staging_object_prefix = props["WebAppStagingObjectPrefix"] 108 | delete(bucket_name, web_app_staging_object_prefix, object_key) 109 | 110 | cfn_send(event, context, CFN_SUCCESS, physicalResourceId=physical_id) 111 | 112 | except KeyError as e: 113 | cfn_error("invalid request. Missing key %s" % str(e)) 114 | except Exception as e: 115 | logger.exception(e) 116 | cfn_error(str(e)) 117 | 118 | # --------------------------------------------------------------------------------------------------- 119 | # sends a response to cloudformation 120 | 121 | 122 | def cfn_send(event, context, responseStatus, responseData={}, physicalResourceId=None, noEcho=False, reason=None): 123 | 124 | responseUrl = event['ResponseURL'] 125 | logger.info(responseUrl) 126 | 127 | responseBody = {} 128 | responseBody['Status'] = responseStatus 129 | responseBody['Reason'] = reason or ( 130 | 'See the details in CloudWatch Log Stream: ' + context.log_stream_name) 131 | responseBody['PhysicalResourceId'] = physicalResourceId or context.log_stream_name 132 | responseBody['StackId'] = event['StackId'] 133 | responseBody['RequestId'] = event['RequestId'] 134 | responseBody['LogicalResourceId'] = event['LogicalResourceId'] 135 | responseBody['NoEcho'] = noEcho 136 | responseBody['Data'] = responseData 137 | 138 | body = json.dumps(responseBody) 139 | logger.info("| response body:\n" + body) 140 | 141 | headers = { 142 | 'content-type': '', 143 | 'content-length': str(len(body)) 144 | } 145 | 146 | try: 147 | request = Request(responseUrl, method='PUT', data=bytes( 148 | body.encode('utf-8')), headers=headers) 149 | with contextlib.closing(urlopen(request)) as response: 150 | logger.info("| status code: " + response.reason) 151 | except Exception as e: 152 | logger.error("| unable to send response to CloudFormation") 153 | logger.exception(e) 154 | -------------------------------------------------------------------------------- /cdk-stacks/lib/cdk-backend-stack.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | import * as cdk from "aws-cdk-lib"; 4 | import { Construct } from "constructs"; 5 | import * as ssm from "aws-cdk-lib/aws-ssm"; 6 | 7 | import { loadSSMParams } from "../config/ssm-params-util"; 8 | const configParams = require("../config/config.params.json"); 9 | 10 | import { CognitoStack } from "./infrastructure/cognito-stack"; 11 | import { FrontendConfigStack } from "./frontend/frontend-config-stack"; 12 | 13 | export class CdkBackendStack extends cdk.Stack { 14 | public readonly backendStackOutputs: { key: string; value: string }[]; 15 | 16 | constructor(scope: Construct, id: string, props?: cdk.StackProps) { 17 | super(scope, id, props); 18 | this.backendStackOutputs = []; 19 | 20 | //store physical stack name to SSM 21 | const outputHierarchy = `${configParams.hierarchy}outputParameters`; 22 | const cdkBackendStackName = new ssm.StringParameter(this, "CdkBackendStackName", { 23 | parameterName: `${outputHierarchy}/CdkBackendStackName`, 24 | stringValue: this.stackName, 25 | }); 26 | 27 | const ssmParams = loadSSMParams(this); 28 | 29 | const cognitoStack = new CognitoStack(this, "CognitoStack", { 30 | SSMParams: ssmParams, 31 | cdkAppName: configParams["CdkAppName"], 32 | }); 33 | 34 | /************************************************************************************************************** 35 | * CDK Outputs * 36 | **************************************************************************************************************/ 37 | this.backendStackOutputs.push({ key: "backendRegion", value: this.region }); 38 | this.backendStackOutputs.push({ key: "identityPoolId", value: cognitoStack.identityPool.ref }); 39 | this.backendStackOutputs.push({ key: "userPoolId", value: cognitoStack.userPool.userPoolId }); 40 | this.backendStackOutputs.push({ key: "userPoolWebClientId", value: cognitoStack.userPoolClient.userPoolClientId }); 41 | this.backendStackOutputs.push({ key: "cognitoDomainURL", value: `https://${cognitoStack.userPoolDomain.domain}.auth.${this.region}.amazoncognito.com` }); 42 | this.backendStackOutputs.push({ key: "connectInstanceURL", value: ssmParams.connectInstanceURL }); 43 | this.backendStackOutputs.push({ key: "connectInstanceRegion", value: ssmParams.connectInstanceRegion }); 44 | this.backendStackOutputs.push({ key: "transcribeRegion", value: ssmParams.transcribeRegion }); 45 | this.backendStackOutputs.push({ key: "translateRegion", value: ssmParams.translateRegion }); 46 | this.backendStackOutputs.push({ key: "translateProxyEnabled", value: String(ssmParams.translateProxyEnabled) }); 47 | this.backendStackOutputs.push({ key: "pollyRegion", value: ssmParams.pollyRegion }); 48 | this.backendStackOutputs.push({ key: "pollyProxyEnabled", value: String(ssmParams.pollyProxyEnabled) }); 49 | 50 | new cdk.CfnOutput(this, "userPoolId", { 51 | value: cognitoStack.userPool.userPoolId, 52 | }); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /cdk-stacks/lib/cdk-frontend-stack.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | import * as cdk from "aws-cdk-lib"; 4 | import { Construct } from "constructs"; 5 | import * as s3 from "aws-cdk-lib/aws-s3"; 6 | import * as ssm from "aws-cdk-lib/aws-ssm"; 7 | import * as cloudfront from "aws-cdk-lib/aws-cloudfront"; 8 | import * as origins from "aws-cdk-lib/aws-cloudfront-origins"; 9 | import * as lambda from "aws-cdk-lib/aws-lambda"; 10 | 11 | import { FrontendS3DeploymentStack } from "../lib/frontend/frontend-s3-deployment-stack"; 12 | import { FrontendConfigStack } from "./frontend/frontend-config-stack"; 13 | import { loadSSMParams } from "../config/ssm-params-util"; 14 | 15 | const configParams = require("../config/config.params.json"); 16 | 17 | export interface CdkFrontendStackProps extends cdk.StackProps { 18 | readonly backendStackOutputs: { key: string; value: string }[]; 19 | } 20 | 21 | export class CdkFrontendStack extends cdk.Stack { 22 | constructor(scope: Construct, id: string, props: CdkFrontendStackProps) { 23 | super(scope, id, props); 24 | 25 | //store physical stack name to SSM 26 | const outputHierarchy = `${configParams.hierarchy}outputParameters`; 27 | const cdkFrontendStackName = new ssm.StringParameter(this, "CdkFrontendStackName", { 28 | parameterName: `${outputHierarchy}/CdkFrontendStackName`, 29 | stringValue: this.stackName, 30 | }); 31 | 32 | const ssmParams = loadSSMParams(this); 33 | 34 | //create webapp bucket 35 | const webAppBucket = new s3.Bucket(this, "WebAppBucket", { 36 | bucketName: `${configParams["CdkAppName"]}-WebAppBucket-${this.account}-${this.region}`.toLowerCase(), 37 | blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL, 38 | encryption: s3.BucketEncryption.S3_MANAGED, 39 | enforceSSL: true, 40 | versioned: true, 41 | removalPolicy: cdk.RemovalPolicy.DESTROY, 42 | }); 43 | 44 | const webAppLogBucket = new s3.Bucket(this, "WebAppLogBucket", { 45 | bucketName: `${configParams["CdkAppName"]}-WebAppLogBucket-${this.account}-${this.region}`.toLowerCase(), 46 | blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL, 47 | encryption: s3.BucketEncryption.S3_MANAGED, 48 | enforceSSL: true, 49 | versioned: true, 50 | removalPolicy: cdk.RemovalPolicy.DESTROY, 51 | objectOwnership: s3.ObjectOwnership.OBJECT_WRITER, 52 | accessControl: s3.BucketAccessControl.LOG_DELIVERY_WRITE, 53 | }); 54 | 55 | const frontendS3DeploymentStack = new FrontendS3DeploymentStack(this, "FrontendS3DeploymentStack", { 56 | cdkAppName: configParams["CdkAppName"], 57 | webAppBucket: webAppBucket, 58 | }); 59 | 60 | const webAppCloudFrontDistribution = new cloudfront.Distribution(this, `${configParams["CdkAppName"]}-WebAppDistribution`, { 61 | comment: `CloudFront for ${configParams["CdkAppName"]}`, 62 | enableIpv6: false, 63 | enableLogging: true, 64 | logBucket: webAppLogBucket, 65 | logIncludesCookies: false, 66 | logFilePrefix: "cloudfront-logs/", 67 | defaultBehavior: { 68 | origin: origins.S3BucketOrigin.withOriginAccessControl(webAppBucket, { 69 | originPath: `/${configParams["WebAppRootPrefix"].replace(/\/$/, "")}`, 70 | }), 71 | allowedMethods: cloudfront.AllowedMethods.ALLOW_GET_HEAD_OPTIONS, 72 | viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS, 73 | compress: true, 74 | cachePolicy: cloudfront.CachePolicy.CACHING_DISABLED, 75 | }, 76 | errorResponses: [ 77 | { 78 | httpStatus: 403, 79 | responseHttpStatus: 200, 80 | responsePagePath: "/index.html", 81 | ttl: cdk.Duration.seconds(60), 82 | }, 83 | ], 84 | }); 85 | 86 | //create frontend config 87 | const frontendConfigStack = new FrontendConfigStack(this, "FrontendConfigStack", { 88 | cdkAppName: configParams["CdkAppName"], 89 | webAppBucket: webAppBucket, 90 | backendStackOutputs: props.backendStackOutputs, 91 | }); 92 | 93 | //create amazon-polly-proxy 94 | if (ssmParams.pollyProxyEnabled) { 95 | webAppCloudFrontDistribution.addBehavior( 96 | "/amazon-polly-proxy/*", 97 | new origins.HttpOrigin(`polly.${ssmParams.pollyRegion}.amazonaws.com`, { 98 | protocolPolicy: cloudfront.OriginProtocolPolicy.HTTPS_ONLY, 99 | }), 100 | { 101 | cachePolicy: new cloudfront.CachePolicy(this, "PollyApiCachePolicy", { 102 | defaultTtl: cdk.Duration.seconds(0), // Don't cache by default 103 | minTtl: cdk.Duration.seconds(0), 104 | maxTtl: cdk.Duration.seconds(1), 105 | enableAcceptEncodingBrotli: true, 106 | enableAcceptEncodingGzip: true, 107 | headerBehavior: cloudfront.CacheHeaderBehavior.allowList("authorization", "content-type"), 108 | }), 109 | originRequestPolicy: new cloudfront.OriginRequestPolicy(this, "PollyApiOriginRequestPolicy", { 110 | headerBehavior: cloudfront.OriginRequestHeaderBehavior.denyList("host"), 111 | queryStringBehavior: cloudfront.OriginRequestQueryStringBehavior.all(), 112 | cookieBehavior: cloudfront.OriginRequestCookieBehavior.none(), 113 | }), 114 | allowedMethods: cloudfront.AllowedMethods.ALLOW_ALL, 115 | viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.HTTPS_ONLY, 116 | compress: true, 117 | functionAssociations: [ 118 | { 119 | function: new cloudfront.Function(this, "PollyUrlRewrite", { 120 | code: cloudfront.FunctionCode.fromInline(` 121 | function handler(event) { 122 | var request = event.request; 123 | var uri = request.uri; 124 | if (uri.startsWith('/amazon-polly-proxy')) { 125 | uri = uri.replace('/amazon-polly-proxy', ''); 126 | if (!uri.startsWith('/')) { 127 | uri = '/' + uri; 128 | } 129 | request.uri = uri; 130 | } 131 | return request; 132 | } 133 | `), 134 | runtime: cloudfront.FunctionRuntime.JS_2_0, 135 | }), 136 | eventType: cloudfront.FunctionEventType.VIEWER_REQUEST, 137 | }, 138 | ], 139 | } 140 | ); 141 | } 142 | 143 | //create amazon-translate-proxy 144 | if (ssmParams.translateProxyEnabled) { 145 | webAppCloudFrontDistribution.addBehavior( 146 | "/amazon-translate-proxy/*", 147 | new origins.HttpOrigin(`translate.${ssmParams.translateRegion}.amazonaws.com`, { 148 | protocolPolicy: cloudfront.OriginProtocolPolicy.HTTPS_ONLY, 149 | }), 150 | { 151 | cachePolicy: new cloudfront.CachePolicy(this, "TranslateApiCachePolicy", { 152 | defaultTtl: cdk.Duration.seconds(0), // Don't cache by default 153 | minTtl: cdk.Duration.seconds(0), 154 | maxTtl: cdk.Duration.seconds(1), 155 | enableAcceptEncodingBrotli: true, 156 | enableAcceptEncodingGzip: true, 157 | headerBehavior: cloudfront.CacheHeaderBehavior.allowList("authorization", "content-type"), 158 | }), 159 | originRequestPolicy: new cloudfront.OriginRequestPolicy(this, "TranslateApiOriginRequestPolicy", { 160 | headerBehavior: cloudfront.OriginRequestHeaderBehavior.denyList("host"), 161 | queryStringBehavior: cloudfront.OriginRequestQueryStringBehavior.none(), 162 | cookieBehavior: cloudfront.OriginRequestCookieBehavior.none(), 163 | }), 164 | allowedMethods: cloudfront.AllowedMethods.ALLOW_ALL, 165 | viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.HTTPS_ONLY, 166 | compress: true, 167 | functionAssociations: [ 168 | { 169 | function: new cloudfront.Function(this, "TranslateUrlRewrite", { 170 | code: cloudfront.FunctionCode.fromInline(` 171 | function handler(event) { 172 | var request = event.request; 173 | var uri = request.uri; 174 | if (uri.startsWith('/amazon-translate-proxy')) { 175 | uri = uri.replace('/amazon-translate-proxy', ''); 176 | if (!uri.startsWith('/')) { 177 | uri = '/' + uri; 178 | } 179 | request.uri = uri; 180 | } 181 | return request; 182 | } 183 | `), 184 | runtime: cloudfront.FunctionRuntime.JS_2_0, 185 | }), 186 | eventType: cloudfront.FunctionEventType.VIEWER_REQUEST, 187 | }, 188 | ], 189 | } 190 | ); 191 | } 192 | 193 | /************************************************************************************************************** 194 | * CDK Outputs * 195 | **************************************************************************************************************/ 196 | 197 | new cdk.CfnOutput(this, "webAppBucket", { 198 | value: webAppBucket.bucketName, 199 | }); 200 | 201 | new cdk.CfnOutput(this, "webAppURL", { 202 | value: `https://${webAppCloudFrontDistribution.distributionDomainName}`, 203 | }); 204 | } 205 | } 206 | -------------------------------------------------------------------------------- /cdk-stacks/lib/frontend/frontend-config-stack.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | import * as cdk from "aws-cdk-lib"; 4 | import { Construct } from "constructs"; 5 | import * as lambda from "aws-cdk-lib/aws-lambda"; 6 | import * as iam from "aws-cdk-lib/aws-iam"; 7 | import * as s3 from "aws-cdk-lib/aws-s3"; 8 | 9 | const configParams = require("../../config/config.params.json"); 10 | 11 | export interface FrontendConfigStackProps extends cdk.NestedStackProps { 12 | readonly backendStackOutputs: { key: string; value: string }[]; 13 | readonly webAppBucket: s3.IBucket; 14 | readonly cdkAppName: string; 15 | } 16 | 17 | export class FrontendConfigStack extends cdk.NestedStack { 18 | constructor(scope: Construct, id: string, props: FrontendConfigStackProps) { 19 | super(scope, id, props); 20 | 21 | const buildConfigParameters = () => { 22 | const result: any = {}; 23 | props.backendStackOutputs.forEach((param) => { 24 | result[param.key] = param.value; 25 | }); 26 | return JSON.stringify(result); 27 | }; 28 | 29 | //frontend config custom resource 30 | const frontendConfigLambda = new lambda.Function(this, `FrontendConfigLambda`, { 31 | functionName: `${props.cdkAppName}-FrontendConfigLambda`, 32 | runtime: lambda.Runtime.PYTHON_3_11, 33 | code: lambda.Code.fromAsset("lambdas/custom-resources/frontend-config"), 34 | handler: "index.handler", 35 | timeout: cdk.Duration.seconds(120), 36 | initialPolicy: [ 37 | new iam.PolicyStatement({ 38 | effect: iam.Effect.ALLOW, 39 | actions: ["s3:PutObject", "s3:DeleteObject"], 40 | resources: [ 41 | `${props.webAppBucket.bucketArn}/${configParams["WebAppStagingPrefix"]}frontend-config.zip`, 42 | `${props.webAppBucket.bucketArn}/${configParams["WebAppRootPrefix"]}frontend-config.js`, 43 | ], 44 | }), 45 | ], 46 | }); 47 | 48 | const frontendConfigCustomResource = new cdk.CustomResource(this, `${props.cdkAppName}-FrontendConfigCustomResource`, { 49 | resourceType: "Custom::FrontendConfig", 50 | serviceToken: frontendConfigLambda.functionArn, 51 | properties: { 52 | BucketName: props.webAppBucket.bucketName, 53 | WebAppStagingObjectPrefix: configParams["WebAppStagingPrefix"], 54 | WebAppRootObjectPrefix: configParams["WebAppRootPrefix"], 55 | ObjectKey: `frontend-config.js`, 56 | ContentType: "text/javascript", 57 | Content: `window.WebappConfig = ${buildConfigParameters()}`, 58 | }, 59 | }); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /cdk-stacks/lib/frontend/frontend-s3-deployment-stack.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | import { Construct } from "constructs"; 4 | import * as cdk from "aws-cdk-lib"; 5 | import * as s3deployment from "aws-cdk-lib/aws-s3-deployment"; 6 | import * as s3 from "aws-cdk-lib/aws-s3"; 7 | 8 | const configParams = require("../../config/config.params.json"); 9 | 10 | export interface FrontendS3DeploymentStackProps extends cdk.NestedStackProps { 11 | readonly cdkAppName: string; 12 | readonly webAppBucket: s3.IBucket; 13 | } 14 | 15 | export class FrontendS3DeploymentStack extends cdk.NestedStack { 16 | public readonly webAppBucket: s3.IBucket; 17 | 18 | constructor(scope: Construct, id: string, props: FrontendS3DeploymentStackProps) { 19 | super(scope, id, props); 20 | 21 | const webAppDeployment = new s3deployment.BucketDeployment(scope, `${props.cdkAppName}-WebAppDeployment`, { 22 | destinationBucket: props.webAppBucket, 23 | retainOnDelete: false, 24 | destinationKeyPrefix: configParams["WebAppRootPrefix"], 25 | sources: [ 26 | s3deployment.Source.asset("../webapp/dist"), 27 | s3deployment.Source.bucket(props.webAppBucket, `${configParams["WebAppStagingPrefix"]}frontend-config.zip`), 28 | ], 29 | }); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /cdk-stacks/lib/infrastructure/cognito-stack.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | import * as cdk from "aws-cdk-lib"; 4 | import * as iam from "aws-cdk-lib/aws-iam"; 5 | import * as cognito from "aws-cdk-lib/aws-cognito"; 6 | import { Construct } from "constructs"; 7 | 8 | export interface CognitoStackProps extends cdk.NestedStackProps { 9 | readonly SSMParams: any; 10 | readonly cdkAppName: string; 11 | } 12 | 13 | export class CognitoStack extends cdk.NestedStack { 14 | public readonly authenticatedRole: iam.IRole; 15 | 16 | public readonly identityPool: cognito.CfnIdentityPool; 17 | public readonly userPool: cognito.IUserPool; 18 | public readonly userPoolClient: cognito.IUserPoolClient; 19 | public readonly userPoolDomain: cognito.CfnUserPoolDomain; 20 | 21 | constructor(scope: Construct, id: string, props: CognitoStackProps) { 22 | super(scope, id, props); 23 | 24 | //create a User Pool 25 | const userPool = new cognito.UserPool(this, "UserPool", { 26 | userPoolName: `${props.cdkAppName}-UserPool`, 27 | removalPolicy: cdk.RemovalPolicy.DESTROY, 28 | signInAliases: { 29 | username: false, 30 | phone: false, 31 | email: true, 32 | }, 33 | standardAttributes: { 34 | email: { 35 | required: false, //Cognito bug with federation - If you make a user pool with required email field then the second login attempt fails (https://github.com/aws-amplify/amplify-js/issues/3526) 36 | mutable: true, 37 | }, 38 | }, 39 | customAttributes: { 40 | connectUserId: new cognito.StringAttribute({ minLen: 36, maxLen: 36, mutable: true }), 41 | }, 42 | userInvitation: { 43 | emailSubject: `Your ${props.SSMParams.CdkAppName} temporary password`, 44 | emailBody: `Your ${props.SSMParams.CdkAppName} username is {username} and temporary password is {####}`, 45 | }, 46 | userVerification: { 47 | emailSubject: `Verify your new ${props.SSMParams.CdkAppName} account`, 48 | emailBody: `The verification code to your new ${props.SSMParams.CdkAppName} account is {####}`, 49 | }, 50 | }); 51 | 52 | //SAML Federation 53 | let supportedIdentityProviders: cognito.UserPoolClientIdentityProvider[] = []; 54 | let userPoolClientOAuthConfig: cognito.OAuthSettings = { 55 | scopes: [cognito.OAuthScope.EMAIL, cognito.OAuthScope.OPENID, cognito.OAuthScope.COGNITO_ADMIN, cognito.OAuthScope.PROFILE], 56 | }; 57 | 58 | //Enable Cognito Managed Login Pages 59 | supportedIdentityProviders.push(cognito.UserPoolClientIdentityProvider.COGNITO); 60 | 61 | //create a User Pool Client 62 | const userPoolClient = new cognito.UserPoolClient(this, "UserPoolClient", { 63 | userPool: userPool, 64 | userPoolClientName: props.SSMParams.CdkFrontendStack, 65 | generateSecret: false, 66 | supportedIdentityProviders: supportedIdentityProviders, 67 | oAuth: { 68 | ...userPoolClientOAuthConfig, 69 | callbackUrls: props.SSMParams.cognitoCallbackUrls.split(",").map((item: string) => item.trim()), 70 | logoutUrls: props.SSMParams.cognitoLogoutUrls.split(",").map((item: string) => item.trim()), 71 | }, 72 | }); 73 | 74 | const userPoolDomain = new cognito.CfnUserPoolDomain(this, "UserPoolDomain", { 75 | domain: props.SSMParams.cognitoDomainPrefix, 76 | userPoolId: userPool.userPoolId, 77 | }); 78 | 79 | //create an Identity Pool 80 | const identityPool = new cognito.CfnIdentityPool(this, "IdentityPool", { 81 | identityPoolName: `${props.cdkAppName}-IdentityPool`, 82 | allowUnauthenticatedIdentities: false, 83 | cognitoIdentityProviders: [ 84 | { 85 | clientId: userPoolClient.userPoolClientId, 86 | providerName: userPool.userPoolProviderName, 87 | }, 88 | ], 89 | }); 90 | 91 | //Cognito Identity Pool Roles 92 | const unauthenticatedRole = new iam.Role(this, "CognitoDefaultUnauthenticatedRole", { 93 | assumedBy: new iam.FederatedPrincipal( 94 | "cognito-identity.amazonaws.com", 95 | { 96 | StringEquals: { "cognito-identity.amazonaws.com:aud": identityPool.ref }, 97 | "ForAnyValue:StringLike": { "cognito-identity.amazonaws.com:amr": "unauthenticated" }, 98 | }, 99 | "sts:AssumeRoleWithWebIdentity" 100 | ), 101 | }); 102 | 103 | unauthenticatedRole.addToPolicy( 104 | new iam.PolicyStatement({ 105 | effect: iam.Effect.ALLOW, 106 | actions: ["mobileanalytics:PutEvents", "cognito-sync:*"], 107 | resources: ["*"], 108 | }) 109 | ); 110 | 111 | const authenticatedRole = new iam.Role(this, "CognitoDefaultAuthenticatedRole", { 112 | assumedBy: new iam.FederatedPrincipal( 113 | "cognito-identity.amazonaws.com", 114 | { 115 | StringEquals: { "cognito-identity.amazonaws.com:aud": identityPool.ref }, 116 | "ForAnyValue:StringLike": { "cognito-identity.amazonaws.com:amr": "authenticated" }, 117 | }, 118 | "sts:AssumeRoleWithWebIdentity" 119 | ), 120 | }); 121 | 122 | authenticatedRole.addToPolicy( 123 | new iam.PolicyStatement({ 124 | effect: iam.Effect.ALLOW, 125 | actions: [ 126 | "mobileanalytics:PutEvents", 127 | "cognito-sync:*", 128 | "cognito-identity:*", 129 | "polly:SynthesizeSpeech", 130 | "polly:DescribeVoices", 131 | "transcribe:StartStreamTranscription", 132 | "transcribe:StartStreamTranscriptionWebSocket", 133 | "translate:ListLanguages", 134 | "translate:TranslateText", 135 | "cognito-identity:GetCredentialsForIdentity", 136 | ], 137 | resources: ["*"], 138 | }) 139 | ); 140 | 141 | const defaultPolicy = new cognito.CfnIdentityPoolRoleAttachment(this, "DefaultValid", { 142 | identityPoolId: identityPool.ref, 143 | roles: { 144 | unauthenticated: unauthenticatedRole.roleArn, 145 | authenticated: authenticatedRole.roleArn, 146 | }, 147 | }); 148 | 149 | this.authenticatedRole = authenticatedRole; 150 | 151 | /************************************************************************************************************** 152 | * Stack Outputs * 153 | **************************************************************************************************************/ 154 | 155 | this.identityPool = identityPool; 156 | this.userPool = userPool; 157 | this.userPoolClient = userPoolClient; 158 | this.userPoolDomain = userPoolDomain; 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /cdk-stacks/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cdk-stacks", 3 | "version": "0.1.0", 4 | "bin": { 5 | "cdk-stacks": "bin/cdk-stacks.js" 6 | }, 7 | "scripts": { 8 | "build": "tsc", 9 | "watch": "tsc -w", 10 | "test": "jest", 11 | "cdk": "cdk", 12 | "echo:cdk-version": "cdk --version", 13 | "configure": "node config/configure.js -il", 14 | "configure:test": "node config/configure.js -ilt", 15 | "configure:delete": "node config/configure.js -d", 16 | "configure:help": "node config/configure.js -h", 17 | "install:webapp": "cd ../webapp && npm install", 18 | "install:cdk-stacks": "npm install", 19 | "install:all": "npm run install:webapp && npm run install:cdk-stacks", 20 | "echo:web-app-root-prefix": "node -e 'var config=require(`./config/config.params.json`); console.log(`${config.WebAppRootPrefix}`)' ", 21 | "echo:cdk-frontend-stack-name-param": "node -e 'var config=require(`./config/config.params.json`); console.log(`${config.hierarchy}outputParameters/CdkFrontendStackName`)' ", 22 | "echo:cdk-frontend-stack-physical-name": "aws ssm get-parameter --query 'Parameter'.'Value' --name $(npm run --silent echo:cdk-frontend-stack-name-param) --output text", 23 | "echo:web-app-bucket": "aws cloudformation describe-stacks --stack-name $(npm run --silent echo:cdk-frontend-stack-physical-name) --query 'Stacks[0].Outputs[?OutputKey==`webAppBucket`].OutputValue' --output text", 24 | "sync-config": "aws s3 cp s3://$(npm run --silent echo:web-app-bucket)/$(npm run --silent echo:web-app-root-prefix)frontend-config.js ../webapp/", 25 | "build:webapp": "cd ../webapp && npm run-script build", 26 | "build:webapp:gitbash": "cd ../webapp && npm run-script build:gitbash", 27 | "cdk:remove:context": "rm -f cdk.context.json", 28 | "cdk:deploy": "npm run cdk:remove:context && cdk deploy --all --disable-rollback", 29 | "cdk:deploy:gitbash": "npm run cdk:remove:context && winpty cdk.cmd deploy --all --disable-rollback", 30 | "build:deploy:all": "npm run build:webapp && npm run cdk:deploy", 31 | "build:deploy:all:gitbash": "npm run build:webapp:gitbash && npm run cdk:deploy:gitbash" 32 | }, 33 | "devDependencies": { 34 | "@types/jest": "^29.5.14", 35 | "@types/node": "22.7.9", 36 | "aws-cdk": "2.177.0", 37 | "jest": "^29.7.0", 38 | "ts-jest": "^29.2.5", 39 | "ts-node": "^10.9.2", 40 | "typescript": "~5.6.3" 41 | }, 42 | "dependencies": { 43 | "@aws-sdk/client-ssm": "^3.699.0", 44 | "aws-cdk-lib": "2.177.0", 45 | "constructs": "^10.0.0" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /cdk-stacks/test/cdk-stacks.test.ts: -------------------------------------------------------------------------------- 1 | // import * as cdk from 'aws-cdk-lib'; 2 | // import { Template } from 'aws-cdk-lib/assertions'; 3 | // import * as CdkStacks from '../lib/cdk-stacks-stack'; 4 | 5 | // example test. To run these tests, uncomment this file along with the 6 | // example resource in lib/cdk-stacks-stack.ts 7 | test('SQS Queue Created', () => { 8 | // const app = new cdk.App(); 9 | // // WHEN 10 | // const stack = new CdkStacks.CdkStacksStack(app, 'MyTestStack'); 11 | // // THEN 12 | // const template = Template.fromStack(stack); 13 | 14 | // template.hasResourceProperties('AWS::SQS::Queue', { 15 | // VisibilityTimeout: 300 16 | // }); 17 | }); 18 | -------------------------------------------------------------------------------- /cdk-stacks/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "module": "commonjs", 5 | "lib": [ 6 | "es2020", 7 | "dom" 8 | ], 9 | "declaration": true, 10 | "strict": true, 11 | "noImplicitAny": true, 12 | "strictNullChecks": true, 13 | "noImplicitThis": true, 14 | "alwaysStrict": true, 15 | "noUnusedLocals": false, 16 | "noUnusedParameters": false, 17 | "noImplicitReturns": true, 18 | "noFallthroughCasesInSwitch": false, 19 | "inlineSourceMap": true, 20 | "inlineSources": true, 21 | "experimentalDecorators": true, 22 | "strictPropertyInitialization": false, 23 | "typeRoots": [ 24 | "./node_modules/@types" 25 | ] 26 | }, 27 | "exclude": [ 28 | "node_modules", 29 | "cdk.out" 30 | ] 31 | } 32 | -------------------------------------------------------------------------------- /diagrams/AmazonConnectV2V-AmazonConnectV2VArchitecture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/connect-v2v-translation-with-cx-options/382803adcd93254a3ec7b7845bc030042b757485/diagrams/AmazonConnectV2V-AmazonConnectV2VArchitecture.png -------------------------------------------------------------------------------- /diagrams/AmazonConnectV2V-EmbeddedCCP.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/connect-v2v-translation-with-cx-options/382803adcd93254a3ec7b7845bc030042b757485/diagrams/AmazonConnectV2V-EmbeddedCCP.png -------------------------------------------------------------------------------- /diagrams/AmazonConnectV2V-NoStreamingAddOns.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/connect-v2v-translation-with-cx-options/382803adcd93254a3ec7b7845bc030042b757485/diagrams/AmazonConnectV2V-NoStreamingAddOns.png -------------------------------------------------------------------------------- /diagrams/AmazonConnectV2V-WithStreamingAddOns.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/connect-v2v-translation-with-cx-options/382803adcd93254a3ec7b7845bc030042b757485/diagrams/AmazonConnectV2V-WithStreamingAddOns.png -------------------------------------------------------------------------------- /diagrams/AmazonConnectV2VScreenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/connect-v2v-translation-with-cx-options/382803adcd93254a3ec7b7845bc030042b757485/diagrams/AmazonConnectV2VScreenshot.png -------------------------------------------------------------------------------- /webapp/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | 26 | #ignore frontend-config.js, as it's downloaded from S3 27 | frontend-config.js -------------------------------------------------------------------------------- /webapp/adapters/pollyAdapter.js: -------------------------------------------------------------------------------- 1 | // Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | import { DescribeVoicesCommand, PollyClient, SynthesizeSpeechCommand, LanguageCode, Engine } from "@aws-sdk/client-polly"; 4 | import { hasValidAwsCredentials, getValidAwsCredentials } from "../utils/authUtility"; 5 | import { HttpRequest } from "@aws-sdk/protocol-http"; 6 | import { POLLY_CONFIG } from "../config"; 7 | import { LOGGER_PREFIX } from "../constants"; 8 | import { Buffer } from "buffer"; 9 | import { isDevEnvironment, isStringUndefinedNullEmpty } from "../utils/commonUtility"; 10 | 11 | let _amazonPollyClient; 12 | 13 | async function getAmazonPollyClient() { 14 | try { 15 | if (_amazonPollyClient != null && hasValidAwsCredentials()) { 16 | // console.info( 17 | // `${LOGGER_PREFIX} - getAmazonPollyClient - Reusing existing Polly client` 18 | // ); 19 | return _amazonPollyClient; 20 | } 21 | 22 | // Initialize AWS services with credentials 23 | const credentials = await getValidAwsCredentials(); 24 | _amazonPollyClient = new PollyClient({ 25 | region: POLLY_CONFIG.pollyRegion, 26 | credentials: { 27 | accessKeyId: credentials.accessKeyId, 28 | secretAccessKey: credentials.secretAccessKey, 29 | sessionToken: credentials.sessionToken, 30 | }, 31 | }); 32 | 33 | //if not running on localhost, add proxy 34 | if (!isDevEnvironment() && POLLY_CONFIG.pollyProxyEnabled) { 35 | _amazonPollyClient.middlewareStack.add( 36 | (next) => async (args) => { 37 | if (HttpRequest.isInstance(args.request)) { 38 | // Change the hostname and add the proxy path 39 | args.request.hostname = POLLY_CONFIG.pollyProxyHostname; 40 | args.request.path = "/amazon-polly-proxy" + args.request.path; 41 | } 42 | return next(args); 43 | }, 44 | { 45 | step: "finalizeRequest", // After signature calculation 46 | name: "addProxyEndpointMiddleware", 47 | } 48 | ); 49 | } 50 | 51 | // console.info( 52 | // `${LOGGER_PREFIX} - getAmazonPollyClient - Created new Polly client` 53 | // ); 54 | return _amazonPollyClient; 55 | } catch (error) { 56 | console.error(`${LOGGER_PREFIX} - initializeAwsServices - Error initializing AWS services:`, error); 57 | throw error; 58 | } 59 | } 60 | 61 | export function listPollyLanguages() { 62 | //returns an array of Polly language codes 63 | return Object.values(LanguageCode); 64 | } 65 | 66 | export function listPollyEngines() { 67 | //returns an array of Polly engines 68 | return Object.values(Engine); 69 | } 70 | 71 | export async function describeVoices(languageCode, engine) { 72 | if (isStringUndefinedNullEmpty(languageCode)) throw new Error("languageCode is required"); 73 | if (isStringUndefinedNullEmpty(engine)) throw new Error("engine is required"); 74 | 75 | const describeVoicesCommand = new DescribeVoicesCommand({ 76 | LanguageCode: languageCode, 77 | Engine: engine, 78 | IncludeAdditionalLanguageCodes: true, 79 | }); 80 | 81 | const amazonPollyClient = await getAmazonPollyClient(); 82 | const response = await amazonPollyClient.send(describeVoicesCommand); 83 | return response.Voices; 84 | } 85 | 86 | export async function synthesizeSpeech(languageCode, engine, voiceId, inputText) { 87 | if (isStringUndefinedNullEmpty(languageCode)) throw new Error("languageCode is required"); 88 | if (isStringUndefinedNullEmpty(engine)) throw new Error("engine is required"); 89 | if (isStringUndefinedNullEmpty(voiceId)) throw new Error("voiceId is required"); 90 | if (isStringUndefinedNullEmpty(inputText)) throw new Error("inputText is required"); 91 | 92 | const synthesizeSpeechCommand = new SynthesizeSpeechCommand({ 93 | OutputFormat: "ogg_vorbis", 94 | LanguageCode: languageCode, 95 | Engine: engine, 96 | VoiceId: voiceId, 97 | Text: inputText, 98 | }); 99 | 100 | const amazonPollyClient = await getAmazonPollyClient(); 101 | const response = await amazonPollyClient.send(synthesizeSpeechCommand); 102 | const audioDataArray = await response.AudioStream.transformToByteArray(); 103 | const base64AudioData = Buffer.from(audioDataArray).toString("base64"); 104 | return base64AudioData; 105 | } 106 | -------------------------------------------------------------------------------- /webapp/adapters/transcribeAdapter.js: -------------------------------------------------------------------------------- 1 | // Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | import { StartStreamTranscriptionCommand, TranscribeStreamingClient, LanguageCode } from "@aws-sdk/client-transcribe-streaming"; 4 | import { TRANSCRIBE_CONFIG } from "../config"; 5 | import { LOGGER_PREFIX, TRANSCRIBE_PARTIAL_RESULTS_STABILITY } from "../constants"; 6 | import { getValidAwsCredentials, hasValidAwsCredentials } from "../utils/authUtility"; 7 | import { isFunction, isObjectUndefinedNullEmpty, isStringUndefinedNullEmpty } from "../utils/commonUtility"; 8 | import { getTranscribeAudioStream, getTranscribeMicStream } from "../utils/transcribeUtils"; 9 | 10 | let _amazonTranscribeClientAgent; 11 | let _amazonTranscribeClientCustomer; 12 | 13 | export async function getAmazonTranscribeClientAgent() { 14 | try { 15 | if (_amazonTranscribeClientAgent != null && hasValidAwsCredentials()) { 16 | return _amazonTranscribeClientAgent; 17 | } 18 | 19 | // Initialize AWS services with credentials 20 | const credentials = await getValidAwsCredentials(); 21 | _amazonTranscribeClientAgent = new TranscribeStreamingClient({ 22 | region: TRANSCRIBE_CONFIG.transcribeRegion, 23 | credentials: { 24 | accessKeyId: credentials.accessKeyId, 25 | secretAccessKey: credentials.secretAccessKey, 26 | sessionToken: credentials.sessionToken, 27 | }, 28 | }); 29 | 30 | return _amazonTranscribeClientAgent; 31 | } catch (error) { 32 | console.error(`${LOGGER_PREFIX} - initializeAwsServices - Error initializing AWS services:`, error); 33 | throw error; 34 | } 35 | } 36 | 37 | export async function getAmazonTranscribeClientCustomer() { 38 | try { 39 | if (_amazonTranscribeClientCustomer != null && hasValidAwsCredentials()) { 40 | return _amazonTranscribeClientCustomer; 41 | } 42 | 43 | // Initialize AWS services with credentials 44 | const credentials = await getValidAwsCredentials(); 45 | _amazonTranscribeClientCustomer = new TranscribeStreamingClient({ 46 | region: TRANSCRIBE_CONFIG.transcribeRegion, 47 | credentials: { 48 | accessKeyId: credentials.accessKeyId, 49 | secretAccessKey: credentials.secretAccessKey, 50 | sessionToken: credentials.sessionToken, 51 | }, 52 | }); 53 | 54 | return _amazonTranscribeClientCustomer; 55 | } catch (error) { 56 | console.error(`${LOGGER_PREFIX} - initializeAwsServices - Error initializing AWS services:`, error); 57 | throw error; 58 | } 59 | } 60 | 61 | export async function startCustomerStreamTranscription( 62 | audioStream, 63 | sampleRate, 64 | languageCode, 65 | partialResultStability, 66 | onFinalTranscribeEvent, 67 | onPartialTranscribeEvent 68 | ) { 69 | if (isObjectUndefinedNullEmpty(audioStream)) throw new Error("audioStream is required"); 70 | if (!Number.isInteger(sampleRate)) throw new Error("sampleRate is required as integer"); 71 | if (isStringUndefinedNullEmpty(languageCode)) throw new Error("languageCode is required"); 72 | if (isStringUndefinedNullEmpty(partialResultStability)) throw new Error("partialResultStability is required"); 73 | if (isFunction(onFinalTranscribeEvent)) throw new Error("onFinalTranscribeEvent is required"); 74 | if (isFunction(onPartialTranscribeEvent)) throw new Error("onPartialTranscribeEvent is required"); 75 | 76 | const enablePartialResultsStabilization = TRANSCRIBE_PARTIAL_RESULTS_STABILITY.includes(partialResultStability); 77 | 78 | const startStreamTranscriptionCommand = new StartStreamTranscriptionCommand({ 79 | LanguageCode: languageCode, 80 | MediaEncoding: "pcm", 81 | MediaSampleRateHertz: sampleRate, 82 | AudioStream: getTranscribeAudioStream(audioStream, sampleRate), 83 | EnablePartialResultsStabilization: enablePartialResultsStabilization, 84 | PartialResultsStability: enablePartialResultsStabilization ? partialResultStability : undefined, 85 | }); 86 | 87 | const amazonTranscribeClientCustomer = await getAmazonTranscribeClientCustomer(); 88 | const startStreamTranscriptionResponse = await amazonTranscribeClientCustomer.send(startStreamTranscriptionCommand); 89 | 90 | let lastProcessedIndex = 0; 91 | 92 | for await (const event of startStreamTranscriptionResponse.TranscriptResultStream) { 93 | const transcriptResults = event.TranscriptEvent.Transcript.Results; 94 | 95 | const getPartialTranscriptResult = getPartialTranscript(transcriptResults, lastProcessedIndex); 96 | if (getPartialTranscriptResult != null) onPartialTranscribeEvent(getPartialTranscriptResult.partialTranscript); 97 | 98 | const getFinalTranscriptResult = getFinalTranscript(transcriptResults, lastProcessedIndex, enablePartialResultsStabilization); 99 | if (getFinalTranscriptResult != null) { 100 | lastProcessedIndex = getFinalTranscriptResult.lastProcessedIndex; 101 | onFinalTranscribeEvent(getFinalTranscriptResult.finalTranscript); 102 | } 103 | } 104 | } 105 | 106 | export async function startAgentStreamTranscription( 107 | audioStream, 108 | sampleRate, 109 | languageCode, 110 | partialResultStability, 111 | onFinalTranscribeEvent, 112 | onPartialTranscribeEvent 113 | ) { 114 | if (isObjectUndefinedNullEmpty(audioStream)) throw new Error("audioStream is required"); 115 | if (!Number.isInteger(sampleRate)) throw new Error("sampleRate is required as integer"); 116 | if (isStringUndefinedNullEmpty(languageCode)) throw new Error("languageCode is required"); 117 | if (isStringUndefinedNullEmpty(partialResultStability)) throw new Error("partialResultStability is required"); 118 | if (isFunction(onFinalTranscribeEvent)) throw new Error("onFinalTranscribeEvent is required"); 119 | if (isFunction(onPartialTranscribeEvent)) throw new Error("onPartialTranscribeEvent is required"); 120 | 121 | const enablePartialResultsStabilization = TRANSCRIBE_PARTIAL_RESULTS_STABILITY.includes(partialResultStability); 122 | 123 | const startStreamTranscriptionCommand = new StartStreamTranscriptionCommand({ 124 | LanguageCode: languageCode, 125 | MediaEncoding: "pcm", 126 | MediaSampleRateHertz: sampleRate, 127 | AudioStream: getTranscribeMicStream(audioStream, sampleRate), 128 | EnablePartialResultsStabilization: enablePartialResultsStabilization, 129 | PartialResultsStability: enablePartialResultsStabilization ? partialResultStability : undefined, 130 | }); 131 | 132 | const amazonTranscribeClientAgent = await getAmazonTranscribeClientAgent(); 133 | const startStreamTranscriptionResponse = await amazonTranscribeClientAgent.send(startStreamTranscriptionCommand); 134 | 135 | let lastProcessedIndex = 0; 136 | 137 | for await (const event of startStreamTranscriptionResponse.TranscriptResultStream) { 138 | const transcriptResults = event.TranscriptEvent.Transcript.Results; 139 | 140 | const getPartialTranscriptResult = getPartialTranscript(transcriptResults, lastProcessedIndex); 141 | if (getPartialTranscriptResult != null) onPartialTranscribeEvent(getPartialTranscriptResult.partialTranscript); 142 | 143 | const getFinalTranscriptResult = getFinalTranscript(transcriptResults, lastProcessedIndex, enablePartialResultsStabilization); 144 | if (getFinalTranscriptResult != null) { 145 | lastProcessedIndex = getFinalTranscriptResult.lastProcessedIndex; 146 | onFinalTranscribeEvent(getFinalTranscriptResult.finalTranscript); 147 | } 148 | } 149 | } 150 | 151 | function getPartialTranscript(transcriptResults = [], lastProcessedIndex = 0) { 152 | if (transcriptResults.length === 0) return null; 153 | if (transcriptResults[0].IsPartial !== true) return null; 154 | 155 | // Handle regular partial transcript - to update the UI as quickly as possible 156 | const partialTranscriptItems = transcriptResults[0].Alternatives[0].Items; 157 | if (partialTranscriptItems?.length > 0) { 158 | // Get only the items after lastProcessedIndex 159 | const partialTranscript = joinTranscriptItems(partialTranscriptItems, lastProcessedIndex); 160 | return { partialTranscript }; 161 | } 162 | } 163 | 164 | function getFinalTranscript(transcriptResults = [], lastProcessedIndex = 0, enablePartialResultsStabilization = false) { 165 | if (transcriptResults.length === 0) return null; 166 | if (transcriptResults[0].IsPartial === true && enablePartialResultsStabilization === false) return null; 167 | 168 | //Handle regular final transcript 169 | if (transcriptResults[0].IsPartial === false) { 170 | const finalTranscriptItems = transcriptResults[0].Alternatives[0].Items; 171 | if (finalTranscriptItems?.length > 0) { 172 | const finalTranscript = joinTranscriptItems(finalTranscriptItems, lastProcessedIndex); 173 | return { finalTranscript, lastProcessedIndex: 0 }; 174 | } 175 | } 176 | 177 | // If transcript is partial, check if we have a stable transcript 178 | // Stable transcript is a transcript where all items are stable and the last item is a punctuation 179 | 180 | // Find the index of the first punctuation after the lastProcessedIndex 181 | const firstSegmentEndIndex = transcriptResults[0].Alternatives[0].Items.findIndex( 182 | (item, index) => index >= lastProcessedIndex && item.Type === "punctuation" && [",", ".", "!", "?"].includes(item.Content) 183 | ); 184 | if (firstSegmentEndIndex === -1) return null; // We were not able to find a punctuation 185 | 186 | // Get all items up to and including the punctuation 187 | const segmentItems = transcriptResults[0].Alternatives[0].Items.slice(lastProcessedIndex, firstSegmentEndIndex + 1); 188 | 189 | // Check if ALL items in this segment are stable 190 | const allItemsAreStable = segmentItems.every((item) => item.Stable === true); 191 | if (allItemsAreStable === false) return null; // We were not able to find a punctuation 192 | 193 | const stableTranscript = joinTranscriptItems(segmentItems); 194 | return { finalTranscript: stableTranscript, lastProcessedIndex: firstSegmentEndIndex + 1 }; 195 | } 196 | 197 | function joinTranscriptItems(transcriptItems = [], lastProcessedIndex = 0) { 198 | const resultTranscriptString = transcriptItems 199 | .slice(lastProcessedIndex) 200 | .map((item) => item.Content) 201 | .join(" ") 202 | .trim() 203 | .replace(/\s+([.,!?])/g, "$1"); // Clean up spaces before punctuation 204 | return resultTranscriptString; 205 | } 206 | 207 | export function listStreamingLanguages() { 208 | //returns an array of streaming language codes 209 | return Object.values(LanguageCode); 210 | } 211 | -------------------------------------------------------------------------------- /webapp/adapters/translateAdapter.js: -------------------------------------------------------------------------------- 1 | // Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | import { ListLanguagesCommand, TranslateClient, TranslateTextCommand } from "@aws-sdk/client-translate"; 4 | import { hasValidAwsCredentials, getValidAwsCredentials } from "../utils/authUtility"; 5 | import { HttpRequest } from "@aws-sdk/protocol-http"; 6 | import { LOGGER_PREFIX } from "../constants"; 7 | import { TRANSLATE_CONFIG } from "../config"; 8 | import { isDevEnvironment, isStringUndefinedNullEmpty } from "../utils/commonUtility"; 9 | 10 | let _amazonTranslateClient; 11 | 12 | async function getAmazonTranslateClient() { 13 | try { 14 | if (_amazonTranslateClient != null && hasValidAwsCredentials()) { 15 | // console.info( 16 | // `${LOGGER_PREFIX} - getAmazonTranslateClient - Reusing existing Translate client` 17 | // ); 18 | return _amazonTranslateClient; 19 | } 20 | 21 | // Initialize AWS services with credentials 22 | const credentials = await getValidAwsCredentials(); 23 | _amazonTranslateClient = new TranslateClient({ 24 | region: TRANSLATE_CONFIG.translateRegion, 25 | credentials: { 26 | accessKeyId: credentials.accessKeyId, 27 | secretAccessKey: credentials.secretAccessKey, 28 | sessionToken: credentials.sessionToken, 29 | }, 30 | }); 31 | 32 | //if not running on localhost, add proxy 33 | if (!isDevEnvironment() && TRANSLATE_CONFIG.translateProxyEnabled) { 34 | _amazonTranslateClient.middlewareStack.add( 35 | (next) => async (args) => { 36 | if (HttpRequest.isInstance(args.request)) { 37 | // Change the hostname and add the proxy path 38 | args.request.hostname = TRANSLATE_CONFIG.translateProxyHostname; 39 | args.request.path = "/amazon-translate-proxy" + args.request.path; 40 | } 41 | return next(args); 42 | }, 43 | { 44 | step: "finalizeRequest", // After signature calculation 45 | name: "addProxyEndpointMiddleware", 46 | } 47 | ); 48 | } 49 | 50 | // console.info( 51 | // `${LOGGER_PREFIX} - getAmazonTranslateClient - Created new Translate client` 52 | // ); 53 | return _amazonTranslateClient; 54 | } catch (error) { 55 | console.error(`${LOGGER_PREFIX} - initializeAwsServices - Error initializing AWS services:`, error); 56 | throw error; 57 | } 58 | } 59 | 60 | export async function translateText(fromLanguage, toLanguage, inputText) { 61 | if (isStringUndefinedNullEmpty(fromLanguage)) throw new Error("fromLanguage is required"); 62 | if (isStringUndefinedNullEmpty(toLanguage)) throw new Error("toLanguage is required"); 63 | if (isStringUndefinedNullEmpty(inputText)) throw new Error("inputText is required"); 64 | 65 | const translateTextCommand = new TranslateTextCommand({ 66 | Text: inputText, 67 | SourceLanguageCode: fromLanguage, 68 | TargetLanguageCode: toLanguage, 69 | }); 70 | 71 | const amazonTranslateClient = await getAmazonTranslateClient(); 72 | const response = await amazonTranslateClient.send(translateTextCommand); 73 | return response.TranslatedText; 74 | } 75 | 76 | export async function listTranslateLanguages() { 77 | const listLanguagesCommand = new ListLanguagesCommand({ 78 | MaxResults: 500, 79 | }); 80 | 81 | const amazonTranslateClient = await getAmazonTranslateClient(); 82 | const response = await amazonTranslateClient.send(listLanguagesCommand); 83 | return response.Languages; 84 | } 85 | -------------------------------------------------------------------------------- /webapp/assets/background_noise.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/connect-v2v-translation-with-cx-options/382803adcd93254a3ec7b7845bc030042b757485/webapp/assets/background_noise.wav -------------------------------------------------------------------------------- /webapp/assets/chime-sound-7143.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/connect-v2v-translation-with-cx-options/382803adcd93254a3ec7b7845bc030042b757485/webapp/assets/chime-sound-7143.mp3 -------------------------------------------------------------------------------- /webapp/assets/javascript.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /webapp/assets/speech_20241113001759828.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/connect-v2v-translation-with-cx-options/382803adcd93254a3ec7b7845bc030042b757485/webapp/assets/speech_20241113001759828.mp3 -------------------------------------------------------------------------------- /webapp/config.js: -------------------------------------------------------------------------------- 1 | export const COGNITO_CONFIG = { 2 | region: getParamValue(window.WebappConfig.backendRegion), 3 | cognitoDomain: getParamValue(window.WebappConfig.cognitoDomainURL), 4 | identityPoolId: getParamValue(window.WebappConfig.identityPoolId), 5 | userPoolId: getParamValue(window.WebappConfig.userPoolId), 6 | clientId: getParamValue(window.WebappConfig.userPoolWebClientId), 7 | }; 8 | 9 | export const CONNECT_CONFIG = { 10 | connectInstanceURL: getParamValue(window.WebappConfig.connectInstanceURL), 11 | connectInstanceRegion: getParamValue(window.WebappConfig.connectInstanceRegion), 12 | }; 13 | 14 | export const TRANSCRIBE_CONFIG = { 15 | transcribeRegion: getParamValue(window.WebappConfig.transcribeRegion), 16 | }; 17 | 18 | export const TRANSLATE_CONFIG = { 19 | translateRegion: getParamValue(window.WebappConfig.translateRegion), 20 | translateProxyEnabled: getBoolParamValue(window.WebappConfig.translateProxyEnabled), 21 | translateProxyHostname: window.location.hostname, // using Amazon Cloudfront as a proxy 22 | }; 23 | 24 | export const POLLY_CONFIG = { 25 | pollyRegion: getParamValue(window.WebappConfig.pollyRegion), 26 | pollyProxyEnabled: getBoolParamValue(window.WebappConfig.pollyProxyEnabled), 27 | pollyProxyHostname: window.location.hostname, // using Amazon Cloudfront as a proxy 28 | }; 29 | 30 | function getParamValue(param) { 31 | const SSM_NOT_DEFINED = "not-defined"; 32 | if (param === SSM_NOT_DEFINED) return undefined; 33 | return param; 34 | } 35 | 36 | function getBoolParamValue(param) { 37 | return param === "true"; 38 | } 39 | -------------------------------------------------------------------------------- /webapp/constants.js: -------------------------------------------------------------------------------- 1 | export const DEPRECATED_CONNECT_DOMAIN = "awsapps.com"; 2 | 3 | export const SESSION_STORAGE_KEYS = {}; 4 | 5 | export const LOGGER_PREFIX = "CCP-V2V"; 6 | 7 | export const CUSTOMER_TRANSLATION_TO_CUSTOMER_VOLUME = 0.1; 8 | export const AGENT_TRANSLATION_TO_AGENT_VOLUME = 0.1; 9 | 10 | export const TRANSCRIBE_PARTIAL_RESULTS_STABILITY = ["low", "medium", "high"]; 11 | 12 | export const AUDIO_FEEDBACK_FILE_PATH = "./assets/background_noise.wav"; 13 | -------------------------------------------------------------------------------- /webapp/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | Amazon Connect V2V 10 | 11 | 12 | 13 |
14 |
15 |

Amazon Connect V2V

16 | 17 |
18 | 19 |
20 |
21 |
22 |

Contact Control Panel

23 |
24 |
25 |
26 |

Audio Controls

27 |
28 |

Select Microphone and Speaker

29 |
30 | 31 | 34 | 35 | 36 |
37 |
38 | 39 | 42 | 43 |
44 |
45 |
46 |
47 |
48 | 55 | 60 |
61 |
62 | 69 | 74 |
75 |
76 | 82 | 87 |
88 |
89 |
90 |

Swap Audio Source

91 | 92 | 93 | 94 |
95 |
96 |
97 |

Audio Playback

98 |
99 | 100 | 101 |
102 |
103 | 104 | 105 |
106 |
107 | 108 | 109 |
110 |
111 |
112 |
113 |
114 |
115 |

Customer Controls

116 |
117 |

Transcribe Customer Voice

118 |
119 | 120 | 123 | 124 |
125 |
126 | 127 | 130 | 131 |
132 |
133 | 134 | 135 |
136 |
137 | 138 | 139 |
140 |
141 | 142 | 143 |
144 |
145 | 146 | 147 |
148 |
149 | 150 |
151 |
152 |
153 |
154 |

Translate Customer Voice

155 |
156 | 157 | 160 | 161 |
162 |
163 | 164 | 167 | 168 |
169 |
170 | 171 |
172 |
173 |
174 |
175 |

Synthesize Customer Voice

176 |
177 | 178 | 181 | 182 |
183 |
184 | 185 | 186 | 187 |
188 |
189 | 190 | 193 | 194 |
195 |
196 |
197 | 198 |
199 |

Agent Controls

200 |
201 |

Transcribe Agent Voice

202 |
203 | 204 | 207 | 208 |
209 |
210 | 211 | 214 | 215 |
216 |
217 | 218 | 219 | 220 |
221 |
222 | 223 | 224 |
225 |
226 | 227 | 228 |
229 |
230 | 231 | 232 |
233 |
234 | 235 | 236 |
237 |
238 | 239 |
240 |
241 |
242 |
243 |

Translate Agent Voice

244 |
245 | 246 | 249 | 250 |
251 |
252 | 253 | 256 | 257 |
258 |
259 | 260 | 261 |
262 |
263 | 264 |
265 |
266 |
267 |
268 |

Synthesize Agent Voice

269 |
270 | 271 | 274 | 275 |
276 |
277 | 278 | 279 | 280 |
281 |
282 | 283 | 286 | 287 |
288 |
289 | 290 | 291 |
292 |
293 |
294 | 295 |
296 |

Transcription

297 |
298 |
299 |
300 | 301 |
302 |
303 |
304 |
305 |
306 |
307 |
308 | 309 | 310 | 311 | -------------------------------------------------------------------------------- /webapp/managers/AudioContextManager.js: -------------------------------------------------------------------------------- 1 | // Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | 3 | import { LOGGER_PREFIX } from "../constants"; 4 | 5 | const INTERACTION_EVENTS = ["click", "touchstart", "keydown"]; 6 | 7 | // SPDX-License-Identifier: MIT-0 8 | export class AudioContextManager { 9 | constructor() { 10 | this.audioContext = new AudioContext(); 11 | this.interactionPromise = null; 12 | this.isWaitingForInteraction = this.audioContext.state === "suspended"; // AudioContext is suspended until the user makes an interaction with the webpage 13 | this.setupUserInteractionListeners(); 14 | } 15 | 16 | //add event listeners to detect if the user has interacted with webpage 17 | setupUserInteractionListeners() { 18 | const handleInteraction = async () => { 19 | console.info(`${LOGGER_PREFIX} - User interaction detected`); 20 | if (this.isWaitingForInteraction) { 21 | await this.audioContext.resume(); 22 | console.info(`${LOGGER_PREFIX} - AudioContext state is [${this.audioContext.state}]`); 23 | // Remove listeners once we've successfully resumed 24 | this.removeUserInteractionListeners(); 25 | } 26 | }; 27 | 28 | this.boundHandleInteraction = handleInteraction.bind(this); 29 | console.info(`${LOGGER_PREFIX} - Setting up user interaction listeners`); 30 | INTERACTION_EVENTS.forEach((eventType) => { 31 | document.addEventListener(eventType, this.boundHandleInteraction, { once: true }); 32 | }); 33 | } 34 | 35 | //once interaction is detected, remove the event listeners 36 | removeUserInteractionListeners() { 37 | INTERACTION_EVENTS.forEach((eventType) => { 38 | document.removeEventListener(eventType, this.boundHandleInteraction); 39 | }); 40 | this.isWaitingForInteraction = false; 41 | } 42 | 43 | promptForInteraction() { 44 | if (this.interactionPromise) { 45 | return this.interactionPromise; 46 | } 47 | 48 | this.interactionPromise = new Promise((resolve) => { 49 | // Create modal overlay 50 | const overlay = document.createElement("div"); 51 | overlay.style.cssText = ` 52 | position: fixed; 53 | top: 0; 54 | left: 0; 55 | right: 0; 56 | bottom: 0; 57 | background: rgba(0, 0, 0, 0.7); 58 | display: flex; 59 | align-items: center; 60 | justify-content: center; 61 | z-index: 1000; 62 | `; 63 | 64 | // Create prompt container 65 | const promptContainer = document.createElement("div"); 66 | promptContainer.style.cssText = ` 67 | background: white; 68 | padding: 20px; 69 | border-radius: 8px; 70 | text-align: center; 71 | max-width: 80%; 72 | `; 73 | 74 | // Create button 75 | const button = document.createElement("button"); 76 | button.textContent = "Click to Enable Audio"; 77 | button.style.cssText = ` 78 | padding: 10px 20px; 79 | font-size: 16px; 80 | cursor: pointer; 81 | background: #007bff; 82 | color: white; 83 | border: none; 84 | border-radius: 4px; 85 | margin-top: 10px; 86 | `; 87 | 88 | const handleInteraction = async () => { 89 | try { 90 | await this.audioContext.resume(); 91 | console.info(`${LOGGER_PREFIX} - AudioContext state is [${this.audioContext.state}]`); 92 | this.removeUserInteractionListeners(); 93 | overlay.remove(); 94 | resolve(); 95 | } catch (error) { 96 | console.error(`${LOGGER_PREFIX} - Failed to resume AudioContext:`, error); 97 | } 98 | }; 99 | 100 | button.addEventListener("click", handleInteraction); 101 | promptContainer.appendChild(button); 102 | overlay.appendChild(promptContainer); 103 | document.body.appendChild(overlay); 104 | }); 105 | 106 | return this.interactionPromise; 107 | } 108 | 109 | async getAudioContext() { 110 | if (this.audioContext?.state === "suspended") { 111 | if (this.isWaitingForInteraction) { 112 | await this.promptForInteraction(); 113 | } else { 114 | await this.audioContext.resume(); // AudioContext can resume if the user has already interacted with the webpage 115 | } 116 | } 117 | 118 | console.info(`${LOGGER_PREFIX} - AudioContext state is [${this.audioContext.state}]`); 119 | return this.audioContext; 120 | } 121 | 122 | async resume() { 123 | if (this.audioContext?.state === "suspended") { 124 | await this.audioContext.resume(); 125 | } 126 | } 127 | 128 | async suspend() { 129 | if (this.audioContext?.state === "running") { 130 | await this.audioContext.suspend(); 131 | } 132 | } 133 | 134 | async dispose() { 135 | if (this.audioContext) { 136 | await this.audioContext.close(); 137 | } 138 | } 139 | 140 | getState() { 141 | return this.audioContext?.state; 142 | } 143 | 144 | getActualSampleRate() { 145 | //The actual sample rate might change when switching audio devices (i.e. switching to wireless headphones) 146 | const tmpAudioContext = new AudioContext(); 147 | const actualSampleRate = tmpAudioContext.sampleRate; 148 | tmpAudioContext.close(); 149 | return actualSampleRate; 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /webapp/managers/AudioStreamManager.js: -------------------------------------------------------------------------------- 1 | // Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | import { LOGGER_PREFIX } from "../constants"; 4 | import { isStringUndefinedNullEmpty } from "../utils/commonUtility"; 5 | 6 | export class AudioStreamManager { 7 | constructor(audioElement, audioContext) { 8 | this.audioContext = audioContext; 9 | this.mediaStreamDestination = this.audioContext.createMediaStreamDestination(); 10 | this.audioElement = audioElement; 11 | 12 | // Set up permanent stream 13 | this.audioElement.srcObject = this.mediaStreamDestination.stream; 14 | // Store the audio track 15 | this.audioTrack = this.mediaStreamDestination.stream.getAudioTracks()[0]; 16 | this.audioElement.play(); 17 | 18 | // Queue for managing multiple audio requests 19 | this.audioQueue = []; 20 | this.isPlaying = false; 21 | 22 | this.audioFeedbackNode = null; 23 | this.shouldPlayAudioFeedback = false; 24 | 25 | this.microphoneStream = null; 26 | this.microphoneGain = null; 27 | this.isMicrophoneActive = false; 28 | this.activeMicrophoneDeviceId; 29 | 30 | this.customFeedbackBuffer = null; 31 | } 32 | 33 | async startMicrophone(microphoneConstraints) { 34 | try { 35 | const microphoneDeviceId = microphoneConstraints?.audio?.deviceId; 36 | if (microphoneDeviceId == null) throw new Error("Microphone deviceId not provided!"); 37 | 38 | if (this.isMicrophoneActive) { 39 | if (this.activeMicrophoneDeviceId === microphoneDeviceId) { 40 | console.info(`${LOGGER_PREFIX} - Microphone [${microphoneDeviceId}] already active`); 41 | return; 42 | } else { 43 | this.stopMicrophone(); 44 | } 45 | } 46 | 47 | // Get microphone stream 48 | this.activeMicrophoneDeviceId = microphoneDeviceId; 49 | const stream = await navigator.mediaDevices.getUserMedia(microphoneConstraints); 50 | 51 | // Create source from microphone 52 | const micSource = this.audioContext.createMediaStreamSource(stream); 53 | 54 | // Create gain node for microphone volume control 55 | this.microphoneGain = this.audioContext.createGain(); 56 | this.microphoneGain.gain.setValueAtTime(1.0, this.audioContext.currentTime); 57 | 58 | // Connect microphone through gain to destination 59 | micSource.connect(this.microphoneGain); 60 | this.microphoneGain.connect(this.mediaStreamDestination); 61 | 62 | // Store stream for cleanup 63 | this.microphoneStream = stream; 64 | this.isMicrophoneActive = true; 65 | 66 | console.info(`${LOGGER_PREFIX} - Microphone started successfully`); 67 | } catch (error) { 68 | console.error(`${LOGGER_PREFIX} - Error starting microphone:`, error); 69 | throw error; 70 | } 71 | } 72 | 73 | stopMicrophone() { 74 | if (!this.isMicrophoneActive) return; 75 | 76 | if (this.microphoneStream) { 77 | // Stop all audio tracks 78 | this.microphoneStream.getTracks().forEach((track) => track.stop()); 79 | this.microphoneStream = null; 80 | } 81 | 82 | if (this.microphoneGain) { 83 | this.microphoneGain.disconnect(); 84 | this.microphoneGain = null; 85 | } 86 | 87 | this.isMicrophoneActive = false; 88 | this.activeMicrophoneDeviceId = null; 89 | console.info(`${LOGGER_PREFIX} - Microphone stopped`); 90 | } 91 | 92 | setMicrophoneVolume(volume) { 93 | if (this.microphoneGain && volume >= 0 && volume <= 1) { 94 | this.microphoneGain.gain.setValueAtTime(volume, this.audioContext.currentTime); 95 | } 96 | } 97 | 98 | isMicrophoneEnabled() { 99 | return this.isMicrophoneActive; 100 | } 101 | 102 | async loadAudioFile(filePath) { 103 | try { 104 | if (isStringUndefinedNullEmpty(filePath)) throw new Error("Invalid file path"); 105 | 106 | const response = await fetch(filePath); 107 | let reader = response.body.getReader(); 108 | let chunks = []; 109 | while (true) { 110 | const { done, value } = await reader.read(); 111 | if (done) break; 112 | chunks.push(value); 113 | } 114 | let blob = new Blob(chunks); 115 | let arrayBuffer = await blob.arrayBuffer(); 116 | const audioBuffer = await this.audioContext.decodeAudioData(arrayBuffer); 117 | 118 | return audioBuffer; 119 | } catch (error) { 120 | console.error(`${LOGGER_PREFIX} - Error loading audio file:`, error); 121 | throw error; 122 | } 123 | } 124 | 125 | // Create audio feedback 126 | createAudioFeedback() { 127 | if (this.customFeedbackBuffer) { 128 | const audioFeedback = this.audioContext.createBufferSource(); 129 | audioFeedback.buffer = this.customFeedbackBuffer; 130 | audioFeedback.loop = true; 131 | 132 | // Add gain node to control volume 133 | const gainNode = this.audioContext.createGain(); 134 | gainNode.gain.value = 0.05; // Adjust volume here (0-1) 135 | 136 | audioFeedback.connect(gainNode); 137 | gainNode.connect(this.mediaStreamDestination); 138 | return audioFeedback; 139 | } 140 | 141 | const bufferSize = 2 * this.audioContext.sampleRate; 142 | const audioFeedbackBuffer = this.audioContext.createBuffer(1, bufferSize, this.audioContext.sampleRate); 143 | const output = audioFeedbackBuffer.getChannelData(0); 144 | 145 | for (let i = 0; i < bufferSize; i++) { 146 | output[i] = Math.random() * 2 - 1; 147 | } 148 | 149 | const audioFeedback = this.audioContext.createBufferSource(); 150 | audioFeedback.buffer = audioFeedbackBuffer; 151 | audioFeedback.loop = true; 152 | 153 | // Add gain node to control volume 154 | const gainNode = this.audioContext.createGain(); 155 | gainNode.gain.value = 0.005; // Adjust volume here (0-1) 156 | 157 | audioFeedback.connect(gainNode); 158 | gainNode.connect(this.mediaStreamDestination); 159 | 160 | console.info(`${LOGGER_PREFIX} - createAudioFeedback - white noise:`, audioFeedback); 161 | return audioFeedback; 162 | } 163 | 164 | startAudioFeedback() { 165 | //console.info(`${LOGGER_PREFIX} - startAudioFeedback`); 166 | if (!this.audioFeedbackNode) { 167 | this.audioFeedbackNode = this.createAudioFeedback(); 168 | this.audioFeedbackNode.start(); 169 | } 170 | } 171 | 172 | stopAudioFeedback() { 173 | if (this.audioFeedbackNode) { 174 | //console.info(`${LOGGER_PREFIX} - stopAudioFeedback`); 175 | this.audioFeedbackNode.stop(); 176 | this.audioFeedbackNode = null; 177 | } 178 | } 179 | 180 | async enableAudioFeedback(filePath = null) { 181 | if (filePath != null) { 182 | try { 183 | this.customFeedbackBuffer = await this.loadAudioFile(filePath); 184 | } catch (error) { 185 | console.error(`${LOGGER_PREFIX} - Failed to load custom audio feedback:`, error); 186 | this.customFeedbackBuffer = null; 187 | // Continue with default white noise 188 | } 189 | } else { 190 | this.customFeedbackBuffer = null; 191 | } 192 | 193 | console.info(`${LOGGER_PREFIX} - enableAudioFeedback`); 194 | this.shouldPlayAudioFeedback = true; 195 | if (!this.isPlaying) { 196 | this.startAudioFeedback(); 197 | } 198 | } 199 | 200 | disableAudioFeedback() { 201 | console.info(`${LOGGER_PREFIX} - disableAudioFeedback`); 202 | this.shouldPlayAudioFeedback = false; 203 | this.stopAudioFeedback(); 204 | } 205 | 206 | // Getter for the audio track 207 | getAudioTrack() { 208 | return this.audioTrack; 209 | } 210 | 211 | async playAudio(audioData, volume = 1.0) { 212 | return new Promise(async (resolve, reject) => { 213 | try { 214 | const audioDataArray = await audioData.transformToByteArray(); 215 | const audioBuffer = await this.audioContext.decodeAudioData(audioDataArray.buffer); 216 | 217 | // Add to queue 218 | this.audioQueue.push({ 219 | buffer: audioBuffer, 220 | volume: volume, 221 | resolve: resolve, 222 | }); 223 | 224 | // Start processing queue if not already playing 225 | if (!this.isPlaying) { 226 | this.processQueue(); 227 | } 228 | } catch (error) { 229 | reject(error); 230 | } 231 | }); 232 | } 233 | 234 | async playAudioBuffer(audioDataArray, volume = 1.0) { 235 | return new Promise(async (resolve, reject) => { 236 | try { 237 | const audioBuffer = await this.audioContext.decodeAudioData(audioDataArray.buffer); 238 | 239 | // Add to queue 240 | this.audioQueue.push({ 241 | buffer: audioBuffer, 242 | volume: volume, 243 | resolve: resolve, 244 | }); 245 | 246 | // Start processing queue if not already playing 247 | if (!this.isPlaying) { 248 | this.processQueue(); 249 | } 250 | } catch (error) { 251 | reject(error); 252 | } 253 | }); 254 | } 255 | 256 | async processQueue() { 257 | if (this.audioQueue.length === 0) { 258 | this.isPlaying = false; 259 | // Start audio feedback when queue is empty 260 | if (this.shouldPlayAudioFeedback) { 261 | this.startAudioFeedback(); 262 | } 263 | return; 264 | } 265 | 266 | // Stop audio feedback when there's something to play 267 | this.stopAudioFeedback(); 268 | 269 | this.isPlaying = true; 270 | const current = this.audioQueue.shift(); 271 | 272 | // Create and set up source 273 | const bufferSource = this.audioContext.createBufferSource(); 274 | bufferSource.buffer = current.buffer; 275 | 276 | // Create gain node for volume control 277 | const gainNode = this.audioContext.createGain(); 278 | gainNode.gain.value = current.volume; // Set the volume (0.0 to 1.0) 279 | 280 | bufferSource.connect(gainNode); 281 | gainNode.connect(this.mediaStreamDestination); 282 | 283 | //bufferSource.connect(this.mediaStreamDestination); 284 | 285 | // Handle completion 286 | bufferSource.onended = () => { 287 | current.resolve(); 288 | this.processQueue(); 289 | }; 290 | 291 | // Start playing 292 | bufferSource.start(); 293 | } 294 | 295 | async resume() { 296 | if (this.audioContext.state === "suspended") { 297 | await this.audioContext.resume(); 298 | } 299 | } 300 | 301 | async suspend() { 302 | if (this.audioContext.state === "running") { 303 | await this.audioContext.suspend(); 304 | } 305 | } 306 | 307 | clearQueue() { 308 | this.audioQueue = []; 309 | } 310 | 311 | getState() { 312 | return { 313 | contextState: this.audioContext.state, 314 | queueLength: this.audioQueue.length, 315 | isPlaying: this.isPlaying, 316 | currentTime: this.audioContext.currentTime, 317 | }; 318 | } 319 | 320 | //Clean up resources 321 | async dispose() { 322 | console.info(`${LOGGER_PREFIX} - dispose - AudioStreamManager disposed`); 323 | this.clearQueue(); 324 | this.stopAudioFeedback(); 325 | this.stopMicrophone(); 326 | if (this.audioTrack != null) { 327 | this.audioTrack.stop(); 328 | } 329 | } 330 | 331 | // Mute methods 332 | muteTrack() { 333 | if (this.audioTrack) { 334 | this.audioTrack.enabled = false; 335 | } 336 | } 337 | 338 | unmuteTrack() { 339 | if (this.audioTrack) { 340 | this.audioTrack.enabled = true; 341 | } 342 | } 343 | 344 | toggleTrackMute() { 345 | if (this.audioTrack) { 346 | this.audioTrack.enabled = !this.audioTrack.enabled; 347 | } 348 | } 349 | 350 | isTrackMuted() { 351 | return this.audioTrack ? !this.audioTrack.enabled : true; 352 | } 353 | 354 | muteAudioElement() { 355 | if (this.audioElement) { 356 | this.audioElement.muted = true; 357 | } 358 | } 359 | 360 | unmuteAudioElement() { 361 | if (this.audioElement) { 362 | this.audioElement.muted = false; 363 | } 364 | } 365 | 366 | toggleAudioElementMute() { 367 | if (this.audioElement) { 368 | this.audioElement.muted = !this.audioElement.muted; 369 | } 370 | } 371 | 372 | isAudioElementMuted() { 373 | return this.audioElement ? this.audioElement.muted : true; 374 | } 375 | } 376 | -------------------------------------------------------------------------------- /webapp/managers/InputTestManager.js: -------------------------------------------------------------------------------- 1 | export class AudioInputTestManager { 2 | constructor(audioContext) { 3 | this.audioContext = audioContext; 4 | this.audioInputStream = null; 5 | this.audioInputStreamSource = null; 6 | this.analyser = null; 7 | this.isTestRunning = false; 8 | this.animationFrameId = null; 9 | this.volumeBarElement = null; 10 | } 11 | 12 | async startAudioTest(audioInputStream, volumeBarElement) { 13 | this.audioInputStream = audioInputStream; 14 | this.volumeBarElement = volumeBarElement; 15 | 16 | this.analyser = this.audioContext.createAnalyser(); 17 | this.audioInputStreamSource = this.audioContext.createMediaStreamSource(this.audioInputStream); 18 | this.audioInputStreamSource.connect(this.analyser); 19 | 20 | this.analyser.fftSize = 256; 21 | const bufferLength = this.analyser.frequencyBinCount; 22 | const dataArray = new Uint8Array(bufferLength); 23 | 24 | const updateVolume = () => { 25 | if (!this.isTestRunning) return; 26 | 27 | this.analyser.getByteFrequencyData(dataArray); 28 | 29 | let sum = 0; 30 | for (let i = 0; i < bufferLength; i++) { 31 | sum += dataArray[i]; 32 | } 33 | const average = sum / bufferLength; 34 | 35 | const volumePercentage = Math.min((average / 255) * 100, 100); 36 | volumeBarElement.style.width = `${volumePercentage}%`; 37 | 38 | this.animationFrameId = requestAnimationFrame(updateVolume); 39 | }; 40 | 41 | this.isTestRunning = true; 42 | updateVolume(); 43 | } 44 | 45 | stopAudioTest() { 46 | if (!this.isTestRunning) return; 47 | 48 | // Cancel the animation frame 49 | if (this.animationFrameId) { 50 | cancelAnimationFrame(this.animationFrameId); 51 | this.animationFrameId = null; 52 | } 53 | 54 | // Stop all tracks in the stream 55 | if (this.audioInputStream) { 56 | this.audioInputStream.getTracks().forEach((track) => track.stop()); 57 | this.audioInputStream = null; 58 | } 59 | 60 | // Disconnect the audio source 61 | if (this.audioInputStreamSource) { 62 | this.audioInputStreamSource.disconnect(); 63 | this.audioInputStreamSource = null; 64 | } 65 | 66 | // Reset the analyser 67 | this.analyser = null; 68 | 69 | // Reset the volume bar 70 | if (this.volumeBarElement) { 71 | volumeBar.style.width = "0%"; 72 | } 73 | 74 | this.isTestRunning = false; 75 | } 76 | 77 | isRunning() { 78 | return this.isTestRunning; 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /webapp/managers/SessionTrackManager.js: -------------------------------------------------------------------------------- 1 | // Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | import { LOGGER_PREFIX } from "../constants"; 4 | import { isStringUndefinedNullEmpty } from "../utils/commonUtility"; 5 | 6 | // Enum for track types 7 | export const TrackType = { 8 | FILE: "FILE", 9 | MIC: "MIC", 10 | POLLY: "POLLY", 11 | SILENT: "SILENT", 12 | }; 13 | 14 | export class SessionTrackManager { 15 | constructor(peerConnection, audioContext) { 16 | this.peerConnection = peerConnection; 17 | this.audioContext = audioContext; 18 | this.currentTrackType = null; 19 | this.currentTrack = null; 20 | this.micStream = null; 21 | this.silentTrack = null; 22 | } 23 | 24 | // Create a silent audio track 25 | createSilentTrack() { 26 | const silentStream = this.audioContext.createMediaStreamDestination().stream; 27 | const silentTrack = silentStream.getAudioTracks()[0]; 28 | return silentTrack; 29 | } 30 | 31 | // Get microphone access and create track 32 | async createMicTrack(microphoneConstraints) { 33 | const micStream = await navigator.mediaDevices.getUserMedia(microphoneConstraints); 34 | 35 | const micStreamAudioTrack = micStream.getAudioTracks()[0]; 36 | return micStreamAudioTrack; 37 | } 38 | 39 | // Create track from file 40 | createFileTrack(inputFilePath) { 41 | if (isStringUndefinedNullEmpty(inputFilePath)) throw new Error("Invalid input file path"); 42 | 43 | const audio = new Audio(inputFilePath); 44 | audio.loop = false; 45 | audio.crossOrigin = "anonymous"; 46 | audio.play(); 47 | 48 | const mediaStreamDestination = this.audioContext.createMediaStreamDestination(); 49 | const mediaElementSource = this.audioContext.createMediaElementSource(audio); 50 | mediaElementSource.connect(mediaStreamDestination); 51 | const fileStream = mediaStreamDestination.stream; 52 | 53 | const fileStreamAudioTrack = fileStream.getAudioTracks()[0]; 54 | return fileStreamAudioTrack; 55 | } 56 | 57 | // Replace the current track with a new one 58 | async replaceTrack(newTrack, newTrackType) { 59 | // If same track type and we already have a track, do nothing 60 | if (this.currentTrackType === newTrackType && this.currentTrack) { 61 | // console.info( 62 | // `${LOGGER_PREFIX} - replaceTrack - Track of type ${newTrackType} already active` 63 | // ); 64 | return; 65 | } 66 | 67 | // Clean up existing track if necessary 68 | await this.cleanupCurrentTrack(); 69 | 70 | try { 71 | this.currentTrackType = newTrackType; 72 | this.currentTrack = newTrack; 73 | this.replaceAudioSenderTrack(newTrack); 74 | return; 75 | } catch (error) { 76 | console.error(`${LOGGER_PREFIX} - replaceTrack - Error replacing track:`, error); 77 | // Fallback to silent track on error 78 | this.currentTrackType = TrackType.SILENT; 79 | this.currentTrack = this.silentTrack; 80 | this.replaceAudioSenderTrack(this.silentTrack); 81 | return; 82 | } 83 | } 84 | 85 | // Clean up the current track 86 | async cleanupCurrentTrack() { 87 | if (this.currentTrack) { 88 | this.currentTrack.stop(); 89 | if (this.currentTrackType === TrackType.MIC && this.micStream) { 90 | this.micStream.getTracks().forEach((track) => track.stop()); 91 | this.micStream = null; 92 | } 93 | } 94 | } 95 | 96 | // Get current track info 97 | getCurrentTrackInfo() { 98 | return { 99 | type: this.currentTrackType, 100 | track: this.currentTrack, 101 | isActive: this.currentTrack ? this.currentTrack.enabled : false, 102 | }; 103 | } 104 | 105 | // Enable/disable the current track 106 | setTrackEnabled(enabled) { 107 | if (this.currentTrack) { 108 | this.currentTrack.enabled = enabled; 109 | } 110 | } 111 | 112 | // Clean up resources 113 | async dispose() { 114 | await this.cleanupCurrentTrack(); 115 | if (this.silentTrack) { 116 | this.silentTrack.stop(); 117 | } 118 | console.info(`${LOGGER_PREFIX} - dispose - SessionTrackManager disposed`); 119 | } 120 | 121 | //Replace Audio Sender Track in PeerConnection 122 | async replaceAudioSenderTrack(newTrack) { 123 | if (this.peerConnection == null) { 124 | console.error(`${LOGGER_PREFIX} - replaceAudioSenderTrack - peerConnection is null`); 125 | return; 126 | } 127 | const senders = this.peerConnection.getSenders(); 128 | if (senders == null) { 129 | console.error(`${LOGGER_PREFIX} - replaceAudioSenderTrack - senders is null`); 130 | return; 131 | } 132 | 133 | const audioSender = senders?.find((sender) => sender.track.kind === "audio"); 134 | 135 | if (audioSender == null) { 136 | console.info(`${LOGGER_PREFIX} - replaceAudioSenderTrack - adding a new track`); 137 | this.peerConnection.addTrack(newTrack); 138 | return; 139 | } 140 | 141 | console.info(`${LOGGER_PREFIX} - replaceAudioSenderTrack - replacing existing track`); 142 | await audioSender.replaceTrack(newTrack); 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /webapp/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "amazon-connect-v2v", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "vite build", 9 | "build:gitbash": "vite build", 10 | "preview": "vite preview" 11 | }, 12 | "devDependencies": { 13 | "vite": "^5.4.14", 14 | "vite-plugin-mkcert": "^1.17.6", 15 | "vite-plugin-node-polyfills": "^0.22.0", 16 | "vite-plugin-static-copy": "^2.2.0" 17 | }, 18 | "dependencies": { 19 | "@aws-sdk/client-polly": "^3.687.0", 20 | "@aws-sdk/client-transcribe-streaming": "^3.687.0", 21 | "@aws-sdk/client-translate": "^3.687.0", 22 | "@aws-sdk/credential-provider-node": "^3.699.0", 23 | "@aws-sdk/protocol-http": "^3.370.0", 24 | "amazon-connect-streams": "^2.18.0", 25 | "buffer": "^6.0.3", 26 | "microphone-stream": "^6.0.1" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /webapp/public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /webapp/style.css: -------------------------------------------------------------------------------- 1 | html, body { 2 | font-family: Arial, sans-serif; 3 | line-height: 1.4; 4 | margin: 0; 5 | padding: 0; 6 | color: #333; 7 | font-size: 12px; 8 | height: 100%; 9 | } 10 | 11 | #app { 12 | max-width: 100%; 13 | height: 100%; 14 | margin: 0 auto; 15 | padding: 10px; 16 | display: flex; 17 | flex-direction: column; 18 | } 19 | 20 | header { 21 | display: flex; 22 | justify-content: space-between; 23 | align-items: center; 24 | padding: 5px 0; 25 | } 26 | 27 | h1 { 28 | font-size: 1.4em; 29 | margin: 0; 30 | } 31 | 32 | h2 { 33 | color: #1c2a39; 34 | border-bottom: 2px solid #3498db; 35 | padding-bottom: 3px; 36 | margin-top: 10px; 37 | margin-bottom: 5px; 38 | font-size: 1.2em; 39 | } 40 | 41 | h3 { 42 | color: #000000; 43 | margin-top: 5px; 44 | margin-bottom: 10px; 45 | font-size: 1.1em; 46 | } 47 | 48 | main { 49 | display: flex; 50 | flex: 1; 51 | overflow: hidden; 52 | font-size: 1em; 53 | } 54 | 55 | #leftColumn { 56 | width: 350px; 57 | overflow-y: auto; 58 | padding-right: 10px; 59 | } 60 | 61 | #rightColumn { 62 | flex: 1; 63 | overflow-y: auto; 64 | padding-left: 10px; 65 | } 66 | 67 | #ccpContainer { 68 | width: 100%; 69 | max-width: 325px; 70 | height: 450px; 71 | border: 1px solid #ddd; 72 | border-radius: 4px; 73 | overflow: hidden; 74 | box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1); 75 | } 76 | 77 | .control-group { 78 | background-color: #f9f9f9; 79 | border: 1px solid #ddd; 80 | border-radius: 5px; 81 | padding: 5px; 82 | margin-bottom: 5px; 83 | } 84 | 85 | .control-group-transcribe { 86 | background-color: #f9f9f9; 87 | border: 1px solid #ddd; 88 | border-radius: 5px; 89 | padding: 5px; 90 | margin-bottom: 5px; 91 | min-height: 343px; 92 | display: flex; 93 | flex-direction: column; 94 | } 95 | 96 | .control-group-translate { 97 | background-color: #f9f9f9; 98 | border: 1px solid #ddd; 99 | border-radius: 5px; 100 | padding: 5px; 101 | margin-bottom: 5px; 102 | min-height: 212px; 103 | display: flex; 104 | flex-direction: column; 105 | } 106 | 107 | .control-group-polly { 108 | background-color: #f9f9f9; 109 | border: 1px solid #ddd; 110 | border-radius: 5px; 111 | padding: 5px; 112 | margin-bottom: 5px; 113 | min-height: 204px; 114 | display: flex; 115 | flex-direction: column; 116 | } 117 | 118 | .input-group, .button-group, .checkbox-group, .output-group { 119 | margin-bottom: 5px; 120 | font: 1em sans-serif; 121 | } 122 | 123 | .output-group { 124 | margin-top: 10px; 125 | margin-bottom: 5px; 126 | font: 1em sans-serif; 127 | margin-top: auto; 128 | } 129 | 130 | .checkbox-group { 131 | margin-top: 10px; 132 | margin-bottom: 5px; 133 | font: 1em sans-serif; 134 | display: flex; 135 | align-items: center; 136 | } 137 | 138 | .checkbox-group input[type="checkbox"] { 139 | margin-right: 5px; 140 | } 141 | 142 | .volume-group { 143 | display: flex; 144 | align-items: center; 145 | margin-bottom: 5px; 146 | font: 1em sans-serif; 147 | } 148 | 149 | label { 150 | display: block; 151 | margin-right: 5px; 152 | margin-bottom: 2px; 153 | font-weight: bold; 154 | font-size: 1em; 155 | } 156 | 157 | select { 158 | width: 62%; 159 | padding: 2px; 160 | margin-bottom: 3px; 161 | margin-right: 5px; 162 | border: 1px solid #ddd; 163 | border-radius: 4px; 164 | font-size: 1em; 165 | } 166 | 167 | input[type="text"], input[type="range"] { 168 | width: 60%; 169 | padding: 5px; 170 | margin-bottom: 3px; 171 | margin-right: 5px; 172 | border: 1px solid #ddd; 173 | border-radius: 4px; 174 | font-size: 1em; 175 | } 176 | 177 | button { 178 | padding: 3px 6px; 179 | margin-right: 3px; 180 | margin-bottom: 3px; 181 | background-color: #3498db; 182 | color: white; 183 | border: none; 184 | border-radius: 4px; 185 | cursor: pointer; 186 | transition: background-color 0.3s; 187 | font-size: 1em; 188 | } 189 | 190 | button:hover { 191 | background-color: #2980b9; 192 | } 193 | 194 | button:disabled { 195 | background-color: #bdc3c7; /* lighter gray color */ 196 | cursor: not-allowed; 197 | opacity: 0.7; 198 | /* Optional: remove hover effect when disabled */ 199 | pointer-events: none; 200 | } 201 | 202 | .volume-bar-container { 203 | width: 100%; 204 | background-color: #f0f0f0; 205 | height: 10px; 206 | margin-top: 3px; 207 | border-radius: 5px; 208 | overflow: hidden; 209 | } 210 | 211 | .volume-bar { 212 | width: 0%; 213 | height: 100%; 214 | background-color: #3498db; 215 | transition: width 0.1s ease; 216 | font-size: 0.9em; 217 | } 218 | 219 | .output-text { 220 | background-color: #fff; 221 | border: 1px solid #ddd; 222 | border-radius: 4px; 223 | padding: 5px; 224 | min-height: 20px; 225 | max-height: 80px; 226 | overflow-y: auto; 227 | font-size: 1em; 228 | } 229 | 230 | #transcribeTranslate { 231 | display: flex; 232 | flex-wrap: wrap; 233 | justify-content: space-between; 234 | } 235 | 236 | #transcribeTranslate > div { 237 | flex-basis: calc(33% - 10px); 238 | } 239 | 240 | #audioPlayback { 241 | background-color: #fff; 242 | padding: 5px; 243 | border-radius: 5px; 244 | margin-top: 5px; 245 | } 246 | 247 | .audio-group { 248 | background-color: #f9f9f9; 249 | border: 1px solid #ddd; 250 | margin-bottom: 5px; 251 | } 252 | 253 | .audio-group audio { 254 | width: 100%; 255 | height: 15px; 256 | font-size: 0.9em; 257 | } 258 | 259 | .hidden { 260 | display: none; 261 | } 262 | 263 | @media (max-width: 1200px) { 264 | main { 265 | flex-direction: column; 266 | } 267 | 268 | #leftColumn, #rightColumn { 269 | width: 100%; 270 | padding: 0; 271 | } 272 | 273 | #transcribeTranslate > div { 274 | flex-basis: 100%; 275 | } 276 | } 277 | 278 | .bg-pale-green { 279 | background-color: #e8f5e9; /* Pale green color */ 280 | } 281 | 282 | .bg-pale-yellow { 283 | background-color: #fffde7; /* Pale yellow color */ 284 | } 285 | 286 | .bg-none { 287 | background-color: transparent; 288 | } 289 | 290 | .control-group-transcription { 291 | height: 100%; 292 | background-color: #f9f9f9; 293 | border: 1px solid #ddd; 294 | border-radius: 5px; 295 | padding: 5px; 296 | margin-bottom: 5px; 297 | min-height: 782px; 298 | max-height: 782px; 299 | display: flex; 300 | flex-direction: column; 301 | padding: 10px; 302 | position: relative; 303 | } 304 | 305 | .transcript-container { 306 | position: absolute; 307 | top: 0; 308 | bottom: 0; 309 | left: 0; 310 | right: 0; 311 | display: flex; 312 | flex-direction: column; 313 | overflow-y: scroll; 314 | padding: 10px; 315 | gap: 10px; 316 | margin-top: auto; 317 | } 318 | 319 | .transcript-spacer { 320 | flex-grow: 1; 321 | } 322 | 323 | .transcript-card { 324 | max-width: 70%; 325 | padding: 10px; 326 | border-radius: 8px; 327 | margin: 4px 0; 328 | word-wrap: break-word; 329 | } 330 | 331 | .transcript-original { 332 | font-size: 1em; 333 | margin-bottom: 8px; 334 | } 335 | 336 | .transcript-separator { 337 | height: 1px; 338 | background-color: rgba(255, 255, 255, 0.2); 339 | margin: 8px 0; 340 | } 341 | 342 | .fromAgent { 343 | align-self: flex-end; 344 | background-color: #007bff; 345 | color: white; 346 | } 347 | 348 | .fromAgent .transcript-separator { 349 | background-color: rgba(255, 255, 255, 0.2); 350 | } 351 | 352 | .toAgent { 353 | align-self: flex-start; 354 | background-color: #e9ecef; 355 | color: black; 356 | } 357 | 358 | .toAgent .transcript-separator { 359 | background-color: rgba(0, 0, 0, 0.1); 360 | } 361 | 362 | .transcript-translated { 363 | font-size: 0.95em; 364 | font-style: italic; 365 | opacity: 0.9; 366 | } -------------------------------------------------------------------------------- /webapp/utils/authUtility.js: -------------------------------------------------------------------------------- 1 | // Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | import { COGNITO_CONFIG } from "../config"; 4 | import { LOGGER_PREFIX } from "../constants"; 5 | 6 | export function setRedirectURI(redirectURI) { 7 | const currentUrl = redirectURI ?? window.location.href; 8 | const url = new URL(currentUrl); 9 | const redirectUri = `${url.origin}${url.pathname.replace(/\/+$/, "")}`; 10 | //set redirect uri in local storage 11 | localStorage.setItem("redirectUri", redirectUri); 12 | } 13 | 14 | function getRedirectURI() { 15 | return localStorage.getItem("redirectUri"); 16 | } 17 | 18 | // Generate the Cognito hosted UI URL 19 | export function getLoginUrl() { 20 | const params = new URLSearchParams({ 21 | client_id: COGNITO_CONFIG.clientId, 22 | response_type: "code", 23 | scope: "email openid profile", 24 | redirect_uri: getRedirectURI(), 25 | }); 26 | 27 | return `${COGNITO_CONFIG.cognitoDomain}/login?${params.toString()}`; 28 | } 29 | 30 | // Handle the redirect from Cognito 31 | export async function handleRedirect() { 32 | const urlParams = new URLSearchParams(window.location.search); 33 | const code = urlParams.get("code"); 34 | 35 | if (code) { 36 | try { 37 | // Exchange the code for tokens 38 | const tokens = await getTokens(code); 39 | // Store tokens 40 | setTokens(tokens); 41 | // Remove code from URL 42 | window.history.replaceState({}, document.title, window.location.pathname); 43 | return true; 44 | } catch (error) { 45 | console.error(`${LOGGER_PREFIX} - handleRedirect - Error exchanging code for tokens:`, error); 46 | return false; 47 | } 48 | } 49 | return false; 50 | } 51 | 52 | // Exchange authorization code for tokens 53 | async function getTokens(code) { 54 | const params = new URLSearchParams({ 55 | grant_type: "authorization_code", 56 | client_id: COGNITO_CONFIG.clientId, 57 | code: code, 58 | redirect_uri: getRedirectURI(), 59 | }); 60 | 61 | const response = await fetch(`${COGNITO_CONFIG.cognitoDomain}/oauth2/token`, { 62 | method: "POST", 63 | headers: { 64 | "Content-Type": "application/x-www-form-urlencoded", 65 | }, 66 | body: params.toString(), 67 | }); 68 | 69 | if (!response.ok) { 70 | throw new Error("Failed to exchange code for tokens"); 71 | } 72 | const tokens = await response.json(); 73 | const idTokenPayload = decodeToken(tokens.id_token); 74 | const idTokenExpires = new Date(idTokenPayload.exp * 1000); 75 | const accessTokenPayload = decodeToken(tokens.access_token); 76 | const accessTokenExpires = new Date(accessTokenPayload.exp * 1000); 77 | console.info( 78 | `${LOGGER_PREFIX} - getTokens - Tokens obtained, id_token expires at ${idTokenExpires.toISOString()}, access_token expires at ${accessTokenExpires.toISOString()}` 79 | ); 80 | return tokens; 81 | } 82 | 83 | // Store tokens in localStorage 84 | function setTokens(tokens) { 85 | localStorage.setItem("accessToken", tokens.access_token); 86 | localStorage.setItem("idToken", tokens.id_token); 87 | if (tokens.refresh_token) { 88 | localStorage.setItem("refreshToken", tokens.refresh_token); 89 | } 90 | } 91 | 92 | function setAwsCredentials(awsCredentials) { 93 | localStorage.setItem("awsCredentials", JSON.stringify(awsCredentials)); 94 | } 95 | 96 | function getAwsCredentials() { 97 | const awsCredentials = localStorage.getItem("awsCredentials"); 98 | return JSON.parse(awsCredentials); 99 | } 100 | 101 | export function isTokenExpired(token) { 102 | if (token == null) return true; 103 | 104 | try { 105 | // Get payload from JWT token (second part between dots) 106 | const payload = JSON.parse(atob(token.split(".")[1])); 107 | 108 | // exp is in seconds, convert current time to seconds 109 | const currentTime = Math.floor(Date.now() / 1000); 110 | 111 | // Check if token has expired 112 | return payload.exp < currentTime; 113 | } catch (error) { 114 | console.error(`${LOGGER_PREFIX} - getAwsCredentials - Error checking token expiration:`, error); 115 | return true; 116 | } 117 | } 118 | 119 | export async function refreshTokens() { 120 | const refreshToken = localStorage.getItem("refreshToken"); 121 | try { 122 | if (refreshToken == null) { 123 | throw new Error("No refresh token available"); 124 | } 125 | 126 | const params = new URLSearchParams({ 127 | grant_type: "refresh_token", 128 | client_id: COGNITO_CONFIG.clientId, 129 | refresh_token: refreshToken, 130 | }); 131 | 132 | const response = await fetch(`${COGNITO_CONFIG.cognitoDomain}/oauth2/token`, { 133 | method: "POST", 134 | headers: { 135 | "Content-Type": "application/x-www-form-urlencoded", 136 | }, 137 | body: params.toString(), 138 | }); 139 | 140 | if (!response.ok) { 141 | throw new Error("Failed to refresh tokens"); 142 | } 143 | 144 | const tokens = await response.json(); 145 | const idTokenPayload = decodeToken(tokens.id_token); 146 | const idTokenExpires = new Date(idTokenPayload.exp * 1000); 147 | const accessTokenPayload = decodeToken(tokens.access_token); 148 | const accessTokenExpires = new Date(accessTokenPayload.exp * 1000); 149 | setTokens(tokens); 150 | console.info( 151 | `${LOGGER_PREFIX} - refreshTokens - Tokens refreshed, id_token expire at ${idTokenExpires.toISOString()}, access_token expire at ${accessTokenExpires.toISOString()}` 152 | ); 153 | return tokens; 154 | } catch (error) { 155 | console.error(`${LOGGER_PREFIX} - refreshTokens - Error refreshing tokens:`, error); 156 | // Clear stored tokens and redirect to login 157 | logout(); 158 | throw error; 159 | } 160 | } 161 | 162 | // Update isAuthenticated to check expiration 163 | export function isAuthenticated() { 164 | const idToken = localStorage.getItem("idToken"); 165 | const accessToken = localStorage.getItem("accessToken"); 166 | const refreshToken = localStorage.getItem("refreshToken"); 167 | if (idToken == null || accessToken == null || refreshToken == null) return false; 168 | if (isTokenExpired(idToken) || isTokenExpired(accessToken)) return false; 169 | return true; 170 | } 171 | 172 | // Get valid access token (refreshing if needed) 173 | export async function getValidTokens() { 174 | const idToken = localStorage.getItem("idToken"); 175 | const accessToken = localStorage.getItem("accessToken"); 176 | const refreshToken = localStorage.getItem("refreshToken"); 177 | 178 | if (refreshToken == null) { 179 | console.error(`${LOGGER_PREFIX} - getValidTokens - No refresh token available`); 180 | // Clear stored tokens and redirect to login 181 | logout(); 182 | return; 183 | } 184 | 185 | if (isTokenExpired(idToken) || isTokenExpired(accessToken)) { 186 | try { 187 | await refreshTokens(); 188 | } catch (error) { 189 | console.error(`${LOGGER_PREFIX} - getValidTokens - Error refreshing tokens:`, error); 190 | // Clear stored tokens and redirect to login 191 | logout(); 192 | return; 193 | } 194 | } 195 | return { 196 | accessToken: localStorage.getItem("accessToken"), 197 | idToken: localStorage.getItem("idToken"), 198 | refreshToken: localStorage.getItem("refreshToken"), 199 | }; 200 | } 201 | 202 | // Helper to decode token payload 203 | export function decodeToken(token) { 204 | try { 205 | return JSON.parse(atob(token.split(".")[1])); 206 | } catch (error) { 207 | console.error(`${LOGGER_PREFIX} - decodeToken - Error decoding token:`, error); 208 | return null; 209 | } 210 | } 211 | 212 | // Get user info from token 213 | export function getUserInfo() { 214 | const token = localStorage.getItem("idToken"); 215 | if (!token) return null; 216 | 217 | const payload = decodeToken(token); 218 | return { 219 | email: payload.email, 220 | username: payload.preferred_username, 221 | sub: payload.sub, 222 | }; 223 | } 224 | 225 | export function startTokenRefreshTimer() { 226 | const idToken = localStorage.getItem("idToken"); 227 | const accessToken = localStorage.getItem("accessToken"); 228 | 229 | if (idToken == null || accessToken == null) throw new Error("Unable to startTokenRefreshTimer - No tokens available"); 230 | 231 | const idTokenPayload = decodeToken(idToken); 232 | const accessTokenPayload = decodeToken(accessToken); 233 | if (idTokenPayload == null || accessTokenPayload == null) throw new Error("Unable to startTokenRefreshTimer - Error decoding tokens"); 234 | 235 | // Calculate time until token expires 236 | const idTokenExpiresIn = idTokenPayload.exp * 1000 - Date.now(); 237 | const accessTokenExpiresIn = accessTokenPayload.exp * 1000 - Date.now(); 238 | const firstTokenExpiresIn = Math.min(idTokenExpiresIn, accessTokenExpiresIn); 239 | 240 | // Refresh 4 minutes before expiration 241 | let refreshTime = firstTokenExpiresIn - 4 * 60 * 1000; 242 | if (refreshTime < 0) refreshTime = 0; 243 | 244 | console.info(`${LOGGER_PREFIX} - startTokenRefreshTimer - Token refresh timer set for ${Math.floor(refreshTime / 1000)}s`); 245 | setTimeout(async () => { 246 | try { 247 | await refreshTokens(); 248 | await getValidAwsCredentials(); 249 | // Start new timer after refresh 250 | startTokenRefreshTimer(); 251 | } catch (error) { 252 | console.error(`${LOGGER_PREFIX} - startTokenRefreshTimer - Error in refresh timer:`, error); 253 | } 254 | }, refreshTime); 255 | } 256 | 257 | export function logout() { 258 | const params = new URLSearchParams({ 259 | client_id: COGNITO_CONFIG.clientId, 260 | logout_uri: getRedirectURI(), 261 | }); 262 | 263 | // Clear local storage 264 | localStorage.removeItem("accessToken"); 265 | localStorage.removeItem("idToken"); 266 | localStorage.removeItem("refreshToken"); 267 | localStorage.removeItem("awsCredentials"); 268 | 269 | // Redirect to Cognito logout 270 | window.location.href = `${COGNITO_CONFIG.cognitoDomain}/logout?${params.toString()}`; 271 | } 272 | 273 | async function getCognitoIdentityCredentials(idToken) { 274 | // First, get the Cognito Identity ID 275 | const identityParams = { 276 | IdentityPoolId: COGNITO_CONFIG.identityPoolId, 277 | Logins: { 278 | [`cognito-idp.${COGNITO_CONFIG.region}.amazonaws.com/${COGNITO_CONFIG.userPoolId}`]: idToken, 279 | }, 280 | }; 281 | 282 | try { 283 | // Get Identity ID 284 | const cognitoIdentity = new AWS.CognitoIdentity({ 285 | region: COGNITO_CONFIG.region, 286 | }); 287 | const { IdentityId } = await cognitoIdentity.getId(identityParams).promise(); 288 | 289 | // Get credentials 290 | const cognitoCredentialsForIdentity = await cognitoIdentity 291 | .getCredentialsForIdentity({ 292 | IdentityId, 293 | Logins: { 294 | [`cognito-idp.${COGNITO_CONFIG.region}.amazonaws.com/${COGNITO_CONFIG.userPoolId}`]: idToken, 295 | }, 296 | }) 297 | .promise(); 298 | 299 | const credentials = { 300 | accessKeyId: cognitoCredentialsForIdentity.Credentials.AccessKeyId, 301 | secretAccessKey: cognitoCredentialsForIdentity.Credentials.SecretKey, 302 | sessionToken: cognitoCredentialsForIdentity.Credentials.SessionToken, 303 | expiration: cognitoCredentialsForIdentity.Credentials.Expiration, 304 | }; 305 | 306 | console.info(`${LOGGER_PREFIX} - getCognitoIdentityCredentials - Cognito Identity credentials obtained, expire at ${credentials.expiration.toISOString()}`); 307 | setAwsCredentials(credentials); 308 | return credentials; 309 | } catch (error) { 310 | console.error(`${LOGGER_PREFIX} - getCognitoIdentityCredentials - Error getting Cognito Identity credentials:`, error); 311 | throw error; 312 | } 313 | } 314 | 315 | // Get AWS credentials using Cognito Identity Pool 316 | export async function getValidAwsCredentials() { 317 | try { 318 | if (hasValidAwsCredentials()) { 319 | return getAwsCredentials(); 320 | } 321 | 322 | const tokens = await getValidTokens(); 323 | 324 | if (tokens?.accessToken == null || tokens?.idToken == null || tokens?.refreshToken == null) { 325 | throw new Error("No tokens available"); 326 | } 327 | 328 | // Configure the credentials provider 329 | const credentials = await getCognitoIdentityCredentials(tokens.idToken); 330 | return credentials; 331 | } catch (error) { 332 | console.error(`${LOGGER_PREFIX} - getValidAwsCredentials - Error getting AWS credentials:`, error); 333 | throw error; 334 | } 335 | } 336 | 337 | export function hasValidAwsCredentials() { 338 | const awsCredentials = getAwsCredentials(); 339 | if ( 340 | awsCredentials?.accessKeyId == null || 341 | awsCredentials?.secretAccessKey == null || 342 | awsCredentials?.sessionToken == null || 343 | awsCredentials?.expiration == null 344 | ) { 345 | return false; 346 | } 347 | 348 | // Add a 15-minute buffer before expiration 349 | const bufferTime = 15 * 60 * 1000; // 15 minutes in milliseconds 350 | const currentTime = new Date(); 351 | const expirationTime = new Date(awsCredentials.expiration); 352 | // console.info( 353 | // `${LOGGER_PREFIX} - hasValidAwsCredentials - AWS Credentials expiration: ${expirationTime.toISOString()}` 354 | // ); 355 | 356 | return currentTime.getTime() + bufferTime < expirationTime.getTime(); 357 | } 358 | -------------------------------------------------------------------------------- /webapp/utils/commonUtility.js: -------------------------------------------------------------------------------- 1 | // Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | import { CONNECT_CONFIG } from "../config"; 4 | import { DEPRECATED_CONNECT_DOMAIN } from "../constants"; 5 | 6 | export const isValidURL = (url) => { 7 | const regexp = 8 | /^(?:(?:https?|ftp):\/\/)?(?:(?!(?:10|127)(?:\.\d{1,3}){3})(?!(?:169\.254|192\.168)(?:\.\d{1,3}){2})(?!172\.(?:1[6-9]|2\d|3[0-1])(?:\.\d{1,3}){2})(?:[1-9]\d?|1\d\d|2[01]\d|22[0-3])(?:\.(?:1?\d{1,2}|2[0-4]\d|25[0-5])){2}(?:\.(?:[1-9]\d?|1\d\d|2[0-4]\d|25[0-4]))|(?:(?:[a-z\u00a1-\uffff0-9]-*)*[a-z\u00a1-\uffff0-9]+)(?:\.(?:[a-z\u00a1-\uffff0-9]-*)*[a-z\u00a1-\uffff0-9]+)*(?:\.(?:[a-z\u00a1-\uffff]{2,})))(?::\d{2,5})?(?:\/\S*)?$/; 9 | if (regexp.test(url)) return true; 10 | return false; 11 | }; 12 | 13 | export const getConnectInstanceURL = () => { 14 | let connectInstanceURL = CONNECT_CONFIG.connectInstanceURL?.replace(/\/$/, ""); 15 | if (!connectInstanceURL) { 16 | console.warn("connectInstanceURL not set!"); 17 | return null; 18 | } 19 | 20 | if (connectInstanceURL.endsWith(DEPRECATED_CONNECT_DOMAIN)) connectInstanceURL = `${connectInstanceURL}/connect`; 21 | return connectInstanceURL; 22 | }; 23 | 24 | const getConnectLoginURL = () => { 25 | const connectInstanceURL = getConnectInstanceURL(); 26 | if (!connectInstanceURL) return null; 27 | return `${connectInstanceURL}/login`; 28 | }; 29 | 30 | const getConnectLogoutURL = () => { 31 | const connectInstanceURL = getConnectInstanceURL(); 32 | if (!connectInstanceURL) return null; 33 | return `${connectInstanceURL}/logout`; 34 | }; 35 | 36 | const getConnectCCPURL = () => { 37 | const connectInstanceURL = getConnectInstanceURL(); 38 | if (!connectInstanceURL) return null; 39 | return `${connectInstanceURL}/ccp-v2`; 40 | }; 41 | 42 | export const getConnectURLS = () => { 43 | return { 44 | connectInstanceURL: getConnectInstanceURL(), 45 | connectLoginURL: getConnectLoginURL(), 46 | connectLogoutURL: getConnectLogoutURL(), 47 | connectCCPURL: getConnectCCPURL(), 48 | }; 49 | }; 50 | 51 | export const goToHome = () => { 52 | window.location.href = `${window.location.protocol}//${window.location.host}`; 53 | }; 54 | 55 | export function addUpdateQueryStringKey(key, value) { 56 | const url = new URL(window.location.href); 57 | url.searchParams.set(key, value); 58 | window.history.pushState({}, "", url.toString()); 59 | } 60 | 61 | export function getQueryStringValueByKey(key) { 62 | const url = new URL(window.location.href); 63 | return url.searchParams.get(key); 64 | } 65 | 66 | export function addUpdateLocalStorageKey(key, value) { 67 | window.localStorage.setItem(key, value); 68 | } 69 | 70 | export function getLocalStorageValueByKey(key) { 71 | return window.localStorage.getItem(key); 72 | } 73 | 74 | export function base64ToArrayBuffer(base64) { 75 | var binary_string = window.atob(base64); 76 | var len = binary_string.length; 77 | var bytes = new Uint8Array(len); 78 | for (var i = 0; i < len; i++) { 79 | bytes[i] = binary_string.charCodeAt(i); 80 | } 81 | return bytes; 82 | } 83 | 84 | /** 85 | * Check if object is empty. 86 | * @param {object} inputObject - inputObject. 87 | * @returns {boolean} - true if object is empty, false otherwise. 88 | */ 89 | export function isObjectEmpty(inputObject) { 90 | return inputObject != null && Object.keys(inputObject).length === 0 && Object.getPrototypeOf(inputObject) === Object.prototype; 91 | } 92 | 93 | /** 94 | * Check if object is undefined, null, empty. 95 | * @param {object} inputObject - inputObject. 96 | * @returns {boolean} - true if object is undefined, null, empty, false otherwise. 97 | */ 98 | export function isObjectUndefinedNullEmpty(inputObject) { 99 | if (inputObject == null) return true; 100 | if (typeof inputObject !== "object") return true; 101 | if (typeof inputObject === "object" && inputObject instanceof Array) return true; 102 | return isObjectEmpty(inputObject); 103 | } 104 | 105 | /** 106 | * Check if string is undefined, null, empty. 107 | * @param {string} inputString - inputString. 108 | * @returns {boolean} - true if string is undefined, null, empty, false otherwise. 109 | */ 110 | export function isStringUndefinedNullEmpty(inputString) { 111 | if (inputString == null) return true; 112 | if (typeof inputString !== "string") return true; 113 | if (inputString.trim().length === 0) return true; 114 | return false; 115 | } 116 | 117 | /** 118 | * Check if inputFunction is a function. 119 | * @param {function} inputFunction - inputFunction. 120 | * @returns {boolean} - true if inputFunction is a function, false otherwise. 121 | */ 122 | export function isFunction(inputFunction) { 123 | return inputFunction && {}.toString.call(inputFunction) === "[object Function]"; 124 | } 125 | 126 | export function isDevEnvironment() { 127 | if (import.meta.env.DEV) { 128 | console.info("Running in development mode (Vite dev server)"); 129 | return true; 130 | } 131 | return false; 132 | } 133 | -------------------------------------------------------------------------------- /webapp/utils/transcribeUtils.js: -------------------------------------------------------------------------------- 1 | // Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | import { Buffer } from "buffer"; 4 | import MicrophoneStream from "microphone-stream"; 5 | 6 | export function encodePCMChunk(chunk) { 7 | const input = MicrophoneStream.toRaw(chunk); 8 | let offset = 0; 9 | const buffer = new ArrayBuffer(input.length * 2); 10 | const view = new DataView(buffer); 11 | for (let i = 0; i < input.length; i++, offset += 2) { 12 | let s = Math.max(-1, Math.min(1, input[i])); 13 | view.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7fff, true); 14 | } 15 | return Buffer.from(buffer); 16 | } 17 | 18 | //Creates Agent Mic Stream, used as input for Amazon Transcribe when transcribing agent's voice 19 | export async function createMicrophoneStream(microphoneConstraints) { 20 | const micStream = new MicrophoneStream(); 21 | micStream.setStream(await navigator.mediaDevices.getUserMedia(microphoneConstraints)); 22 | return micStream; 23 | } 24 | 25 | export const getTranscribeMicStream = async function* (amazonTranscribeMicStream, sampleRate) { 26 | for await (const chunk of amazonTranscribeMicStream) { 27 | if (chunk.length <= sampleRate) { 28 | const encodedChunk = encodePCMChunk(chunk); 29 | yield { 30 | AudioEvent: { 31 | AudioChunk: encodedChunk, 32 | }, 33 | }; 34 | } 35 | } 36 | }; 37 | 38 | export const getTranscribeAudioStream = async function* (amazonTranscribeAudioStream, sampleRate) { 39 | for await (const chunk of amazonTranscribeAudioStream) { 40 | if (chunk.length <= sampleRate) { 41 | const encodedChunk = encodePCMChunk(chunk); 42 | yield { 43 | AudioEvent: { 44 | AudioChunk: encodedChunk, 45 | }, 46 | }; 47 | } 48 | } 49 | }; 50 | -------------------------------------------------------------------------------- /webapp/vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import mkcert from "vite-plugin-mkcert"; 3 | import { nodePolyfills } from "vite-plugin-node-polyfills"; 4 | import { viteStaticCopy } from "vite-plugin-static-copy"; 5 | 6 | export default defineConfig({ 7 | server: { 8 | https: true, 9 | fs: { 10 | cachedChecks: false, 11 | }, 12 | }, // Not needed for Vite 5+ 13 | plugins: [ 14 | mkcert(), 15 | nodePolyfills(), 16 | viteStaticCopy({ 17 | targets: [ 18 | { 19 | src: "lib/*", 20 | dest: "./lib", 21 | }, 22 | { 23 | src: "assets/*", 24 | dest: "./assets", 25 | }, 26 | ], 27 | }), 28 | ], 29 | define: { 30 | // By default, Vite doesn't include shims for NodeJS/ 31 | // necessary for segment analytics lib to work 32 | global: {}, 33 | }, 34 | }); 35 | --------------------------------------------------------------------------------