@chat21/chat21-web-widget 5.1.34 → 5.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.angular-mcp-cache/package.json +1 -0
- package/.cursor/angular18-accessibility-auditor-skill.md +442 -0
- package/.cursor/mcp.json +15 -0
- package/.github/workflows/playwright.yml +27 -0
- package/CHANGELOG.md +25 -0
- package/Dockerfile +4 -5
- package/README.md +1 -1
- package/angular.json +21 -3
- package/docs/ACCESSIBILITY-STATEMENT.md +388 -0
- package/docs/TILEDESK_WIDGET_ACCESSIBILITY_ALIGNMENT.md +60 -0
- package/docs/TILEDESK_WIDGET_ACCESSIBILITY_STATEMENT_COMPLETE.md +386 -0
- package/env.sample +3 -2
- package/mocks/voice-websocket-mock/server.cjs +245 -0
- package/package.json +10 -3
- package/playwright.config.ts +41 -0
- package/src/app/app.component.html +2 -2
- package/src/app/app.component.scss +25 -14
- package/src/app/app.component.spec.ts +21 -6
- package/src/app/app.module.ts +13 -0
- package/src/app/component/conversation-detail/conversation/conversation.component.html +25 -11
- package/src/app/component/conversation-detail/conversation/conversation.component.scss +38 -0
- package/src/app/component/conversation-detail/conversation/conversation.component.spec.ts +644 -75
- package/src/app/component/conversation-detail/conversation/conversation.component.ts +70 -2
- package/src/app/component/conversation-detail/conversation-audio-recorder/conversation-audio-recorder.component.html +25 -13
- package/src/app/component/conversation-detail/conversation-audio-recorder/conversation-audio-recorder.component.spec.ts +123 -5
- package/src/app/component/conversation-detail/conversation-audio-recorder/conversation-audio-recorder.component.ts +1 -0
- package/src/app/component/conversation-detail/conversation-content/conversation-content.component.html +23 -10
- package/src/app/component/conversation-detail/conversation-content/conversation-content.component.scss +18 -0
- package/src/app/component/conversation-detail/conversation-content/conversation-content.component.spec.ts +241 -149
- package/src/app/component/conversation-detail/conversation-content/conversation-content.component.ts +8 -5
- package/src/app/component/conversation-detail/conversation-emojii/conversation-emojii.component.spec.ts +53 -3
- package/src/app/component/conversation-detail/conversation-footer/conversation-footer.component.html +203 -110
- package/src/app/component/conversation-detail/conversation-footer/conversation-footer.component.scss +212 -1
- package/src/app/component/conversation-detail/conversation-footer/conversation-footer.component.spec.ts +458 -78
- package/src/app/component/conversation-detail/conversation-footer/conversation-footer.component.ts +288 -76
- package/src/app/component/conversation-detail/conversation-header/conversation-header.component.html +113 -53
- package/src/app/component/conversation-detail/conversation-header/conversation-header.component.scss +12 -4
- package/src/app/component/conversation-detail/conversation-header/conversation-header.component.spec.ts +274 -29
- package/src/app/component/conversation-detail/conversation-internal-frame/conversation-internal-frame.component.html +23 -9
- package/src/app/component/conversation-detail/conversation-internal-frame/conversation-internal-frame.component.spec.ts +80 -8
- package/src/app/component/conversation-detail/conversation-preview/conversation-preview.component.html +29 -23
- package/src/app/component/conversation-detail/conversation-preview/conversation-preview.component.spec.ts +185 -16
- package/src/app/component/conversation-detail/conversation-preview/conversation-preview.component.ts +34 -14
- package/src/app/component/conversation-detail/stream-audio-spectrum/stream-audio-spectrum.component.html +46 -0
- package/src/app/component/conversation-detail/stream-audio-spectrum/stream-audio-spectrum.component.scss +83 -0
- package/src/app/component/conversation-detail/stream-audio-spectrum/stream-audio-spectrum.component.ts +192 -0
- package/src/app/component/error-alert/error-alert.component.spec.ts +65 -5
- package/src/app/component/eyeeye-catcher-card/eyeeye-catcher-card.component.html +16 -7
- package/src/app/component/eyeeye-catcher-card/eyeeye-catcher-card.component.scss +21 -0
- package/src/app/component/eyeeye-catcher-card/eyeeye-catcher-card.component.spec.ts +89 -7
- package/src/app/component/form/form-builder/form-builder.component.html +1 -1
- package/src/app/component/form/form-builder/form-builder.component.spec.ts +163 -21
- package/src/app/component/form/inputs/form-checkbox/form-checkbox.component.html +8 -4
- package/src/app/component/form/inputs/form-checkbox/form-checkbox.component.scss +10 -5
- package/src/app/component/form/inputs/form-checkbox/form-checkbox.component.spec.ts +90 -16
- package/src/app/component/form/inputs/form-checkbox/form-checkbox.component.ts +26 -0
- package/src/app/component/form/inputs/form-label/form-label.component.spec.ts +45 -11
- package/src/app/component/form/inputs/form-radio-button/form-radio-button.component.spec.ts +24 -6
- package/src/app/component/form/inputs/form-select/form-select.component.spec.ts +14 -5
- package/src/app/component/form/inputs/form-text/form-text.component.html +14 -12
- package/src/app/component/form/inputs/form-text/form-text.component.scss +11 -1
- package/src/app/component/form/inputs/form-text/form-text.component.spec.ts +113 -17
- package/src/app/component/form/inputs/form-text/form-text.component.ts +35 -3
- package/src/app/component/form/inputs/form-textarea/form-textarea.component.html +13 -11
- package/src/app/component/form/inputs/form-textarea/form-textarea.component.scss +6 -5
- package/src/app/component/form/inputs/form-textarea/form-textarea.component.spec.ts +149 -13
- package/src/app/component/form/inputs/form-textarea/form-textarea.component.ts +26 -0
- package/src/app/component/form/prechat-form/prechat-form.component.html +14 -11
- package/src/app/component/form/prechat-form/prechat-form.component.spec.ts +102 -10
- package/src/app/component/form/prechat-form/prechat-form.component.ts +8 -1
- package/src/app/component/form/prechat-form-test-mock.ts +35 -0
- package/src/app/component/home/home.component.html +38 -31
- package/src/app/component/home/home.component.scss +4 -2
- package/src/app/component/home/home.component.spec.ts +226 -11
- package/src/app/component/home-conversations/home-conversations.component.html +30 -26
- package/src/app/component/home-conversations/home-conversations.component.scss +3 -0
- package/src/app/component/home-conversations/home-conversations.component.spec.ts +212 -36
- package/src/app/component/last-message/last-message.component.html +15 -9
- package/src/app/component/last-message/last-message.component.scss +16 -2
- package/src/app/component/last-message/last-message.component.spec.ts +204 -23
- package/src/app/component/launcher-button/launcher-button.component.html +8 -13
- package/src/app/component/launcher-button/launcher-button.component.spec.ts +104 -8
- package/src/app/component/list-all-conversations/list-all-conversations.component.html +12 -17
- package/src/app/component/list-all-conversations/list-all-conversations.component.scss +2 -0
- package/src/app/component/list-conversations/list-conversations.component.html +22 -22
- package/src/app/component/menu-options/menu-options.component.html +30 -20
- package/src/app/component/menu-options/menu-options.component.spec.ts +125 -9
- package/src/app/component/message/audio/audio.component.html +13 -15
- package/src/app/component/message/audio/audio.component.spec.ts +140 -5
- package/src/app/component/message/audio/audio.component.ts +1 -5
- package/src/app/component/message/audio-sync/audio-sync.component.html +18 -0
- package/src/app/component/message/audio-sync/audio-sync.component.scss +65 -0
- package/src/app/component/message/audio-sync/audio-sync.component.spec.ts +112 -0
- package/src/app/component/message/audio-sync/audio-sync.component.ts +714 -0
- package/src/app/component/message/avatar/avatar.component.html +2 -2
- package/src/app/component/message/avatar/avatar.component.spec.ts +99 -7
- package/src/app/component/message/bubble-message/bubble-message.component.html +41 -51
- package/src/app/component/message/bubble-message/bubble-message.component.scss +54 -1
- package/src/app/component/message/bubble-message/bubble-message.component.spec.ts +147 -57
- package/src/app/component/message/bubble-message/bubble-message.component.ts +95 -13
- package/src/app/component/message/buttons/action-button/action-button.component.html +3 -4
- package/src/app/component/message/buttons/action-button/action-button.component.spec.ts +49 -5
- package/src/app/component/message/buttons/link-button/link-button.component.scss +5 -8
- package/src/app/component/message/buttons/link-button/link-button.component.spec.ts +50 -5
- package/src/app/component/message/buttons/text-button/text-button.component.spec.ts +44 -5
- package/src/app/component/message/carousel/carousel.component.html +29 -16
- package/src/app/component/message/carousel/carousel.component.scss +20 -8
- package/src/app/component/message/carousel/carousel.component.spec.ts +80 -3
- package/src/app/component/message/carousel/carousel.component.ts +16 -0
- package/src/app/component/message/frame/frame.component.html +9 -4
- package/src/app/component/message/frame/frame.component.spec.ts +34 -15
- package/src/app/component/message/frame/frame.component.ts +7 -2
- package/src/app/component/message/html/html.component.html +1 -1
- package/src/app/component/message/html/html.component.scss +1 -1
- package/src/app/component/message/html/html.component.spec.ts +24 -7
- package/src/app/component/message/image/image.component.html +12 -10
- package/src/app/component/message/image/image.component.scss +16 -0
- package/src/app/component/message/image/image.component.spec.ts +101 -15
- package/src/app/component/message/image/image.component.ts +90 -51
- package/src/app/component/message/info-message/info-message.component.spec.ts +26 -14
- package/src/app/component/message/json-sources/json-sources.component.html +6 -5
- package/src/app/component/message/json-sources/json-sources.component.scss +26 -18
- package/src/app/component/message/json-sources/json-sources.component.ts +41 -0
- package/src/app/component/message/like-unlike/like-unlike.component.html +7 -9
- package/src/app/component/message/like-unlike/like-unlike.component.spec.ts +31 -3
- package/src/app/component/message/return-receipt/return-receipt.component.spec.ts +38 -17
- package/src/app/component/message/text/text.component.html +3 -3
- package/src/app/component/message/text/text.component.scss +80 -86
- package/src/app/component/message/text/text.component.spec.ts +106 -13
- package/src/app/component/message-attachment/message-attachment.component.spec.ts +134 -13
- package/src/app/component/selection-department/selection-department.component.html +21 -23
- package/src/app/component/selection-department/selection-department.component.spec.ts +159 -14
- package/src/app/component/selection-department/selection-department.component.ts +8 -1
- package/src/app/component/send-button/send-button.component.html +5 -13
- package/src/app/component/send-button/send-button.component.spec.ts +2 -2
- package/src/app/component/star-rating-widget/star-rating-widget.component.html +51 -81
- package/src/app/directives/tooltip.directive.spec.ts +8 -4
- package/src/app/modals/confirm-close/confirm-close.component.html +20 -8
- package/src/app/modals/confirm-close/confirm-close.component.scss +3 -0
- package/src/app/modals/confirm-close/confirm-close.component.spec.ts +13 -4
- package/src/app/modals/confirm-close/confirm-close.component.ts +8 -1
- package/src/app/pipe/html-entites-encode.pipe.spec.ts +35 -2
- package/src/app/pipe/marked.pipe.spec.ts +38 -2
- package/src/app/pipe/marked.pipe.ts +51 -41
- package/src/app/providers/app-config.service.ts +4 -2
- package/src/app/providers/brand.service.spec.ts +23 -2
- package/src/app/providers/brand.service.ts +1 -1
- package/src/app/providers/global-settings.service.spec.ts +1009 -14
- package/src/app/providers/global-settings.service.ts +40 -2
- package/src/app/providers/json-sources-parser.service.ts +13 -1
- package/src/app/providers/translator.service.ts +24 -7
- package/src/app/providers/tts-audio-playback-coordinator.service.spec.ts +116 -0
- package/src/app/providers/tts-audio-playback-coordinator.service.ts +122 -0
- package/src/app/providers/voice/STT&TTS/openai-voice.config.ts +12 -0
- package/src/app/providers/voice/STT&TTS/openai-voice.provider.ts +156 -0
- package/src/app/providers/voice/STT&TTS/speech-provider.abstract.ts +39 -0
- package/src/app/providers/voice/audio.types.ts +40 -0
- package/src/app/providers/voice/vad.service.spec.ts +28 -0
- package/src/app/providers/voice/vad.service.ts +70 -0
- package/src/app/providers/voice/voice-streaming.service.spec.ts +23 -0
- package/src/app/providers/voice/voice-streaming.service.ts +702 -0
- package/src/app/providers/voice/voice-streaming.types.ts +112 -0
- package/src/app/providers/voice/voice.service.spec.ts +227 -0
- package/src/app/providers/voice/voice.service.ts +969 -0
- package/src/app/sass/_variables.scss +2 -0
- package/src/app/sass/animations.scss +19 -1
- package/src/app/shims/onnxruntime-web-wasm.ts +4 -0
- package/src/app/utils/globals.ts +14 -0
- package/src/app/utils/utils-resources.ts +1 -1
- package/src/assets/i18n/en.json +128 -100
- package/src/assets/i18n/es.json +128 -100
- package/src/assets/i18n/fr.json +128 -100
- package/src/assets/i18n/it.json +128 -98
- package/src/assets/onnx/ort-wasm-simd-threaded.mjs +59 -0
- package/src/assets/onnx/ort-wasm-simd-threaded.wasm +0 -0
- package/src/assets/sounds/keyboard.mp3 +0 -0
- package/src/assets/vad/silero_vad_legacy.onnx +0 -0
- package/src/assets/vad/vad.worklet.bundle.min.js +1 -0
- package/src/chat21-core/models/message.ts +2 -1
- package/src/chat21-core/providers/chat-manager.spec.ts +72 -0
- package/src/chat21-core/providers/firebase/firebase-conversation-handler.ts +3 -2
- package/src/chat21-core/providers/firebase/firebase-init-service.ts +5 -5
- package/src/chat21-core/providers/mqtt/mqtt-conversation-handler.ts +12 -0
- package/src/chat21-core/providers/scripts/script.service.spec.ts +12 -2
- package/src/chat21-core/utils/utils-message.ts +7 -0
- package/src/widget-config-template.json +3 -1
- package/src/widget-config.json +28 -27
- package/tests/widget-form-rich.spec.ts +67 -0
- package/tests/widget-index-dev-settings.spec.ts +52 -0
- package/tests/widget-twp-iframe.spec.ts +39 -0
- package/tsconfig.json +5 -0
|
@@ -6,7 +6,6 @@ import { BehaviorSubject, Observable } from 'rxjs';
|
|
|
6
6
|
import { Globals } from '../utils/globals';
|
|
7
7
|
import { convertColorToRGBA, detectIfIsMobile, getImageUrlThumb, getParameterByName, stringToBoolean, stringToNumber } from '../utils/utils';
|
|
8
8
|
|
|
9
|
-
import { TemplateBindingParseResult } from '@angular/compiler';
|
|
10
9
|
import { AppStorageService } from '../../chat21-core/providers/abstract/app-storage.service';
|
|
11
10
|
import { LoggerService } from '../../chat21-core/providers/abstract/logger.service';
|
|
12
11
|
import { LoggerInstance } from '../../chat21-core/providers/logger/loggerInstance';
|
|
@@ -573,6 +572,9 @@ export class GlobalSettingsService {
|
|
|
573
572
|
if (variables.hasOwnProperty('allowedUploadExtentions')) {
|
|
574
573
|
globals['fileUploadAccept'] = variables['allowedUploadExtentions'];
|
|
575
574
|
}
|
|
575
|
+
if(variables.hasOwnProperty('showAudioStreamFooterButton')) {
|
|
576
|
+
globals['showAudioStreamFooterButton'] = variables['showAudioStreamFooterButton'];
|
|
577
|
+
}
|
|
576
578
|
|
|
577
579
|
}
|
|
578
580
|
}
|
|
@@ -702,7 +704,7 @@ export class GlobalSettingsService {
|
|
|
702
704
|
}
|
|
703
705
|
TEMP = tiledeskSettings['lang'];
|
|
704
706
|
// this.logger.debug('[GLOBAL-SET] setVariablesFromSettings > lang:: ', TEMP);
|
|
705
|
-
if (
|
|
707
|
+
if (TEMP !== undefined) {
|
|
706
708
|
globals.lang = TEMP;
|
|
707
709
|
// globals.setParameter('lang', TEMP);
|
|
708
710
|
}
|
|
@@ -919,6 +921,14 @@ export class GlobalSettingsService {
|
|
|
919
921
|
if (TEMP !== undefined) {
|
|
920
922
|
globals.soundEnabled = TEMP;
|
|
921
923
|
}
|
|
924
|
+
TEMP = tiledeskSettings['keyboardSoundVolume'];
|
|
925
|
+
if (TEMP !== undefined) {
|
|
926
|
+
globals.keyboardSoundVolume = +TEMP;
|
|
927
|
+
}
|
|
928
|
+
TEMP = tiledeskSettings['keyboardSoundFile'];
|
|
929
|
+
if (TEMP !== undefined) {
|
|
930
|
+
globals.keyboardSoundFile = TEMP;
|
|
931
|
+
}
|
|
922
932
|
TEMP = tiledeskSettings['openExternalLinkButton'];
|
|
923
933
|
// this.logger.debug('[GLOBAL-SET] setVariablesFromSettings > openExternalLinkButton:: ', TEMP]);
|
|
924
934
|
if (TEMP !== undefined) {
|
|
@@ -1125,6 +1135,11 @@ export class GlobalSettingsService {
|
|
|
1125
1135
|
if (TEMP !== undefined) {
|
|
1126
1136
|
globals.showAudioRecorderFooterButton = (TEMP === true) ? true : false;
|
|
1127
1137
|
}
|
|
1138
|
+
TEMP = tiledeskSettings['showAudioStreamFooterButton'];
|
|
1139
|
+
// this.logger.debug('[GLOBAL-SET] setVariablesFromSettings > showAudioStreamFooterButton:: ', TEMP]);
|
|
1140
|
+
if (TEMP !== undefined) {
|
|
1141
|
+
globals.showAudioStreamFooterButton = (TEMP === true) ? true : false;
|
|
1142
|
+
}
|
|
1128
1143
|
TEMP = tiledeskSettings['size'];
|
|
1129
1144
|
// this.logger.debug('[GLOBAL-SET] setVariablesFromSettings > size:: ', TEMP]);
|
|
1130
1145
|
if (TEMP !== undefined) {
|
|
@@ -1297,6 +1312,14 @@ export class GlobalSettingsService {
|
|
|
1297
1312
|
if (TEMP !== null) {
|
|
1298
1313
|
this.globals.soundEnabled = TEMP;
|
|
1299
1314
|
}
|
|
1315
|
+
TEMP = el.nativeElement.getAttribute('keyboardSoundVolume');
|
|
1316
|
+
if (TEMP !== null) {
|
|
1317
|
+
this.globals.keyboardSoundVolume = +TEMP;
|
|
1318
|
+
}
|
|
1319
|
+
TEMP = el.nativeElement.getAttribute('keyboardSoundFile');
|
|
1320
|
+
if (TEMP !== null) {
|
|
1321
|
+
this.globals.keyboardSoundFile = TEMP;
|
|
1322
|
+
}
|
|
1300
1323
|
TEMP = el.nativeElement.getAttribute('openExternalLinkButton');
|
|
1301
1324
|
if (TEMP !== null) {
|
|
1302
1325
|
this.globals.openExternalLinkButton = TEMP;
|
|
@@ -1696,6 +1719,16 @@ export class GlobalSettingsService {
|
|
|
1696
1719
|
globals.soundEnabled = stringToBoolean(TEMP);
|
|
1697
1720
|
}
|
|
1698
1721
|
|
|
1722
|
+
TEMP = getParameterByName(windowContext, 'tiledesk_keyboardSoundVolume');
|
|
1723
|
+
if (TEMP) {
|
|
1724
|
+
globals.keyboardSoundVolume = +TEMP;
|
|
1725
|
+
}
|
|
1726
|
+
|
|
1727
|
+
TEMP = getParameterByName(windowContext, 'tiledesk_keyboardSoundFile');
|
|
1728
|
+
if (TEMP) {
|
|
1729
|
+
globals.keyboardSoundFile = TEMP;
|
|
1730
|
+
}
|
|
1731
|
+
|
|
1699
1732
|
TEMP = getParameterByName(windowContext, 'tiledesk_openExternalLinkButton');
|
|
1700
1733
|
if (TEMP) {
|
|
1701
1734
|
globals.openExternalLinkButton = stringToBoolean(TEMP);
|
|
@@ -1867,6 +1900,11 @@ export class GlobalSettingsService {
|
|
|
1867
1900
|
globals.showAttachmentFooterButton = stringToBoolean(TEMP);
|
|
1868
1901
|
}
|
|
1869
1902
|
|
|
1903
|
+
TEMP = getParameterByName(windowContext, 'tiledesk_showAudioStreamFooterButton');
|
|
1904
|
+
if (TEMP) {
|
|
1905
|
+
globals.showAudioStreamFooterButton = stringToBoolean(TEMP);
|
|
1906
|
+
}
|
|
1907
|
+
|
|
1870
1908
|
TEMP = getParameterByName(windowContext, 'tiledesk_showEmojiFooterButton');
|
|
1871
1909
|
if (TEMP) {
|
|
1872
1910
|
globals.showEmojiFooterButton = stringToBoolean(TEMP);
|
|
@@ -5,9 +5,17 @@ import { extractUrlsFromText } from 'src/app/utils/url-utils';
|
|
|
5
5
|
import { JsonSourceItem } from 'src/app/component/message/json-sources/json-sources.component';
|
|
6
6
|
import { mergeJsonSourcesMissingFields } from 'src/app/utils/json-sources-utils';
|
|
7
7
|
|
|
8
|
+
export type UrlPreviewDisplayFields = {
|
|
9
|
+
title?: boolean;
|
|
10
|
+
description?: boolean;
|
|
11
|
+
image?: boolean;
|
|
12
|
+
};
|
|
13
|
+
|
|
8
14
|
export type UrlPreviewMessage = {
|
|
9
15
|
type?: string; // "url_preview"
|
|
10
16
|
text?: string;
|
|
17
|
+
displayFields?: UrlPreviewDisplayFields;
|
|
18
|
+
previewBackgroundColor?: string;
|
|
11
19
|
};
|
|
12
20
|
|
|
13
21
|
/**
|
|
@@ -79,7 +87,11 @@ export class JsonSourcesParserService {
|
|
|
79
87
|
return this.enrichSources(base);
|
|
80
88
|
}
|
|
81
89
|
|
|
82
|
-
|
|
90
|
+
/**
|
|
91
|
+
* Public: lets callers (UI components) read the raw `url_preview` payload to
|
|
92
|
+
* extract presentation options like `displayFields` or `previewBackgroundColor`.
|
|
93
|
+
*/
|
|
94
|
+
getUrlPreviewPayload(messageLike?: any): UrlPreviewMessage | null {
|
|
83
95
|
if (!messageLike) return null;
|
|
84
96
|
const candidates: any[] = [
|
|
85
97
|
messageLike,
|
|
@@ -234,12 +234,27 @@ export class TranslatorService {
|
|
|
234
234
|
this._translate.use(lang);
|
|
235
235
|
this.logger.debug(`[TRANSLATOR-SERV] »»»» initI18n - »»» loadRemoteTranslations ?`, environment.loadRemoteTranslations);
|
|
236
236
|
this._translate.setTranslation(lang, data, true);
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
237
|
+
this.syncDocumentLang(lang);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Synchronize the document `<html lang="...">` attribute with the active i18n language
|
|
242
|
+
* (WCAG 3.1.1 Language of Page, EN 301 549 § 9.3.1.1).
|
|
243
|
+
*
|
|
244
|
+
* The widget runs inside its own iframe, so we update the document of that iframe.
|
|
245
|
+
* If the widget is also embedded in a parent page (Tiledesk launcher), the parent
|
|
246
|
+
* page already declares its own `lang`, which we intentionally leave untouched.
|
|
247
|
+
*/
|
|
248
|
+
private syncDocumentLang(lang: string) {
|
|
249
|
+
if (!lang) { return; }
|
|
250
|
+
const normalized = lang.toLowerCase().substring(0, 2);
|
|
251
|
+
try {
|
|
252
|
+
if (typeof document !== 'undefined' && document.documentElement) {
|
|
253
|
+
document.documentElement.setAttribute('lang', normalized);
|
|
254
|
+
}
|
|
255
|
+
} catch (e) {
|
|
256
|
+
this.logger.warn('[TRANSLATOR-SERV] syncDocumentLang error', e);
|
|
257
|
+
}
|
|
243
258
|
}
|
|
244
259
|
|
|
245
260
|
/** */
|
|
@@ -302,7 +317,8 @@ export class TranslatorService {
|
|
|
302
317
|
'CLOSED',
|
|
303
318
|
'LABEL_PREVIEW',
|
|
304
319
|
'MAX_ATTACHMENT',
|
|
305
|
-
'EMOJI'
|
|
320
|
+
'EMOJI',
|
|
321
|
+
'BUTTON_OPEN_CHAT'
|
|
306
322
|
];
|
|
307
323
|
|
|
308
324
|
|
|
@@ -359,6 +375,7 @@ export class TranslatorService {
|
|
|
359
375
|
globals.LABEL_ERROR_FIELD_REQUIRED= res['LABEL_ERROR_FIELD_REQUIRED']
|
|
360
376
|
globals.MAX_ATTACHMENT = res['MAX_ATTACHMENT']
|
|
361
377
|
globals.EMOJI = res['EMOJI']
|
|
378
|
+
globals.BUTTON_OPEN_CHAT = res['BUTTON_OPEN_CHAT']
|
|
362
379
|
|
|
363
380
|
|
|
364
381
|
if(globals.WELCOME_TITLE === 'WELLCOME_TITLE') globals.WELCOME_TITLE = res['WELCOME_TITLE']
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { TtsAudioPlaybackCoordinator } from './tts-audio-playback-coordinator.service';
|
|
2
|
+
|
|
3
|
+
describe('TtsAudioPlaybackCoordinator', () => {
|
|
4
|
+
let coordinator: TtsAudioPlaybackCoordinator;
|
|
5
|
+
|
|
6
|
+
beforeEach(() => {
|
|
7
|
+
coordinator = new TtsAudioPlaybackCoordinator();
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
// ── Basic lifecycle ───────────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
it('should start playing immediately when nothing is active', () => {
|
|
13
|
+
const start = jasmine.createSpy('start');
|
|
14
|
+
coordinator.requestStart('msg-1', start);
|
|
15
|
+
expect(start).toHaveBeenCalledTimes(1);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('isTTSPlaying$ should be true while playing and false after release', () => {
|
|
19
|
+
const states: boolean[] = [];
|
|
20
|
+
coordinator.isTTSPlaying$.subscribe((v) => states.push(v));
|
|
21
|
+
|
|
22
|
+
coordinator.requestStart('msg-1', () => {});
|
|
23
|
+
coordinator.releaseIfCurrent('msg-1');
|
|
24
|
+
|
|
25
|
+
expect(states).toEqual([false, true, false]);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('stopAll clears the queue, sets playing=false, and emits stopAllPlayback$', () => {
|
|
29
|
+
const stopAllFired: void[] = [];
|
|
30
|
+
coordinator.stopAllPlayback$.subscribe(() => stopAllFired.push(undefined));
|
|
31
|
+
|
|
32
|
+
coordinator.requestStart('msg-1', () => {});
|
|
33
|
+
coordinator.stopAll();
|
|
34
|
+
|
|
35
|
+
let playing = true;
|
|
36
|
+
coordinator.isTTSPlaying$.subscribe((v) => (playing = v));
|
|
37
|
+
expect(playing).toBe(false);
|
|
38
|
+
expect(stopAllFired.length).toBe(1);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
// ── Preemption tests (SPEC-002) ───────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
it('requestStart while playing preempts old owner: new start() is called immediately', () => {
|
|
44
|
+
const start1 = jasmine.createSpy('start1');
|
|
45
|
+
const start2 = jasmine.createSpy('start2');
|
|
46
|
+
|
|
47
|
+
coordinator.requestStart('msg-1', start1);
|
|
48
|
+
coordinator.requestStart('msg-2', start2);
|
|
49
|
+
|
|
50
|
+
expect(start1).toHaveBeenCalledTimes(1);
|
|
51
|
+
expect(start2).toHaveBeenCalledTimes(1); // started immediately, not queued
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('preemptPlayback$ emits evicted ownerId only (not the new owner)', () => {
|
|
55
|
+
const preempted: string[] = [];
|
|
56
|
+
coordinator.preemptPlayback$.subscribe((id) => preempted.push(id));
|
|
57
|
+
|
|
58
|
+
coordinator.requestStart('msg-1', () => {});
|
|
59
|
+
coordinator.requestStart('msg-2', () => {}); // preempts msg-1
|
|
60
|
+
|
|
61
|
+
expect(preempted).toEqual(['msg-1']);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('preemptPlayback$ does NOT emit the new owner id', () => {
|
|
65
|
+
const preempted: string[] = [];
|
|
66
|
+
coordinator.preemptPlayback$.subscribe((id) => preempted.push(id));
|
|
67
|
+
|
|
68
|
+
coordinator.requestStart('msg-1', () => {});
|
|
69
|
+
coordinator.requestStart('msg-2', () => {});
|
|
70
|
+
|
|
71
|
+
expect(preempted).not.toContain('msg-2');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('isTTSPlaying$ stays true after preemption until new owner releases', () => {
|
|
75
|
+
const states: boolean[] = [];
|
|
76
|
+
coordinator.isTTSPlaying$.subscribe((v) => states.push(v));
|
|
77
|
+
|
|
78
|
+
coordinator.requestStart('msg-1', () => {}); // true
|
|
79
|
+
coordinator.requestStart('msg-2', () => {}); // still true (preemption, new owner active)
|
|
80
|
+
coordinator.releaseIfCurrent('msg-2'); // false
|
|
81
|
+
|
|
82
|
+
expect(states).toEqual([false, true, false]);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('releaseIfCurrent for an evicted owner is a no-op', () => {
|
|
86
|
+
const states: boolean[] = [];
|
|
87
|
+
coordinator.isTTSPlaying$.subscribe((v) => states.push(v));
|
|
88
|
+
|
|
89
|
+
coordinator.requestStart('msg-1', () => {});
|
|
90
|
+
coordinator.requestStart('msg-2', () => {}); // msg-1 evicted
|
|
91
|
+
|
|
92
|
+
// Old owner calls release after being preempted — should not affect playing state
|
|
93
|
+
coordinator.releaseIfCurrent('msg-1');
|
|
94
|
+
|
|
95
|
+
expect(states).toEqual([false, true]); // no extra false emission
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('chain of preemptions: each new requestStart immediately evicts the current owner', () => {
|
|
99
|
+
const preempted: string[] = [];
|
|
100
|
+
coordinator.preemptPlayback$.subscribe((id) => preempted.push(id));
|
|
101
|
+
|
|
102
|
+
coordinator.requestStart('msg-1', () => {});
|
|
103
|
+
coordinator.requestStart('msg-2', () => {});
|
|
104
|
+
coordinator.requestStart('msg-3', () => {});
|
|
105
|
+
|
|
106
|
+
expect(preempted).toEqual(['msg-1', 'msg-2']);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('requestStart is idempotent for the current owner', () => {
|
|
110
|
+
const start = jasmine.createSpy('start');
|
|
111
|
+
coordinator.requestStart('msg-1', start);
|
|
112
|
+
coordinator.requestStart('msg-1', start); // same owner — should be ignored
|
|
113
|
+
|
|
114
|
+
expect(start).toHaveBeenCalledTimes(1);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { Injectable } from '@angular/core';
|
|
2
|
+
import { BehaviorSubject, Observable, Subject } from 'rxjs';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Garantisce un solo messaggio TTS in riproduzione alla volta.
|
|
6
|
+
* Quando arriva un nuovo messaggio TTS mentre un altro è in corso, quello vecchio viene
|
|
7
|
+
* interrotto immediatamente (preemption) e il nuovo parte subito.
|
|
8
|
+
*/
|
|
9
|
+
@Injectable({ providedIn: 'root' })
|
|
10
|
+
export class TtsAudioPlaybackCoordinator {
|
|
11
|
+
private currentOwnerId: string | null = null;
|
|
12
|
+
private readonly queue: Array<{ ownerId: string; start: () => void }> = [];
|
|
13
|
+
|
|
14
|
+
private readonly cancelAllSource = new Subject<void>();
|
|
15
|
+
/** Emesso quando la riproduzione TTS va interrotta globalmente (es. l’utente parla al microfono). */
|
|
16
|
+
readonly cancelAll$: Observable<void> = this.cancelAllSource.asObservable();
|
|
17
|
+
|
|
18
|
+
/** Emits true while any TTS is playing or queued; false when the queue is fully drained. */
|
|
19
|
+
private readonly _isTTSPlaying$ = new BehaviorSubject<boolean>(false);
|
|
20
|
+
readonly isTTSPlaying$ = this._isTTSPlaying$.asObservable();
|
|
21
|
+
|
|
22
|
+
/** Emits once when stopAll() is called — signals every AudioSyncComponent to abort immediately. */
|
|
23
|
+
private readonly _stopAll$ = new Subject<void>();
|
|
24
|
+
readonly stopAllPlayback$: Observable<void> = this._stopAll$.asObservable();
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Emits the ownerId of the component being preempted (stopped mid-playback by a newer message).
|
|
28
|
+
* Only the component whose ownerId matches should react — unlike stopAll$ which targets everyone.
|
|
29
|
+
*/
|
|
30
|
+
private readonly _preemptCurrent$ = new Subject<string>();
|
|
31
|
+
readonly preemptPlayback$: Observable<string> = this._preemptCurrent$.asObservable();
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Richiede l'avvio della riproduzione TTS per `ownerId`.
|
|
35
|
+
* Se un altro TTS è già in corso, viene interrotto immediatamente (preemption) e
|
|
36
|
+
* `ownerId` parte subito. Qualsiasi coda pendente viene svuotata.
|
|
37
|
+
*/
|
|
38
|
+
requestStart(ownerId: string, start: () => void): void {
|
|
39
|
+
const id = (ownerId || '').trim();
|
|
40
|
+
if (!id) {
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
if (this.currentOwnerId === id) {
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (this.currentOwnerId) {
|
|
48
|
+
// Preempt: signal only the evicted owner to stop (not a broadcast stopAll).
|
|
49
|
+
// This avoids stopping the component that is about to start playing.
|
|
50
|
+
const evicted = this.currentOwnerId;
|
|
51
|
+
this.queue.length = 0;
|
|
52
|
+
this.currentOwnerId = null;
|
|
53
|
+
this._preemptCurrent$.next(evicted);
|
|
54
|
+
} else {
|
|
55
|
+
this.queue.length = 0;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
this.currentOwnerId = id;
|
|
59
|
+
if (!this._isTTSPlaying$.getValue()) {
|
|
60
|
+
this._isTTSPlaying$.next(true);
|
|
61
|
+
}
|
|
62
|
+
try {
|
|
63
|
+
start();
|
|
64
|
+
} catch {
|
|
65
|
+
this.releaseIfCurrent(id);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Chiamare a fine riproduzione naturale (`ended`) se questo messaggio era ancora “attivo”. */
|
|
70
|
+
releaseIfCurrent(ownerId: string): void {
|
|
71
|
+
const id = (ownerId || '').trim();
|
|
72
|
+
if (!id) {
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
if (this.currentOwnerId !== id) {
|
|
76
|
+
// Se era in coda, rimuovilo.
|
|
77
|
+
const idx = this.queue.findIndex((j) => j.ownerId === id);
|
|
78
|
+
if (idx !== -1) {
|
|
79
|
+
this.queue.splice(idx, 1);
|
|
80
|
+
}
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
this.currentOwnerId = null;
|
|
85
|
+
const next = this.queue.shift();
|
|
86
|
+
if (!next) {
|
|
87
|
+
this._isTTSPlaying$.next(false);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
this.currentOwnerId = next.ownerId;
|
|
91
|
+
try {
|
|
92
|
+
next.start();
|
|
93
|
+
} catch {
|
|
94
|
+
this.releaseIfCurrent(next.ownerId);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Distruzione componente o stop esplicito. */
|
|
99
|
+
release(ownerId: string): void {
|
|
100
|
+
this.releaseIfCurrent(ownerId);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Interrompe TUTTA la riproduzione TTS (corrente + coda) e notifica i componenti.
|
|
105
|
+
* I componenti devono fermare l’audio e mostrare il testo per intero.
|
|
106
|
+
*/
|
|
107
|
+
cancelAll(): void {
|
|
108
|
+
this.stopAll();
|
|
109
|
+
this.cancelAllSource.next();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Stops all TTS playback immediately and clears the queue.
|
|
114
|
+
* Broadcasts on stopAllPlayback$ so every AudioSyncComponent can abort its stream and reveal all text.
|
|
115
|
+
*/
|
|
116
|
+
stopAll(): void {
|
|
117
|
+
this.queue.length = 0;
|
|
118
|
+
this.currentOwnerId = null;
|
|
119
|
+
this._isTTSPlaying$.next(false);
|
|
120
|
+
this._stopAll$.next();
|
|
121
|
+
}
|
|
122
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configurazione opzionale per i servizi voce OpenAI (da `environment` o runtime).
|
|
3
|
+
*/
|
|
4
|
+
export interface OpenAiVoiceEnvironmentConfig {
|
|
5
|
+
/** Obbligatoria per chiamate API reali; se assente, STT/TTS non inviano richieste. */
|
|
6
|
+
apiKey?: string;
|
|
7
|
+
baseUrl?: string;
|
|
8
|
+
transcriptionModel?: string;
|
|
9
|
+
ttsModel?: string;
|
|
10
|
+
/** Voce predefinita TTS (es. `alloy`). */
|
|
11
|
+
ttsVoice?: string;
|
|
12
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { HttpClient, HttpErrorResponse, HttpHeaders } from '@angular/common/http';
|
|
2
|
+
import { Injectable } from '@angular/core';
|
|
3
|
+
import { firstValueFrom } from 'rxjs';
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
SpeechToTextProvider,
|
|
7
|
+
TextToSpeechProvider,
|
|
8
|
+
type SpeechToTextRequest,
|
|
9
|
+
type SpeechToTextResult,
|
|
10
|
+
type TextToSpeechRequest,
|
|
11
|
+
type TextToSpeechResult,
|
|
12
|
+
} from './speech-provider.abstract';
|
|
13
|
+
import { AppConfigService } from '../../app-config.service';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Routes STT and TTS calls through the tiledesk-speech-proxy.
|
|
17
|
+
*
|
|
18
|
+
* STT: POST <proxyBase>/api/stt — multipart/form-data, field "audio"
|
|
19
|
+
* TTS: POST <proxyBase>/api/tts — JSON body { text, ... }
|
|
20
|
+
*/
|
|
21
|
+
@Injectable({ providedIn: 'root' })
|
|
22
|
+
export class OpenAiVoiceProviderService extends SpeechToTextProvider implements TextToSpeechProvider {
|
|
23
|
+
constructor(
|
|
24
|
+
private readonly httpClient: HttpClient,
|
|
25
|
+
private readonly appConfig: AppConfigService,
|
|
26
|
+
) {
|
|
27
|
+
super();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async transcribe(request: SpeechToTextRequest): Promise<SpeechToTextResult> {
|
|
31
|
+
const proxyBase = this.proxyBase();
|
|
32
|
+
if (!proxyBase) {
|
|
33
|
+
return { text: '' };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const url = `${proxyBase}/api/stt`;
|
|
37
|
+
const ext = this.extensionForMime(request.mimeType);
|
|
38
|
+
const file = new File([request.audio], `segment.${ext}`, { type: request.mimeType });
|
|
39
|
+
|
|
40
|
+
const form = new FormData();
|
|
41
|
+
form.append('audio', file);
|
|
42
|
+
if (request.language) {
|
|
43
|
+
form.append('language', request.language);
|
|
44
|
+
}
|
|
45
|
+
const projectId = String(this.appConfig.g?.projectid ?? '').trim();
|
|
46
|
+
if (projectId) form.append('projectId', projectId);
|
|
47
|
+
const requestId = this.parseRequestId(this.appConfig.g?.recipientId ?? '');
|
|
48
|
+
if (requestId) {
|
|
49
|
+
form.append('requestId', requestId);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const headers = new HttpHeaders({ Authorization: this.authHeader() });
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
const data = await firstValueFrom(
|
|
56
|
+
this.httpClient.post<{ transcript?: string }>(url, form, { headers }),
|
|
57
|
+
);
|
|
58
|
+
return { text: (data.transcript ?? '').trim() };
|
|
59
|
+
} catch (e) {
|
|
60
|
+
if (e instanceof HttpErrorResponse && e.error instanceof Blob) {
|
|
61
|
+
const errText = await e.error.text();
|
|
62
|
+
throw new Error(`Speech proxy STT ${e.status}: ${errText || e.statusText}`);
|
|
63
|
+
}
|
|
64
|
+
throw this.mapHttpError('Speech proxy STT', e);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async synthesize(request: TextToSpeechRequest): Promise<TextToSpeechResult> {
|
|
69
|
+
const proxyBase = this.proxyBase();
|
|
70
|
+
if (!proxyBase) {
|
|
71
|
+
throw new Error('voiceProxyApiBaseUrl not configured');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const url = `${proxyBase}/api/tts`;
|
|
75
|
+
const body: Record<string, unknown> = { text: request.text };
|
|
76
|
+
if (request.language) body['language'] = request.language;
|
|
77
|
+
if (request.voice) body['voiceId'] = request.voice;
|
|
78
|
+
if (request.responseFormat) body['outputFormat'] = request.responseFormat;
|
|
79
|
+
const projectId = String(this.appConfig.g?.projectid ?? '').trim();
|
|
80
|
+
if (projectId) body['projectId'] = projectId;
|
|
81
|
+
const requestId = this.parseRequestId(this.appConfig.g?.recipientId ?? '');
|
|
82
|
+
if (requestId) body['requestId'] = requestId;
|
|
83
|
+
|
|
84
|
+
const headers = new HttpHeaders({
|
|
85
|
+
Authorization: this.authHeader(),
|
|
86
|
+
'Content-Type': 'application/json',
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
const blob = await firstValueFrom(
|
|
91
|
+
this.httpClient.post(url, body, { headers, responseType: 'blob' }),
|
|
92
|
+
);
|
|
93
|
+
return { audio: blob, mimeType: this.mimeForFormat(request.responseFormat ?? 'mp3') };
|
|
94
|
+
} catch (e) {
|
|
95
|
+
if (e instanceof HttpErrorResponse && e.error instanceof Blob) {
|
|
96
|
+
const errText = await e.error.text();
|
|
97
|
+
throw new Error(`Speech proxy TTS ${e.status}: ${errText || e.statusText}`);
|
|
98
|
+
}
|
|
99
|
+
throw this.mapHttpError('Speech proxy TTS', e);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
private proxyBase(): string | null {
|
|
104
|
+
const base = String(this.appConfig.getConfig()?.voiceProxyApiBaseUrl ?? '').trim();
|
|
105
|
+
return base ? base.replace(/\/$/, '') : null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Returns `JWT <rawToken>`, stripping any existing prefix first. */
|
|
109
|
+
private authHeader(): string {
|
|
110
|
+
const raw = (
|
|
111
|
+
String(this.appConfig.g?.tiledeskToken ?? this.appConfig.g?.jwt ?? '').trim()
|
|
112
|
+
).replace(/^(JWT|Bearer)\s+/i, '').trim();
|
|
113
|
+
return raw ? `JWT ${raw}` : '';
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Extracts the Tiledesk requestId from a Chat21 recipient string.
|
|
118
|
+
* Format: `support-group-<projectId>-<requestId>`
|
|
119
|
+
*/
|
|
120
|
+
private parseRequestId(recipient: string): string | null {
|
|
121
|
+
const parts = recipient.split('-');
|
|
122
|
+
if (parts.length < 4) return null;
|
|
123
|
+
return parts.slice(3).join('-') || null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
private mapHttpError(label: string, e: unknown): Error {
|
|
127
|
+
if (!(e instanceof HttpErrorResponse)) {
|
|
128
|
+
return e instanceof Error ? e : new Error(String(e));
|
|
129
|
+
}
|
|
130
|
+
if (typeof e.error === 'object' && e.error !== null && 'error' in e.error) {
|
|
131
|
+
const err = (e.error as { error?: { message?: string } }).error;
|
|
132
|
+
return new Error(`${label} ${e.status}: ${err?.message ?? JSON.stringify(e.error)}`);
|
|
133
|
+
}
|
|
134
|
+
if (typeof e.error === 'string') {
|
|
135
|
+
return new Error(`${label} ${e.status}: ${e.error}`);
|
|
136
|
+
}
|
|
137
|
+
return new Error(`${label} ${e.status}: ${e.message || e.statusText}`);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
private extensionForMime(mime: string): string {
|
|
141
|
+
if (mime.includes('webm')) return 'webm';
|
|
142
|
+
if (mime.includes('mp4') || mime.includes('m4a')) return 'm4a';
|
|
143
|
+
if (mime.includes('wav')) return 'wav';
|
|
144
|
+
if (mime.includes('mpeg') || mime.includes('mp3')) return 'mp3';
|
|
145
|
+
return 'webm';
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
private mimeForFormat(fmt: string): string {
|
|
149
|
+
switch (fmt) {
|
|
150
|
+
case 'opus': return 'audio/opus';
|
|
151
|
+
case 'aac': return 'audio/aac';
|
|
152
|
+
case 'flac': return 'audio/flac';
|
|
153
|
+
default: return 'audio/mpeg';
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Contratti astratti per Speech-to-Text e Text-to-Speech.
|
|
3
|
+
* Implementazione OpenAI unificata: `OpenAiVoiceProviderService` (`openai-voice.provider.ts`).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** Input per la trascrizione di un segmento audio. */
|
|
7
|
+
export interface SpeechToTextRequest {
|
|
8
|
+
audio: Blob;
|
|
9
|
+
mimeType: string;
|
|
10
|
+
/** ISO 639-1 opzionale (es. `it`, `en`). */
|
|
11
|
+
language?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface SpeechToTextResult {
|
|
15
|
+
text: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Input per la sintesi vocale. */
|
|
19
|
+
export interface TextToSpeechRequest {
|
|
20
|
+
text: string;
|
|
21
|
+
/** Voce provider-specific (es. OpenAI: `alloy`, `echo`, …). */
|
|
22
|
+
voice?: string;
|
|
23
|
+
language?: string;
|
|
24
|
+
/** Formato audio desiderato (dipende dal provider). */
|
|
25
|
+
responseFormat?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface TextToSpeechResult {
|
|
29
|
+
audio: Blob;
|
|
30
|
+
mimeType: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export abstract class SpeechToTextProvider {
|
|
34
|
+
abstract transcribe(request: SpeechToTextRequest): Promise<SpeechToTextResult>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export abstract class TextToSpeechProvider {
|
|
38
|
+
abstract synthesize(request: TextToSpeechRequest): Promise<TextToSpeechResult>;
|
|
39
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tipi condivisi per cattura microfono, VAD e registrazione (WebM).
|
|
3
|
+
*/
|
|
4
|
+
import type { VoiceStreamingSessionConfig } from './voice-streaming.types';
|
|
5
|
+
|
|
6
|
+
export const DEFAULT_VOICE_AUDIO_CONSTRAINTS: MediaTrackConstraints = {
|
|
7
|
+
echoCancellation: true,
|
|
8
|
+
noiseSuppression: true,
|
|
9
|
+
autoGainControl: true,
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export const DEFAULT_VOICE_MEDIA_STREAM_CONSTRAINTS: MediaStreamConstraints = {
|
|
13
|
+
audio: DEFAULT_VOICE_AUDIO_CONSTRAINTS,
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export interface VoiceRecordedBlob {
|
|
17
|
+
blob: Blob;
|
|
18
|
+
mimeType: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Segmento audio dopo VAD; può includere `transcript` se STT è configurato e abilitato.
|
|
23
|
+
*/
|
|
24
|
+
export interface VoiceSegmentPayload extends VoiceRecordedBlob {
|
|
25
|
+
transcript?: string;
|
|
26
|
+
transcriptionError?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface VoiceSessionStartOptions {
|
|
30
|
+
/** Opzionale se usi solo {@link VoiceService.audioSegment$}. */
|
|
31
|
+
onRecordingComplete?: (result: VoiceSegmentPayload) => void;
|
|
32
|
+
constraints?: MediaStreamConstraints;
|
|
33
|
+
/** Default `true`. Se `false`, non viene chiamato lo STT sul segmento. */
|
|
34
|
+
enableTranscription?: boolean;
|
|
35
|
+
/**
|
|
36
|
+
* Con `voiceIngressStream`: solo streaming WSS — niente VAD locale; transcript e TTS dal server.
|
|
37
|
+
* Senza: MicVAD + segmenti e upload/STT lato client.
|
|
38
|
+
*/
|
|
39
|
+
voiceIngressStream?: VoiceStreamingSessionConfig | null;
|
|
40
|
+
}
|