From fdde4fa38a50db472577733500b91436a64d0ee7 Mon Sep 17 00:00:00 2001
From: yinhew <46698869+yinhew@users.noreply.github.com>
Date: Fri, 6 Sep 2024 23:02:08 +0800
Subject: [PATCH 01/11] [Python][Avatar][Live] Switch from REST API to SDK for
calling AOAI (#2577)
---
samples/python/web/avatar/app.py | 142 ++++++++-------------
samples/python/web/avatar/requirements.txt | 1 +
2 files changed, 52 insertions(+), 91 deletions(-)
diff --git a/samples/python/web/avatar/app.py b/samples/python/web/avatar/app.py
index 0c6bda4e1..c106e7e5f 100644
--- a/samples/python/web/avatar/app.py
+++ b/samples/python/web/avatar/app.py
@@ -16,6 +16,7 @@
import uuid
from flask import Flask, Response, render_template, request
from azure.identity import DefaultAzureCredential
+from openai import AzureOpenAI
# Create the Flask app
app = Flask(__name__, template_folder='.')
@@ -52,6 +53,10 @@
client_contexts = {} # Client contexts
speech_token = None # Speech token
ice_token = None # ICE token
+azure_openai = AzureOpenAI(
+ azure_endpoint=azure_openai_endpoint,
+ api_version='2024-06-01',
+ api_key=azure_openai_api_key)
# The default route, which shows the default web page (basic.html)
@app.route("/")
@@ -309,22 +314,25 @@ def initializeChatContext(system_prompt: str, client_id: uuid.UUID) -> None:
if cognitive_search_endpoint and cognitive_search_api_key and cognitive_search_index_name:
# On-your-data scenario
data_source = {
- 'type': 'AzureCognitiveSearch',
+ 'type': 'azure_search',
'parameters': {
'endpoint': cognitive_search_endpoint,
- 'key': cognitive_search_api_key,
- 'indexName': cognitive_search_index_name,
- 'semanticConfiguration': '',
- 'queryType': 'simple',
- 'fieldsMapping': {
- 'contentFieldsSeparator': '\n',
- 'contentFields': ['content'],
- 'filepathField': None,
- 'titleField': 'title',
- 'urlField': None
+ 'index_name': cognitive_search_index_name,
+ 'authentication': {
+ 'type': 'api_key',
+ 'key': cognitive_search_api_key
},
- 'inScope': True,
- 'roleInformation': system_prompt
+ 'semantic_configuration': '',
+ 'query_type': 'simple',
+ 'fields_mapping': {
+ 'content_fields_separator': '\n',
+ 'content_fields': ['content'],
+ 'filepath_field': None,
+ 'title_field': 'title',
+ 'url_field': None
+ },
+ 'in_scope': True,
+ 'role_information': system_prompt
}
}
data_sources.append(data_source)
@@ -364,88 +372,40 @@ def handleUserQuery(user_query: str, client_id: uuid.UUID):
if len(data_sources) > 0 and enable_quick_reply:
speakWithQueue(random.choice(quick_replies), 2000)
- url = f"{azure_openai_endpoint}/openai/deployments/{azure_openai_deployment_name}/chat/completions?api-version=2023-06-01-preview"
- body = json.dumps({
- 'messages': messages,
- 'stream': True
- })
-
- if len(data_sources) > 0:
- url = f"{azure_openai_endpoint}/openai/deployments/{azure_openai_deployment_name}/extensions/chat/completions?api-version=2023-06-01-preview"
- body = json.dumps({
- 'dataSources': data_sources,
- 'messages': messages,
- 'stream': True
- })
-
assistant_reply = ''
tool_content = ''
spoken_sentence = ''
- response = requests.post(url, stream=True, headers={
- 'api-key': azure_openai_api_key,
- 'Content-Type': 'application/json'
- }, data=body)
-
- if not response.ok:
- raise Exception(f"Chat API response status: {response.status_code} {response.reason}")
-
- # Iterate chunks from the response stream
- iterator = response.iter_content(chunk_size=None)
- for chunk in iterator:
- if not chunk:
- # End of stream
- return
-
- # Process the chunk of data (value)
- chunk_string = chunk.decode()
-
- if not chunk_string.endswith('}\n\n') and not chunk_string.endswith('[DONE]\n\n'):
- # This is an incomplete chunk, read the next chunk
- while not chunk_string.endswith('}\n\n') and not chunk_string.endswith('[DONE]\n\n'):
- chunk_string += next(iterator).decode()
-
- for line in chunk_string.split('\n\n'):
- try:
- if line.startswith('data:') and not line.endswith('[DONE]'):
- response_json = json.loads(line[5:].strip())
- response_token = None
- if len(response_json['choices']) > 0:
- choice = response_json['choices'][0]
- if len(data_sources) == 0:
- if len(choice['delta']) > 0 and 'content' in choice['delta']:
- response_token = choice['delta']['content']
- elif len(choice['messages']) > 0 and 'delta' in choice['messages'][0]:
- delta = choice['messages'][0]['delta']
- if 'role' in delta and delta['role'] == 'tool' and 'content' in delta:
- tool_content = response_json['choices'][0]['messages'][0]['delta']['content']
- elif 'content' in delta:
- response_token = response_json['choices'][0]['messages'][0]['delta']['content']
- if response_token is not None:
- if oyd_doc_regex.search(response_token):
- response_token = oyd_doc_regex.sub('', response_token).strip()
- if response_token == '[DONE]':
- response_token = None
-
- if response_token is not None:
- # Log response_token here if need debug
- yield response_token # yield response token to client as display text
- assistant_reply += response_token # build up the assistant message
- if response_token == '\n' or response_token == '\n\n':
- speakWithQueue(spoken_sentence.strip(), 0, client_id)
- spoken_sentence = ''
- else:
- response_token = response_token.replace('\n', '')
- spoken_sentence += response_token # build up the spoken sentence
- if len(response_token) == 1 or len(response_token) == 2:
- for punctuation in sentence_level_punctuations:
- if response_token.startswith(punctuation):
- speakWithQueue(spoken_sentence.strip(), 0, client_id)
- spoken_sentence = ''
- break
- except Exception as e:
- print(f"Error occurred while parsing the response: {e}")
- print(line)
+ aoai_start_time = datetime.datetime.now(pytz.UTC)
+ response = azure_openai.chat.completions.create(
+ model=azure_openai_deployment_name,
+ messages=messages,
+ extra_body={ 'data_sources' : data_sources } if len(data_sources) > 0 else None,
+ stream=True)
+ aoai_reponse_time = datetime.datetime.now(pytz.UTC)
+ print(f"AOAI latency: {(aoai_reponse_time - aoai_start_time).total_seconds() * 1000}ms")
+
+ for chunk in response:
+ if len(chunk.choices) > 0:
+ response_token = chunk.choices[0].delta.content
+ if response_token is not None:
+ # Log response_token here if need debug
+ if oyd_doc_regex.search(response_token):
+ response_token = oyd_doc_regex.sub('', response_token).strip()
+ yield response_token # yield response token to client as display text
+ assistant_reply += response_token # build up the assistant message
+ if response_token == '\n' or response_token == '\n\n':
+ speakWithQueue(spoken_sentence.strip(), 0, client_id)
+ spoken_sentence = ''
+ else:
+ response_token = response_token.replace('\n', '')
+ spoken_sentence += response_token # build up the spoken sentence
+ if len(response_token) == 1 or len(response_token) == 2:
+ for punctuation in sentence_level_punctuations:
+ if response_token.startswith(punctuation):
+ speakWithQueue(spoken_sentence.strip(), 0, client_id)
+ spoken_sentence = ''
+ break
if spoken_sentence != '':
speakWithQueue(spoken_sentence.strip(), 0, client_id)
diff --git a/samples/python/web/avatar/requirements.txt b/samples/python/web/avatar/requirements.txt
index 12b252995..3a3ef484e 100644
--- a/samples/python/web/avatar/requirements.txt
+++ b/samples/python/web/avatar/requirements.txt
@@ -1,5 +1,6 @@
azure-cognitiveservices-speech
azure-identity
flask
+openai
pytz
requests
From 28140d294027e8986eaeb131988cd7a57e4384ee Mon Sep 17 00:00:00 2001
From: yinhew <46698869+yinhew@users.noreply.github.com>
Date: Tue, 10 Sep 2024 16:43:15 +0800
Subject: [PATCH 02/11] [Python][Avatar][Live] Some optimization for latency
reduction (#2580)
---
samples/python/web/avatar/app.py | 11 +++++------
samples/python/web/avatar/chat.html | 6 +++---
samples/python/web/avatar/static/js/chat.js | 14 ++++++++++++++
3 files changed, 22 insertions(+), 9 deletions(-)
diff --git a/samples/python/web/avatar/app.py b/samples/python/web/avatar/app.py
index c106e7e5f..032f6b137 100644
--- a/samples/python/web/avatar/app.py
+++ b/samples/python/web/avatar/app.py
@@ -216,7 +216,11 @@ def speak() -> Response:
# The API route to stop avatar from speaking
@app.route("/api/stopSpeaking", methods=["POST"])
def stopSpeaking() -> Response:
- stopSpeakingInternal(uuid.UUID(request.headers.get('ClientId')))
+ global client_contexts
+ client_id = uuid.UUID(request.headers.get('ClientId'))
+ is_speaking = client_contexts[client_id]['is_speaking']
+ if is_speaking:
+ stopSpeakingInternal(client_id)
return Response('Speaking stopped.', status=200)
# The API route for chat
@@ -354,7 +358,6 @@ def handleUserQuery(user_query: str, client_id: uuid.UUID):
azure_openai_deployment_name = client_context['azure_openai_deployment_name']
messages = client_context['messages']
data_sources = client_context['data_sources']
- is_speaking = client_context['is_speaking']
chat_message = {
'role': 'user',
@@ -363,10 +366,6 @@ def handleUserQuery(user_query: str, client_id: uuid.UUID):
messages.append(chat_message)
- # Stop previous speaking if there is any
- if is_speaking:
- stopSpeakingInternal(client_id)
-
# For 'on your data' scenario, chat API currently has long (4s+) latency
# We return some quick reply here before the chat API returns to mitigate.
if len(data_sources) > 0 and enable_quick_reply:
diff --git a/samples/python/web/avatar/chat.html b/samples/python/web/avatar/chat.html
index 8538299ad..59b9ad190 100644
--- a/samples/python/web/avatar/chat.html
+++ b/samples/python/web/avatar/chat.html
@@ -32,15 +32,15 @@
Chat Configuration
Speech Configuration
-
+
-
+
- Continuous Conversation
+ Continuous Conversation
diff --git a/samples/python/web/avatar/static/js/chat.js b/samples/python/web/avatar/static/js/chat.js
index 2d2725382..ea380c5b4 100644
--- a/samples/python/web/avatar/static/js/chat.js
+++ b/samples/python/web/avatar/static/js/chat.js
@@ -8,6 +8,7 @@ var peerConnection
var isSpeaking = false
var sessionActive = false
var lastSpeakTime
+var isFirstRecognizingEvent = true
// Connect to avatar service
function connectAvatar() {
@@ -48,6 +49,10 @@ function createSpeechRecognizer() {
SpeechSDK.SpeechConfig.fromEndpoint(new URL(`wss://${speechRegion}.stt.speech.microsoft.com/speech/universal/v2`), '')
speechRecognitionConfig.authorizationToken = speechToken
speechRecognitionConfig.setProperty(SpeechSDK.PropertyId.SpeechServiceConnection_LanguageIdMode, "Continuous")
+ speechRecognitionConfig.setProperty("SpeechContext-PhraseDetection.TrailingSilenceTimeout", "3000")
+ speechRecognitionConfig.setProperty("SpeechContext-PhraseDetection.InitialSilenceTimeout", "10000")
+ speechRecognitionConfig.setProperty("SpeechContext-PhraseDetection.Dictation.Segmentation.Mode", "Custom")
+ speechRecognitionConfig.setProperty("SpeechContext-PhraseDetection.Dictation.Segmentation.SegmentationSilenceTimeoutMs", "200")
var sttLocales = document.getElementById('sttLocales').value.split(',')
var autoDetectSourceLanguageConfig = SpeechSDK.AutoDetectSourceLanguageConfig.fromLanguages(sttLocales)
speechRecognizer = SpeechSDK.SpeechRecognizer.FromConfig(speechRecognitionConfig, autoDetectSourceLanguageConfig, SpeechSDK.AudioConfig.fromDefaultMicrophoneInput())
@@ -439,6 +444,13 @@ window.microphone = () => {
}
document.getElementById('microphone').disabled = true
+ speechRecognizer.recognizing = async (s, e) => {
+ if (isFirstRecognizingEvent && isSpeaking) {
+ window.stopSpeaking()
+ isFirstRecognizingEvent = false
+ }
+ }
+
speechRecognizer.recognized = async (s, e) => {
if (e.result.reason === SpeechSDK.ResultReason.RecognizedSpeech) {
let userQuery = e.result.text.trim()
@@ -468,6 +480,8 @@ window.microphone = () => {
chatHistoryTextArea.scrollTop = chatHistoryTextArea.scrollHeight
handleUserQuery(userQuery)
+
+ isFirstRecognizingEvent = true
}
}
From d8b463a6b6781c77b19d06f3591158c29a771c69 Mon Sep 17 00:00:00 2001
From: poleli
Date: Wed, 11 Sep 2024 21:06:28 +0800
Subject: [PATCH 03/11] [VideoTranslation][Client][C#] Add API client sample
code. (#2567)
---
.../DeploymentEnvironmentAttribute.cs | 61 +
.../CommandParser/ArgumentAttribute.cs | 179 +++
.../CommandParser/CommandLineParser.cs | 1281 +++++++++++++++++
.../CommandParser/CommentAttribute.cs | 51 +
.../CommonLib/CommandParser/ConsoleApp.cs | 59 +
.../CommonLib/CommandParser/ExitCode.cs | 19 +
.../CommonLib/CommandParser/InOutType.cs | 17 +
.../csharp/Common/CommonLib/CommonConst.cs | 55 +
.../csharp/Common/CommonLib/CommonLib.csproj | 23 +
.../CommonLib/CustomContractResolver.cs | 143 ++
.../DTOs/Public/PaginatedResources.cs | 18 +
.../DataContracts/DTOs/Public/ResponseBase.cs | 13 +
.../DTOs/Public/StatefulResourceBase.cs | 17 +
.../DTOs/Public/StatelessResourceBase.cs | 27 +
.../DTOs/Public/VoiceGeneralTaskBrief.cs | 12 +
.../Public/VoiceGeneralTaskInputFileBase.cs | 17 +
.../Common/CommonLib/Enums/OneApiState.cs | 24 +
.../Enums/VideoTranslationFileKind.cs | 15 +
...TranslationMergeParagraphAudioAlignKind.cs | 18 +
.../Enums/VideoTranslationVoiceKind.cs | 20 +
.../CommonLib/Extensions/EnumExtensions.cs | 21 +
.../Extensions/FileNameExtensions.cs | 295 ++++
.../CommonLib/Extensions/StringExtensions.cs | 54 +
.../csharp/Common/CommonLib/HttpClientBase.cs | 222 +++
.../Common/CommonLib/HttpClientConfigBase.cs | 49 +
.../csharp/Common/CommonLib/Readme.txt | 2 +
.../Common/CommonLib/Util/CommonHelper.cs | 216 +++
.../Common/CommonLib/Util/ConsoleHelper.cs | 52 +
.../CommonLib/Util/ConsoleMaskSasHelper.cs | 24 +
.../Common/CommonLib/Util/EnumExtensions.cs | 58 +
.../Common/CommonLib/Util/ExceptionHelper.cs | 234 +++
.../Common/CommonLib/Util/JsonHelper.cs | 70 +
.../Common/CommonLib/Util/Sha256Helper.cs | 66 +
.../CommonLib/Util/StringFormatHelper.cs | 17 +
.../Common/CommonLib/Util/TaskNameHelper.cs | 61 +
.../csharp/Common/CommonLib/Util/UriHelper.cs | 53 +
.../Enum/DeploymentEnvironment.cs | 22 +
.../VideoTranslationConstant.cs | 13 +
.../VideoTranslationLib.Common.csproj | 19 +
.../ConsoleAppHelper.cs | 67 +
.../Enum/OperationStatus.cs | 19 +
.../Enum/VoiceKind.cs | 15 +
.../Enum/VoiceKindExtensions.cs | 21 +
.../Enum/WebvttFileKind.cs | 17 +
.../Public-2024-05-20-preview/Iteration.cs | 28 +
.../IterationInput.cs | 17 +
.../IterationResult.cs | 19 +
.../Public-2024-05-20-preview/Operation.cs | 17 +
.../PagedTranslation.cs | 12 +
.../Public-2024-05-20-preview/Translation.cs | 19 +
.../TranslationInput.cs | 39 +
.../Public-2024-05-20-preview/WebvttFile.cs | 20 +
.../HttpClient/IterationClient.cs | 211 +++
.../HttpClient/OperationClient.cs | 104 ++
.../HttpClient/TranslationClient.cs | 237 +++
.../VideoTranslationLib.PublicPreview.csproj | 13 +
...ranslationPublicPreviewHttpClientConfig.cs | 42 +
.../VideoTranslationSample.sln | 50 +
.../VideoTranslationSample/Arguments.cs | 230 +++
.../VideoTranslationSample/Mode.cs | 29 +
.../VideoTranslationSample/Program.cs | 198 +++
.../VideoTranslationSample.csproj | 16 +
samples/video-translation/csharp/readme.md | 60 +
63 files changed, 5117 insertions(+)
create mode 100644 samples/video-translation/csharp/Common/CommonLib/Attributes/DeploymentEnvironmentAttribute.cs
create mode 100644 samples/video-translation/csharp/Common/CommonLib/CommandParser/ArgumentAttribute.cs
create mode 100644 samples/video-translation/csharp/Common/CommonLib/CommandParser/CommandLineParser.cs
create mode 100644 samples/video-translation/csharp/Common/CommonLib/CommandParser/CommentAttribute.cs
create mode 100644 samples/video-translation/csharp/Common/CommonLib/CommandParser/ConsoleApp.cs
create mode 100644 samples/video-translation/csharp/Common/CommonLib/CommandParser/ExitCode.cs
create mode 100644 samples/video-translation/csharp/Common/CommonLib/CommandParser/InOutType.cs
create mode 100644 samples/video-translation/csharp/Common/CommonLib/CommonConst.cs
create mode 100644 samples/video-translation/csharp/Common/CommonLib/CommonLib.csproj
create mode 100644 samples/video-translation/csharp/Common/CommonLib/CustomContractResolver.cs
create mode 100644 samples/video-translation/csharp/Common/CommonLib/DataContracts/DTOs/Public/PaginatedResources.cs
create mode 100644 samples/video-translation/csharp/Common/CommonLib/DataContracts/DTOs/Public/ResponseBase.cs
create mode 100644 samples/video-translation/csharp/Common/CommonLib/DataContracts/DTOs/Public/StatefulResourceBase.cs
create mode 100644 samples/video-translation/csharp/Common/CommonLib/DataContracts/DTOs/Public/StatelessResourceBase.cs
create mode 100644 samples/video-translation/csharp/Common/CommonLib/DataContracts/DTOs/Public/VoiceGeneralTaskBrief.cs
create mode 100644 samples/video-translation/csharp/Common/CommonLib/DataContracts/DTOs/Public/VoiceGeneralTaskInputFileBase.cs
create mode 100644 samples/video-translation/csharp/Common/CommonLib/Enums/OneApiState.cs
create mode 100644 samples/video-translation/csharp/Common/CommonLib/Enums/VideoTranslationFileKind.cs
create mode 100644 samples/video-translation/csharp/Common/CommonLib/Enums/VideoTranslationMergeParagraphAudioAlignKind.cs
create mode 100644 samples/video-translation/csharp/Common/CommonLib/Enums/VideoTranslationVoiceKind.cs
create mode 100644 samples/video-translation/csharp/Common/CommonLib/Extensions/EnumExtensions.cs
create mode 100644 samples/video-translation/csharp/Common/CommonLib/Extensions/FileNameExtensions.cs
create mode 100644 samples/video-translation/csharp/Common/CommonLib/Extensions/StringExtensions.cs
create mode 100644 samples/video-translation/csharp/Common/CommonLib/HttpClientBase.cs
create mode 100644 samples/video-translation/csharp/Common/CommonLib/HttpClientConfigBase.cs
create mode 100644 samples/video-translation/csharp/Common/CommonLib/Readme.txt
create mode 100644 samples/video-translation/csharp/Common/CommonLib/Util/CommonHelper.cs
create mode 100644 samples/video-translation/csharp/Common/CommonLib/Util/ConsoleHelper.cs
create mode 100644 samples/video-translation/csharp/Common/CommonLib/Util/ConsoleMaskSasHelper.cs
create mode 100644 samples/video-translation/csharp/Common/CommonLib/Util/EnumExtensions.cs
create mode 100644 samples/video-translation/csharp/Common/CommonLib/Util/ExceptionHelper.cs
create mode 100644 samples/video-translation/csharp/Common/CommonLib/Util/JsonHelper.cs
create mode 100644 samples/video-translation/csharp/Common/CommonLib/Util/Sha256Helper.cs
create mode 100644 samples/video-translation/csharp/Common/CommonLib/Util/StringFormatHelper.cs
create mode 100644 samples/video-translation/csharp/Common/CommonLib/Util/TaskNameHelper.cs
create mode 100644 samples/video-translation/csharp/Common/CommonLib/Util/UriHelper.cs
create mode 100644 samples/video-translation/csharp/Common/VideoTranslationLib.Common/Enum/DeploymentEnvironment.cs
create mode 100644 samples/video-translation/csharp/Common/VideoTranslationLib.Common/VideoTranslationConstant.cs
create mode 100644 samples/video-translation/csharp/Common/VideoTranslationLib.Common/VideoTranslationLib.Common.csproj
create mode 100644 samples/video-translation/csharp/Common/VideoTranslationLib.PublicPreview/ConsoleAppHelper.cs
create mode 100644 samples/video-translation/csharp/Common/VideoTranslationLib.PublicPreview/DataContracts/DTOs/Public-2024-05-20-preview/Enum/OperationStatus.cs
create mode 100644 samples/video-translation/csharp/Common/VideoTranslationLib.PublicPreview/DataContracts/DTOs/Public-2024-05-20-preview/Enum/VoiceKind.cs
create mode 100644 samples/video-translation/csharp/Common/VideoTranslationLib.PublicPreview/DataContracts/DTOs/Public-2024-05-20-preview/Enum/VoiceKindExtensions.cs
create mode 100644 samples/video-translation/csharp/Common/VideoTranslationLib.PublicPreview/DataContracts/DTOs/Public-2024-05-20-preview/Enum/WebvttFileKind.cs
create mode 100644 samples/video-translation/csharp/Common/VideoTranslationLib.PublicPreview/DataContracts/DTOs/Public-2024-05-20-preview/Iteration.cs
create mode 100644 samples/video-translation/csharp/Common/VideoTranslationLib.PublicPreview/DataContracts/DTOs/Public-2024-05-20-preview/IterationInput.cs
create mode 100644 samples/video-translation/csharp/Common/VideoTranslationLib.PublicPreview/DataContracts/DTOs/Public-2024-05-20-preview/IterationResult.cs
create mode 100644 samples/video-translation/csharp/Common/VideoTranslationLib.PublicPreview/DataContracts/DTOs/Public-2024-05-20-preview/Operation.cs
create mode 100644 samples/video-translation/csharp/Common/VideoTranslationLib.PublicPreview/DataContracts/DTOs/Public-2024-05-20-preview/PagedTranslation.cs
create mode 100644 samples/video-translation/csharp/Common/VideoTranslationLib.PublicPreview/DataContracts/DTOs/Public-2024-05-20-preview/Translation.cs
create mode 100644 samples/video-translation/csharp/Common/VideoTranslationLib.PublicPreview/DataContracts/DTOs/Public-2024-05-20-preview/TranslationInput.cs
create mode 100644 samples/video-translation/csharp/Common/VideoTranslationLib.PublicPreview/DataContracts/DTOs/Public-2024-05-20-preview/WebvttFile.cs
create mode 100644 samples/video-translation/csharp/Common/VideoTranslationLib.PublicPreview/HttpClient/IterationClient.cs
create mode 100644 samples/video-translation/csharp/Common/VideoTranslationLib.PublicPreview/HttpClient/OperationClient.cs
create mode 100644 samples/video-translation/csharp/Common/VideoTranslationLib.PublicPreview/HttpClient/TranslationClient.cs
create mode 100644 samples/video-translation/csharp/Common/VideoTranslationLib.PublicPreview/VideoTranslationLib.PublicPreview.csproj
create mode 100644 samples/video-translation/csharp/Common/VideoTranslationLib.PublicPreview/VideoTranslationPublicPreviewHttpClientConfig.cs
create mode 100644 samples/video-translation/csharp/VideoTranslationSample/VideoTranslationSample.sln
create mode 100644 samples/video-translation/csharp/VideoTranslationSample/VideoTranslationSample/Arguments.cs
create mode 100644 samples/video-translation/csharp/VideoTranslationSample/VideoTranslationSample/Mode.cs
create mode 100644 samples/video-translation/csharp/VideoTranslationSample/VideoTranslationSample/Program.cs
create mode 100644 samples/video-translation/csharp/VideoTranslationSample/VideoTranslationSample/VideoTranslationSample.csproj
create mode 100644 samples/video-translation/csharp/readme.md
diff --git a/samples/video-translation/csharp/Common/CommonLib/Attributes/DeploymentEnvironmentAttribute.cs b/samples/video-translation/csharp/Common/CommonLib/Attributes/DeploymentEnvironmentAttribute.cs
new file mode 100644
index 000000000..621612a0d
--- /dev/null
+++ b/samples/video-translation/csharp/Common/CommonLib/Attributes/DeploymentEnvironmentAttribute.cs
@@ -0,0 +1,61 @@
+//
+// Copyright (c) Microsoft. All rights reserved.
+// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
+//
+
+namespace Microsoft.SpeechServices.CommonLib.Attributes;
+
+using Microsoft.SpeechServices.CommonLib.Extensions;
+using System;
+using System.IO;
+
+[AttributeUsage(AttributeTargets.Field, Inherited = false, AllowMultiple = false)]
+public sealed class DeploymentEnvironmentAttribute : Attribute
+{
+ public DeploymentEnvironmentAttribute(
+ string regionIdentifier)
+ {
+ this.RegionIdentifier = regionIdentifier;
+ }
+
+ public string RegionIdentifier { get; internal set; }
+
+ public string ApimHostName
+ {
+ get
+ {
+ return $"{this.RegionIdentifier}.api.cognitive.microsoft.com";
+ }
+ }
+
+ public static TDeploymentEnvironment ParseFromRegionIdentifier(string regionIdentifier)
+ where TDeploymentEnvironment : Enum
+ {
+ if (string.IsNullOrEmpty(regionIdentifier))
+ {
+ throw new ArgumentNullException(nameof(regionIdentifier));
+ }
+
+ foreach (TDeploymentEnvironment environment in Enum.GetValues(typeof(TDeploymentEnvironment)))
+ {
+ var attribute = environment.GetAttributeOfType();
+ if (string.Equals(attribute?.RegionIdentifier, regionIdentifier, StringComparison.OrdinalIgnoreCase))
+ {
+ return environment;
+ }
+ }
+
+ throw new NotSupportedException($"Not supported region: {regionIdentifier}");
+ }
+
+ public Uri GetApimApiBaseUrl()
+ {
+ Uri url = null;
+ if (!string.IsNullOrEmpty(this.ApimHostName))
+ {
+ url = new Uri($"https://{this.ApimHostName}/");
+ }
+
+ return url;
+ }
+}
diff --git a/samples/video-translation/csharp/Common/CommonLib/CommandParser/ArgumentAttribute.cs b/samples/video-translation/csharp/Common/CommonLib/CommandParser/ArgumentAttribute.cs
new file mode 100644
index 000000000..e12c179d2
--- /dev/null
+++ b/samples/video-translation/csharp/Common/CommonLib/CommandParser/ArgumentAttribute.cs
@@ -0,0 +1,179 @@
+//
+// Copyright (c) Microsoft. All rights reserved.
+// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
+//
+
+namespace Microsoft.SpeechServices.CommonLib.CommandParser;
+
+using System;
+using System.Globalization;
+
+[AttributeUsage(AttributeTargets.Field, Inherited = false)]
+public sealed class ArgumentAttribute : Attribute
+{
+ private readonly string flag;
+ private string description = string.Empty;
+ private string usagePlaceholder;
+ private bool optional;
+ private bool hidden;
+ private InOutType inoutType;
+ private string requiredModes;
+ private string optionalModes;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// Flag string for this attribute.
+ public ArgumentAttribute(string optionName)
+ {
+ if (optionName == null)
+ {
+ throw new ArgumentNullException(nameof(optionName));
+ }
+
+ this.flag = optionName;
+ }
+
+ ///
+ /// Gets The parse recognising flag.
+ ///
+ [System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Globalization", "CA1308:NormalizeStringsToUppercase", Justification = "Ignore.")]
+ public string OptionName
+ {
+ get { return this.flag.ToLower(CultureInfo.InvariantCulture); }
+ }
+
+ ///
+ /// Gets or sets Description will display in the PrintUsage method.
+ ///
+ public string Description
+ {
+ get
+ {
+ return this.description;
+ }
+
+ set
+ {
+ if (value == null)
+ {
+ throw new ArgumentNullException(nameof(value));
+ }
+
+ this.description = value;
+ }
+ }
+
+ ///
+ /// Gets or sets In the PrintUsage method this will display a place hold for a parameter.
+ ///
+ public string UsagePlaceholder
+ {
+ get
+ {
+ return this.usagePlaceholder;
+ }
+
+ set
+ {
+ if (value == null)
+ {
+ throw new ArgumentNullException(nameof(value));
+ }
+
+ this.usagePlaceholder = value;
+ }
+ }
+
+ ///
+ /// Gets or sets a value indicating whether (optional = true) means not necessarily in the command-line.
+ ///
+ public bool Optional
+ {
+ get { return this.optional; }
+ set { this.optional = value; }
+ }
+
+ ///
+ /// Gets or sets a value indicating whether (Hidden = true) means this option will not be printed in the command-line.
+ /// While one option is set with Hidden, the Optional must be true.
+ ///
+ public bool Hidden
+ {
+ get { return this.hidden; }
+ set { this.hidden = value; }
+ }
+
+ ///
+ /// Gets or sets The in/out type of argument.
+ ///
+ public InOutType InOutType
+ {
+ get { return this.inoutType; }
+ set { this.inoutType = value; }
+ }
+
+ ///
+ /// Gets or sets The modes require this argument.
+ ///
+ [System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Globalization", "CA1308:NormalizeStringsToUppercase", Justification = "Ignore.")]
+ public string RequiredModes
+ {
+ get
+ {
+ return this.requiredModes;
+ }
+
+ set
+ {
+ this.requiredModes = value?.ToLower(CultureInfo.InvariantCulture);
+ }
+ }
+
+ ///
+ /// Gets or sets The modes optionally require this argument.
+ ///
+ [System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Globalization", "CA1308:NormalizeStringsToUppercase", Justification = "Ignore.")]
+ public string OptionalModes
+ {
+ get
+ {
+ return this.optionalModes;
+ }
+
+ set
+ {
+ this.optionalModes = value?.ToLower(CultureInfo.InvariantCulture);
+ }
+ }
+
+ ///
+ /// Get required modes in an array.
+ ///
+ /// Mode array.
+ public string[] GetRequiredModeArray()
+ {
+ string[] modes = null;
+ if (!string.IsNullOrEmpty(this.requiredModes))
+ {
+ modes = this.requiredModes.Split(new char[] { ',', ' ' }, StringSplitOptions.RemoveEmptyEntries);
+ }
+
+ return modes;
+ }
+
+ ///
+ /// Get optional modes in an array.
+ ///
+ /// Mode array.
+ public string[] GetOptionalModeArray()
+ {
+ string[] modes = null;
+ if (!string.IsNullOrEmpty(this.optionalModes))
+ {
+ modes = this.optionalModes.Split(new char[] { ',', ' ' }, StringSplitOptions.RemoveEmptyEntries);
+ }
+
+ return modes;
+ }
+}
diff --git a/samples/video-translation/csharp/Common/CommonLib/CommandParser/CommandLineParser.cs b/samples/video-translation/csharp/Common/CommonLib/CommandParser/CommandLineParser.cs
new file mode 100644
index 000000000..bd404a9ec
--- /dev/null
+++ b/samples/video-translation/csharp/Common/CommonLib/CommandParser/CommandLineParser.cs
@@ -0,0 +1,1281 @@
+//
+// Copyright (c) Microsoft. All rights reserved.
+// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
+//
+
+namespace Microsoft.SpeechServices.CommonLib.CommandParser;
+
+using System;
+using System.Collections;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Globalization;
+using System.IO;
+using System.Reflection;
+using System.Runtime.Serialization;
+using System.Security.Permissions;
+using System.Text;
+
+public static class CommandLineParser
+{
+ public static void Parse(string[] args, object target)
+ {
+ ClpHelper.CheckTarget(target);
+ ClpHelper.CheckArgs(args, target);
+
+ InternalFlags internalTarget = new InternalFlags();
+ ClpHelper helper = new ClpHelper(target, internalTarget);
+
+ helper.ParseArgs(args);
+
+ if (!string.IsNullOrEmpty(internalTarget.ConfigFile))
+ {
+ args = ClpHelper.GetStringsFromConfigFile(internalTarget.ConfigFile);
+ helper.ParseArgs(args);
+ }
+
+ if (internalTarget.NeedHelp)
+ {
+ throw new CommandLineParseException(string.Empty, "help");
+ }
+
+ helper.CheckAllRequiredDestination();
+ }
+
+ public static void PrintUsage(object target)
+ {
+ string usage = BuildUsage(target);
+ Console.WriteLine();
+ Console.WriteLine(usage);
+ }
+
+ public static void HandleException(object target, Exception exception)
+ {
+ ArgumentNullException.ThrowIfNull(exception);
+ if (!string.IsNullOrEmpty(exception.Message))
+ {
+ ArgumentNullException.ThrowIfNull(exception.Message);
+ }
+ else
+ {
+ PrintUsage(target);
+ }
+ }
+
+ public static string BuildUsage(object target)
+ {
+ ClpHelper.CheckTarget(target);
+
+ CommentAttribute[] ca = (CommentAttribute[])target.GetType().GetCustomAttributes(typeof(CommentAttribute), false);
+
+ StringBuilder sb = new StringBuilder();
+
+ if (ca.Length == 1 && !string.IsNullOrEmpty(ca[0].HeadComment))
+ {
+ sb.AppendLine(ca[0].HeadComment);
+ }
+
+ sb.AppendLine();
+
+ Assembly entryAssm = Assembly.GetEntryAssembly();
+
+ // entryAssm is a null reference when a managed assembly has been loaded
+ // from an unmanaged application; Currently we don't allow such calling.
+ // But when calling by our Nunit test framework, this value is null
+ if (entryAssm != null)
+ {
+ sb.AppendFormat(CultureInfo.InvariantCulture, "Version {0}", entryAssm.GetName().Version.ToString());
+ sb.AppendLine();
+ }
+
+ sb.Append(ClpHelper.BuildUsageLine(target));
+ sb.Append(ClpHelper.BuildOptionsString(target));
+
+ if (ca.Length == 1 && !string.IsNullOrEmpty(ca[0].RearComment))
+ {
+ sb.AppendLine();
+ sb.AppendLine(ca[0].RearComment);
+ }
+
+ return sb.ToString();
+ }
+
+ ///
+ /// Command line parser helper class.
+ ///
+ private class ClpHelper
+ {
+ public const BindingFlags AllFieldBindingFlags =
+ BindingFlags.Instance | BindingFlags.Static |
+ BindingFlags.Public | BindingFlags.NonPublic |
+ BindingFlags.DeclaredOnly;
+
+ public const string Mode = "mode";
+
+ // const members.
+ private const int MaxCommandLineStringNumber = 800;
+ private const int MaxConfigFileSize = 32 * 1024; // 32k
+
+ private string modeString;
+
+ // class members.
+ private object clpTarget;
+ private InternalFlags internalTarget;
+ private Dictionary destMap = new Dictionary();
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// Target object to reflect usage information.
+ /// Internal flags.
+ public ClpHelper(object target, InternalFlags internalTarget)
+ {
+ this.clpTarget = target;
+ this.internalTarget = internalTarget; // interal flags class, include "-h","-?","-help","-C"
+
+ this.ParseTheDestination(target);
+ }
+
+ ///
+ /// Check the target objcet, which is to save the value, to avoid misuse.
+ ///
+ /// Target object to reflect usage information.
+ public static void CheckTarget(object target)
+ {
+ if (target == null)
+ {
+ throw new ArgumentNullException(nameof(target));
+ }
+
+ if (!target.GetType().IsClass)
+ {
+ throw new ArgumentException(string.Format(CultureInfo.InvariantCulture, "Object target is not a class."), "target");
+ }
+
+ // Check each field of the target class to ensure that every field, which wanted to be
+ // filled, has defined a static TryParse(string, out Value) funtion. In the parsing time,
+ // the CLP class will use this static function to parse the string to value.
+ foreach (FieldInfo fieldInfo in target.GetType().GetFields(ClpHelper.AllFieldBindingFlags))
+ {
+ if (fieldInfo.IsDefined(typeof(ArgumentAttribute), false))
+ {
+ Type type = fieldInfo.FieldType;
+ if (type.IsArray)
+ {
+ type = type.GetElementType();
+ }
+
+ // string Type don't need a TryParse function, so skip check it.
+ if (type == typeof(string))
+ {
+ continue;
+ }
+
+ Type reftype = Type.GetType(type.ToString() + "&");
+ if (reftype == null)
+ {
+ throw new ArgumentException(
+ "This Type does not exist in this assembly GetType(" + type + ")failed.",
+ fieldInfo.ToString());
+ }
+
+ MethodInfo mi = type.GetMethod("TryParse", new Type[] { typeof(string), reftype });
+ if (mi == null)
+ {
+ throw new ArgumentException(
+ "Type " + type + " don't have a TryParse(string, out Value) method.",
+ fieldInfo.ToString());
+ }
+ }
+ }
+ }
+
+ ///
+ /// Check args from static Main() function, to avoid misuse this library.
+ ///
+ /// Argument string array.
+ /// Target object to reflect usage information.
+ public static void CheckArgs(string[] args, object target)
+ {
+ if (args == null)
+ {
+ string message = string.Format(CultureInfo.InvariantCulture, "Empty parameters.");
+ throw new CommandLineParseException(message);
+ }
+
+ int requiredArgumentCount = GetRequiredArgumentCount(target);
+ if (args.Length == 0)
+ {
+ // if there is no parameter given
+ if (requiredArgumentCount > 0)
+ {
+ // some parameters are required
+ throw new CommandLineParseException(string.Empty, "help");
+ }
+
+ // run the application with default option values
+ }
+
+ if (args.Length > MaxCommandLineStringNumber)
+ {
+ throw new CommandLineParseException(string.Format(CultureInfo.InvariantCulture, "Input parameter number is larger than {0}.", MaxCommandLineStringNumber), "args");
+ }
+
+ for (int i = 0; i < args.Length; ++i)
+ {
+ if (string.IsNullOrEmpty(args[i]))
+ {
+ string message = string.Format(CultureInfo.InvariantCulture, "The {0}(th) parameter in the command line could not be null or empty.", i + 1);
+ throw new CommandLineParseException(message);
+ }
+ }
+ }
+
+ ///
+ /// Parse the configuration file into a string[], this string[] will be send to the
+ /// ParseArgs(string[] args). This function will do some simple check of the
+ /// Command line, the first character the config line in the file must '-',
+ /// Otherwise, this line will not be parsed.
+ ///
+ /// Configuration file path.
+ /// Configuration strings.
+ public static string[] GetStringsFromConfigFile(string filePath)
+ {
+ if (!File.Exists(filePath))
+ {
+ string message = string.Format(CultureInfo.InvariantCulture, "The configuration file [{0}] can not found.", filePath);
+ throw new CommandLineParseException(message, filePath);
+ }
+
+ FileInfo fileInfo = new FileInfo(filePath);
+
+ if (fileInfo.Length > MaxConfigFileSize)
+ {
+ string message = string.Format(CultureInfo.InvariantCulture, "Not supported configuration file [{0}], for the size of it is bigger than {1} byte.", filePath, MaxConfigFileSize);
+ throw new CommandLineParseException(message, filePath);
+ }
+
+ string[] lines;
+ using (StreamReader streamFile = new StreamReader(filePath))
+ {
+ lines = streamFile.ReadToEnd().Split(Environment.NewLine.ToCharArray());
+ }
+
+ List strList = new List();
+
+ // Go through the file, and expand the listed parameters
+ // into the List of existing parameters.
+ foreach (string line in lines)
+ {
+ string trimedLine = line.Trim();
+
+ if (trimedLine.IndexOf('-') == 0)
+ {
+ string[] strArray = trimedLine.Split(new char[] { ' ', '\t' });
+ foreach (string str in strArray)
+ {
+ if (!string.IsNullOrEmpty(str))
+ {
+ strList.Add(str);
+ }
+ }
+ }
+ }
+
+ return strList.ToArray();
+ }
+
+ ///
+ /// Count the number of required arguments.
+ ///
+ /// Target object to reflect usage information.
+ /// The number of required arguments.
+ public static int GetRequiredArgumentCount(object target)
+ {
+ int count = 0;
+ foreach (FieldInfo field in target.GetType().GetFields(AllFieldBindingFlags))
+ {
+ ArgumentAttribute argument = GetFieldArgumentAttribute(field);
+ if (argument == null)
+ {
+ continue; // skip those field that don't define the ArgumentAttribute.
+ }
+
+ if (!argument.Optional)
+ {
+ count++;
+ }
+ }
+
+ return count;
+ }
+
+ ///
+ /// Build the useage line. First print the file name of current execution files.
+ /// And then, print the each flag of these options.
+ ///
+ /// Target object to reflect usage information.
+ /// Useage string.
+ public static string BuildUsageLine(object target)
+ {
+ StringBuilder sb = new StringBuilder();
+ sb.AppendFormat(CultureInfo.InvariantCulture, "Usage:{0}", Environment.NewLine);
+
+ string[] allModes = GetAllModes(target);
+ if (allModes != null)
+ {
+ foreach (string mode in allModes)
+ {
+ sb.AppendFormat(CultureInfo.InvariantCulture, @" Mode ""{0}"" has following usage: {1}", mode, Environment.NewLine);
+ sb.AppendFormat(CultureInfo.InvariantCulture, " {0} -mode {1}", AppDomain.CurrentDomain.FriendlyName, mode);
+
+ foreach (FieldInfo field in target.GetType().GetFields(AllFieldBindingFlags))
+ {
+ ArgumentAttribute argument = GetFieldArgumentAttribute(field);
+ if (argument == null)
+ {
+ continue; // skip those field that don't define the ArgumentAttribute.
+ }
+
+ if (argument.OptionName == ClpHelper.Mode)
+ {
+ continue;
+ }
+
+ string[] optionalModes = argument.GetOptionalModeArray();
+ string[] requiredModes = argument.GetRequiredModeArray();
+ if (requiredModes == null && optionalModes == null)
+ {
+ // should not print out hidden argument
+ if (!argument.Hidden)
+ {
+ if (argument.Optional)
+ {
+ sb.AppendFormat(CultureInfo.InvariantCulture, " [{0}]", GetFlagAndPlaceHolderString(argument));
+ }
+ else
+ {
+ sb.AppendFormat(CultureInfo.InvariantCulture, " {0}", GetFlagAndPlaceHolderString(argument));
+ }
+ }
+ }
+ else
+ {
+ if ((optionalModes != null) && IsInArray(optionalModes, mode))
+ {
+ sb.AppendFormat(CultureInfo.InvariantCulture, " [{0}]", GetFlagAndPlaceHolderString(argument));
+ }
+ else if (requiredModes != null && IsInArray(requiredModes, mode))
+ {
+ sb.AppendFormat(CultureInfo.InvariantCulture, " {0}", GetFlagAndPlaceHolderString(argument));
+ }
+ }
+ }
+
+ sb.AppendLine(string.Empty);
+ sb.AppendLine(string.Empty);
+ }
+ }
+ else
+ {
+ sb.AppendFormat(CultureInfo.InvariantCulture, " {0}", AppDomain.CurrentDomain.FriendlyName);
+
+ foreach (FieldInfo field in target.GetType().GetFields(AllFieldBindingFlags))
+ {
+ ArgumentAttribute argument = GetFieldArgumentAttribute(field);
+ if (argument == null)
+ {
+ continue; // skip those field that don't define the ArgumentAttribute.
+ }
+
+ string optionLine = BuildOptionLine(argument);
+ sb.Append(optionLine);
+ }
+ }
+
+ sb.AppendLine();
+ sb.AppendLine();
+
+ return sb.ToString();
+ }
+
+ ///
+ /// Print flag and description of each options.
+ ///
+ /// Target object to reflect usage information.
+ /// Flag and description string of each options.
+ public static string BuildOptionsString(object target)
+ {
+ StringBuilder sb = new StringBuilder();
+ sb.AppendLine(" Options\tDescriptions");
+ sb.Append(BuildOptionsString(target, null));
+ return sb.ToString();
+ }
+
+ ///
+ /// Parse the args string from the static Main() or from configuration file.
+ ///
+ /// Argument string array.
+ [System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Globalization", "CA1308:NormalizeStringsToUppercase", Justification = "Ignore.")]
+ public void ParseArgs(string[] args)
+ {
+ Destination destination = null;
+
+ foreach (string str in args)
+ {
+ Destination dest = this.IsFlagStringAndGetTheDestination(str);
+
+ // Is a flag string
+ if (dest != null)
+ {
+ if (destination != null)
+ {
+ destination.Save(this.clpTarget);
+ }
+
+ destination = dest;
+
+ if (destination.AlreadySaved)
+ {
+ string message = string.Format(CultureInfo.InvariantCulture, "The option flag [-{0}] could not be dupalicated.", destination.Argument.OptionName);
+ throw new CommandLineParseException(message, str);
+ }
+ }
+ else
+ {
+ if (destination == null)
+ {
+ destination = this.SaveValueStringToEmptyFlag(str);
+ }
+ else
+ {
+ if (!destination.TryToAddValue(this.clpTarget, str))
+ {
+ destination.Save(this.clpTarget);
+ destination = this.SaveValueStringToEmptyFlag(str);
+ }
+ }
+
+ if (destination != null)
+ {
+ if (destination.Argument.OptionName == ClpHelper.Mode)
+ {
+ this.modeString = str.ToLower(CultureInfo.InvariantCulture);
+ }
+ }
+ }
+ }
+
+ // deal with the last flag
+ if (destination != null)
+ {
+ destination.Save(this.clpTarget);
+ }
+ }
+
+ ///
+ /// By the end of the command line parsing, we must make sure that all non-optional
+ /// Flags have been given by the tool user.
+ ///
+ public void CheckAllRequiredDestination()
+ {
+ string[] allModes = GetAllModes(this.clpTarget);
+ foreach (Destination destination in this.destMap.Values)
+ {
+ bool requiredMissing = false;
+ if (destination.InternalTarget != null)
+ {
+ continue;
+ }
+
+ if (allModes != null)
+ {
+ Debug.Assert(this.destMap.ContainsKey(Mode), "Failed");
+ if (!string.IsNullOrEmpty(this.modeString))
+ {
+ string[] requireModes = destination.Argument.GetRequiredModeArray();
+ string[] optionalModes = destination.Argument.GetOptionalModeArray();
+ if (requireModes == null)
+ {
+ if (optionalModes == null)
+ {
+ // if required modes and optional modes are all empty
+ // Means the argument is commonly optional or not in all modes.
+ // we can use the Optional flag to simplify
+ requiredMissing = !destination.Argument.Optional && !destination.AlreadySaved;
+ }
+ }
+ else
+ {
+ if (IsInArray(requireModes, this.modeString))
+ {
+ requiredMissing = !destination.AlreadySaved;
+ }
+ }
+
+ if (destination.AlreadySaved && (optionalModes != null || requireModes != null))
+ {
+ if ((requireModes == null && !IsInArray(optionalModes, this.modeString)) ||
+ (optionalModes == null && !IsInArray(requireModes, this.modeString)) ||
+ (requireModes != null && optionalModes != null &&
+ !IsInArray(optionalModes, this.modeString) && !IsInArray(requireModes, this.modeString)))
+ {
+ string message = string.Format(CultureInfo.InvariantCulture, "Parameter [{0}] is not needed for mode [{1}].", destination.Argument.OptionName, this.modeString);
+ throw new CommandLineParseException(message);
+ }
+ }
+ }
+ else
+ {
+ string message = string.Format(CultureInfo.InvariantCulture, "The mode option is required for the command.");
+ throw new CommandLineParseException(message);
+ }
+ }
+ else
+ {
+ requiredMissing = !destination.Argument.Optional && !destination.AlreadySaved;
+ }
+
+ if (requiredMissing)
+ {
+ string optionLine = BuildOptionLine(destination.Argument);
+ string message = string.Format(CultureInfo.InvariantCulture, "The option '{0}' is required for the command.", optionLine.Trim());
+ throw new CommandLineParseException(message, "-" + destination.Argument.OptionName);
+ }
+ }
+ }
+
+ ///
+ /// Check if a value is in array.
+ ///
+ /// Array.
+ /// Value.
+ /// Boolean.
+ private static bool IsInArray(string[] arr, string value)
+ {
+ bool found = false;
+ for (int i = 0; i < arr.Length; i++)
+ {
+ if (arr[i] == value)
+ {
+ found = true;
+ break;
+ }
+ }
+
+ return found;
+ }
+
+ private static void CheckModeArray(string[] totalModes, string[] modes)
+ {
+ ArgumentNullException.ThrowIfNull(totalModes);
+ if (modes == null)
+ {
+ return;
+ }
+
+ string msg = "Mode {0} should be listed in mode argument's Modes string.";
+ if (modes != null)
+ {
+ for (int i = 0; i < modes.Length; i++)
+ {
+ if (!IsInArray(totalModes, modes[i]))
+ {
+ throw new ArgumentException(string.Format(CultureInfo.InvariantCulture, msg, modes[i]));
+ }
+ }
+ }
+ }
+
+ private static string BuildOptionsString(object target, string mode)
+ {
+ StringBuilder sb = new StringBuilder();
+ foreach (FieldInfo field in target.GetType().GetFields(AllFieldBindingFlags))
+ {
+ ArgumentAttribute argument = GetFieldArgumentAttribute(field);
+ if (argument == null)
+ {
+ continue;
+ }
+
+ if (!string.IsNullOrEmpty(mode))
+ {
+ string[] modeArray = argument.GetRequiredModeArray();
+ if (modeArray != null)
+ {
+ bool found = IsInArray(modeArray, mode);
+ if (!found)
+ {
+ continue;
+ }
+ }
+ }
+
+ if (!argument.Hidden)
+ {
+ string str = field.FieldType.ToString();
+ int i = str.LastIndexOf('.');
+ str = str.Substring(i == -1 ? 0 : i + 1);
+
+ sb.AppendFormat(CultureInfo.InvariantCulture, " {0}{1}\t\t({3}) {2}", GetFlagAndPlaceHolderString(argument), Environment.NewLine, argument.Description, str);
+ if (argument.InOutType != InOutType.Unknown)
+ {
+ sb.AppendFormat(CultureInfo.InvariantCulture, " [{0}]", Enum.GetName(typeof(InOutType), argument.InOutType));
+ }
+
+ sb.Append(Environment.NewLine);
+ }
+ else
+ {
+ if (!argument.Optional)
+ {
+ string message = string.Format(CultureInfo.InvariantCulture, "Argument for {0} can be hidden but can not be optional at the meantime.", field.Name);
+ Debug.Assert(argument.Optional, message);
+ throw new ArgumentException(message);
+ }
+ }
+ }
+
+ return sb.ToString();
+ }
+
+ private static string[] GetAllModes(object target)
+ {
+ ArgumentAttribute modeArgument = null;
+ foreach (FieldInfo field in target.GetType().GetFields(AllFieldBindingFlags))
+ {
+ ArgumentAttribute argument = GetFieldArgumentAttribute(field);
+ if (argument == null)
+ {
+ continue;
+ }
+
+ if (argument.OptionName == ClpHelper.Mode)
+ {
+ modeArgument = argument;
+ break;
+ }
+ }
+
+ if (modeArgument == null)
+ {
+ return null;
+ }
+
+ string[] modeArray = modeArgument.GetRequiredModeArray();
+ if (modeArray == null || modeArray.Length == 0)
+ {
+ return null;
+ }
+
+ foreach (FieldInfo fieldInfo in target.GetType().GetFields(AllFieldBindingFlags))
+ {
+ ArgumentAttribute argument = GetFieldArgumentAttribute(fieldInfo);
+ if (argument == null)
+ {
+ continue;
+ }
+
+ string[] requiredModes = argument.GetRequiredModeArray();
+ string[] optionalModes = argument.GetOptionalModeArray();
+ CheckModeArray(modeArray, requiredModes);
+ CheckModeArray(modeArray, optionalModes);
+ if (requiredModes != null && optionalModes != null)
+ {
+ for (int i = 0; i < requiredModes.Length; i++)
+ {
+ if (IsInArray(optionalModes, requiredModes[i]))
+ {
+ throw new ArgumentException(string.Format(CultureInfo.InvariantCulture, "Required modes {0} is conflicted with optional modes {1}", argument.RequiredModes, argument.OptionalModes));
+ }
+ }
+ }
+ }
+
+ return modeArray;
+ }
+
+ private static string BuildOptionLine(ArgumentAttribute argument)
+ {
+ StringBuilder sb = new StringBuilder();
+ if (!argument.Hidden)
+ {
+ if (argument.Optional)
+ {
+ sb.AppendFormat(CultureInfo.InvariantCulture, " [{0}]", GetFlagAndPlaceHolderString(argument));
+ }
+ else
+ {
+ sb.AppendFormat(CultureInfo.InvariantCulture, " {0}", GetFlagAndPlaceHolderString(argument));
+ }
+ }
+ else
+ {
+ if (!argument.Optional)
+ {
+ string message = string.Format(CultureInfo.InvariantCulture, "Argument for -{0} can be hidden but can not be optional at the meantime.", argument.OptionName);
+ Debug.Assert(argument.Optional, message);
+ throw new ArgumentException(message);
+ }
+ }
+
+ return sb.ToString();
+ }
+
+ ///
+ /// Get the ArgumentAttribute from the field. If the field don't define this
+ /// Custom attribute, it will return null.
+ ///
+ /// Field information.
+ /// Argument attribute associated with the field.
+ private static ArgumentAttribute GetFieldArgumentAttribute(FieldInfo fieldInfo)
+ {
+ ArgumentAttribute[] argument =
+ (ArgumentAttribute[])fieldInfo.GetCustomAttributes(typeof(ArgumentAttribute), false);
+
+ return argument.Length == 1 ? argument[0] : null;
+ }
+
+ ///
+ /// When output the usage, this function will generate the flag string
+ /// Such as "-time n1 n2..." string.
+ ///
+ /// Argument attribute.
+ /// Argument presentation on command line.
+ private static string GetFlagAndPlaceHolderString(ArgumentAttribute argument)
+ {
+ return (!string.IsNullOrEmpty(argument.OptionName) ? "-" : string.Empty) +
+ argument.OptionName +
+ (string.IsNullOrEmpty(argument.UsagePlaceholder) ?
+ string.Empty : " " + argument.UsagePlaceholder);
+ }
+
+ ///
+ /// Call by the GetFlagAndPlaceHolderString() function, and generate the frendly
+ /// Name of each parameter in command line, such as "n1 n2 ..." string.
+ ///
+ /// Field information.
+ /// Field name of the argument.
+ private static string GetFieldFriendlyTypeName(FieldInfo fieldInfo)
+ {
+ Type type = fieldInfo.FieldType.IsArray ?
+ fieldInfo.FieldType.GetElementType() : fieldInfo.FieldType;
+
+ string str;
+ if (type == typeof(bool))
+ {
+ str = string.Empty;
+ }
+ else
+ {
+ // Use the Type name's first character,
+ // for example: System.int -> i, System.double -> d
+ str = type.ToString();
+ int i = str.LastIndexOf('.');
+ i = i == -1 ? 0 : i + 1;
+ str = char.ToLower(str[i], CultureInfo.CurrentCulture).ToString();
+ }
+
+ return fieldInfo.FieldType.IsArray ? str + "1 " + str + "2 ..." : str;
+ }
+
+ ///
+ /// Check and parse the internal target and external target, then push the result
+ /// Of parsing into the DestMap.
+ /// Internal target class has predefined some flags, such as "-h", "-C"
+ /// External target class are defined by the library users.
+ ///
+ /// Target object to reflect usage information.
+ private void ParseTheDestination(object target)
+ {
+ // Check and parse the internal target, so use Debug.Assert to catch the error.
+ foreach (FieldInfo fieldInfo in typeof(InternalFlags).GetFields(AllFieldBindingFlags))
+ {
+ ArgumentAttribute argument = GetFieldArgumentAttribute(fieldInfo);
+ if (string.IsNullOrEmpty(argument.UsagePlaceholder))
+ {
+ argument.UsagePlaceholder = GetFieldFriendlyTypeName(fieldInfo);
+ }
+
+ Debug.Assert(argument != null, "Failed");
+
+ Destination destination = new Destination(fieldInfo, argument, this.internalTarget);
+
+ Debug.Assert(destination.Argument.OptionName.Length != 0, "Failed");
+ Debug.Assert(char.IsLetter(destination.Argument.OptionName[0]) || destination.Argument.OptionName[0] == '?', "Failed");
+
+ // Assert there is no duplicate flag in the user defined argument class.
+ Debug.Assert(!this.destMap.ContainsKey(destination.Argument.OptionName), "Failed");
+
+ this.destMap.Add(destination.Argument.OptionName, destination);
+ }
+
+ // Check and parse the external target, so use throw exception
+ // to handle the unexpect target difine.
+ foreach (FieldInfo fieldInfo in target.GetType().GetFields(AllFieldBindingFlags))
+ {
+ ArgumentAttribute argument = GetFieldArgumentAttribute(fieldInfo);
+ if (argument == null)
+ {
+ continue;
+ }
+
+ Destination destination = new Destination(fieldInfo, argument, null);
+
+ // Assert user don't define a non-letter as a flag in the user defined argument class.
+ if (destination.Argument.OptionName.Length > 0 && !char.IsLetter(destination.Argument.OptionName[0]))
+ {
+ throw new ArgumentException(string.Format(CultureInfo.InvariantCulture, "User can't define a non-letter flag ({0}).", destination.Argument.OptionName[0]), destination.Argument.OptionName[0].ToString());
+ }
+
+ // Assert there is no duplicate flag in the user defined argument class.
+ if (this.destMap.ContainsKey(destination.Argument.OptionName))
+ {
+ throw new ArgumentException(string.Format(CultureInfo.InvariantCulture, "Duplicate flag are defined in the argument class."), destination.Argument.OptionName);
+ }
+
+ this.destMap.Add(destination.Argument.OptionName, destination);
+ }
+ }
+
+ ///
+ /// Check the given string is a flag, if so, get the corresponding destination class of the flag.
+ ///
+ /// String to test.
+ /// Destination.
+ [System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Globalization", "CA1308:NormalizeStringsToUppercase", Justification = "Ignore.")]
+ private Destination IsFlagStringAndGetTheDestination(string str)
+ {
+ Debug.Assert(!string.IsNullOrEmpty(str), "Failed");
+
+ if (str.Length < 2 || str[0] != '-'
+ || (!char.IsLetter(str[1]) && str[1] != '?'))
+ {
+ return null;
+ }
+
+ str = str.Substring(1).ToLower(CultureInfo.InvariantCulture);
+ return this.destMap.ContainsKey(str) ? this.destMap[str] : null;
+ }
+
+ ///
+ /// Save the given string to the empty flag("") destination class.
+ ///
+ /// Flag string to save, not the realy null Flag, is the "" Flag.
+ /// Destination.
+ private Destination SaveValueStringToEmptyFlag(string str)
+ {
+ Destination destination = this.destMap.ContainsKey(string.Empty) ? this.destMap[string.Empty] : null;
+
+ if (destination == null || destination.AlreadySaved ||
+ !destination.TryToAddValue(this.clpTarget, str))
+ {
+ StringBuilder sb = new StringBuilder();
+ sb.Append($"Unrecognized command {str}. ");
+
+ Assembly assembly = Assembly.GetEntryAssembly();
+ if (assembly != null)
+ {
+ sb.Append($"Run '{Path.GetFileName(assembly.Location)} -?' for help.");
+ }
+
+ throw new CommandLineParseException(sb.ToString(), str);
+ }
+
+ return destination;
+ }
+ }
+
+ ///
+ /// Private class, to hold the information of the target object.
+ ///
+ private class Destination
+ {
+ private FieldInfo fieldInfo;
+ private ArgumentAttribute argument;
+
+ // Hold the internal target, it distinguish
+ private InternalFlags internalTarget;
+ private bool alreadySaved;
+
+ // A internal target to a external target. If it is external, this member is null.
+ private ArrayList parameterList;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// Field information.
+ /// Argument attribute.
+ /// Internal flags.
+ public Destination(FieldInfo fieldInfo, ArgumentAttribute argument, InternalFlags internalTarget)
+ {
+ this.fieldInfo = fieldInfo;
+ this.argument = argument;
+ this.internalTarget = internalTarget;
+
+ // _AlreadySaved = false;
+ this.parameterList = fieldInfo.FieldType.IsArray ? new ArrayList() : null;
+ }
+
+ ///
+ /// Gets internal target.
+ ///
+ public InternalFlags InternalTarget
+ {
+ get { return this.internalTarget; }
+ }
+
+ ///
+ /// Gets Argument attribute.
+ ///
+ public ArgumentAttribute Argument
+ {
+ get { return this.argument; }
+ }
+
+ ///
+ /// Gets a value indicating whether Value already saved.
+ ///
+ public bool AlreadySaved
+ {
+ get { return this.alreadySaved; }
+ }
+
+ ///
+ /// Parse the string to given type of value.
+ ///
+ /// Type of value.
+ /// String to parse.
+ /// Result value.
+ public static object TryParseStringToValue(Type type, string str)
+ {
+ object obj = null;
+
+ if (type == typeof(string))
+ {
+ // string to string, don't need parse.
+ obj = str;
+ }
+ else if (type == typeof(sbyte) || type == typeof(byte) ||
+ type == typeof(short) || type == typeof(ushort) ||
+ type == typeof(int) || type == typeof(uint) ||
+ type == typeof(long) || type == typeof(ulong))
+ {
+ // Use the dec style to parse the string into integer value frist.
+ // If it failed, then use the hex sytle to parse it again.
+ obj = TryParse(str, type, NumberStyles.Integer | NumberStyles.AllowThousands);
+ if (obj == null && str.Substring(0, 2) == "0x")
+ {
+ obj = TryParse(str.Substring(2), type, NumberStyles.HexNumber);
+ }
+ }
+ else if (type == typeof(double) || type == typeof(float))
+ {
+ // Use float style to parse the string into float value.
+ obj = TryParse(str, type, NumberStyles.Float | NumberStyles.AllowThousands);
+ }
+ else
+ {
+ // Use the default style to parse the string.
+ obj = TryParse(str, type);
+ }
+
+ return obj;
+ }
+
+ ///
+ /// Try to and a value to the target. Frist prase the string form parameter
+ /// To a given value. And then, save the value to a target field or a value
+ /// List.
+ ///
+ /// Target object to reflect usage information.
+ /// String value to add.
+ /// True if succeeded, otherwise false.
+ public bool TryToAddValue(object target, string str)
+ {
+ if (this.alreadySaved)
+ {
+ return false;
+ }
+
+ if (this.internalTarget != null)
+ {
+ target = this.internalTarget;
+ }
+
+ // If this field is an array, it will save the prased value into an value list.
+ // Otherwise, it will save the parse value to the field of the target directly.
+ if (this.fieldInfo.FieldType.IsArray)
+ {
+ object value = TryParseStringToValue(this.fieldInfo.FieldType.GetElementType(), str);
+ if (value == null)
+ {
+ return false;
+ }
+
+ this.parameterList.Add(value);
+ }
+ else
+ {
+ object value = TryParseStringToValue(this.fieldInfo.FieldType, str);
+ if (value == null)
+ {
+ return false;
+ }
+
+ this.fieldInfo.SetValue(target, value);
+ this.alreadySaved = true;
+ }
+
+ return true;
+ }
+
+ ///
+ /// Save function will do some cleanup of the value save.
+ ///
+ /// Target object to reflect usage information.
+ public void Save(object target)
+ {
+ if (this.internalTarget != null)
+ {
+ target = this.internalTarget;
+ }
+
+ if (this.fieldInfo.FieldType.IsArray)
+ {
+ // When the filed is an array, this function will save all values in the ParameterList
+ // into the array field.
+ Debug.Assert(!this.alreadySaved, "Failed");
+ Array array = (Array)this.fieldInfo.GetValue(target);
+ if (array != null && array.Length != this.parameterList.Count)
+ {
+ string message = string.Format(CultureInfo.InvariantCulture, "For option flag -{0}, the parameter number is {1}, which is not as expected {2}.", this.argument.OptionName, this.parameterList.Count, array.Length);
+ throw new CommandLineParseException(message, "-" + this.argument.OptionName);
+ }
+
+ this.fieldInfo.SetValue(target, this.parameterList.ToArray(this.fieldInfo.FieldType.GetElementType()));
+ }
+ else if (this.fieldInfo.FieldType == typeof(bool))
+ {
+ if (!this.alreadySaved)
+ {
+ bool b = (bool)this.fieldInfo.GetValue(target);
+ b = !b;
+ this.fieldInfo.SetValue(target, b);
+ }
+ }
+ else if (!this.alreadySaved)
+ {
+ // Other types do nothing, only check its already saved,
+ // beacuse the value must be saved in the TryToAddValue();
+ string message = string.Format(CultureInfo.InvariantCulture, "The option flag [-{0}] needs {1} parameter.", this.argument.OptionName, this.argument.UsagePlaceholder);
+ throw new CommandLineParseException(message, "-" + this.argument.OptionName);
+ }
+
+ this.alreadySaved = true;
+ }
+
+ ///
+ /// Use the given style to parse the string to given type of value.
+ ///
+ /// String to parse.
+ /// Type of value.
+ /// Number styles.
+ /// Result value.
+ private static object TryParse(string str, Type type, NumberStyles ns)
+ {
+ // Use reflection to dynamic load the TryParse function of given type.
+ Type[] typeArgs = new Type[]
+ {
+ typeof(string),
+ typeof(NumberStyles),
+ typeof(IFormatProvider),
+ Type.GetType(type.ToString() + "&"),
+ };
+
+ MethodInfo mi = type.GetMethod("TryParse", typeArgs);
+
+ // Initilze these four parameters of the Tryparse funtion.
+ object[] objArgs = new object[]
+ {
+ str,
+ ns,
+ CultureInfo.InvariantCulture,
+ Activator.CreateInstance(type),
+ };
+
+ return DoTryParse(mi, objArgs);
+ }
+
+ ///
+ /// Use the defalut style to parse the string to given type of value.
+ ///
+ /// String to parse.
+ /// Type of value.
+ /// Result value.
+ private static object TryParse(string str, Type type)
+ {
+ // Use reflection to dynamic load the TryParse function of given type.
+ MethodInfo mi = type.GetMethod("TryParse", new Type[] { typeof(string), Type.GetType(type.ToString() + "&") });
+
+ // Initilze these two parameters of the Tryparse funtion.
+ object[] objArgs = new object[] { str, Activator.CreateInstance(type) };
+
+ return DoTryParse(mi, objArgs);
+ }
+
+ ///
+ /// Run the TryParse function by the given method and parameters.
+ ///
+ /// Method information.
+ /// Method arguments.
+ /// Result value.
+ private static object DoTryParse(MethodInfo methodInfo, object[] methodArgs)
+ {
+ object retVal = methodInfo.Invoke(null, methodArgs);
+
+ if (!(retVal is bool))
+ {
+ throw new ArgumentException(string.Format(CultureInfo.InvariantCulture, "TryParse() method must return a bool value."), methodArgs[methodArgs.Length - 1].GetType().ToString());
+ }
+
+ // the last parameter of TryParse method is a reference of a value.
+ // Therefore, it will return the last value of the parameter array.
+ // If the TryParse function failed when parsing, this DoTryParse will
+ // return null.
+ return (bool)retVal ? methodArgs[methodArgs.Length - 1] : null;
+ }
+ }
+
+ ///
+ /// This class is defined to take the internal flags, such as -h, -?, -C, and etc.
+ /// When the parse begin to parse the target object, it will parse is class's object
+ /// First. So, the parse can first put the internal flags into the DestMap to avoid
+ /// Library user redifined those flags. And When finish parsed all flags, The library
+ /// Will check the property NeedHelp to determinated those flags are appeared in the
+ /// Command line.
+ ///
+ private sealed class InternalFlags
+ {
+ [Argument("h", Description = "Help", Optional = true)]
+ private bool needHelp1;
+
+ [Argument("?", Description = "Help", Optional = true)]
+ private bool needHelp2;
+
+ [Argument("help", Description = "Help", Optional = true)]
+ private bool needHelp3;
+
+ [Argument("conf", Description = "Configuration file", Optional = true)]
+ private string configFile; // use internal instead of private to avoid unusing warning.
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public InternalFlags()
+ {
+ this.needHelp1 = this.needHelp2 = this.needHelp3 = false;
+ }
+
+ ///
+ /// Gets a value indicating whether Flag indicating whether user requires help.
+ ///
+ public bool NeedHelp
+ {
+ get { return this.needHelp1 || this.needHelp2 || this.needHelp3; }
+ }
+
+ ///
+ /// Gets or sets Configuration file path.
+ ///
+ public string ConfigFile
+ {
+ get { return this.configFile; }
+ set { this.configFile = value; }
+ }
+ }
+}
+
+///
+/// When the CommandLineParser meet an unacceptabile command line
+/// Parameter, it will throw the CommandLineParseException. If the
+/// CLP meet another arguments error by anaylse the target object,
+/// It will throw the ArgumentException defined by .NET framework.
+///
+[Serializable]
+#pragma warning disable SA1402 // File may only contain a single type
+public class CommandLineParseException : Exception
+#pragma warning restore SA1402 // File may only contain a single type
+{
+ ///
+ /// The error string is "help".
+ ///
+ public const string ErrorStringHelp = "help";
+
+ private readonly string errorString;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// Message.
+ /// Error string.
+ public CommandLineParseException(string message, string error)
+ : base(message)
+ {
+ this.errorString = string.IsNullOrEmpty(error) ? string.Empty : error;
+ }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public CommandLineParseException()
+ : base()
+ {
+ this.errorString = string.Empty;
+ }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// Message.
+ public CommandLineParseException(string message)
+ : base(message)
+ {
+ this.errorString = string.Empty;
+ }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// Message.
+ /// Inner exception.
+ public CommandLineParseException(string message, Exception inner)
+ : base(message, inner)
+ {
+ this.errorString = string.Empty;
+ }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// Serialization info.
+ /// Streaming context.
+ protected CommandLineParseException(SerializationInfo info, StreamingContext context)
+ : base(info, context)
+ {
+ this.errorString = string.Empty;
+ }
+
+ ///
+ /// Gets To save the error string.
+ ///
+ public string ErrorString
+ {
+ get { return this.errorString; }
+ }
+
+ public override void GetObjectData(SerializationInfo info, StreamingContext context)
+ {
+ base.GetObjectData(info, context);
+ }
+}
\ No newline at end of file
diff --git a/samples/video-translation/csharp/Common/CommonLib/CommandParser/CommentAttribute.cs b/samples/video-translation/csharp/Common/CommonLib/CommandParser/CommentAttribute.cs
new file mode 100644
index 000000000..366e6a1b7
--- /dev/null
+++ b/samples/video-translation/csharp/Common/CommonLib/CommandParser/CommentAttribute.cs
@@ -0,0 +1,51 @@
+//
+// Copyright (c) Microsoft. All rights reserved.
+// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
+//
+
+namespace Microsoft.SpeechServices.CommonLib.CommandParser;
+
+using System;
+
+[AttributeUsage(AttributeTargets.Class, Inherited = false)]
+public sealed class CommentAttribute : Attribute
+{
+ private readonly string headComment;
+ private readonly string rearComment = "Copyright (C) Microsoft Corporation. All rights reserved.";
+
+ public CommentAttribute(string headComment)
+ {
+ if (headComment == null)
+ {
+ throw new ArgumentNullException(nameof(headComment));
+ }
+
+ this.headComment = headComment;
+ }
+
+ public CommentAttribute(string headComment, string rearComment)
+ {
+ if (headComment == null)
+ {
+ throw new ArgumentNullException(nameof(headComment));
+ }
+
+ if (rearComment == null)
+ {
+ throw new ArgumentNullException(nameof(rearComment));
+ }
+
+ this.headComment = headComment;
+ this.rearComment = rearComment;
+ }
+
+ public string HeadComment
+ {
+ get { return this.headComment; }
+ }
+
+ public string RearComment
+ {
+ get { return this.rearComment; }
+ }
+}
diff --git a/samples/video-translation/csharp/Common/CommonLib/CommandParser/ConsoleApp.cs b/samples/video-translation/csharp/Common/CommonLib/CommandParser/ConsoleApp.cs
new file mode 100644
index 000000000..af5eb90ee
--- /dev/null
+++ b/samples/video-translation/csharp/Common/CommonLib/CommandParser/ConsoleApp.cs
@@ -0,0 +1,59 @@
+//
+// Copyright (c) Microsoft. All rights reserved.
+// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
+//
+
+namespace Microsoft.SpeechServices.CommonLib.CommandParser
+{
+ using System;
+ using System.IO;
+ using System.Text;
+ using System.Threading.Tasks;
+
+ public static class ConsoleApp
+ where T : new()
+ {
+ public static async Task RunAsync(string[] arguments, Func> processAsync)
+ {
+ ArgumentNullException.ThrowIfNull(processAsync);
+
+ ArgumentNullException.ThrowIfNull(arguments);
+
+ int ret = ExitCode.NoError;
+
+ T arg = new T();
+ try
+ {
+ try
+ {
+ CommandLineParser.Parse(arguments, arg);
+ }
+ catch (CommandLineParseException cpe)
+ {
+ if (cpe.ErrorString == CommandLineParseException.ErrorStringHelp)
+ {
+ CommandLineParser.PrintUsage(arg);
+ }
+ else if (!string.IsNullOrEmpty(cpe.Message))
+ {
+ Console.WriteLine(cpe.Message);
+ }
+
+ return ExitCode.InvalidArgument;
+ }
+
+ ret = await processAsync(arg).ConfigureAwait(false);
+ return ret;
+ }
+ catch (Exception)
+ {
+ if (ret != ExitCode.NoError)
+ {
+ return ret;
+ }
+
+ throw;
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/samples/video-translation/csharp/Common/CommonLib/CommandParser/ExitCode.cs b/samples/video-translation/csharp/Common/CommonLib/CommandParser/ExitCode.cs
new file mode 100644
index 000000000..8f98ca91c
--- /dev/null
+++ b/samples/video-translation/csharp/Common/CommonLib/CommandParser/ExitCode.cs
@@ -0,0 +1,19 @@
+//
+// Copyright (c) Microsoft. All rights reserved.
+// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
+//
+
+namespace Microsoft.SpeechServices.CommonLib.CommandParser;
+
+public sealed class ExitCode
+{
+ public const int NoError = 0;
+
+ public const int InvalidArgument = -1;
+
+ public const int GenericError = 999;
+
+ private ExitCode()
+ {
+ }
+}
\ No newline at end of file
diff --git a/samples/video-translation/csharp/Common/CommonLib/CommandParser/InOutType.cs b/samples/video-translation/csharp/Common/CommonLib/CommandParser/InOutType.cs
new file mode 100644
index 000000000..59efc8a3e
--- /dev/null
+++ b/samples/video-translation/csharp/Common/CommonLib/CommandParser/InOutType.cs
@@ -0,0 +1,17 @@
+//
+// Copyright (c) Microsoft. All rights reserved.
+// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
+//
+
+namespace Microsoft.SpeechServices.CommonLib.CommandParser;
+
+public enum InOutType
+{
+ Unknown,
+
+ In,
+
+ Out,
+
+ InOut,
+}
diff --git a/samples/video-translation/csharp/Common/CommonLib/CommonConst.cs b/samples/video-translation/csharp/Common/CommonLib/CommonConst.cs
new file mode 100644
index 000000000..525fce4c1
--- /dev/null
+++ b/samples/video-translation/csharp/Common/CommonLib/CommonConst.cs
@@ -0,0 +1,55 @@
+//
+// Copyright (c) Microsoft. All rights reserved.
+// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
+//
+
+namespace Microsoft.SpeechServices.CommonLib;
+
+using System;
+using System.Collections.Generic;
+
+public static class CommonConst
+{
+ public static class VideoTranslation
+ {
+ public static class ApiVersions
+ {
+ public const string PublicPreviewApiVersion20240520Preview = "2024-05-20-preview";
+ }
+ }
+
+ public static class Http
+ {
+ public static readonly TimeSpan OperationQueryDuration = TimeSpan.FromSeconds(3);
+
+ public static class Headers
+ {
+ public const string OperationId = "Operation-Id";
+ public const string OperationLocation = "Operation-Location";
+
+ public readonly static IEnumerable OperationHeaders = new[]
+ {
+ OperationId,
+ OperationLocation,
+ };
+ }
+
+ public static class MimeType
+ {
+ public const string HttpAudioBasic = "audio/basic";
+ public const string HttpAudioSilk = "audio/SILK";
+ public const string HttpAudioSilk24K = "audio/SILK; samplerate=24000";
+ public const string HttpAudioXwave = "audio/x-wav";
+ public const string TextXml = "text/xml";
+ public const string Text = "text/plain";
+ public const string TextJson = "application/json";
+ public const string HttpSsmlXml = "application/ssml+xml";
+ public const string HttpAudioMpeg = "audio/mpeg";
+ public const string OpusAudio16K = "audio/ogg; codecs=opus; rate=16000";
+ public const string OpusAudio24K = "audio/ogg; codecs=opus; rate=24000";
+ public const string OpusAudio48K = "audio/ogg; codecs=opus; rate=48000";
+ public const string Zip = "application/zip";
+ public const string AudioMp3 = "audio/mpeg3";
+ }
+ }
+}
diff --git a/samples/video-translation/csharp/Common/CommonLib/CommonLib.csproj b/samples/video-translation/csharp/Common/CommonLib/CommonLib.csproj
new file mode 100644
index 000000000..0aa211b67
--- /dev/null
+++ b/samples/video-translation/csharp/Common/CommonLib/CommonLib.csproj
@@ -0,0 +1,23 @@
+
+
+
+ net7.0
+ Microsoft.SpeechServices.CommonLib
+ Microsoft.SpeechServices.CommonLib
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/video-translation/csharp/Common/CommonLib/CustomContractResolver.cs b/samples/video-translation/csharp/Common/CommonLib/CustomContractResolver.cs
new file mode 100644
index 000000000..3885709e8
--- /dev/null
+++ b/samples/video-translation/csharp/Common/CommonLib/CustomContractResolver.cs
@@ -0,0 +1,143 @@
+//
+// Copyright (c) Microsoft. All rights reserved.
+// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
+//
+
+namespace Microsoft.SpeechServices.CommonLib;
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Reflection;
+using Microsoft.SpeechServices.CommonLib.Enums;
+using Newtonsoft.Json;
+using Newtonsoft.Json.Converters;
+using Newtonsoft.Json.Serialization;
+
+public class CustomContractResolver : CamelCasePropertyNamesContractResolver
+{
+ public static readonly CustomContractResolver ReaderContractResolver = new CustomContractResolver();
+ public static readonly CustomContractResolver WriterContractResolver = new CustomContractResolver();
+
+ public static JsonSerializerSettings WriterSettings { get; } = new JsonSerializerSettings
+ {
+ ContractResolver = WriterContractResolver,
+ ConstructorHandling = ConstructorHandling.AllowNonPublicDefaultConstructor,
+ Converters = new List { new StringEnumConverter() { AllowIntegerValues = false } },
+ DateFormatString = "yyyy-MM-ddTHH\\:mm\\:ss.fffZ",
+ NullValueHandling = NullValueHandling.Ignore,
+ Formatting = Formatting.Indented,
+ ReferenceLoopHandling = ReferenceLoopHandling.Ignore
+ };
+
+ public static JsonSerializerSettings ReaderSettings { get; } = new JsonSerializerSettings
+ {
+ ContractResolver = ReaderContractResolver,
+ ConstructorHandling = ConstructorHandling.AllowNonPublicDefaultConstructor,
+ Converters = new List { new StringEnumConverter() { AllowIntegerValues = true } },
+ Formatting = Formatting.Indented
+ };
+
+ public static string GetResolvedPropertyName(PropertyInfo property)
+ {
+ ArgumentNullException.ThrowIfNull(property);
+
+ string propertyName;
+ var jsonAttribute = property.GetCustomAttributes(typeof(JsonPropertyAttribute)).Cast().FirstOrDefault();
+ if (jsonAttribute != null && !string.IsNullOrWhiteSpace(jsonAttribute.PropertyName))
+ {
+ propertyName = jsonAttribute.PropertyName;
+ }
+ else
+ {
+ propertyName = ReaderContractResolver.GetResolvedPropertyName(property.Name);
+ }
+
+ return propertyName;
+ }
+
+ protected override JsonProperty CreateProperty(MemberInfo member, MemberSerialization memberSerialization)
+ {
+ var property = base.CreateProperty(member, memberSerialization);
+
+ if (!property.Writable)
+ {
+ var propertyInfo = member as PropertyInfo;
+ if (propertyInfo != null)
+ {
+ property.Writable = propertyInfo.CanWrite;
+ }
+ }
+
+ const string createdDateTime = "CreatedDateTime";
+ const string lastActionDateTime = "LastActionDateTime";
+ const string status = "Status";
+ const string timeToLive = "TimeToLive";
+ const string duration = "Duration";
+ const string customProperties = "CustomProperties";
+
+ if (property.PropertyType == typeof(DateTime) && property.PropertyName == this.ResolvePropertyName(createdDateTime))
+ {
+ property.ShouldSerialize =
+ instance =>
+ {
+ var value = (DateTime)instance.GetType().GetProperty(createdDateTime).GetValue(instance);
+ return value != default(DateTime);
+ };
+ }
+ else if (property.PropertyType == typeof(DateTime) && property.PropertyName == this.ResolvePropertyName(lastActionDateTime))
+ {
+ property.ShouldSerialize =
+ instance =>
+ {
+ var value = (DateTime)instance.GetType().GetProperty(lastActionDateTime).GetValue(instance);
+ return value != default(DateTime);
+ };
+ }
+ else if (property.PropertyType == typeof(OneApiState) && property.PropertyName == this.ResolvePropertyName(status))
+ {
+ property.ShouldSerialize =
+ instance =>
+ {
+ var value = (OneApiState)instance.GetType().GetProperty(status).GetValue(instance);
+ return value != default(OneApiState);
+ };
+ }
+ else if (property.PropertyType == typeof(TimeSpan) && property.PropertyName == this.ResolvePropertyName(timeToLive))
+ {
+ property.ShouldSerialize =
+ instance =>
+ {
+ var value = (TimeSpan)instance.GetType().GetProperty(timeToLive).GetValue(instance);
+ return value != TimeSpan.Zero;
+ };
+ }
+ else if (property.PropertyType == typeof(TimeSpan) && property.PropertyName == this.ResolvePropertyName(duration))
+ {
+ property.ShouldSerialize =
+ instance =>
+ {
+ var value = (TimeSpan)instance.GetType().GetProperty(duration).GetValue(instance);
+ return value != TimeSpan.Zero;
+ };
+ }
+ else if (property.PropertyType == typeof(IReadOnlyDictionary) && property.PropertyName == this.ResolvePropertyName(customProperties))
+ {
+ property.ShouldSerialize =
+ instance =>
+ {
+ var value = (IReadOnlyDictionary)instance.GetType().GetProperty(customProperties).GetValue(instance);
+ return value != null && value.Count > 0;
+ };
+ }
+
+ return property;
+ }
+
+ // do not javascriptify (camel case) dictionary keys. This would e.g. change
+ // key in artifact properties.
+ protected override string ResolveDictionaryKey(string dictionaryKey)
+ {
+ return dictionaryKey;
+ }
+}
diff --git a/samples/video-translation/csharp/Common/CommonLib/DataContracts/DTOs/Public/PaginatedResources.cs b/samples/video-translation/csharp/Common/CommonLib/DataContracts/DTOs/Public/PaginatedResources.cs
new file mode 100644
index 000000000..e5af96422
--- /dev/null
+++ b/samples/video-translation/csharp/Common/CommonLib/DataContracts/DTOs/Public/PaginatedResources.cs
@@ -0,0 +1,18 @@
+//
+// Copyright (c) Microsoft. All rights reserved.
+// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
+//
+
+namespace Microsoft.SpeechServices.DataContracts;
+
+using System;
+using System.Collections.Generic;
+using Newtonsoft.Json;
+
+public class PaginatedResources
+{
+ public IEnumerable Value { get; set; }
+
+ [JsonProperty(PropertyName = "@nextLink")]
+ public Uri NextLink { get; set; }
+}
diff --git a/samples/video-translation/csharp/Common/CommonLib/DataContracts/DTOs/Public/ResponseBase.cs b/samples/video-translation/csharp/Common/CommonLib/DataContracts/DTOs/Public/ResponseBase.cs
new file mode 100644
index 000000000..867b26196
--- /dev/null
+++ b/samples/video-translation/csharp/Common/CommonLib/DataContracts/DTOs/Public/ResponseBase.cs
@@ -0,0 +1,13 @@
+//
+// Copyright (c) Microsoft. All rights reserved.
+// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
+//
+
+namespace Microsoft.SpeechServices.DataContracts;
+
+using System;
+
+public abstract class ResponseBase
+{
+ public Uri Self { get; set; }
+}
diff --git a/samples/video-translation/csharp/Common/CommonLib/DataContracts/DTOs/Public/StatefulResourceBase.cs b/samples/video-translation/csharp/Common/CommonLib/DataContracts/DTOs/Public/StatefulResourceBase.cs
new file mode 100644
index 000000000..58f54eef6
--- /dev/null
+++ b/samples/video-translation/csharp/Common/CommonLib/DataContracts/DTOs/Public/StatefulResourceBase.cs
@@ -0,0 +1,17 @@
+//
+// Copyright (c) Microsoft. All rights reserved.
+// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
+//
+
+namespace Microsoft.SpeechServices.Cris.Http.DTOs.Public;
+
+using System;
+using Microsoft.SpeechServices.Common.Client;
+using Microsoft.SpeechServices.CommonLib.Enums;
+
+public abstract class StatefulResourceBase : StatelessResourceBase
+{
+ public OneApiState Status { get; set; }
+
+ public DateTime LastActionDateTime { get; set; }
+}
diff --git a/samples/video-translation/csharp/Common/CommonLib/DataContracts/DTOs/Public/StatelessResourceBase.cs b/samples/video-translation/csharp/Common/CommonLib/DataContracts/DTOs/Public/StatelessResourceBase.cs
new file mode 100644
index 000000000..eb717d1f6
--- /dev/null
+++ b/samples/video-translation/csharp/Common/CommonLib/DataContracts/DTOs/Public/StatelessResourceBase.cs
@@ -0,0 +1,27 @@
+//
+// Copyright (c) Microsoft. All rights reserved.
+// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
+//
+
+namespace Microsoft.SpeechServices.Cris.Http.DTOs.Public;
+
+using System;
+
+public abstract class StatelessResourceBase
+{
+ public Uri Self { get; set; }
+
+ public string Id { get; set; }
+
+ public string DisplayName { get; set; }
+
+ public string Description { get; set; }
+
+ public DateTime CreatedDateTime { get; set; }
+
+ public Guid ParseIdFromSelf()
+ {
+ var url = this.Self.OriginalString;
+ return Guid.Parse(url.Substring(url.LastIndexOf("/") + 1, 36));
+ }
+}
diff --git a/samples/video-translation/csharp/Common/CommonLib/DataContracts/DTOs/Public/VoiceGeneralTaskBrief.cs b/samples/video-translation/csharp/Common/CommonLib/DataContracts/DTOs/Public/VoiceGeneralTaskBrief.cs
new file mode 100644
index 000000000..89c227ffb
--- /dev/null
+++ b/samples/video-translation/csharp/Common/CommonLib/DataContracts/DTOs/Public/VoiceGeneralTaskBrief.cs
@@ -0,0 +1,12 @@
+//
+// Copyright (c) Microsoft. All rights reserved.
+// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
+//
+
+using Microsoft.SpeechServices.Cris.Http.DTOs.Public;
+
+namespace Microsoft.SpeechServices.DataContracts;
+
+public class VoiceGeneralTaskBrief : StatefulResourceBase
+{
+}
diff --git a/samples/video-translation/csharp/Common/CommonLib/DataContracts/DTOs/Public/VoiceGeneralTaskInputFileBase.cs b/samples/video-translation/csharp/Common/CommonLib/DataContracts/DTOs/Public/VoiceGeneralTaskInputFileBase.cs
new file mode 100644
index 000000000..6d36ea60f
--- /dev/null
+++ b/samples/video-translation/csharp/Common/CommonLib/DataContracts/DTOs/Public/VoiceGeneralTaskInputFileBase.cs
@@ -0,0 +1,17 @@
+//
+// Copyright (c) Microsoft. All rights reserved.
+// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
+//
+
+namespace Microsoft.SpeechServices.Cris.Http.DTOs.Public.VoiceGeneralTask;
+
+using System;
+
+public class VoiceGeneralTaskInputFileBase : StatefulResourceBase
+{
+ public string FileContentSha256 { get; set; }
+
+ public Uri Url { get; set; }
+
+ public long? Version { get; set; }
+}
diff --git a/samples/video-translation/csharp/Common/CommonLib/Enums/OneApiState.cs b/samples/video-translation/csharp/Common/CommonLib/Enums/OneApiState.cs
new file mode 100644
index 000000000..f266ebd9e
--- /dev/null
+++ b/samples/video-translation/csharp/Common/CommonLib/Enums/OneApiState.cs
@@ -0,0 +1,24 @@
+//
+// Copyright (c) Microsoft. All rights reserved.
+// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
+//
+
+using System;
+using System.Runtime.Serialization;
+
+namespace Microsoft.SpeechServices.CommonLib.Enums;
+
+[DataContract]
+public enum OneApiState
+{
+ [Obsolete("Do not use directly - used to discover deserializer issues.")]
+ None = 0,
+
+ NotStarted,
+
+ Running,
+
+ Succeeded,
+
+ Failed,
+}
\ No newline at end of file
diff --git a/samples/video-translation/csharp/Common/CommonLib/Enums/VideoTranslationFileKind.cs b/samples/video-translation/csharp/Common/CommonLib/Enums/VideoTranslationFileKind.cs
new file mode 100644
index 000000000..00a218558
--- /dev/null
+++ b/samples/video-translation/csharp/Common/CommonLib/Enums/VideoTranslationFileKind.cs
@@ -0,0 +1,15 @@
+//
+// Copyright (c) Microsoft. All rights reserved.
+// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
+//
+
+namespace Microsoft.SpeechServices.CommonLib.Enums;
+
+public enum VideoTranslationFileKind
+{
+ None = 0,
+
+ VideoFile,
+
+ AudioFile,
+}
diff --git a/samples/video-translation/csharp/Common/CommonLib/Enums/VideoTranslationMergeParagraphAudioAlignKind.cs b/samples/video-translation/csharp/Common/CommonLib/Enums/VideoTranslationMergeParagraphAudioAlignKind.cs
new file mode 100644
index 000000000..f55d869a2
--- /dev/null
+++ b/samples/video-translation/csharp/Common/CommonLib/Enums/VideoTranslationMergeParagraphAudioAlignKind.cs
@@ -0,0 +1,18 @@
+//
+// Copyright (c) Microsoft. All rights reserved.
+// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
+//
+
+namespace Microsoft.SpeechServices.CommonLib.Enums;
+
+using System;
+
+public enum VideoTranslationMergeParagraphAudioAlignKind
+{
+ [Obsolete("Do not use directly - used to discover serializer issues.")]
+ None = 0,
+
+ TruncateIfExceed,
+
+ SpeedUpIfExceed,
+}
diff --git a/samples/video-translation/csharp/Common/CommonLib/Enums/VideoTranslationVoiceKind.cs b/samples/video-translation/csharp/Common/CommonLib/Enums/VideoTranslationVoiceKind.cs
new file mode 100644
index 000000000..730e69a78
--- /dev/null
+++ b/samples/video-translation/csharp/Common/CommonLib/Enums/VideoTranslationVoiceKind.cs
@@ -0,0 +1,20 @@
+//
+// Copyright (c) Microsoft. All rights reserved.
+// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
+//
+
+using System;
+
+namespace Microsoft.SpeechServices.Common.Client;
+
+public enum VideoTranslationVoiceKind
+{
+ [Obsolete("Do not use directly - used to discover serializer issues.")]
+ None = 0,
+
+ PlatformVoice,
+
+ PersonalVoice,
+
+ ZeroShot,
+}
diff --git a/samples/video-translation/csharp/Common/CommonLib/Extensions/EnumExtensions.cs b/samples/video-translation/csharp/Common/CommonLib/Extensions/EnumExtensions.cs
new file mode 100644
index 000000000..bed0a4515
--- /dev/null
+++ b/samples/video-translation/csharp/Common/CommonLib/Extensions/EnumExtensions.cs
@@ -0,0 +1,21 @@
+//
+// Copyright (c) Microsoft. All rights reserved.
+// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
+//
+
+namespace Microsoft.SpeechServices.CommonLib.Extensions;
+
+using System;
+using System.Linq;
+using System.Reflection;
+
+public static class EnumExtensions
+{
+ public static T GetAttributeOfType(this Enum enumValue) where T : Attribute
+ {
+ var type = enumValue.GetType();
+ var memInfo = type.GetMember(enumValue.ToString()).First();
+ var attributes = memInfo.GetCustomAttributes(false);
+ return attributes.FirstOrDefault();
+ }
+}
diff --git a/samples/video-translation/csharp/Common/CommonLib/Extensions/FileNameExtensions.cs b/samples/video-translation/csharp/Common/CommonLib/Extensions/FileNameExtensions.cs
new file mode 100644
index 000000000..e76b9998c
--- /dev/null
+++ b/samples/video-translation/csharp/Common/CommonLib/Extensions/FileNameExtensions.cs
@@ -0,0 +1,295 @@
+//
+// Copyright (c) Microsoft. All rights reserved.
+// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
+//
+
+namespace Microsoft.SpeechServices.Common;
+
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+
+public static class FileNameExtensions
+{
+ public const char FileExtensionDelimiter = '.';
+
+ public const string Mp3 = "mp3";
+
+ public const string Mp4 = "mp4";
+
+ public const string CloudAudioMetadataFile = "metadata";
+
+ public const string VideoTranslationDubbingMetricsReferenceYaml = "info.yaml";
+
+ public const string CloudAudioTsvFile = "tsv";
+
+ public const string Waveform = "wav";
+
+ public const string RawWave = "raw";
+
+ public const string Ogg = "ogg";
+
+ public const string Text = "txt";
+
+ public const string Tsv = "tsv";
+
+ public const string Xml = "xml";
+
+ public const string Yaml = "yaml";
+
+ public const string Configuration = "config";
+
+ public const string CsvFile = "csv";
+
+ public const string ExcelFile = "xlsx";
+
+ public const string ZipFile = "zip";
+
+ public const string HtmlFile = "html";
+
+ public const string PngFile = "png";
+
+ public const string JpegFile = "jpeg";
+
+ public const string JsonFile = "json";
+
+ public const string Zip7Z = "7z";
+
+ public const string IniFile = "ini";
+
+ public const string LgMarkdownFile = "lg";
+
+ public const string PdfFile = "pdf";
+
+ public const string PptxFile = "pptx";
+
+ public const string WaveformRaw = "raw";
+
+ public const string SubRipFile = "srt";
+
+ public const string WebVttFile = "vtt";
+
+ public const string WebmVideoFile = "webm";
+
+ public const string M4aAudioFile = "m4a";
+
+ public const string PitchF0File = "if0";
+
+ public static string EnsureExtensionWithoutDelimiter(this string extension)
+ {
+ string extensionWithoutDelimeter = string.Empty;
+ if (!string.IsNullOrEmpty(extension))
+ {
+ if (extension[0] == FileExtensionDelimiter)
+ {
+ extensionWithoutDelimeter = extension.Substring(1);
+ }
+ else
+ {
+ extensionWithoutDelimeter = extension;
+ }
+ }
+
+ return extensionWithoutDelimeter;
+ }
+
+ public static string EnsureExtensionWithDelimiter(this string extension)
+ {
+ string extensionWithDelimiter = extension;
+ if (!string.IsNullOrEmpty(extension))
+ {
+ if (extension[0] != FileExtensionDelimiter)
+ {
+ extensionWithDelimiter = FileExtensionDelimiter + extension;
+ }
+ else
+ {
+ extensionWithDelimiter = extension;
+ }
+ }
+
+ return extensionWithDelimiter;
+ }
+
+ public static string AppendExtensionName(this string file, string extensionName)
+ {
+ extensionName = extensionName ?? string.Empty;
+ return (string.IsNullOrEmpty(extensionName) || extensionName[0] == FileExtensionDelimiter) ? file + extensionName : file + FileExtensionDelimiter + extensionName;
+ }
+
+ public static bool IsWithFileExtension(this string file, string extensionName)
+ {
+ if (string.IsNullOrEmpty(file))
+ {
+ throw new ArgumentNullException(nameof(file));
+ }
+
+ if (string.IsNullOrEmpty(extensionName))
+ {
+ throw new ArgumentNullException(nameof(extensionName));
+ }
+
+ if (extensionName[0] != FileExtensionDelimiter)
+ {
+ extensionName = FileExtensionDelimiter + extensionName;
+ }
+
+ return file.EndsWith(extensionName, StringComparison.OrdinalIgnoreCase);
+ }
+
+ public static bool IsSameFileExtension(this string actualExtension, string expectedExtension)
+ {
+ if (string.IsNullOrEmpty(actualExtension))
+ {
+ throw new ArgumentNullException(nameof(actualExtension));
+ }
+
+ if (string.IsNullOrEmpty(expectedExtension))
+ {
+ throw new ArgumentNullException(nameof(expectedExtension));
+ }
+
+ string actualExtensionWithoutDelimeter = actualExtension;
+ if (actualExtension[0] == FileExtensionDelimiter)
+ {
+ actualExtensionWithoutDelimeter = actualExtension.Substring(1);
+ }
+
+ string expectedExtensionWithoutDelimeter = expectedExtension;
+ if (expectedExtension[0] == FileExtensionDelimiter)
+ {
+ expectedExtensionWithoutDelimeter = expectedExtension.Substring(1);
+ }
+
+ bool isSame = true;
+ if (string.CompareOrdinal(actualExtensionWithoutDelimeter, expectedExtensionWithoutDelimeter) != 0)
+ {
+ isSame = false;
+ }
+
+ return isSame;
+ }
+
+ public static bool IsSupportedFileExtension(this string actualExtension, IEnumerable supportedExtensions, StringComparison stringComparison = StringComparison.CurrentCulture)
+ {
+ ArgumentNullException.ThrowIfNull(actualExtension);
+ ArgumentNullException.ThrowIfNull(supportedExtensions);
+
+ string actualExtensionWithoutDelimeter = actualExtension.EnsureExtensionWithoutDelimiter();
+ var supportedExtensionsWithoutDelimeter = supportedExtensions.Select(extension => extension.EnsureExtensionWithoutDelimiter());
+
+ return supportedExtensionsWithoutDelimeter.Any(extenstion => actualExtensionWithoutDelimeter.Equals(extenstion, stringComparison));
+ }
+
+ public static string CreateSearchPatternWithFileExtension(this string fileExtension)
+ {
+ if (string.IsNullOrEmpty(fileExtension))
+ {
+ throw new ArgumentNullException(nameof(fileExtension));
+ }
+
+ return "*".AppendExtensionName(fileExtension);
+ }
+
+ public static string RemoveFilePathExtension(this string filePath)
+ {
+ if (string.IsNullOrEmpty(filePath))
+ {
+ throw new ArgumentNullException(nameof(filePath));
+ }
+
+ return Path.Combine(Path.GetDirectoryName(filePath), Path.GetFileNameWithoutExtension(filePath));
+ }
+
+ public static string ChangeFilePathExtension(this string filePath, string newFileNameExtension)
+ {
+ if (string.IsNullOrEmpty(filePath))
+ {
+ throw new ArgumentNullException(nameof(filePath));
+ }
+
+ return Path.GetFileNameWithoutExtension(filePath).AppendExtensionName(newFileNameExtension);
+ }
+
+ public static bool HasFileExtension(this string fileName)
+ {
+ try
+ {
+ return !string.IsNullOrWhiteSpace(fileName) && !string.IsNullOrWhiteSpace(fileName.GetFileExtension());
+ }
+ catch (ArgumentException)
+ {
+ return false;
+ }
+ }
+
+ public static string GetFileExtension(this string fileName, bool withDelimiter = true)
+ {
+ if (string.IsNullOrWhiteSpace(fileName))
+ {
+ throw new ArgumentException("The file name is either an empty string, null or whitespace.", nameof(fileName));
+ }
+
+ try
+ {
+ var fileInfo = new FileInfo(fileName);
+
+ if (!withDelimiter)
+ {
+ if (fileInfo.Extension.StartsWith(FileExtensionDelimiter.ToString(), StringComparison.InvariantCultureIgnoreCase))
+ {
+ return fileInfo.Extension.Substring(1);
+ }
+ }
+
+ return fileInfo.Extension;
+ }
+ catch (ArgumentException)
+ {
+ return string.Empty;
+ }
+ }
+
+ public static string GetLowerCaseFileNameExtensionWithoutDot(string fileName)
+ {
+ if (string.IsNullOrEmpty(fileName))
+ {
+ throw new ArgumentNullException(nameof(fileName));
+ }
+
+ var extension = Path.GetExtension(fileName);
+ if (string.IsNullOrEmpty(extension))
+ {
+ return extension;
+ }
+
+ extension = extension.TrimStart('.');
+#pragma warning disable CA1308 // Normalize strings to uppercase
+ return extension.ToLowerInvariant();
+#pragma warning restore CA1308 // Normalize strings to uppercase
+ }
+
+ public static string GetFileNameExtensionFromCodec(string codec)
+ {
+ var fileExtension = FileNameExtensions.Waveform;
+ if (codec.IndexOf("riff", StringComparison.OrdinalIgnoreCase) >= 0)
+ {
+ fileExtension = FileNameExtensions.Waveform;
+ }
+ else if (codec.IndexOf("mp3", StringComparison.OrdinalIgnoreCase) >= 0)
+ {
+ fileExtension = FileNameExtensions.Mp3;
+ }
+ else if (codec.IndexOf("raw", StringComparison.OrdinalIgnoreCase) >= 0)
+ {
+ fileExtension = FileNameExtensions.RawWave;
+ }
+ else if (codec.IndexOf("json", StringComparison.OrdinalIgnoreCase) >= 0)
+ {
+ fileExtension = FileNameExtensions.JsonFile;
+ }
+
+ return fileExtension;
+ }
+}
diff --git a/samples/video-translation/csharp/Common/CommonLib/Extensions/StringExtensions.cs b/samples/video-translation/csharp/Common/CommonLib/Extensions/StringExtensions.cs
new file mode 100644
index 000000000..abe278949
--- /dev/null
+++ b/samples/video-translation/csharp/Common/CommonLib/Extensions/StringExtensions.cs
@@ -0,0 +1,54 @@
+//
+// Copyright (c) Microsoft. All rights reserved.
+// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
+//
+
+namespace Microsoft.SpeechServices.CommonLib.Extensions;
+
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Text;
+using System.Text.RegularExpressions;
+
+public static class StringExtensions
+{
+ public static string MaskSasToken(this string str)
+ {
+ if (string.IsNullOrWhiteSpace(str))
+ {
+ return str;
+ }
+
+ var sasRegexPattern = "(?sig=[\\w%]+)";
+ var matches = Regex.Matches(str, sasRegexPattern);
+ foreach (Match match in matches)
+ {
+ str = str.Replace(match.Groups["signature"].Value, "SIGMASKED");
+ }
+
+ return str;
+ }
+
+ public static IReadOnlyDictionary ToDictionaryWithDelimeter(this string value)
+ {
+ var headers = new Dictionary();
+ if (!string.IsNullOrEmpty(value))
+ {
+ var headerPairs = value.Split(new char[] { ',', ';' }, StringSplitOptions.RemoveEmptyEntries);
+ foreach (var headerPair in headerPairs.Where(x => !string.IsNullOrEmpty(x)))
+ {
+ var delimeterIndex = headerPair.IndexOf('=');
+ if (delimeterIndex < 0)
+ {
+ throw new InvalidDataException($"Invalid argument format: {value}");
+ }
+
+ headers[headerPair.Substring(0, delimeterIndex)] = headerPair.Substring(delimeterIndex + 1);
+ }
+ }
+
+ return headers;
+ }
+}
diff --git a/samples/video-translation/csharp/Common/CommonLib/HttpClientBase.cs b/samples/video-translation/csharp/Common/CommonLib/HttpClientBase.cs
new file mode 100644
index 000000000..f14f6463a
--- /dev/null
+++ b/samples/video-translation/csharp/Common/CommonLib/HttpClientBase.cs
@@ -0,0 +1,222 @@
+//
+// Copyright (c) Microsoft. All rights reserved.
+// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
+//
+
+namespace Microsoft.SpeechServices.CommonLib.Util;
+
+using Flurl;
+using Flurl.Http;
+using Flurl.Http.Configuration;
+using Microsoft.SpeechServices.CommonLib.Enums;
+using Microsoft.SpeechServices.Cris.Http.DTOs.Public;
+using Microsoft.SpeechServices.CustomVoice.TtsLib.TtsUtil;
+using Newtonsoft.Json;
+using Polly;
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+using System.Net;
+using System.Threading.Tasks;
+
+public abstract class HttpClientBase
+ where TDeploymentEnvironment : Enum
+{
+ public HttpClientBase(HttpClientConfigBase config)
+ {
+ this.Config = config;
+ }
+
+ protected HttpClientConfigBase Config { get; set; }
+
+ public abstract string ControllerName { get; }
+
+ public async Task DeleteByIdAsync(
+ string id,
+ IReadOnlyDictionary queryParams = null)
+ {
+ var url = this.BuildRequestBase();
+
+ url = url.AppendPathSegment(id);
+
+ if (queryParams != null)
+ {
+ foreach (var (name, value) in queryParams)
+ {
+ url = url.SetQueryParam(name, value);
+ }
+ }
+
+ return await this.RequestWithRetryAsync(async () =>
+ {
+ return await url
+ .DeleteAsync()
+ .ConfigureAwait(false);
+ }).ConfigureAwait(false);
+ }
+
+ public async Task QueryByIdResponseStringAsync(
+ Guid id,
+ IReadOnlyDictionary additionalHeaders = null)
+ {
+ var url = this.BuildRequestBase(additionalHeaders: additionalHeaders)
+ .AppendPathSegment(id.ToString());
+
+ return await this.RequestWithRetryAsync(async () =>
+ {
+ return await url
+ .GetAsync()
+ .ReceiveString()
+ .ConfigureAwait(false);
+ }).ConfigureAwait(false);
+ }
+
+ protected async Task QueryByIdAsync(
+ Guid id,
+ IReadOnlyDictionary additionalHeaders = null)
+ {
+ var url = this.BuildRequestBase(additionalHeaders: additionalHeaders)
+ .AppendPathSegment(id.ToString());
+
+ return await this.RequestWithRetryAsync(async () =>
+ {
+ return await url
+ .GetAsync()
+ .ReceiveJson()
+ .ConfigureAwait(false);
+ }).ConfigureAwait(false);
+ }
+
+ protected IFlurlRequest BuildRequestBase(
+ IReadOnlyDictionary additionalHeaders = null)
+ {
+ var url = this.Config.RootUrl
+ .AppendPathSegment(this.ControllerName)
+ .SetQueryParam("api-version", this.Config.ApiVersion)
+ .WithHeader("Ocp-Apim-Subscription-Key", this.Config.SubscriptionKey);
+ if (additionalHeaders != null)
+ {
+ foreach (var additionalHeader in additionalHeaders)
+ {
+ url.WithHeader(additionalHeader.Key, additionalHeader.Value);
+ }
+ }
+
+ // Default json serializer will serialize enum to number, which will cause API parse DTO failure:
+ // "Error converting value 0 to type 'Microsoft.SpeechServices.Common.Client.OneApiState'. Path 'Status', line 1, position 56."
+ url.Settings.JsonSerializer = new NewtonsoftJsonSerializer(CustomContractResolver.WriterSettings);
+
+ return url;
+ }
+
+ public async Task QueryTaskByIdUntilTerminatedAsync(
+ Guid id,
+ IReadOnlyDictionary additionalHeaders = null,
+ bool printFirstQueryResult = false,
+ TimeSpan? timeout = null)
+ where T : StatefulResourceBase
+ {
+ var startTime = DateTime.Now;
+ OneApiState? state = null;
+ var firstTimePrinted = false;
+
+ while (DateTime.Now - startTime < (timeout ?? TimeSpan.FromHours(3)))
+ {
+ var translation = await this.QueryByIdAsync(id, additionalHeaders).ConfigureAwait(false);
+ if (translation == null)
+ {
+ return null;
+ }
+
+ var runPrinted = false;
+ if (printFirstQueryResult && !firstTimePrinted)
+ {
+ runPrinted = true;
+ firstTimePrinted = true;
+ ConsoleMaskSasHelper.WriteLineMaskSas(JsonConvert.SerializeObject(
+ translation,
+ Formatting.Indented,
+ CustomContractResolver.WriterSettings));
+ }
+
+ if (new[] { OneApiState.Failed, OneApiState.Succeeded }.Contains(translation.Status))
+ {
+ Console.WriteLine();
+ Console.WriteLine();
+ Console.WriteLine($"Task completed with state: {translation.Status.AsString()}");
+ if (!runPrinted)
+ {
+ ConsoleMaskSasHelper.WriteLineMaskSas(JsonConvert.SerializeObject(
+ translation,
+ Formatting.Indented,
+ CustomContractResolver.WriterSettings));
+ }
+
+ return translation;
+ }
+ else
+ {
+ await Task.Delay(TimeSpan.FromSeconds(3)).ConfigureAwait(false);
+ if (state == null || state != translation.Status)
+ {
+ Console.WriteLine();
+ Console.WriteLine();
+ Console.WriteLine($"Task {translation.Status.AsString()}:");
+ state = translation.Status;
+ }
+ else
+ {
+ Console.Write(".");
+ }
+ }
+ }
+
+ Console.WriteLine();
+ Console.WriteLine();
+ Console.WriteLine($"Task run timeout after {(DateTime.Now - startTime).TotalMinutes.ToString("0.00", CultureInfo.InvariantCulture)} mins");
+ return null;
+ }
+
+ public async Task RequestWithRetryAsync(Func> requestAsyncFunc)
+ {
+ var policy = BuildRetryPolicy();
+
+ return await policy.ExecuteAsync(async () =>
+ {
+ return await ExceptionHelper.PrintHandleExceptionAsync(async () =>
+ {
+ return await requestAsyncFunc().ConfigureAwait(false);
+ });
+ });
+ }
+
+ public static Polly.Retry.AsyncRetryPolicy BuildRetryPolicy()
+ {
+ var retryPolicy = Policy
+ .Handle(IsTransientError)
+ .WaitAndRetryAsync(5, retryAttempt =>
+ {
+ var nextAttemptIn = TimeSpan.FromSeconds(5 * Math.Pow(2, retryAttempt));
+ Console.WriteLine($"Retry attempt {retryAttempt} to make request. Next try on {nextAttemptIn.TotalSeconds} seconds.");
+ return nextAttemptIn;
+ });
+
+ return retryPolicy;
+ }
+
+ protected static bool IsTransientError(FlurlHttpException exception)
+ {
+ int[] httpStatusCodesWorthRetrying =
+ {
+ (int)HttpStatusCode.RequestTimeout, // 408
+ (int)HttpStatusCode.BadGateway, // 502
+ (int)HttpStatusCode.ServiceUnavailable, // 503
+ (int)HttpStatusCode.GatewayTimeout, // 504
+ (int)HttpStatusCode.TooManyRequests, // 429
+ };
+
+ Console.WriteLine($"Flurl exception status code: {exception.StatusCode}");
+ return exception.StatusCode.HasValue && httpStatusCodesWorthRetrying.Contains(exception.StatusCode.Value);
+ }
+}
diff --git a/samples/video-translation/csharp/Common/CommonLib/HttpClientConfigBase.cs b/samples/video-translation/csharp/Common/CommonLib/HttpClientConfigBase.cs
new file mode 100644
index 000000000..c15348682
--- /dev/null
+++ b/samples/video-translation/csharp/Common/CommonLib/HttpClientConfigBase.cs
@@ -0,0 +1,49 @@
+//
+// Copyright (c) Microsoft. All rights reserved.
+// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
+//
+
+namespace Microsoft.SpeechServices.CommonLib.Util;
+
+using Flurl;
+using Flurl.Http;
+using Microsoft.SpeechServices.CommonLib.Attributes;
+using Microsoft.SpeechServices.CommonLib.Extensions;
+using System;
+
+public abstract class HttpClientConfigBase
+ where TDeploymentEnvironment : Enum
+{
+ public HttpClientConfigBase(TDeploymentEnvironment environment, string subKey)
+ {
+ this.Environment = environment;
+ this.SubscriptionKey = subKey;
+ }
+
+ public virtual Uri RootUrl
+ {
+ get
+ {
+ // Use APIM for public API.
+ return this.BaseUrl
+ .AppendPathSegment(RouteBase)
+ .ToUri();
+ }
+ }
+
+ public virtual string ApiVersion { get; set; }
+
+ public abstract string RouteBase { get; }
+
+ public TDeploymentEnvironment Environment { get; set; }
+
+ public string SubscriptionKey { get; set; }
+
+ public virtual Uri BaseUrl
+ {
+ get
+ {
+ return this.Environment.GetAttributeOfType()?.GetApimApiBaseUrl();
+ }
+ }
+}
diff --git a/samples/video-translation/csharp/Common/CommonLib/Readme.txt b/samples/video-translation/csharp/Common/CommonLib/Readme.txt
new file mode 100644
index 000000000..c057f40db
--- /dev/null
+++ b/samples/video-translation/csharp/Common/CommonLib/Readme.txt
@@ -0,0 +1,2 @@
+Nuget:
+ 1. Not upgrade Flurl to 4.0 due to 4.0 doesn't support NewtonJson for ReceiveJson.
\ No newline at end of file
diff --git a/samples/video-translation/csharp/Common/CommonLib/Util/CommonHelper.cs b/samples/video-translation/csharp/Common/CommonLib/Util/CommonHelper.cs
new file mode 100644
index 000000000..47de5b996
--- /dev/null
+++ b/samples/video-translation/csharp/Common/CommonLib/Util/CommonHelper.cs
@@ -0,0 +1,216 @@
+//
+// Copyright (c) Microsoft. All rights reserved.
+// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
+//
+
+namespace Microsoft.SpeechServices.CommonLib.TtsUtil;
+
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+using System.Text;
+
+public static partial class CommonHelper
+{
+ ///
+ /// Format the string in language independent way.
+ ///
+ /// Format string.
+ /// Arguments to format.
+ /// Formated string.
+ public static string NeutralFormat(string format, params object[] arg)
+ {
+ if (string.IsNullOrEmpty(format))
+ {
+ throw new ArgumentNullException(nameof(format));
+ }
+
+ return string.Format(CultureInfo.InvariantCulture, format, arg);
+ }
+
+ ///
+ /// Ensure directory exist for certain file path, this is,
+ /// If the directory does not exist, create it.
+ ///
+ /// File path to process.
+ public static void EnsureFolderExistForFile(string filePath)
+ {
+ string dir = Path.GetDirectoryName(filePath);
+ EnsureFolderExist(dir);
+ }
+
+ ///
+ /// Ensure directory exist for certain file path, this is,
+ /// If the directory does not exist, create it.
+ ///
+ /// Directory path to process.
+ public static void EnsureFolderExist(string dirPath)
+ {
+ if (!string.IsNullOrEmpty(dirPath) &&
+ !Directory.Exists(dirPath))
+ {
+ Directory.CreateDirectory(dirPath);
+ }
+ }
+
+ ///
+ /// Checks file exists.
+ ///
+ /// FilePath.
+ public static void ThrowIfFileNotExist(string filePath)
+ {
+ ThrowIfNull(filePath);
+ if (!File.Exists(filePath))
+ {
+ throw CreateException(typeof(FileNotFoundException), filePath);
+ }
+ }
+
+ ///
+ /// Create new exception instance with given exception type and parameter.
+ ///
+ /// Exception type.
+ ///
+ /// 1. if type is FileNotFoundException/DirectoryNotFoundException,
+ /// Parameter should be file/directory name.
+ ///
+ /// Exception created.
+ public static Exception CreateException(Type type, string parameter)
+ {
+ if (type == null)
+ {
+ throw new ArgumentNullException(nameof(type));
+ }
+
+ // check public parameter
+ if (string.IsNullOrEmpty(parameter))
+ {
+ parameter = string.Empty;
+ }
+
+ if (type.Equals(typeof(FileNotFoundException)))
+ {
+ string message = $"Could not find file [{parameter}].";
+ return new FileNotFoundException(message, parameter);
+ }
+ else if (type.Equals(typeof(DirectoryNotFoundException)))
+ {
+ string message = $"Could not find a part of the path [{parameter}].";
+ return new DirectoryNotFoundException(message);
+ }
+ else
+ {
+ string message = $"Unsupported exception message with parameter [{parameter}]";
+ return new NotSupportedException(message);
+ }
+ }
+
+ ///
+ /// Checks dir exists.
+ ///
+ /// DirPath.
+ public static void ThrowIfDirectoryNotExist(string dirPath)
+ {
+ ThrowIfNull(dirPath);
+ if (!Directory.Exists(dirPath))
+ {
+ throw CreateException(typeof(DirectoryNotFoundException), dirPath);
+ }
+ }
+
+ public static void ThrowIfNullOrEmpty(string instance)
+ {
+ if (string.IsNullOrEmpty(instance))
+ {
+ throw new ArgumentNullException(nameof(instance));
+ }
+ }
+
+ ///
+ /// Checks object argument not as null.
+ ///
+ /// Object instance to check.
+ public static void ThrowIfNull(object instance)
+ {
+ if (instance == null)
+ {
+ throw new ArgumentNullException(nameof(instance));
+ }
+ }
+
+ public static IEnumerable FileLines(Stream stream, Encoding encoding)
+ {
+ ArgumentNullException.ThrowIfNull(stream);
+ ArgumentNullException.ThrowIfNull(encoding);
+
+ using (StreamReader sr = new StreamReader(stream, encoding))
+ {
+ string line = null;
+ while ((line = sr.ReadLine()) != null)
+ {
+ yield return line;
+ }
+ }
+ }
+
+ public static IEnumerable FileLines(string filePath)
+ {
+ return FileLines(filePath, true);
+ }
+
+ public static IEnumerable FileLines(string filePath, bool ignoreBlankLine)
+ {
+ if (string.IsNullOrEmpty(filePath))
+ {
+ throw new ArgumentNullException(nameof(filePath));
+ }
+
+ using (StreamReader sr = new StreamReader(filePath))
+ {
+ string line = null;
+ while ((line = sr.ReadLine()) != null)
+ {
+ if (ignoreBlankLine && string.IsNullOrEmpty(line.Trim()))
+ {
+ continue;
+ }
+
+ yield return line;
+ }
+ }
+ }
+
+ public static IEnumerable FileLines(string filePath, Encoding encoding)
+ {
+ return FileLines(filePath, encoding, true);
+ }
+
+ public static IEnumerable FileLines(string filePath, Encoding encoding, bool ignoreBlankLine)
+ {
+ if (string.IsNullOrEmpty(filePath))
+ {
+ throw new ArgumentNullException(nameof(filePath));
+ }
+
+ if (encoding == null)
+ {
+ throw new ArgumentNullException(nameof(encoding));
+ }
+
+ using (StreamReader sr = new StreamReader(filePath, encoding))
+ {
+ string line = null;
+ while ((line = sr.ReadLine()) != null)
+ {
+ if (ignoreBlankLine && string.IsNullOrEmpty(line.Trim()))
+ {
+ continue;
+ }
+
+ yield return line;
+ }
+ }
+ }
+
+}
diff --git a/samples/video-translation/csharp/Common/CommonLib/Util/ConsoleHelper.cs b/samples/video-translation/csharp/Common/CommonLib/Util/ConsoleHelper.cs
new file mode 100644
index 000000000..4eb3bc50e
--- /dev/null
+++ b/samples/video-translation/csharp/Common/CommonLib/Util/ConsoleHelper.cs
@@ -0,0 +1,52 @@
+//
+// Copyright (c) Microsoft. All rights reserved.
+// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
+//
+
+namespace Microsoft.SpeechServices.CustomVoice.TtsLib.Util;
+
+using Microsoft.SpeechServices.CommonLib.Extensions;
+using System;
+using System.Diagnostics;
+using System.IO;
+
+public static class ConsoleHelper
+{
+ public static void WriteLineErrorMaskSas(string message)
+ {
+ Console.Error.WriteLine(message.MaskSasToken());
+ }
+
+ public static void WriteLineErrorMaskSas()
+ {
+ Console.Error.WriteLine();
+ }
+
+ public static void WriteMaskSas(string message)
+ {
+ Console.Write(message.MaskSasToken());
+ }
+
+ public static void WriteLineMaskSas()
+ {
+ Console.WriteLine();
+ }
+
+ public static void WriteLineMaskSas(string message)
+ {
+ Console.WriteLine(message.MaskSasToken());
+ }
+
+ public static string TempRoot
+ {
+ get
+ {
+ return Path.Combine(Path.GetPathRoot(Process.GetCurrentProcess().MainModule.FileName), "Temp");
+ }
+ }
+
+ public static string GetTempDir()
+ {
+ return Path.Combine(TempRoot, Guid.NewGuid().ToString());
+ }
+}
diff --git a/samples/video-translation/csharp/Common/CommonLib/Util/ConsoleMaskSasHelper.cs b/samples/video-translation/csharp/Common/CommonLib/Util/ConsoleMaskSasHelper.cs
new file mode 100644
index 000000000..61cc39b3c
--- /dev/null
+++ b/samples/video-translation/csharp/Common/CommonLib/Util/ConsoleMaskSasHelper.cs
@@ -0,0 +1,24 @@
+//
+// Copyright (c) Microsoft. All rights reserved.
+// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
+//
+
+namespace Microsoft.SpeechServices.CommonLib.Util;
+
+using Microsoft.SpeechServices.CommonLib.Extensions;
+using System;
+
+public static class ConsoleMaskSasHelper
+{
+ public static bool ShowSas { get; set; }
+
+ static ConsoleMaskSasHelper()
+ {
+ ShowSas = false;
+ }
+
+ public static void WriteLineMaskSas(string message)
+ {
+ Console.WriteLine(ShowSas ? message : message.MaskSasToken());
+ }
+}
diff --git a/samples/video-translation/csharp/Common/CommonLib/Util/EnumExtensions.cs b/samples/video-translation/csharp/Common/CommonLib/Util/EnumExtensions.cs
new file mode 100644
index 000000000..0ce93d881
--- /dev/null
+++ b/samples/video-translation/csharp/Common/CommonLib/Util/EnumExtensions.cs
@@ -0,0 +1,58 @@
+//
+// Copyright (c) Microsoft. All rights reserved.
+// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
+//
+
+namespace Microsoft.SpeechServices.CommonLib.Util;
+
+using System;
+using System.Linq;
+using System.Runtime.Serialization;
+
+public static class EnumExtensions
+{
+ public static string AsString(this TEnum enumValue)
+ where TEnum : Enum
+ {
+ if (typeof(TEnum).GetCustomAttributes(typeof(FlagsAttribute), false).Any())
+ {
+ return enumValue.ToString();
+ }
+
+ var enumMemberName = Enum.GetName(typeof(TEnum), enumValue);
+
+ var enumMember = typeof(TEnum).GetMember(enumMemberName).Single();
+ var jsonPropertyAttribute = enumMember
+ .GetCustomAttributes(typeof(DataMemberAttribute), true)
+ .Cast()
+ .SingleOrDefault();
+
+ if (jsonPropertyAttribute != null)
+ {
+ return jsonPropertyAttribute.Name;
+ }
+
+ return enumMemberName;
+ }
+
+ public static TEnum AsEnumValue(this string value)
+ {
+ return value.AsEnumValue(false);
+ }
+
+ public static TEnum AsEnumValue(this string value, bool ignoreCase)
+ {
+ var enumMembers = typeof(TEnum).GetMembers();
+ var membersAndAttributes = enumMembers
+ .Select(m => (member: m, attribute: m.GetCustomAttributes(typeof(DataMemberAttribute), true).Cast().SingleOrDefault()))
+ .Where(m => m.attribute != null)
+ .Where(m => m.attribute.Name == value);
+
+ if (membersAndAttributes.Any())
+ {
+ value = membersAndAttributes.Single().member.Name;
+ }
+
+ return (TEnum)Enum.Parse(typeof(TEnum), value, ignoreCase);
+ }
+}
\ No newline at end of file
diff --git a/samples/video-translation/csharp/Common/CommonLib/Util/ExceptionHelper.cs b/samples/video-translation/csharp/Common/CommonLib/Util/ExceptionHelper.cs
new file mode 100644
index 000000000..35552f0fd
--- /dev/null
+++ b/samples/video-translation/csharp/Common/CommonLib/Util/ExceptionHelper.cs
@@ -0,0 +1,234 @@
+//
+// Copyright (c) Microsoft. All rights reserved.
+// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
+//
+
+namespace Microsoft.SpeechServices.CustomVoice.TtsLib.TtsUtil;
+
+using Flurl.Http;
+using System;
+using System.Text;
+using System.Threading.Tasks;
+
+public class ExceptionHelper
+{
+ public static async Task PrintHandleExceptionAsync(Func> requestAsyncFunc)
+ {
+ ArgumentNullException.ThrowIfNull(requestAsyncFunc);
+
+ try
+ {
+ return await requestAsyncFunc().ConfigureAwait(false);
+ }
+ catch (FlurlHttpTimeoutException e)
+ {
+ Console.WriteLine($"Timeout with error: {e.Message}");
+ throw;
+ }
+ catch (FlurlHttpException ex)
+ {
+ var error = $"{nameof(FlurlHttpException)}: {await ex.GetResponseStringAsync()}";
+ Console.WriteLine(error);
+ throw;
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"{ex.GetType().Name}: {ex.Message}");
+ throw;
+ }
+ }
+
+ public static string BuildExceptionMessage(Exception exception)
+ {
+ return BuildExceptionMessage(exception, false);
+ }
+
+ public static async Task<(bool success, string error, T result)> HasRunWithoutExceptionAsync(Func> func, bool isAppendStackTrace = false)
+ {
+ if (func == null)
+ {
+ throw new ArgumentNullException(nameof(func));
+ }
+
+ try
+ {
+ var result = await func().ConfigureAwait(false);
+ return (true, null, result);
+ }
+ catch (Exception e)
+ {
+ return (false, $"Failed to run function with exception: {BuildExceptionMessage(e, isAppendStackTrace)}", default(T));
+ }
+ }
+
+ public static async Task<(bool success, string error)> HasRunWithoutExceptionAsync(Func func, bool isAppendStackTrace = false)
+ {
+ if (func == null)
+ {
+ throw new ArgumentNullException(nameof(func));
+ }
+
+ try
+ {
+ await func().ConfigureAwait(false);
+ return (true, null);
+ }
+ catch (Exception e)
+ {
+ return (false, $"Failed to run function with exception: {BuildExceptionMessage(e, isAppendStackTrace)}");
+ }
+ }
+
+ public static string BuildExceptionMessage(Exception exception, bool isAppendStackTrace)
+ {
+ if (exception == null)
+ {
+ throw new ArgumentNullException(nameof(exception));
+ }
+
+ var messageBuilder = new StringBuilder();
+ for (Exception current = exception; current != null; current = current.InnerException)
+ {
+ if (current.InnerException != null)
+ {
+ messageBuilder.AppendLine(current.Message);
+ }
+ else
+ {
+ messageBuilder.Append(current.Message);
+ }
+ }
+
+ if (isAppendStackTrace)
+ {
+ messageBuilder.Append(exception.StackTrace);
+ }
+
+ return messageBuilder.ToString();
+ }
+
+#pragma warning disable CA1031
+ public static (bool success, string error) HasRunWithoutException(Func<(bool success, string error)> func, bool isAppendStackTrace = false)
+ {
+ if (func == null)
+ {
+ throw new ArgumentNullException(nameof(func));
+ }
+
+ try
+ {
+ return func();
+ }
+ catch (Exception e)
+ {
+ return (false, BuildExceptionMessage(e, isAppendStackTrace));
+ }
+ }
+
+ public static (bool success, string error) HasRunWithoutException(Action action, bool isAppendStackTrace = false)
+ {
+ if (action == null)
+ {
+ throw new ArgumentNullException(nameof(action));
+ }
+
+ try
+ {
+ action();
+ return (true, null);
+ }
+ catch (Exception e)
+ {
+ return (false, BuildExceptionMessage(e, isAppendStackTrace));
+ }
+ }
+
+ public static (bool success, string error) HasRunWithoutExceptoin(Func<(bool success, string error)> func, bool isAppendStackTrace = false)
+ {
+ if (func == null)
+ {
+ throw new ArgumentNullException(nameof(func));
+ }
+
+ try
+ {
+ return func();
+ }
+ catch (Exception e)
+ {
+ return (false, ExceptionHelper.BuildExceptionMessage(e, isAppendStackTrace));
+ }
+ }
+
+ public static (bool success, string error) HasRunWithoutExceptoin(Action action, bool isAppendStackTrace = false)
+ {
+ if (action == null)
+ {
+ throw new ArgumentNullException(nameof(action));
+ }
+
+ try
+ {
+ action();
+ return (true, null);
+ }
+ catch (Exception e)
+ {
+ return (false, ExceptionHelper.BuildExceptionMessage(e, isAppendStackTrace));
+ }
+ }
+
+ public static async Task<(bool success, string error)> HasRunWithoutExceptoinAsync(Func func, bool isAppendStackTrace = false)
+ {
+ if (func == null)
+ {
+ throw new ArgumentNullException(nameof(func));
+ }
+
+ try
+ {
+ await func().ConfigureAwait(false);
+ return (true, null);
+ }
+ catch (Exception e)
+ {
+ return (false, $"Failed to run function with exception: {BuildExceptionMessage(e, isAppendStackTrace)}");
+ }
+ }
+
+ public static async Task<(bool success, string error, T result)> HasRunWithoutExceptoinAsync(Func> func, bool isAppendStackTrace = false)
+ {
+ if (func == null)
+ {
+ throw new ArgumentNullException(nameof(func));
+ }
+
+ try
+ {
+ var result = await func().ConfigureAwait(false);
+ return (true, null, result);
+ }
+ catch (Exception e)
+ {
+ return (false, $"Failed to run function with exception: {ExceptionHelper.BuildExceptionMessage(e, isAppendStackTrace)}", default(T));
+ }
+ }
+
+ public static (bool success, string error, T result) HasRunWithoutExceptoin(Func func, bool isAppendStackTrace = false)
+ {
+ if (func == null)
+ {
+ throw new ArgumentNullException(nameof(func));
+ }
+
+ try
+ {
+ var result = func();
+ return (true, null, result);
+ }
+ catch (Exception e)
+ {
+ return (false, $"Failed to run function with exception: {ExceptionHelper.BuildExceptionMessage(e, isAppendStackTrace)}", default(T));
+ }
+ }
+}
diff --git a/samples/video-translation/csharp/Common/CommonLib/Util/JsonHelper.cs b/samples/video-translation/csharp/Common/CommonLib/Util/JsonHelper.cs
new file mode 100644
index 000000000..90894ae3c
--- /dev/null
+++ b/samples/video-translation/csharp/Common/CommonLib/Util/JsonHelper.cs
@@ -0,0 +1,70 @@
+//
+// Copyright (c) Microsoft. All rights reserved.
+// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
+//
+
+namespace Microsoft.SpeechServices.CustomVoice;
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Microsoft.SpeechServices.CommonLib;
+using Microsoft.SpeechServices.CustomVoice.TtsLib.TtsUtil;
+using Newtonsoft.Json;
+using Newtonsoft.Json.Linq;
+
+public static class JsonHelper
+{
+ public static (bool success, string error, IEnumerable texts) ExtractElementsByJSONPath(string jsonContent, string jsonPath)
+ {
+ var texts = new List();
+ if (string.IsNullOrEmpty(jsonContent))
+ {
+ return (true, null, texts);
+ }
+
+ try
+ {
+ JObject o = JObject.Parse(jsonContent);
+ if (o == null)
+ {
+ return (false, "Empty JObject returned when parse content with JObject.", null);
+ }
+
+ var tokens = o.SelectTokens(jsonPath);
+ if (tokens?.Any() ?? false)
+ {
+ tokens.Where(x => x != null).ToList().ForEach(x => texts.Add(x.ToString()));
+ }
+ }
+ catch (JsonReaderException e)
+ {
+ return (false, e.Message, null);
+ }
+
+ return (true, null, texts);
+ }
+
+ public static (bool success, string error, T result) TryParse(string jsonString, JsonSerializerSettings readerSettings)
+ {
+ ArgumentNullException.ThrowIfNull(readerSettings);
+
+ if (string.IsNullOrEmpty(jsonString))
+ {
+ return (false, "Json input should not be empty.", default(T));
+ }
+
+ T result = default(T);
+ (var success, var error) = ExceptionHelper.HasRunWithoutException(() =>
+ {
+ result = JsonConvert.DeserializeObject(jsonString, readerSettings);
+ });
+
+ return (success, error, result);
+ }
+
+ public static (bool success, string error, T result) TryParse(string jsonString)
+ {
+ return TryParse(jsonString, CustomContractResolver.ReaderSettings);
+ }
+}
diff --git a/samples/video-translation/csharp/Common/CommonLib/Util/Sha256Helper.cs b/samples/video-translation/csharp/Common/CommonLib/Util/Sha256Helper.cs
new file mode 100644
index 000000000..72a9d18e1
--- /dev/null
+++ b/samples/video-translation/csharp/Common/CommonLib/Util/Sha256Helper.cs
@@ -0,0 +1,66 @@
+//
+// Copyright (c) Microsoft. All rights reserved.
+// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
+//
+
+namespace Microsoft.SpeechServices.CommonLib.Util;
+
+using Microsoft.SpeechServices.Common;
+using System;
+using System.Globalization;
+using System.IO;
+using System.Security.Cryptography;
+using System.Text;
+
+public static class Sha256Helper
+{
+ public static string GetSha256FromFile(string filename)
+ {
+ if (string.IsNullOrEmpty(filename))
+ {
+ throw new ArgumentNullException(nameof(filename));
+ }
+
+ if (!File.Exists(filename))
+ {
+ throw new FileNotFoundException(filename);
+ }
+
+ using (var md5Hash = SHA256.Create())
+ using (FileStream stream = File.OpenRead(filename))
+ {
+ byte[] data = md5Hash.ComputeHash(stream);
+ var sb = new StringBuilder();
+
+ for (int i = 0; i < data.Length; i++)
+ {
+ sb.Append(data[i].ToString("x2", CultureInfo.InvariantCulture));
+ }
+
+ return sb.ToString();
+ }
+ }
+
+ public static string GetSha256WithExtensionFromFile(string filename)
+ {
+ return $"{GetSha256FromFile(filename).AppendExtensionName(Path.GetExtension(filename))}";
+ }
+
+ public static string GetSha256FromString(string value)
+ {
+ value = value ?? string.Empty;
+ using (var md5Hash = SHA256.Create())
+ {
+ byte[] data = md5Hash.ComputeHash(Encoding.UTF8.GetBytes(value));
+
+ StringBuilder sb = new StringBuilder();
+
+ for (int i = 0; i < data.Length; i++)
+ {
+ sb.Append(data[i].ToString("x2", CultureInfo.InvariantCulture));
+ }
+
+ return sb.ToString();
+ }
+ }
+}
diff --git a/samples/video-translation/csharp/Common/CommonLib/Util/StringFormatHelper.cs b/samples/video-translation/csharp/Common/CommonLib/Util/StringFormatHelper.cs
new file mode 100644
index 000000000..abaaf7b42
--- /dev/null
+++ b/samples/video-translation/csharp/Common/CommonLib/Util/StringFormatHelper.cs
@@ -0,0 +1,17 @@
+//
+// Copyright (c) Microsoft. All rights reserved.
+// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
+//
+
+namespace Microsoft.SpeechServices.CustomVoice.TtsLib.Util;
+
+using System;
+using System.Globalization;
+
+public static class StringFormatHelper
+{
+ public static string ToIdSuffix(this DateTime timestamp)
+ {
+ return timestamp.ToString(@"yyMMddhhmmss", CultureInfo.InvariantCulture);
+ }
+}
diff --git a/samples/video-translation/csharp/Common/CommonLib/Util/TaskNameHelper.cs b/samples/video-translation/csharp/Common/CommonLib/Util/TaskNameHelper.cs
new file mode 100644
index 000000000..c654d51cd
--- /dev/null
+++ b/samples/video-translation/csharp/Common/CommonLib/Util/TaskNameHelper.cs
@@ -0,0 +1,61 @@
+//
+// Copyright (c) Microsoft. All rights reserved.
+// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
+//
+
+namespace Microsoft.SpeechServices.CommonLib.Util;
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+
+public static class TaskNameHelper
+{
+ public const int NameMaxCharLength = 256;
+ public const string IncompleteFileNamePrefix = "...";
+
+ public static string BuildAutoGeneratedFileName(IEnumerable fileNames)
+ {
+ ArgumentNullException.ThrowIfNull(fileNames);
+
+ var fileName = string.Empty;
+ if (!fileNames.Any())
+ {
+ fileName = "No file selected.";
+ }
+ else if (fileNames.Count() == 1)
+ {
+ fileName = $"{fileNames.First()}";
+ }
+ else
+ {
+ fileName = TrimFileNameToDisplayText(
+ $"{fileNames.Count()} files: {string.Join(",", fileNames)}",
+ NameMaxCharLength);
+ }
+
+ return fileName;
+ }
+
+ public static string TrimFileNameToDisplayText(string fullFileName, int maxFileNameCharCount)
+ {
+ if (string.IsNullOrEmpty(fullFileName))
+ {
+ return fullFileName;
+ }
+
+ var sb = new StringBuilder();
+ if (fullFileName.Length > maxFileNameCharCount && fullFileName.Length > IncompleteFileNamePrefix.Length)
+ {
+ sb.Append(fullFileName.Substring(fullFileName.Length - IncompleteFileNamePrefix.Length));
+ sb.Append(IncompleteFileNamePrefix);
+ }
+ else
+ {
+ sb.Append(fullFileName);
+ }
+
+ return sb.ToString();
+ }
+}
diff --git a/samples/video-translation/csharp/Common/CommonLib/Util/UriHelper.cs b/samples/video-translation/csharp/Common/CommonLib/Util/UriHelper.cs
new file mode 100644
index 000000000..3bfb4b7a1
--- /dev/null
+++ b/samples/video-translation/csharp/Common/CommonLib/Util/UriHelper.cs
@@ -0,0 +1,53 @@
+//
+// Copyright (c) Microsoft. All rights reserved.
+// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
+//
+
+namespace Microsoft.SpeechServices.CustomVoice.TtsLib.Util;
+
+using System;
+using System.Linq;
+
+public static class UriHelper
+{
+ public static string GetFileName(Uri url)
+ {
+ if (string.IsNullOrWhiteSpace(url?.OriginalString))
+ {
+ throw new ArgumentNullException(nameof(url));
+ }
+
+ var videoFileName = url.Segments.Last();
+ videoFileName = Uri.UnescapeDataString(videoFileName);
+ return videoFileName;
+ }
+
+ public static string AppendOptionalQuery(this string query, string name, string value)
+ {
+ if (string.IsNullOrEmpty(value))
+ {
+ return query;
+ }
+
+ var item = $"{name}={Uri.EscapeDataString(value)}";
+ return string.IsNullOrEmpty(query) ? item : $"{query}&{item}";
+ }
+
+ // query include ?
+ public static Uri SetQuery(Uri uri, string query)
+ {
+ if (uri == null)
+ {
+ throw new ArgumentNullException(nameof(uri));
+ }
+
+ var uriString = uri.ToString();
+ var index = uriString.IndexOf("?");
+ if (index < 0)
+ {
+ return uri;
+ }
+
+ return new Uri($"{uriString.Substring(0, index)}{query}");
+ }
+}
diff --git a/samples/video-translation/csharp/Common/VideoTranslationLib.Common/Enum/DeploymentEnvironment.cs b/samples/video-translation/csharp/Common/VideoTranslationLib.Common/Enum/DeploymentEnvironment.cs
new file mode 100644
index 000000000..a52619777
--- /dev/null
+++ b/samples/video-translation/csharp/Common/VideoTranslationLib.Common/Enum/DeploymentEnvironment.cs
@@ -0,0 +1,22 @@
+//
+// Copyright (c) Microsoft. All rights reserved.
+// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
+//
+
+namespace Microsoft.SpeechServices.VideoTranslationLib.Enums;
+
+using Microsoft.SpeechServices.CommonLib.Attributes;
+using System;
+using System.Runtime.Serialization;
+
+[DataContract]
+public enum DeploymentEnvironment
+{
+ [EnumMember]
+ Default,
+
+ [EnumMember]
+ [DeploymentEnvironment(
+ regionIdentifier: "eastus")]
+ ProductionEUS,
+}
\ No newline at end of file
diff --git a/samples/video-translation/csharp/Common/VideoTranslationLib.Common/VideoTranslationConstant.cs b/samples/video-translation/csharp/Common/VideoTranslationLib.Common/VideoTranslationConstant.cs
new file mode 100644
index 000000000..9b67dc350
--- /dev/null
+++ b/samples/video-translation/csharp/Common/VideoTranslationLib.Common/VideoTranslationConstant.cs
@@ -0,0 +1,13 @@
+//
+// Copyright (c) Microsoft. All rights reserved.
+// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
+//
+
+namespace Microsoft.SpeechServices.VideoTranslation;
+
+using System;
+
+public static class VideoTranslationConstant
+{
+ public readonly static TimeSpan UploadVideoOrAudioFileTimeout = TimeSpan.FromMinutes(10);
+}
diff --git a/samples/video-translation/csharp/Common/VideoTranslationLib.Common/VideoTranslationLib.Common.csproj b/samples/video-translation/csharp/Common/VideoTranslationLib.Common/VideoTranslationLib.Common.csproj
new file mode 100644
index 000000000..c418d57fa
--- /dev/null
+++ b/samples/video-translation/csharp/Common/VideoTranslationLib.Common/VideoTranslationLib.Common.csproj
@@ -0,0 +1,19 @@
+
+
+
+ net7.0
+ Microsoft.SpeechServices.$(MSBuildProjectName)
+ Microsoft.SpeechServices.VideoTranslation
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/video-translation/csharp/Common/VideoTranslationLib.PublicPreview/ConsoleAppHelper.cs b/samples/video-translation/csharp/Common/VideoTranslationLib.PublicPreview/ConsoleAppHelper.cs
new file mode 100644
index 000000000..c7b2208dc
--- /dev/null
+++ b/samples/video-translation/csharp/Common/VideoTranslationLib.PublicPreview/ConsoleAppHelper.cs
@@ -0,0 +1,67 @@
+//
+// Copyright (c) Microsoft. All rights reserved.
+// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
+//
+
+namespace Microsoft.SpeechServices.VideoTranslationLib.PublicPreview.Base;
+
+using Microsoft.SpeechServices.CommonLib;
+using Microsoft.SpeechServices.CommonLib.Util;
+using Microsoft.SpeechServices.Cris.Http.DTOs.Public.VideoTranslation.Public20240520Preview;
+using Microsoft.SpeechServices.CustomVoice.TtsLib.Util;
+using Microsoft.VisualBasic;
+using Newtonsoft.Json;
+using System;
+using System.Globalization;
+using System.Threading.Tasks;
+using VideoTranslationPublicPreviewLib.HttpClient;
+
+public static class ConsoleAppHelper
+{
+ public static async Task CreateTranslationAsync(
+ TranslationClient translationClient,
+ string translationId,
+ CultureInfo sourceLocale,
+ CultureInfo targetLocale,
+ VoiceKind voiceKind,
+ Uri videoFileUrl,
+ int? speakerCount)
+ where TDeploymentEnvironment : Enum
+ where TIteration : Iteration
+ where TIterationInput : IterationInput
+ {
+ ArgumentNullException.ThrowIfNull(translationClient);
+ if (string.IsNullOrWhiteSpace(videoFileUrl?.OriginalString))
+ {
+ throw new ArgumentNullException(nameof(videoFileUrl));
+ }
+
+ var fileName = UriHelper.GetFileName(videoFileUrl);
+ var translation = new Translation()
+ {
+ Id = translationId,
+ DisplayName = fileName,
+ Description = $"Translation {fileName} from {sourceLocale} to {targetLocale} with {voiceKind.AsString()}",
+ Input = new TranslationInput()
+ {
+ SourceLocale = sourceLocale,
+ TargetLocale = targetLocale,
+ SpeakerCount = speakerCount,
+ VoiceKind = voiceKind,
+ VideoFileUrl = videoFileUrl,
+ },
+ };
+
+ var operationId = Guid.NewGuid().ToString();
+ (translation, var headers) = await translationClient.CreateTranslationAsync(
+ translation: translation,
+ operationId: operationId).ConfigureAwait(false);
+
+ Console.WriteLine();
+ Console.WriteLine("Created translation:");
+ Console.WriteLine(JsonConvert.SerializeObject(
+ translation,
+ Formatting.Indented,
+ CustomContractResolver.WriterSettings));
+ }
+}
diff --git a/samples/video-translation/csharp/Common/VideoTranslationLib.PublicPreview/DataContracts/DTOs/Public-2024-05-20-preview/Enum/OperationStatus.cs b/samples/video-translation/csharp/Common/VideoTranslationLib.PublicPreview/DataContracts/DTOs/Public-2024-05-20-preview/Enum/OperationStatus.cs
new file mode 100644
index 000000000..78944ca99
--- /dev/null
+++ b/samples/video-translation/csharp/Common/VideoTranslationLib.PublicPreview/DataContracts/DTOs/Public-2024-05-20-preview/Enum/OperationStatus.cs
@@ -0,0 +1,19 @@
+//
+// Copyright (c) Microsoft. All rights reserved.
+// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
+//
+
+namespace Microsoft.SpeechServices.Cris.Http.DTOs.Public.VideoTranslation.Public20240520Preview;
+
+public enum OperationStatus
+{
+ NotStarted,
+
+ Running,
+
+ Succeeded,
+
+ Failed,
+
+ Canceled,
+}
diff --git a/samples/video-translation/csharp/Common/VideoTranslationLib.PublicPreview/DataContracts/DTOs/Public-2024-05-20-preview/Enum/VoiceKind.cs b/samples/video-translation/csharp/Common/VideoTranslationLib.PublicPreview/DataContracts/DTOs/Public-2024-05-20-preview/Enum/VoiceKind.cs
new file mode 100644
index 000000000..0fd4d4322
--- /dev/null
+++ b/samples/video-translation/csharp/Common/VideoTranslationLib.PublicPreview/DataContracts/DTOs/Public-2024-05-20-preview/Enum/VoiceKind.cs
@@ -0,0 +1,15 @@
+//
+// Copyright (c) Microsoft. All rights reserved.
+// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
+//
+
+namespace Microsoft.SpeechServices.Cris.Http.DTOs.Public.VideoTranslation.Public20240520Preview;
+
+public enum VoiceKind
+{
+ None = 0,
+
+ PlatformVoice,
+
+ PersonalVoice,
+}
diff --git a/samples/video-translation/csharp/Common/VideoTranslationLib.PublicPreview/DataContracts/DTOs/Public-2024-05-20-preview/Enum/VoiceKindExtensions.cs b/samples/video-translation/csharp/Common/VideoTranslationLib.PublicPreview/DataContracts/DTOs/Public-2024-05-20-preview/Enum/VoiceKindExtensions.cs
new file mode 100644
index 000000000..7d25feb72
--- /dev/null
+++ b/samples/video-translation/csharp/Common/VideoTranslationLib.PublicPreview/DataContracts/DTOs/Public-2024-05-20-preview/Enum/VoiceKindExtensions.cs
@@ -0,0 +1,21 @@
+//
+// Copyright (c) Microsoft. All rights reserved.
+// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
+//
+
+namespace Microsoft.SpeechServices.Cris.Http.DTOs.Public.VideoTranslation.Public20240520Preview;
+
+using Microsoft.SpeechServices.Common.Client;
+
+public static class VoiceKindExtensions
+{
+ public static VideoTranslationVoiceKind AsCoreEngineEnum(this VoiceKind voiceKind)
+ {
+ return voiceKind switch
+ {
+ VoiceKind.PlatformVoice => VideoTranslationVoiceKind.PlatformVoice,
+ VoiceKind.PersonalVoice => VideoTranslationVoiceKind.PersonalVoice,
+ _ => VideoTranslationVoiceKind.PlatformVoice,
+ };
+ }
+}
diff --git a/samples/video-translation/csharp/Common/VideoTranslationLib.PublicPreview/DataContracts/DTOs/Public-2024-05-20-preview/Enum/WebvttFileKind.cs b/samples/video-translation/csharp/Common/VideoTranslationLib.PublicPreview/DataContracts/DTOs/Public-2024-05-20-preview/Enum/WebvttFileKind.cs
new file mode 100644
index 000000000..6680121a2
--- /dev/null
+++ b/samples/video-translation/csharp/Common/VideoTranslationLib.PublicPreview/DataContracts/DTOs/Public-2024-05-20-preview/Enum/WebvttFileKind.cs
@@ -0,0 +1,17 @@
+//
+// Copyright (c) Microsoft. All rights reserved.
+// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
+//
+
+namespace Microsoft.SpeechServices.Cris.Http.DTOs.Public.VideoTranslation.Public20240520Preview;
+
+public enum WebvttFileKind
+{
+ None = 0,
+
+ SourceLocaleSubtitle,
+
+ TargetLocaleSubtitle,
+
+ MetadataJson,
+}
diff --git a/samples/video-translation/csharp/Common/VideoTranslationLib.PublicPreview/DataContracts/DTOs/Public-2024-05-20-preview/Iteration.cs b/samples/video-translation/csharp/Common/VideoTranslationLib.PublicPreview/DataContracts/DTOs/Public-2024-05-20-preview/Iteration.cs
new file mode 100644
index 000000000..487a42133
--- /dev/null
+++ b/samples/video-translation/csharp/Common/VideoTranslationLib.PublicPreview/DataContracts/DTOs/Public-2024-05-20-preview/Iteration.cs
@@ -0,0 +1,28 @@
+//
+// Copyright (c) Microsoft. All rights reserved.
+// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
+//
+
+namespace Microsoft.SpeechServices.Cris.Http.DTOs.Public.VideoTranslation.Public20240520Preview;
+
+using Newtonsoft.Json;
+
+public class Iteration : StatefulResourceBase
+ where TIterationInput : IterationInput
+{
+ public TIterationInput Input { get; set; }
+
+ public IterationResult Result { get; set; }
+
+ public string? FailureReason { get; set; }
+
+ // This is different from the content editin concept in billing, billing is based on first iteration or not.
+ [JsonIgnore]
+ public bool IsContentEditing
+ {
+ get
+ {
+ return this.Input?.WebvttFile?.HasValue() ?? false;
+ }
+ }
+}
diff --git a/samples/video-translation/csharp/Common/VideoTranslationLib.PublicPreview/DataContracts/DTOs/Public-2024-05-20-preview/IterationInput.cs b/samples/video-translation/csharp/Common/VideoTranslationLib.PublicPreview/DataContracts/DTOs/Public-2024-05-20-preview/IterationInput.cs
new file mode 100644
index 000000000..906467385
--- /dev/null
+++ b/samples/video-translation/csharp/Common/VideoTranslationLib.PublicPreview/DataContracts/DTOs/Public-2024-05-20-preview/IterationInput.cs
@@ -0,0 +1,17 @@
+//
+// Copyright (c) Microsoft. All rights reserved.
+// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
+//
+
+namespace Microsoft.SpeechServices.Cris.Http.DTOs.Public.VideoTranslation.Public20240520Preview;
+
+public class IterationInput
+{
+ public int? SpeakerCount { get; set; }
+
+ public int? SubtitleMaxCharCountPerSegment { get; set; }
+
+ public bool? ExportSubtitleInVideo { get; set; }
+
+ public WebvttFile WebvttFile { get; set; }
+}
diff --git a/samples/video-translation/csharp/Common/VideoTranslationLib.PublicPreview/DataContracts/DTOs/Public-2024-05-20-preview/IterationResult.cs b/samples/video-translation/csharp/Common/VideoTranslationLib.PublicPreview/DataContracts/DTOs/Public-2024-05-20-preview/IterationResult.cs
new file mode 100644
index 000000000..45a883526
--- /dev/null
+++ b/samples/video-translation/csharp/Common/VideoTranslationLib.PublicPreview/DataContracts/DTOs/Public-2024-05-20-preview/IterationResult.cs
@@ -0,0 +1,19 @@
+//
+// Copyright (c) Microsoft. All rights reserved.
+// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
+//
+
+namespace Microsoft.SpeechServices.Cris.Http.DTOs.Public.VideoTranslation.Public20240520Preview;
+
+using System;
+
+public class IterationResult
+{
+ public Uri TranslatedVideoFileUrl { get; set; }
+
+ public Uri SourceLocaleSubtitleWebvttFileUrl { get; set; }
+
+ public Uri TargetLocaleSubtitleWebvttFileUrl { get; set; }
+
+ public Uri MetadataJsonWebvttFileUrl { get; set; }
+}
diff --git a/samples/video-translation/csharp/Common/VideoTranslationLib.PublicPreview/DataContracts/DTOs/Public-2024-05-20-preview/Operation.cs b/samples/video-translation/csharp/Common/VideoTranslationLib.PublicPreview/DataContracts/DTOs/Public-2024-05-20-preview/Operation.cs
new file mode 100644
index 000000000..e75ef5f62
--- /dev/null
+++ b/samples/video-translation/csharp/Common/VideoTranslationLib.PublicPreview/DataContracts/DTOs/Public-2024-05-20-preview/Operation.cs
@@ -0,0 +1,17 @@
+//
+// Copyright (c) Microsoft. All rights reserved.
+// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
+//
+
+namespace Microsoft.SpeechServices.Cris.Http.DTOs.Public.VideoTranslation.Public20240520Preview;
+
+using System.ComponentModel.DataAnnotations;
+
+public class Operation
+{
+ [Required]
+ [RegularExpression(@"^[a-zA-Z0-9][a-zA-Z0-9._-]{1,62}[a-zA-Z0-9]$")]
+ public string Id { get; set; }
+
+ public OperationStatus Status { get; set; }
+}
diff --git a/samples/video-translation/csharp/Common/VideoTranslationLib.PublicPreview/DataContracts/DTOs/Public-2024-05-20-preview/PagedTranslation.cs b/samples/video-translation/csharp/Common/VideoTranslationLib.PublicPreview/DataContracts/DTOs/Public-2024-05-20-preview/PagedTranslation.cs
new file mode 100644
index 000000000..b0faa685c
--- /dev/null
+++ b/samples/video-translation/csharp/Common/VideoTranslationLib.PublicPreview/DataContracts/DTOs/Public-2024-05-20-preview/PagedTranslation.cs
@@ -0,0 +1,12 @@
+//
+// Copyright (c) Microsoft. All rights reserved.
+// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
+//
+
+using Microsoft.SpeechServices.DataContracts;
+
+namespace Microsoft.SpeechServices.Cris.Http.DTOs.Public.VideoTranslation.Public20240520Preview;
+
+public class PagedTranslation : PaginatedResources, IterationInput>>
+{
+}
diff --git a/samples/video-translation/csharp/Common/VideoTranslationLib.PublicPreview/DataContracts/DTOs/Public-2024-05-20-preview/Translation.cs b/samples/video-translation/csharp/Common/VideoTranslationLib.PublicPreview/DataContracts/DTOs/Public-2024-05-20-preview/Translation.cs
new file mode 100644
index 000000000..9f99356e2
--- /dev/null
+++ b/samples/video-translation/csharp/Common/VideoTranslationLib.PublicPreview/DataContracts/DTOs/Public-2024-05-20-preview/Translation.cs
@@ -0,0 +1,19 @@
+//
+// Copyright (c) Microsoft. All rights reserved.
+// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
+//
+
+namespace Microsoft.SpeechServices.Cris.Http.DTOs.Public.VideoTranslation.Public20240520Preview;
+
+public class Translation : StatefulResourceBase
+ where TIteration : Iteration
+ where TIterationInput : IterationInput
+{
+ public TranslationInput Input { get; set; }
+
+ public Iteration LatestIteration { get; set; }
+
+ public Iteration LatestSucceededIteration { get; set; }
+
+ public string FailureReason { get; set; }
+}
diff --git a/samples/video-translation/csharp/Common/VideoTranslationLib.PublicPreview/DataContracts/DTOs/Public-2024-05-20-preview/TranslationInput.cs b/samples/video-translation/csharp/Common/VideoTranslationLib.PublicPreview/DataContracts/DTOs/Public-2024-05-20-preview/TranslationInput.cs
new file mode 100644
index 000000000..3e3feebe4
--- /dev/null
+++ b/samples/video-translation/csharp/Common/VideoTranslationLib.PublicPreview/DataContracts/DTOs/Public-2024-05-20-preview/TranslationInput.cs
@@ -0,0 +1,39 @@
+//
+// Copyright (c) Microsoft. All rights reserved.
+// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
+//
+
+namespace Microsoft.SpeechServices.Cris.Http.DTOs.Public.VideoTranslation.Public20240520Preview;
+
+using System;
+using System.Globalization;
+using System.Linq;
+
+public class TranslationInput
+{
+ public CultureInfo SourceLocale { get; set; }
+
+ public CultureInfo TargetLocale { get; set; }
+
+ public VoiceKind VoiceKind { get; set; }
+
+ public int? SpeakerCount { get; set; }
+
+ public int? SubtitleMaxCharCountPerSegment { get; set; }
+
+ public bool? ExportSubtitleInVideo { get; set; }
+
+ public Uri VideoFileUrl { get; set; }
+
+ public string GetVideoFileName()
+ {
+ if (string.IsNullOrEmpty(this.VideoFileUrl?.OriginalString))
+ {
+ return string.Empty;
+ }
+
+ var videoFileName = this.VideoFileUrl.Segments.Last();
+ videoFileName = Uri.UnescapeDataString(videoFileName);
+ return videoFileName;
+ }
+}
diff --git a/samples/video-translation/csharp/Common/VideoTranslationLib.PublicPreview/DataContracts/DTOs/Public-2024-05-20-preview/WebvttFile.cs b/samples/video-translation/csharp/Common/VideoTranslationLib.PublicPreview/DataContracts/DTOs/Public-2024-05-20-preview/WebvttFile.cs
new file mode 100644
index 000000000..4d9dd0d2b
--- /dev/null
+++ b/samples/video-translation/csharp/Common/VideoTranslationLib.PublicPreview/DataContracts/DTOs/Public-2024-05-20-preview/WebvttFile.cs
@@ -0,0 +1,20 @@
+//
+// Copyright (c) Microsoft. All rights reserved.
+// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
+//
+
+namespace Microsoft.SpeechServices.Cris.Http.DTOs.Public.VideoTranslation.Public20240520Preview;
+
+using System;
+
+public class WebvttFile
+{
+ public Uri Url { get; set; }
+
+ public WebvttFileKind Kind { get; set; }
+
+ public bool HasValue()
+ {
+ return !string.IsNullOrEmpty(this.Url?.OriginalString) && this.Kind != WebvttFileKind.None;
+ }
+}
diff --git a/samples/video-translation/csharp/Common/VideoTranslationLib.PublicPreview/HttpClient/IterationClient.cs b/samples/video-translation/csharp/Common/VideoTranslationLib.PublicPreview/HttpClient/IterationClient.cs
new file mode 100644
index 000000000..8cc21e01b
--- /dev/null
+++ b/samples/video-translation/csharp/Common/VideoTranslationLib.PublicPreview/HttpClient/IterationClient.cs
@@ -0,0 +1,211 @@
+//
+// Copyright (c) Microsoft. All rights reserved.
+// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
+//
+
+namespace VideoTranslationPublicPreviewLib.HttpClient;
+
+using Flurl;
+using Flurl.Http;
+using Flurl.Util;
+using Microsoft.SpeechServices.CommonLib;
+using Microsoft.SpeechServices.CommonLib.Util;
+using Microsoft.SpeechServices.Cris.Http.DTOs.Public;
+using Microsoft.SpeechServices.DataContracts;
+using Newtonsoft.Json;
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Net;
+using System.Threading.Tasks;
+
+public class IterationClient : HttpClientBase
+ where TDeploymentEnvironment : Enum
+{
+ public IterationClient(HttpClientConfigBase config)
+ : base(config)
+ {
+ }
+
+ public override string ControllerName => "translations";
+
+ public async Task<(TIteration iteration, IReadOnlyNameValueList headers)> GetIterationAsync(
+ string translationId,
+ string iterationId,
+ IReadOnlyDictionary additionalHeaders)
+ {
+ var responseTask = GetIterationWithResponseAsync(
+ translationId: translationId,
+ iterationId: iterationId,
+ additionalHeaders: additionalHeaders);
+ var response = await responseTask.ConfigureAwait(false);
+ var iteration = await response.GetJsonAsync().ConfigureAwait(false);
+ return (iteration, response.Headers);
+ }
+
+ public async Task<(string iterationStringResponse, IReadOnlyNameValueList headers)> GetIterationStringResponseAsync(
+ string translationId,
+ string iterationId,
+ IReadOnlyDictionary additionalHeaders = null)
+ {
+ var responseTask = GetIterationWithResponseAsync(
+ translationId: translationId,
+ iterationId: iterationId,
+ additionalHeaders: additionalHeaders);
+ var response = await responseTask.ConfigureAwait(false);
+ var responseString = await response.GetStringAsync().ConfigureAwait(false);
+ return (responseString, response.Headers);
+ }
+
+ public async Task GetIterationWithResponseAsync(
+ string translationId,
+ string iterationId,
+ IReadOnlyDictionary additionalHeaders)
+ {
+ ArgumentException.ThrowIfNullOrEmpty(translationId);
+ ArgumentException.ThrowIfNullOrEmpty(iterationId);
+
+ var url = BuildRequestBase(additionalHeaders: additionalHeaders)
+ .AppendPathSegment(translationId)
+ .AppendPathSegment("iterations")
+ .AppendPathSegment(iterationId);
+
+ return await RequestWithRetryAsync(async () =>
+ {
+ try
+ {
+ return await url
+ .GetAsync()
+ .ConfigureAwait(false);
+ }
+ catch (FlurlHttpException ex)
+ {
+ if (ex.StatusCode == (int)HttpStatusCode.NotFound)
+ {
+ return null;
+ }
+
+ Console.Write($"Response failed with error: {await ex.GetResponseStringAsync().ConfigureAwait(false)}");
+ throw;
+ }
+ }).ConfigureAwait(false);
+ }
+
+ public async Task> QueryIterationsAsync(string translationId)
+ {
+ var url = BuildRequestBase()
+ .AppendPathSegment(translationId);
+
+ return await RequestWithRetryAsync(async () =>
+ {
+ // var responseJson = await url.GetStringAsync().ConfigureAwait(false);
+ return await url.GetAsync()
+ .ReceiveJson>()
+ .ConfigureAwait(false);
+ }).ConfigureAwait(false);
+ }
+
+ public async Task CreateIterationAndWaitUntilTerminatedAsync(
+ string translationId,
+ TIteration iteration,
+ IReadOnlyDictionary additionalHeaders = null)
+ where TIteration : StatefulResourceBase
+ {
+ var operationId = Guid.NewGuid().ToString();
+
+ Console.WriteLine($"Creating iteration {iteration.Id} for translation {translationId} :");
+ var (iterationResponse, headers) = await CreateIterationAsync(
+ translationId: translationId,
+ iteration: iteration,
+ operationId: operationId,
+ additionalHeaders: additionalHeaders).ConfigureAwait(false);
+ ArgumentNullException.ThrowIfNull(iterationResponse);
+
+ if (!headers.TryGetFirst(CommonConst.Http.Headers.OperationLocation, out var operationLocation) ||
+ string.IsNullOrEmpty(operationLocation))
+ {
+ throw new InvalidDataException($"Missing header {CommonConst.Http.Headers.OperationLocation} in headers");
+ }
+
+ var operationClient = new OperationClient(this.Config);
+
+ await operationClient.QueryOperationUntilTerminateAsync(new Uri(operationLocation)).ConfigureAwait(false);
+
+ (iterationResponse, headers) = await GetIterationAsync(
+ translationId: translationId,
+ iterationId: iterationResponse.Id,
+ additionalHeaders: additionalHeaders).ConfigureAwait(false);
+
+ Console.WriteLine("Created iteration:");
+ Console.WriteLine(JsonConvert.SerializeObject(
+ iterationResponse,
+ Formatting.Indented,
+ CustomContractResolver.WriterSettings));
+
+ return iterationResponse;
+ }
+
+ public async Task<(TIteration iteration, IReadOnlyNameValueList headers)> CreateIterationAsync(
+ string translationId,
+ TIteration iteration,
+ string operationId,
+ IReadOnlyDictionary additionalHeaders = null)
+ where TIteration : StatefulResourceBase
+ {
+ ArgumentNullException.ThrowIfNull(iteration);
+
+ var responseTask = CreateIterationWithResponseAsync(
+ translationId: translationId,
+ iteration: iteration,
+ operationId: operationId,
+ additionalHeaders: additionalHeaders);
+ var response = await responseTask.ConfigureAwait(false);
+ var iterationResponse = await response.GetJsonAsync()
+ .ConfigureAwait(false);
+ return (iterationResponse, response.Headers);
+ }
+
+ public async Task<(string iterationResponseString, IReadOnlyNameValueList headers)> CreateIterationWithStringResponseAsync(
+ string translationId,
+ TIteration iteration,
+ string operationId,
+ IReadOnlyDictionary additionalHeaders = null)
+ where TIteration : StatefulResourceBase
+ {
+ var responseTask = CreateIterationWithResponseAsync(
+ translationId: translationId,
+ iteration: iteration,
+ operationId: operationId,
+ additionalHeaders: additionalHeaders);
+ var response = await responseTask.ConfigureAwait(false);
+ var responseString = await response.GetStringAsync()
+ .ConfigureAwait(false);
+ return (responseString, response.Headers);
+ }
+
+ private async Task CreateIterationWithResponseAsync(
+ string translationId,
+ TIteration iteration,
+ string operationId,
+ IReadOnlyDictionary additionalHeaders = null)
+ where TIteration : StatefulResourceBase
+ {
+ ArgumentNullException.ThrowIfNull(iteration);
+ ArgumentException.ThrowIfNullOrEmpty(translationId);
+ ArgumentException.ThrowIfNullOrEmpty(iteration.Id);
+ ArgumentException.ThrowIfNullOrEmpty(operationId);
+
+ var url = BuildRequestBase(additionalHeaders: additionalHeaders)
+ .AppendPathSegment(translationId)
+ .AppendPathSegment("iterations")
+ .AppendPathSegment(iteration.Id)
+ .WithHeader(CommonConst.Http.Headers.OperationId, operationId);
+
+ return await RequestWithRetryAsync(async () =>
+ {
+ return await url
+ .PutJsonAsync(iteration)
+ .ConfigureAwait(false);
+ }).ConfigureAwait(false);
+ }
+}
diff --git a/samples/video-translation/csharp/Common/VideoTranslationLib.PublicPreview/HttpClient/OperationClient.cs b/samples/video-translation/csharp/Common/VideoTranslationLib.PublicPreview/HttpClient/OperationClient.cs
new file mode 100644
index 000000000..be70071a3
--- /dev/null
+++ b/samples/video-translation/csharp/Common/VideoTranslationLib.PublicPreview/HttpClient/OperationClient.cs
@@ -0,0 +1,104 @@
+//
+// Copyright (c) Microsoft. All rights reserved.
+// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
+//
+
+namespace VideoTranslationPublicPreviewLib.HttpClient;
+
+using Flurl;
+using Flurl.Http;
+using Microsoft.SpeechServices.CommonLib;
+using Microsoft.SpeechServices.CommonLib.Util;
+using Microsoft.SpeechServices.Cris.Http.DTOs.Public.VideoTranslation.Public20240520Preview;
+using System;
+using System.Linq;
+using System.Net;
+using System.Threading.Tasks;
+
+public class OperationClient : HttpClientBase
+ where TDeploymentEnvironment : Enum
+{
+ public OperationClient(HttpClientConfigBase config)
+ : base(config)
+ {
+ }
+
+ public override string ControllerName => "Operations";
+
+ public async Task QueryOperationUntilTerminateAsync(
+ Uri operationLocation)
+ {
+ var operation = await this.GetOperationAsync(operationLocation).ConfigureAwait(false);
+ ArgumentNullException.ThrowIfNull(operation);
+ Console.WriteLine($"Querying operation: {operationLocation}:");
+ var lastStatus = operation.Status;
+ Console.WriteLine(operation.Status.AsString());
+ while (new[]
+ {
+ OperationStatus.NotStarted,
+ OperationStatus.Running,
+ }.Contains(operation.Status))
+ {
+ operation = await this.GetOperationAsync(operationLocation).ConfigureAwait(false);
+ ArgumentNullException.ThrowIfNull(operation);
+ if (operation.Status != lastStatus)
+ {
+ Console.WriteLine();
+ Console.WriteLine(operation.Status.AsString());
+ lastStatus = operation.Status;
+ }
+
+ Console.Write(".");
+ await Task.Delay(CommonConst.Http.OperationQueryDuration).ConfigureAwait(false);
+ }
+
+ Console.WriteLine();
+ }
+
+ public async Task GetOperationStringAsync(Uri operationLocation)
+ {
+ var url = operationLocation
+ .SetQueryParam("api-version", this.Config.ApiVersion)
+ .WithHeader("Ocp-Apim-Subscription-Key", this.Config.SubscriptionKey);
+
+ var response = await GetOperationWithResponseAsync(operationLocation).ConfigureAwait(false);
+ return await response.GetStringAsync().ConfigureAwait(false);
+ }
+
+ public async Task GetOperationAsync(Uri operationLocation)
+ {
+ var url = operationLocation
+ .SetQueryParam("api-version", this.Config.ApiVersion)
+ .WithHeader("Ocp-Apim-Subscription-Key", this.Config.SubscriptionKey);
+
+ var response = await GetOperationWithResponseAsync(operationLocation).ConfigureAwait(false);
+ return await response.GetJsonAsync().ConfigureAwait(false);
+ }
+
+ public async Task GetOperationWithResponseAsync(Uri operationLocation)
+ {
+ var url = operationLocation
+ .SetQueryParam("api-version", this.Config.ApiVersion)
+ .WithHeader("Ocp-Apim-Subscription-Key", this.Config.SubscriptionKey);
+
+ return await RequestWithRetryAsync(async () =>
+ {
+ try
+ {
+ return await url
+ .GetAsync()
+ .ConfigureAwait(false);
+ }
+ catch (FlurlHttpException ex)
+ {
+ if (ex.StatusCode == (int)HttpStatusCode.NotFound)
+ {
+ return null;
+ }
+
+ Console.Write($"Response failed with error: {await ex.GetResponseStringAsync().ConfigureAwait(false)}");
+ throw;
+ }
+ }).ConfigureAwait(false);
+ }
+}
diff --git a/samples/video-translation/csharp/Common/VideoTranslationLib.PublicPreview/HttpClient/TranslationClient.cs b/samples/video-translation/csharp/Common/VideoTranslationLib.PublicPreview/HttpClient/TranslationClient.cs
new file mode 100644
index 000000000..a89faf27d
--- /dev/null
+++ b/samples/video-translation/csharp/Common/VideoTranslationLib.PublicPreview/HttpClient/TranslationClient.cs
@@ -0,0 +1,237 @@
+//
+// Copyright (c) Microsoft. All rights reserved.
+// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
+//
+
+namespace VideoTranslationPublicPreviewLib.HttpClient;
+
+using Flurl;
+using Flurl.Http;
+using Flurl.Util;
+using Microsoft.SpeechServices.CommonLib;
+using Microsoft.SpeechServices.CommonLib.Util;
+using Microsoft.SpeechServices.Cris.Http.DTOs.Public.VideoTranslation.Public20240520Preview;
+using Microsoft.SpeechServices.DataContracts;
+using Newtonsoft.Json;
+using System;
+using System.IO;
+using System.Linq;
+using System.Net;
+using System.Threading.Tasks;
+
+public class TranslationClient : HttpClientBase
+ where TDeploymentEnvironment : Enum
+ where TIteration : Iteration
+ where TIterationInput : IterationInput
+{
+ public TranslationClient(HttpClientConfigBase config)
+ : base(config)
+ {
+ }
+
+ public override string ControllerName => "Translations";
+
+
+ public async Task DeleteTranslationAsync(string translationId)
+ {
+ ArgumentException.ThrowIfNullOrEmpty(translationId);
+
+ var url = BuildRequestBase()
+ .AppendPathSegment(translationId);
+
+ return await RequestWithRetryAsync(async () =>
+ {
+ return await url
+ .DeleteAsync()
+ .ConfigureAwait(false);
+ }).ConfigureAwait(false);
+ }
+
+ public async Task GetTranslationStringAsync(string translationId)
+ {
+ var response = await GetTranslationResponseAsync(translationId).ConfigureAwait(false);
+ return await response.GetStringAsync().ConfigureAwait(false);
+ }
+
+ public async Task> GetTranslationAsync(string translationId)
+ {
+ var response = await GetTranslationResponseAsync(translationId).ConfigureAwait(false);
+
+ // Not exist.
+ if (response == null)
+ {
+ return null;
+ }
+
+ return await response.GetJsonAsync>().ConfigureAwait(false);
+ }
+
+ public async Task GetTranslationResponseAsync(string translationId)
+ {
+ var url = BuildRequestBase();
+
+ url = url.AppendPathSegment(translationId.ToString());
+
+ return await RequestWithRetryAsync(async () =>
+ {
+ try
+ {
+ return await url
+ .GetAsync()
+ .ConfigureAwait(false);
+ }
+ catch (FlurlHttpException ex)
+ {
+ if (ex.StatusCode == (int)HttpStatusCode.NotFound)
+ {
+ return null;
+ }
+
+ Console.Write($"Response failed with error: {await ex.GetResponseStringAsync().ConfigureAwait(false)}");
+ throw;
+ }
+ }).ConfigureAwait(false);
+ }
+
+ public async Task>> GetTranslationsAsync()
+ {
+ var url = BuildRequestBase();
+
+ return await RequestWithRetryAsync(async () =>
+ {
+ // var responseJson = await url.GetStringAsync().ConfigureAwait(false);
+ return await url.GetAsync()
+ .ReceiveJson>>()
+ .ConfigureAwait(false);
+ }).ConfigureAwait(false);
+ }
+
+ public async Task> GetIterationsAsync(string translationId)
+ {
+ var url = BuildRequestBase();
+
+ return await RequestWithRetryAsync(async () =>
+ {
+ // var responseJson = await url.GetStringAsync().ConfigureAwait(false);
+ return await url
+ .AppendPathSegment(translationId)
+ .AppendPathSegment("iterations")
+ .GetAsync()
+ .ReceiveJson>()
+ .ConfigureAwait(false);
+ }).ConfigureAwait(false);
+ }
+
+ public async Task GetIterationAsync(string translationId, string iterationId)
+ {
+ var url = BuildRequestBase();
+
+ return await RequestWithRetryAsync(async () =>
+ {
+ // var responseJson = await url.GetStringAsync().ConfigureAwait(false);
+ return await url
+ .AppendPathSegment(translationId)
+ .AppendPathSegment("iterations")
+ .AppendPathSegment(iterationId)
+ .GetAsync()
+ .ReceiveJson()
+ .ConfigureAwait(false);
+ }).ConfigureAwait(false);
+ }
+
+ public async Task<(Translation translation, TIteration iteration)> CreateTranslationAndIterationAndWaitUntilTerminatedAsync(
+ Translation translation,
+ TIteration iteration)
+ {
+ var transaltionResponse = await CreateTranslationAndWaitUntilTerminatedAsync(
+ translation: translation).ConfigureAwait(false);
+ ArgumentNullException.ThrowIfNull(transaltionResponse);
+
+ Console.WriteLine("Created translation:");
+ Console.WriteLine(JsonConvert.SerializeObject(
+ transaltionResponse,
+ Formatting.Indented,
+ CustomContractResolver.WriterSettings));
+
+ var iterationClient = new IterationClient(this.Config);
+ var iterationResponse = await iterationClient.CreateIterationAndWaitUntilTerminatedAsync(
+ translationId: transaltionResponse.Id,
+ iteration: iteration,
+ additionalHeaders: null).ConfigureAwait(false);
+
+ return (transaltionResponse, iterationResponse);
+ }
+
+ public async Task> CreateTranslationAndWaitUntilTerminatedAsync(
+ Translation translation)
+ {
+ Console.WriteLine($"Creating translation {translation.Id} :");
+
+ var operationId = Guid.NewGuid().ToString();
+ var (responseTranslation, createTranslationResponseHeaders) = await CreateTranslationAsync(
+ translation: translation,
+ operationId: operationId).ConfigureAwait(false);
+ ArgumentNullException.ThrowIfNull(responseTranslation);
+
+ if (!createTranslationResponseHeaders.TryGetFirst(CommonConst.Http.Headers.OperationLocation, out var operationLocation) ||
+ string.IsNullOrEmpty(operationLocation))
+ {
+ throw new InvalidDataException($"Missing header {CommonConst.Http.Headers.OperationLocation} in headers");
+ }
+
+ var operationClient = new OperationClient(this.Config);
+
+ await operationClient.QueryOperationUntilTerminateAsync(new Uri(operationLocation)).ConfigureAwait(false);
+
+ return await GetTranslationAsync(
+ translationId: responseTranslation.Id).ConfigureAwait(false);
+ }
+
+ public async Task<(Translation translation, IReadOnlyNameValueList headers)> CreateTranslationAsync(
+ Translation translation,
+ string operationId)
+ {
+ ArgumentNullException.ThrowIfNull(translation);
+ var responseTask = CreateTranslationWithResponseAsync(
+ translation: translation,
+ operationId: operationId);
+ var response = await responseTask.ConfigureAwait(false);
+ var translationResponse = await response.GetJsonAsync>()
+ .ConfigureAwait(false);
+ return (translationResponse, response.Headers);
+ }
+
+ public async Task<(string responseString, IReadOnlyNameValueList headers)> CreateTranslationWithStringResponseAsync(
+ Translation translation,
+ string operationId)
+ {
+ ArgumentNullException.ThrowIfNull(translation);
+ var responseTask = CreateTranslationWithResponseAsync(
+ translation: translation,
+ operationId: operationId);
+ var response = await responseTask.ConfigureAwait(false);
+ var translationResponse = await response.GetStringAsync()
+ .ConfigureAwait(false);
+ return (translationResponse, response.Headers);
+ }
+
+ private async Task CreateTranslationWithResponseAsync(
+ Translation translation,
+ string operationId)
+ {
+ ArgumentNullException.ThrowIfNull(translation);
+ ArgumentException.ThrowIfNullOrEmpty(translation.Id);
+ ArgumentException.ThrowIfNullOrEmpty(operationId);
+
+ var url = BuildRequestBase()
+ .AppendPathSegment(translation.Id)
+ .WithHeader(CommonConst.Http.Headers.OperationId, operationId);
+
+ return await RequestWithRetryAsync(async () =>
+ {
+ return await url
+ .PutJsonAsync(translation)
+ .ConfigureAwait(false);
+ }).ConfigureAwait(false);
+ }
+}
diff --git a/samples/video-translation/csharp/Common/VideoTranslationLib.PublicPreview/VideoTranslationLib.PublicPreview.csproj b/samples/video-translation/csharp/Common/VideoTranslationLib.PublicPreview/VideoTranslationLib.PublicPreview.csproj
new file mode 100644
index 000000000..8f8fbf67d
--- /dev/null
+++ b/samples/video-translation/csharp/Common/VideoTranslationLib.PublicPreview/VideoTranslationLib.PublicPreview.csproj
@@ -0,0 +1,13 @@
+
+
+
+ net7.0
+ Microsoft.SpeechServices.VideoTranslationLib.PublicPreview.Base
+ Microsoft.SpeechServices.VideoTranslationLib.PublicPreview.Base
+
+
+
+
+
+
+
diff --git a/samples/video-translation/csharp/Common/VideoTranslationLib.PublicPreview/VideoTranslationPublicPreviewHttpClientConfig.cs b/samples/video-translation/csharp/Common/VideoTranslationLib.PublicPreview/VideoTranslationPublicPreviewHttpClientConfig.cs
new file mode 100644
index 000000000..f6941ef3b
--- /dev/null
+++ b/samples/video-translation/csharp/Common/VideoTranslationLib.PublicPreview/VideoTranslationPublicPreviewHttpClientConfig.cs
@@ -0,0 +1,42 @@
+//
+// Copyright (c) Microsoft. All rights reserved.
+// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
+//
+
+namespace Microsoft.SpeechServices.VideoTranslation;
+
+using Flurl;
+using Microsoft.SpeechServices.CommonLib.Attributes;
+using Microsoft.SpeechServices.CommonLib.Extensions;
+using Microsoft.SpeechServices.CommonLib.Util;
+using System;
+
+public class VideoTranslationPublicPreviewHttpClientConfig :
+ HttpClientConfigBase
+ where TDeploymentEnvironment : Enum
+{
+ public VideoTranslationPublicPreviewHttpClientConfig(TDeploymentEnvironment environment, string subKey)
+ : base(environment, subKey)
+ {
+ }
+
+ public override string RouteBase => "videotranslation";
+
+ public override Uri RootUrl
+ {
+ get
+ {
+ return this.BaseUrl
+ .AppendPathSegment(RouteBase)
+ .ToUri();
+ }
+ }
+
+ public override Uri BaseUrl
+ {
+ get
+ {
+ return this.Environment.GetAttributeOfType()?.GetApimApiBaseUrl();
+ }
+ }
+}
diff --git a/samples/video-translation/csharp/VideoTranslationSample/VideoTranslationSample.sln b/samples/video-translation/csharp/VideoTranslationSample/VideoTranslationSample.sln
new file mode 100644
index 000000000..11bd742c1
--- /dev/null
+++ b/samples/video-translation/csharp/VideoTranslationSample/VideoTranslationSample.sln
@@ -0,0 +1,50 @@
+
+Microsoft Visual Studio Solution File, Format Version 12.00
+# Visual Studio Version 17
+VisualStudioVersion = 17.10.35027.167
+MinimumVisualStudioVersion = 10.0.40219.1
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CommonLib", "..\Common\CommonLib\CommonLib.csproj", "{1E17112D-FC76-41C1-9009-E1831F237226}"
+EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "VideoTranslationSample", "VideoTranslationSample\VideoTranslationSample.csproj", "{95F70A8E-FEA6-45F4-9C22-C9BDEA60544D}"
+EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "VideoTranslationLib.PublicPreview", "..\Common\VideoTranslationLib.PublicPreview\VideoTranslationLib.PublicPreview.csproj", "{DC18FF21-5F97-47AA-BE14-79D219B8183B}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Lib", "Lib", "{A9B28A07-F3F2-4EBF-B460-0492B5EDE1C4}"
+EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "VideoTranslationLib.Common", "..\Common\VideoTranslationLib.Common\VideoTranslationLib.Common.csproj", "{B86258A9-9501-4080-AA11-AD598C8C7365}"
+EndProject
+Global
+ GlobalSection(SolutionConfigurationPlatforms) = preSolution
+ Debug|Any CPU = Debug|Any CPU
+ Release|Any CPU = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(ProjectConfigurationPlatforms) = postSolution
+ {1E17112D-FC76-41C1-9009-E1831F237226}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {1E17112D-FC76-41C1-9009-E1831F237226}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {1E17112D-FC76-41C1-9009-E1831F237226}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {1E17112D-FC76-41C1-9009-E1831F237226}.Release|Any CPU.Build.0 = Release|Any CPU
+ {95F70A8E-FEA6-45F4-9C22-C9BDEA60544D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {95F70A8E-FEA6-45F4-9C22-C9BDEA60544D}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {95F70A8E-FEA6-45F4-9C22-C9BDEA60544D}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {95F70A8E-FEA6-45F4-9C22-C9BDEA60544D}.Release|Any CPU.Build.0 = Release|Any CPU
+ {DC18FF21-5F97-47AA-BE14-79D219B8183B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {DC18FF21-5F97-47AA-BE14-79D219B8183B}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {DC18FF21-5F97-47AA-BE14-79D219B8183B}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {DC18FF21-5F97-47AA-BE14-79D219B8183B}.Release|Any CPU.Build.0 = Release|Any CPU
+ {B86258A9-9501-4080-AA11-AD598C8C7365}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {B86258A9-9501-4080-AA11-AD598C8C7365}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {B86258A9-9501-4080-AA11-AD598C8C7365}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {B86258A9-9501-4080-AA11-AD598C8C7365}.Release|Any CPU.Build.0 = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(SolutionProperties) = preSolution
+ HideSolutionNode = FALSE
+ EndGlobalSection
+ GlobalSection(NestedProjects) = preSolution
+ {1E17112D-FC76-41C1-9009-E1831F237226} = {A9B28A07-F3F2-4EBF-B460-0492B5EDE1C4}
+ {DC18FF21-5F97-47AA-BE14-79D219B8183B} = {A9B28A07-F3F2-4EBF-B460-0492B5EDE1C4}
+ {B86258A9-9501-4080-AA11-AD598C8C7365} = {A9B28A07-F3F2-4EBF-B460-0492B5EDE1C4}
+ EndGlobalSection
+ GlobalSection(ExtensibilityGlobals) = postSolution
+ SolutionGuid = {B09F3B56-6D3D-4CF3-954C-D9308D6A1A68}
+ EndGlobalSection
+EndGlobal
diff --git a/samples/video-translation/csharp/VideoTranslationSample/VideoTranslationSample/Arguments.cs b/samples/video-translation/csharp/VideoTranslationSample/VideoTranslationSample/Arguments.cs
new file mode 100644
index 000000000..5e93b43cb
--- /dev/null
+++ b/samples/video-translation/csharp/VideoTranslationSample/VideoTranslationSample/Arguments.cs
@@ -0,0 +1,230 @@
+//
+// Copyright (c) Microsoft. All rights reserved.
+// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
+//
+
+namespace Microsoft.SpeechServices.VideoTranslation.ApiSampleCode.PublicPreview;
+
+using Microsoft.SpeechServices.CommonLib.Attributes;
+using Microsoft.SpeechServices.CommonLib.CommandParser;
+using Microsoft.SpeechServices.Cris.Http.DTOs.Public.VideoTranslation.Public20240520Preview;
+using System;
+using System.Globalization;
+
+[Comment("VideoTranslation tool.")]
+public class Arguments
+ where TDeploymentEnvironment : System.Enum
+{
+ [Argument(
+ "mode",
+ Description = "Specifies the execute modes.",
+ Optional = false,
+ UsagePlaceholder = "mode",
+ RequiredModes = "CreateTranslation,QueryTranslations,QueryTranslation,DeleteTranslation,CreateIteration,QueryIterations,QueryIteration,CreateConsent,QueryConsents,QueryConsent,DeleteConsent,CreateTranslationAndIterationAndWaitUntilTerminated")]
+ private string modeString = string.Empty;
+
+ [Argument(
+ "apiVersion",
+ Description = "Specifies the api version: 2024-05-20-preview or 2024-07-30-preview",
+ Optional = true,
+ UsagePlaceholder = "apiVersion",
+ RequiredModes = "CreateTranslation,QueryTranslations,QueryTranslation,DeleteTranslation,CreateIteration,QueryIterations,QueryIteration,CreateConsent,QueryConsents,QueryConsent,DeleteConsent,CreateTranslationAndIterationAndWaitUntilTerminated")]
+ private string apiVersion = string.Empty;
+
+ [Argument(
+ "region",
+ Description = "Specifies the environment: eastus",
+ Optional = false,
+ UsagePlaceholder = "eastus",
+ RequiredModes = "CreateTranslation,QueryTranslations,QueryTranslation,DeleteTranslation,CreateIteration,QueryIterations,QueryIteration,CreateConsent,QueryConsents,QueryConsent,DeleteConsent,CreateTranslationAndIterationAndWaitUntilTerminated")]
+ private string regionString = string.Empty;
+
+ [Argument(
+ "subscriptionKey",
+ Description = "Specifies speech subscription key.",
+ Optional = false,
+ UsagePlaceholder = "subscriptionKey",
+ RequiredModes = "CreateTranslation,QueryTranslations,QueryTranslation,DeleteTranslation,CreateIteration,QueryIterations,QueryIteration,CreateConsent,QueryConsents,QueryConsent,DeleteConsent,CreateTranslationAndIterationAndWaitUntilTerminated")]
+ private string subscriptionKey = string.Empty;
+
+ [Argument(
+ "translationId",
+ Description = "Specifies the translation resource ID",
+ Optional = true,
+ UsagePlaceholder = "translationId",
+ RequiredModes = "CreateTranslation,QueryTranslation,DeleteTranslation,CreateIteration,QueryIteration,CreateTranslationAndIterationAndWaitUntilTerminated,QueryIterations")]
+ private string translationId = string.Empty;
+
+ [Argument(
+ "iterationId",
+ Description = "Specifies the iteration resource ID",
+ Optional = true,
+ UsagePlaceholder = "iterationId",
+ RequiredModes = "CreateIteration,QueryIteration,CreateTranslationAndIterationAndWaitUntilTerminated")]
+ private string iterationId = string.Empty;
+
+ [Argument(
+ "sourceLocale",
+ Description = "Specifies the source locale e.g. en-US, zh-CN",
+ Optional = true,
+ UsagePlaceholder = "en-US",
+ RequiredModes = "CreateTranslation,CreateTranslationAndIterationAndWaitUntilTerminated")]
+ private string sourceLocaleString = string.Empty;
+
+ [Argument(
+ "targetLocale",
+ Description = "Specifies the target locale e.g. en-US, zh-CN",
+ Optional = true,
+ UsagePlaceholder = "zh-CN",
+ OptionalModes = "CreateTranslation,CreateTranslationAndIterationAndWaitUntilTerminated")]
+ private string targetLocaleString = string.Empty;
+
+ [Argument(
+ "videoFileAzureBlobUrl",
+ Description = "Specifies path of input video file azure blob URL.",
+ Optional = true,
+ UsagePlaceholder = "videoFileAzureBlobUrl",
+ RequiredModes = "CreateTranslation,CreateTranslationAndIterationAndWaitUntilTerminated")]
+ private string videoFileAzureBlobUrl = string.Empty;
+
+ [Argument(
+ "webvttFileAzureBlobUrl",
+ Description = "Specifies path of input webvtt file azure blob URL.",
+ Optional = true,
+ UsagePlaceholder = "webvttFileAzureBlobUrl",
+ OptionalModes = "CreateIteration,CreateTranslationAndIterationAndWaitUntilTerminated")]
+ private string webvttFileAzureBlobUrl = string.Empty;
+
+ [Argument(
+ "webvttFileKind",
+ Description = "Specifies webvtt file kind: MetadataJson(default), SourceLocaleSubtitle or TargetLocaleSubtitle.",
+ Optional = true,
+ UsagePlaceholder = "MetadataJson(default)/SourceLocaleSubtitle/TargetLocaleSubtitle",
+ OptionalModes = "CreateIteration,CreateTranslationAndIterationAndWaitUntilTerminated")]
+ private string webvttFileKind = string.Empty;
+
+ [Argument(
+ "voiceKind",
+ Description = "Specifies TTS synthesis voice kind: PlatformVoice or PersonalVoice",
+ Optional = true,
+ UsagePlaceholder = "PlatformVoice(default)/PersonalVoice",
+ RequiredModes = "CreateTranslation,CreateTranslationAndIterationAndWaitUntilTerminated")]
+ private string voiceKindString = string.Empty;
+
+ [Argument(
+ "subtitleMaxCharCountPerSegment",
+ Description = "Subtitle max char per segment.",
+ Optional = true,
+ OptionalModes = "CreateIteration,CreateTranslationAndIterationAndWaitUntilTerminated")]
+ private int subtitleMaxCharCountPerSegment = 0;
+
+ [Argument(
+ "speakerCount",
+ Description = "Max speaker count.",
+ Optional = true,
+ OptionalModes = "CreateIteration,CreateTranslationAndIterationAndWaitUntilTerminated")]
+ private int speakerCount = 0;
+
+ [Argument(
+ "exportSubtitleInVideo",
+ Description = "Whether export subtitle in video.",
+ Optional = true,
+ UsagePlaceholder = "exportSubtitleInVideo",
+ OptionalModes = "CreateIteration,CreateTranslationAndIterationAndWaitUntilTerminated")]
+ private bool exportSubtitleInVideo = false;
+
+ public Mode Mode
+ {
+ get
+ {
+ if (!Enum.TryParse(this.modeString, true, out var mode))
+ {
+ throw new ArgumentException($"Invalid mode arguments.");
+ }
+
+ return mode;
+ }
+ }
+
+ public string ApiVersion => this.apiVersion;
+
+ public TDeploymentEnvironment Environment
+ {
+ get
+ {
+ if (string.IsNullOrEmpty(this.regionString))
+ {
+ throw new ArgumentNullException(nameof(this.regionString));
+ }
+
+ return DeploymentEnvironmentAttribute.ParseFromRegionIdentifier(this.regionString);
+ }
+ }
+
+ public string SpeechSubscriptionKey => this.subscriptionKey;
+
+ public string TranslationId => this.translationId;
+
+ public string IterationId => this.iterationId;
+
+ public CultureInfo TypedSourceLocale
+ {
+ get
+ {
+ if (string.IsNullOrEmpty(this.sourceLocaleString))
+ {
+ return null;
+ }
+
+ return CultureInfo.CreateSpecificCulture(this.sourceLocaleString);
+ }
+ }
+
+ public CultureInfo TypedTargetLocale
+ {
+ get
+ {
+ if (string.IsNullOrEmpty(this.targetLocaleString))
+ {
+ return null;
+ }
+
+ return CultureInfo.CreateSpecificCulture(this.targetLocaleString);
+ }
+ }
+
+ public Uri TypedVideoFileAzureBlobUrl => string.IsNullOrWhiteSpace(this.videoFileAzureBlobUrl) ?
+ null : new Uri(this.videoFileAzureBlobUrl);
+
+ public Uri TypedWebvttFileAzureBlobUrl => string.IsNullOrWhiteSpace(this.webvttFileAzureBlobUrl) ?
+ null : new Uri(this.webvttFileAzureBlobUrl);
+
+ public WebvttFileKind? TypedWebvttFileKind => string.IsNullOrWhiteSpace(this.webvttFileKind) ?
+ null : Enum.Parse(this.webvttFileKind);
+
+ public VoiceKind? TypedVoiceKind
+ {
+ get
+ {
+ if (!string.IsNullOrEmpty(this.voiceKindString))
+ {
+ if (Enum.TryParse(this.voiceKindString, true, out var voiceKind))
+ {
+ return voiceKind;
+ }
+ else
+ {
+ throw new NotSupportedException(this.voiceKindString);
+ }
+ }
+
+ return null;
+ }
+ }
+
+ public int? SubtitleMaxCharCountPerSegment => this.subtitleMaxCharCountPerSegment == 0 ? null : this.subtitleMaxCharCountPerSegment;
+
+ public int? SpeakerCount => this.speakerCount == 0 ? null : this.speakerCount;
+
+ public bool? ExportSubtitleInVideo => this.exportSubtitleInVideo ? true : null;
+}
diff --git a/samples/video-translation/csharp/VideoTranslationSample/VideoTranslationSample/Mode.cs b/samples/video-translation/csharp/VideoTranslationSample/VideoTranslationSample/Mode.cs
new file mode 100644
index 000000000..a1c85ed9a
--- /dev/null
+++ b/samples/video-translation/csharp/VideoTranslationSample/VideoTranslationSample/Mode.cs
@@ -0,0 +1,29 @@
+//
+// Copyright (c) Microsoft. All rights reserved.
+// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
+//
+
+namespace Microsoft.SpeechServices.VideoTranslation.ApiSampleCode.PublicPreview;
+
+public enum Mode
+{
+ None = 0,
+
+ // Combined operations.
+ CreateTranslationAndIterationAndWaitUntilTerminated,
+
+ // Atom operations.
+ CreateTranslation,
+
+ QueryTranslations,
+
+ QueryTranslation,
+
+ DeleteTranslation,
+
+ CreateIteration,
+
+ QueryIterations,
+
+ QueryIteration,
+}
\ No newline at end of file
diff --git a/samples/video-translation/csharp/VideoTranslationSample/VideoTranslationSample/Program.cs b/samples/video-translation/csharp/VideoTranslationSample/VideoTranslationSample/Program.cs
new file mode 100644
index 000000000..253b45134
--- /dev/null
+++ b/samples/video-translation/csharp/VideoTranslationSample/VideoTranslationSample/Program.cs
@@ -0,0 +1,198 @@
+//
+// Copyright (c) Microsoft. All rights reserved.
+// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
+//
+
+namespace Microsoft.SpeechServices.VideoTranslation.ApiSampleCode.PublicPreview;
+
+using Microsoft.SpeechServices.CommonLib;
+using Microsoft.SpeechServices.CommonLib.CommandParser;
+using Microsoft.SpeechServices.CommonLib.Util;
+using Microsoft.SpeechServices.Cris.Http.DTOs.Public.VideoTranslation.Public20240520Preview;
+using Microsoft.SpeechServices.CustomVoice.TtsLib.Util;
+using Microsoft.SpeechServices.VideoTranslationLib.Enums;
+using Microsoft.SpeechServices.VideoTranslationLib.PublicPreview.Base;
+using Microsoft.VisualBasic;
+using Newtonsoft.Json;
+using System;
+using System.Threading.Tasks;
+using VideoTranslationPublicPreviewLib.HttpClient;
+
+internal class Program
+{
+ static async Task Main(string[] args)
+ {
+ ConsoleMaskSasHelper.ShowSas = true;
+ return await ConsoleApp>.RunAsync(
+ args,
+ ProcessAsync).ConfigureAwait(false);
+ }
+
+ public static async Task ProcessAsync(
+ Arguments args)
+ where TDeploymentEnvironment : Enum
+ {
+ try
+ {
+ var httpConfig = new VideoTranslationPublicPreviewHttpClientConfig(
+ args.Environment,
+ args.SpeechSubscriptionKey)
+ {
+ ApiVersion = args.ApiVersion,
+ };
+
+ var translationClient = new TranslationClient, IterationInput>(httpConfig);
+
+ var iterationClient = new IterationClient(httpConfig);
+
+ var operationClient = new OperationClient(httpConfig);
+
+ switch (args.Mode)
+ {
+ case Mode.CreateTranslationAndIterationAndWaitUntilTerminated:
+ {
+ var iteration = new Iteration()
+ {
+ Id = args.IterationId,
+ DisplayName = args.IterationId,
+ Input = new IterationInput()
+ {
+ SpeakerCount = args.SpeakerCount,
+ SubtitleMaxCharCountPerSegment = args.SubtitleMaxCharCountPerSegment,
+ ExportSubtitleInVideo = args.ExportSubtitleInVideo,
+ WebvttFile = args.TypedWebvttFileAzureBlobUrl == null ? null : new WebvttFile()
+ {
+ Kind = args.TypedWebvttFileKind ?? WebvttFileKind.TargetLocaleSubtitle,
+ Url = args.TypedWebvttFileAzureBlobUrl,
+ }
+ }
+ };
+
+ var voiceKind = args.TypedVoiceKind ?? VoiceKind.PlatformVoice;
+ var fileName = UriHelper.GetFileName(args.TypedVideoFileAzureBlobUrl);
+ var translation = new Translation, IterationInput>()
+ {
+ Id = args.TranslationId,
+ DisplayName = fileName,
+ Description = $"Translation {fileName} from {args.TypedSourceLocale} to {args.TypedTargetLocale} with {voiceKind.AsString()}",
+ Input = new TranslationInput()
+ {
+ SpeakerCount = iteration.Input?.SpeakerCount,
+ SubtitleMaxCharCountPerSegment = iteration.Input?.SubtitleMaxCharCountPerSegment,
+ ExportSubtitleInVideo = iteration.Input?.ExportSubtitleInVideo,
+ SourceLocale = args.TypedSourceLocale,
+ TargetLocale = args.TypedTargetLocale,
+ VoiceKind = voiceKind,
+ VideoFileUrl = args.TypedVideoFileAzureBlobUrl,
+ }
+ };
+
+ (translation, iteration) = await translationClient.CreateTranslationAndIterationAndWaitUntilTerminatedAsync(
+ translation: translation,
+ iteration: iteration).ConfigureAwait(false);
+ break;
+ }
+
+ case Mode.CreateTranslation:
+ {
+ await ConsoleAppHelper.CreateTranslationAsync(
+ translationClient: translationClient,
+ translationId: args.TranslationId,
+ sourceLocale: args.TypedSourceLocale,
+ targetLocale: args.TypedTargetLocale,
+ voiceKind: args.TypedVoiceKind ?? VoiceKind.PlatformVoice,
+ videoFileUrl: args.TypedVideoFileAzureBlobUrl,
+ speakerCount: args.SpeakerCount).ConfigureAwait(false);
+ break;
+ }
+
+ case Mode.QueryTranslations:
+ {
+ var translations = await translationClient.GetTranslationsAsync().ConfigureAwait(false);
+ Console.WriteLine(JsonConvert.SerializeObject(
+ translations,
+ Formatting.Indented,
+ CustomContractResolver.WriterSettings));
+ break;
+ }
+
+ case Mode.QueryTranslation:
+ {
+ var translation = await translationClient.GetTranslationAsync(
+ args.TranslationId).ConfigureAwait(false);
+ Console.WriteLine(JsonConvert.SerializeObject(
+ translation,
+ Formatting.Indented,
+ CustomContractResolver.WriterSettings));
+ break;
+ }
+
+ case Mode.DeleteTranslation:
+ {
+ var response = await translationClient.DeleteTranslationAsync(args.TranslationId).ConfigureAwait(false);
+ Console.WriteLine(response.StatusCode);
+ break;
+ }
+
+ case Mode.CreateIteration:
+ {
+ var iteration = new Iteration()
+ {
+ Id = args.IterationId,
+ DisplayName = args.IterationId,
+ Input = new IterationInput()
+ {
+ SpeakerCount = args.SpeakerCount,
+ SubtitleMaxCharCountPerSegment = args.SubtitleMaxCharCountPerSegment,
+ ExportSubtitleInVideo = args.ExportSubtitleInVideo,
+ WebvttFile = args.TypedWebvttFileAzureBlobUrl == null ? null : new WebvttFile()
+ {
+ Kind = args.TypedWebvttFileKind ?? WebvttFileKind.TargetLocaleSubtitle,
+ Url = args.TypedWebvttFileAzureBlobUrl,
+ }
+ }
+ };
+
+ var iterationResponse = await iterationClient.CreateIterationAndWaitUntilTerminatedAsync(
+ translationId: args.TranslationId,
+ iteration: iteration,
+ additionalHeaders: null).ConfigureAwait(false);
+ break;
+ }
+
+ case Mode.QueryIterations:
+ {
+ var translations = await translationClient.GetIterationsAsync(args.TranslationId).ConfigureAwait(false);
+ Console.WriteLine(JsonConvert.SerializeObject(
+ translations,
+ Formatting.Indented,
+ CustomContractResolver.WriterSettings));
+ break;
+ }
+
+ case Mode.QueryIteration:
+ {
+ var translations = await translationClient.GetIterationAsync(args.TranslationId, args.IterationId).ConfigureAwait(false);
+ Console.WriteLine(JsonConvert.SerializeObject(
+ translations,
+ Formatting.Indented,
+ CustomContractResolver.WriterSettings));
+ break;
+ }
+
+ default:
+ throw new NotSupportedException(args.Mode.AsString());
+ }
+ }
+ catch (Exception e)
+ {
+ Console.WriteLine($"Failed to run with exception: {e.Message}");
+ return ExitCode.GenericError;
+ }
+
+ Console.WriteLine();
+ Console.WriteLine("Process completed successfully.");
+
+ return ExitCode.NoError;
+ }
+}
diff --git a/samples/video-translation/csharp/VideoTranslationSample/VideoTranslationSample/VideoTranslationSample.csproj b/samples/video-translation/csharp/VideoTranslationSample/VideoTranslationSample/VideoTranslationSample.csproj
new file mode 100644
index 000000000..50ac3490a
--- /dev/null
+++ b/samples/video-translation/csharp/VideoTranslationSample/VideoTranslationSample/VideoTranslationSample.csproj
@@ -0,0 +1,16 @@
+
+
+
+ Exe
+ net7.0
+ Microsoft.SpeechServices.VideoTranslation.ApiSampleCode.PublicPreview
+ Microsoft.SpeechServices.VideoTranslation.ApiSampleCode.PublicPreview
+
+
+
+
+
+
+
+
+
diff --git a/samples/video-translation/csharp/readme.md b/samples/video-translation/csharp/readme.md
new file mode 100644
index 000000000..02750f71a
--- /dev/null
+++ b/samples/video-translation/csharp/readme.md
@@ -0,0 +1,60 @@
+# Video Dubbing
+
+Video dubbing client tool and API sample code
+
+# Solution:
+ [VideoTranslationApiSampleCode.sln](VideoTranslationSample/VideoTranslationSample.sln)
+
+
+# API sample:
+
+## Usage:
+ For RESTful API usage reference below API core library class.
+
+## RESTful API core library:
+ Translation API core library: [TranslationClient.cs](Common/VideoTranslationLib.PublicPreview.Base/HttpClient/TranslationClient.cs)
+
+ Iteration API core library: [TranslationClient.cs](Common/VideoTranslationLib.PublicPreview.Base/HttpClient/IterationClient.cs)
+
+ Operation API core library: [TranslationClient.cs](Common/VideoTranslationLib.PublicPreview.Base/HttpClient/OperationClient.cs)
+
+# For project CommonLib
+ Not upgrade Flurl to 4.0 due to 4.0 doesn't support NewtonJson for ReceiveJson.
+
+# Supported OS
+ ## Windows
+ Microsoft.VideoTranslationClient.exe
+
+# Runn tool on Windows prerequisite:
+ [Install dotnet 7.0](https://dotnet.microsoft.com/en-us/download/dotnet/7.0)
+
+# Command line sample
+ | Description | Command Sample |
+ | ------------ | -------------- |
+ | Upload new video file for translation, and run first iteration of the translation | -mode CreateTranslationAndIterationAndWaitUntilTerminated -region eastus -subscriptionKey subscriptionKey -apiVersion 2024-05-20-preview -sourceLocale zh-CN -targetLocales en-US -VoiceKind PersonalVoice -videoFileAzureBlobUrl VideoFileAzureBlobUrl |
+ | Create translation for a video file. | -mode CreateTranslation -region eastus -subscriptionKey subscriptionKey -apiVersion 2024-05-20-preview -sourceLocale zh-CN -targetLocale en-US -voiceKind PlatformVoice -translationId translationId -videoFileAzureBlobUrl VideoFileAzureBlobUrl |
+ | Query translations. | -mode QueryTranslations -region eastus -subscriptionKey subscriptionKey -apiVersion 2024-05-20-preview |
+ | Query translation by ID. | -mode QueryTranslation -region eastus -subscriptionKey subscriptionKey -apiVersion 2024-05-20-preview -translationId translationId |
+ | Delete translation by ID. | -mode DeleteTranslation -region eastus -subscriptionKey subscriptionKey -apiVersion 2024-05-20-preview -translationId translationId |
+ | Create iteration for a translation. | -mode CreateIteration -region eastus -subscriptionKey subscriptionKey -apiVersion 2024-05-20-preview -translationId translationId -iterationId iterationId |
+ | Query iterations. | -mode QueryIterations -region eastus -subscriptionKey subscriptionKey -apiVersion 2024-05-20-preview -translationId translationId |
+ | Query iteration by ID. | -mode QueryIteration -region eastus -subscriptionKey subscriptionKey -apiVersion 2024-05-20-preview -translationId translationId -iterationId iterationId |
+
+# Command line tool arguments
+ | Argument | Supported Values Sample | Description |
+ | -------- | ---------------- | ----------- |
+ | -region | eastus | Supported regions |
+ | -subscriptionKey | | Your speech resource key |
+ | -apiVersion | 2024-05-20-preview | API version |
+ | -VoiceKind | PlatformVoice/PersonalVoice | For trnaslated target video, synthesis TTS with either PlatformVoice or PersonalVoice. |
+ | -sourceLocale | en-US | Video file source locale, supported source locales can be queried by running tool with QueryMetadata mode. |
+ | -targetLocales | en-US | translation target locale, supported source locales can be queried by running tool with QueryMetadata mode. |
+ | -translationId | MyTranslateVideo1FromZhCNToEnUS2024050601 | Translation ID. |
+ | -iterationId | MyFirstIteration2024050601 | Iteration ID. |
+ | -videoFileAzureBlobUrl | | Video file URL with SAS(or not) which is hosted in Azure storage blob. |
+ | -webvttFileAzureBlobUrl | | Webvtt file URL with SAS(or not) which is hosted in Azure storage blob. |
+ | -webvttFileKind | TargetLocaleSubtitle/SourceLocaleSubtitle/MetadataJson | Webvtt file kind. |
+ | -subtitleMaxCharCountPerSegment | 100 | Subtitle max char count per segment. |
+ | -speakerCount | 1 | Speaker count of the video. |
+ | -enableLipSync | false | Enable lip sync. |
+ | -exportSubtitleInVideo | false | Export subtitle in video. |
From 8f3c89fcec11ff3e4989f3771fd5df5008152ad6 Mon Sep 17 00:00:00 2001
From: jinshan1979
Date: Thu, 12 Sep 2024 13:07:27 +0800
Subject: [PATCH 04/11] avatar for csharp (#2531)
* avatar for c#
* update readme and add license info
* remove the locale in url
* logging the WebRTC event
* Address yinhe's comments
* Switch from REST API to SDK for calling AOAI
* Some refinement
* A small bug fixing
* Fix a bug that exception is thrown when setting a status code for a streaming response
* Get speaking status from WebRTC event, and remove the getSpeakingStatus API from backend code
* Some optimization for latency reduction
* Fix build failure per repository check
---------
Co-authored-by: v-jizh23
Co-authored-by: Yinhe Wei
Co-authored-by: yinhew <46698869+yinhew@users.noreply.github.com>
---
.gitattributes | 1 +
samples/csharp/web/avatar/Avatar.csproj | 34 +
samples/csharp/web/avatar/Avatar.sln | 25 +
.../avatar/Controllers/AvatarController.cs | 663 ++++++++++++++++++
.../csharp/web/avatar/Models/ClientContext.cs | 62 ++
.../web/avatar/Models/ClientSettings.cs | 52 ++
.../web/avatar/Models/ErrorViewModel.cs | 14 +
samples/csharp/web/avatar/Program.cs | 52 ++
samples/csharp/web/avatar/README.md | 103 +++
.../web/avatar/Services/ClientService.cs | 56 ++
.../web/avatar/Services/GlobalVariables.cs | 18 +
.../web/avatar/Services/IClientService.cs | 16 +
.../web/avatar/Services/IceTokenService.cs | 38 +
.../web/avatar/Services/SpeechTokenService.cs | 58 ++
.../Services/TokenRefreshBackgroundService.cs | 30 +
.../csharp/web/avatar/Views/Home/basic.cshtml | 72 ++
.../csharp/web/avatar/Views/Home/chat.cshtml | 94 +++
.../web/avatar/Views/Shared/Error.cshtml | 13 +
samples/csharp/web/avatar/Views/Web.config | 43 ++
.../web/avatar/appsettings.Development.json | 8 +
samples/csharp/web/avatar/appsettings.json | 26 +
.../csharp/web/avatar/wwwroot/css/styles.css | 350 +++++++++
.../web/avatar/wwwroot/image/background.png | Bin 0 -> 1724 bytes
.../web/avatar/wwwroot/image/favicon.ico | Bin 0 -> 5430 bytes
samples/csharp/web/avatar/wwwroot/js/basic.js | 324 +++++++++
samples/csharp/web/avatar/wwwroot/js/chat.js | 539 ++++++++++++++
26 files changed, 2691 insertions(+)
create mode 100644 samples/csharp/web/avatar/Avatar.csproj
create mode 100644 samples/csharp/web/avatar/Avatar.sln
create mode 100644 samples/csharp/web/avatar/Controllers/AvatarController.cs
create mode 100644 samples/csharp/web/avatar/Models/ClientContext.cs
create mode 100644 samples/csharp/web/avatar/Models/ClientSettings.cs
create mode 100644 samples/csharp/web/avatar/Models/ErrorViewModel.cs
create mode 100644 samples/csharp/web/avatar/Program.cs
create mode 100644 samples/csharp/web/avatar/README.md
create mode 100644 samples/csharp/web/avatar/Services/ClientService.cs
create mode 100644 samples/csharp/web/avatar/Services/GlobalVariables.cs
create mode 100644 samples/csharp/web/avatar/Services/IClientService.cs
create mode 100644 samples/csharp/web/avatar/Services/IceTokenService.cs
create mode 100644 samples/csharp/web/avatar/Services/SpeechTokenService.cs
create mode 100644 samples/csharp/web/avatar/Services/TokenRefreshBackgroundService.cs
create mode 100644 samples/csharp/web/avatar/Views/Home/basic.cshtml
create mode 100644 samples/csharp/web/avatar/Views/Home/chat.cshtml
create mode 100644 samples/csharp/web/avatar/Views/Shared/Error.cshtml
create mode 100644 samples/csharp/web/avatar/Views/Web.config
create mode 100644 samples/csharp/web/avatar/appsettings.Development.json
create mode 100644 samples/csharp/web/avatar/appsettings.json
create mode 100644 samples/csharp/web/avatar/wwwroot/css/styles.css
create mode 100644 samples/csharp/web/avatar/wwwroot/image/background.png
create mode 100644 samples/csharp/web/avatar/wwwroot/image/favicon.ico
create mode 100644 samples/csharp/web/avatar/wwwroot/js/basic.js
create mode 100644 samples/csharp/web/avatar/wwwroot/js/chat.js
diff --git a/.gitattributes b/.gitattributes
index 51ff1103f..d671d3445 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -19,6 +19,7 @@ proguard-rules.pro text
*.config text
*.cpp text
*.cs text
+*.cshtml text
*.csproj text
*.css text
*.editorconfig text
diff --git a/samples/csharp/web/avatar/Avatar.csproj b/samples/csharp/web/avatar/Avatar.csproj
new file mode 100644
index 000000000..a958ab7ba
--- /dev/null
+++ b/samples/csharp/web/avatar/Avatar.csproj
@@ -0,0 +1,34 @@
+
+
+
+ net8.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
+ PreserveNewest
+
+
+ PreserveNewest
+
+
+ PreserveNewest
+
+
+ PreserveNewest
+
+
+ PreserveNewest
+
+
+
+
diff --git a/samples/csharp/web/avatar/Avatar.sln b/samples/csharp/web/avatar/Avatar.sln
new file mode 100644
index 000000000..1827bb423
--- /dev/null
+++ b/samples/csharp/web/avatar/Avatar.sln
@@ -0,0 +1,25 @@
+
+Microsoft Visual Studio Solution File, Format Version 12.00
+# Visual Studio Version 17
+VisualStudioVersion = 17.10.35027.167
+MinimumVisualStudioVersion = 10.0.40219.1
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Avatar", "Avatar.csproj", "{9A644E20-F550-447F-832B-C7AAD0C0E20F}"
+EndProject
+Global
+ GlobalSection(SolutionConfigurationPlatforms) = preSolution
+ Debug|Any CPU = Debug|Any CPU
+ Release|Any CPU = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(ProjectConfigurationPlatforms) = postSolution
+ {9A644E20-F550-447F-832B-C7AAD0C0E20F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {9A644E20-F550-447F-832B-C7AAD0C0E20F}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {9A644E20-F550-447F-832B-C7AAD0C0E20F}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {9A644E20-F550-447F-832B-C7AAD0C0E20F}.Release|Any CPU.Build.0 = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(SolutionProperties) = preSolution
+ HideSolutionNode = FALSE
+ EndGlobalSection
+ GlobalSection(ExtensibilityGlobals) = postSolution
+ SolutionGuid = {27B5668F-A2D4-4389-B5E1-49BE2AEA3D15}
+ EndGlobalSection
+EndGlobal
diff --git a/samples/csharp/web/avatar/Controllers/AvatarController.cs b/samples/csharp/web/avatar/Controllers/AvatarController.cs
new file mode 100644
index 000000000..fb16fec40
--- /dev/null
+++ b/samples/csharp/web/avatar/Controllers/AvatarController.cs
@@ -0,0 +1,663 @@
+//
+// Copyright (c) Microsoft. All rights reserved.
+// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
+//
+
+using Avatar.Models;
+using Avatar.Services;
+using Azure;
+using Azure.AI.OpenAI;
+using Azure.AI.OpenAI.Chat;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.CognitiveServices.Speech;
+using Microsoft.Extensions.Options;
+using Newtonsoft.Json;
+using Newtonsoft.Json.Linq;
+using OpenAI.Chat;
+using System.Text;
+using System.Web;
+
+namespace Avatar.Controllers
+{
+ public class HomeController(IOptions clientSettings, IClientService clientService, ClientContext clientContext) : Controller
+ {
+ private readonly ClientSettings _clientSettings = clientSettings.Value;
+
+ private readonly IClientService _clientService = clientService;
+
+ private readonly ClientContext _clientContext = clientContext;
+
+ private static ChatClient? chatClient;
+
+ [HttpGet("")]
+ public IActionResult Index()
+ {
+ var clientId = _clientService.InitializeClient();
+ return View("Basic", clientId);
+ }
+
+ [HttpGet("basic")]
+ public IActionResult BasicView()
+ {
+ var clientId = _clientService.InitializeClient();
+ return View("Basic", clientId);
+ }
+
+ [HttpGet("chat")]
+ public ActionResult ChatView()
+ {
+ var clientId = _clientService.InitializeClient();
+ if (chatClient == null)
+ {
+ if (_clientSettings.AzureOpenAIEndpoint != null &&
+ _clientSettings.AzureOpenAIAPIKey != null &&
+ _clientSettings.AzureOpenAIDeploymentName != null)
+ {
+ AzureOpenAIClient aoaiClient = new AzureOpenAIClient(
+ new Uri(_clientSettings.AzureOpenAIEndpoint),
+ new AzureKeyCredential(_clientSettings.AzureOpenAIAPIKey));
+ chatClient = aoaiClient.GetChatClient(_clientSettings.AzureOpenAIDeploymentName);
+ }
+ }
+
+ return View("Chat", clientId);
+ }
+
+ [HttpGet("api/getSpeechToken")]
+ public IActionResult GetSpeechToken()
+ {
+ // Retrieve the speech token and other variables
+ var speechToken = GlobalVariables.SpeechToken;
+ var speechRegion = _clientSettings.SpeechRegion;
+ var speechPrivateEndpoint = _clientSettings.SpeechPrivateEndpoint;
+
+ // Create a ContentResult to allow setting response headers
+ var contentResult = new ContentResult
+ {
+ Content = speechToken,
+ ContentType = "text/plain"
+ };
+
+ // Set response headers
+ Response.Headers["SpeechRegion"] = speechRegion;
+ if (!string.IsNullOrEmpty(speechPrivateEndpoint))
+ {
+ Response.Headers["SpeechPrivateEndpoint"] = speechPrivateEndpoint;
+ }
+
+ return contentResult;
+ }
+
+ [HttpGet("api/getIceToken")]
+ public IActionResult GetIceToken()
+ {
+ // Apply customized ICE server if provided
+ if (!string.IsNullOrEmpty(_clientSettings.IceServerUrl) &&
+ !string.IsNullOrEmpty(_clientSettings.IceServerUsername) &&
+ !string.IsNullOrEmpty(_clientSettings.IceServerPassword))
+ {
+ var customIceToken = new
+ {
+ Urls = new[] { _clientSettings.IceServerUrl },
+ Username = _clientSettings.IceServerUsername,
+ Password = _clientSettings.IceServerPassword
+ };
+
+ var customIceTokenJson = JsonConvert.SerializeObject(customIceToken);
+ return base.Content(customIceTokenJson, "application/json");
+ }
+
+ try
+ {
+ var iceToken = GlobalVariables.IceToken ?? string.Empty;
+ return Content(iceToken, "application/json");
+ }
+ catch (Exception ex)
+ {
+ return BadRequest(ex.Message);
+ }
+ }
+
+ [HttpPost("api/connectAvatar")]
+ public async Task ConnectAvatar()
+ {
+ try
+ {
+ var clientId = new Guid(Request.Headers["ClientId"]!);
+
+ var clientContext = _clientService.GetClientContext(clientId);
+
+ // Override default values with client provided values
+ clientContext.AzureOpenAIDeploymentName = Request.Headers["AoaiDeploymentName"].FirstOrDefault() ?? _clientSettings.AzureOpenAIDeploymentName;
+ clientContext.CognitiveSearchIndexName = Request.Headers["CognitiveSearchIndexName"].FirstOrDefault() ?? _clientSettings.CognitiveSearchIndexName;
+ clientContext.TtsVoice = Request.Headers["TtsVoice"].FirstOrDefault() ?? ClientSettings.DefaultTtsVoice;
+ clientContext.CustomVoiceEndpointId = Request.Headers["CustomVoiceEndpointId"].FirstOrDefault();
+ clientContext.PersonalVoiceSpeakerProfileId = Request.Headers["PersonalVoiceSpeakerProfileId"].FirstOrDefault();
+
+ var customVoiceEndpointId = clientContext.CustomVoiceEndpointId;
+
+ SpeechConfig speechConfig;
+ if (!string.IsNullOrEmpty(_clientSettings.SpeechPrivateEndpoint))
+ {
+ var speechPrivateEndpointWss = _clientSettings.SpeechPrivateEndpoint.Replace("https://", "wss://");
+ speechConfig = SpeechConfig.FromSubscription($"{speechPrivateEndpointWss}/tts/cognitiveservices/websocket/v1?enableTalkingAvatar=true", _clientSettings.SpeechKey);
+ }
+ else
+ {
+ string endpointUrl = $"wss://{_clientSettings.SpeechRegion}.tts.speech.microsoft.com/cognitiveservices/websocket/v1?enableTalkingAvatar=true";
+ speechConfig = SpeechConfig.FromEndpoint(new Uri(endpointUrl), _clientSettings.SpeechKey);
+ }
+
+ if (!string.IsNullOrEmpty(customVoiceEndpointId))
+ {
+ speechConfig.EndpointId = customVoiceEndpointId;
+ }
+
+ var speechSynthesizer = new SpeechSynthesizer(speechConfig);
+ clientContext.SpeechSynthesizer = speechSynthesizer;
+
+ if (string.IsNullOrEmpty(GlobalVariables.IceToken))
+ {
+ return BadRequest("IceToken is missing or invalid.");
+ }
+
+ var iceTokenObj = JsonConvert.DeserializeObject>(GlobalVariables.IceToken);
+
+ // Apply customized ICE server if provided
+ if (!string.IsNullOrEmpty(_clientSettings.IceServerUrl) && !string.IsNullOrEmpty(_clientSettings.IceServerUsername) && !string.IsNullOrEmpty(_clientSettings.IceServerPassword))
+ {
+ iceTokenObj = new Dictionary
+ {
+ { "Urls", string.IsNullOrEmpty(_clientSettings.IceServerUrlRemote) ? [_clientSettings.IceServerUrl] : new[] { _clientSettings.IceServerUrlRemote } },
+ { "Username", _clientSettings.IceServerUsername },
+ { "Password", _clientSettings.IceServerPassword }
+ };
+ }
+
+ string localSdp;
+ using (var reader = new StreamReader(Request.Body, Encoding.UTF8))
+ {
+ localSdp = await reader.ReadToEndAsync();
+ }
+
+ var avatarCharacter = Request.Headers["AvatarCharacter"].FirstOrDefault();
+ var avatarStyle = Request.Headers["AvatarStyle"].FirstOrDefault();
+ var backgroundColor = Request.Headers["BackgroundColor"].FirstOrDefault() ?? "#FFFFFFFF";
+ var backgroundImageUrl = Request.Headers["BackgroundImageUrl"].FirstOrDefault();
+ var isCustomAvatar = Request.Headers["IsCustomAvatar"].FirstOrDefault();
+ var transparentBackground = Request.Headers["TransparentBackground"].FirstOrDefault() ?? "false";
+ var videoCrop = Request.Headers["VideoCrop"].FirstOrDefault() ?? "false";
+
+ // Configure avatar settings
+ var urlsArray = iceTokenObj?.TryGetValue("Urls", out var value) == true ? value as string[] : null;
+
+ var firstUrl = urlsArray?.FirstOrDefault()?.ToString();
+
+ var avatarConfig = new
+ {
+ synthesis = new
+ {
+ video = new
+ {
+ protocol = new
+ {
+ name = "WebRTC",
+ webrtcConfig = new
+ {
+ clientDescription = localSdp,
+ iceServers = new[]
+ {
+ new
+ {
+ urls = new[] { firstUrl },
+ username = iceTokenObj!["Username"],
+ credential = iceTokenObj["Password"]
+ }
+ }
+ }
+ },
+ format = new
+ {
+ crop = new
+ {
+ topLeft = new
+ {
+ x = videoCrop.ToLower() == "true" ? 600 : 0,
+ y = 0
+ },
+ bottomRight = new
+ {
+ x = videoCrop.ToLower() == "true" ? 1320 : 1920,
+ y = 1080
+ }
+ },
+ bitrate = 1000000
+ },
+ talkingAvatar = new
+ {
+ customized = (isCustomAvatar?.ToLower() ?? "false") == "true",
+ character = avatarCharacter,
+ style = avatarStyle,
+ background = new
+ {
+ color = transparentBackground.ToLower() == "true" ? "#00FF00FF" : backgroundColor,
+ image = new
+ {
+ url = backgroundImageUrl
+ }
+ }
+ }
+ }
+ }
+ };
+
+ var connection = Connection.FromSpeechSynthesizer(speechSynthesizer);
+ connection.SetMessageProperty("speech.config", "context", JsonConvert.SerializeObject(avatarConfig));
+
+ var speechSynthesisResult = speechSynthesizer.SpeakTextAsync("").Result;
+ Console.WriteLine($"Result ID: {speechSynthesisResult.ResultId}");
+ if (speechSynthesisResult.Reason == ResultReason.Canceled)
+ {
+ var cancellationDetails = SpeechSynthesisCancellationDetails.FromResult(speechSynthesisResult);
+ throw new Exception(cancellationDetails.ErrorDetails);
+ }
+
+ var turnStartMessage = speechSynthesizer.Properties.
+ GetProperty("SpeechSDKInternal-ExtraTurnStartMessage");
+ var turnStartMessageJson = JsonConvert.DeserializeObject(turnStartMessage);
+ var connectionStringToken = turnStartMessageJson?["webrtc"]?["connectionString"];
+ var remoteSdp = connectionStringToken?.ToString() ?? string.Empty;
+ return Content(remoteSdp, "application/json");
+ }
+ catch (Exception ex)
+ {
+ return BadRequest(new { message = ex.Message });
+ }
+ }
+
+ [HttpPost("api/speak")]
+ public async Task Speak()
+ {
+ try
+ {
+ // Extract the client ID from the request headers
+ var clientIdHeader = Request.Headers["ClientId"];
+ if (!Guid.TryParse(clientIdHeader, out Guid clientId))
+ {
+ return BadRequest("Invalid ClientId");
+ }
+
+ // Read the SSML data from the request body
+ string ssml;
+ using (var reader = new StreamReader(Request.Body, Encoding.UTF8))
+ {
+ ssml = await reader.ReadToEndAsync();
+ }
+
+ // Call your method to process the SSML
+ string resultId = await SpeakSsml(ssml, clientId).ConfigureAwait(false);
+ return Content(resultId, "text/plain");
+ }
+ catch (Exception ex)
+ {
+ return BadRequest($"Speak failed. Error message: {ex.Message}");
+ }
+ }
+
+ [HttpPost("api/stopSpeaking")]
+ public async Task StopSpeaking()
+ {
+ try
+ {
+ // Extract the client ID from the request headers
+ var clientIdHeader = Request.Headers["ClientId"];
+ if (!Guid.TryParse(clientIdHeader, out Guid clientId))
+ {
+ return BadRequest("Invalid ClientId");
+ }
+
+ var clientContext = _clientService.GetClientContext(clientId);
+ if (clientContext.IsSpeaking)
+ {
+ // Call the internal method to stop speaking
+ await StopSpeakingInternal(clientId);
+ }
+
+ // Return a success message
+ return Ok("Speaking stopped.");
+ }
+ catch (Exception ex)
+ {
+ return StatusCode(StatusCodes.Status500InternalServerError, $"Error: {ex.Message}");
+ }
+ }
+
+ [HttpPost("api/chat")]
+ public async Task Chat()
+ {
+ // Retrieve and parse the ClientId from headers
+ var clientIdHeaderValues = Request.Headers["ClientId"];
+ var clientId = clientIdHeaderValues.FirstOrDefault();
+ if (string.IsNullOrEmpty(clientId) || !Guid.TryParse(clientId, out var clientGuid))
+ {
+ // Handle missing or invalid ClientId
+ return BadRequest("Invalid or missing ClientId.");
+ }
+
+ // Retrieve the SystemPrompt from headers (optional)
+ var systemPromptHeaderValues = Request.Headers["SystemPrompt"];
+ string systemPrompt = systemPromptHeaderValues.FirstOrDefault() ?? string.Empty;
+
+ // Read the user query from the request body
+ string userQuery;
+ using (var reader = new StreamReader(Request.Body, Encoding.UTF8))
+ {
+ userQuery = await reader.ReadToEndAsync();
+ }
+
+ var clientContext = _clientService.GetClientContext(clientGuid);
+
+ if (!clientContext.ChatInitiated)
+ {
+ InitializeChatContext(systemPrompt, clientGuid);
+ clientContext.ChatInitiated = true;
+ }
+
+ await HandleUserQuery(userQuery, clientGuid, Response);
+
+ return new EmptyResult();
+ }
+
+ [HttpPost("api/chat/clearHistory")]
+ public IActionResult ClearChatHistory()
+ {
+ try
+ {
+ // Extract the client ID from the request headers
+ var clientIdHeader = Request.Headers["ClientId"];
+ if (!Guid.TryParse(clientIdHeader, out Guid clientId))
+ {
+ return BadRequest("Invalid ClientId");
+ }
+
+ // Retrieve the client context and clear chat history
+ var clientContext = _clientService.GetClientContext(clientId);
+ var systemPrompt = Request.Headers["SystemPrompt"].FirstOrDefault() ?? string.Empty;
+ InitializeChatContext(systemPrompt, clientId);
+ clientContext.ChatInitiated = true;
+
+ return Ok("Chat history cleared.");
+ }
+ catch (Exception ex)
+ {
+ return StatusCode(StatusCodes.Status500InternalServerError, $"Error: {ex.Message}");
+ }
+ }
+
+ [HttpPost("api/disconnectAvatar")]
+ public IActionResult DisconnectAvatar()
+ {
+ try
+ {
+ // Extract the client ID from the request headers
+ var clientIdHeader = Request.Headers["ClientId"];
+ if (!Guid.TryParse(clientIdHeader, out Guid clientId))
+ {
+ return BadRequest("Invalid ClientId");
+ }
+
+ // Retrieve the client context
+ var clientContext = _clientService.GetClientContext(clientId);
+
+ if (clientContext == null)
+ {
+ return StatusCode(StatusCodes.Status204NoContent, "Client context not found");
+ }
+
+ var speechSynthesizer = clientContext.SpeechSynthesizer as SpeechSynthesizer;
+ if (speechSynthesizer != null)
+ {
+ var connection = Connection.FromSpeechSynthesizer(speechSynthesizer);
+ connection.Close();
+ }
+
+ return Ok("Disconnected avatar");
+ }
+ catch (Exception ex)
+ {
+ return BadRequest($"Error: {ex.Message}");
+ }
+ }
+
+ [HttpGet("api/initializeClient")]
+ public ActionResult InitializeClient()
+ {
+ try
+ {
+ var clientId = _clientService.InitializeClient();
+ return Ok(new { ClientId = clientId });
+ }
+ catch (Exception ex)
+ {
+ return StatusCode(StatusCodes.Status500InternalServerError, $"Error: {ex.Message}");
+ }
+ }
+
+ public async Task HandleUserQuery(string userQuery, Guid clientId, HttpResponse httpResponse)
+ {
+ var clientContext = _clientService.GetClientContext(clientId);
+ var azureOpenaiDeploymentName = clientContext.AzureOpenAIDeploymentName;
+ var messages = clientContext.Messages;
+
+ var chatMessage = new UserChatMessage(userQuery);
+ messages.Add(chatMessage);
+
+ // For 'on your data' scenario, chat API currently has long (4s+) latency
+ // We return some quick reply here before the chat API returns to mitigate.
+ if (ClientSettings.EnableQuickReply)
+ {
+ await SpeakWithQueue(ClientSettings.QuickReplies[new Random().Next(ClientSettings.QuickReplies.Count)], 2000, clientId);
+ }
+
+ // Process the responseContent as needed
+ var assistantReply = new StringBuilder();
+ var spokenSentence = new StringBuilder();
+
+ if (chatClient == null)
+ {
+ // Skip if the chat client is not ready
+ return;
+ }
+
+ var chatOptions = new ChatCompletionOptions();
+ if (!string.IsNullOrEmpty(_clientSettings.CognitiveSearchEndpoint) &&
+ !string.IsNullOrEmpty(_clientSettings.CognitiveSearchIndexName) &&
+ !string.IsNullOrEmpty(_clientSettings.CognitiveSearchAPIKey))
+ {
+#pragma warning disable AOAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
+ chatOptions.AddDataSource(new AzureSearchChatDataSource()
+ {
+ Endpoint = new Uri(_clientSettings.CognitiveSearchEndpoint),
+ IndexName = _clientSettings.CognitiveSearchIndexName,
+ Authentication = DataSourceAuthentication.FromApiKey(_clientSettings.CognitiveSearchAPIKey)
+ });
+#pragma warning restore AOAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
+ }
+
+ var chatUpdates = chatClient.CompleteChatStreaming(messages, chatOptions);
+
+ foreach (var chatUpdate in chatUpdates)
+ {
+ foreach (var contentPart in chatUpdate.ContentUpdate)
+ {
+ var responseToken = contentPart.Text;
+ if (ClientSettings.OydDocRegex.IsMatch(responseToken))
+ {
+ responseToken = ClientSettings.OydDocRegex.Replace(responseToken, string.Empty);
+ }
+
+ await httpResponse.WriteAsync(responseToken).ConfigureAwait(false);
+
+ assistantReply.Append(responseToken);
+ if (responseToken == "\n" || responseToken == "\n\n")
+ {
+ await SpeakWithQueue(spokenSentence.ToString().Trim(), 0, clientId);
+ spokenSentence.Clear();
+ }
+ else
+ {
+ responseToken = responseToken.Replace("\n", string.Empty);
+ spokenSentence.Append(responseToken); // build up the spoken sentence
+ if (responseToken.Length == 1 || responseToken.Length == 2)
+ {
+ foreach (var punctuation in ClientSettings.SentenceLevelPunctuations)
+ {
+ if (responseToken.StartsWith(punctuation))
+ {
+ await SpeakWithQueue(spokenSentence.ToString().Trim(), 0, clientId);
+ spokenSentence.Clear();
+ break;
+ }
+ }
+ }
+ }
+ }
+ }
+
+ if (spokenSentence.Length > 0)
+ {
+ await SpeakWithQueue(spokenSentence.ToString().Trim(), 0, clientId);
+ }
+
+ var assistantMessage = new AssistantChatMessage(assistantReply.ToString());
+ messages.Add(assistantMessage);
+ }
+
+ public void InitializeChatContext(string systemPrompt, Guid clientId)
+ {
+ var clientContext = _clientService.GetClientContext(clientId);
+ var messages = clientContext.Messages;
+
+ // Initialize messages
+ messages.Clear();
+ var systemMessage = new SystemChatMessage(systemPrompt);
+ messages.Add(systemMessage);
+ }
+
+ // Speak the given text. If there is already a speaking in progress, add the text to the queue. For chat scenario.
+ public Task SpeakWithQueue(string text, int endingSilenceMs, Guid clientId)
+ {
+ var clientContext = _clientService.GetClientContext(clientId);
+
+ var spokenTextQueue = clientContext.SpokenTextQueue;
+ var isSpeaking = clientContext.IsSpeaking;
+
+ spokenTextQueue.Enqueue(text);
+
+ if (!isSpeaking)
+ {
+ clientContext.IsSpeaking = true;
+
+ var ttsVoice = clientContext.TtsVoice;
+ var personalVoiceSpeakerProfileId = clientContext.PersonalVoiceSpeakerProfileId;
+
+ _ = Task.Run(async () =>
+ {
+ try
+ {
+ while (spokenTextQueue.Count > 0)
+ {
+ var currentText = spokenTextQueue.Dequeue();
+ await SpeakText(currentText, ttsVoice!, personalVoiceSpeakerProfileId!, endingSilenceMs, clientId);
+ clientContext.LastSpeakTime = DateTime.UtcNow;
+ }
+ }
+ finally
+ {
+ clientContext.IsSpeaking = false;
+ }
+ });
+ }
+
+ return Task.CompletedTask;
+ }
+
+ public async Task SpeakText(string text, string voice, string speakerProfileId, int endingSilenceMs, Guid clientId)
+ {
+ var escapedText = HttpUtility.HtmlEncode(text);
+ string ssml;
+
+ if (endingSilenceMs > 0)
+ {
+ ssml = $@"
+
+
+
+ {escapedText}
+
+
+
+ ";
+ }
+ else
+ {
+ ssml = $@"
+
+
+
+ {escapedText}
+
+
+ ";
+ }
+
+ return await SpeakSsml(ssml, clientId);
+ }
+
+ public async Task SpeakSsml(string ssml, Guid clientId)
+ {
+ var clientContext = _clientService.GetClientContext(clientId);
+
+ var speechSynthesizer = clientContext.SpeechSynthesizer as SpeechSynthesizer;
+ if (speechSynthesizer == null)
+ {
+ throw new InvalidOperationException("SpeechSynthesizer is not of type SpeechSynthesizer.");
+ }
+
+ var speechSynthesisResult = await speechSynthesizer.SpeakSsmlAsync(ssml).ConfigureAwait(false);
+
+ if (speechSynthesisResult.Reason == ResultReason.Canceled)
+ {
+ var cancellationDetails = SpeechSynthesisCancellationDetails.FromResult(speechSynthesisResult);
+ Console.WriteLine($"Speech synthesis canceled: {cancellationDetails.Reason}");
+
+ if (cancellationDetails.Reason == CancellationReason.Error)
+ {
+ Console.WriteLine($"Result ID: {speechSynthesisResult.ResultId}. Error details: {cancellationDetails.ErrorDetails}");
+ throw new Exception(cancellationDetails.ErrorDetails);
+ }
+ }
+
+ return speechSynthesisResult.ResultId;
+ }
+
+ public async Task StopSpeakingInternal(Guid clientId)
+ {
+ var clientContext = _clientService.GetClientContext(clientId);
+
+ var speechSynthesizer = clientContext.SpeechSynthesizer as SpeechSynthesizer;
+ var spokenTextQueue = clientContext.SpokenTextQueue;
+ spokenTextQueue.Clear();
+
+ try
+ {
+ var connection = Connection.FromSpeechSynthesizer(speechSynthesizer);
+ await connection.SendMessageAsync("synthesis.control", "{\"action\":\"stop\"}");
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine(ex.Message);
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/samples/csharp/web/avatar/Models/ClientContext.cs b/samples/csharp/web/avatar/Models/ClientContext.cs
new file mode 100644
index 000000000..205797f32
--- /dev/null
+++ b/samples/csharp/web/avatar/Models/ClientContext.cs
@@ -0,0 +1,62 @@
+//
+// Copyright (c) Microsoft. All rights reserved.
+// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
+//
+
+using Azure.Core;
+using Azure.Identity;
+using OpenAI.Chat;
+using Microsoft.Extensions.Options;
+
+namespace Avatar.Models
+{
+ public class ClientContext
+ {
+ private readonly DefaultAzureCredential _credential;
+
+ private readonly ClientSettings _clientSettings;
+
+ public string? AzureOpenAIDeploymentName { get; set; }
+
+ public string? CognitiveSearchIndexName { get; set; }
+
+ public string? TtsVoice { get; set; }
+
+ public string? CustomVoiceEndpointId { get; set; }
+
+ public string? PersonalVoiceSpeakerProfileId { get; set; }
+
+ public object? SpeechSynthesizer { get; set; }
+
+ public string? SpeechToken { get; set; }
+
+ public string? IceToken { get; set; }
+
+ public bool ChatInitiated { get; set; }
+
+ public List Messages { get; set; } = [];
+
+ public bool IsSpeaking { get; set; }
+
+ public Queue SpokenTextQueue { get; set; } = new Queue();
+
+ public Thread? SpeakingThread { get; set; }
+
+ public DateTime? LastSpeakTime { get; set; }
+
+ public ClientContext(IOptions clientSettings)
+ {
+ _clientSettings = clientSettings.Value;
+ _credential = new DefaultAzureCredential(new DefaultAzureCredentialOptions
+ {
+ ManagedIdentityClientId = _clientSettings.UserAssignedManagedIdentityClientId
+ });
+ }
+ public async Task GetAzureTokenAsync()
+ {
+ var tokenRequestContext = new TokenRequestContext(["https://cognitiveservices.azure.com/.default"]);
+ var token = await _credential.GetTokenAsync(tokenRequestContext);
+ return token.Token;
+ }
+ }
+}
\ No newline at end of file
diff --git a/samples/csharp/web/avatar/Models/ClientSettings.cs b/samples/csharp/web/avatar/Models/ClientSettings.cs
new file mode 100644
index 000000000..b71230ce1
--- /dev/null
+++ b/samples/csharp/web/avatar/Models/ClientSettings.cs
@@ -0,0 +1,52 @@
+//
+// Copyright (c) Microsoft. All rights reserved.
+// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
+//
+
+using System.Text.RegularExpressions;
+
+namespace Avatar.Models
+{
+ public class ClientSettings
+ {
+ public static readonly List SentenceLevelPunctuations = new List { ".", "?", "!", ":", ";", "。", "?", "!", ":", ";" };
+
+ public static readonly List QuickReplies = new List { "Let me take a look.", "Let me check.", "One moment, please." };
+
+ public static readonly Regex OydDocRegex = new Regex(@"\[doc(\d+)\]");
+
+ public static readonly string DefaultTtsVoice = "en-US-JennyNeural";
+
+ public static readonly bool EnableQuickReply = false;
+
+ public string? SpeechRegion { get; set; }
+
+ public string? SpeechKey { get; set; }
+
+ public string? SpeechPrivateEndpoint { get; set; }
+
+ public string? SpeechResourceUrl { get; set; }
+
+ public string? UserAssignedManagedIdentityClientId { get; set; }
+
+ public string? AzureOpenAIEndpoint { get; set; }
+
+ public string? AzureOpenAIAPIKey { get; set; }
+
+ public string? AzureOpenAIDeploymentName { get; set; }
+
+ public string? CognitiveSearchEndpoint { get; set; }
+
+ public string? CognitiveSearchAPIKey { get; set; }
+
+ public string? CognitiveSearchIndexName { get; set; }
+
+ public string? IceServerUrl { get; set; }
+
+ public string? IceServerUrlRemote { get; set; }
+
+ public string? IceServerUsername { get; set; }
+
+ public string? IceServerPassword { get; set; }
+ }
+}
diff --git a/samples/csharp/web/avatar/Models/ErrorViewModel.cs b/samples/csharp/web/avatar/Models/ErrorViewModel.cs
new file mode 100644
index 000000000..916e992fe
--- /dev/null
+++ b/samples/csharp/web/avatar/Models/ErrorViewModel.cs
@@ -0,0 +1,14 @@
+//
+// Copyright (c) Microsoft. All rights reserved.
+// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
+//
+
+namespace Avatar.Models
+{
+ public class ErrorViewModel
+ {
+ public string? RequestId { get; set; }
+
+ public bool ShowRequestId => !string.IsNullOrEmpty(RequestId);
+ }
+}
diff --git a/samples/csharp/web/avatar/Program.cs b/samples/csharp/web/avatar/Program.cs
new file mode 100644
index 000000000..c984e1735
--- /dev/null
+++ b/samples/csharp/web/avatar/Program.cs
@@ -0,0 +1,52 @@
+//
+// Copyright (c) Microsoft. All rights reserved.
+// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
+//
+
+using Avatar.Models;
+using Avatar.Services;
+
+var builder = WebApplication.CreateBuilder(args);
+
+// Config ClientSettings
+builder.Services.Configure(builder.Configuration.GetSection("ClientSettings"));
+
+builder.Services.AddSingleton();
+builder.Services.AddSingleton();
+
+// Register HttpClient and services
+builder.Services.AddHttpClient();
+builder.Services.AddHttpClient();
+
+// Register services with DI container
+builder.Services.AddSingleton();
+builder.Services.AddSingleton();
+
+// Register the background service to manage token refresh
+builder.Services.AddHostedService();
+
+// Add services to the container.
+builder.Services.AddControllersWithViews();
+
+var app = builder.Build();
+
+// Configure the HTTP request pipeline.
+if (!app.Environment.IsDevelopment())
+{
+ app.UseExceptionHandler("/Home/Error");
+ // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
+ app.UseHsts();
+}
+
+app.UseHttpsRedirection();
+app.UseStaticFiles();
+
+app.UseRouting();
+
+app.UseAuthorization();
+
+app.MapControllerRoute(
+ name: "default",
+ pattern: "{controller=Home}/{action=Index}/{id?}");
+
+app.Run();
diff --git a/samples/csharp/web/avatar/README.md b/samples/csharp/web/avatar/README.md
new file mode 100644
index 000000000..26d0a04a7
--- /dev/null
+++ b/samples/csharp/web/avatar/README.md
@@ -0,0 +1,103 @@
+# Instructions to run Microsoft Azure TTS Talking Avatar sample code
+
+## Pre-requisites
+
+* Follow [Text to speech quickstart](https://learn.microsoft.com/azure/ai-services/speech-service/get-started-text-to-speech?pivots=programming-language-csharp#set-up-the-environment) to set up the environment for running Speech SDK in csharp.
+
+## Basic Sample
+
+This sample demonstrates the basic usage of Azure text-to-speech avatar real-time API.
+
+* Step 1: Open a console and navigate to the folder containing this README.md document.
+ * Set below varibles in appsettings.json:
+ * `SpeechRegion` - the region of your Azure speech resource, e.g. westus2.
+ * `SpeechKey` - the API key of your Azure speech resource.
+ * `SpeechPrivateEndpoint` - the private endpoint of your Azure speech resource. e.g. https://my-speech-service.cognitiveservices.azure.com. This is optional, and only needed when you want to use private endpoint to access Azure speech service. This is optional, which is only needed when you are using custom endpoint.
+ * Set below varibles if you want to use customized ICE server:
+ * `IceServerUrl` - the URL of your customized ICE server.
+ * `IceServerUrlRemote` - the URL of your customized ICE server for remote side. This is only required when the ICE address for remote side is different from local side.
+ * `IceServerUsername` - the username of your customized ICE server.
+ * `IceServerPassword` - the password of your customized ICE server.
+ * Run `dotnet restore` to restore the dependencies and tools specified in the project file.
+ * Run `dotnet run --urls http://localhost:5000` to start this sample.
+
+* Step 2: Open a browser and navigate to `http://localhost:5000/basic` to view the web UI of this sample.
+
+* Step 3: Fill or select below information:
+ * TTS Configuration
+ * TTS Voice - the voice of the TTS. Here is the [available TTS voices list](https://learn.microsoft.com/azure/ai-services/speech-service/language-support?tabs=tts#supported-languages)
+ * Custom Voice Deployment ID (Endpoint ID) - the deployment ID (also called endpoint ID) of your custom voice. If you are not using a custom voice, please leave it empty.
+ * Personal Voice Speaker Profile ID - the personal voice speaker profile ID of your personal voice. Please follow [here](https://learn.microsoft.com/azure/ai-services/speech-service/personal-voice-overview) to view and create personal voice.
+ * Avatar Configuration
+ * Avatar Character - The character of the avatar. By default it's `lisa`, and you can update this value to use a different avatar.
+ * Avatar Style - The style of the avatar. You can update this value to use a different avatar style. This parameter is optional for custom avatar.
+ * Background Color - The color of the avatar background.
+ * Background Image (URL) - The URL of the background image. If you want to have a background image for the avatar, please fill this field. You need first upload your image to a publicly accessbile place, with a public URL. e.g. https://samples-files.com/samples/Images/jpg/1920-1080-sample.jpg
+ * Custom Avatar - Check this if you are using a custom avatar.
+ * Transparent Background - Check this if you want to use transparent background for the avatar. When this is checked, the background color of the video stream from server side is automatically set to green(#00FF00FF), and the js code on client side (check the `makeBackgroundTransparent` function in main.js) will do the real-time matting by replacing the green color with transparent color.
+ * Video Crop - By checking this, you can crop the video stream from server side to a smaller size. This is useful when you want to put the avatar video into a customized rectangle area.
+
+* Step 4: Click `Start Session` button to setup video connection with Azure TTS Talking Avatar service. If everything goes well, you should see a live video with an avatar being shown on the web page.
+
+* Step 5: Type some text in the `Spoken Text` text box and click `Speak` button to send the text to Azure TTS Talking Avatar service. The service will synthesize the text to talking avatar video, and send the video stream back to the browser. The browser will play the video stream. You should see the avatar speaking the text you typed with mouth movement, and hear the voice which is synchronized with the mouth movement.
+
+* Step 6: You can either continue to type text in the `Spoken Text` text box and let the avatar speak that text by clicking `Speak` button, or click `Stop Session` button to stop the video connection with Azure TTS Talking Avatar service. If you click `Stop Session` button, you can click `Start Session` button to start a new video connection with Azure TTS Talking Avatar service.
+
+## Chat Sample
+
+This sample demonstrates the chat scenario, with integration of Azure speech-to-text, Azure OpenAI, and Azure text-to-speech avatar real-time API.
+
+* Step 1: Open a console and navigate to the folder containing this README.md document.
+ * Set below varibles in appsettings.json:
+ * `SpeechRegion` - the region of your Azure speech resource, e.g. westus2.
+ * `SpeechKey` - the API key of your Azure speech resource.
+ * `SpeechPrivateEndpoint` - the private endpoint of your Azure speech resource. e.g. https://my-speech-service.cognitiveservices.azure.com. This is optional, and only needed when you want to use private endpoint to access Azure speech service. This is optional, which is only needed when you are using custom endpoint. For more information about private endpoint, please refer to [Enable private endpoint](https://learn.microsoft.com/azure/ai-services/speech-service/speech-services-private-link).
+ * `SpeechResourceUrl` - the URL of your Azure speech resource, e.g. /subscriptions/6e83d8b7-00dd-4b0a-9e98-dab9f060418b/resourceGroups/my-resource-group/providers/Microsoft.CognitiveServices/accounts/my-speech-resource. To fetch the speech resource URL, go to your speech resource overview page on Azure portal, click `JSON View` link, and then copy the `Resource ID` value on the popped up page. This is optional, which is only needed when you want to use private endpoint to access Azure speech service.
+ * `UserAssignedManagedIdentityClientId` - the client ID of your user-assigned managed identity. This is optional, which is only needed when you want to use private endpoint with user-assigned managed identity to access Azure speech service. For more information about user-assigned managed identity, please refer to [Use a user-assigned managed identity](https://learn.microsoft.com/azure/active-directory/managed-identities-azure-resources/how-to-use-vm-token?tabs=azure-cli).
+ * `AzureOpenAIEndpoint` - the endpoint of your Azure OpenAI resource, e.g. https://my-aoai.openai.azure.com/, which can be found in the `Keys and Endpoint` section of your Azure OpenAI resource in Azure portal.
+ * `AzureOpenAIAPIKey` - the API key of your Azure OpenAI resource, which can be found in the `Keys and Endpoint` section of your Azure OpenAI resource in Azure portal.
+ * `AzureOpenAIDeploymentName` - the name of your Azure OpenAI model deployment, which can be found in the `Model deployments` section of your Azure OpenAI resource in Azure portal.
+ * Set below varibles if you want to use your own data to constrain the chat:
+ * `CognitiveSearchEndpoint` - the endpoint of your Azure Cognitive Search resource, e.g. https://my-cognitive-search.search.windows.net/, which can be found in the `Overview` section of your Azure Cognitive Search resource in Azure portal, appearing at `Essentials -> Url` field.
+ * `CognitiveSearchAPIKey` - the API key of your Azure Cognitive Search resource, which can be found in the `Keys` section of your Azure Cognitive Search resource in Azure portal. Please make sure to use the `Admin Key` instead of `Query Key`.
+ * `CognitiveSearchIndexName` - the name of your Azure Cognitive Search index, which can be found in the `Indexes` section of your Azure Cognitive Search resource in Azure portal.
+ * Set below varibles if you want to use customized ICE server:
+ * `IceServerUrl` - the URL of your customized ICE server.
+ * `IceServerUrlRemote` - the URL of your customized ICE server for remote side. This is only required when the ICE address for remote side is different from local side.
+ * `IceServerUsername` - the username of your customized ICE server.
+ * `IceServerPassword` - the password of your customized ICE server.
+ * Run `dotnet restore` to restore the dependencies and tools specified in the project file.
+ * Run `dotnet run --urls http://localhost:5000` to start this sample.
+
+* Step 2: Open a browser and navigate to `http://localhost:5000/chat` to view the web UI of this sample.
+
+* Step 3: Fill or select below information:
+ * Chat Configuration
+ * Azure OpenAI Deployment Name - the name of your Azure OpenAI model deployment, which can be found in the `Model deployments` section of your Azure OpenAI resource in Azure portal.
+ * System Prompt - you can edit this text to preset the context for the chat API. The chat API will then generate the response based on this context.
+ * Enable On Your Data - check this if you want to use your own data to constrain the chat. If you check this, you need to fill `Azure Cognitive Search Index Name` field below.
+ * Azure Cognitive Search Index Name - the name of your Azure Cognitive Search index, which can be found in the `Indexes` section of your Azure Cognitive Search resource in Azure portal.
+ * Speech Configuration
+ * STT Locale(s) - the locale(s) of the STT. Here is the [available STT languages list](https://learn.microsoft.com/azure/ai-services/speech-service/language-support?tabs=stt#supported-languages). If multiple locales are specified, the STT will enable multi-language recognition, which means the STT will recognize the speech in any of the specified locales.
+ * TTS Voice - the voice of the TTS. Here is the [available TTS voices list](https://learn.microsoft.com/azure/ai-services/speech-service/language-support?tabs=tts#supported-languages)
+ * Custom Voice Deployment ID (Endpoint ID) - the deployment ID (also called endpoint ID) of your custom voice. If you are not using a custom voice, please leave it empty.
+ * Personal Voice Speaker Profile ID - the personal voice speaker profile ID of your personal voice. Please follow [here](https://learn.microsoft.com/azure/ai-services/speech-service/personal-voice-overview) to view and create personal voice.
+ * Continuous Conversation - check this if you want to enable continuous conversation. If this is checked, the STT will keep listening to your speech, with microphone always on until you click `Stop Microphone` button. If this is not checked, the microphone will automatically stop once an utterance is recognized, and you need click `Start Microphone` every time before you give a speech. The `Continuous Conversation` mode is suitable for quiet environment, while the `Non-Continuous Conversation` mode is suitable for noisy environment, which can avoid the noise being recorded while you are not speaking.
+ * Avatar Configuration
+ * Avatar Character - The character of the avatar. By default it's `lisa`, and you can update this value to use a different avatar.
+ * Avatar Style - The style of the avatar. You can update this value to use a different avatar style. This parameter is optional for custom avatar.
+ * Custom Avatar - Check this if you are using a custom avatar.
+ * Auto Reconnect - Check this if you want to enable auto reconnect. If this is checked, the avatar video stream is automatically reconnected once the connection is lost.
+ * Use Local Video for Idle - Check this if you want to use local video for idle part. If this is checked, the avatar video stream is replaced by local video when the avatar is idle. To use this feature, you need to prepare a local video file. Usually, you can record a video of the avatar doing idle action. [Here](https://ttspublic.blob.core.windows.net/sampledata/video/avatar/lisa-casual-sitting-idle.mp4) is a sample video for lisa-casual-sitting avatar idle status. You can download it and put it to `video/lisa-casual-sitting-idle.mp4` under the same folder of `chat.html`.
+
+* Step 4: Click `Open Avatar Session` button to setup video connection with Azure TTS Talking Avatar service. If everything goes well, you should see a live video with an avatar being shown on the web page.
+
+* Step 5: Click `Start Microphone` button to start microphone (make sure to allow the microphone access tip box popping up in the browser), and then you can start chatting with the avatar with speech. The chat history (the text of what you said, and the response text by the Azure OpenAI chat API) will be shown beside the avatar. The avatar will then speak out the response of the chat API.
+
+# Additional Tip(s)
+
+* If you want to enforce the avatar to stop speaking before the avatar finishes the utterance, you can click `Stop Speaking` button. This is useful when you want to interrupt the avatar speaking.
+
+* If you want to clear the chat history and start a new round of chat, you can click `Clear Chat History` button. And if you want to stop the avatar service, please click `Close Avatar Session` button to close the connection with avatar service.
+
+* If you want to type your query message instead of speaking, you can check the `Type Message` checkbox, and then type your query message in the text box showing up below the checkbox.
diff --git a/samples/csharp/web/avatar/Services/ClientService.cs b/samples/csharp/web/avatar/Services/ClientService.cs
new file mode 100644
index 000000000..122420570
--- /dev/null
+++ b/samples/csharp/web/avatar/Services/ClientService.cs
@@ -0,0 +1,56 @@
+//
+// Copyright (c) Microsoft. All rights reserved.
+// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
+//
+
+using Avatar.Models;
+using Microsoft.Extensions.Options;
+using System.Collections.Concurrent;
+
+namespace Avatar.Services
+{
+ public class ClientService(IOptions clientSettings, ClientContext clientContext) : IClientService
+ {
+ private readonly ClientContext _clientContext = clientContext;
+
+ private readonly ConcurrentDictionary _clientContexts = new();
+
+ private readonly ClientSettings _clientSettings = clientSettings.Value;
+
+ public Guid InitializeClient()
+ {
+ var clientId = Guid.NewGuid();
+ var clientContext = _clientContext;
+
+ // set ClientContext property value
+ clientContext.AzureOpenAIDeploymentName = _clientSettings.AzureOpenAIDeploymentName;
+ clientContext.CognitiveSearchIndexName = _clientSettings.CognitiveSearchIndexName;
+ clientContext.TtsVoice = ClientSettings.DefaultTtsVoice;
+ clientContext.CustomVoiceEndpointId = null;
+ clientContext.PersonalVoiceSpeakerProfileId = null;
+ clientContext.SpeechSynthesizer = null;
+ clientContext.SpeechToken = null;
+ clientContext.IceToken = null;
+ clientContext.ChatInitiated = false;
+ clientContext.Messages = [];
+ clientContext.IsSpeaking = false;
+ clientContext.SpokenTextQueue = new Queue();
+ clientContext.SpeakingThread = null;
+ clientContext.LastSpeakTime = null;
+
+ _clientContexts[clientId] = clientContext;
+
+ return clientId;
+ }
+
+ public ClientContext GetClientContext(Guid clientId)
+ {
+ if (!_clientContexts.TryGetValue(clientId, out var context))
+ {
+ throw new KeyNotFoundException($"Client context for ID {clientId} was not found.");
+ }
+
+ return context;
+ }
+ }
+}
diff --git a/samples/csharp/web/avatar/Services/GlobalVariables.cs b/samples/csharp/web/avatar/Services/GlobalVariables.cs
new file mode 100644
index 000000000..cfc2e6000
--- /dev/null
+++ b/samples/csharp/web/avatar/Services/GlobalVariables.cs
@@ -0,0 +1,18 @@
+//
+// Copyright (c) Microsoft. All rights reserved.
+// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
+//
+
+using Avatar.Models;
+
+namespace Avatar.Services
+{
+ public static class GlobalVariables
+ {
+ public static Dictionary ClientContexts { get; } = new();
+
+ public static string? SpeechToken { get; set; }
+
+ public static string? IceToken { get; set; }
+ }
+}
diff --git a/samples/csharp/web/avatar/Services/IClientService.cs b/samples/csharp/web/avatar/Services/IClientService.cs
new file mode 100644
index 000000000..bcf4c7907
--- /dev/null
+++ b/samples/csharp/web/avatar/Services/IClientService.cs
@@ -0,0 +1,16 @@
+//
+// Copyright (c) Microsoft. All rights reserved.
+// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
+//
+
+using Avatar.Models;
+
+namespace Avatar.Services
+{
+ public interface IClientService
+ {
+ Guid InitializeClient();
+
+ ClientContext GetClientContext(Guid clientId);
+ }
+}
diff --git a/samples/csharp/web/avatar/Services/IceTokenService.cs b/samples/csharp/web/avatar/Services/IceTokenService.cs
new file mode 100644
index 000000000..7e7c3441c
--- /dev/null
+++ b/samples/csharp/web/avatar/Services/IceTokenService.cs
@@ -0,0 +1,38 @@
+//
+// Copyright (c) Microsoft. All rights reserved.
+// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
+//
+
+using Avatar.Models;
+using Microsoft.Extensions.Options;
+
+namespace Avatar.Services
+{
+ public class IceTokenService
+ {
+ private readonly HttpClient _httpClient;
+
+ private readonly ClientSettings _clientSettings;
+
+ public IceTokenService(HttpClient httpClient, IOptions clientSettings)
+ {
+ _httpClient = httpClient;
+ _clientSettings = clientSettings.Value;
+ }
+
+ public async Task RefreshIceTokenAsync()
+ {
+ var url = !string.IsNullOrEmpty(_clientSettings.SpeechPrivateEndpoint)
+ ? $"{_clientSettings.SpeechPrivateEndpoint}/tts/cognitiveservices/avatar/relay/token/v1"
+ : $"https://{_clientSettings.SpeechRegion}.tts.speech.microsoft.com/cognitiveservices/avatar/relay/token/v1";
+
+ var request = new HttpRequestMessage(HttpMethod.Get, url);
+ request.Headers.Add("Ocp-Apim-Subscription-Key", _clientSettings.SpeechKey);
+
+ var response = await _httpClient.SendAsync(request);
+ response.EnsureSuccessStatusCode();
+
+ GlobalVariables.IceToken = await response.Content.ReadAsStringAsync();
+ }
+ }
+}
diff --git a/samples/csharp/web/avatar/Services/SpeechTokenService.cs b/samples/csharp/web/avatar/Services/SpeechTokenService.cs
new file mode 100644
index 000000000..7517f01da
--- /dev/null
+++ b/samples/csharp/web/avatar/Services/SpeechTokenService.cs
@@ -0,0 +1,58 @@
+//
+// Copyright (c) Microsoft. All rights reserved.
+// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
+//
+
+using Avatar.Models;
+using Azure.Core;
+using Azure.Identity;
+using Microsoft.Extensions.Options;
+
+namespace Avatar.Services
+{
+ public class SpeechTokenService
+ {
+ private readonly HttpClient _httpClient;
+
+ private readonly ClientSettings _clientSettings;
+
+ public SpeechTokenService(HttpClient httpClient, IOptions clientSettings)
+ {
+ _httpClient = httpClient;
+ _clientSettings = clientSettings.Value;
+ }
+
+ public async Task RefreshSpeechTokenAsync(CancellationToken stoppingToken)
+ {
+ while (!stoppingToken.IsCancellationRequested)
+ {
+ if (!string.IsNullOrEmpty(_clientSettings.SpeechPrivateEndpoint))
+ {
+ var credential = new DefaultAzureCredential(new DefaultAzureCredentialOptions
+ {
+ ManagedIdentityClientId = _clientSettings.UserAssignedManagedIdentityClientId
+ });
+
+ var token = await credential.GetTokenAsync(new TokenRequestContext(new[] { "https://cognitiveservices.azure.com/.default" }));
+ GlobalVariables.SpeechToken = $"aad#{_clientSettings.SpeechResourceUrl}#{token.Token}";
+ Console.WriteLine("Token refreshed using managed identity.");
+ }
+ else
+ {
+ var url = $"https://{_clientSettings.SpeechRegion}.api.cognitive.microsoft.com/sts/v1.0/issueToken";
+ var request = new HttpRequestMessage(HttpMethod.Post, url);
+ request.Headers.Add("Ocp-Apim-Subscription-Key", _clientSettings.SpeechKey);
+
+ var response = await _httpClient.SendAsync(request);
+ response.EnsureSuccessStatusCode();
+
+ GlobalVariables.SpeechToken = await response.Content.ReadAsStringAsync();
+ Console.WriteLine("Token refreshed using API key.");
+ }
+
+ Console.WriteLine($"Token generated: {GlobalVariables.SpeechToken}");
+ await Task.Delay(TimeSpan.FromMinutes(9), stoppingToken); // Refresh every 9 minutes
+ }
+ }
+ }
+}
diff --git a/samples/csharp/web/avatar/Services/TokenRefreshBackgroundService.cs b/samples/csharp/web/avatar/Services/TokenRefreshBackgroundService.cs
new file mode 100644
index 000000000..8c667cd17
--- /dev/null
+++ b/samples/csharp/web/avatar/Services/TokenRefreshBackgroundService.cs
@@ -0,0 +1,30 @@
+//
+// Copyright (c) Microsoft. All rights reserved.
+// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
+//
+
+namespace Avatar.Services
+{
+ public class TokenRefreshBackgroundService : BackgroundService
+ {
+ private readonly IceTokenService _iceTokenService;
+
+ private readonly SpeechTokenService _speechTokenService;
+
+ public TokenRefreshBackgroundService(IceTokenService iceTokenService, SpeechTokenService speechTokenService)
+ {
+ _iceTokenService = iceTokenService;
+ _speechTokenService = speechTokenService;
+ }
+
+ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
+ {
+ // Refresh ICE token once at startup
+ await _iceTokenService.RefreshIceTokenAsync();
+
+ // Start the background task for refreshing the speech token
+ var refreshSpeechTokenTask = _speechTokenService.RefreshSpeechTokenAsync(stoppingToken);
+ await Task.WhenAny(refreshSpeechTokenTask, Task.Delay(-1, stoppingToken));
+ }
+ }
+}
diff --git a/samples/csharp/web/avatar/Views/Home/basic.cshtml b/samples/csharp/web/avatar/Views/Home/basic.cshtml
new file mode 100644
index 000000000..4d8bf3369
--- /dev/null
+++ b/samples/csharp/web/avatar/Views/Home/basic.cshtml
@@ -0,0 +1,72 @@
+@model Guid
+
+
+
+
+
+ Talking Avatar Service Demo
+
+
+
+
+
+
+ Talking Avatar Service Demo
+
+
+
+
+ Avatar Control Panel
+
+
+
+
+
+
+
+
+ Avatar Video
+
+
+
+ Logs
+
+
+
diff --git a/samples/csharp/web/avatar/Views/Home/chat.cshtml b/samples/csharp/web/avatar/Views/Home/chat.cshtml
new file mode 100644
index 000000000..742f79302
--- /dev/null
+++ b/samples/csharp/web/avatar/Views/Home/chat.cshtml
@@ -0,0 +1,94 @@
+@model Guid
+
+
+
+
+
+ Talking Avatar Chat Demo
+
+
+
+
+
+
+Talking Avatar Chat Demo
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Type Message
+
+
+
+
diff --git a/samples/csharp/web/avatar/Views/Shared/Error.cshtml b/samples/csharp/web/avatar/Views/Shared/Error.cshtml
new file mode 100644
index 000000000..4288d569d
--- /dev/null
+++ b/samples/csharp/web/avatar/Views/Shared/Error.cshtml
@@ -0,0 +1,13 @@
+
+
+
+
+ Error
+
+
+
+ Error.
+ An error occurred while processing your request.
+
+
+
diff --git a/samples/csharp/web/avatar/Views/Web.config b/samples/csharp/web/avatar/Views/Web.config
new file mode 100644
index 000000000..5d50c1fd8
--- /dev/null
+++ b/samples/csharp/web/avatar/Views/Web.config
@@ -0,0 +1,43 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/csharp/web/avatar/appsettings.Development.json b/samples/csharp/web/avatar/appsettings.Development.json
new file mode 100644
index 000000000..0c208ae91
--- /dev/null
+++ b/samples/csharp/web/avatar/appsettings.Development.json
@@ -0,0 +1,8 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ }
+}
diff --git a/samples/csharp/web/avatar/appsettings.json b/samples/csharp/web/avatar/appsettings.json
new file mode 100644
index 000000000..4adb1c5da
--- /dev/null
+++ b/samples/csharp/web/avatar/appsettings.json
@@ -0,0 +1,26 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ },
+ "AllowedHosts": "*",
+ "ClientSettings": {
+ "SpeechRegion": "",
+ "SpeechKey": "",
+ "SpeechPrivateEndpoint": "",
+ "SpeechResourceUrl": "",
+ "UserAssignedManagedIdentityClientId": "",
+ "AzureOpenAIEndpoint": "",
+ "AzureOpenAIAPIKey": "",
+ "AzureOpenAIDeploymentName": "",
+ "CognitiveSearchEndpoint": "",
+ "CognitiveSearchAPIKey": "",
+ "CognitiveSearchIndexName": "",
+ "IceServerUrl": "",
+ "IceServerUrlRemote": "",
+ "IceServerUsername": "",
+ "IceServerPassword": ""
+ }
+}
diff --git a/samples/csharp/web/avatar/wwwroot/css/styles.css b/samples/csharp/web/avatar/wwwroot/css/styles.css
new file mode 100644
index 000000000..e030ae4d9
--- /dev/null
+++ b/samples/csharp/web/avatar/wwwroot/css/styles.css
@@ -0,0 +1,350 @@
+/*
+ * Copyright (c) 2015 The WebRTC project authors. All Rights Reserved.
+ *
+ * Use of this source code is governed by a BSD-style license
+ * that can be found in the LICENSE file in the root of the source
+ * tree.
+ */
+.hidden {
+ display: none;
+}
+
+.highlight {
+ background-color: #eee;
+ font-size: 1.2em;
+ margin: 0 0 30px 0;
+ padding: 0.2em 1.5em;
+}
+
+.warning {
+ color: red;
+ font-weight: 400;
+}
+
+@media screen and (min-width: 1000px) {
+ /* hack! to detect non-touch devices */
+ div#links a {
+ line-height: 0.8em;
+ }
+}
+
+audio {
+ max-width: 100%;
+}
+
+body {
+ font-family: 'Roboto', sans-serif;
+ font-weight: 300;
+ margin: 0;
+ padding: 1em;
+ word-break: break-word;
+}
+
+button {
+ background-color: #d84a38;
+ border: none;
+ border-radius: 2px;
+ color: white;
+ font-family: 'Roboto', sans-serif;
+ font-size: 0.8em;
+ margin: 10px 0 1em 0;
+ padding: 0.5em 0.7em 0.6em 0.7em;
+}
+
+button:active {
+ background-color: #cf402f;
+}
+
+button:hover {
+ background-color: #cf402f;
+}
+
+button[disabled] {
+ color: #ccc;
+}
+
+button[disabled]:hover {
+ background-color: #d84a38;
+}
+
+canvas {
+ background-color: #ccc;
+ max-width: 100%;
+ width: 100%;
+}
+
+code {
+ font-family: 'Roboto', sans-serif;
+ font-weight: 400;
+}
+
+div#container {
+ margin: 0 auto 0 auto;
+ max-width: 60em;
+ padding: 1em 1.5em 1.3em 1.5em;
+}
+
+div#links {
+ padding: 0.5em 0 0 0;
+}
+
+h1 {
+ border-bottom: 1px solid #ccc;
+ font-family: 'Roboto', sans-serif;
+ font-weight: 500;
+ margin: 0 0 0.8em 0;
+ padding: 0 0 0.2em 0;
+}
+
+h2 {
+ color: #444;
+ font-weight: 500;
+}
+
+h3 {
+ border-top: 1px solid #eee;
+ color: #666;
+ font-weight: 500;
+ margin: 10px 0 10px 0;
+ white-space: nowrap;
+}
+
+li {
+ margin: 0 0 0.4em 0;
+}
+
+html {
+ /* avoid annoying page width change
+ when moving from the home page */
+ overflow-y: scroll;
+}
+
+img {
+ border: none;
+ max-width: 100%;
+}
+
+input[type=radio] {
+ position: relative;
+ top: -1px;
+}
+
+p {
+ color: #444;
+ font-weight: 300;
+}
+
+p#data {
+ border-top: 1px dotted #666;
+ font-family: Courier New, monospace;
+ line-height: 1.3em;
+ max-height: 1000px;
+ overflow-y: auto;
+ padding: 1em 0 0 0;
+}
+
+p.borderBelow {
+ border-bottom: 1px solid #aaa;
+ padding: 0 0 20px 0;
+}
+
+section p:last-of-type {
+ margin: 0;
+}
+
+section {
+ border-bottom: 1px solid #eee;
+ margin: 0 0 30px 0;
+ padding: 0 0 20px 0;
+}
+
+section:last-of-type {
+ border-bottom: none;
+ padding: 0 0 1em 0;
+}
+
+select {
+ margin: 0 1em 1em 0;
+ position: relative;
+ top: -1px;
+}
+
+h1 span {
+ white-space: nowrap;
+}
+
+a {
+ color: #1D6EEE;
+ font-weight: 300;
+ text-decoration: none;
+}
+
+h1 a {
+ font-weight: 300;
+ margin: 0 10px 0 0;
+ white-space: nowrap;
+}
+
+a:hover {
+ color: #3d85c6;
+ text-decoration: underline;
+}
+
+a#viewSource {
+ display: block;
+ margin: 1.3em 0 0 0;
+ border-top: 1px solid #999;
+ padding: 1em 0 0 0;
+}
+
+div#errorMsg p {
+ color: #F00;
+}
+
+div#links a {
+ display: block;
+ line-height: 1.3em;
+ margin: 0 0 1.5em 0;
+}
+
+div.outputSelector {
+ margin: -1.3em 0 2em 0;
+}
+
+p.description {
+ margin: 0 0 0.5em 0;
+}
+
+strong {
+ font-weight: 500;
+}
+
+textarea {
+ font-family: 'Roboto', sans-serif;
+}
+
+video {
+ background: #222;
+ margin: 0 0 20px 0;
+ --width: 100%;
+ width: var(--width);
+ height: calc(var(--width) * 0.75);
+}
+
+ul {
+ margin: 0 0 0.5em 0;
+}
+
+@media screen and (max-width: 650px) {
+ .highlight {
+ font-size: 1em;
+ margin: 0 0 20px 0;
+ padding: 0.2em 1em;
+ }
+
+ h1 {
+ font-size: 24px;
+ }
+}
+
+@media screen and (max-width: 550px) {
+ button:active {
+ background-color: darkRed;
+ }
+
+ h1 {
+ font-size: 22px;
+ }
+}
+
+@media screen and (max-width: 450px) {
+ h1 {
+ font-size: 20px;
+ }
+}
+
+textarea {
+ width: 800px;
+ min-height: 60px;
+ margin-bottom: 10px;
+}
+
+#webrtc textarea {
+ width: 800px;
+ min-height: 100px;
+ resize: none;
+}
+
+#knownIssues {
+ color: red;
+}
+
+/* The switch - the box around the slider */
+.switch {
+ position: relative;
+ display: inline-block;
+ width: 48px;
+ height: 26px;
+}
+
+/* Hide default HTML checkbox */
+.switch input {
+ opacity: 0;
+ width: 0;
+ height: 0;
+}
+
+/* The slider */
+.slider {
+ position: absolute;
+ cursor: pointer;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background-color: #ccc;
+ -webkit-transition: .4s;
+ transition: .4s;
+}
+
+.slider:before {
+ position: absolute;
+ content: "";
+ height: 20px;
+ width: 20px;
+ left: 4px;
+ bottom: 3px;
+ background-color: white;
+ -webkit-transition: .4s;
+ transition: .4s;
+}
+
+input:checked + .slider {
+ background-color: #d84a38;
+}
+
+input:focus + .slider {
+ box-shadow: 0 0 1px #d84a38;
+}
+
+input:checked + .slider:before {
+ -webkit-transform: translateX(20px);
+ -ms-transform: translateX(20px);
+ transform: translateX(20px);
+}
+
+/* Rounded sliders */
+.slider.round {
+ border-radius: 34px;
+}
+
+.slider.round:before {
+ border-radius: 50%;
+}
+
+.switchLabel {
+ display: inline-block;
+ vertical-align: middle;
+ position: relative;
+ margin-top: 5px;
+}
\ No newline at end of file
diff --git a/samples/csharp/web/avatar/wwwroot/image/background.png b/samples/csharp/web/avatar/wwwroot/image/background.png
new file mode 100644
index 0000000000000000000000000000000000000000..31296f566c59894686bfa87e35b4c6db4693ab93
GIT binary patch
literal 1724
zcma)-X*3&%8ipfP6-#9ZqZz~!W{BErjS}TX(h)?aO_b55$_&L2rV*j0RXHL)x&Eik3HxIUlIiX&@uf-
z3K%^6F91L(5aW#^B!9bEBe?j*6>u&kj(!Qt`3@5ZGA;8x`EWWQ;N68!i7=J3Wjc6#
zPI-eS9{fasxj>`tN2mX|YA_ew$-BuAP09?4yS_}3+uBD)@VUbs9Ua)L@I(D0b+}>P
z3JN@tZCHK^WvM|W8Yr@m%H$L{g>gsC2oJ~ZaWw@)0OTK~p
zMN~Jh1cugdvZs&-(~so)+l#u=RQ+E0p=@{RXOEQIdmHmRI|-i~D=Vw>
z86QL3rf)h9h^)A6mmfc&XxW!7WgN*(-v}DfcI>^qp2F?L;fde!?}OLZUp{W%-Q5+N
z6_slGuE6duwaYL(kim
zgJ;-T$`hf~rjh#d>4&Xohs_MTJYf0hzv-hYTZ_Y=#N%3=3Jc3ykV+npcW`i^djX-;
zm`N_37!MF+pBQO9>hA6?DpFUoz@Gt`<8&Dd4;!s);W`zlXu)FwE
zQ;WkHC0VW-2FRy*C-s-mV{o@?jDo-#nBT-JSE;@Ah
z9VZ`+G=gW=9>`(x7XqvhSIlXsgl~;`3n(x|UD<}@{w%v`ImCres>S7&>zX(gbRLOM
z61);NNbBqCJ5|Io7L4@$Pxh?fM>?{B`I~F;(b0(V7d&k2==39oqRdzJ%Kc-z=%St3
zo+*qh(CvK{qc&RuJAd2R&F#(c(v18X8yT&KMV)6ebYe$)l%^feVO#oGw()CXPs^fH
zsZZ04#y|_h>ra3-4fEL6QOe^H2Z2O_g&|dk9rsG+4xX-oWH%r4N_{pm%;#NYgxcv5KSH?#&=^HMO8=o{_5;?px4Fbhyh?Ypn4JXl*>
z`|cVx+#wPK%5fsKA)9BAMncGh(yA9FejVTtJ5ar@!QXghtMpf!yde)*WVR%asfn5eETQ``
zNk-j5xPRwcz2{=KvDQ2W?LHorq`qTfj4M+8HsZj}ZdA;6MIsB^@e+x!rF}~rbnb;j
zV!qB!bq{OSqA42kU`j*H0w6d)YX3teikl8K&c!)QQboeah8fc!URdG#)N*kh=lURJ
z6_>H?G0jOUqQu0UoxqO3k@R~GIYhYm+KyBz{dWX&q%hPH!JAm=v{1^fjq0v?2u-t*
z&~I>hCqWy}OTKD^H3?WFGI1on@oqD5?!*_;)X)lvV_bZfCa%Jym+HX5r*_smFZ9K|
zln6l@RH13NFyILL=u($B1}oWw$sqU21|Qya!)sl1e=ZH%{4CS4U|u9LEiKySCckbn
zE4PSqDW$6QEo8ui496WQqqmM~U27_KnO;kOTfSi<;0S_nP
zYQ>cYG}nckh*jNKi6`?v>(ZvOG^6c82u=PT2o%7t*D3dZ@GuwHH$rU3jEHK?rSbZZaBqwSqITqgL0IHQg^lU+t%d!QR($RzVb7vHa5x-roEUTw(0{vG=)065*F~q^rJsFSFk4z|k2S|7SXS
zXNOHb52Mx4UiisHuEeOzH|h?=^MNX~iqm^xCIn+}+D`W&E;!b2rh$O1uS&R-g_u%+
zYfn^#Gc98HPz(nM{8cDnr)${-CFg1DK3F>OumAUdE0ax|3G!3pE5*!K=NZOv;{Y%|
LIPVUxtJMDjb8b2@
literal 0
HcmV?d00001
diff --git a/samples/csharp/web/avatar/wwwroot/image/favicon.ico b/samples/csharp/web/avatar/wwwroot/image/favicon.ico
new file mode 100644
index 0000000000000000000000000000000000000000..63e859b476eff5055e0e557aaa151ca8223fbeef
GIT binary patch
literal 5430
zcmc&&Yj2xp8Fqnv;>&(QB_ve7>^E#o2mu=cO~A%R>DU-_hfbSRv1t;m7zJ_AMrntN
zy0+^f&8be>q&YYzH%(88lQ?#KwiCzaCO*ZEo%j&v;<}&Lj_stKTKK>#U3nin@AF>w
zb3ONSAFR{u(S1d?cdw53y}Gt1b-Hirbh;;bm(Rcbnoc*%@jiaXM|4jU^1WO~`TYZ~
zC-~jh9~b-f?fX`DmwvcguQzn*uV}c^Vd&~?H|RUs4Epv~gTAfR(B0lT&?RWQOtduM
z^1vUD9{HQsW!{a9|0crA34m7Z6lpG^}f6f?={zD+
zXAzk^i^aKN_}s2$eX81wjSMONE#WVdzf|MT)Ap*}Vsn!XbvsI#6o&ij{87^d%$|A{
z=F{KB%)g%@z76yBzbb7seW**Ju8r4e*Z3PWNX3_tTDgzZatz7)Q6ytwB%@&@A|XT;
zecM`Snxx5po$C)%yCP!KEtos~eOS)@2=kX-RIm)4glMCoagTEFxrBeSX%Euz734Fk
z%7)x(k~T!@Hbg_37NSQL!vlTBXoURSzt~I**Zw`&F24fH*&kx=%nvZv|49SC*daD(
zIw<~%#=lk8{2-l(BcIjy^Q$Q&m#KlWL9?UG{b8@qhlD
z;umc+6p%|NsAT~0@DgV4-NKgQuWPWrmPIK&&XhV&n%`{l
zOl^bbWYjQNuVXTXESO)@|iUKVmErPUDfz2Wh`4dF@OFiaCW|d`3paV^@|r^8T_ZxM)Z+$p5qx#
z#K=z@%;aBPO=C4JNNGqVv6@UGolIz;KZsAro``Rz8X%vq_gpi^qEV&evgHb_=Y9-l
z`)imdx0UC>GWZYj)3+3aKh?zVb}=@%oNzg7a8%kfVl)SV-Amp1Okw&+hEZ3|v(k8vRjXW9?ih`&FFM
zV$~{j3IzhtcXk?Mu_!12;=+I7XK-IR2>Yd%VB^?oI9c^E&Chb&&je$NV0P-R;ujkP
z;cbLCCPEF6|22NDj=S`F^2e~XwT1ZnRX8ra0#DaFa9-X|8(xNW_+JhD75WnSd7cxo
z2>I_J5{c|WPfrgl7E2R)^c}F7ry()Z>$Jhk9CzZxiPKL#_0%`&{MX>P_%b~Dx0D^S
z7xP1(DQ!d_Icpk!RN3I1w@~|O1ru#CO==h#9M~S4Chx*@?=EKUPGBv$tmU+7Zs_al
z`!jR?6T&Z7(%uVq>#yLu`abWk!FBlnY{RFNHlj~6zh*;@u}+}viRKsD`IIxN#R-X3
z@vxu#EA_m}I503U(8Qmx^}u;)KfGP`O9E1H1Q|xeeksX8jC%@!{YT1)!lWgO=+Y3*jr=iSxvOW1}^HSy=y){tOMQJ@an>sOl4FYniE
z;GOxd7AqxZNbYFNqobpv&HVO$c-w!Y*6r;$2oJ~h(a#(Bp<-)dg*mNigX~9rPqcHv
z^;c*|Md?tD)$y?6FO$DWl$jUGV`F1G_^E&E>sY*YnA~ruv3=z9F8&&~Xpm<<75?N3
z>x~`I&M9q)O1=zWZHN9hZWx>RQ}zLP+iL57Q)%&_^$Sme^^G7;e-P~CR?kqU#Io#(
z(nH1Wn*Ig)|M>WLGrxoU?FZrS`4GO&w;+39A3f8w{{Q7eg|$+dIlNFPAe+tN=FOYU
z{A&Fg|H73+w1IK(W=j*L>JQgz$g0
z7JpKXLHIh}#$wm|N`s}o-@|L_`>*(gTQ~)wr3Eap7g%PVNisKw82im;Gdv#85x#s+
zoqqtnwu4ycd>cOQgRh-=aEJbnvVK`}ja%+FZx}&ehtX)n(9nVfe4{mn0bgijUbNr7Tf5X^$*{qh2%`?--%+sbSrjE^;1e3>%
zqa%jdY16{Y)a1hSy*mr0JGU05Z%=qlx5vGvTjSpTt6k%nR06q}1DU`SQh_ZAeJ}A@`hL~xvv05U?0%=spP`R>dk?cOWM9^KNb7B?xjex>OZo%JMQQ1Q
zB|q@}8RiP@DWn-(fB;phPaIOP2Yp)XN3-Fsn)S3w($4&+p8f5W_f%gac}QvmkHfCj$2=!t`boCvQ
zCW;&Dto=f8v##}dy^wg3VNaBy&kCe3N;1|@n@pUaMPT?(aJ9b*(gJ28$}(2qFt$H~u5z94xcIQkcOI++)*exzbrk?WOOOf*|%k5#KV
zL=&ky3)Eirv$wbRJ2F2s_ILQY--D~~7>^f}W|Aw^e7inXr#WLI{@h`0|jHud2Y~cI~Yn{r_kU^Vo{1gja {
+ document.getElementById('logging').innerHTML += msg + '
'
+}
+
+// Setup WebRTC
+function setupWebRTC(iceServerUrl, iceServerUsername, iceServerCredential) {
+ // Create WebRTC peer connection
+ peerConnection = new RTCPeerConnection({
+ iceServers: [{
+ urls: [ iceServerUrl ],
+ username: iceServerUsername,
+ credential: iceServerCredential
+ }],
+ iceTransportPolicy: 'relay'
+ })
+
+ // Fetch WebRTC video stream and mount it to an HTML video element
+ peerConnection.ontrack = function (event) {
+ // Clean up existing video element if there is any
+ remoteVideoDiv = document.getElementById('remoteVideo')
+ for (var i = 0; i < remoteVideoDiv.childNodes.length; i++) {
+ if (remoteVideoDiv.childNodes[i].localName === event.track.kind) {
+ remoteVideoDiv.removeChild(remoteVideoDiv.childNodes[i])
+ }
+ }
+
+ const mediaPlayer = document.createElement(event.track.kind)
+ mediaPlayer.id = event.track.kind
+ mediaPlayer.srcObject = event.streams[0]
+ mediaPlayer.autoplay = true
+ document.getElementById('remoteVideo').appendChild(mediaPlayer)
+ document.getElementById('videoLabel').hidden = true
+ document.getElementById('overlayArea').hidden = false
+
+ if (event.track.kind === 'video') {
+ mediaPlayer.playsInline = true
+ remoteVideoDiv = document.getElementById('remoteVideo')
+ canvas = document.getElementById('canvas')
+ if (document.getElementById('transparentBackground').checked) {
+ remoteVideoDiv.style.width = '0.1px'
+ canvas.getContext('2d').clearRect(0, 0, canvas.width, canvas.height)
+ canvas.hidden = false
+ } else {
+ canvas.hidden = true
+ }
+
+ mediaPlayer.addEventListener('play', () => {
+ if (document.getElementById('transparentBackground').checked) {
+ window.requestAnimationFrame(makeBackgroundTransparent)
+ } else {
+ remoteVideoDiv.style.width = mediaPlayer.videoWidth / 2 + 'px'
+ }
+ })
+ }
+ else
+ {
+ // Mute the audio player to make sure it can auto play, will unmute it when speaking
+ // Refer to https://developer.mozilla.org/en-US/docs/Web/Media/Autoplay_guide
+ mediaPlayer.muted = true
+ }
+ }
+
+ // Listen to data channel, to get the event from the server
+ peerConnection.addEventListener("datachannel", event => {
+ const dataChannel = event.channel
+ dataChannel.onmessage = e => {
+ console.log("[" + (new Date()).toISOString() + "] WebRTC event received: " + e.data)
+
+ if (e.data.includes("EVENT_TYPE_SWITCH_TO_IDLE")) {
+ document.getElementById('speak').disabled = false
+ document.getElementById('stopSpeaking').disabled = true
+ }
+ }
+ })
+
+ // This is a workaround to make sure the data channel listening is working by creating a data channel from the client side
+ c = peerConnection.createDataChannel("eventChannel")
+
+ // Make necessary update to the web page when the connection state changes
+ peerConnection.oniceconnectionstatechange = e => {
+ log("WebRTC status: " + peerConnection.iceConnectionState)
+
+ if (peerConnection.iceConnectionState === 'connected') {
+ document.getElementById('stopSession').disabled = false
+ document.getElementById('speak').disabled = false
+ document.getElementById('configuration').hidden = true
+ }
+
+ if (peerConnection.iceConnectionState === 'disconnected' || peerConnection.iceConnectionState === 'failed') {
+ document.getElementById('speak').disabled = true
+ document.getElementById('stopSpeaking').disabled = true
+ document.getElementById('stopSession').disabled = true
+ document.getElementById('startSession').disabled = false
+ document.getElementById('configuration').hidden = false
+ }
+ }
+
+ // Offer to receive 1 audio, and 1 video track
+ peerConnection.addTransceiver('video', { direction: 'sendrecv' })
+ peerConnection.addTransceiver('audio', { direction: 'sendrecv' })
+
+ // Connect to avatar service when ICE candidates gathering is done
+ iceGatheringDone = false
+
+ peerConnection.onicecandidate = e => {
+ if (!e.candidate && !iceGatheringDone) {
+ iceGatheringDone = true
+ connectToAvatarService(peerConnection)
+ }
+ }
+
+ peerConnection.createOffer().then(sdp => {
+ peerConnection.setLocalDescription(sdp).then(() => { setTimeout(() => {
+ if (!iceGatheringDone) {
+ iceGatheringDone = true
+ connectToAvatarService(peerConnection)
+ }
+ }, 2000) })
+ })
+}
+
+// Connect to TTS Avatar Service
+function connectToAvatarService(peerConnection) {
+ let localSdp = btoa(JSON.stringify(peerConnection.localDescription))
+ let headers = {
+ 'ClientId': clientId,
+ 'AvatarCharacter': document.getElementById('talkingAvatarCharacter').value,
+ 'AvatarStyle': document.getElementById('talkingAvatarStyle').value,
+ 'BackgroundColor': document.getElementById('backgroundColor').value,
+ 'BackgroundImageUrl': document.getElementById('backgroundImageUrl').value,
+ 'IsCustomAvatar': document.getElementById('customizedAvatar').checked,
+ 'TransparentBackground': document.getElementById('transparentBackground').checked,
+ 'VideoCrop': document.getElementById('videoCrop').checked
+ }
+
+ if (document.getElementById('customVoiceEndpointId').value !== '') {
+ headers['CustomVoiceEndpointId'] = document.getElementById('customVoiceEndpointId').value
+ }
+
+ fetch('/api/connectAvatar', {
+ method: 'POST',
+ headers: headers,
+ body: localSdp
+ })
+ .then(response => {
+ if (response.ok) {
+ response.text().then(text => {
+ const remoteSdp = text
+ peerConnection.setRemoteDescription(new RTCSessionDescription(JSON.parse(atob(remoteSdp))))
+ })
+ } else {
+ document.getElementById('startSession').disabled = false;
+ document.getElementById('configuration').hidden = false;
+ throw new Error(`Failed connecting to the Avatar service: ${response.status} ${response.statusText}`)
+ }
+ })
+}
+
+// Make video background transparent by matting
+function makeBackgroundTransparent(timestamp) {
+ // Throttle the frame rate to 30 FPS to reduce CPU usage
+ if (timestamp - previousAnimationFrameTimestamp > 30) {
+ video = document.getElementById('video')
+ tmpCanvas = document.getElementById('tmpCanvas')
+ tmpCanvasContext = tmpCanvas.getContext('2d', { willReadFrequently: true })
+ tmpCanvasContext.drawImage(video, 0, 0, video.videoWidth, video.videoHeight)
+ if (video.videoWidth > 0) {
+ let frame = tmpCanvasContext.getImageData(0, 0, video.videoWidth, video.videoHeight)
+ for (let i = 0; i < frame.data.length / 4; i++) {
+ let r = frame.data[i * 4 + 0]
+ let g = frame.data[i * 4 + 1]
+ let b = frame.data[i * 4 + 2]
+ if (g - 150 > r + b) {
+ // Set alpha to 0 for pixels that are close to green
+ frame.data[i * 4 + 3] = 0
+ } else if (g + g > r + b) {
+ // Reduce green part of the green pixels to avoid green edge issue
+ adjustment = (g - (r + b) / 2) / 3
+ r += adjustment
+ g -= adjustment * 2
+ b += adjustment
+ frame.data[i * 4 + 0] = r
+ frame.data[i * 4 + 1] = g
+ frame.data[i * 4 + 2] = b
+ // Reduce alpha part for green pixels to make the edge smoother
+ a = Math.max(0, 255 - adjustment * 4)
+ frame.data[i * 4 + 3] = a
+ }
+ }
+
+ canvas = document.getElementById('canvas')
+ canvasContext = canvas.getContext('2d')
+ canvasContext.putImageData(frame, 0, 0);
+ }
+
+ previousAnimationFrameTimestamp = timestamp
+ }
+
+ window.requestAnimationFrame(makeBackgroundTransparent)
+}
+
+// Do HTML encoding on given text
+function htmlEncode(text) {
+ const entityMap = {
+ '&': '&',
+ '<': '<',
+ '>': '>',
+ '"': '"',
+ "'": ''',
+ '/': '/'
+ };
+
+ return String(text).replace(/[&<>"'\/]/g, (match) => entityMap[match])
+}
+
+window.onload = () => {
+ clientId = document.getElementById('clientId').value
+}
+
+window.startSession = () => {
+ document.getElementById('startSession').disabled = true
+
+ fetch('/api/getIceToken', {
+ method: 'GET',
+ })
+ .then(response => {
+ if (response.ok) {
+ response.json().then(data => {
+ const iceServerUrl = data.Urls[0]
+ const iceServerUsername = data.Username
+ const iceServerCredential = data.Password
+ setupWebRTC(iceServerUrl, iceServerUsername, iceServerCredential)
+ })
+ } else {
+ throw new Error(`Failed fetching ICE token: ${response.status} ${response.statusText}`)
+ }
+ })
+}
+
+window.speak = () => {
+ document.getElementById('speak').disabled = true;
+ document.getElementById('stopSpeaking').disabled = false
+ document.getElementById('audio').muted = false
+ let spokenText = document.getElementById('spokenText').value
+ let ttsVoice = document.getElementById('ttsVoice').value
+ let personalVoiceSpeakerProfileID = document.getElementById('personalVoiceSpeakerProfileID').value
+ let spokenSsml = `${htmlEncode(spokenText)}`
+ console.log("[" + (new Date()).toISOString() + "] Speak request sent.")
+
+ fetch('/api/speak', {
+ method: 'POST',
+ headers: {
+ 'ClientId': clientId,
+ 'Content-Type': 'application/ssml+xml'
+ },
+ body: spokenSsml
+ })
+ .then(response => {
+ if (response.ok) {
+ response.text().then(text => {
+ console.log(`[${new Date().toISOString()}] Speech synthesized to speaker for text [ ${spokenText} ]. Result ID: ${text}`)
+ })
+ } else {
+ throw new Error(`[${new Date().toISOString()}] Unable to speak text. ${response.status} ${response.statusText}`)
+ }
+ })
+}
+
+window.stopSpeaking = () => {
+ document.getElementById('stopSpeaking').disabled = true
+
+ fetch('/api/stopSpeaking', {
+ method: 'POST',
+ headers: {
+ 'ClientId': clientId
+ },
+ body: ''
+ })
+ .then(response => {
+ if (response.ok) {
+ console.log(`[${new Date().toISOString()}] Speaking stopped.`)
+ } else {
+ throw new Error(`[${new Date().toISOString()}] Unable to stop speaking. ${response.status} ${response.statusText}`)
+ }
+ })
+}
+
+window.stopSession = () => {
+ document.getElementById('speak').disabled = true
+ document.getElementById('stopSpeaking').disabled = true
+ document.getElementById('stopSession').disabled = true
+
+ fetch('/api/disconnectAvatar', {
+ method: 'POST',
+ headers: {
+ 'ClientId': clientId
+ },
+ body: ''
+ })
+}
+
+window.updataTransparentBackground = () => {
+ if (document.getElementById('transparentBackground').checked) {
+ document.body.background = './image/background.png'
+ document.getElementById('backgroundColor').value = '#00FF00FF'
+ document.getElementById('backgroundColor').disabled = true
+ document.getElementById('backgroundImageUrl').value = ''
+ document.getElementById('backgroundImageUrl').disabled = true
+ } else {
+ document.body.background = ''
+ document.getElementById('backgroundColor').value = '#FFFFFFFF'
+ document.getElementById('backgroundColor').disabled = false
+ document.getElementById('backgroundImageUrl').disabled = false
+ }
+}
diff --git a/samples/csharp/web/avatar/wwwroot/js/chat.js b/samples/csharp/web/avatar/wwwroot/js/chat.js
new file mode 100644
index 000000000..7d35eacf2
--- /dev/null
+++ b/samples/csharp/web/avatar/wwwroot/js/chat.js
@@ -0,0 +1,539 @@
+// Copyright (c) Microsoft. All rights reserved.
+// Licensed under the MIT license.
+
+// Global objects
+var clientId
+var speechRecognizer
+var peerConnection
+var isSpeaking = false
+var sessionActive = false
+var lastSpeakTime
+var isFirstRecognizingEvent = true
+
+// Connect to avatar service
+function connectAvatar() {
+ document.getElementById('startSession').disabled = true
+
+ fetch('/api/getIceToken', {
+ method: 'GET',
+ })
+ .then(response => {
+ if (response.ok) {
+ response.json().then(data => {
+ const iceServerUrl = data.Urls[0]
+ const iceServerUsername = data.Username
+ const iceServerCredential = data.Password
+ setupWebRTC(iceServerUrl, iceServerUsername, iceServerCredential)
+ })
+ } else {
+ throw new Error(`Failed fetching ICE token: ${response.status} ${response.statusText}`)
+ }
+ })
+
+ document.getElementById('configuration').hidden = true
+}
+
+// Create speech recognizer
+function createSpeechRecognizer() {
+ fetch('/api/getSpeechToken', {
+ method: 'GET',
+ })
+ .then(response => {
+ if (response.ok) {
+ const speechRegion = response.headers.get('SpeechRegion')
+ const speechPrivateEndpoint = response.headers.get('SpeechPrivateEndpoint')
+ response.text().then(text => {
+ const speechToken = text
+ const speechRecognitionConfig = speechPrivateEndpoint ?
+ SpeechSDK.SpeechConfig.fromEndpoint(new URL(`wss://${speechPrivateEndpoint.replace('https://', '')}/stt/speech/universal/v2`), '') :
+ SpeechSDK.SpeechConfig.fromEndpoint(new URL(`wss://${speechRegion}.stt.speech.microsoft.com/speech/universal/v2`), '')
+ speechRecognitionConfig.authorizationToken = speechToken
+ speechRecognitionConfig.setProperty(SpeechSDK.PropertyId.SpeechServiceConnection_LanguageIdMode, "Continuous")
+ speechRecognitionConfig.setProperty("SpeechContext-PhraseDetection.TrailingSilenceTimeout", "3000")
+ speechRecognitionConfig.setProperty("SpeechContext-PhraseDetection.InitialSilenceTimeout", "10000")
+ speechRecognitionConfig.setProperty("SpeechContext-PhraseDetection.Dictation.Segmentation.Mode", "Custom")
+ speechRecognitionConfig.setProperty("SpeechContext-PhraseDetection.Dictation.Segmentation.SegmentationSilenceTimeoutMs", "200")
+ var sttLocales = document.getElementById('sttLocales').value.split(',')
+ var autoDetectSourceLanguageConfig = SpeechSDK.AutoDetectSourceLanguageConfig.fromLanguages(sttLocales)
+ speechRecognizer = SpeechSDK.SpeechRecognizer.FromConfig(speechRecognitionConfig, autoDetectSourceLanguageConfig, SpeechSDK.AudioConfig.fromDefaultMicrophoneInput())
+ })
+ } else {
+ throw new Error(`Failed fetching speech token: ${response.status} ${response.statusText}`)
+ }
+ })
+}
+
+// Disconnect from avatar service
+function disconnectAvatar(closeSpeechRecognizer = false) {
+ fetch('/api/disconnectAvatar', {
+ method: 'POST',
+ headers: {
+ 'ClientId': clientId
+ },
+ body: ''
+ })
+
+ if (speechRecognizer !== undefined) {
+ speechRecognizer.stopContinuousRecognitionAsync()
+ if (closeSpeechRecognizer) {
+ speechRecognizer.close()
+ }
+ }
+
+ sessionActive = false
+}
+
+// Setup WebRTC
+function setupWebRTC(iceServerUrl, iceServerUsername, iceServerCredential) {
+ // Create WebRTC peer connection
+ peerConnection = new RTCPeerConnection({
+ iceServers: [{
+ urls: [ iceServerUrl ],
+ username: iceServerUsername,
+ credential: iceServerCredential
+ }],
+ iceTransportPolicy: 'relay'
+ })
+
+ // Fetch WebRTC video stream and mount it to an HTML video element
+ peerConnection.ontrack = function (event) {
+ if (event.track.kind === 'audio') {
+ let audioElement = document.createElement('audio')
+ audioElement.id = 'audioPlayer'
+ audioElement.srcObject = event.streams[0]
+ audioElement.autoplay = true
+
+ audioElement.onplaying = () => {
+ console.log(`WebRTC ${event.track.kind} channel connected.`)
+ }
+
+ document.getElementById('remoteVideo').appendChild(audioElement)
+ }
+
+ if (event.track.kind === 'video') {
+ let videoElement = document.createElement('video')
+ videoElement.id = 'videoPlayer'
+ videoElement.srcObject = event.streams[0]
+ videoElement.autoplay = true
+ videoElement.playsInline = true
+
+ videoElement.onplaying = () => {
+ // Clean up existing video element if there is any
+ remoteVideoDiv = document.getElementById('remoteVideo')
+ for (var i = 0; i < remoteVideoDiv.childNodes.length; i++) {
+ if (remoteVideoDiv.childNodes[i].localName === event.track.kind) {
+ remoteVideoDiv.removeChild(remoteVideoDiv.childNodes[i])
+ }
+ }
+
+ // Append the new video element
+ document.getElementById('remoteVideo').appendChild(videoElement)
+
+ console.log(`WebRTC ${event.track.kind} channel connected.`)
+ document.getElementById('microphone').disabled = false
+ document.getElementById('stopSession').disabled = false
+ document.getElementById('remoteVideo').style.width = '960px'
+ document.getElementById('chatHistory').hidden = false
+ document.getElementById('showTypeMessage').disabled = false
+
+ if (document.getElementById('useLocalVideoForIdle').checked) {
+ document.getElementById('localVideo').hidden = true
+ if (lastSpeakTime === undefined) {
+ lastSpeakTime = new Date()
+ }
+ }
+
+ setTimeout(() => { sessionActive = true }, 5000) // Set session active after 5 seconds
+ }
+ }
+ }
+
+ // Listen to data channel, to get the event from the server
+ peerConnection.addEventListener("datachannel", event => {
+ const dataChannel = event.channel
+ dataChannel.onmessage = e => {
+ console.log("[" + (new Date()).toISOString() + "] WebRTC event received: " + e.data)
+
+ if (e.data.includes("EVENT_TYPE_SWITCH_TO_SPEAKING")) {
+ isSpeaking = true
+ document.getElementById('stopSpeaking').disabled = false
+ } else if (e.data.includes("EVENT_TYPE_SWITCH_TO_IDLE")) {
+ isSpeaking = false
+ lastSpeakTime = new Date()
+ document.getElementById('stopSpeaking').disabled = true
+ }
+ }
+ })
+
+ // This is a workaround to make sure the data channel listening is working by creating a data channel from the client side
+ c = peerConnection.createDataChannel("eventChannel")
+
+ // Make necessary update to the web page when the connection state changes
+ peerConnection.oniceconnectionstatechange = e => {
+ console.log("WebRTC status: " + peerConnection.iceConnectionState)
+ if (peerConnection.iceConnectionState === 'disconnected') {
+ if (document.getElementById('useLocalVideoForIdle').checked) {
+ document.getElementById('localVideo').hidden = false
+ document.getElementById('remoteVideo').style.width = '0.1px'
+ }
+ }
+ }
+
+ // Offer to receive 1 audio, and 1 video track
+ peerConnection.addTransceiver('video', { direction: 'sendrecv' })
+ peerConnection.addTransceiver('audio', { direction: 'sendrecv' })
+
+ // Connect to avatar service when ICE candidates gathering is done
+ iceGatheringDone = false
+
+ peerConnection.onicecandidate = e => {
+ if (!e.candidate && !iceGatheringDone) {
+ iceGatheringDone = true
+ connectToAvatarService(peerConnection)
+ }
+ }
+
+ peerConnection.createOffer().then(sdp => {
+ peerConnection.setLocalDescription(sdp).then(() => { setTimeout(() => {
+ if (!iceGatheringDone) {
+ iceGatheringDone = true
+ connectToAvatarService(peerConnection)
+ }
+ }, 2000) })
+ })
+}
+
+// Connect to TTS Avatar Service
+function connectToAvatarService(peerConnection) {
+ let localSdp = btoa(JSON.stringify(peerConnection.localDescription))
+ let headers = {
+ 'ClientId': clientId,
+ 'AvatarCharacter': document.getElementById('talkingAvatarCharacter').value,
+ 'AvatarStyle': document.getElementById('talkingAvatarStyle').value,
+ 'IsCustomAvatar': document.getElementById('customizedAvatar').checked
+ }
+
+ if (document.getElementById('azureOpenAIDeploymentName').value !== '') {
+ headers['AoaiDeploymentName'] = document.getElementById('azureOpenAIDeploymentName').value
+ }
+
+ if (document.getElementById('enableOyd').checked && document.getElementById('azureCogSearchIndexName').value !== '') {
+ headers['CognitiveSearchIndexName'] = document.getElementById('azureCogSearchIndexName').value
+ }
+
+ if (document.getElementById('ttsVoice').value !== '') {
+ headers['TtsVoice'] = document.getElementById('ttsVoice').value
+ }
+
+ if (document.getElementById('customVoiceEndpointId').value !== '') {
+ headers['CustomVoiceEndpointId'] = document.getElementById('customVoiceEndpointId').value
+ }
+
+ if (document.getElementById('personalVoiceSpeakerProfileID').value !== '') {
+ headers['PersonalVoiceSpeakerProfileId'] = document.getElementById('personalVoiceSpeakerProfileID').value
+ }
+
+ fetch('/api/connectAvatar', {
+ method: 'POST',
+ headers: headers,
+ body: localSdp
+ })
+ .then(response => {
+ if (response.ok) {
+ response.text().then(text => {
+ const remoteSdp = text
+ peerConnection.setRemoteDescription(new RTCSessionDescription(JSON.parse(atob(remoteSdp))))
+ })
+ } else {
+ document.getElementById('startSession').disabled = false;
+ document.getElementById('configuration').hidden = false;
+ throw new Error(`Failed connecting to the Avatar service: ${response.status} ${response.statusText}`)
+ }
+ })
+}
+
+// Handle user query. Send user query to the chat API and display the response.
+function handleUserQuery(userQuery) {
+ fetch('/api/chat', {
+ method: 'POST',
+ headers: {
+ 'ClientId': clientId,
+ 'SystemPrompt': document.getElementById('prompt').value,
+ 'Content-Type': 'text/plain'
+ },
+ body: userQuery
+ })
+ .then(response => {
+ if (!response.ok) {
+ throw new Error(`Chat API response status: ${response.status} ${response.statusText}`)
+ }
+
+ let chatHistoryTextArea = document.getElementById('chatHistory')
+ chatHistoryTextArea.innerHTML += 'Assistant: '
+
+ const reader = response.body.getReader()
+
+ // Function to recursively read chunks from the stream
+ function read() {
+ return reader.read().then(({ value, done }) => {
+ // Check if there is still data to read
+ if (done) {
+ // Stream complete
+ return
+ }
+
+ // Process the chunk of data (value)
+ let chunkString = new TextDecoder().decode(value, { stream: true })
+
+ chatHistoryTextArea.innerHTML += `${chunkString}`
+ chatHistoryTextArea.scrollTop = chatHistoryTextArea.scrollHeight
+
+ // Continue reading the next chunk
+ return read()
+ })
+ }
+
+ // Start reading the stream
+ return read()
+ })
+}
+
+// Handle local video. If the user is not speaking for 15 seconds, switch to local video.
+function handleLocalVideo() {
+ if (lastSpeakTime === undefined) {
+ return
+ }
+
+ let currentTime = new Date()
+ if (currentTime - lastSpeakTime > 15000) {
+ if (document.getElementById('useLocalVideoForIdle').checked && sessionActive && !isSpeaking) {
+ disconnectAvatar()
+ document.getElementById('localVideo').hidden = false
+ document.getElementById('remoteVideo').style.width = '0.1px'
+ sessionActive = false
+ }
+ }
+}
+
+// Check whether the avatar video stream is hung
+function checkHung() {
+ // Check whether the avatar video stream is hung, by checking whether the video time is advancing
+ let videoElement = document.getElementById('videoPlayer')
+ if (videoElement !== null && videoElement !== undefined && sessionActive) {
+ let videoTime = videoElement.currentTime
+ setTimeout(() => {
+ // Check whether the video time is advancing
+ if (videoElement.currentTime === videoTime) {
+ // Check whether the session is active to avoid duplicatedly triggering reconnect
+ if (sessionActive) {
+ sessionActive = false
+ if (document.getElementById('autoReconnectAvatar').checked) {
+ console.log(`[${(new Date()).toISOString()}] The video stream got disconnected, need reconnect.`)
+ connectAvatar()
+ createSpeechRecognizer()
+ }
+ }
+ }
+ }, 2000)
+ }
+}
+
+window.onload = () => {
+ clientId = document.getElementById('clientId').value
+ setInterval(() => {
+ checkHung()
+ }, 2000) // Check session activity every 2 seconds
+}
+
+window.startSession = () => {
+ createSpeechRecognizer()
+ if (document.getElementById('useLocalVideoForIdle').checked) {
+ document.getElementById('startSession').disabled = true
+ document.getElementById('configuration').hidden = true
+ document.getElementById('microphone').disabled = false
+ document.getElementById('stopSession').disabled = false
+ document.getElementById('localVideo').hidden = false
+ document.getElementById('remoteVideo').style.width = '0.1px'
+ document.getElementById('chatHistory').hidden = false
+ document.getElementById('showTypeMessage').disabled = false
+ return
+ }
+
+ connectAvatar()
+}
+
+window.stopSpeaking = () => {
+ document.getElementById('stopSpeaking').disabled = true
+
+ fetch('/api/stopSpeaking', {
+ method: 'POST',
+ headers: {
+ 'ClientId': clientId
+ },
+ body: ''
+ })
+ .then(response => {
+ if (response.ok) {
+ console.log('Successfully stopped speaking.')
+ } else {
+ throw new Error(`Failed to stop speaking: ${response.status} ${response.statusText}`)
+ }
+ })
+}
+
+window.stopSession = () => {
+ document.getElementById('startSession').disabled = false
+ document.getElementById('microphone').disabled = true
+ document.getElementById('stopSession').disabled = true
+ document.getElementById('configuration').hidden = false
+ document.getElementById('chatHistory').hidden = true
+ document.getElementById('showTypeMessage').checked = false
+ document.getElementById('showTypeMessage').disabled = true
+ document.getElementById('userMessageBox').hidden = true
+ if (document.getElementById('useLocalVideoForIdle').checked) {
+ document.getElementById('localVideo').hidden = true
+ }
+
+ disconnectAvatar(true)
+}
+
+window.clearChatHistory = () => {
+ fetch('/api/chat/clearHistory', {
+ method: 'POST',
+ headers: {
+ 'ClientId': clientId,
+ 'SystemPrompt': document.getElementById('prompt').value
+ },
+ body: ''
+ })
+ .then(response => {
+ if (response.ok) {
+ document.getElementById('chatHistory').innerHTML = ''
+ } else {
+ throw new Error(`Failed to clear chat history: ${response.status} ${response.statusText}`)
+ }
+ })
+}
+
+window.microphone = () => {
+ if (document.getElementById('microphone').innerHTML === 'Stop Microphone') {
+ // Stop microphone
+ document.getElementById('microphone').disabled = true
+ speechRecognizer.stopContinuousRecognitionAsync(
+ () => {
+ document.getElementById('microphone').innerHTML = 'Start Microphone'
+ document.getElementById('microphone').disabled = false
+ }, (err) => {
+ console.log("Failed to stop continuous recognition:", err)
+ document.getElementById('microphone').disabled = false
+ })
+
+ return
+ }
+
+ if (document.getElementById('useLocalVideoForIdle').checked) {
+ if (!sessionActive) {
+ connectAvatar()
+ }
+
+ setTimeout(() => {
+ document.getElementById('audioPlayer').play()
+ }, 5000)
+ } else {
+ document.getElementById('audioPlayer').play()
+ }
+
+ document.getElementById('microphone').disabled = true
+ speechRecognizer.recognizing = async (s, e) => {
+ if (isFirstRecognizingEvent && isSpeaking) {
+ window.stopSpeaking()
+ isFirstRecognizingEvent = false
+ }
+ }
+
+ speechRecognizer.recognized = async (s, e) => {
+ console.log("recognized event handler is set.")
+ if (e.result.reason === SpeechSDK.ResultReason.RecognizedSpeech) {
+ let userQuery = e.result.text.trim()
+ if (userQuery === '') {
+ return
+ }
+
+ // Auto stop microphone when a phrase is recognized, when it's not continuous conversation mode
+ if (!document.getElementById('continuousConversation').checked) {
+ document.getElementById('microphone').disabled = true
+ speechRecognizer.stopContinuousRecognitionAsync(
+ () => {
+ document.getElementById('microphone').innerHTML = 'Start Microphone'
+ document.getElementById('microphone').disabled = false
+ }, (err) => {
+ console.log("Failed to stop continuous recognition:", err)
+ document.getElementById('microphone').disabled = false
+ })
+ }
+
+ let chatHistoryTextArea = document.getElementById('chatHistory')
+ if (chatHistoryTextArea.innerHTML !== '' && !chatHistoryTextArea.innerHTML.endsWith('\n\n')) {
+ chatHistoryTextArea.innerHTML += '\n\n'
+ }
+
+ chatHistoryTextArea.innerHTML += "User: " + userQuery + '\n\n'
+ chatHistoryTextArea.scrollTop = chatHistoryTextArea.scrollHeight
+
+ handleUserQuery(userQuery)
+
+ isFirstRecognizingEvent = true
+ }
+ }
+
+ speechRecognizer.startContinuousRecognitionAsync(
+ () => {
+ console.log('Speech recognition started successfully.');
+ document.getElementById('microphone').innerHTML = 'Stop Microphone'
+ document.getElementById('microphone').disabled = false
+ }, (err) => {
+ console.log("Failed to start continuous recognition:", err)
+ document.getElementById('microphone').disabled = false
+ })
+}
+
+window.updataEnableOyd = () => {
+ if (document.getElementById('enableOyd').checked) {
+ document.getElementById('cogSearchConfig').hidden = false
+ } else {
+ document.getElementById('cogSearchConfig').hidden = true
+ }
+}
+
+window.updateTypeMessageBox = () => {
+ if (document.getElementById('showTypeMessage').checked) {
+ document.getElementById('userMessageBox').hidden = false
+ document.getElementById('userMessageBox').addEventListener('keyup', (e) => {
+ if (e.key === 'Enter') {
+ const userQuery = document.getElementById('userMessageBox').value
+ if (userQuery !== '') {
+ let chatHistoryTextArea = document.getElementById('chatHistory')
+ if (chatHistoryTextArea.innerHTML !== '' && !chatHistoryTextArea.innerHTML.endsWith('\n\n')) {
+ chatHistoryTextArea.innerHTML += '\n\n'
+ }
+
+ chatHistoryTextArea.innerHTML += "User: " + userQuery.trim('\n') + '\n\n'
+ chatHistoryTextArea.scrollTop = chatHistoryTextArea.scrollHeight
+
+ handleUserQuery(userQuery.trim('\n'))
+ document.getElementById('userMessageBox').value = ''
+ }
+ }
+ })
+ } else {
+ document.getElementById('userMessageBox').hidden = true
+ }
+}
+
+window.updateLocalVideoForIdle = () => {
+ if (document.getElementById('useLocalVideoForIdle').checked) {
+ document.getElementById('showTypeMessageCheckbox').hidden = true
+ } else {
+ document.getElementById('showTypeMessageCheckbox').hidden = false
+ }
+}
From 75d8e479c09ada879b32a9120a4a739249bd8a16 Mon Sep 17 00:00:00 2001
From: yinhew <46698869+yinhew@users.noreply.github.com>
Date: Tue, 17 Sep 2024 09:55:07 +0800
Subject: [PATCH 05/11] [Live Avatar][Python, CSharp] Add logging for latency
(#2587)
---
.../avatar/Controllers/AvatarController.cs | 27 +++++++++
.../csharp/web/avatar/Views/Home/basic.cshtml | 2 +-
.../csharp/web/avatar/Views/Home/chat.cshtml | 12 +++-
samples/csharp/web/avatar/wwwroot/js/chat.js | 60 ++++++++++++++++++-
samples/python/web/avatar/app.py | 19 +++++-
samples/python/web/avatar/chat.html | 10 ++++
samples/python/web/avatar/static/js/chat.js | 58 ++++++++++++++++++
7 files changed, 182 insertions(+), 6 deletions(-)
diff --git a/samples/csharp/web/avatar/Controllers/AvatarController.cs b/samples/csharp/web/avatar/Controllers/AvatarController.cs
index fb16fec40..28e9700db 100644
--- a/samples/csharp/web/avatar/Controllers/AvatarController.cs
+++ b/samples/csharp/web/avatar/Controllers/AvatarController.cs
@@ -484,13 +484,24 @@ public async Task HandleUserQuery(string userQuery, Guid clientId, HttpResponse
#pragma warning restore AOAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
}
+ var aoaiStartTime = DateTime.Now;
var chatUpdates = chatClient.CompleteChatStreaming(messages, chatOptions);
+ var isFirstChunk = true;
+ var isFirstSentence = true;
foreach (var chatUpdate in chatUpdates)
{
foreach (var contentPart in chatUpdate.ContentUpdate)
{
var responseToken = contentPart.Text;
+ if (isFirstChunk)
+ {
+ var aoaiFirstTokenLatency = (int)(DateTime.Now.Subtract(aoaiStartTime).TotalMilliseconds + 0.5);
+ Console.WriteLine($"AOAI first token latency: {aoaiFirstTokenLatency}ms");
+ await httpResponse.WriteAsync($"{aoaiFirstTokenLatency}");
+ isFirstChunk = false;
+ }
+
if (ClientSettings.OydDocRegex.IsMatch(responseToken))
{
responseToken = ClientSettings.OydDocRegex.Replace(responseToken, string.Empty);
@@ -501,6 +512,14 @@ public async Task HandleUserQuery(string userQuery, Guid clientId, HttpResponse
assistantReply.Append(responseToken);
if (responseToken == "\n" || responseToken == "\n\n")
{
+ if (isFirstSentence)
+ {
+ var aoaiFirstSentenceLatency = (int)(DateTime.Now.Subtract(aoaiStartTime).TotalMilliseconds + 0.5);
+ Console.WriteLine($"AOAI first sentence latency: {aoaiFirstSentenceLatency}ms");
+ await httpResponse.WriteAsync($"{aoaiFirstSentenceLatency}");
+ isFirstSentence = false;
+ }
+
await SpeakWithQueue(spokenSentence.ToString().Trim(), 0, clientId);
spokenSentence.Clear();
}
@@ -514,6 +533,14 @@ public async Task HandleUserQuery(string userQuery, Guid clientId, HttpResponse
{
if (responseToken.StartsWith(punctuation))
{
+ if (isFirstSentence)
+ {
+ var aoaiFirstSentenceLatency = (int)(DateTime.Now.Subtract(aoaiStartTime).TotalMilliseconds + 0.5);
+ Console.WriteLine($"AOAI first sentence latency: {aoaiFirstSentenceLatency}ms");
+ await httpResponse.WriteAsync($"{aoaiFirstSentenceLatency}");
+ isFirstSentence = false;
+ }
+
await SpeakWithQueue(spokenSentence.ToString().Trim(), 0, clientId);
spokenSentence.Clear();
break;
diff --git a/samples/csharp/web/avatar/Views/Home/basic.cshtml b/samples/csharp/web/avatar/Views/Home/basic.cshtml
index 4d8bf3369..16c3d4acf 100644
--- a/samples/csharp/web/avatar/Views/Home/basic.cshtml
+++ b/samples/csharp/web/avatar/Views/Home/basic.cshtml
@@ -46,7 +46,7 @@
Avatar Control Panel
-
+
diff --git a/samples/csharp/web/avatar/Views/Home/chat.cshtml b/samples/csharp/web/avatar/Views/Home/chat.cshtml
index 742f79302..049dfde5e 100644
--- a/samples/csharp/web/avatar/Views/Home/chat.cshtml
+++ b/samples/csharp/web/avatar/Views/Home/chat.cshtml
@@ -80,8 +80,18 @@
background-color: transparent;
overflow: hidden;" hidden>
+
+
+
-
+
diff --git a/samples/csharp/web/avatar/wwwroot/js/chat.js b/samples/csharp/web/avatar/wwwroot/js/chat.js
index 7d35eacf2..c87af236a 100644
--- a/samples/csharp/web/avatar/wwwroot/js/chat.js
+++ b/samples/csharp/web/avatar/wwwroot/js/chat.js
@@ -7,8 +7,12 @@ var speechRecognizer
var peerConnection
var isSpeaking = false
var sessionActive = false
+var recognitionStartedTime
+var chatResponseReceivedTime
var lastSpeakTime
var isFirstRecognizingEvent = true
+var firstTokenLatencyRegex = new RegExp(/(\d+)<\/FTL>/)
+var firstSentenceLatencyRegex = new RegExp(/(\d+)<\/FSL>/)
// Connect to avatar service
function connectAvatar() {
@@ -134,6 +138,7 @@ function setupWebRTC(iceServerUrl, iceServerUsername, iceServerCredential) {
document.getElementById('stopSession').disabled = false
document.getElementById('remoteVideo').style.width = '960px'
document.getElementById('chatHistory').hidden = false
+ document.getElementById('latencyLog').hidden = false
document.getElementById('showTypeMessage').disabled = false
if (document.getElementById('useLocalVideoForIdle').checked) {
@@ -155,6 +160,16 @@ function setupWebRTC(iceServerUrl, iceServerUsername, iceServerCredential) {
console.log("[" + (new Date()).toISOString() + "] WebRTC event received: " + e.data)
if (e.data.includes("EVENT_TYPE_SWITCH_TO_SPEAKING")) {
+ if (chatResponseReceivedTime !== undefined) {
+ let speakStartTime = new Date()
+ let ttsLatency = speakStartTime - chatResponseReceivedTime
+ console.log(`TTS latency: ${ttsLatency} ms`)
+ let latencyLogTextArea = document.getElementById('latencyLog')
+ latencyLogTextArea.innerHTML += `TTS latency: ${ttsLatency} ms\n\n`
+ latencyLogTextArea.scrollTop = latencyLogTextArea.scrollHeight
+ chatResponseReceivedTime = undefined
+ }
+
isSpeaking = true
document.getElementById('stopSpeaking').disabled = false
} else if (e.data.includes("EVENT_TYPE_SWITCH_TO_IDLE")) {
@@ -254,6 +269,7 @@ function connectToAvatarService(peerConnection) {
// Handle user query. Send user query to the chat API and display the response.
function handleUserQuery(userQuery) {
+ let chatRequestSentTime = new Date()
fetch('/api/chat', {
method: 'POST',
headers: {
@@ -285,6 +301,32 @@ function handleUserQuery(userQuery) {
// Process the chunk of data (value)
let chunkString = new TextDecoder().decode(value, { stream: true })
+ if (firstTokenLatencyRegex.test(chunkString)) {
+ let aoaiFirstTokenLatency = parseInt(firstTokenLatencyRegex.exec(chunkString)[0].replace('', '').replace('', ''))
+ // console.log(`AOAI first token latency: ${aoaiFirstTokenLatency} ms`)
+ chunkString = chunkString.replace(firstTokenLatencyRegex, '')
+ if (chunkString === '') {
+ return read()
+ }
+ }
+
+ if (firstSentenceLatencyRegex.test(chunkString)) {
+ let aoaiFirstSentenceLatency = parseInt(firstSentenceLatencyRegex.exec(chunkString)[0].replace('', '').replace('', ''))
+ chatResponseReceivedTime = new Date()
+ let chatLatency = chatResponseReceivedTime - chatRequestSentTime
+ let appServiceLatency = chatLatency - aoaiFirstSentenceLatency
+ console.log(`App service latency: ${appServiceLatency} ms`)
+ console.log(`AOAI latency: ${aoaiFirstSentenceLatency} ms`)
+ let latencyLogTextArea = document.getElementById('latencyLog')
+ latencyLogTextArea.innerHTML += `App service latency: ${appServiceLatency} ms\n`
+ latencyLogTextArea.innerHTML += `AOAI latency: ${aoaiFirstSentenceLatency} ms\n`
+ latencyLogTextArea.scrollTop = latencyLogTextArea.scrollHeight
+ chunkString = chunkString.replace(firstSentenceLatencyRegex, '')
+ if (chunkString === '') {
+ return read()
+ }
+ }
+
chatHistoryTextArea.innerHTML += `${chunkString}`
chatHistoryTextArea.scrollTop = chatHistoryTextArea.scrollHeight
@@ -355,6 +397,7 @@ window.startSession = () => {
document.getElementById('localVideo').hidden = false
document.getElementById('remoteVideo').style.width = '0.1px'
document.getElementById('chatHistory').hidden = false
+ document.getElementById('latencyLog').hidden = false
document.getElementById('showTypeMessage').disabled = false
return
}
@@ -387,6 +430,7 @@ window.stopSession = () => {
document.getElementById('stopSession').disabled = true
document.getElementById('configuration').hidden = false
document.getElementById('chatHistory').hidden = true
+ document.getElementById('latencyLog').hidden = true
document.getElementById('showTypeMessage').checked = false
document.getElementById('showTypeMessage').disabled = true
document.getElementById('userMessageBox').hidden = true
@@ -409,6 +453,7 @@ window.clearChatHistory = () => {
.then(response => {
if (response.ok) {
document.getElementById('chatHistory').innerHTML = ''
+ document.getElementById('latencyLog').innerHTML = ''
} else {
throw new Error(`Failed to clear chat history: ${response.status} ${response.statusText}`)
}
@@ -452,13 +497,20 @@ window.microphone = () => {
}
speechRecognizer.recognized = async (s, e) => {
- console.log("recognized event handler is set.")
if (e.result.reason === SpeechSDK.ResultReason.RecognizedSpeech) {
let userQuery = e.result.text.trim()
if (userQuery === '') {
return
}
+ let recognitionResultReceivedTime = new Date()
+ let speechFinishedOffset = (e.result.offset + e.result.duration) / 10000
+ let sttLatency = recognitionResultReceivedTime - recognitionStartedTime - speechFinishedOffset
+ console.log(`STT latency: ${sttLatency} ms`)
+ let latencyLogTextArea = document.getElementById('latencyLog')
+ latencyLogTextArea.innerHTML += `STT latency: ${sttLatency} ms\n`
+ latencyLogTextArea.scrollTop = latencyLogTextArea.scrollHeight
+
// Auto stop microphone when a phrase is recognized, when it's not continuous conversation mode
if (!document.getElementById('continuousConversation').checked) {
document.getElementById('microphone').disabled = true
@@ -486,9 +538,9 @@ window.microphone = () => {
}
}
+ recognitionStartedTime = new Date()
speechRecognizer.startContinuousRecognitionAsync(
() => {
- console.log('Speech recognition started successfully.');
document.getElementById('microphone').innerHTML = 'Stop Microphone'
document.getElementById('microphone').disabled = false
}, (err) => {
@@ -520,6 +572,10 @@ window.updateTypeMessageBox = () => {
chatHistoryTextArea.innerHTML += "User: " + userQuery.trim('\n') + '\n\n'
chatHistoryTextArea.scrollTop = chatHistoryTextArea.scrollHeight
+ if (isSpeaking) {
+ window.stopSpeaking()
+ }
+
handleUserQuery(userQuery.trim('\n'))
document.getElementById('userMessageBox').value = ''
}
diff --git a/samples/python/web/avatar/app.py b/samples/python/web/avatar/app.py
index 032f6b137..000714244 100644
--- a/samples/python/web/avatar/app.py
+++ b/samples/python/web/avatar/app.py
@@ -381,19 +381,29 @@ def handleUserQuery(user_query: str, client_id: uuid.UUID):
messages=messages,
extra_body={ 'data_sources' : data_sources } if len(data_sources) > 0 else None,
stream=True)
- aoai_reponse_time = datetime.datetime.now(pytz.UTC)
- print(f"AOAI latency: {(aoai_reponse_time - aoai_start_time).total_seconds() * 1000}ms")
+ is_first_chunk = True
+ is_first_sentence = True
for chunk in response:
if len(chunk.choices) > 0:
response_token = chunk.choices[0].delta.content
if response_token is not None:
# Log response_token here if need debug
+ if is_first_chunk:
+ first_token_latency_ms = round((datetime.datetime.now(pytz.UTC) - aoai_start_time).total_seconds() * 1000)
+ print(f"AOAI first token latency: {first_token_latency_ms}ms")
+ yield f"{first_token_latency_ms}"
+ is_first_chunk = False
if oyd_doc_regex.search(response_token):
response_token = oyd_doc_regex.sub('', response_token).strip()
yield response_token # yield response token to client as display text
assistant_reply += response_token # build up the assistant message
if response_token == '\n' or response_token == '\n\n':
+ if is_first_sentence:
+ first_sentence_latency_ms = round((datetime.datetime.now(pytz.UTC) - aoai_start_time).total_seconds() * 1000)
+ print(f"AOAI first sentence latency: {first_sentence_latency_ms}ms")
+ yield f"{first_sentence_latency_ms}"
+ is_first_sentence = False
speakWithQueue(spoken_sentence.strip(), 0, client_id)
spoken_sentence = ''
else:
@@ -402,6 +412,11 @@ def handleUserQuery(user_query: str, client_id: uuid.UUID):
if len(response_token) == 1 or len(response_token) == 2:
for punctuation in sentence_level_punctuations:
if response_token.startswith(punctuation):
+ if is_first_sentence:
+ first_sentence_latency_ms = round((datetime.datetime.now(pytz.UTC) - aoai_start_time).total_seconds() * 1000)
+ print(f"AOAI first sentence latency: {first_sentence_latency_ms}ms")
+ yield f"{first_sentence_latency_ms}"
+ is_first_sentence = False
speakWithQueue(spoken_sentence.strip(), 0, client_id)
spoken_sentence = ''
break
diff --git a/samples/python/web/avatar/chat.html b/samples/python/web/avatar/chat.html
index 59b9ad190..ff378dcb1 100644
--- a/samples/python/web/avatar/chat.html
+++ b/samples/python/web/avatar/chat.html
@@ -78,6 +78,16 @@ Avatar Configuration
background-color: transparent;
overflow: hidden;" hidden>
+
+
+
diff --git a/samples/python/web/avatar/static/js/chat.js b/samples/python/web/avatar/static/js/chat.js
index ea380c5b4..c87af236a 100644
--- a/samples/python/web/avatar/static/js/chat.js
+++ b/samples/python/web/avatar/static/js/chat.js
@@ -7,8 +7,12 @@ var speechRecognizer
var peerConnection
var isSpeaking = false
var sessionActive = false
+var recognitionStartedTime
+var chatResponseReceivedTime
var lastSpeakTime
var isFirstRecognizingEvent = true
+var firstTokenLatencyRegex = new RegExp(/(\d+)<\/FTL>/)
+var firstSentenceLatencyRegex = new RegExp(/(\d+)<\/FSL>/)
// Connect to avatar service
function connectAvatar() {
@@ -134,6 +138,7 @@ function setupWebRTC(iceServerUrl, iceServerUsername, iceServerCredential) {
document.getElementById('stopSession').disabled = false
document.getElementById('remoteVideo').style.width = '960px'
document.getElementById('chatHistory').hidden = false
+ document.getElementById('latencyLog').hidden = false
document.getElementById('showTypeMessage').disabled = false
if (document.getElementById('useLocalVideoForIdle').checked) {
@@ -155,6 +160,16 @@ function setupWebRTC(iceServerUrl, iceServerUsername, iceServerCredential) {
console.log("[" + (new Date()).toISOString() + "] WebRTC event received: " + e.data)
if (e.data.includes("EVENT_TYPE_SWITCH_TO_SPEAKING")) {
+ if (chatResponseReceivedTime !== undefined) {
+ let speakStartTime = new Date()
+ let ttsLatency = speakStartTime - chatResponseReceivedTime
+ console.log(`TTS latency: ${ttsLatency} ms`)
+ let latencyLogTextArea = document.getElementById('latencyLog')
+ latencyLogTextArea.innerHTML += `TTS latency: ${ttsLatency} ms\n\n`
+ latencyLogTextArea.scrollTop = latencyLogTextArea.scrollHeight
+ chatResponseReceivedTime = undefined
+ }
+
isSpeaking = true
document.getElementById('stopSpeaking').disabled = false
} else if (e.data.includes("EVENT_TYPE_SWITCH_TO_IDLE")) {
@@ -254,6 +269,7 @@ function connectToAvatarService(peerConnection) {
// Handle user query. Send user query to the chat API and display the response.
function handleUserQuery(userQuery) {
+ let chatRequestSentTime = new Date()
fetch('/api/chat', {
method: 'POST',
headers: {
@@ -285,6 +301,32 @@ function handleUserQuery(userQuery) {
// Process the chunk of data (value)
let chunkString = new TextDecoder().decode(value, { stream: true })
+ if (firstTokenLatencyRegex.test(chunkString)) {
+ let aoaiFirstTokenLatency = parseInt(firstTokenLatencyRegex.exec(chunkString)[0].replace('', '').replace('', ''))
+ // console.log(`AOAI first token latency: ${aoaiFirstTokenLatency} ms`)
+ chunkString = chunkString.replace(firstTokenLatencyRegex, '')
+ if (chunkString === '') {
+ return read()
+ }
+ }
+
+ if (firstSentenceLatencyRegex.test(chunkString)) {
+ let aoaiFirstSentenceLatency = parseInt(firstSentenceLatencyRegex.exec(chunkString)[0].replace('', '').replace('', ''))
+ chatResponseReceivedTime = new Date()
+ let chatLatency = chatResponseReceivedTime - chatRequestSentTime
+ let appServiceLatency = chatLatency - aoaiFirstSentenceLatency
+ console.log(`App service latency: ${appServiceLatency} ms`)
+ console.log(`AOAI latency: ${aoaiFirstSentenceLatency} ms`)
+ let latencyLogTextArea = document.getElementById('latencyLog')
+ latencyLogTextArea.innerHTML += `App service latency: ${appServiceLatency} ms\n`
+ latencyLogTextArea.innerHTML += `AOAI latency: ${aoaiFirstSentenceLatency} ms\n`
+ latencyLogTextArea.scrollTop = latencyLogTextArea.scrollHeight
+ chunkString = chunkString.replace(firstSentenceLatencyRegex, '')
+ if (chunkString === '') {
+ return read()
+ }
+ }
+
chatHistoryTextArea.innerHTML += `${chunkString}`
chatHistoryTextArea.scrollTop = chatHistoryTextArea.scrollHeight
@@ -355,6 +397,7 @@ window.startSession = () => {
document.getElementById('localVideo').hidden = false
document.getElementById('remoteVideo').style.width = '0.1px'
document.getElementById('chatHistory').hidden = false
+ document.getElementById('latencyLog').hidden = false
document.getElementById('showTypeMessage').disabled = false
return
}
@@ -387,6 +430,7 @@ window.stopSession = () => {
document.getElementById('stopSession').disabled = true
document.getElementById('configuration').hidden = false
document.getElementById('chatHistory').hidden = true
+ document.getElementById('latencyLog').hidden = true
document.getElementById('showTypeMessage').checked = false
document.getElementById('showTypeMessage').disabled = true
document.getElementById('userMessageBox').hidden = true
@@ -409,6 +453,7 @@ window.clearChatHistory = () => {
.then(response => {
if (response.ok) {
document.getElementById('chatHistory').innerHTML = ''
+ document.getElementById('latencyLog').innerHTML = ''
} else {
throw new Error(`Failed to clear chat history: ${response.status} ${response.statusText}`)
}
@@ -458,6 +503,14 @@ window.microphone = () => {
return
}
+ let recognitionResultReceivedTime = new Date()
+ let speechFinishedOffset = (e.result.offset + e.result.duration) / 10000
+ let sttLatency = recognitionResultReceivedTime - recognitionStartedTime - speechFinishedOffset
+ console.log(`STT latency: ${sttLatency} ms`)
+ let latencyLogTextArea = document.getElementById('latencyLog')
+ latencyLogTextArea.innerHTML += `STT latency: ${sttLatency} ms\n`
+ latencyLogTextArea.scrollTop = latencyLogTextArea.scrollHeight
+
// Auto stop microphone when a phrase is recognized, when it's not continuous conversation mode
if (!document.getElementById('continuousConversation').checked) {
document.getElementById('microphone').disabled = true
@@ -485,6 +538,7 @@ window.microphone = () => {
}
}
+ recognitionStartedTime = new Date()
speechRecognizer.startContinuousRecognitionAsync(
() => {
document.getElementById('microphone').innerHTML = 'Stop Microphone'
@@ -518,6 +572,10 @@ window.updateTypeMessageBox = () => {
chatHistoryTextArea.innerHTML += "User: " + userQuery.trim('\n') + '\n\n'
chatHistoryTextArea.scrollTop = chatHistoryTextArea.scrollHeight
+ if (isSpeaking) {
+ window.stopSpeaking()
+ }
+
handleUserQuery(userQuery.trim('\n'))
document.getElementById('userMessageBox').value = ''
}
From 203bd467e6849825f3823c435f9a91691bb34f0f Mon Sep 17 00:00:00 2001
From: Henry van der Vegte
Date: Wed, 18 Sep 2024 16:13:39 +0200
Subject: [PATCH 06/11] [IngestionClient] Use dependency injection for
BatchClient, add User-Agent request header (#2592)
---
.../ingestion-client/Connector/BatchClient.cs | 53 +++++++++++--------
.../FetchTranscription/Config/AppConfig.cs | 2 +
.../FetchTranscription/FetchTranscription.cs | 11 +++-
.../FetchTranscription/Program.cs | 11 ++++
.../TranscriptionProcessor.cs | 22 ++++----
.../Config/AppConfig.cs | 2 +
.../StartTranscriptionByTimer/Program.cs | 11 ++++
.../StartTranscriptionHelper.cs | 6 ++-
.../ingestion-client/infra/main.bicep | 4 +-
.../ingestion-client/infra/main.json | 10 ++--
10 files changed, 93 insertions(+), 39 deletions(-)
diff --git a/samples/ingestion/ingestion-client/Connector/BatchClient.cs b/samples/ingestion/ingestion-client/Connector/BatchClient.cs
index c5791a3e6..4fc471942 100644
--- a/samples/ingestion/ingestion-client/Connector/BatchClient.cs
+++ b/samples/ingestion/ingestion-client/Connector/BatchClient.cs
@@ -17,7 +17,7 @@ namespace Connector
using Polly;
using Polly.Retry;
- public static class BatchClient
+ public class BatchClient
{
private const string TranscriptionsBasePath = "speechtotext/v3.0/Transcriptions/";
@@ -29,36 +29,42 @@ public static class BatchClient
private static readonly TimeSpan GetFilesTimeout = TimeSpan.FromMinutes(5);
- private static readonly HttpClient HttpClient = new HttpClient() { Timeout = Timeout.InfiniteTimeSpan };
-
private static readonly AsyncRetryPolicy RetryPolicy =
Policy
.Handle(e => e is HttpStatusCodeException || e is HttpRequestException)
.WaitAndRetryAsync(MaxNumberOfRetries, retryAttempt => TimeSpan.FromSeconds(5));
- public static Task GetTranscriptionReportFileFromSasAsync(string sasUri)
+ private readonly HttpClient httpClient;
+
+ public BatchClient(IHttpClientFactory httpClientFactory)
+ {
+ ArgumentNullException.ThrowIfNull(httpClientFactory, nameof(httpClientFactory));
+ this.httpClient = httpClientFactory.CreateClient(nameof(BatchClient));
+ }
+
+ public Task GetTranscriptionReportFileFromSasAsync(string sasUri)
{
- return GetAsync(sasUri, null, DefaultTimeout);
+ return this.GetAsync(sasUri, null, DefaultTimeout);
}
- public static Task GetSpeechTranscriptFromSasAsync(string sasUri)
+ public Task GetSpeechTranscriptFromSasAsync(string sasUri)
{
- return GetAsync(sasUri, null, DefaultTimeout);
+ return this.GetAsync(sasUri, null, DefaultTimeout);
}
- public static Task GetTranscriptionAsync(string transcriptionLocation, string subscriptionKey)
+ public Task GetTranscriptionAsync(string transcriptionLocation, string subscriptionKey)
{
- return GetAsync