├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── images ├── banner.png ├── frown.png ├── neutral.png ├── smile.png ├── transcript_1_6.png ├── transcript_2_6.png ├── transcript_3_6.png ├── transcript_4_6.png ├── transcript_5_6.png └── transcript_6_6.png ├── python └── ts-to-word.py └── sample-data ├── example-call-redacted.wav ├── example-call.docx ├── example-call.json └── example-call.wav /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 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 7 | the Software, and to permit persons to whom the Software is furnished to do so. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 10 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 11 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 12 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 13 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 14 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 15 | 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![banner](./images/banner.png) 2 | 3 | ## Convert JSON To Word Document 4 | 5 | #### Overview 6 | 7 | This Python3 application will process the results of a synchronous Amazon Transcribe job and will turn it into a Microsoft Word document that contains a turn-by-turn transcript from each speaker. It can handle processing a local JSON output file, or it can dynamically query the Amazon Transcribe service to download the JSON. It works in one of two different modes: 8 | 9 | - **Call Analytics Mode** - using the Call Analytics feature of Amazon Transcribe, the Word document will present all of the analytical data in either a tabular or graphical form 10 | - **Standard Mode** - this will optionally call Amazon Comprehend to generate sentiment for each turn of the conversation. This mode can handle either speaker-separated or channel-separated audio files 11 | 12 | #### Features 13 | 14 | The following table summarise which features are available in each mode. 15 | 16 | | **Feature** | **Call Analytics Mode** | **Standard Mode** | 17 | | ----------------------------- | ----------------------- | ------------------------- | 18 | | Job information [1] | ☑️ | ☑️ | 19 | | Word-level confidence scores | ☑️ | ☑️ | 20 | | Call-level sentiment | ☑️ | | 21 | | Speaker sentiment trend [2] | ☑️ | ☑️ *via Amazon Comprehend* | 22 | | Turn-level sentiment | ☑️ | ☑️ *via Amazon Comprehend* | 23 | | Turn-level sentiment scores | | ☑️ *via Amazon Comprehend* | 24 | | Speaker volume | ☑️ | | 25 | | Speaker interruptions | ☑️ | | 26 | | Speaker talk time | ☑️ | | 27 | | Call non-talk ("silent") time | ☑️ | | 28 | | Category detection [3] | ☑️ | | 29 | | Call issue detection | ☑️ | | 30 | 31 | *[1] If the JSON is read from a local file, but the underlying Amazon Transcribe job no longer exists, then the majority of the job information is no longer available* 32 | 33 | *[2] Speaker sentiment trend in Call Analytics Mode only provides data points for each quarter of the call per speaker, whilst in Standard Mode it can provide points for each turn in the conversation* 34 | 35 | *[3] Categories must first be defined by the customer within Amazon Transcribe, and only those defined when the Amazon Transcribe job executed will be reported* 36 | 37 | #### Usage 38 | 39 | ##### Prerequisites 40 | 41 | This application relies upon three external python libraries, which you will need to install onto the system that you wise to deploy this application to. They are as follows: 42 | 43 | - python-docx 44 | - scipy 45 | - matplotlib 46 | 47 | These should all be installed using the relevant tool for your target platform - typically this would be via `pip`, the Python package manager, but could be via `yum`, `apt-get` or something else. Please consult your platform's Python documentation for more information. 48 | 49 | Additionally, as the Python code will call APIs in Amazon Transcribe and, optionally, Amazon Comprehend, the target platform will need to have access to AWS access keys or an IAM role that gives access to the following API calls: 50 | 51 | - Amazon Transcribe - *GetTranscriptionJob()* and *GetCallAnalyticsJob()* 52 | - Amazon Comprehend - *DetectSentiment()* 53 | 54 | ##### Parameters 55 | 56 | The Python3 application has the following usage rules: 57 | 58 | ``` 59 | usage: ts-to-word (--inputFile filename | --inputJob job-id) 60 | [--outputFile filename] [--sentiment {on,off}] 61 | [--confidence {on,off}] [--keep] 62 | ``` 63 | 64 | - **Mandatory** - *(only one of these can be specified)* 65 | - `--inputFile` - path to a JSON results file from an Amazon Transcribe job that is to be processed 66 | - `--inputJob` - the JobId of the Amazon Transcribe job that is to be processed 67 | - **Optional** 68 | - `--outputFile` - the name of the output Word document. Defaults to the name of the input file/job with a ".docx" extention 69 | - `--sentiment {on|off}` - if this is a Standard Mode job then this will enable or disable the generation of sentiment data via Amazon Comprehend. Defaults to off 70 | - `--confidence {on|off}` - displays a table and graph of confidence scores for all words in the transcript. Defaults to off 71 | - `--keep` - retains any downloaded JSON results file 72 | 73 | #### Sample Files 74 | 75 | The repository contains the following sample files in the `sample-data` folder: 76 | 77 | - **example-call.wav** - an example two-channel call audio file 78 | - **example-call.json** - the result from Amazon Transcribe when the example audio file is processed in Call Analytics mode 79 | - **example-call.docx** - the output document generated by this application against a completed Amazon Transcribe Call Analytics job using the example audio file. The next section describes this file structure in more detail 80 | - **example-call-redacted.wav** - the example call with all PII redacted, which can be output by Call Analytics if you enable PII and request that results are delivered to your own S3 bucket 81 | 82 | ## Microsoft Word Output 83 | 84 | ![tsHeader](./images/transcript_1_6.png) 85 | 86 | The header part of the transcript contains two sections – the call summary information, which is extracted from Transcribe’s APIs, and if you have used Call Analytics then this is followed by the caller sentiment trend and talk time split on the call. As you can see, many of the custom options for Transcribe, such as PII redaction, custom vocabulary and vocabulary filters, are called out, so you can always see what options were used. 87 | 88 | Note that if you processing a local JSON file, and the related Amazon Transcribe job is no longer available in your AWS acccount, then most of the details in the Call Summary will not be available, as they only exist in the status recorded associated with the job. 89 | 90 | ![tsGraph](./images/transcript_2_6.png) 91 | 92 | If you have used Call Analytics then this is followed by a graph combining many elements of the Call Analytics analysis. This graph combines speaker decibel levels, sentiment per speech segment, non-talk time and interruptions. If hen there is only one speaker on the call - unusual, but possible - then information is only shown for that speaker. 93 | 94 | ![tsIssuesCategories](./images/transcript_3_6.png) 95 | 96 | If you have used Call Analytics then this is followed some tables that show the following: 97 | 98 | - Any user-defined categories that have been detected in the call (including timestamps) 99 | - Any issues that have been detected in the call 100 | - Speaker sentiment scores in the range +/- 5.0 for each quarter of the call and for the whole call 101 | 102 | ![tsTranscript](./images/transcript_4_6.png) 103 | 104 | In the transcribe we show the obvious things – the speech segment text, the start time for that segment and its duration. We also show the sentiment indicator for that segment if it has been enabled or if it was a Call Analytics job. Naturally, as is common in call centre scenarios, line-by-line sentiment is often neutral, which is partly why we have now added the per-quarter and whole-call sentiment values to Call Analytics. If this is not a Call Analytics job then the sentiment indicator will also include the sentiment scrore, as provided by Amazon Comprehend. 105 | 106 | If this is a Call Analytics job then we also indicate where we have detected categories, issues and interruptions, highlighting the point of the call where it happened and also emphasing the text that triggered the issue detection. 107 | 108 | ![tsConfidence](./images/transcript_5_6.png) 109 | 110 | If you have enabled it then we show the word confidence scores for this audio file transcript. We show how the confidence scores are distributed across a number of buckets. The scatter plot and confidence mean is also shown, and in this example our average confidence score was 97.36%. 111 | 112 | If sentiment has been enabled on a non-Call Analytics jobt then we will display this call sentiment graph. This shows the sentiment per speaker where the speech segment was not neutral, which implies that there my be far fewer datapoints per speaker than they have speech segments attributed to them. 113 | 114 | ![tsSentiment](./images/transcript_6_6.png) 115 | 116 | ## Security 117 | 118 | See [CONTRIBUTING](CONTRIBUTING.md#security-issue-notifications) for more information. 119 | 120 | ## License 121 | 122 | This library is licensed under the MIT-0 License. See the LICENSE file. 123 | 124 | -------------------------------------------------------------------------------- /images/banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-transcribe-output-word-document/d91b6c58ffb2e8919f58cabd66019397c747dc35/images/banner.png -------------------------------------------------------------------------------- /images/frown.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-transcribe-output-word-document/d91b6c58ffb2e8919f58cabd66019397c747dc35/images/frown.png -------------------------------------------------------------------------------- /images/neutral.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-transcribe-output-word-document/d91b6c58ffb2e8919f58cabd66019397c747dc35/images/neutral.png -------------------------------------------------------------------------------- /images/smile.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-transcribe-output-word-document/d91b6c58ffb2e8919f58cabd66019397c747dc35/images/smile.png -------------------------------------------------------------------------------- /images/transcript_1_6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-transcribe-output-word-document/d91b6c58ffb2e8919f58cabd66019397c747dc35/images/transcript_1_6.png -------------------------------------------------------------------------------- /images/transcript_2_6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-transcribe-output-word-document/d91b6c58ffb2e8919f58cabd66019397c747dc35/images/transcript_2_6.png -------------------------------------------------------------------------------- /images/transcript_3_6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-transcribe-output-word-document/d91b6c58ffb2e8919f58cabd66019397c747dc35/images/transcript_3_6.png -------------------------------------------------------------------------------- /images/transcript_4_6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-transcribe-output-word-document/d91b6c58ffb2e8919f58cabd66019397c747dc35/images/transcript_4_6.png -------------------------------------------------------------------------------- /images/transcript_5_6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-transcribe-output-word-document/d91b6c58ffb2e8919f58cabd66019397c747dc35/images/transcript_5_6.png -------------------------------------------------------------------------------- /images/transcript_6_6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-transcribe-output-word-document/d91b6c58ffb2e8919f58cabd66019397c747dc35/images/transcript_6_6.png -------------------------------------------------------------------------------- /python/ts-to-word.py: -------------------------------------------------------------------------------- 1 | """ 2 | This sample, non-production-ready application will produce Word Document transcriptions using automatic speech 3 | recognition from Amazon Transcribe, and handles all processing modes of Amazon Transcribe in terms of diarization: 4 | speaker-separated audio, channel-separated audio, or Call Analytics audio. The application requires the following 5 | non-standard python libraries to be installed: 6 | 7 | - python-docx 8 | - scipy 9 | - matplotlib 10 | 11 | © 2021 Amazon Web Services, Inc. or its affiliates. All Rights Reserved. 12 | This AWS Content is provided subject to the terms of the AWS Customer Agreement available at 13 | http://aws.amazon.com/agreement or other written agreement between Customer and either 14 | Amazon Web Services, Inc. or Amazon Web Services EMEA SARL or both. 15 | """ 16 | 17 | from docx import Document 18 | from docx.shared import Cm, Mm, Pt, Inches, RGBColor 19 | from docx.enum.text import WD_ALIGN_PARAGRAPH, WD_COLOR_INDEX, WD_BREAK 20 | from docx.enum.style import WD_STYLE_TYPE 21 | from docx.enum.section import WD_SECTION 22 | from docx.oxml.shared import OxmlElement, qn 23 | from docx.oxml.ns import nsdecls 24 | from docx.oxml import parse_xml 25 | from pathlib import Path 26 | from time import perf_counter 27 | from scipy.interpolate import make_interp_spline 28 | import urllib.request 29 | import json 30 | import datetime 31 | import matplotlib.pyplot as plt 32 | import matplotlib.ticker as ticker 33 | import numpy as np 34 | import statistics 35 | import os 36 | import boto3 37 | import argparse 38 | from io import BytesIO 39 | 40 | 41 | # Common formats and styles 42 | CUSTOM_STYLE_HEADER = "CustomHeader" 43 | TABLE_STYLE_STANDARD = "Light List" 44 | CATEGORY_TRANSCRIPT_BG_COLOUR = "EEFFFF" 45 | CATEGORY_TRANSCRIPT_FG_COLOUR = RGBColor(0, 128, 255) 46 | ALTERNATE_ROW_COLOUR = "F0F0F0" 47 | BAR_CHART_WIDTH = 1.0 48 | 49 | # Column offsets in Transcribe output document table 50 | COL_STARTTIME = 0 51 | COL_ENDTIME = 1 52 | COL_SPEAKER = 2 53 | COL_SENTIMENT = 3 54 | COL_CONTENT = 4 55 | 56 | # Comprehend Sentiment helpers - note, if a language code in Comprehend has multiple suffixed versions 57 | # then the suffixed versions MUST be defined in the language list BEFORE the base one; e.h. "zh-TW" before "zh" 58 | MIN_SENTIMENT_LENGTH = 16 59 | MIN_SENTIMENT_NEGATIVE = 0.4 60 | MIN_SENTIMENT_POSITIVE = 0.6 61 | SENTIMENT_LANGUAGES = ["en", "es", "fr", "de", "it", "pt", "ar", "hi", "ja", "ko", "zh-TW", "zh"] 62 | 63 | # Image download URLS 64 | IMAGE_URL_BANNER = "https://raw.githubusercontent.com/aws-samples/amazon-transcribe-output-word-document/main/images/banner.png" 65 | IMAGE_URL_SMILE = "https://raw.githubusercontent.com/aws-samples/amazon-transcribe-output-word-document/main/images/smile.png" 66 | IMAGE_URL_FROWN = "https://raw.githubusercontent.com/aws-samples/amazon-transcribe-output-word-document/main/images/frown.png" 67 | IMAGE_URL_NEUTRAL = "https://raw.githubusercontent.com/aws-samples/amazon-transcribe-output-word-document/main/images/neutral.png" 68 | 69 | # Definitions to use whilst scanning summarisation data 70 | CALL_SUMMARY_MAP = [ 71 | {"Field": "segmentIssuesDetected", "Title": "Issues Detected", "Color": "FF3333"}, 72 | {"Field": "segmentActionItemsDetected", "Title": "Action Items Detected", "Color": "FFB266"}, 73 | {"Field": "segmentOutcomesDetected", "Title": "Outcomes Detected", "Color": "66CC00"} 74 | ] 75 | 76 | # Additional Constants 77 | START_NEW_SEGMENT_DELAY = 2.0 # After n seconds pause by one speaker, put next speech in new segment 78 | 79 | 80 | class SpeechSegment: 81 | """ Class to hold information about a single speech segment """ 82 | def __init__(self): 83 | self.segmentStartTime = 0.0 84 | self.segmentEndTime = 0.0 85 | self.segmentSpeaker = "" 86 | self.segmentText = "" 87 | self.segmentConfidence = [] 88 | self.segmentSentimentScore = -1.0 # -1.0 => no sentiment calculated 89 | self.segmentPositive = 0.0 90 | self.segmentNegative = 0.0 91 | self.segmentIsPositive = False 92 | self.segmentIsNegative = False 93 | self.segmentAllSentiments = [] 94 | self.segmentLoudnessScores = [] 95 | self.segmentInterruption = False 96 | self.segmentIssuesDetected = [] 97 | self.segmentActionItemsDetected = [] 98 | self.segmentOutcomesDetected = [] 99 | 100 | 101 | def convert_timestamp(time_in_seconds): 102 | """ 103 | Function to help convert timestamps from s to H:M:S:MM 104 | 105 | :param time_in_seconds: Time in seconds to be displayed 106 | :return: Formatted string for this timestamp value 107 | """ 108 | timeDelta = datetime.timedelta(seconds=float(time_in_seconds)) 109 | tsFront = timeDelta - datetime.timedelta(microseconds=timeDelta.microseconds) 110 | tsSmall = timeDelta.microseconds 111 | return str(tsFront) + "." + str(int(tsSmall / 10000)) 112 | 113 | 114 | def get_text_colour_analytics_sentiment(score): 115 | """ 116 | Returns RGB code text to represent the strength of negative or positive sentiment 117 | 118 | :param score: Sentiment score in range +/- 5.0 119 | :return: Background RGB colour text string to use in sentiment text 120 | """ 121 | # Get our score into the range [0..4], which is our shade 'strength' - higher => brighter shade 122 | truncated = min(abs(int(score)), 4) 123 | col_shade = (4 - truncated) * 51 124 | 125 | if score >= 0: 126 | # Positive sentiment => Green shade 127 | background_colour = "{0:0>2X}{1:0>2X}{2:0>2X}".format(col_shade, 255, col_shade) 128 | else: 129 | # Negative sentiment => Red shade 130 | background_colour = "{0:0>2X}{1:0>2X}{2:0>2X}".format(255, col_shade, col_shade) 131 | 132 | return background_colour 133 | 134 | 135 | def set_table_row_bold(row, bold): 136 | for cell in row.cells: 137 | for paragraph in cell.paragraphs: 138 | for run in paragraph.runs: 139 | run.font.bold = bold 140 | 141 | def set_transcript_text_style(run, force_highlight, confidence=0.0, rgb_color=None): 142 | """ 143 | Sets the colour and potentially the style of a given run of text in a transcript. You can either 144 | supply the hex-code, or base it upon the confidence score in the transcript. 145 | 146 | :param run: DOCX paragraph run to be modified 147 | :param force_highlight: Indicates that we're going to forcibly set the background colour 148 | :param confidence: Confidence score for this word, used to dynamically set the colour 149 | :param rgb_color: Specific colour for the text 150 | """ 151 | 152 | # If we have an RGB colour then use it 153 | if rgb_color is not None: 154 | run.font.color.rgb = rgb_color 155 | else: 156 | # Set the colour based upon the supplied confidence score 157 | if confidence >= 0.90: 158 | run.font.color.rgb = RGBColor(0, 0, 0) 159 | elif confidence >= 0.5: 160 | run.font.color.rgb = RGBColor(102, 51, 0) 161 | else: 162 | run.font.color.rgb = RGBColor(255, 0, 0) 163 | 164 | # Apply any other styles wanted 165 | if confidence == 0.0: 166 | # Call out any total disasters in bold 167 | run.font.bold = True 168 | 169 | # Force the background colour if required 170 | if force_highlight: 171 | run.font.highlight_color = WD_COLOR_INDEX.YELLOW 172 | 173 | 174 | def write_transcribe_text(output_table, sentiment_enabled, analytics_mode, speech_segments, keyed_categories): 175 | """ 176 | Writes out each line of the transcript in the Word table structure, optionally including sentiments 177 | 178 | :param output_table: Word document structure to write the table into 179 | :param sentiment_enabled: Flag to indicate we need to show some sentiment 180 | :param analytics_mode: Flag to indicate we're in Analytics mode, not Standard 181 | :param speech_segments: Turn-by-turn speech list 182 | :param keyed_categories: List of categories identified at any timestamps 183 | """ 184 | 185 | # Load our image files if we have sentiment enabled 186 | if sentiment_enabled: 187 | png_smile = load_image(IMAGE_URL_SMILE) 188 | png_frown = load_image(IMAGE_URL_FROWN) 189 | png_neutral = load_image(IMAGE_URL_NEUTRAL) 190 | content_col_offset = 0 191 | else: 192 | # Ensure we offset the CONTENT column correctly due to no sentiment 193 | content_col_offset = -1 194 | 195 | # Create a row populate it for each segment that we have 196 | shading_reqd = False 197 | for segment in speech_segments: 198 | # Before we start, does an angory start at this time? 199 | start_in_millis = segment.segmentStartTime * 1000.0 200 | end_in_millis = segment.segmentEndTime * 1000.0 201 | if start_in_millis in keyed_categories: 202 | insert_category_row(content_col_offset, keyed_categories, output_table, start_in_millis) 203 | keyed_categories.pop(start_in_millis) 204 | 205 | # Start with the easy stuff 206 | row_cells = output_table.add_row().cells 207 | row_cells[COL_STARTTIME].text = convert_timestamp(segment.segmentStartTime) 208 | row_cells[COL_ENDTIME].text = f"{(segment.segmentEndTime - segment.segmentStartTime):.1f}s" 209 | row_cells[COL_SPEAKER].text = segment.segmentSpeaker 210 | 211 | # Mark the start of the turn as INTERRUPTED if that's the case 212 | if segment.segmentInterruption: 213 | run = row_cells[COL_CONTENT + content_col_offset].paragraphs[0].add_run("[INTERRUPTION]") 214 | set_transcript_text_style(run, True, confidence=0.0) 215 | row_cells[COL_CONTENT + content_col_offset].paragraphs[0].add_run(" ") 216 | 217 | # Summarised data blocks are in order - pick out the first for each of our 218 | # types, as well as getting list of the remaining ones for this segment 219 | issues, next_issue = setup_summarised_data(segment.segmentIssuesDetected) 220 | actions, next_action = setup_summarised_data(segment.segmentActionItemsDetected) 221 | outcomes, next_outcome = setup_summarised_data(segment.segmentOutcomesDetected) 222 | 223 | # Then do each word with confidence-level colouring 224 | text_index = 1 225 | live_issue = False 226 | live_action = False 227 | live_outcome = False 228 | for eachWord in segment.segmentConfidence: 229 | # Look to start a new summary block if needed, in strict priority order - issues, actions, then outcomes. 230 | # We cannot start a new one until an existing one finishes, so if 2 overlap (unlikely) we skip the second 231 | live_issue = start_summary_run_highlight(content_col_offset, live_issue, live_action or live_outcome, 232 | next_issue, row_cells, text_index, "[ISSUE]") 233 | live_action = start_summary_run_highlight(content_col_offset, live_action, live_issue or live_outcome, 234 | next_action, row_cells, text_index, "[ACTION]") 235 | live_outcome = start_summary_run_highlight(content_col_offset, live_outcome, live_issue or live_action, 236 | next_outcome, row_cells, text_index, "[OUTCOME]") 237 | 238 | # Output the next word, with the correct confidence styling and forced background 239 | run = row_cells[COL_CONTENT + content_col_offset].paragraphs[0].add_run(eachWord["text"]) 240 | text_index += len(eachWord["text"]) 241 | confLevel = eachWord["confidence"] 242 | set_transcript_text_style(run, live_issue or live_outcome or live_action, confidence=confLevel) 243 | 244 | # Has any in-progress summarisation block now finished? Check each one 245 | live_issue, next_issue = stop_summary_run_highlight(issues, live_issue, next_issue, text_index) 246 | live_action, next_action = stop_summary_run_highlight(actions, live_action, next_action, text_index) 247 | live_outcome, next_outcome = stop_summary_run_highlight(outcomes, live_outcome, next_outcome, text_index) 248 | 249 | # If enabled, finish with the base sentiment for the segment - don't write out 250 | # score if it turns out that this segment ie neither Negative nor Positive 251 | if sentiment_enabled: 252 | if segment.segmentIsPositive or segment.segmentIsNegative: 253 | paragraph = row_cells[COL_SENTIMENT].paragraphs[0] 254 | img_run = paragraph.add_run() 255 | if segment.segmentIsPositive: 256 | img_run.add_picture(png_smile, width=Mm(4)) 257 | else: 258 | img_run.add_picture(png_frown, width=Mm(4)) 259 | 260 | # We only have turn-by-turn sentiment score values in non-analytics mode 261 | if not analytics_mode: 262 | text_run = paragraph.add_run(' (' + str(segment.segmentSentimentScore)[:4] + ')') 263 | text_run.font.size = Pt(7) 264 | text_run.font.italic = True 265 | else: 266 | row_cells[COL_SENTIMENT].paragraphs[0].add_run().add_picture(png_neutral, width=Mm(4)) 267 | 268 | # Add highlighting to the row if required 269 | if shading_reqd: 270 | for column in range(0, COL_CONTENT + content_col_offset + 1): 271 | set_table_cell_background_colour(row_cells[column], ALTERNATE_ROW_COLOUR) 272 | shading_reqd = not shading_reqd 273 | 274 | # Check if a category occurs in the middle of a segment - put it after the segment, as timestamp is "later" 275 | for category_start in keyed_categories.copy().keys(): 276 | if (start_in_millis < category_start) and (category_start < end_in_millis): 277 | insert_category_row(content_col_offset, keyed_categories, output_table, category_start) 278 | keyed_categories.pop(category_start) 279 | 280 | # Before we end, does an analytics category start with this line's end time? 281 | if end_in_millis in keyed_categories: 282 | # If so, write out the line after this 283 | insert_category_row(content_col_offset, keyed_categories, output_table, end_in_millis) 284 | keyed_categories.pop(end_in_millis) 285 | 286 | 287 | def stop_summary_run_highlight(summaries, live_summary, next_summary, text_index): 288 | """ 289 | Checks the supplied flags to see that particular type of call summary - e.g. issues or actions - has 290 | reached the end of it's final word. If so then it resets the flags and shifts the structures to 291 | the next summary item of that type in this segment (there most-likely aren't any more) 292 | 293 | :param summaries: List of remaining summary data items to be fully-processed 294 | :param live_summary: Flag to indicate is this type of call summary data is currently running 295 | :param next_summary: Start/end word offset information for the current/next summary data item 296 | :param text_index: Text offset position for this segment what we've rendered up to 297 | """ 298 | 299 | if live_summary and next_summary["End"] <= text_index: 300 | # Yes - stop highlighting, and pick up any pending summary left on this line of this type 301 | live_summary = False 302 | if len(summaries) > 0: 303 | next_summary = summaries.pop() 304 | else: 305 | next_summary = {} 306 | return live_summary, next_summary 307 | 308 | 309 | def start_summary_run_highlight(content_col_offset, this_summary, other_summaries, next_summ_item, row_cells, 310 | text_index, output_phrase): 311 | """ 312 | This looks at a call summary data block to see if it has started - if it has then we output a 313 | message with a highlight and set the text-run highlighting to continue. If a summary block of 314 | any other type is currently in-progress then we skip displaying this one, as in a Word document 315 | the highlighting would be confusing and hard to do. 316 | 317 | :param content_col_offset: Offset into the Word table so we can skip non-existent sentiment columns 318 | :param this_summary: Flag indicating if a highlighting run for this type is already in progress 319 | :param other_summaries: Flag indicating if a highlighting run for any other type is already in progress 320 | :param next_summ_item: The next summary item to be considered for highlighting 321 | :param row_cells: Cell reference in the Word table for the current speech segment 322 | :param text_index: Text offset position for this segment what we've rendered up to 323 | :param output_phrase: Phrase to use in the transcript to mark the start of this highighting run 324 | """ 325 | 326 | new_summary = this_summary 327 | 328 | if len(next_summ_item) > 0 and not this_summary and not other_summaries: 329 | if (next_summ_item["Begin"] == 0 and text_index == 1) or (next_summ_item["Begin"] == text_index): 330 | # If so, start the highlighting run, tagging on a leading/trailing 331 | # highlight space depending on where were are in the segment 332 | if text_index == 1: 333 | next_phrase = output_phrase + " " 334 | else: 335 | next_phrase = " " + output_phrase 336 | run = row_cells[COL_CONTENT + content_col_offset].paragraphs[0].add_run(next_phrase) 337 | set_transcript_text_style(run, True, confidence=0.0) 338 | new_summary = True 339 | 340 | return new_summary 341 | 342 | 343 | def setup_summarised_data(summary_block): 344 | """ 345 | Creates a copy of specified call-summary data block in preparation for writing out the transcription. This is 346 | used for each of the supported summary data types. Returns the first item in the block, or {} if there 347 | aren't any items, as well as the copy of the block minus the header item 348 | 349 | :param summary_block: The summarise block of data that we're interested in 350 | """ 351 | summary_data = summary_block.copy() 352 | if len(summary_data) > 0: 353 | next_data_item = summary_data.pop() 354 | else: 355 | next_data_item = {} 356 | return summary_data, next_data_item 357 | 358 | 359 | def insert_category_row(content_col_offset, keyed_categories, output_table, timestamp_millis): 360 | """ 361 | When writing out the transcript table this method will add in an additional row based 362 | upon the found entry in the time-keyed category list 363 | 364 | :param content_col_offset: Any additionl 365 | :param keyed_categories: List of categories identified at any timestamps 366 | :param output_table: Word document structure to write the table into 367 | :param timestamp_millis: Timestamp key whose data we have to write out (in milliseconds) 368 | """ 369 | 370 | # Create a new row with the timestamp leading cell, then merge the other cells together 371 | row_cells = output_table.add_row().cells 372 | row_cells[COL_STARTTIME].text = convert_timestamp(timestamp_millis / 1000.0) 373 | merged_cells = row_cells[COL_ENDTIME].merge(row_cells[COL_CONTENT + content_col_offset]) 374 | 375 | # Insert the text for each found category 376 | run = merged_cells.paragraphs[0].add_run("[CATEGORY]") 377 | set_transcript_text_style(run, False, rgb_color=CATEGORY_TRANSCRIPT_FG_COLOUR) 378 | run = merged_cells.paragraphs[0].add_run(" " + " ".join(keyed_categories[timestamp_millis])) 379 | set_transcript_text_style(run, False, confidence=0.5) 380 | 381 | # Give this row a special colour so that it stands out when scrolling 382 | set_table_cell_background_colour(row_cells[COL_STARTTIME], CATEGORY_TRANSCRIPT_BG_COLOUR) 383 | set_table_cell_background_colour(merged_cells, CATEGORY_TRANSCRIPT_BG_COLOUR) 384 | 385 | 386 | def merge_speaker_segments(input_segment_list): 387 | """ 388 | Merges together consecutive speaker segments unless: 389 | (a) There is a speaker change, or 390 | (b) The gap between segments is greater than our acceptable level of delay 391 | 392 | :param input_segment_list: Full time-sorted list of speaker segments 393 | :return: An updated segment list 394 | """ 395 | outputSegmentList = [] 396 | lastSpeaker = "" 397 | lastSegment = None 398 | 399 | # Step through each of our defined speaker segments 400 | for segment in input_segment_list: 401 | if (segment.segmentSpeaker != lastSpeaker) or \ 402 | ((segment.segmentStartTime - lastSegment.segmentEndTime) >= START_NEW_SEGMENT_DELAY): 403 | # Simple case - speaker change or > n-second gap means new output segment 404 | outputSegmentList.append(segment) 405 | 406 | # This is now our base segment moving forward 407 | lastSpeaker = segment.segmentSpeaker 408 | lastSegment = segment 409 | else: 410 | # Same speaker, short time, need to copy this info to the last one 411 | lastSegment.segmentEndTime = segment.segmentEndTime 412 | lastSegment.segmentText += " " + segment.segmentText 413 | segment.segmentConfidence[0]["text"] = " " + segment.segmentConfidence[0]["text"] 414 | for wordConfidence in segment.segmentConfidence: 415 | lastSegment.segmentConfidence.append(wordConfidence) 416 | 417 | return outputSegmentList 418 | 419 | 420 | def generate_sentiment(segment_list, language_code): 421 | """ 422 | Generates sentiment per speech segment, inserting the results into the input list. This will use 423 | Amazon Comprehend, but we need to map the job language code to one that Comprehend understands 424 | 425 | :param segment_list: List of speech segments 426 | :param language_code: Language code to use for the Comprehend job 427 | """ 428 | # Get our botot3 client, then go through each segment 429 | client = boto3.client("comprehend") 430 | for nextSegment in segment_list: 431 | if len(nextSegment.segmentText) >= MIN_SENTIMENT_LENGTH: 432 | nextText = nextSegment.segmentText 433 | response = client.detect_sentiment(Text=nextText, LanguageCode=language_code) 434 | positiveBase = response["SentimentScore"]["Positive"] 435 | negativeBase = response["SentimentScore"]["Negative"] 436 | 437 | # If we're over the NEGATIVE threshold then we're negative 438 | if negativeBase >= MIN_SENTIMENT_NEGATIVE: 439 | nextSegment.segmentIsNegative = True 440 | nextSegment.segmentSentimentScore = negativeBase 441 | # Else if we're over the POSITIVE threshold then we're positive, 442 | # otherwise we're either MIXED or NEUTRAL and we don't really care 443 | elif positiveBase >= MIN_SENTIMENT_POSITIVE: 444 | nextSegment.segmentIsPositive = True 445 | nextSegment.segmentSentimentScore = positiveBase 446 | 447 | # Store all of the original sentiments for future use 448 | nextSegment.segmentAllSentiments = response["SentimentScore"] 449 | nextSegment.segmentPositive = positiveBase 450 | nextSegment.segmentNegative = negativeBase 451 | 452 | 453 | def set_repeat_table_header(row): 454 | """ 455 | Set Word repeat table row on every new page 456 | """ 457 | row_pointer = row._tr.get_or_add_trPr() 458 | table_header = OxmlElement('w:tblHeader') 459 | table_header.set(qn('w:val'), "true") 460 | row_pointer.append(table_header) 461 | return row 462 | 463 | 464 | def load_image(url): 465 | """ 466 | Loads binary image data from a URL for later embedding into a docx document 467 | :param url: URL of image to be downloaded 468 | :return: BytesIO object that can be added as a docx image 469 | """ 470 | image_url = urllib.request.urlopen(url) 471 | io_url = BytesIO() 472 | io_url.write(image_url.read()) 473 | io_url.seek(0) 474 | return io_url 475 | 476 | 477 | def write_small_header_text(document, text, confidence): 478 | """ 479 | Helper function to write out small header entries, where the text colour matches the 480 | colour of the transcript text for a given confidence value 481 | 482 | :param document: Document to write the text to 483 | :param text: Text to be output 484 | :param confidence: Confidence score, which changes the text colour 485 | """ 486 | run = document.paragraphs[-1].add_run(text) 487 | set_transcript_text_style(run, False, confidence=confidence) 488 | run.font.size = Pt(7) 489 | run.font.italic = True 490 | 491 | 492 | def write(cli_arguments, speech_segments, job_status, summaries_detected): 493 | """ 494 | Write a transcript from the .json transcription file and other data generated 495 | by the results parser, putting it all into a human-readable Word document 496 | 497 | :param cli_arguments: CLI arguments used for this processing run 498 | :param speech_segments: List of call speech segments 499 | :param job_status: Status of the Transcribe job 500 | :param summaries_detected: Flag to indicate presence of call summary data 501 | """ 502 | 503 | json_filepath = Path(cli_arguments.inputFile) 504 | data = json.load(open(json_filepath.absolute(), "r", encoding="utf-8")) 505 | sentimentEnabled = (cli_arguments.sentiment == 'on') 506 | tempFiles = [] 507 | 508 | # Initiate Document, orientation and margins 509 | document = Document() 510 | document.sections[0].left_margin = Mm(19.1) 511 | document.sections[0].right_margin = Mm(19.1) 512 | document.sections[0].top_margin = Mm(19.1) 513 | document.sections[0].bottom_margin = Mm(19.1) 514 | document.sections[0].page_width = Mm(210) 515 | document.sections[0].page_height = Mm(297) 516 | 517 | # Set the base font and document title 518 | font = document.styles["Normal"].font 519 | font.name = "Calibri" 520 | font.size = Pt(10) 521 | 522 | # Create our custom text header style 523 | custom_style = document.styles.add_style(CUSTOM_STYLE_HEADER, WD_STYLE_TYPE.PARAGRAPH) 524 | custom_style.paragraph_format.widow_control = True 525 | custom_style.paragraph_format.keep_with_next = True 526 | custom_style.paragraph_format.space_after = Pt(0) 527 | custom_style.font.size = font.size 528 | custom_style.font.name = font.name 529 | custom_style.font.bold = True 530 | custom_style.font.italic = True 531 | 532 | # Intro banner header 533 | document.add_picture(load_image(IMAGE_URL_BANNER), width=Mm(171)) 534 | 535 | # Pull out header information - some from the JSON, but most only exists in the Transcribe job status 536 | if cli_arguments.analyticsMode: 537 | # We need 2 columns only if we're in analytics mode, as we put the charts on the right of the table 538 | document.add_section(WD_SECTION.CONTINUOUS) 539 | section_ptr = document.sections[-1]._sectPr 540 | cols = section_ptr.xpath('./w:cols')[0] 541 | cols.set(qn('w:num'), '2') 542 | 543 | # Write put the call summary table - depending on the mode that Transcribe was used in, and 544 | # if the request is being run on a JSON results file rather than reading the job info from Transcribe, 545 | # not all of the information is available. 546 | # -- Media information 547 | # -- Amazon Transcribe job information 548 | # -- Average transcript word-confidence scores 549 | write_custom_text_header(document, "Amazon Transcribe Audio Source") 550 | table = document.add_table(rows=1, cols=2) 551 | table.style = document.styles[TABLE_STYLE_STANDARD] 552 | table.alignment = WD_ALIGN_PARAGRAPH.LEFT 553 | hdr_cells = table.rows[0].cells 554 | hdr_cells[0].text = "Job Name" 555 | if cli_arguments.analyticsMode: 556 | hdr_cells[1].text = data["JobName"] 557 | else: 558 | hdr_cells[1].text = data["jobName"] 559 | job_data = [] 560 | # Audio duration is the end-time of the final voice segment, which might be shorter than the actual file duration 561 | if len(speech_segments) > 0: 562 | audio_duration = speech_segments[-1].segmentEndTime 563 | dur_text = str(int(audio_duration / 60)) + "m " + str(round(audio_duration % 60, 2)) + "s" 564 | job_data.append({"name": "Audio Duration", "value": dur_text}) 565 | # We can infer diarization mode from the JSON results data structure 566 | if cli_arguments.analyticsMode: 567 | job_data.append({"name": "Audio Ident", "value": "Call Analytics"}) 568 | elif "speaker_labels" in data["results"]: 569 | job_data.append({"name": "Audio Ident", "value": "Speaker-separated"}) 570 | else: 571 | job_data.append({"name": "Audio Ident", "value": "Channel-separated"}) 572 | 573 | # Some information is only in the job status 574 | if job_status is not None: 575 | job_data.append({"name": "Language", "value": job_status["LanguageCode"]}) 576 | job_data.append({"name": "File Format", "value": job_status["MediaFormat"]}) 577 | job_data.append({"name": "Sample Rate", "value": str(job_status["MediaSampleRateHertz"]) + " Hz"}) 578 | job_data.append({"name": "Job Created", "value": job_status["CreationTime"].strftime("%a %d %b '%y at %X")}) 579 | if "ContentRedaction" in job_status["Settings"]: 580 | redact_type = job_status["Settings"]["ContentRedaction"]["RedactionType"] 581 | redact_output = job_status["Settings"]["ContentRedaction"]["RedactionOutput"] 582 | job_data.append({"name": "Redaction Mode", "value": redact_type + " [" + redact_output + "]"}) 583 | if "VocabularyFilterName" in job_status["Settings"]: 584 | vocab_filter = job_status["Settings"]["VocabularyFilterName"] 585 | vocab_method = job_status["Settings"]["VocabularyFilterMethod"] 586 | job_data.append({"name": "Vocabulary Filter", "value": vocab_filter + " [" + vocab_method + "]"}) 587 | if "VocabularyName" in job_status["Settings"]: 588 | job_data.append({"name": "Custom Vocabulary", "value": job_status["Settings"]["VocabularyName"]}) 589 | 590 | # Finish with the confidence scores (if we have any) 591 | stats = generate_confidence_stats(speech_segments) 592 | if len(stats["accuracy"]) > 0: 593 | job_data.append({"name": "Avg. Confidence", "value": str(round(statistics.mean(stats["accuracy"]), 2)) + "%"}) 594 | 595 | # Place all of our job-summary fields into the Table, one row at a time 596 | for next_row in job_data: 597 | row_cells = table.add_row().cells 598 | row_cells[0].text = next_row["name"] 599 | row_cells[1].text = next_row["value"] 600 | 601 | # Formatting transcript table widths 602 | widths = (Cm(3.44), Cm(4.89)) 603 | for row in table.rows: 604 | for idx, width in enumerate(widths): 605 | row.cells[idx].width = width 606 | 607 | # Spacer paragraph 608 | document.add_paragraph() 609 | 610 | # Conversational Analytics (other column) if enabled 611 | # -- Caller sentiment graph 612 | # -- Talk time split 613 | if cli_arguments.analyticsMode: 614 | write_header_graphs(data, document, tempFiles) 615 | 616 | # At this point, if we have no transcript then we need to quickly exit 617 | if len(speech_segments) == 0: 618 | document.add_section(WD_SECTION.CONTINUOUS) 619 | section_ptr = document.sections[-1]._sectPr 620 | cols = section_ptr.xpath('./w:cols')[0] 621 | cols.set(qn('w:num'), '1') 622 | write_custom_text_header(document, "This call had no audible speech to transcribe.") 623 | else: 624 | # Conversational Analytics (new Section) 625 | # -- Show speaker loudness graph, with sentiment, interrupts and non-talk time highlighted 626 | # -- Show a summary of any call analytics categories detected 627 | # -- Show a summary of any issues detected in the transcript 628 | # -- Process and display speaker sentiment by period 629 | if cli_arguments.analyticsMode: 630 | build_call_loudness_charts(document, speech_segments, data["ConversationCharacteristics"]["Interruptions"], 631 | data["ConversationCharacteristics"]["NonTalkTime"], 632 | data["ConversationCharacteristics"]["TalkTime"], tempFiles) 633 | keyed_categories = write_detected_categories(document, data["Categories"]["MatchedDetails"]) 634 | write_analytics_sentiment(data, document) 635 | 636 | # Write out any call summarisation data 637 | if summaries_detected: 638 | write_detected_summaries(document, speech_segments) 639 | else: 640 | # No analytics => no categories 641 | keyed_categories = {} 642 | 643 | # Process and display transcript by speaker segments (new section) 644 | # -- Conversation "turn" start time and duration 645 | # -- Speaker identification 646 | # -- Sentiment type (if enabled) and sentiment score (if available) 647 | # -- Transcribed text with (if available) Call Analytics markers 648 | document.add_section(WD_SECTION.CONTINUOUS) 649 | section_ptr = document.sections[-1]._sectPr 650 | cols = section_ptr.xpath('./w:cols')[0] 651 | cols.set(qn('w:num'), '1') 652 | write_custom_text_header(document, "Call Transcription") 653 | document.add_paragraph() # Spacing 654 | write_small_header_text(document, "WORD CONFIDENCE: >= 90% in black, ", 0.9) 655 | write_small_header_text(document, ">= 50% in brown, ", 0.5) 656 | write_small_header_text(document, "< 50% in red", 0.49) 657 | table_cols = 4 658 | if sentimentEnabled or cli_arguments.analyticsMode: 659 | # Ensure that we add space for the sentiment column 660 | table_cols += 1 661 | content_col_offset = 0 662 | else: 663 | # Will need to shift the content column to the left, as Sentiment isn't there now 664 | content_col_offset = -1 665 | table = document.add_table(rows=1, cols=table_cols) 666 | table.style = document.styles[TABLE_STYLE_STANDARD] 667 | hdr_cells = table.rows[0].cells 668 | hdr_cells[COL_STARTTIME].text = "Start" 669 | hdr_cells[COL_ENDTIME].text = "Dur." 670 | hdr_cells[COL_SPEAKER].text = "Speaker" 671 | hdr_cells[COL_CONTENT + content_col_offset].text = "Transcription" 672 | 673 | # Based upon our segment list, write out the transcription table 674 | write_transcribe_text(table, sentimentEnabled or cli_arguments.analyticsMode, cli_arguments.analyticsMode, 675 | speech_segments, keyed_categories) 676 | document.add_paragraph() 677 | 678 | # Formatting transcript table widths - we need to add sentiment 679 | # column if needed, and it and the content width accordingly 680 | widths = [Inches(0.8), Inches(0.5), Inches(0.5), 0] 681 | if sentimentEnabled: 682 | # Comprehend sentiment needs space for the icon and % score 683 | widths.append(0) 684 | widths[COL_CONTENT + + content_col_offset] = Inches(7) 685 | widths[COL_SENTIMENT] = Inches(0.7) 686 | elif cli_arguments.analyticsMode: 687 | # Analytics sentiment just needs an icon 688 | widths.append(0) 689 | widths[COL_CONTENT + + content_col_offset] = Inches(7.4) 690 | widths[COL_SENTIMENT] = Inches(0.3) 691 | else: 692 | widths[COL_CONTENT + content_col_offset] = Inches(7.7) 693 | for row in table.rows: 694 | for idx, width in enumerate(widths): 695 | row.cells[idx].width = width 696 | 697 | # Setup the repeating header 698 | set_repeat_table_header(table.rows[0]) 699 | 700 | # Display confidence count table, if requested (new section) 701 | # -- Summary table of confidence scores into "bins" 702 | # -- Scatter plot of confidence scores over the whole transcript 703 | if cli_arguments.confidence == 'on': 704 | write_confidence_scores(document, stats, tempFiles) 705 | document.add_section(WD_SECTION.CONTINUOUS) 706 | 707 | # Generate our raw data for the Comprehend sentiment graph (if requested) 708 | if sentimentEnabled: 709 | write_comprehend_sentiment(document, speech_segments, tempFiles) 710 | 711 | # Save the whole document 712 | document.save(cli_arguments.outputFile) 713 | 714 | # Now delete any local images that we created 715 | for filename in tempFiles: 716 | os.remove(filename) 717 | 718 | 719 | def write_header_graphs(data, document, temp_files): 720 | """ 721 | Writes out the two header-level graphs for caller sentiment and talk-time split 722 | 723 | :param data: JSON result data from Transcribe 724 | :param document: Word document structure to write the table into 725 | :param temp_files: List of temporary files for later deletion 726 | """ 727 | characteristics = data["ConversationCharacteristics"] 728 | # Caller sentiment graph 729 | fig, ax = plt.subplots(nrows=1, ncols=2, figsize=(12.5 / 2.54, 8 / 2.54), gridspec_kw={'width_ratios': [4, 3]}) 730 | period_sentiment = characteristics["Sentiment"]["SentimentByPeriod"]["QUARTER"] 731 | # Graph configuration 732 | ax[0].set_xlim(xmin=1, xmax=4) 733 | ax[0].set_ylim(ymax=5, ymin=-5) 734 | ax[0].yaxis.set_major_locator(ticker.MultipleLocator(5.0)) 735 | ax[0].spines['bottom'].set_position('zero') 736 | ax[0].spines['top'].set_color('none') 737 | ax[0].spines['right'].set_color('none') 738 | ax[0].set_xticks([]) 739 | ax[0].set_title("Customer sentiment", fontsize=10, fontweight="bold", pad="12.0") 740 | # Only draw the sentiment line if we actually have a Customer that talked 741 | if "CUSTOMER" in period_sentiment: 742 | # Setup our data holders, then extract it all 743 | x_sentiment = np.array([]) 744 | y_sentiment = np.array([]) 745 | period_index = 1 746 | for score in period_sentiment["CUSTOMER"]: 747 | x_sentiment = np.append(x_sentiment, period_index) 748 | y_sentiment = np.append(y_sentiment, score["Score"]) 749 | period_index += 1 750 | 751 | # Set the line colour to match the overall sentiment 752 | if characteristics["Sentiment"]["OverallSentiment"]["CUSTOMER"] >= 0.0: 753 | line_colour = "darkgreen" 754 | else: 755 | line_colour = "red" 756 | 757 | # Now draw out the simple line plot 758 | x_new = np.linspace(1, 4, 200) 759 | spline = make_interp_spline(x_sentiment, y_sentiment) 760 | y_smooth = spline(x_new) 761 | ax[0].plot(x_new, y_smooth, linewidth=3, color=line_colour) 762 | # Talk time calculations and ratios 763 | non_talk = characteristics["NonTalkTime"]["Instances"] 764 | quiet_time = 0 765 | for quiet in non_talk: 766 | quiet_time += quiet["DurationMillis"] 767 | if "AGENT" in characteristics["TalkTime"]["DetailsByParticipant"]: 768 | agent_talk_time = characteristics["TalkTime"]["DetailsByParticipant"]["AGENT"]["TotalTimeMillis"] 769 | else: 770 | agent_talk_time = 0 771 | if "CUSTOMER" in characteristics["TalkTime"]["DetailsByParticipant"]: 772 | caller_talk_time = characteristics["TalkTime"]["DetailsByParticipant"]["CUSTOMER"]["TotalTimeMillis"] 773 | else: 774 | caller_talk_time = 0 775 | total_time = agent_talk_time + caller_talk_time + quiet_time 776 | if total_time > 0: 777 | quiet_ratio = quiet_time / total_time * 100.0 778 | agent_ratio = agent_talk_time / total_time * 100.0 779 | caller_ratio = caller_talk_time / total_time * 100.0 780 | else: 781 | quiet_ratio = 0.0 782 | agent_ratio = 0.0 783 | caller_ratio = 0.0 784 | ratio_format = "{speaker} ({ratio:.1f}%)" 785 | # Additional configuration 786 | ax[1].set_xticks([]) 787 | ax[1].set_yticks([]) 788 | ax[1].set_title("Talk time", fontsize=10, fontweight="bold", pad="10.0") 789 | ax[1].spines['top'].set_color('none') 790 | ax[1].spines['bottom'].set_color('none') 791 | ax[1].spines['left'].set_color('none') 792 | ax[1].spines['right'].set_color('none') 793 | # Now draw out the plot 794 | labels = ["time"] 795 | width = 1.0 796 | ax[1].bar(labels, [quiet_time], width, label=ratio_format.format(ratio=quiet_ratio, speaker="Non-Talk"), 797 | bottom=[agent_talk_time + caller_talk_time]) 798 | ax[1].bar(labels, [caller_talk_time], width, label=ratio_format.format(ratio=caller_ratio, speaker="Customer"), 799 | bottom=[agent_talk_time]) 800 | ax[1].bar(labels, [agent_talk_time], width, label=ratio_format.format(ratio=agent_ratio, speaker="Agent")) 801 | box = ax[1].get_position() 802 | ax[1].set_position([box.x0, box.y0 + box.height * 0.25, box.width, box.height * 0.75]) 803 | ax[1].legend(loc="upper center", bbox_to_anchor=(0.5, -0.05), ncol=1) 804 | chart_file_name = "./" + "talk-time.png" 805 | plt.savefig(chart_file_name, facecolor="aliceblue") 806 | temp_files.append(chart_file_name) 807 | document.add_picture(chart_file_name, width=Cm(7.5)) 808 | plt.clf() 809 | 810 | 811 | def generate_confidence_stats(speech_segments): 812 | """ 813 | Creates a map of timestamps and confidence scores to allow for both summarising and graphing in the document. 814 | We also need to bucket the stats for summarising into bucket ranges that feel important (but are easily changed) 815 | 816 | :param speech_segments: List of call speech segments 817 | :return: Confidence and timestamp structures for graphing 818 | """"" 819 | 820 | # Stats dictionary 821 | stats = { 822 | "timestamps": [], 823 | "accuracy": [], 824 | "9.8": 0, "9": 0, "8": 0, "7": 0, "6": 0, "5": 0, "4": 0, "3": 0, "2": 0, "1": 0, "0": 0, 825 | "parsedWords": 0} 826 | 827 | # Confidence count - we need the average confidence score regardless 828 | for line in speech_segments: 829 | for word in line.segmentConfidence: 830 | stats["timestamps"].append(word["start_time"]) 831 | conf_value = word["confidence"] 832 | stats["accuracy"].append(int(conf_value * 100)) 833 | if conf_value >= 0.98: 834 | stats["9.8"] += 1 835 | elif conf_value >= 0.9: 836 | stats["9"] += 1 837 | elif conf_value >= 0.8: 838 | stats["8"] += 1 839 | elif conf_value >= 0.7: 840 | stats["7"] += 1 841 | elif conf_value >= 0.6: 842 | stats["6"] += 1 843 | elif conf_value >= 0.5: 844 | stats["5"] += 1 845 | elif conf_value >= 0.4: 846 | stats["4"] += 1 847 | elif conf_value >= 0.3: 848 | stats["3"] += 1 849 | elif conf_value >= 0.2: 850 | stats["2"] += 1 851 | elif conf_value >= 0.1: 852 | stats["1"] += 1 853 | else: 854 | stats["0"] += 1 855 | stats["parsedWords"] += 1 856 | return stats 857 | 858 | 859 | def write_custom_text_header(document, text_label): 860 | """ 861 | Adds a run of text to the document with the given text label, but using our customer text-header style 862 | 863 | :param document: Word document structure to write the table into 864 | :param text_label: Header text to write out 865 | :return: 866 | """ 867 | paragraph = document.add_paragraph(text_label) 868 | paragraph.style = CUSTOM_STYLE_HEADER 869 | 870 | 871 | def write_confidence_scores(document, stats, temp_files): 872 | """ 873 | Using the pre-build confidence stats list, create a summary table of confidence score 874 | spreads, as well as a scatter-plot showing each word against the overall mean 875 | 876 | :param document: Word document structure to write the table into 877 | :param stats: Statistics for the confidence scores in the conversation 878 | :param temp_files: List of temporary files for later deletion 879 | :return: 880 | """ 881 | document.add_section(WD_SECTION.CONTINUOUS) 882 | section_ptr = document.sections[-1]._sectPr 883 | cols = section_ptr.xpath('./w:cols')[0] 884 | cols.set(qn('w:num'), '2') 885 | write_custom_text_header(document, "Word Confidence Scores") 886 | # Start with the fixed headers 887 | table = document.add_table(rows=1, cols=3) 888 | table.style = document.styles[TABLE_STYLE_STANDARD] 889 | table.alignment = WD_ALIGN_PARAGRAPH.LEFT 890 | hdr_cells = table.rows[0].cells 891 | hdr_cells[0].text = "Confidence" 892 | hdr_cells[1].text = "Count" 893 | hdr_cells[2].text = "Percentage" 894 | parsedWords = stats["parsedWords"] 895 | confidenceRanges = ["98% - 100%", "90% - 97%", "80% - 89%", "70% - 79%", "60% - 69%", "50% - 59%", "40% - 49%", 896 | "30% - 39%", "20% - 29%", "10% - 19%", "0% - 9%"] 897 | confidenceRangeStats = ["9.8", "9", "8", "7", "6", "5", "4", "3", "2", "1", "0"] 898 | # Add on each row 899 | shading_reqd = False 900 | for confRange, rangeStats in zip(confidenceRanges, confidenceRangeStats): 901 | row_cells = table.add_row().cells 902 | row_cells[0].text = confRange 903 | row_cells[1].text = str(stats[rangeStats]) 904 | row_cells[2].text = str(round(stats[rangeStats] / parsedWords * 100, 2)) + "%" 905 | 906 | # Add highlighting to the row if required 907 | if shading_reqd: 908 | for column in range(0, 3): 909 | set_table_cell_background_colour(row_cells[column], ALTERNATE_ROW_COLOUR) 910 | shading_reqd = not shading_reqd 911 | 912 | # Formatting transcript table widths, then move to the next column 913 | widths = (Inches(1.2), Inches(0.8), Inches(0.8)) 914 | for row in table.rows: 915 | for idx, width in enumerate(widths): 916 | row.cells[idx].width = width 917 | # Confidence of each word as scatter graph, and the mean as a line across 918 | fig, ax = plt.subplots(nrows=1, ncols=1, figsize=(6, 4)) 919 | ax.scatter(stats["timestamps"], stats["accuracy"]) 920 | ax.plot([stats["timestamps"][0], stats["timestamps"][-1]], [statistics.mean(stats["accuracy"]), 921 | statistics.mean(stats["accuracy"])], "r") 922 | # Formatting 923 | ax.set_xlabel("Time (seconds)") 924 | ax.set_ylabel("Word Confidence (percent)") 925 | ax.set_yticks(range(0, 101, 10)) 926 | fig.suptitle("Word Confidence During Transcription", fontsize=11, fontweight="bold") 927 | ax.legend(["Word Confidence Mean", "Individual words"], loc="lower center") 928 | # Write out the chart 929 | chart_file_name = "./" + "chart.png" 930 | plt.savefig(chart_file_name, facecolor="aliceblue") 931 | temp_files.append(chart_file_name) 932 | plt.clf() 933 | document.add_picture(chart_file_name, width=Cm(8)) 934 | document.paragraphs[-1].alignment = WD_ALIGN_PARAGRAPH.LEFT 935 | document.add_paragraph() 936 | 937 | 938 | def insert_line_and_col_break(document): 939 | """ 940 | Inserts a line break and column break into the document 941 | 942 | :param document: Word document structure to write the breaks into 943 | """ 944 | # Blank line followed by column break 945 | document.add_paragraph() # Spacing 946 | run = document.paragraphs[-1].add_run() 947 | run.add_break(WD_BREAK.LINE) 948 | run.add_break(WD_BREAK.COLUMN) 949 | 950 | 951 | def write_detected_categories(document, category_list): 952 | """ 953 | If there are any detected categories then write out a simple list 954 | 955 | :param document: Word document structure to write the table into 956 | :param category_list: Details of detected categories 957 | :return: A timestamp-keyed list of detected categories, which we'll use later when writing out the transcript 958 | """ 959 | timed_categories = {} 960 | if category_list != {}: 961 | # Start with a new single-column section 962 | document.add_section(WD_SECTION.CONTINUOUS) 963 | section_ptr = document.sections[-1]._sectPr 964 | cols = section_ptr.xpath('./w:cols')[0] 965 | cols.set(qn('w:num'), '1') 966 | write_custom_text_header(document, "Categories Detected") 967 | 968 | # Table header information 969 | table = document.add_table(rows=1, cols=3) 970 | table.style = document.styles[TABLE_STYLE_STANDARD] 971 | hdr_cells = table.rows[0].cells 972 | hdr_cells[0].text = "Category" 973 | hdr_cells[1].text = "#" 974 | hdr_cells[1].paragraphs[0].alignment = WD_ALIGN_PARAGRAPH.CENTER 975 | hdr_cells[2].text = "Timestamps found at" 976 | 977 | # Go through each detected category 978 | for next_cat in category_list.keys(): 979 | row_cells = table.add_row().cells 980 | row_cells[0].text = next_cat 981 | 982 | # Instances and timestamps for the category do not exist for "negative" categories 983 | if category_list[next_cat]["PointsOfInterest"] != []: 984 | row_cells[1].text = str(len(category_list[next_cat]["PointsOfInterest"])) 985 | row_cells[1].paragraphs[0].alignment = WD_ALIGN_PARAGRAPH.CENTER 986 | 987 | # Now go through each instance of it 988 | instance = 0 989 | for next_timestamp in category_list[next_cat]["PointsOfInterest"]: 990 | # Add the next timestamp to the document row, with separating punctuation if needed 991 | start_time_millis = next_timestamp["BeginOffsetMillis"] 992 | start_time_text = convert_timestamp(start_time_millis / 1000.0) 993 | if instance > 0: 994 | row_cells[2].paragraphs[0].add_run(", ") 995 | row_cells[2].paragraphs[0].add_run(start_time_text) 996 | instance += 1 997 | 998 | # Now add this to our time-keyed category list 999 | if start_time_millis not in timed_categories: 1000 | timed_categories[start_time_millis] = [next_cat] 1001 | else: 1002 | timed_categories[start_time_millis].append(next_cat) 1003 | 1004 | # Formatting transcript table widths 1005 | widths = (Cm(4.0), Cm(1.0), Cm(12.2)) 1006 | shading_reqd = False 1007 | for row in table.rows: 1008 | for idx, width in enumerate(widths): 1009 | row.cells[idx].width = width 1010 | if shading_reqd: 1011 | set_table_cell_background_colour(row.cells[idx], ALTERNATE_ROW_COLOUR) 1012 | shading_reqd = not shading_reqd 1013 | 1014 | # Finish with some spacing 1015 | document.add_paragraph() 1016 | 1017 | # Return our time-keyed category list 1018 | return timed_categories 1019 | 1020 | 1021 | def write_detected_summaries(document, speech_segments): 1022 | """ 1023 | Scans the speech segments for any detected summaries of the requested type, and if there are any 1024 | then a new table is added to the document. This assumes that we do have some summaries, as if 1025 | not we'll just output a table header on its own 1026 | 1027 | :param document: Word document structure to write the table into 1028 | :param speech_segments: Call transcript structures 1029 | """ 1030 | 1031 | # Start with a new single-column section 1032 | document.add_section(WD_SECTION.CONTINUOUS) 1033 | section_ptr = document.sections[-1]._sectPr 1034 | cols = section_ptr.xpath('./w:cols')[0] 1035 | cols.set(qn('w:num'), '1') 1036 | table = document.add_table(rows=1, cols=3) 1037 | table.style = document.styles[TABLE_STYLE_STANDARD] 1038 | hdr_cells = table.rows[0].cells 1039 | hdr_cells[0].text = "Call Summary Highlights" 1040 | hdr_cells[0].merge(hdr_cells[2]) 1041 | 1042 | # Loop through each of our summary types 1043 | for summary_map in CALL_SUMMARY_MAP: 1044 | # Scan through the segments and extract the issues 1045 | summary_detected = [] 1046 | for turn in speech_segments: 1047 | summary_block = getattr(turn, summary_map["Field"]) 1048 | # for issue in turn.myVar: 1049 | for issue in summary_block: 1050 | new_summary = {"Speaker": turn.segmentSpeaker} 1051 | new_summary["Timestamp"] = turn.segmentStartTime 1052 | new_summary["Text"] = turn.segmentText[issue["Begin"]:issue["End"]] 1053 | # May need a prefix or suffix for partial text 1054 | if issue["Begin"] > 0: 1055 | new_summary["Text"] = "..." + new_summary["Text"] 1056 | if issue["End"] < len(turn.segmentText): 1057 | new_summary["Text"] = new_summary["Text"] + "..." 1058 | summary_detected.append(new_summary) 1059 | 1060 | # If we found some of this type then write out a table 1061 | if summary_detected: 1062 | # Header section for this block 1063 | row_cells = table.add_row().cells 1064 | row_cells[0].text = summary_map["Title"] 1065 | set_table_cell_background_colour(row_cells[0], summary_map["Color"]) 1066 | row_cells[0].merge(row_cells[2]) 1067 | 1068 | # Column header section for this block 1069 | next_row = table.add_row() 1070 | row_cells = next_row.cells 1071 | row_cells[0].text = "Speaker" 1072 | row_cells[1].text = "Turn Time" 1073 | row_cells[2].text = "Detected Text" 1074 | set_table_row_bold(next_row, True) 1075 | shading_reqd = False 1076 | 1077 | # Output each row 1078 | for issue in summary_detected: 1079 | # First column is the speaker 1080 | next_row = table.add_row() 1081 | row_cells = next_row.cells 1082 | row_cells[0].text = issue["Speaker"] 1083 | row_cells[1].text = convert_timestamp(issue["Timestamp"]) 1084 | row_cells[2].text = issue["Text"] 1085 | set_table_row_bold(next_row, False) 1086 | 1087 | # Add highlighting to the row if required; e.g. every 2nd row 1088 | if shading_reqd: 1089 | for column in range(0, 3): 1090 | set_table_cell_background_colour(row_cells[column], ALTERNATE_ROW_COLOUR) 1091 | shading_reqd = not shading_reqd 1092 | 1093 | # Formatting transcript table widths 1094 | widths = (Cm(2.2), Cm(2.2), Cm(12.8)) 1095 | for row in table.rows: 1096 | for idx, width in enumerate(widths): 1097 | row.cells[idx].width = width 1098 | 1099 | # Finish with some spacing 1100 | document.add_paragraph() 1101 | 1102 | 1103 | def build_call_loudness_charts(document, speech_segments, interruptions, quiet_time, talk_time, temp_files): 1104 | """ 1105 | Creates the call loudness charts for each caller, which we also overlay sentiment on 1106 | :param document: Word document structure to write the graphics into 1107 | :param speech_segments: Call transcript structures 1108 | :param interruptions: Call speaker interruption structures 1109 | :param quiet_time: Call non-talk time structures 1110 | :param talk_time: Call talk time structures 1111 | :param temp_files: List of temporary files for later deletion (includes our graph) 1112 | """ 1113 | 1114 | # Start with a new single-column section 1115 | document.add_section(WD_SECTION.CONTINUOUS) 1116 | section_ptr = document.sections[-1]._sectPr 1117 | cols = section_ptr.xpath('./w:cols')[0] 1118 | cols.set(qn('w:num'), '1') 1119 | document.add_paragraph() 1120 | write_custom_text_header(document, "Conversation Volume Levels with Sentiment and Interruptions") 1121 | 1122 | # Initialise our loudness structures 1123 | secsLoudAgent = [] 1124 | dbLoudAgent = [] 1125 | secsLoudCaller = [] 1126 | dbLoudCaller = [] 1127 | 1128 | # Work through each conversation turn, extracting timestamp/decibel values as we go 1129 | for segment in speech_segments: 1130 | this_second = int(segment.segmentStartTime) 1131 | # Each segment has a loudness score per second or part second 1132 | for score in segment.segmentLoudnessScores: 1133 | # This can be set to NONE, which causes errors later 1134 | if score is None: 1135 | score = 0.0 1136 | # Track the Agent loudness 1137 | if segment.segmentSpeaker == "Agent": 1138 | secsLoudAgent.append(this_second) 1139 | dbLoudAgent.append(score) 1140 | # Track the Caller loudness 1141 | else: 1142 | secsLoudCaller.append(this_second) 1143 | dbLoudCaller.append(score) 1144 | this_second += 1 1145 | agentLoudness = {"Seconds": secsLoudAgent, "dB": dbLoudAgent} 1146 | callerLoudness = {"Seconds": secsLoudCaller, "dB": dbLoudCaller} 1147 | 1148 | # Work out our final talk "second", as we need both charts to line up, but 1149 | # be careful as there may just be one speaker in the Call Analytics output 1150 | if talk_time["DetailsByParticipant"]["AGENT"]["TotalTimeMillis"] == 0: 1151 | final_second = max(secsLoudCaller) 1152 | max_decibel = max(dbLoudCaller) 1153 | haveAgent = False 1154 | haveCaller = True 1155 | plotRows = 1 1156 | elif talk_time["DetailsByParticipant"]["CUSTOMER"]["TotalTimeMillis"] == 0: 1157 | final_second = max(secsLoudAgent) 1158 | max_decibel = max(dbLoudAgent) 1159 | haveAgent = True 1160 | haveCaller = False 1161 | plotRows = 1 1162 | else: 1163 | final_second = max(max(secsLoudAgent), max(secsLoudCaller)) 1164 | max_decibel = max(max(dbLoudAgent), max(dbLoudCaller)) 1165 | haveAgent = True 1166 | haveCaller = True 1167 | plotRows = 2 1168 | 1169 | # Add some headroom to our decibel limit to give space for "interruption" markers 1170 | max_decibel_headroom = (int(max_decibel / 10) + 2) * 10 1171 | 1172 | # Create a dataset for interruptions, which needs to be in the background on both charts 1173 | intSecs = [] 1174 | intDb = [] 1175 | for speaker in interruptions["InterruptionsByInterrupter"]: 1176 | for entry in interruptions["InterruptionsByInterrupter"][speaker]: 1177 | start = int(entry["BeginOffsetMillis"] / 1000) 1178 | end = int(entry["EndOffsetMillis"] / 1000) 1179 | for second in range(start, end+1): 1180 | intSecs.append(second) 1181 | intDb.append(max_decibel_headroom) 1182 | intSegments = {"Seconds": intSecs, "dB": intDb} 1183 | 1184 | # Create a dataset for non-talk time, which needs to be in the background on both charts 1185 | quietSecs = [] 1186 | quietdB = [] 1187 | for quiet_period in quiet_time["Instances"]: 1188 | start = int(quiet_period["BeginOffsetMillis"] / 1000) 1189 | end = int(quiet_period["EndOffsetMillis"] / 1000) 1190 | for second in range(start, end + 1): 1191 | quietSecs.append(second) 1192 | quietdB.append(max_decibel_headroom) 1193 | quietSegments = {"Seconds": quietSecs, "dB": quietdB} 1194 | 1195 | # Either speaker may be missing, so we cannot assume this is a 2-row or 1-row plot 1196 | # We want a 2-row figure, one row per speaker, but with the interruptions on the background 1197 | fig, ax = plt.subplots(nrows=plotRows, ncols=1, figsize=(12, 2.5 * plotRows)) 1198 | if haveAgent: 1199 | if haveCaller: 1200 | build_single_loudness_chart(ax[0], agentLoudness, intSegments, quietSegments, speech_segments, 1201 | final_second, max_decibel_headroom, "Agent", False, True) 1202 | build_single_loudness_chart(ax[1], callerLoudness, intSegments, quietSegments, speech_segments, 1203 | final_second, max_decibel_headroom, "Customer", True, False) 1204 | else: 1205 | build_single_loudness_chart(ax, agentLoudness, intSegments, quietSegments, speech_segments, 1206 | final_second, max_decibel_headroom, "Agent", True, True) 1207 | elif haveCaller: 1208 | build_single_loudness_chart(ax, callerLoudness, intSegments, quietSegments, speech_segments, 1209 | final_second, max_decibel_headroom, "Customer", True, True) 1210 | 1211 | # Add the chart to our document 1212 | chart_file_name = "./" + "volume.png" 1213 | fig.savefig(chart_file_name, facecolor="aliceblue") 1214 | temp_files.append(chart_file_name) 1215 | document.add_picture(chart_file_name, width=Cm(17)) 1216 | document.paragraphs[-1].alignment = WD_ALIGN_PARAGRAPH.LEFT 1217 | plt.clf() 1218 | 1219 | 1220 | def build_single_loudness_chart(axes, loudness, interrupts, quiet_time, speech_segments, xaxis_max, yaxis_max, caller, 1221 | show_x_legend, show_chart_legend): 1222 | """ 1223 | Builds a single loundness/sentiment chart using the given data 1224 | 1225 | :param axes: Axis to use for the chart in our larger table 1226 | :param loudness: Data series for the speakers loudness levels 1227 | :param interrupts: Data series for marking interrupts on the chart 1228 | :param quiet_time: Data series for marking non-talk time on the chart 1229 | :param speech_segments: Call transcript structures 1230 | :param xaxis_max: Second for the last speech entry in the call, which may not have been this speaker 1231 | :param yaxis_max: Max decibel level in the call, which may not have been this speaker 1232 | :param caller: Name of the caller to check for in the transcript 1233 | :param show_x_legend: Flag to show/hide the x-axis legend 1234 | :param show_chart_legend: Flag to show/hide the top-right graph legend 1235 | """ 1236 | 1237 | # Draw the main loudness data bar-chart 1238 | seconds = loudness["Seconds"] 1239 | decibels = loudness["dB"] 1240 | axes.bar(seconds, decibels, label="Speaker volume", width=BAR_CHART_WIDTH) 1241 | axes.set_xlim(xmin=0, xmax=xaxis_max) 1242 | axes.set_ylim(ymax=yaxis_max) 1243 | if show_x_legend: 1244 | axes.set_xlabel("Time (in seconds)") 1245 | axes.set_ylabel("decibels") 1246 | 1247 | # Build up sentiment data series for positive and negative, plotting it at the bottom 1248 | x = np.linspace(0, max(seconds), endpoint=True, num=(max(seconds) + 1)) 1249 | ypos = np.linspace(0, 0, endpoint=True, num=(max(seconds) + 1)) 1250 | yneg = np.linspace(0, 0, endpoint=True, num=(max(seconds) + 1)) 1251 | yneut = np.linspace(0, 0, endpoint=True, num=(max(seconds) + 1)) 1252 | for segment in speech_segments: 1253 | this_second = int(segment.segmentStartTime) 1254 | if segment.segmentSpeaker == caller: 1255 | if segment.segmentIsPositive: 1256 | for score in segment.segmentLoudnessScores: 1257 | ypos[this_second] = 10 1258 | this_second += 1 1259 | elif segment.segmentNegative: 1260 | for score in segment.segmentLoudnessScores: 1261 | yneg[this_second] = 10 1262 | this_second += 1 1263 | else: 1264 | for score in segment.segmentLoudnessScores: 1265 | yneut[this_second] = 10 1266 | this_second += 1 1267 | axes.bar(x, ypos, label="Positive sentiment", color="limegreen", width=BAR_CHART_WIDTH) 1268 | axes.bar(x, yneg, label="Negative sentiment", color="orangered", width=BAR_CHART_WIDTH) 1269 | axes.bar(x, yneut, label="Neutral sentiment", color="cadetblue", width=BAR_CHART_WIDTH) 1270 | 1271 | # Finish with the non-talk and interrupt overlays (if there are any) 1272 | if len(quiet_time["Seconds"]) > 0: 1273 | axes.bar(quiet_time["Seconds"], quiet_time["dB"], label="Non-talk time", color="lightcyan", width=BAR_CHART_WIDTH) 1274 | if len(interrupts["Seconds"]) > 0: 1275 | axes.bar(interrupts["Seconds"], interrupts["dB"], label="Interruptions", color="goldenrod", width=BAR_CHART_WIDTH, alpha=0.5, bottom=10) 1276 | 1277 | # Only show the legend for the top graph if requested 1278 | box = axes.get_position() 1279 | axes.set_position([0.055, box.y0, box.width, box.height]) 1280 | axes.text(5, yaxis_max-5, caller, style='normal', color='black', bbox={'facecolor': 'white', 'pad': 5}) 1281 | if show_chart_legend: 1282 | axes.legend(loc="upper right", bbox_to_anchor=(1.21, 1.0), ncol=1, borderaxespad=0) 1283 | 1284 | 1285 | def write_comprehend_sentiment(document, speech_segments, temp_files): 1286 | """ 1287 | Writes out tables for per-period, per-speaker sentiment from the analytics mode, as well as 1288 | the overall sentiment for a speaker 1289 | 1290 | :param document: Docx document to add the sentiment graph to 1291 | :param speech_segments: Process transcript text holding turn-by-turn sentiment 1292 | :param temp_files: List of temp files to be deleted later 1293 | :return: 1294 | """ 1295 | # Initialise our base structures 1296 | speaker0labels = ['ch_0', 'spk_0'] 1297 | speaker1labels = ['ch_1', 'spk_1'] 1298 | speaker0timestamps = [] 1299 | speaker0data = [] 1300 | speaker1timestamps = [] 1301 | speaker1data = [] 1302 | 1303 | # Start with some spacing and a new sub-header 1304 | document.add_paragraph() 1305 | write_custom_text_header(document, "Amazon Comprehend Sentiment") 1306 | # Now step through and process each speech segment's sentiment 1307 | for segment in speech_segments: 1308 | if segment.segmentIsPositive or segment.segmentIsNegative: 1309 | # Only interested in actual sentiment entries 1310 | score = segment.segmentSentimentScore 1311 | timestamp = segment.segmentStartTime 1312 | 1313 | # Positive re-calculation 1314 | if segment.segmentIsPositive: 1315 | score = 2 * ((1 - (1 - score) / (1 - MIN_SENTIMENT_POSITIVE)) * 0.5) 1316 | # Negative re-calculation 1317 | else: 1318 | score = 2 * ((1 - score) / (1 - MIN_SENTIMENT_NEGATIVE) * 0.5 - 0.5) 1319 | 1320 | if segment.segmentSpeaker in speaker1labels: 1321 | speaker1data.append(score) 1322 | speaker1timestamps.append(timestamp) 1323 | elif segment.segmentSpeaker in speaker0labels: 1324 | speaker0data.append(score) 1325 | speaker0timestamps.append(timestamp) 1326 | 1327 | # Spline fit needs at least 4 points for k=3, but 5 works better 1328 | speaker1k = 3 1329 | speaker0k = 3 1330 | if len(speaker1data) < 5: 1331 | speaker1k = 1 1332 | if len(speaker0data) < 5: 1333 | speaker0k = 1 1334 | 1335 | # Create Speaker-0 graph 1336 | plt.figure(figsize=(8, 5)) 1337 | speaker0xnew = np.linspace(speaker0timestamps[0], speaker0timestamps[-1], 1338 | int((speaker0timestamps[-1] - speaker0timestamps[0]) + 1.0)) 1339 | speaker0spl = make_interp_spline(speaker0timestamps, speaker0data, k=speaker0k) 1340 | speaker0powerSmooth = speaker0spl(speaker0xnew) 1341 | plt.plot(speaker0timestamps, speaker0data, "ro") 1342 | plt.plot(speaker0xnew, speaker0powerSmooth, "r", label="Speaker 1") 1343 | 1344 | # Create Speaker-1 graph 1345 | speaker1xnew = np.linspace(speaker1timestamps[0], speaker1timestamps[-1], 1346 | int((speaker1timestamps[-1] - speaker1timestamps[0]) + 1.0)) 1347 | speaker1spl = make_interp_spline(speaker1timestamps, speaker1data, k=speaker1k) 1348 | speaker1powerSmooth = speaker1spl(speaker1xnew) 1349 | plt.plot(speaker1timestamps, speaker1data, "bo") 1350 | plt.plot(speaker1xnew, speaker1powerSmooth, "b", label="Speaker 2") 1351 | 1352 | # Draw it out 1353 | plt.title("Call Sentiment - Pos/Neg Only") 1354 | plt.xlabel("Time (seconds)") 1355 | plt.axis([0, max(speaker0timestamps[-1], speaker1timestamps[-1]), -1.5, 1.5]) 1356 | plt.legend() 1357 | plt.axhline(y=0, color='k') 1358 | plt.axvline(x=0, color='k') 1359 | plt.grid(True) 1360 | plt.xticks(np.arange(0, max(speaker0timestamps[-1], speaker1timestamps[-1]), 60)) 1361 | plt.yticks(np.arange(-1, 1.01, 0.25)) 1362 | 1363 | # Write out the chart 1364 | chart_file_name = "./" + "sentiment.png" 1365 | plt.savefig(chart_file_name) 1366 | temp_files.append(chart_file_name) 1367 | plt.clf() 1368 | document.add_picture(chart_file_name, width=Cm(14.64)) 1369 | document.paragraphs[-1].alignment = WD_ALIGN_PARAGRAPH.LEFT 1370 | 1371 | 1372 | def set_table_cell_background_colour(cell, rgb_hex): 1373 | """ 1374 | Modifies the background color of the given table cell to the given RGB hex value. This currently isn't 1375 | supporting by the DOCX module, and the only option is to modify the underlying Word document XML 1376 | 1377 | :param cell: Table cell to be changed 1378 | :param rgb_hex: RBG hex string for the background color 1379 | """ 1380 | parsed_xml = parse_xml(r''.format(nsdecls('w'), rgb_hex)) 1381 | cell._tc.get_or_add_tcPr().append(parsed_xml) 1382 | 1383 | 1384 | def write_analytics_sentiment(data, document): 1385 | """ 1386 | Writes out tables for per-period, per-speaker sentiment from the analytics mode, as well as 1387 | the overall sentiment for a speaker 1388 | 1389 | :param data: Transcribe results data 1390 | :param document: Docx document to add the tables to 1391 | """ 1392 | 1393 | # Start with a new 2-column section 1394 | document.add_section(WD_SECTION.CONTINUOUS) 1395 | section_ptr = document.sections[-1]._sectPr 1396 | cols = section_ptr.xpath('./w:cols')[0] 1397 | cols.set(qn('w:num'), '2') 1398 | 1399 | # Table 1 - Period sentiment per speaker 1400 | write_custom_text_header(document, "Call Sentiment per Quarter of the call") 1401 | table = document.add_table(rows=1, cols=5) 1402 | table.style = document.styles[TABLE_STYLE_STANDARD] 1403 | hdr_cells = table.rows[0].cells 1404 | hdr_cells[0].text = "Speaker" 1405 | hdr_cells[1].text = "Q1" 1406 | hdr_cells[2].text = "Q2" 1407 | hdr_cells[3].text = "Q3" 1408 | hdr_cells[4].text = "Q4" 1409 | for col in range(1, 5): 1410 | hdr_cells[col].paragraphs[0].alignment = WD_ALIGN_PARAGRAPH.CENTER 1411 | 1412 | # Work through our sentiment period data 1413 | period_sentiment = data["ConversationCharacteristics"]["Sentiment"]["SentimentByPeriod"]["QUARTER"] 1414 | for caller in period_sentiment: 1415 | # First column is the speaker 1416 | row_cells = table.add_row().cells 1417 | row_cells[0].text = caller.title() 1418 | col_offset = 1 1419 | # Further columns on that row hold the value for one period on the call 1420 | for period in period_sentiment[caller]: 1421 | row_cells[col_offset].text = str(period["Score"]) 1422 | row_cells[col_offset].paragraphs[0].alignment = WD_ALIGN_PARAGRAPH.CENTER 1423 | cell_colour = get_text_colour_analytics_sentiment(period["Score"]) 1424 | set_table_cell_background_colour(row_cells[col_offset], cell_colour) 1425 | col_offset += 1 1426 | 1427 | # Put in a short table footer, then move to the next column 1428 | document.add_paragraph() # Spacing 1429 | write_small_header_text(document, "SENTIMENT: Range from +5 (Positive) to -5 (Negative)", 0.9) 1430 | 1431 | # Table 2 - Overall speaker sentiment 1432 | write_custom_text_header(document, "Overall Speaker Sentiment") 1433 | table = document.add_table(rows=1, cols=2) 1434 | table.style = document.styles[TABLE_STYLE_STANDARD] 1435 | hdr_cells = table.rows[0].cells 1436 | hdr_cells[0].text = "Speaker" 1437 | hdr_cells[1].text = "Sentiment" 1438 | hdr_cells[1].paragraphs[0].alignment = WD_ALIGN_PARAGRAPH.CENTER 1439 | speaker_sentiment = data["ConversationCharacteristics"]["Sentiment"]["OverallSentiment"] 1440 | for caller in speaker_sentiment: 1441 | row_cells = table.add_row().cells 1442 | row_cells[0].text = caller.title() 1443 | row_cells[1].text = str(speaker_sentiment[caller]) 1444 | row_cells[1].paragraphs[0].alignment = WD_ALIGN_PARAGRAPH.CENTER 1445 | cell_colour = get_text_colour_analytics_sentiment(speaker_sentiment[caller]) 1446 | set_table_cell_background_colour(row_cells[1], cell_colour) 1447 | 1448 | # Keep the columns narrow for the 2nd table 1449 | widths = (Cm(2.2), Cm(1.5)) 1450 | for row in table.rows: 1451 | for idx, width in enumerate(widths): 1452 | row.cells[idx].width = width 1453 | document.add_paragraph() # Spacing 1454 | 1455 | 1456 | def create_turn_by_turn_segments(data, cli_args): 1457 | """ 1458 | This creates a list of per-turn speech segments based upon the transcript data. It has to work in three 1459 | slightly different ways, as each operational mode from Transcribe outputs slightly different JSON structures. 1460 | These modes are (a) Speaker-separated audio, (b) Channel-separated audio, and (c) Call Analytics audio 1461 | 1462 | :param data: JSON result data from Transcribe 1463 | :param cli_args: CLI arguments used for this processing run 1464 | :return: List of transcription speech segments 1465 | :return: Flag to indicate the presence of call summary data 1466 | """ 1467 | speechSegmentList = [] 1468 | summaries_detected = False 1469 | 1470 | # Decide on our operational mode - it's in the job-status or, if necessary, infer it from the data file 1471 | # STANDARD => speaker separated, channel separated; ANALYTICS => different format 1472 | isAnalyticsMode = cli_args.analyticsMode 1473 | if isAnalyticsMode: 1474 | # We know if its analytics mode, as it's defined in the job-status and file 1475 | isChannelMode = False 1476 | isSpeakerMode = False 1477 | else: 1478 | # Channel/Speaker-mode only relevant if not using analytics 1479 | isChannelMode = "channel_labels" in data["results"] 1480 | isSpeakerMode = not isChannelMode 1481 | 1482 | lastSpeaker = "" 1483 | lastEndTime = 0.0 1484 | skipLeadingSpace = False 1485 | confidenceList = [] 1486 | nextSpeechSegment = None 1487 | 1488 | # Process a Speaker-separated non-analytics file 1489 | if isSpeakerMode: 1490 | # A segment is a blob of pronunciation and punctuation by an individual speaker 1491 | for segment in data["results"]["speaker_labels"]["segments"]: 1492 | 1493 | # If there is content in the segment then pick out the time and speaker 1494 | if len(segment["items"]) > 0: 1495 | # Pick out our next data 1496 | nextStartTime = float(segment["start_time"]) 1497 | nextEndTime = float(segment["end_time"]) 1498 | nextSpeaker = str(segment["speaker_label"]) 1499 | 1500 | # If we've changed speaker, or there's a gap, create a new row 1501 | if (nextSpeaker != lastSpeaker) or ((nextStartTime - lastEndTime) >= START_NEW_SEGMENT_DELAY): 1502 | nextSpeechSegment = SpeechSegment() 1503 | speechSegmentList.append(nextSpeechSegment) 1504 | nextSpeechSegment.segmentStartTime = nextStartTime 1505 | nextSpeechSegment.segmentSpeaker = nextSpeaker 1506 | skipLeadingSpace = True 1507 | confidenceList = [] 1508 | nextSpeechSegment.segmentConfidence = confidenceList 1509 | nextSpeechSegment.segmentEndTime = nextEndTime 1510 | 1511 | # Note the speaker and end time of this segment for the next iteration 1512 | lastSpeaker = nextSpeaker 1513 | lastEndTime = nextEndTime 1514 | 1515 | # For each word in the segment... 1516 | for word in segment["items"]: 1517 | 1518 | # Get the word with the highest confidence 1519 | pronunciations = list(filter(lambda x: x["type"] == "pronunciation", data["results"]["items"])) 1520 | word_result = list(filter(lambda x: x["start_time"] == word["start_time"] and x["end_time"] == word["end_time"], pronunciations)) 1521 | try: 1522 | result = sorted(word_result[-1]["alternatives"], key=lambda x: x["confidence"])[-1] 1523 | confidence = float(result["confidence"]) 1524 | except: 1525 | result = word_result[-1]["alternatives"][0] 1526 | confidence = float(result["redactions"][0]["confidence"]) 1527 | 1528 | # Write the word, and a leading space if this isn't the start of the segment 1529 | if skipLeadingSpace: 1530 | skipLeadingSpace = False 1531 | wordToAdd = result["content"] 1532 | else: 1533 | wordToAdd = " " + result["content"] 1534 | 1535 | # If the next item is punctuation, add it to the current word 1536 | try: 1537 | word_result_index = data["results"]["items"].index(word_result[0]) 1538 | next_item = data["results"]["items"][word_result_index + 1] 1539 | if next_item["type"] == "punctuation": 1540 | wordToAdd += next_item["alternatives"][0]["content"] 1541 | except IndexError: 1542 | pass 1543 | 1544 | nextSpeechSegment.segmentText += wordToAdd 1545 | confidenceList.append({"text": wordToAdd, 1546 | "confidence": confidence, 1547 | "start_time": float(word["start_time"]), 1548 | "end_time": float(word["end_time"])}) 1549 | 1550 | # Process a Channel-separated non-analytics file 1551 | elif isChannelMode: 1552 | 1553 | # A channel contains all pronunciation and punctuation from a single speaker 1554 | for channel in data["results"]["channel_labels"]["channels"]: 1555 | 1556 | # If there is content in the channel then start processing it 1557 | if len(channel["items"]) > 0: 1558 | 1559 | # We have the same speaker all the way through this channel 1560 | nextSpeaker = str(channel["channel_label"]) 1561 | for word in channel["items"]: 1562 | # Pick out our next data from a 'pronunciation' 1563 | if word["type"] == "pronunciation": 1564 | nextStartTime = float(word["start_time"]) 1565 | nextEndTime = float(word["end_time"]) 1566 | 1567 | # If we've changed speaker, or we haven't and the 1568 | # pause is very small, then start a new text segment 1569 | if (nextSpeaker != lastSpeaker) or\ 1570 | ((nextSpeaker == lastSpeaker) and ((nextStartTime - lastEndTime) > 0.1)): 1571 | nextSpeechSegment = SpeechSegment() 1572 | speechSegmentList.append(nextSpeechSegment) 1573 | nextSpeechSegment.segmentStartTime = nextStartTime 1574 | nextSpeechSegment.segmentSpeaker = nextSpeaker 1575 | skipLeadingSpace = True 1576 | confidenceList = [] 1577 | nextSpeechSegment.segmentConfidence = confidenceList 1578 | nextSpeechSegment.segmentEndTime = nextEndTime 1579 | 1580 | # Note the speaker and end time of this segment for the next iteration 1581 | lastSpeaker = nextSpeaker 1582 | lastEndTime = nextEndTime 1583 | 1584 | # Get the word with the highest confidence 1585 | pronunciations = list(filter(lambda x: x["type"] == "pronunciation", channel["items"])) 1586 | word_result = list(filter(lambda x: x["start_time"] == word["start_time"] and x["end_time"] == word["end_time"], pronunciations)) 1587 | try: 1588 | result = sorted(word_result[-1]["alternatives"], key=lambda x: x["confidence"])[-1] 1589 | confidence = float(result["confidence"]) 1590 | except: 1591 | result = word_result[-1]["alternatives"][0] 1592 | confidence = float(result["redactions"][0]["confidence"]) 1593 | # result = sorted(word_result[-1]["alternatives"], key=lambda x: x["confidence"])[-1] 1594 | 1595 | # Write the word, and a leading space if this isn't the start of the segment 1596 | if (skipLeadingSpace): 1597 | skipLeadingSpace = False 1598 | wordToAdd = result["content"] 1599 | else: 1600 | wordToAdd = " " + result["content"] 1601 | 1602 | # If the next item is punctuation, add it to the current word 1603 | try: 1604 | word_result_index = channel["items"].index(word_result[0]) 1605 | next_item = channel["items"][word_result_index + 1] 1606 | if next_item["type"] == "punctuation": 1607 | wordToAdd += next_item["alternatives"][0]["content"] 1608 | except IndexError: 1609 | pass 1610 | 1611 | # Finally, add the word and confidence to this segment's list 1612 | nextSpeechSegment.segmentText += wordToAdd 1613 | confidenceList.append({"text": wordToAdd, 1614 | "confidence": confidence, 1615 | "start_time": float(word["start_time"]), 1616 | "end_time": float(word["end_time"])}) 1617 | 1618 | # Sort the segments, as they are in channel-order and not speaker-order, then 1619 | # merge together turns from the same speaker that are very close together 1620 | speechSegmentList = sorted(speechSegmentList, key=lambda segment: segment.segmentStartTime) 1621 | speechSegmentList = merge_speaker_segments(speechSegmentList) 1622 | 1623 | # Process a Call Analytics file 1624 | elif isAnalyticsMode: 1625 | 1626 | # Lookup shortcuts 1627 | interrupts = data["ConversationCharacteristics"]["Interruptions"] 1628 | 1629 | # Each turn has already been processed by Transcribe, so the outputs are in order 1630 | for turn in data["Transcript"]: 1631 | 1632 | # Setup the next speaker block 1633 | nextSpeechSegment = SpeechSegment() 1634 | speechSegmentList.append(nextSpeechSegment) 1635 | nextSpeechSegment.segmentStartTime = float(turn["BeginOffsetMillis"]) / 1000.0 1636 | nextSpeechSegment.segmentEndTime = float(turn["EndOffsetMillis"]) / 1000.0 1637 | nextSpeechSegment.segmentSpeaker = turn["ParticipantRole"].title() 1638 | nextSpeechSegment.segmentText = turn["Content"] 1639 | nextSpeechSegment.segmentLoudnessScores = turn["LoudnessScores"] 1640 | confidenceList = [] 1641 | nextSpeechSegment.segmentConfidence = confidenceList 1642 | skipLeadingSpace = True 1643 | 1644 | # Check if this block is within an interruption block for the speaker 1645 | if turn["ParticipantRole"] in interrupts["InterruptionsByInterrupter"]: 1646 | for entry in interrupts["InterruptionsByInterrupter"][turn["ParticipantRole"]]: 1647 | if turn["BeginOffsetMillis"] == entry["BeginOffsetMillis"]: 1648 | nextSpeechSegment.segmentInterruption = True 1649 | 1650 | # Record any issues detected 1651 | if "IssuesDetected" in turn: 1652 | summaries_detected = True 1653 | for issue in turn["IssuesDetected"]: 1654 | # Grab the transcript offsets for the issue text 1655 | nextSpeechSegment.segmentIssuesDetected.append(issue["CharacterOffsets"]) 1656 | 1657 | # Record any actions detected 1658 | if "ActionItemsDetected" in turn: 1659 | summaries_detected = True 1660 | for action in turn["ActionItemsDetected"]: 1661 | # Grab the transcript offsets for the issue text 1662 | nextSpeechSegment.segmentActionItemsDetected.append(action["CharacterOffsets"]) 1663 | 1664 | # Record any outcomes detected 1665 | if "OutcomesDetected" in turn: 1666 | summaries_detected = True 1667 | for outcome in turn["OutcomesDetected"]: 1668 | # Grab the transcript offsets for the issue text 1669 | nextSpeechSegment.segmentOutcomesDetected.append(outcome["CharacterOffsets"]) 1670 | 1671 | # Process each word in this turn 1672 | for word in turn["Items"]: 1673 | # Pick out our next data from a 'pronunciation' 1674 | if word["Type"] == "pronunciation": 1675 | # Write the word, and a leading space if this isn't the start of the segment 1676 | if skipLeadingSpace: 1677 | skipLeadingSpace = False 1678 | wordToAdd = word["Content"] 1679 | else: 1680 | wordToAdd = " " + word["Content"] 1681 | 1682 | # If the word is redacted then the word confidence is a bit more buried 1683 | if "Confidence" in word: 1684 | conf_score = float(word["Confidence"]) 1685 | elif "Redaction" in word: 1686 | conf_score = float(word["Redaction"][0]["Confidence"]) 1687 | 1688 | # Add the word and confidence to this segment's list 1689 | confidenceList.append({"text": wordToAdd, 1690 | "confidence": conf_score, 1691 | "start_time": float(word["BeginOffsetMillis"]) / 1000.0, 1692 | "end_time": float(word["BeginOffsetMillis"] / 1000.0)}) 1693 | else: 1694 | # Punctuation, needs to be added to the previous word 1695 | last_word = nextSpeechSegment.segmentConfidence[-1] 1696 | last_word["text"] = last_word["text"] + word["Content"] 1697 | 1698 | # Tag on the sentiment - analytics has no per-turn numbers 1699 | turn_sentiment = turn["Sentiment"] 1700 | if turn_sentiment == "POSITIVE": 1701 | nextSpeechSegment.segmentIsPositive = True 1702 | nextSpeechSegment.segmentPositive = 1.0 1703 | nextSpeechSegment.segmentSentimentScore = 1.0 1704 | elif turn_sentiment == "NEGATIVE": 1705 | nextSpeechSegment.segmentIsNegative = True 1706 | nextSpeechSegment.segmentNegative = 1.0 1707 | nextSpeechSegment.segmentSentimentScore = 1.0 1708 | 1709 | # Return our full turn-by-turn speaker segment list with sentiment, 1710 | # along with a flag to indicate the presence of call summary data 1711 | return speechSegmentList, summaries_detected 1712 | 1713 | 1714 | def load_transcribe_job_status(cli_args): 1715 | """ 1716 | Loads in the job status for the job named in cli_args.inputJob. This will try both the standard Transcribe API 1717 | as well as the Analytics API, as the customer may not know which one their job relates to 1718 | 1719 | :param cli_args: CLI arguments used for this processing run 1720 | :return: The job status structure (different between standard/analytics), and a 'job-completed' flag 1721 | """ 1722 | transcribe_client = boto3.client("transcribe") 1723 | 1724 | try: 1725 | # Extract the standard Transcribe job status 1726 | job_status = transcribe_client.get_transcription_job(TranscriptionJobName=cli_args.inputJob)["TranscriptionJob"] 1727 | cli_args.analyticsMode = False 1728 | completed = job_status["TranscriptionJobStatus"] 1729 | except: 1730 | # That job doesn't exist, but it may have been an analytics job 1731 | job_status = transcribe_client.get_call_analytics_job(CallAnalyticsJobName=cli_args.inputJob)["CallAnalyticsJob"] 1732 | cli_args.analyticsMode = True 1733 | completed = job_status["CallAnalyticsJobStatus"] 1734 | 1735 | return job_status, completed 1736 | 1737 | 1738 | def generate_document(): 1739 | """ 1740 | Entrypoint for the command-line interface. 1741 | """ 1742 | # Parameter extraction 1743 | cli_parser = argparse.ArgumentParser(prog='ts-to-word', 1744 | description='Turn an Amazon Transcribe job output into an MS Word document') 1745 | source_group = cli_parser.add_mutually_exclusive_group(required=True) 1746 | source_group.add_argument('--inputFile', metavar='filename', type=str, help='File containing Transcribe JSON output') 1747 | source_group.add_argument('--inputJob', metavar='job-id', type=str, help='Transcribe job identifier') 1748 | cli_parser.add_argument('--outputFile', metavar='filename', type=str, help='Output file to hold MS Word document') 1749 | cli_parser.add_argument('--sentiment', choices=['on', 'off'], default='off', help='Enables sentiment analysis on each conversational turn via Amazon Comprehend') 1750 | cli_parser.add_argument('--confidence', choices=['on', 'off'], default='off', help='Displays information on word confidence scores throughout the transcript') 1751 | cli_parser.add_argument('--keep', action='store_true', help='Keeps any downloaded job transcript JSON file') 1752 | cli_args = cli_parser.parse_args() 1753 | 1754 | # If we're downloading a job transcript then validate that we have a job, then download it 1755 | if cli_args.inputJob is not None: 1756 | try: 1757 | job_info, job_status = load_transcribe_job_status(cli_args) 1758 | except: 1759 | # Exception, most-likely due to the job not existing 1760 | print("NOT FOUND: Requested job-id '{0}' does not exist.".format(cli_args.inputJob)) 1761 | exit(-1) 1762 | 1763 | # If the job hasn't completed then there is no transcript available 1764 | if job_status == "FAILED": 1765 | print("{0}: Requested job-id '{1}' has failed to complete".format(job_status, cli_args.inputJob)) 1766 | exit(-1) 1767 | elif job_status != "COMPLETED": 1768 | print("{0}: Requested job-id '{1}' has not yet completed.".format(job_status, cli_args.inputJob)) 1769 | exit(-1) 1770 | 1771 | # The transcript is available from a signed URL - get the redacted if it exists, otherwise the non-redacted 1772 | if "RedactedTranscriptFileUri" in job_info["Transcript"]: 1773 | # Get the redacted transcript 1774 | download_url = job_info["Transcript"]["RedactedTranscriptFileUri"] 1775 | else: 1776 | # Gen the non-redacted transcript 1777 | download_url = job_info["Transcript"]["TranscriptFileUri"] 1778 | cli_args.inputFile = cli_args.inputJob + "-asrOutput.json" 1779 | 1780 | # Try and download the JSON - this will fail if the job delivered it to 1781 | # an S3 bucket, as in that case the service no longer has the results 1782 | try: 1783 | urllib.request.urlretrieve(download_url, cli_args.inputFile) 1784 | except: 1785 | print("UNAVAILABLE: Transcript for job-id '{0}' is not available for download.".format(cli_args.inputJob)) 1786 | exit(-1) 1787 | 1788 | # Set our output filename if one wasn't supplied 1789 | if cli_args.outputFile is None: 1790 | cli_args.outputFile = cli_args.inputJob + ".docx" 1791 | 1792 | # Load in the JSON file for processing 1793 | json_filepath = Path(cli_args.inputFile) 1794 | if json_filepath.is_file(): 1795 | json_data = json.load(open(json_filepath.absolute(), "r", encoding="utf-8")) 1796 | else: 1797 | print("FAIL: Specified JSON file '{0}' does not exists.".format(cli_args.inputFile)) 1798 | exit(-1) 1799 | 1800 | # If this is a file-input run then try and load the job status (which may no longer exist) 1801 | if cli_args.inputJob is None: 1802 | try: 1803 | # Ensure we don't delete our JSON later, reset our output file to match the job-name if it's currently blank 1804 | cli_args.keep = True 1805 | if cli_args.outputFile is None: 1806 | if "results" in json_data: 1807 | cli_args.outputFile = json_data["jobName"] + ".docx" 1808 | cli_args.inputJob = json_data["jobName"] 1809 | else: 1810 | cli_args.outputFile = json_data["JobName"] + ".docx" 1811 | cli_args.inputJob = json_data["JobName"] 1812 | job_info, job_status = load_transcribe_job_status(cli_args) 1813 | except: 1814 | # No job status - need to quickly work out what mode we're in, 1815 | # as standard job results look different from analytical ones 1816 | cli_args.inputJob = None 1817 | cli_args.outputFile = cli_args.inputFile + ".docx" 1818 | cli_args.analyticsMode = "results" not in json_data 1819 | job_info = None 1820 | 1821 | # Disable Comprehend's sentiment if we're in Analytics mode 1822 | if cli_args.analyticsMode: 1823 | cli_args.sentiment = 'off' 1824 | 1825 | # Generate the core transcript 1826 | start = perf_counter() 1827 | speech_segments, summaries_detected = create_turn_by_turn_segments(json_data, cli_args) 1828 | 1829 | # Inject Comprehend-based sentiments into the segment list if required 1830 | if cli_args.sentiment == 'on': 1831 | # Work out the mapped language code, as Transcribe supports more languages than Comprehend. Just 1832 | # see if the Transcribe language code starts with any of those that Comprehend supports and use that 1833 | sentiment_lang_code = None 1834 | for comprehend_code in SENTIMENT_LANGUAGES: 1835 | if job_info["LanguageCode"].startswith(comprehend_code): 1836 | sentiment_lang_code = comprehend_code 1837 | break 1838 | 1839 | # If we have no match then we cannot perform sentiment analysis 1840 | if sentiment_lang_code is not None: 1841 | generate_sentiment(speech_segments, sentiment_lang_code) 1842 | else: 1843 | cli_args.sentiment = 'off' 1844 | 1845 | # Write out our file and the performance statistics 1846 | write(cli_args, speech_segments, job_info, summaries_detected) 1847 | finish = perf_counter() 1848 | duration = round(finish - start, 2) 1849 | print(f"> Transcript {cli_args.outputFile} writen in {duration} seconds.") 1850 | 1851 | # Finally, remove any temporary downloaded JSON results file 1852 | if (cli_args.inputJob is not None) and (not cli_args.keep): 1853 | os.remove(cli_args.inputFile) 1854 | 1855 | # Main entrypoint 1856 | if __name__ == "__main__": 1857 | generate_document() 1858 | -------------------------------------------------------------------------------- /sample-data/example-call-redacted.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-transcribe-output-word-document/d91b6c58ffb2e8919f58cabd66019397c747dc35/sample-data/example-call-redacted.wav -------------------------------------------------------------------------------- /sample-data/example-call.docx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-transcribe-output-word-document/d91b6c58ffb2e8919f58cabd66019397c747dc35/sample-data/example-call.docx -------------------------------------------------------------------------------- /sample-data/example-call.json: -------------------------------------------------------------------------------- 1 | {"JobStatus":"COMPLETED","LanguageCode":"en-US","Transcript":[{"LoudnessScores":[88.14,87.71,86.15,86.54,86.64,34.83],"Content":"Thank you for calling Anchor Credit Union, the number one choice for captains worldwide, how can I help you?","Items":[{"Type":"pronunciation","Confidence":1.0,"Content":"Thank","BeginOffsetMillis":40,"EndOffsetMillis":330},{"Type":"pronunciation","Confidence":1.0,"Content":"you","BeginOffsetMillis":330,"EndOffsetMillis":440},{"Type":"pronunciation","Confidence":1.0,"Content":"for","BeginOffsetMillis":440,"EndOffsetMillis":520},{"Type":"pronunciation","Confidence":1.0,"Content":"calling","BeginOffsetMillis":520,"EndOffsetMillis":860},{"Type":"pronunciation","Confidence":0.2993,"Content":"Anchor","BeginOffsetMillis":860,"EndOffsetMillis":1170},{"Type":"pronunciation","Confidence":1.0,"Content":"Credit","BeginOffsetMillis":1170,"EndOffsetMillis":1480},{"Type":"pronunciation","Confidence":1.0,"Content":"Union","BeginOffsetMillis":1480,"EndOffsetMillis":1810},{"Type":"punctuation","Confidence":0.0,"Content":","},{"Type":"pronunciation","Confidence":0.9872,"Content":"the","BeginOffsetMillis":1810,"EndOffsetMillis":1900},{"Type":"pronunciation","Confidence":1.0,"Content":"number","BeginOffsetMillis":1900,"EndOffsetMillis":2160},{"Type":"pronunciation","Confidence":1.0,"Content":"one","BeginOffsetMillis":2160,"EndOffsetMillis":2330},{"Type":"pronunciation","Confidence":1.0,"Content":"choice","BeginOffsetMillis":2330,"EndOffsetMillis":2620},{"Type":"pronunciation","Confidence":0.8812,"Content":"for","BeginOffsetMillis":2620,"EndOffsetMillis":2730},{"Type":"pronunciation","Confidence":0.5919,"Content":"captains","BeginOffsetMillis":2730,"EndOffsetMillis":3220},{"Type":"pronunciation","Confidence":0.9772,"Content":"worldwide","BeginOffsetMillis":3220,"EndOffsetMillis":3950},{"Type":"punctuation","Confidence":0.0,"Content":","},{"Type":"pronunciation","Confidence":1.0,"Content":"how","BeginOffsetMillis":3950,"EndOffsetMillis":4110},{"Type":"pronunciation","Confidence":1.0,"Content":"can","BeginOffsetMillis":4110,"EndOffsetMillis":4260},{"Type":"pronunciation","Confidence":1.0,"Content":"I","BeginOffsetMillis":4260,"EndOffsetMillis":4320},{"Type":"pronunciation","Confidence":1.0,"Content":"help","BeginOffsetMillis":4320,"EndOffsetMillis":4610},{"Type":"pronunciation","Confidence":1.0,"Content":"you","BeginOffsetMillis":4610,"EndOffsetMillis":5050},{"Type":"punctuation","Confidence":0.0,"Content":"?"}],"Id":"28e2b43d-8f32-42d7-a89e-ec53a6e22f12","BeginOffsetMillis":40,"EndOffsetMillis":5050,"Sentiment":"POSITIVE","ParticipantRole":"AGENT"},{"LoudnessScores":[88.04,89.25,88.81,86.81,84.4],"Content":"Hi um Okay so I was at fisherman's terminal.","Items":[{"Type":"pronunciation","Confidence":0.9931,"Content":"Hi","BeginOffsetMillis":6310,"EndOffsetMillis":6930},{"Type":"pronunciation","Confidence":0.9469,"Content":"um","BeginOffsetMillis":6930,"EndOffsetMillis":7460},{"Type":"pronunciation","Confidence":0.728,"Content":"Okay","BeginOffsetMillis":7840,"EndOffsetMillis":8140},{"Type":"pronunciation","Confidence":1.0,"Content":"so","BeginOffsetMillis":8140,"EndOffsetMillis":8490},{"Type":"pronunciation","Confidence":1.0,"Content":"I","BeginOffsetMillis":8490,"EndOffsetMillis":8750},{"Type":"pronunciation","Confidence":1.0,"Content":"was","BeginOffsetMillis":8750,"EndOffsetMillis":8930},{"Type":"pronunciation","Confidence":1.0,"Content":"at","BeginOffsetMillis":8930,"EndOffsetMillis":9080},{"Type":"pronunciation","Confidence":0.9939,"Content":"fisherman's","BeginOffsetMillis":9080,"EndOffsetMillis":9680},{"Type":"pronunciation","Confidence":1.0,"Content":"terminal","BeginOffsetMillis":9680,"EndOffsetMillis":10450},{"Type":"punctuation","Confidence":0.0,"Content":"."}],"Id":"29b72233-e8f3-4a69-bafc-e47c3c00e2ff","BeginOffsetMillis":6310,"EndOffsetMillis":10450,"Sentiment":"NEUTRAL","ParticipantRole":"CUSTOMER"},{"LoudnessScores":[61.39,77.04,69.51,84.19,84.55],"Content":"Yeah. Mhm.","Items":[{"Type":"pronunciation","Confidence":0.3963,"Content":"Yeah","BeginOffsetMillis":7940,"EndOffsetMillis":8150},{"Type":"punctuation","Confidence":0.0,"Content":"."},{"Type":"pronunciation","Confidence":0.9188,"Content":"Mhm","BeginOffsetMillis":10740,"EndOffsetMillis":11380},{"Type":"punctuation","Confidence":0.0,"Content":"."}],"Id":"4251379c-59a2-4163-b6df-a9143c2bbf49","BeginOffsetMillis":7940,"EndOffsetMillis":11380,"Sentiment":"NEUTRAL","ParticipantRole":"AGENT"},{"LoudnessScores":[85.57,87.48,85.65,80.92,85.41,87.61,83.61,84.11,84.94,83.41],"Content":"I'm trying to dock up my ship and my card fell into the water so I'm hoping to get it replaced.","IssuesDetected":[{"CharacterOffsets":{"Begin":0,"End":95}}],"Items":[{"Type":"pronunciation","Confidence":0.7459,"Content":"I'm","BeginOffsetMillis":11040,"EndOffsetMillis":11320},{"Type":"pronunciation","Confidence":1.0,"Content":"trying","BeginOffsetMillis":11330,"EndOffsetMillis":12140},{"Type":"pronunciation","Confidence":1.0,"Content":"to","BeginOffsetMillis":12140,"EndOffsetMillis":12330},{"Type":"pronunciation","Confidence":0.9103,"Content":"dock","BeginOffsetMillis":12330,"EndOffsetMillis":12620},{"Type":"pronunciation","Confidence":1.0,"Content":"up","BeginOffsetMillis":12620,"EndOffsetMillis":12790},{"Type":"pronunciation","Confidence":1.0,"Content":"my","BeginOffsetMillis":12790,"EndOffsetMillis":13520},{"Type":"pronunciation","Confidence":0.9929,"Content":"ship","BeginOffsetMillis":13530,"EndOffsetMillis":14160},{"Type":"pronunciation","Confidence":1.0,"Content":"and","BeginOffsetMillis":15440,"EndOffsetMillis":15770},{"Type":"pronunciation","Confidence":1.0,"Content":"my","BeginOffsetMillis":15770,"EndOffsetMillis":15950},{"Type":"pronunciation","Confidence":0.971,"Content":"card","BeginOffsetMillis":15950,"EndOffsetMillis":16320},{"Type":"pronunciation","Confidence":1.0,"Content":"fell","BeginOffsetMillis":16320,"EndOffsetMillis":16730},{"Type":"pronunciation","Confidence":1.0,"Content":"into","BeginOffsetMillis":16740,"EndOffsetMillis":17020},{"Type":"pronunciation","Confidence":1.0,"Content":"the","BeginOffsetMillis":17020,"EndOffsetMillis":17120},{"Type":"pronunciation","Confidence":1.0,"Content":"water","BeginOffsetMillis":17120,"EndOffsetMillis":17560},{"Type":"pronunciation","Confidence":1.0,"Content":"so","BeginOffsetMillis":18740,"EndOffsetMillis":19100},{"Type":"pronunciation","Confidence":1.0,"Content":"I'm","BeginOffsetMillis":19100,"EndOffsetMillis":19610},{"Type":"pronunciation","Confidence":1.0,"Content":"hoping","BeginOffsetMillis":19620,"EndOffsetMillis":19940},{"Type":"pronunciation","Confidence":1.0,"Content":"to","BeginOffsetMillis":19940,"EndOffsetMillis":20070},{"Type":"pronunciation","Confidence":1.0,"Content":"get","BeginOffsetMillis":20070,"EndOffsetMillis":20200},{"Type":"pronunciation","Confidence":1.0,"Content":"it","BeginOffsetMillis":20200,"EndOffsetMillis":20280},{"Type":"pronunciation","Confidence":1.0,"Content":"replaced","BeginOffsetMillis":20280,"EndOffsetMillis":20860},{"Type":"punctuation","Confidence":0.0,"Content":"."}],"Id":"e521a003-c632-4861-98fb-497a7a87088e","BeginOffsetMillis":11040,"EndOffsetMillis":20860,"Sentiment":"NEGATIVE","ParticipantRole":"CUSTOMER"},{"LoudnessScores":[85.56,86.31,67.64,50.08,87.87,86.79,88.29,null,null,88.82],"Content":"Uh Not a problem. Let's see if we can get that taken care of you. Uh What's your name?","Items":[{"Type":"pronunciation","Confidence":0.9408,"Content":"Uh","BeginOffsetMillis":18740,"EndOffsetMillis":19360},{"Type":"pronunciation","Confidence":1.0,"Content":"Not","BeginOffsetMillis":22540,"EndOffsetMillis":22810},{"Type":"pronunciation","Confidence":1.0,"Content":"a","BeginOffsetMillis":22810,"EndOffsetMillis":22860},{"Type":"pronunciation","Confidence":1.0,"Content":"problem","BeginOffsetMillis":22860,"EndOffsetMillis":23130},{"Type":"punctuation","Confidence":0.0,"Content":"."},{"Type":"pronunciation","Confidence":0.9551,"Content":"Let's","BeginOffsetMillis":23130,"EndOffsetMillis":23290},{"Type":"pronunciation","Confidence":1.0,"Content":"see","BeginOffsetMillis":23290,"EndOffsetMillis":23400},{"Type":"pronunciation","Confidence":1.0,"Content":"if","BeginOffsetMillis":23400,"EndOffsetMillis":23510},{"Type":"pronunciation","Confidence":1.0,"Content":"we","BeginOffsetMillis":23510,"EndOffsetMillis":23600},{"Type":"pronunciation","Confidence":1.0,"Content":"can","BeginOffsetMillis":23600,"EndOffsetMillis":23780},{"Type":"pronunciation","Confidence":1.0,"Content":"get","BeginOffsetMillis":23780,"EndOffsetMillis":23940},{"Type":"pronunciation","Confidence":1.0,"Content":"that","BeginOffsetMillis":23940,"EndOffsetMillis":24110},{"Type":"pronunciation","Confidence":1.0,"Content":"taken","BeginOffsetMillis":24110,"EndOffsetMillis":24460},{"Type":"pronunciation","Confidence":1.0,"Content":"care","BeginOffsetMillis":24460,"EndOffsetMillis":24820},{"Type":"pronunciation","Confidence":1.0,"Content":"of","BeginOffsetMillis":24820,"EndOffsetMillis":25260},{"Type":"pronunciation","Confidence":0.9805,"Content":"you","BeginOffsetMillis":26240,"EndOffsetMillis":26560},{"Type":"punctuation","Confidence":0.0,"Content":"."},{"Type":"pronunciation","Confidence":0.989,"Content":"Uh","BeginOffsetMillis":26560,"EndOffsetMillis":27000},{"Type":"pronunciation","Confidence":0.9915,"Content":"What's","BeginOffsetMillis":27000,"EndOffsetMillis":27110},{"Type":"pronunciation","Confidence":1.0,"Content":"your","BeginOffsetMillis":27110,"EndOffsetMillis":27210},{"Type":"pronunciation","Confidence":1.0,"Content":"name","BeginOffsetMillis":27210,"EndOffsetMillis":27650},{"Type":"punctuation","Confidence":0.0,"Content":"?"}],"Id":"12c4c12e-a784-4a0a-9eb4-76758bb4af71","BeginOffsetMillis":18740,"EndOffsetMillis":27650,"Sentiment":"NEUTRAL","ParticipantRole":"AGENT"},{"LoudnessScores":[83.56,39.24,85.47],"Content":"Alright [PII]","Redaction":{"RedactedTimestamps":[{"BeginOffsetMillis":31040,"EndOffsetMillis":31660}]},"Items":[{"Type":"pronunciation","Confidence":0.8427,"Content":"Alright","BeginOffsetMillis":29240,"EndOffsetMillis":29660},{"Type":"pronunciation","Content":"[PII]","Redaction":[{"Confidence":0.9822}],"BeginOffsetMillis":31040,"EndOffsetMillis":31660}],"Id":"761ce02e-b09d-449b-ab6e-16b907325f56","BeginOffsetMillis":29240,"EndOffsetMillis":31660,"Sentiment":"NEUTRAL","ParticipantRole":"CUSTOMER"},{"LoudnessScores":[82.74,83.73,86.87,86.66,88.91,88.61,87.79,85.45],"Content":"[PII] hi [PII] I'm [PII] happy to be helping you today. Um What was the billing zip code for that card?","Redaction":{"RedactedTimestamps":[{"BeginOffsetMillis":32740,"EndOffsetMillis":33300},{"BeginOffsetMillis":33520,"EndOffsetMillis":33860},{"BeginOffsetMillis":34070,"EndOffsetMillis":34490}]},"Items":[{"Type":"pronunciation","Content":"[PII]","Redaction":[{"Confidence":0.9999}],"BeginOffsetMillis":32740,"EndOffsetMillis":33300},{"Type":"pronunciation","Confidence":1.0,"Content":"hi","BeginOffsetMillis":33310,"EndOffsetMillis":33520},{"Type":"pronunciation","Content":"[PII]","Redaction":[{"Confidence":0.9999}],"BeginOffsetMillis":33520,"EndOffsetMillis":33860},{"Type":"pronunciation","Confidence":1.0,"Content":"I'm","BeginOffsetMillis":33860,"EndOffsetMillis":34070},{"Type":"pronunciation","Content":"[PII]","Redaction":[{"Confidence":0.9999}],"BeginOffsetMillis":34070,"EndOffsetMillis":34490},{"Type":"pronunciation","Confidence":1.0,"Content":"happy","BeginOffsetMillis":34490,"EndOffsetMillis":34720},{"Type":"pronunciation","Confidence":1.0,"Content":"to","BeginOffsetMillis":34720,"EndOffsetMillis":34800},{"Type":"pronunciation","Confidence":1.0,"Content":"be","BeginOffsetMillis":34800,"EndOffsetMillis":34890},{"Type":"pronunciation","Confidence":1.0,"Content":"helping","BeginOffsetMillis":34890,"EndOffsetMillis":35190},{"Type":"pronunciation","Confidence":1.0,"Content":"you","BeginOffsetMillis":35190,"EndOffsetMillis":35280},{"Type":"pronunciation","Confidence":1.0,"Content":"today","BeginOffsetMillis":35280,"EndOffsetMillis":35760},{"Type":"punctuation","Confidence":0.0,"Content":"."},{"Type":"pronunciation","Confidence":0.9932,"Content":"Um","BeginOffsetMillis":36140,"EndOffsetMillis":36840},{"Type":"pronunciation","Confidence":1.0,"Content":"What","BeginOffsetMillis":36850,"EndOffsetMillis":37290},{"Type":"pronunciation","Confidence":1.0,"Content":"was","BeginOffsetMillis":37300,"EndOffsetMillis":38050},{"Type":"pronunciation","Confidence":1.0,"Content":"the","BeginOffsetMillis":38050,"EndOffsetMillis":38220},{"Type":"pronunciation","Confidence":1.0,"Content":"billing","BeginOffsetMillis":38220,"EndOffsetMillis":38500},{"Type":"pronunciation","Confidence":0.9903,"Content":"zip","BeginOffsetMillis":38500,"EndOffsetMillis":38760},{"Type":"pronunciation","Confidence":0.9886,"Content":"code","BeginOffsetMillis":38760,"EndOffsetMillis":39130},{"Type":"pronunciation","Confidence":1.0,"Content":"for","BeginOffsetMillis":39130,"EndOffsetMillis":39210},{"Type":"pronunciation","Confidence":1.0,"Content":"that","BeginOffsetMillis":39210,"EndOffsetMillis":39390},{"Type":"pronunciation","Confidence":1.0,"Content":"card","BeginOffsetMillis":39390,"EndOffsetMillis":39760},{"Type":"punctuation","Confidence":0.0,"Content":"?"}],"Id":"7b1d8fc6-e8f5-4c32-b64a-d754b1db8b69","BeginOffsetMillis":32740,"EndOffsetMillis":39760,"Sentiment":"POSITIVE","ParticipantRole":"AGENT"},{"LoudnessScores":[85.5,85.03,74.29],"Content":"[PII].","Redaction":{"RedactedTimestamps":[{"BeginOffsetMillis":40740,"EndOffsetMillis":42260}]},"Items":[{"Type":"pronunciation","Content":"[PII]","Redaction":[{"Confidence":0.9966}],"BeginOffsetMillis":40740,"EndOffsetMillis":42260},{"Type":"punctuation","Confidence":0.0,"Content":"."}],"Id":"dd986961-92e3-430d-a3de-1f1579c16d8d","BeginOffsetMillis":40740,"EndOffsetMillis":42260,"Sentiment":"NEUTRAL","ParticipantRole":"CUSTOMER"},{"LoudnessScores":[82.73,88.87,87.24,84.73],"Content":"Okay and do you happen to remember the last four digits of that card?","Items":[{"Type":"pronunciation","Confidence":1.0,"Content":"Okay","BeginOffsetMillis":43140,"EndOffsetMillis":43560},{"Type":"pronunciation","Confidence":1.0,"Content":"and","BeginOffsetMillis":43730,"EndOffsetMillis":44270},{"Type":"pronunciation","Confidence":1.0,"Content":"do","BeginOffsetMillis":44270,"EndOffsetMillis":44350},{"Type":"pronunciation","Confidence":1.0,"Content":"you","BeginOffsetMillis":44350,"EndOffsetMillis":44440},{"Type":"pronunciation","Confidence":1.0,"Content":"happen","BeginOffsetMillis":44440,"EndOffsetMillis":44640},{"Type":"pronunciation","Confidence":1.0,"Content":"to","BeginOffsetMillis":44640,"EndOffsetMillis":44700},{"Type":"pronunciation","Confidence":1.0,"Content":"remember","BeginOffsetMillis":44700,"EndOffsetMillis":44890},{"Type":"pronunciation","Confidence":1.0,"Content":"the","BeginOffsetMillis":44890,"EndOffsetMillis":45010},{"Type":"pronunciation","Confidence":1.0,"Content":"last","BeginOffsetMillis":45010,"EndOffsetMillis":45370},{"Type":"pronunciation","Confidence":1.0,"Content":"four","BeginOffsetMillis":45370,"EndOffsetMillis":45520},{"Type":"pronunciation","Confidence":1.0,"Content":"digits","BeginOffsetMillis":45520,"EndOffsetMillis":45830},{"Type":"pronunciation","Confidence":1.0,"Content":"of","BeginOffsetMillis":45830,"EndOffsetMillis":45910},{"Type":"pronunciation","Confidence":1.0,"Content":"that","BeginOffsetMillis":45910,"EndOffsetMillis":46060},{"Type":"pronunciation","Confidence":1.0,"Content":"card","BeginOffsetMillis":46060,"EndOffsetMillis":46460},{"Type":"punctuation","Confidence":0.0,"Content":"?"}],"Id":"00b1155d-446d-4dcd-b47a-25efeef92188","BeginOffsetMillis":43140,"EndOffsetMillis":46460,"Sentiment":"NEUTRAL","ParticipantRole":"AGENT"},{"LoudnessScores":[26.26,89.24,85.27,81.54,83.59,90.27],"Content":"Um Either [PII].","Redaction":{"RedactedTimestamps":[{"BeginOffsetMillis":50960,"EndOffsetMillis":52760}]},"Items":[{"Type":"pronunciation","Confidence":0.9426,"Content":"Um","BeginOffsetMillis":47940,"EndOffsetMillis":48940},{"Type":"pronunciation","Confidence":1.0,"Content":"Either","BeginOffsetMillis":50640,"EndOffsetMillis":50950},{"Type":"pronunciation","Content":"[PII]","Redaction":[{"Confidence":0.9892}],"BeginOffsetMillis":50960,"EndOffsetMillis":52760},{"Type":"punctuation","Confidence":0.0,"Content":"."}],"Id":"6ef7c5a7-19b3-453e-9189-b69c2ddeea51","BeginOffsetMillis":47940,"EndOffsetMillis":52760,"Sentiment":"NEUTRAL","ParticipantRole":"CUSTOMER"},{"LoudnessScores":[55.18],"Content":"Mm","Items":[{"Type":"pronunciation","Confidence":0.7336,"Content":"Mm","BeginOffsetMillis":51640,"EndOffsetMillis":51850}],"Id":"d1eccac1-bc00-4212-8234-e2b53790575c","BeginOffsetMillis":51640,"EndOffsetMillis":51850,"Sentiment":"NEUTRAL","ParticipantRole":"AGENT"},{"LoudnessScores":[85.07,83.8,84.92,84.41,91.07,87.3,82.53],"Content":"Alright [PII]. For something like that. Is there other information I can give you instead?","Redaction":{"RedactedTimestamps":[{"BeginOffsetMillis":54240,"EndOffsetMillis":55820}]},"Items":[{"Type":"pronunciation","Confidence":0.8573,"Content":"Alright","BeginOffsetMillis":53440,"EndOffsetMillis":53860},{"Type":"pronunciation","Content":"[PII]","Redaction":[{"Confidence":0.1935}],"BeginOffsetMillis":54240,"EndOffsetMillis":55820},{"Type":"punctuation","Confidence":0.0,"Content":"."},{"Type":"pronunciation","Confidence":1.0,"Content":"For","BeginOffsetMillis":55830,"EndOffsetMillis":56600},{"Type":"pronunciation","Confidence":1.0,"Content":"something","BeginOffsetMillis":56610,"EndOffsetMillis":57090},{"Type":"pronunciation","Confidence":1.0,"Content":"like","BeginOffsetMillis":57090,"EndOffsetMillis":57250},{"Type":"pronunciation","Confidence":1.0,"Content":"that","BeginOffsetMillis":57250,"EndOffsetMillis":57630},{"Type":"punctuation","Confidence":0.0,"Content":"."},{"Type":"pronunciation","Confidence":1.0,"Content":"Is","BeginOffsetMillis":57640,"EndOffsetMillis":57890},{"Type":"pronunciation","Confidence":0.9892,"Content":"there","BeginOffsetMillis":57890,"EndOffsetMillis":58030},{"Type":"pronunciation","Confidence":1.0,"Content":"other","BeginOffsetMillis":58030,"EndOffsetMillis":58250},{"Type":"pronunciation","Confidence":1.0,"Content":"information","BeginOffsetMillis":58250,"EndOffsetMillis":58680},{"Type":"pronunciation","Confidence":1.0,"Content":"I","BeginOffsetMillis":58680,"EndOffsetMillis":58730},{"Type":"pronunciation","Confidence":1.0,"Content":"can","BeginOffsetMillis":58730,"EndOffsetMillis":58860},{"Type":"pronunciation","Confidence":0.9693,"Content":"give","BeginOffsetMillis":58860,"EndOffsetMillis":58990},{"Type":"pronunciation","Confidence":1.0,"Content":"you","BeginOffsetMillis":58990,"EndOffsetMillis":59070},{"Type":"pronunciation","Confidence":1.0,"Content":"instead","BeginOffsetMillis":59070,"EndOffsetMillis":59550},{"Type":"punctuation","Confidence":0.0,"Content":"?"}],"Id":"08fcbbe6-9626-44ef-bd71-3d7a320ddd27","BeginOffsetMillis":53440,"EndOffsetMillis":59550,"Sentiment":"NEUTRAL","ParticipantRole":"CUSTOMER"},{"LoudnessScores":[81.49,59.89,88.91,86.9,null],"Content":"Okay no problem. How about you? Give me your date of birth?","Items":[{"Type":"pronunciation","Confidence":0.9695,"Content":"Okay","BeginOffsetMillis":58040,"EndOffsetMillis":58360},{"Type":"pronunciation","Confidence":1.0,"Content":"no","BeginOffsetMillis":60040,"EndOffsetMillis":60200},{"Type":"pronunciation","Confidence":1.0,"Content":"problem","BeginOffsetMillis":60200,"EndOffsetMillis":60410},{"Type":"punctuation","Confidence":0.0,"Content":"."},{"Type":"pronunciation","Confidence":1.0,"Content":"How","BeginOffsetMillis":60410,"EndOffsetMillis":60550},{"Type":"pronunciation","Confidence":0.9864,"Content":"about","BeginOffsetMillis":60550,"EndOffsetMillis":60770},{"Type":"pronunciation","Confidence":1.0,"Content":"you","BeginOffsetMillis":60770,"EndOffsetMillis":60870},{"Type":"punctuation","Confidence":0.0,"Content":"?"},{"Type":"pronunciation","Confidence":0.9936,"Content":"Give","BeginOffsetMillis":60870,"EndOffsetMillis":61020},{"Type":"pronunciation","Confidence":1.0,"Content":"me","BeginOffsetMillis":61020,"EndOffsetMillis":61180},{"Type":"pronunciation","Confidence":1.0,"Content":"your","BeginOffsetMillis":61180,"EndOffsetMillis":61460},{"Type":"pronunciation","Confidence":1.0,"Content":"date","BeginOffsetMillis":61460,"EndOffsetMillis":61650},{"Type":"pronunciation","Confidence":1.0,"Content":"of","BeginOffsetMillis":61650,"EndOffsetMillis":61720},{"Type":"pronunciation","Confidence":1.0,"Content":"birth","BeginOffsetMillis":61720,"EndOffsetMillis":62050},{"Type":"punctuation","Confidence":0.0,"Content":"?"}],"Id":"4ec4865c-f991-4ec9-960d-fdcc9b1832f6","BeginOffsetMillis":58040,"EndOffsetMillis":62050,"Sentiment":"NEUTRAL","ParticipantRole":"AGENT"},{"LoudnessScores":[86.05,86.12,78.37],"Content":"Great, August 19, 2020.","Items":[{"Type":"pronunciation","Confidence":1.0,"Content":"Great","BeginOffsetMillis":63240,"EndOffsetMillis":63650},{"Type":"punctuation","Confidence":0.0,"Content":","},{"Type":"pronunciation","Confidence":0.9793000000000001,"Content":"August","BeginOffsetMillis":63660,"EndOffsetMillis":64840},{"Type":"pronunciation","Confidence":1.0,"Content":"19","BeginOffsetMillis":64840,"EndOffsetMillis":65160},{"Type":"punctuation","Confidence":0.0,"Content":","},{"Type":"pronunciation","Confidence":1.0,"Content":"2020","BeginOffsetMillis":65160,"EndOffsetMillis":65550},{"Type":"punctuation","Confidence":0.0,"Content":"."}],"Id":"71c3be12-c86a-4f75-bb1e-1793cade7e07","BeginOffsetMillis":63240,"EndOffsetMillis":65550,"Sentiment":"POSITIVE","ParticipantRole":"CUSTOMER"},{"LoudnessScores":[73.03,67.21,44.82,85.17,89.62,86.56,85.54],"Content":"Okay perfect and quick security question, what city were you born in?","Items":[{"Type":"pronunciation","Confidence":0.8585,"Content":"Okay","BeginOffsetMillis":64440,"EndOffsetMillis":64650},{"Type":"pronunciation","Confidence":1.0,"Content":"perfect","BeginOffsetMillis":67140,"EndOffsetMillis":67750},{"Type":"pronunciation","Confidence":1.0,"Content":"and","BeginOffsetMillis":67770,"EndOffsetMillis":68130},{"Type":"pronunciation","Confidence":1.0,"Content":"quick","BeginOffsetMillis":68130,"EndOffsetMillis":68350},{"Type":"pronunciation","Confidence":1.0,"Content":"security","BeginOffsetMillis":68350,"EndOffsetMillis":68910},{"Type":"pronunciation","Confidence":1.0,"Content":"question","BeginOffsetMillis":68910,"EndOffsetMillis":69570},{"Type":"punctuation","Confidence":0.0,"Content":","},{"Type":"pronunciation","Confidence":1.0,"Content":"what","BeginOffsetMillis":69570,"EndOffsetMillis":69790},{"Type":"pronunciation","Confidence":1.0,"Content":"city","BeginOffsetMillis":69790,"EndOffsetMillis":70040},{"Type":"pronunciation","Confidence":1.0,"Content":"were","BeginOffsetMillis":70040,"EndOffsetMillis":70170},{"Type":"pronunciation","Confidence":1.0,"Content":"you","BeginOffsetMillis":70170,"EndOffsetMillis":70330},{"Type":"pronunciation","Confidence":1.0,"Content":"born","BeginOffsetMillis":70330,"EndOffsetMillis":70580},{"Type":"pronunciation","Confidence":1.0,"Content":"in","BeginOffsetMillis":70580,"EndOffsetMillis":70950},{"Type":"punctuation","Confidence":0.0,"Content":"?"}],"Id":"5c10cc22-56c0-442a-abdd-fd9a8f2dd695","BeginOffsetMillis":64440,"EndOffsetMillis":70950,"Sentiment":"POSITIVE","ParticipantRole":"AGENT"},{"LoudnessScores":[85.71,85.76],"Content":"Perfect","Items":[{"Type":"pronunciation","Confidence":0.999,"Content":"Perfect","BeginOffsetMillis":71840,"EndOffsetMillis":72260}],"Id":"3be00a2b-c972-4fc0-bcb5-17589aa53d34","BeginOffsetMillis":71840,"EndOffsetMillis":72260,"Sentiment":"POSITIVE","ParticipantRole":"CUSTOMER"},{"LoudnessScores":[87.48,86.05,88.74,84.68,86.38,89.49,86.58,87.68,89.09,88.9,91.01,89.89,88.0,81.27,69.1],"Content":"Okay there we go. I see that card in the system. Um Okay so you have two cards with us, Do you happen to know if it was the seaside Rewards Credit card or the day at the beach rewards Card?","Items":[{"Type":"pronunciation","Confidence":0.9948,"Content":"Okay","BeginOffsetMillis":73960,"EndOffsetMillis":74600},{"Type":"pronunciation","Confidence":0.9867,"Content":"there","BeginOffsetMillis":74750,"EndOffsetMillis":74970},{"Type":"pronunciation","Confidence":1.0,"Content":"we","BeginOffsetMillis":74970,"EndOffsetMillis":75070},{"Type":"pronunciation","Confidence":1.0,"Content":"go","BeginOffsetMillis":75070,"EndOffsetMillis":75270},{"Type":"punctuation","Confidence":0.0,"Content":"."},{"Type":"pronunciation","Confidence":1.0,"Content":"I","BeginOffsetMillis":75270,"EndOffsetMillis":75900},{"Type":"pronunciation","Confidence":1.0,"Content":"see","BeginOffsetMillis":75910,"EndOffsetMillis":76100},{"Type":"pronunciation","Confidence":1.0,"Content":"that","BeginOffsetMillis":76100,"EndOffsetMillis":76260},{"Type":"pronunciation","Confidence":1.0,"Content":"card","BeginOffsetMillis":76260,"EndOffsetMillis":76470},{"Type":"pronunciation","Confidence":0.9932,"Content":"in","BeginOffsetMillis":76470,"EndOffsetMillis":76540},{"Type":"pronunciation","Confidence":1.0,"Content":"the","BeginOffsetMillis":76540,"EndOffsetMillis":76630},{"Type":"pronunciation","Confidence":1.0,"Content":"system","BeginOffsetMillis":76630,"EndOffsetMillis":77250},{"Type":"punctuation","Confidence":0.0,"Content":"."},{"Type":"pronunciation","Confidence":0.9872,"Content":"Um","BeginOffsetMillis":77450,"EndOffsetMillis":77810},{"Type":"pronunciation","Confidence":0.952,"Content":"Okay","BeginOffsetMillis":77810,"EndOffsetMillis":78030},{"Type":"pronunciation","Confidence":1.0,"Content":"so","BeginOffsetMillis":78030,"EndOffsetMillis":78160},{"Type":"pronunciation","Confidence":1.0,"Content":"you","BeginOffsetMillis":78160,"EndOffsetMillis":78270},{"Type":"pronunciation","Confidence":1.0,"Content":"have","BeginOffsetMillis":78270,"EndOffsetMillis":78570},{"Type":"pronunciation","Confidence":1.0,"Content":"two","BeginOffsetMillis":78580,"EndOffsetMillis":78780},{"Type":"pronunciation","Confidence":1.0,"Content":"cards","BeginOffsetMillis":78780,"EndOffsetMillis":79100},{"Type":"pronunciation","Confidence":1.0,"Content":"with","BeginOffsetMillis":79100,"EndOffsetMillis":79250},{"Type":"pronunciation","Confidence":1.0,"Content":"us","BeginOffsetMillis":79250,"EndOffsetMillis":79810},{"Type":"punctuation","Confidence":0.0,"Content":","},{"Type":"pronunciation","Confidence":1.0,"Content":"Do","BeginOffsetMillis":79840,"EndOffsetMillis":79980},{"Type":"pronunciation","Confidence":1.0,"Content":"you","BeginOffsetMillis":79980,"EndOffsetMillis":80060},{"Type":"pronunciation","Confidence":1.0,"Content":"happen","BeginOffsetMillis":80060,"EndOffsetMillis":80280},{"Type":"pronunciation","Confidence":1.0,"Content":"to","BeginOffsetMillis":80280,"EndOffsetMillis":80390},{"Type":"pronunciation","Confidence":1.0,"Content":"know","BeginOffsetMillis":80390,"EndOffsetMillis":80660},{"Type":"pronunciation","Confidence":1.0,"Content":"if","BeginOffsetMillis":80660,"EndOffsetMillis":80900},{"Type":"pronunciation","Confidence":1.0,"Content":"it","BeginOffsetMillis":80900,"EndOffsetMillis":81090},{"Type":"pronunciation","Confidence":1.0,"Content":"was","BeginOffsetMillis":81090,"EndOffsetMillis":81570},{"Type":"pronunciation","Confidence":1.0,"Content":"the","BeginOffsetMillis":81580,"EndOffsetMillis":82410},{"Type":"pronunciation","Confidence":0.9918,"Content":"seaside","BeginOffsetMillis":82420,"EndOffsetMillis":83020},{"Type":"pronunciation","Confidence":0.9984,"Content":"Rewards","BeginOffsetMillis":83020,"EndOffsetMillis":83540},{"Type":"pronunciation","Confidence":1.0,"Content":"Credit","BeginOffsetMillis":83540,"EndOffsetMillis":83810},{"Type":"pronunciation","Confidence":1.0,"Content":"card","BeginOffsetMillis":83810,"EndOffsetMillis":84370},{"Type":"pronunciation","Confidence":1.0,"Content":"or","BeginOffsetMillis":84380,"EndOffsetMillis":84860},{"Type":"pronunciation","Confidence":1.0,"Content":"the","BeginOffsetMillis":84860,"EndOffsetMillis":85290},{"Type":"pronunciation","Confidence":0.9655,"Content":"day","BeginOffsetMillis":85290,"EndOffsetMillis":85500},{"Type":"pronunciation","Confidence":0.9675,"Content":"at","BeginOffsetMillis":85500,"EndOffsetMillis":85590},{"Type":"pronunciation","Confidence":1.0,"Content":"the","BeginOffsetMillis":85590,"EndOffsetMillis":85700},{"Type":"pronunciation","Confidence":1.0,"Content":"beach","BeginOffsetMillis":85700,"EndOffsetMillis":86270},{"Type":"pronunciation","Confidence":0.9774,"Content":"rewards","BeginOffsetMillis":86280,"EndOffsetMillis":86730},{"Type":"pronunciation","Confidence":1.0,"Content":"Card","BeginOffsetMillis":86730,"EndOffsetMillis":87160},{"Type":"punctuation","Confidence":0.0,"Content":"?"}],"Id":"5e33af38-0108-43a1-a9aa-ac0cebede640","BeginOffsetMillis":73960,"EndOffsetMillis":87160,"Sentiment":"NEUTRAL","ParticipantRole":"AGENT"},{"LoudnessScores":[85.86,84.57,86.08],"Content":"day of the beaches.","Items":[{"Type":"pronunciation","Confidence":0.9261,"Content":"day","BeginOffsetMillis":88940,"EndOffsetMillis":89800},{"Type":"pronunciation","Confidence":0.9309,"Content":"of","BeginOffsetMillis":89800,"EndOffsetMillis":89920},{"Type":"pronunciation","Confidence":1.0,"Content":"the","BeginOffsetMillis":89920,"EndOffsetMillis":90010},{"Type":"pronunciation","Confidence":0.7297,"Content":"beaches","BeginOffsetMillis":90010,"EndOffsetMillis":90360},{"Type":"punctuation","Confidence":0.0,"Content":"."}],"Id":"2df895f8-9744-4b5e-837f-7ece61883362","BeginOffsetMillis":88940,"EndOffsetMillis":90360,"Sentiment":"NEUTRAL","ParticipantRole":"CUSTOMER"},{"LoudnessScores":[71.49,81.7],"Content":"Yeah yes.","Items":[{"Type":"pronunciation","Confidence":0.9562,"Content":"Yeah","BeginOffsetMillis":90040,"EndOffsetMillis":90250},{"Type":"pronunciation","Confidence":1.0,"Content":"yes","BeginOffsetMillis":91540,"EndOffsetMillis":91960},{"Type":"punctuation","Confidence":0.0,"Content":"."}],"Id":"823ccfef-da0d-4b72-9911-2d3b013854f1","BeginOffsetMillis":90040,"EndOffsetMillis":91960,"Sentiment":"NEUTRAL","ParticipantRole":"AGENT"},{"LoudnessScores":[86.08,85.55,83.21,82.66],"Content":"The red card, It was the Red one.","Items":[{"Type":"pronunciation","Confidence":1.0,"Content":"The","BeginOffsetMillis":90360,"EndOffsetMillis":90470},{"Type":"pronunciation","Confidence":0.9974,"Content":"red","BeginOffsetMillis":90470,"EndOffsetMillis":90670},{"Type":"pronunciation","Confidence":1.0,"Content":"card","BeginOffsetMillis":90670,"EndOffsetMillis":91160},{"Type":"punctuation","Confidence":0.0,"Content":","},{"Type":"pronunciation","Confidence":0.9979,"Content":"It","BeginOffsetMillis":92440,"EndOffsetMillis":92530},{"Type":"pronunciation","Confidence":1.0,"Content":"was","BeginOffsetMillis":92530,"EndOffsetMillis":92670},{"Type":"pronunciation","Confidence":1.0,"Content":"the","BeginOffsetMillis":92670,"EndOffsetMillis":92790},{"Type":"pronunciation","Confidence":0.9994,"Content":"Red","BeginOffsetMillis":92790,"EndOffsetMillis":93000},{"Type":"pronunciation","Confidence":1.0,"Content":"one","BeginOffsetMillis":93000,"EndOffsetMillis":93360},{"Type":"punctuation","Confidence":0.0,"Content":"."}],"Id":"f0b10f61-b63c-4516-b5bb-363b1562bc85","BeginOffsetMillis":90360,"EndOffsetMillis":93360,"Sentiment":"NEUTRAL","ParticipantRole":"CUSTOMER"},{"LoudnessScores":[80.44,79.81,89.49,88.22,84.99,86.26,87.61,92.1,89.78,86.63],"Content":"Okay so I'll go ahead and get that one canceled, just in case someone fish it out the fishman terminal waters. Uh Would you like me to get another one sent to you?","Items":[{"Type":"pronunciation","Confidence":0.9618,"Content":"Okay","BeginOffsetMillis":93740,"EndOffsetMillis":94160},{"Type":"pronunciation","Confidence":0.8813,"Content":"so","BeginOffsetMillis":94940,"EndOffsetMillis":95050},{"Type":"pronunciation","Confidence":0.8888,"Content":"I'll","BeginOffsetMillis":95050,"EndOffsetMillis":95150},{"Type":"pronunciation","Confidence":1.0,"Content":"go","BeginOffsetMillis":95150,"EndOffsetMillis":95340},{"Type":"pronunciation","Confidence":1.0,"Content":"ahead","BeginOffsetMillis":95340,"EndOffsetMillis":95720},{"Type":"pronunciation","Confidence":1.0,"Content":"and","BeginOffsetMillis":95720,"EndOffsetMillis":96140},{"Type":"pronunciation","Confidence":1.0,"Content":"get","BeginOffsetMillis":96140,"EndOffsetMillis":96280},{"Type":"pronunciation","Confidence":1.0,"Content":"that","BeginOffsetMillis":96280,"EndOffsetMillis":96400},{"Type":"pronunciation","Confidence":1.0,"Content":"one","BeginOffsetMillis":96400,"EndOffsetMillis":96580},{"Type":"pronunciation","Confidence":0.9615,"Content":"canceled","BeginOffsetMillis":96580,"EndOffsetMillis":97090},{"Type":"punctuation","Confidence":0.0,"Content":","},{"Type":"pronunciation","Confidence":1.0,"Content":"just","BeginOffsetMillis":97090,"EndOffsetMillis":97310},{"Type":"pronunciation","Confidence":1.0,"Content":"in","BeginOffsetMillis":97310,"EndOffsetMillis":97420},{"Type":"pronunciation","Confidence":1.0,"Content":"case","BeginOffsetMillis":97420,"EndOffsetMillis":97670},{"Type":"pronunciation","Confidence":0.9875,"Content":"someone","BeginOffsetMillis":97670,"EndOffsetMillis":97970},{"Type":"pronunciation","Confidence":0.9606,"Content":"fish","BeginOffsetMillis":97970,"EndOffsetMillis":98210},{"Type":"pronunciation","Confidence":0.9683,"Content":"it","BeginOffsetMillis":98210,"EndOffsetMillis":98330},{"Type":"pronunciation","Confidence":0.9024,"Content":"out","BeginOffsetMillis":98330,"EndOffsetMillis":98430},{"Type":"pronunciation","Confidence":0.786,"Content":"the","BeginOffsetMillis":98430,"EndOffsetMillis":98590},{"Type":"pronunciation","Confidence":0.5759,"Content":"fishman","BeginOffsetMillis":98590,"EndOffsetMillis":98970},{"Type":"pronunciation","Confidence":1.0,"Content":"terminal","BeginOffsetMillis":98970,"EndOffsetMillis":99430},{"Type":"pronunciation","Confidence":0.8977,"Content":"waters","BeginOffsetMillis":99430,"EndOffsetMillis":100040},{"Type":"punctuation","Confidence":0.0,"Content":"."},{"Type":"pronunciation","Confidence":0.9976,"Content":"Uh","BeginOffsetMillis":100230,"EndOffsetMillis":100810},{"Type":"pronunciation","Confidence":1.0,"Content":"Would","BeginOffsetMillis":100810,"EndOffsetMillis":100960},{"Type":"pronunciation","Confidence":1.0,"Content":"you","BeginOffsetMillis":100960,"EndOffsetMillis":101030},{"Type":"pronunciation","Confidence":1.0,"Content":"like","BeginOffsetMillis":101030,"EndOffsetMillis":101190},{"Type":"pronunciation","Confidence":1.0,"Content":"me","BeginOffsetMillis":101190,"EndOffsetMillis":101260},{"Type":"pronunciation","Confidence":1.0,"Content":"to","BeginOffsetMillis":101260,"EndOffsetMillis":101340},{"Type":"pronunciation","Confidence":1.0,"Content":"get","BeginOffsetMillis":101340,"EndOffsetMillis":101460},{"Type":"pronunciation","Confidence":1.0,"Content":"another","BeginOffsetMillis":101460,"EndOffsetMillis":101700},{"Type":"pronunciation","Confidence":1.0,"Content":"one","BeginOffsetMillis":101700,"EndOffsetMillis":101860},{"Type":"pronunciation","Confidence":1.0,"Content":"sent","BeginOffsetMillis":101860,"EndOffsetMillis":102090},{"Type":"pronunciation","Confidence":1.0,"Content":"to","BeginOffsetMillis":102090,"EndOffsetMillis":102190},{"Type":"pronunciation","Confidence":0.9997,"Content":"you","BeginOffsetMillis":102190,"EndOffsetMillis":102640},{"Type":"punctuation","Confidence":0.0,"Content":"?"}],"Id":"19c47c37-3131-4b85-b7b8-a6f146dfdc3c","BeginOffsetMillis":93740,"EndOffsetMillis":102640,"Sentiment":"NEGATIVE","ParticipantRole":"AGENT"},{"LoudnessScores":[89.42,85.28,74.16],"Content":"That would be awesome. Thank you so much.","Items":[{"Type":"pronunciation","Confidence":0.9788,"Content":"That","BeginOffsetMillis":103240,"EndOffsetMillis":103470},{"Type":"pronunciation","Confidence":0.9788,"Content":"would","BeginOffsetMillis":103470,"EndOffsetMillis":103570},{"Type":"pronunciation","Confidence":1.0,"Content":"be","BeginOffsetMillis":103570,"EndOffsetMillis":103710},{"Type":"pronunciation","Confidence":1.0,"Content":"awesome","BeginOffsetMillis":103710,"EndOffsetMillis":104210},{"Type":"punctuation","Confidence":0.0,"Content":"."},{"Type":"pronunciation","Confidence":1.0,"Content":"Thank","BeginOffsetMillis":104210,"EndOffsetMillis":104450},{"Type":"pronunciation","Confidence":1.0,"Content":"you","BeginOffsetMillis":104450,"EndOffsetMillis":104560},{"Type":"pronunciation","Confidence":1.0,"Content":"so","BeginOffsetMillis":104560,"EndOffsetMillis":104710},{"Type":"pronunciation","Confidence":1.0,"Content":"much","BeginOffsetMillis":104710,"EndOffsetMillis":105060},{"Type":"punctuation","Confidence":0.0,"Content":"."}],"Id":"cd6e3362-775e-4560-8e6c-6f7685f7c03a","BeginOffsetMillis":103240,"EndOffsetMillis":105060,"Sentiment":"POSITIVE","ParticipantRole":"CUSTOMER"},{"LoudnessScores":[83.93,89.51,88.46,84.94,85.83,87.78,87.94,88.9,88.33,86.15,85.59,87.6,84.47],"Content":"Great um so I have a couple of of addresses for you. I have like a home mailing address. I also have a mailbox at a fishing terminal and Alaska which one's gonna be the most convenient for you,","Items":[{"Type":"pronunciation","Confidence":1.0,"Content":"Great","BeginOffsetMillis":105640,"EndOffsetMillis":106150},{"Type":"pronunciation","Confidence":0.9931,"Content":"um","BeginOffsetMillis":106540,"EndOffsetMillis":106870},{"Type":"pronunciation","Confidence":1.0,"Content":"so","BeginOffsetMillis":106870,"EndOffsetMillis":106990},{"Type":"pronunciation","Confidence":1.0,"Content":"I","BeginOffsetMillis":106990,"EndOffsetMillis":107050},{"Type":"pronunciation","Confidence":1.0,"Content":"have","BeginOffsetMillis":107050,"EndOffsetMillis":107210},{"Type":"pronunciation","Confidence":1.0,"Content":"a","BeginOffsetMillis":107210,"EndOffsetMillis":107290},{"Type":"pronunciation","Confidence":1.0,"Content":"couple","BeginOffsetMillis":107290,"EndOffsetMillis":107510},{"Type":"pronunciation","Confidence":1.0,"Content":"of","BeginOffsetMillis":107510,"EndOffsetMillis":107820},{"Type":"pronunciation","Confidence":0.9989,"Content":"of","BeginOffsetMillis":107830,"EndOffsetMillis":108120},{"Type":"pronunciation","Confidence":0.998,"Content":"addresses","BeginOffsetMillis":108120,"EndOffsetMillis":108890},{"Type":"pronunciation","Confidence":1.0,"Content":"for","BeginOffsetMillis":108890,"EndOffsetMillis":109060},{"Type":"pronunciation","Confidence":0.9946,"Content":"you","BeginOffsetMillis":109060,"EndOffsetMillis":109500},{"Type":"punctuation","Confidence":0.0,"Content":"."},{"Type":"pronunciation","Confidence":1.0,"Content":"I","BeginOffsetMillis":109500,"EndOffsetMillis":109680},{"Type":"pronunciation","Confidence":1.0,"Content":"have","BeginOffsetMillis":109680,"EndOffsetMillis":109880},{"Type":"pronunciation","Confidence":1.0,"Content":"like","BeginOffsetMillis":109880,"EndOffsetMillis":110030},{"Type":"pronunciation","Confidence":1.0,"Content":"a","BeginOffsetMillis":110030,"EndOffsetMillis":110110},{"Type":"pronunciation","Confidence":0.9897,"Content":"home","BeginOffsetMillis":110110,"EndOffsetMillis":110470},{"Type":"pronunciation","Confidence":1.0,"Content":"mailing","BeginOffsetMillis":110470,"EndOffsetMillis":110810},{"Type":"pronunciation","Confidence":1.0,"Content":"address","BeginOffsetMillis":110810,"EndOffsetMillis":111400},{"Type":"punctuation","Confidence":0.0,"Content":"."},{"Type":"pronunciation","Confidence":1.0,"Content":"I","BeginOffsetMillis":111590,"EndOffsetMillis":111850},{"Type":"pronunciation","Confidence":1.0,"Content":"also","BeginOffsetMillis":111860,"EndOffsetMillis":112540},{"Type":"pronunciation","Confidence":1.0,"Content":"have","BeginOffsetMillis":112540,"EndOffsetMillis":113050},{"Type":"pronunciation","Confidence":1.0,"Content":"a","BeginOffsetMillis":113060,"EndOffsetMillis":113340},{"Type":"pronunciation","Confidence":1.0,"Content":"mailbox","BeginOffsetMillis":113340,"EndOffsetMillis":114180},{"Type":"pronunciation","Confidence":1.0,"Content":"at","BeginOffsetMillis":114190,"EndOffsetMillis":114390},{"Type":"pronunciation","Confidence":0.998,"Content":"a","BeginOffsetMillis":114390,"EndOffsetMillis":114470},{"Type":"pronunciation","Confidence":0.9838,"Content":"fishing","BeginOffsetMillis":114470,"EndOffsetMillis":114790},{"Type":"pronunciation","Confidence":1.0,"Content":"terminal","BeginOffsetMillis":114790,"EndOffsetMillis":115220},{"Type":"pronunciation","Confidence":0.8398,"Content":"and","BeginOffsetMillis":115220,"EndOffsetMillis":115360},{"Type":"pronunciation","Confidence":1.0,"Content":"Alaska","BeginOffsetMillis":115360,"EndOffsetMillis":116200},{"Type":"pronunciation","Confidence":1.0,"Content":"which","BeginOffsetMillis":116200,"EndOffsetMillis":116370},{"Type":"pronunciation","Confidence":0.5333,"Content":"one's","BeginOffsetMillis":116370,"EndOffsetMillis":116500},{"Type":"pronunciation","Confidence":0.9481,"Content":"gonna","BeginOffsetMillis":116500,"EndOffsetMillis":116650},{"Type":"pronunciation","Confidence":1.0,"Content":"be","BeginOffsetMillis":116650,"EndOffsetMillis":116710},{"Type":"pronunciation","Confidence":0.9267,"Content":"the","BeginOffsetMillis":116710,"EndOffsetMillis":116770},{"Type":"pronunciation","Confidence":1.0,"Content":"most","BeginOffsetMillis":116770,"EndOffsetMillis":116950},{"Type":"pronunciation","Confidence":1.0,"Content":"convenient","BeginOffsetMillis":116950,"EndOffsetMillis":117450},{"Type":"pronunciation","Confidence":1.0,"Content":"for","BeginOffsetMillis":117450,"EndOffsetMillis":117550},{"Type":"pronunciation","Confidence":1.0,"Content":"you","BeginOffsetMillis":117550,"EndOffsetMillis":117850},{"Type":"punctuation","Confidence":0.0,"Content":","}],"Id":"e00f07f8-4a08-47d7-99b3-e9aee2ce1b50","BeginOffsetMillis":105640,"EndOffsetMillis":117850,"Sentiment":"POSITIVE","ParticipantRole":"AGENT"},{"LoudnessScores":[84.99,87.15,83.11,83.43,86.27,87.35,85.8,87.6,85.1],"Content":"I'm actually in route right now to [PII]. Uh to dock up there. Can I give you a P. O. Box?","Redaction":{"RedactedTimestamps":[{"BeginOffsetMillis":120870,"EndOffsetMillis":122050}]},"Items":[{"Type":"pronunciation","Confidence":1.0,"Content":"I'm","BeginOffsetMillis":118840,"EndOffsetMillis":119030},{"Type":"pronunciation","Confidence":1.0,"Content":"actually","BeginOffsetMillis":119030,"EndOffsetMillis":119530},{"Type":"pronunciation","Confidence":1.0,"Content":"in","BeginOffsetMillis":119540,"EndOffsetMillis":119870},{"Type":"pronunciation","Confidence":0.8059,"Content":"route","BeginOffsetMillis":119870,"EndOffsetMillis":120150},{"Type":"pronunciation","Confidence":1.0,"Content":"right","BeginOffsetMillis":120150,"EndOffsetMillis":120440},{"Type":"pronunciation","Confidence":1.0,"Content":"now","BeginOffsetMillis":120440,"EndOffsetMillis":120750},{"Type":"pronunciation","Confidence":1.0,"Content":"to","BeginOffsetMillis":120750,"EndOffsetMillis":120870},{"Type":"pronunciation","Content":"[PII]","Redaction":[{"Confidence":0.9977}],"BeginOffsetMillis":120870,"EndOffsetMillis":122050},{"Type":"punctuation","Confidence":0.0,"Content":"."},{"Type":"pronunciation","Confidence":0.9705,"Content":"Uh","BeginOffsetMillis":122440,"EndOffsetMillis":123010},{"Type":"pronunciation","Confidence":1.0,"Content":"to","BeginOffsetMillis":123020,"EndOffsetMillis":123330},{"Type":"pronunciation","Confidence":0.6285,"Content":"dock","BeginOffsetMillis":123330,"EndOffsetMillis":123620},{"Type":"pronunciation","Confidence":1.0,"Content":"up","BeginOffsetMillis":123620,"EndOffsetMillis":123790},{"Type":"pronunciation","Confidence":0.9587,"Content":"there","BeginOffsetMillis":123790,"EndOffsetMillis":124050},{"Type":"punctuation","Confidence":0.0,"Content":"."},{"Type":"pronunciation","Confidence":1.0,"Content":"Can","BeginOffsetMillis":124050,"EndOffsetMillis":124250},{"Type":"pronunciation","Confidence":1.0,"Content":"I","BeginOffsetMillis":124250,"EndOffsetMillis":124400},{"Type":"pronunciation","Confidence":1.0,"Content":"give","BeginOffsetMillis":124400,"EndOffsetMillis":124660},{"Type":"pronunciation","Confidence":1.0,"Content":"you","BeginOffsetMillis":124660,"EndOffsetMillis":125010},{"Type":"pronunciation","Confidence":1.0,"Content":"a","BeginOffsetMillis":125020,"EndOffsetMillis":125540},{"Type":"pronunciation","Confidence":1.0,"Content":"P","BeginOffsetMillis":125550,"EndOffsetMillis":125760},{"Type":"punctuation","Confidence":0.0,"Content":"."},{"Type":"pronunciation","Confidence":1.0,"Content":"O","BeginOffsetMillis":125760,"EndOffsetMillis":125830},{"Type":"punctuation","Confidence":0.0,"Content":"."},{"Type":"pronunciation","Confidence":1.0,"Content":"Box","BeginOffsetMillis":125830,"EndOffsetMillis":126250},{"Type":"punctuation","Confidence":0.0,"Content":"?"}],"Id":"6576e1ed-6543-408d-9b97-20b7d720237a","BeginOffsetMillis":118840,"EndOffsetMillis":126250,"Sentiment":"NEUTRAL","ParticipantRole":"CUSTOMER"},{"LoudnessScores":[88.06,90.55,86.64,73.35],"Content":"yep that will work just fine, go ahead. I'm ready when you are.","Items":[{"Type":"pronunciation","Confidence":0.7035,"Content":"yep","BeginOffsetMillis":126840,"EndOffsetMillis":127140},{"Type":"pronunciation","Confidence":0.9902,"Content":"that","BeginOffsetMillis":127140,"EndOffsetMillis":127260},{"Type":"pronunciation","Confidence":0.9853,"Content":"will","BeginOffsetMillis":127260,"EndOffsetMillis":127380},{"Type":"pronunciation","Confidence":1.0,"Content":"work","BeginOffsetMillis":127380,"EndOffsetMillis":127550},{"Type":"pronunciation","Confidence":1.0,"Content":"just","BeginOffsetMillis":127550,"EndOffsetMillis":127750},{"Type":"pronunciation","Confidence":1.0,"Content":"fine","BeginOffsetMillis":127750,"EndOffsetMillis":127990},{"Type":"punctuation","Confidence":0.0,"Content":","},{"Type":"pronunciation","Confidence":1.0,"Content":"go","BeginOffsetMillis":127990,"EndOffsetMillis":128110},{"Type":"pronunciation","Confidence":1.0,"Content":"ahead","BeginOffsetMillis":128110,"EndOffsetMillis":128389},{"Type":"punctuation","Confidence":0.0,"Content":"."},{"Type":"pronunciation","Confidence":1.0,"Content":"I'm","BeginOffsetMillis":128400,"EndOffsetMillis":128539},{"Type":"pronunciation","Confidence":1.0,"Content":"ready","BeginOffsetMillis":128539,"EndOffsetMillis":128710},{"Type":"pronunciation","Confidence":1.0,"Content":"when","BeginOffsetMillis":128710,"EndOffsetMillis":128810},{"Type":"pronunciation","Confidence":1.0,"Content":"you","BeginOffsetMillis":128810,"EndOffsetMillis":128930},{"Type":"pronunciation","Confidence":1.0,"Content":"are","BeginOffsetMillis":128930,"EndOffsetMillis":129150},{"Type":"punctuation","Confidence":0.0,"Content":"."}],"Id":"0ef6c4f3-f3a3-4f11-a65b-9e5ad12ef55d","BeginOffsetMillis":126840,"EndOffsetMillis":129150,"Sentiment":"POSITIVE","ParticipantRole":"AGENT"},{"LoudnessScores":[80.8,77.46,89.63,86.33,88.43,81.95],"Content":"Great [PII],","Redaction":{"RedactedTimestamps":[{"BeginOffsetMillis":130840,"EndOffsetMillis":134050}]},"Items":[{"Type":"pronunciation","Confidence":1.0,"Content":"Great","BeginOffsetMillis":129639,"EndOffsetMillis":130060},{"Type":"pronunciation","Content":"[PII]","Redaction":[{"Confidence":1.0}],"BeginOffsetMillis":130840,"EndOffsetMillis":134050},{"Type":"punctuation","Confidence":0.0,"Content":","}],"Id":"2331aca7-7ccf-4052-bf93-79c8ba17985e","BeginOffsetMillis":129639,"EndOffsetMillis":134050,"Sentiment":"POSITIVE","ParticipantRole":"CUSTOMER"},{"LoudnessScores":[80.44,80.34,80.54,88.05,89.17,87.96,88.66,88.65,88.33,87.99,86.23,85.99,85.1],"ActionItemsDetected":[{"CharacterOffsets":{"Begin":23,"End":64}}],"Content":"Okay I've got that and I will email you a confirmation is [PII]. Still the email address for you.","Redaction":{"RedactedTimestamps":[{"BeginOffsetMillis":141250,"EndOffsetMillis":145110}]},"Items":[{"Type":"pronunciation","Confidence":0.9618,"Content":"Okay","BeginOffsetMillis":134840,"EndOffsetMillis":135260},{"Type":"pronunciation","Confidence":0.9977,"Content":"I've","BeginOffsetMillis":136540,"EndOffsetMillis":137020},{"Type":"pronunciation","Confidence":1.0,"Content":"got","BeginOffsetMillis":137020,"EndOffsetMillis":137210},{"Type":"pronunciation","Confidence":1.0,"Content":"that","BeginOffsetMillis":137210,"EndOffsetMillis":137500},{"Type":"pronunciation","Confidence":1.0,"Content":"and","BeginOffsetMillis":137500,"EndOffsetMillis":137760},{"Type":"pronunciation","Confidence":1.0,"Content":"I","BeginOffsetMillis":137760,"EndOffsetMillis":137910},{"Type":"pronunciation","Confidence":1.0,"Content":"will","BeginOffsetMillis":137910,"EndOffsetMillis":138080},{"Type":"pronunciation","Confidence":1.0,"Content":"email","BeginOffsetMillis":138080,"EndOffsetMillis":138430},{"Type":"pronunciation","Confidence":1.0,"Content":"you","BeginOffsetMillis":138430,"EndOffsetMillis":138690},{"Type":"pronunciation","Confidence":1.0,"Content":"a","BeginOffsetMillis":138690,"EndOffsetMillis":138860},{"Type":"pronunciation","Confidence":1.0,"Content":"confirmation","BeginOffsetMillis":138860,"EndOffsetMillis":140010},{"Type":"pronunciation","Confidence":1.0,"Content":"is","BeginOffsetMillis":140220,"EndOffsetMillis":141250},{"Type":"pronunciation","Content":"[PII]","Redaction":[{"Confidence":0.9997}],"BeginOffsetMillis":141250,"EndOffsetMillis":145110},{"Type":"punctuation","Confidence":0.0,"Content":"."},{"Type":"pronunciation","Confidence":1.0,"Content":"Still","BeginOffsetMillis":145110,"EndOffsetMillis":145360},{"Type":"pronunciation","Confidence":1.0,"Content":"the","BeginOffsetMillis":145360,"EndOffsetMillis":145480},{"Type":"pronunciation","Confidence":1.0,"Content":"email","BeginOffsetMillis":145480,"EndOffsetMillis":145710},{"Type":"pronunciation","Confidence":1.0,"Content":"address","BeginOffsetMillis":145710,"EndOffsetMillis":145980},{"Type":"pronunciation","Confidence":1.0,"Content":"for","BeginOffsetMillis":145980,"EndOffsetMillis":146080},{"Type":"pronunciation","Confidence":1.0,"Content":"you","BeginOffsetMillis":146080,"EndOffsetMillis":146460},{"Type":"punctuation","Confidence":0.0,"Content":"."}],"Id":"55e02bce-c970-4cea-aecd-a72f22aff431","BeginOffsetMillis":134840,"EndOffsetMillis":146460,"Sentiment":"NEUTRAL","ParticipantRole":"AGENT"},{"LoudnessScores":[83.22],"Content":"correct?","Items":[{"Type":"pronunciation","Confidence":1.0,"Content":"correct","BeginOffsetMillis":147240,"EndOffsetMillis":147660},{"Type":"punctuation","Confidence":0.0,"Content":"?"}],"Id":"74d417ce-9a9b-40af-8c2e-daea1a6ccc7b","BeginOffsetMillis":147240,"EndOffsetMillis":147660,"Sentiment":"NEUTRAL","ParticipantRole":"CUSTOMER"},{"LoudnessScores":[86.95,86.71,88.84,87.09,88.53,85.9,85.52,87.68,84.85],"Content":"Okay great so to review we've got that card canceled, we're sending it to the [PII] and I've emailed you a confirmation. Is that everything that you needed today?","Redaction":{"RedactedTimestamps":[{"BeginOffsetMillis":151330,"EndOffsetMillis":153180}]},"OutcomesDetected":[{"CharacterOffsets":{"Begin":17,"End":120}}],"Items":[{"Type":"pronunciation","Confidence":0.9137,"Content":"Okay","BeginOffsetMillis":148140,"EndOffsetMillis":148460},{"Type":"pronunciation","Confidence":0.9625,"Content":"great","BeginOffsetMillis":148460,"EndOffsetMillis":148740},{"Type":"pronunciation","Confidence":1.0,"Content":"so","BeginOffsetMillis":148750,"EndOffsetMillis":148970},{"Type":"pronunciation","Confidence":0.9968,"Content":"to","BeginOffsetMillis":148970,"EndOffsetMillis":149060},{"Type":"pronunciation","Confidence":1.0,"Content":"review","BeginOffsetMillis":149060,"EndOffsetMillis":149290},{"Type":"pronunciation","Confidence":0.9984,"Content":"we've","BeginOffsetMillis":149290,"EndOffsetMillis":149430},{"Type":"pronunciation","Confidence":1.0,"Content":"got","BeginOffsetMillis":149430,"EndOffsetMillis":149560},{"Type":"pronunciation","Confidence":1.0,"Content":"that","BeginOffsetMillis":149560,"EndOffsetMillis":149680},{"Type":"pronunciation","Confidence":1.0,"Content":"card","BeginOffsetMillis":149680,"EndOffsetMillis":149910},{"Type":"pronunciation","Confidence":0.8368,"Content":"canceled","BeginOffsetMillis":149910,"EndOffsetMillis":150440},{"Type":"punctuation","Confidence":0.0,"Content":","},{"Type":"pronunciation","Confidence":0.9975,"Content":"we're","BeginOffsetMillis":150450,"EndOffsetMillis":150680},{"Type":"pronunciation","Confidence":1.0,"Content":"sending","BeginOffsetMillis":150680,"EndOffsetMillis":150960},{"Type":"pronunciation","Confidence":1.0,"Content":"it","BeginOffsetMillis":150960,"EndOffsetMillis":151100},{"Type":"pronunciation","Confidence":1.0,"Content":"to","BeginOffsetMillis":151100,"EndOffsetMillis":151210},{"Type":"pronunciation","Confidence":1.0,"Content":"the","BeginOffsetMillis":151210,"EndOffsetMillis":151330},{"Type":"pronunciation","Content":"[PII]","Redaction":[{"Confidence":0.9996}],"BeginOffsetMillis":151330,"EndOffsetMillis":153180},{"Type":"pronunciation","Confidence":1.0,"Content":"and","BeginOffsetMillis":153210,"EndOffsetMillis":153540},{"Type":"pronunciation","Confidence":0.9862,"Content":"I've","BeginOffsetMillis":153540,"EndOffsetMillis":153750},{"Type":"pronunciation","Confidence":1.0,"Content":"emailed","BeginOffsetMillis":153760,"EndOffsetMillis":154110},{"Type":"pronunciation","Confidence":1.0,"Content":"you","BeginOffsetMillis":154110,"EndOffsetMillis":154210},{"Type":"pronunciation","Confidence":1.0,"Content":"a","BeginOffsetMillis":154210,"EndOffsetMillis":154300},{"Type":"pronunciation","Confidence":1.0,"Content":"confirmation","BeginOffsetMillis":154300,"EndOffsetMillis":154880},{"Type":"punctuation","Confidence":0.0,"Content":"."},{"Type":"pronunciation","Confidence":0.9978,"Content":"Is","BeginOffsetMillis":154880,"EndOffsetMillis":154980},{"Type":"pronunciation","Confidence":1.0,"Content":"that","BeginOffsetMillis":154980,"EndOffsetMillis":155110},{"Type":"pronunciation","Confidence":1.0,"Content":"everything","BeginOffsetMillis":155110,"EndOffsetMillis":155470},{"Type":"pronunciation","Confidence":1.0,"Content":"that","BeginOffsetMillis":155470,"EndOffsetMillis":155590},{"Type":"pronunciation","Confidence":1.0,"Content":"you","BeginOffsetMillis":155590,"EndOffsetMillis":155670},{"Type":"pronunciation","Confidence":1.0,"Content":"needed","BeginOffsetMillis":155670,"EndOffsetMillis":155930},{"Type":"pronunciation","Confidence":1.0,"Content":"today","BeginOffsetMillis":155930,"EndOffsetMillis":156360},{"Type":"punctuation","Confidence":0.0,"Content":"?"}],"Id":"d02d6c32-0ab2-4330-b5d0-14169e2c91cf","BeginOffsetMillis":148140,"EndOffsetMillis":156360,"Sentiment":"NEUTRAL","ParticipantRole":"AGENT"},{"LoudnessScores":[87.19,87.58,83.37,83.17,83.71],"Content":"That is also um my pronouns have changed since we spoke with last.","Items":[{"Type":"pronunciation","Confidence":1.0,"Content":"That","BeginOffsetMillis":157340,"EndOffsetMillis":157560},{"Type":"pronunciation","Confidence":1.0,"Content":"is","BeginOffsetMillis":157560,"EndOffsetMillis":157780},{"Type":"pronunciation","Confidence":1.0,"Content":"also","BeginOffsetMillis":157790,"EndOffsetMillis":158350},{"Type":"pronunciation","Confidence":0.9678,"Content":"um","BeginOffsetMillis":158350,"EndOffsetMillis":158850},{"Type":"pronunciation","Confidence":1.0,"Content":"my","BeginOffsetMillis":158860,"EndOffsetMillis":159180},{"Type":"pronunciation","Confidence":0.994,"Content":"pronouns","BeginOffsetMillis":159180,"EndOffsetMillis":159770},{"Type":"pronunciation","Confidence":0.9901,"Content":"have","BeginOffsetMillis":159770,"EndOffsetMillis":159940},{"Type":"pronunciation","Confidence":1.0,"Content":"changed","BeginOffsetMillis":159940,"EndOffsetMillis":160420},{"Type":"pronunciation","Confidence":1.0,"Content":"since","BeginOffsetMillis":160420,"EndOffsetMillis":160700},{"Type":"pronunciation","Confidence":1.0,"Content":"we","BeginOffsetMillis":160700,"EndOffsetMillis":160840},{"Type":"pronunciation","Confidence":1.0,"Content":"spoke","BeginOffsetMillis":160840,"EndOffsetMillis":161120},{"Type":"pronunciation","Confidence":1.0,"Content":"with","BeginOffsetMillis":161120,"EndOffsetMillis":161440},{"Type":"pronunciation","Confidence":0.9993,"Content":"last","BeginOffsetMillis":161450,"EndOffsetMillis":161860},{"Type":"punctuation","Confidence":0.0,"Content":"."}],"Id":"a35983bc-7409-4333-96d5-86d5875cc8ad","BeginOffsetMillis":157340,"EndOffsetMillis":161860,"Sentiment":"NEUTRAL","ParticipantRole":"CUSTOMER"},{"LoudnessScores":[60.15,57.64,30.01,28.41,26.98,86.44,86.59],"Content":"Mhm. Thank you for letting me know.","Items":[{"Type":"pronunciation","Confidence":0.2816,"Content":"Mhm","BeginOffsetMillis":157640,"EndOffsetMillis":157850},{"Type":"punctuation","Confidence":0.0,"Content":"."},{"Type":"pronunciation","Confidence":1.0,"Content":"Thank","BeginOffsetMillis":162240,"EndOffsetMillis":162670},{"Type":"pronunciation","Confidence":1.0,"Content":"you","BeginOffsetMillis":162670,"EndOffsetMillis":162730},{"Type":"pronunciation","Confidence":1.0,"Content":"for","BeginOffsetMillis":162730,"EndOffsetMillis":162810},{"Type":"pronunciation","Confidence":1.0,"Content":"letting","BeginOffsetMillis":162810,"EndOffsetMillis":162960},{"Type":"pronunciation","Confidence":1.0,"Content":"me","BeginOffsetMillis":162960,"EndOffsetMillis":163020},{"Type":"pronunciation","Confidence":0.9993,"Content":"know","BeginOffsetMillis":163020,"EndOffsetMillis":163260},{"Type":"punctuation","Confidence":0.0,"Content":"."}],"Id":"f5125aca-4108-45ad-b7ad-7a7478b10ef3","BeginOffsetMillis":157640,"EndOffsetMillis":163260,"Sentiment":"POSITIVE","ParticipantRole":"AGENT"},{"LoudnessScores":[90.76,90.1,81.35,81.69,6.43,33.72,25.74,84.8,46.9],"Content":"Is there a way that that can just be updated in your system as well today then please?","Items":[{"Type":"pronunciation","Confidence":1.0,"Content":"Is","BeginOffsetMillis":162540,"EndOffsetMillis":162670},{"Type":"pronunciation","Confidence":1.0,"Content":"there","BeginOffsetMillis":162670,"EndOffsetMillis":162850},{"Type":"pronunciation","Confidence":1.0,"Content":"a","BeginOffsetMillis":162850,"EndOffsetMillis":162950},{"Type":"pronunciation","Confidence":1.0,"Content":"way","BeginOffsetMillis":162950,"EndOffsetMillis":163310},{"Type":"pronunciation","Confidence":1.0,"Content":"that","BeginOffsetMillis":163310,"EndOffsetMillis":163580},{"Type":"pronunciation","Confidence":1.0,"Content":"that","BeginOffsetMillis":163590,"EndOffsetMillis":163860},{"Type":"pronunciation","Confidence":1.0,"Content":"can","BeginOffsetMillis":163860,"EndOffsetMillis":163990},{"Type":"pronunciation","Confidence":1.0,"Content":"just","BeginOffsetMillis":163990,"EndOffsetMillis":164180},{"Type":"pronunciation","Confidence":1.0,"Content":"be","BeginOffsetMillis":164180,"EndOffsetMillis":164320},{"Type":"pronunciation","Confidence":1.0,"Content":"updated","BeginOffsetMillis":164320,"EndOffsetMillis":164770},{"Type":"pronunciation","Confidence":0.996,"Content":"in","BeginOffsetMillis":164770,"EndOffsetMillis":164840},{"Type":"pronunciation","Confidence":1.0,"Content":"your","BeginOffsetMillis":164840,"EndOffsetMillis":164980},{"Type":"pronunciation","Confidence":1.0,"Content":"system","BeginOffsetMillis":164980,"EndOffsetMillis":165330},{"Type":"pronunciation","Confidence":1.0,"Content":"as","BeginOffsetMillis":165330,"EndOffsetMillis":165450},{"Type":"pronunciation","Confidence":1.0,"Content":"well","BeginOffsetMillis":165450,"EndOffsetMillis":165860},{"Type":"pronunciation","Confidence":0.9188,"Content":"today","BeginOffsetMillis":169240,"EndOffsetMillis":169490},{"Type":"pronunciation","Confidence":0.976,"Content":"then","BeginOffsetMillis":169490,"EndOffsetMillis":169700},{"Type":"pronunciation","Confidence":1.0,"Content":"please","BeginOffsetMillis":169700,"EndOffsetMillis":170050},{"Type":"punctuation","Confidence":0.0,"Content":"?"}],"Id":"3e61a277-be66-4a39-a1fd-ed7cf3ac6558","BeginOffsetMillis":162540,"EndOffsetMillis":170050,"Sentiment":"NEUTRAL","ParticipantRole":"CUSTOMER"},{"LoudnessScores":[85.96,88.41,83.42,45.89,81.97,86.14,88.22,87.33,88.01,81.13],"Content":"Yes, I'll do that right now. What would you prefer? Okay, that is in our system. Thank you for letting us know. Is there anything else I can help you with today?","Items":[{"Type":"pronunciation","Confidence":1.0,"Content":"Yes","BeginOffsetMillis":166440,"EndOffsetMillis":166770},{"Type":"punctuation","Confidence":0.0,"Content":","},{"Type":"pronunciation","Confidence":0.9698,"Content":"I'll","BeginOffsetMillis":166770,"EndOffsetMillis":166970},{"Type":"pronunciation","Confidence":1.0,"Content":"do","BeginOffsetMillis":166970,"EndOffsetMillis":167090},{"Type":"pronunciation","Confidence":1.0,"Content":"that","BeginOffsetMillis":167090,"EndOffsetMillis":167280},{"Type":"pronunciation","Confidence":1.0,"Content":"right","BeginOffsetMillis":167280,"EndOffsetMillis":167510},{"Type":"pronunciation","Confidence":1.0,"Content":"now","BeginOffsetMillis":167510,"EndOffsetMillis":167810},{"Type":"punctuation","Confidence":0.0,"Content":"."},{"Type":"pronunciation","Confidence":1.0,"Content":"What","BeginOffsetMillis":167810,"EndOffsetMillis":167960},{"Type":"pronunciation","Confidence":1.0,"Content":"would","BeginOffsetMillis":167960,"EndOffsetMillis":168100},{"Type":"pronunciation","Confidence":1.0,"Content":"you","BeginOffsetMillis":168100,"EndOffsetMillis":168230},{"Type":"pronunciation","Confidence":1.0,"Content":"prefer","BeginOffsetMillis":168230,"EndOffsetMillis":168660},{"Type":"punctuation","Confidence":0.0,"Content":"?"},{"Type":"pronunciation","Confidence":0.9631,"Content":"Okay","BeginOffsetMillis":171090,"EndOffsetMillis":171540},{"Type":"punctuation","Confidence":0.0,"Content":","},{"Type":"pronunciation","Confidence":1.0,"Content":"that","BeginOffsetMillis":171550,"EndOffsetMillis":171860},{"Type":"pronunciation","Confidence":1.0,"Content":"is","BeginOffsetMillis":171870,"EndOffsetMillis":172150},{"Type":"pronunciation","Confidence":1.0,"Content":"in","BeginOffsetMillis":172160,"EndOffsetMillis":172320},{"Type":"pronunciation","Confidence":1.0,"Content":"our","BeginOffsetMillis":172320,"EndOffsetMillis":172440},{"Type":"pronunciation","Confidence":1.0,"Content":"system","BeginOffsetMillis":172440,"EndOffsetMillis":172810},{"Type":"punctuation","Confidence":0.0,"Content":"."},{"Type":"pronunciation","Confidence":1.0,"Content":"Thank","BeginOffsetMillis":172810,"EndOffsetMillis":173020},{"Type":"pronunciation","Confidence":1.0,"Content":"you","BeginOffsetMillis":173020,"EndOffsetMillis":173120},{"Type":"pronunciation","Confidence":1.0,"Content":"for","BeginOffsetMillis":173120,"EndOffsetMillis":173220},{"Type":"pronunciation","Confidence":1.0,"Content":"letting","BeginOffsetMillis":173220,"EndOffsetMillis":173460},{"Type":"pronunciation","Confidence":1.0,"Content":"us","BeginOffsetMillis":173460,"EndOffsetMillis":173620},{"Type":"pronunciation","Confidence":1.0,"Content":"know","BeginOffsetMillis":173620,"EndOffsetMillis":173990},{"Type":"punctuation","Confidence":0.0,"Content":"."},{"Type":"pronunciation","Confidence":1.0,"Content":"Is","BeginOffsetMillis":174000,"EndOffsetMillis":174210},{"Type":"pronunciation","Confidence":1.0,"Content":"there","BeginOffsetMillis":174210,"EndOffsetMillis":174300},{"Type":"pronunciation","Confidence":1.0,"Content":"anything","BeginOffsetMillis":174300,"EndOffsetMillis":174500},{"Type":"pronunciation","Confidence":1.0,"Content":"else","BeginOffsetMillis":174500,"EndOffsetMillis":174640},{"Type":"pronunciation","Confidence":1.0,"Content":"I","BeginOffsetMillis":174640,"EndOffsetMillis":174680},{"Type":"pronunciation","Confidence":1.0,"Content":"can","BeginOffsetMillis":174680,"EndOffsetMillis":174780},{"Type":"pronunciation","Confidence":1.0,"Content":"help","BeginOffsetMillis":174780,"EndOffsetMillis":174950},{"Type":"pronunciation","Confidence":1.0,"Content":"you","BeginOffsetMillis":174950,"EndOffsetMillis":175020},{"Type":"pronunciation","Confidence":1.0,"Content":"with","BeginOffsetMillis":175020,"EndOffsetMillis":175150},{"Type":"pronunciation","Confidence":1.0,"Content":"today","BeginOffsetMillis":175150,"EndOffsetMillis":175460},{"Type":"punctuation","Confidence":0.0,"Content":"?"}],"Id":"1c8f0e3c-d000-4f2a-b51e-141530d25b54","BeginOffsetMillis":166440,"EndOffsetMillis":175460,"Sentiment":"POSITIVE","ParticipantRole":"AGENT"},{"LoudnessScores":[88.18,85.31],"Content":"No, that's it. Thank you.","Items":[{"Type":"pronunciation","Confidence":1.0,"Content":"No","BeginOffsetMillis":176140,"EndOffsetMillis":176390},{"Type":"punctuation","Confidence":0.0,"Content":","},{"Type":"pronunciation","Confidence":1.0,"Content":"that's","BeginOffsetMillis":176390,"EndOffsetMillis":176630},{"Type":"pronunciation","Confidence":1.0,"Content":"it","BeginOffsetMillis":176630,"EndOffsetMillis":176760},{"Type":"punctuation","Confidence":0.0,"Content":"."},{"Type":"pronunciation","Confidence":1.0,"Content":"Thank","BeginOffsetMillis":176760,"EndOffsetMillis":177050},{"Type":"pronunciation","Confidence":1.0,"Content":"you","BeginOffsetMillis":177050,"EndOffsetMillis":177360},{"Type":"punctuation","Confidence":0.0,"Content":"."}],"Id":"fbcb84f8-a8e2-4973-b057-82307dc6ea41","BeginOffsetMillis":176140,"EndOffsetMillis":177360,"Sentiment":"POSITIVE","ParticipantRole":"CUSTOMER"},{"LoudnessScores":[84.13,88.97,87.24,83.64],"Content":"Wonderful. Have smooth sailing down to [PII].","Redaction":{"RedactedTimestamps":[{"BeginOffsetMillis":179210,"EndOffsetMillis":180080}]},"Items":[{"Type":"pronunciation","Confidence":1.0,"Content":"Wonderful","BeginOffsetMillis":177740,"EndOffsetMillis":178210},{"Type":"punctuation","Confidence":0.0,"Content":"."},{"Type":"pronunciation","Confidence":0.9981,"Content":"Have","BeginOffsetMillis":178210,"EndOffsetMillis":178380},{"Type":"pronunciation","Confidence":1.0,"Content":"smooth","BeginOffsetMillis":178380,"EndOffsetMillis":178720},{"Type":"pronunciation","Confidence":1.0,"Content":"sailing","BeginOffsetMillis":178720,"EndOffsetMillis":179030},{"Type":"pronunciation","Confidence":1.0,"Content":"down","BeginOffsetMillis":179030,"EndOffsetMillis":179180},{"Type":"pronunciation","Confidence":1.0,"Content":"to","BeginOffsetMillis":179180,"EndOffsetMillis":179210},{"Type":"pronunciation","Content":"[PII]","Redaction":[{"Confidence":0.9428}],"BeginOffsetMillis":179210,"EndOffsetMillis":180080},{"Type":"punctuation","Confidence":0.0,"Content":"."}],"Id":"2b54e59a-5893-43ee-b868-a74838d8952d","BeginOffsetMillis":177740,"EndOffsetMillis":180080,"Sentiment":"POSITIVE","ParticipantRole":"AGENT"},{"LoudnessScores":[83.38],"Content":"Thank you. Bye.","Items":[{"Type":"pronunciation","Confidence":1.0,"Content":"Thank","BeginOffsetMillis":181040,"EndOffsetMillis":181320},{"Type":"pronunciation","Confidence":1.0,"Content":"you","BeginOffsetMillis":181320,"EndOffsetMillis":181530},{"Type":"punctuation","Confidence":0.0,"Content":"."},{"Type":"pronunciation","Confidence":0.9985,"Content":"Bye","BeginOffsetMillis":181540,"EndOffsetMillis":181960},{"Type":"punctuation","Confidence":0.0,"Content":"."}],"Id":"b3c2b4bf-38a6-44b5-93d5-50316bec514c","BeginOffsetMillis":181040,"EndOffsetMillis":181960,"Sentiment":"POSITIVE","ParticipantRole":"CUSTOMER"},{"LoudnessScores":[59.91,75.69],"Content":"Bye.","Items":[{"Type":"pronunciation","Confidence":0.9881,"Content":"Bye","BeginOffsetMillis":181940,"EndOffsetMillis":182890},{"Type":"punctuation","Confidence":0.0,"Content":"."}],"Id":"432f1144-6ca7-4fbc-a2e0-8535e92b9fb6","BeginOffsetMillis":181940,"EndOffsetMillis":182890,"Sentiment":"NEUTRAL","ParticipantRole":"AGENT"}],"AccountId":"145109938727","Categories":{"MatchedDetails":{"No_Closing_Remark":{"PointsOfInterest":[]}},"MatchedCategories":["No_Closing_Remark"]},"Channel":"VOICE","JobName":"example-call.wav","Participants":[{"ParticipantRole":"AGENT"},{"ParticipantRole":"CUSTOMER"}],"ConversationCharacteristics":{"NonTalkTime":{"Instances":[],"TotalTimeMillis":0},"Interruptions":{"TotalCount":2,"TotalTimeMillis":4340,"InterruptionsByInterrupter":{"AGENT":[{"BeginOffsetMillis":18740,"DurationMillis":2120,"EndOffsetMillis":20860},{"BeginOffsetMillis":166440,"DurationMillis":2220,"EndOffsetMillis":168660}]}},"TotalConversationDurationMillis":182890,"Sentiment":{"OverallSentiment":{"AGENT":2.4,"CUSTOMER":1.8},"SentimentByPeriod":{"QUARTER":{"AGENT":[{"Score":2.9,"BeginOffsetMillis":0,"EndOffsetMillis":45722},{"Score":1.0,"BeginOffsetMillis":45722,"EndOffsetMillis":91445},{"Score":2.0,"BeginOffsetMillis":91445,"EndOffsetMillis":137167},{"Score":3.8,"BeginOffsetMillis":137167,"EndOffsetMillis":182890}],"CUSTOMER":[{"Score":-1.3,"BeginOffsetMillis":0,"EndOffsetMillis":45490},{"Score":2.1,"BeginOffsetMillis":45490,"EndOffsetMillis":90980},{"Score":3.3,"BeginOffsetMillis":90980,"EndOffsetMillis":136470},{"Score":2.5,"BeginOffsetMillis":136470,"EndOffsetMillis":181960}]}}},"TalkSpeed":{"DetailsByParticipant":{"AGENT":{"AverageWordsPerMinute":193},"CUSTOMER":{"AverageWordsPerMinute":135}}},"TalkTime":{"DetailsByParticipant":{"AGENT":{"TotalTimeMillis":104349},"CUSTOMER":{"TotalTimeMillis":63790}},"TotalTimeMillis":168139}},"ContentMetadata":{"Output":"Redacted","RedactionTypes":["PII"]}} -------------------------------------------------------------------------------- /sample-data/example-call.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-transcribe-output-word-document/d91b6c58ffb2e8919f58cabd66019397c747dc35/sample-data/example-call.wav --------------------------------------------------------------------------------