@chat21/chat21-web-widget 5.1.32-rc4 → 5.1.33
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/.github/workflows/docker-community-push-latest.yml +13 -23
- package/.github/workflows/docker-image-tag-community-tag-push.yml +12 -22
- package/CHANGELOG.md +7 -50
- package/Dockerfile +5 -4
- package/angular.json +2 -5
- package/deploy_amazon_beta.sh +7 -17
- package/deploy_amazon_prod.sh +41 -0
- package/docs/changelog/this-branch.md +0 -36
- package/nginx.conf +2 -22
- package/package.json +1 -4
- package/src/app/app.component.ts +9 -10
- package/src/app/app.module.ts +0 -9
- package/src/app/component/conversation-detail/conversation/conversation.component.html +2 -8
- package/src/app/component/conversation-detail/conversation/conversation.component.scss +2 -12
- package/src/app/component/conversation-detail/conversation/conversation.component.ts +5 -45
- package/src/app/component/conversation-detail/conversation-content/conversation-content.component.html +1 -1
- package/src/app/component/conversation-detail/conversation-content/conversation-content.component.scss +1 -10
- package/src/app/component/conversation-detail/conversation-content/conversation-content.component.ts +0 -1
- package/src/app/component/conversation-detail/conversation-footer/conversation-footer.component.html +79 -146
- package/src/app/component/conversation-detail/conversation-footer/conversation-footer.component.scss +13 -140
- package/src/app/component/conversation-detail/conversation-footer/conversation-footer.component.ts +7 -124
- package/src/app/component/last-message/last-message.component.ts +1 -4
- package/src/app/component/message/audio/audio.component.ts +5 -0
- package/src/app/component/message/bubble-message/bubble-message.component.html +1 -6
- package/src/app/component/message/bubble-message/bubble-message.component.ts +1 -2
- package/src/app/providers/global-settings.service.ts +0 -21
- package/src/app/providers/translator.service.ts +0 -2
- package/src/app/sass/_variables.scss +0 -3
- package/src/app/utils/globals.ts +1 -7
- package/src/assets/i18n/en.json +0 -1
- package/src/assets/i18n/es.json +0 -1
- package/src/assets/i18n/fr.json +0 -1
- package/src/assets/i18n/it.json +0 -1
- package/src/chat21-core/models/message.ts +1 -2
- package/src/chat21-core/providers/firebase/firebase-conversation-handler.ts +2 -3
- package/src/chat21-core/providers/mqtt/mqtt-conversation-handler.ts +0 -12
- package/src/chat21-core/providers/tiledesk/tiledesk-requests.service.ts +1 -1
- package/src/chat21-core/utils/utils-message.ts +0 -7
- package/src/chat21-core/utils/utils.ts +2 -5
- package/src/launch.js +41 -32
- package/src/launch_template.js +41 -32
- package/tsconfig.json +0 -5
- package/src/app/component/message/audio-sync/audio-sync.component.html +0 -19
- package/src/app/component/message/audio-sync/audio-sync.component.scss +0 -65
- package/src/app/component/message/audio-sync/audio-sync.component.spec.ts +0 -23
- package/src/app/component/message/audio-sync/audio-sync.component.ts +0 -197
- package/src/app/providers/voice/STT&TTS/openai-voice.config.ts +0 -12
- package/src/app/providers/voice/STT&TTS/openai-voice.provider.ts +0 -171
- package/src/app/providers/voice/STT&TTS/speech-provider.abstract.ts +0 -39
- package/src/app/providers/voice/audio.types.ts +0 -34
- package/src/app/providers/voice/vad.service.spec.ts +0 -28
- package/src/app/providers/voice/vad.service.ts +0 -70
- package/src/app/providers/voice/voice.service.spec.ts +0 -60
- package/src/app/providers/voice/voice.service.ts +0 -294
- package/src/app/shims/onnxruntime-web-wasm.ts +0 -4
- package/src/assets/onnx/ort-wasm-simd-threaded.mjs +0 -59
- package/src/assets/onnx/ort-wasm-simd-threaded.wasm +0 -0
- package/src/assets/vad/silero_vad_legacy.onnx +0 -0
- package/src/assets/vad/vad.worklet.bundle.min.js +0 -1
package/src/launch_template.js
CHANGED
|
@@ -219,67 +219,76 @@ function loadIframe(tiledeskScriptBaseLocation) {
|
|
|
219
219
|
iDiv.appendChild(ifrm);
|
|
220
220
|
|
|
221
221
|
// Funzione helper per caricare iframe con fallback per compatibilità CSP (Wix, etc.)
|
|
222
|
-
//
|
|
223
|
-
|
|
224
|
-
|
|
222
|
+
// Priorità: document.write / srcdoc prima della Blob URL. Le Blob URL spesso danno origine opaca
|
|
223
|
+
// (blob:null): l'iframe non può leggere window.parent.tiledeskSettings → projectid mancante.
|
|
224
|
+
function loadIframeContent(iframe, htmlContent) {
|
|
225
225
|
var blobUrl = null;
|
|
226
|
-
|
|
227
|
-
//
|
|
228
|
-
|
|
226
|
+
|
|
227
|
+
// 1) document.write: iframe stessa origine della pagina host → tiledeskSettings sul parent accessibile
|
|
228
|
+
try {
|
|
229
|
+
var cw = iframe.contentWindow;
|
|
230
|
+
if (cw && cw.document) {
|
|
231
|
+
cw.document.open();
|
|
232
|
+
cw.document.write(htmlContent);
|
|
233
|
+
cw.document.close();
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
} catch (e) {
|
|
237
|
+
console.warn('[Tiledesk] iframe document.write failed, trying srcdoc/blob:', e);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// 2) srcdoc: stessa origine del parent (HTML5); utile se document.write è bloccato
|
|
241
|
+
if ('srcdoc' in iframe) {
|
|
242
|
+
try {
|
|
243
|
+
iframe.srcdoc = htmlContent;
|
|
244
|
+
return;
|
|
245
|
+
} catch (e) {
|
|
246
|
+
console.warn('[Tiledesk] iframe srcdoc failed, trying blob:', e);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// 3) Blob URL (spesso permesso da CSP dove srcdoc/write no; può rompere lettura parent.tiledeskSettings)
|
|
229
251
|
if (typeof Blob !== 'undefined' && typeof URL !== 'undefined' && URL.createObjectURL) {
|
|
230
252
|
try {
|
|
231
253
|
var blob = new Blob([htmlContent], { type: 'text/html;charset=utf-8' });
|
|
232
254
|
blobUrl = URL.createObjectURL(blob);
|
|
233
255
|
iframe.src = blobUrl;
|
|
234
|
-
|
|
235
|
-
// Cleanup del blob URL dopo il caricamento per liberare memoria
|
|
256
|
+
|
|
236
257
|
var originalOnload = iframe.onload;
|
|
237
258
|
iframe.onload = function() {
|
|
238
|
-
// Revoca il blob URL dopo un delay per assicurarsi che tutto sia caricato
|
|
239
259
|
setTimeout(function() {
|
|
240
260
|
if (blobUrl) {
|
|
241
261
|
try {
|
|
242
262
|
URL.revokeObjectURL(blobUrl);
|
|
243
263
|
blobUrl = null;
|
|
244
|
-
} catch(
|
|
245
|
-
console.warn('Error revoking blob URL:',
|
|
264
|
+
} catch (err) {
|
|
265
|
+
console.warn('Error revoking blob URL:', err);
|
|
246
266
|
}
|
|
247
267
|
}
|
|
248
268
|
}, 1000);
|
|
249
269
|
if (originalOnload) originalOnload.call(this);
|
|
250
270
|
};
|
|
251
|
-
return;
|
|
252
|
-
} catch(e) {
|
|
253
|
-
console.warn('Blob URL not available
|
|
254
|
-
}
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
// Metodo 2: srcdoc (fallback se Blob URL non disponibile)
|
|
258
|
-
// Skip per localhost (usa document.write per compatibilità sviluppo)
|
|
259
|
-
if (!isLocalhost && 'srcdoc' in iframe) {
|
|
260
|
-
try {
|
|
261
|
-
iframe.srcdoc = htmlContent;
|
|
262
|
-
return; // srcdoc impostato
|
|
263
|
-
} catch(e) {
|
|
264
|
-
console.warn('srcdoc not allowed, trying document.write:', e);
|
|
271
|
+
return;
|
|
272
|
+
} catch (e) {
|
|
273
|
+
console.warn('Blob URL not available:', e);
|
|
265
274
|
}
|
|
266
275
|
}
|
|
267
|
-
|
|
268
|
-
//
|
|
269
|
-
if (
|
|
276
|
+
|
|
277
|
+
// 4) Ultimo tentativo document.write (iframe magari non pronto al primo passo)
|
|
278
|
+
if (iframe.contentWindow && iframe.contentWindow.document) {
|
|
270
279
|
try {
|
|
271
280
|
iframe.contentWindow.document.open();
|
|
272
281
|
iframe.contentWindow.document.write(htmlContent);
|
|
273
282
|
iframe.contentWindow.document.close();
|
|
274
|
-
return;
|
|
275
|
-
} catch(e) {
|
|
276
|
-
console.error('All iframe loading methods failed:', e);
|
|
283
|
+
return;
|
|
284
|
+
} catch (e) {
|
|
285
|
+
console.error('[Tiledesk] All iframe loading methods failed:', e);
|
|
277
286
|
}
|
|
278
287
|
}
|
|
279
288
|
}
|
|
280
289
|
|
|
281
290
|
// Carica il contenuto dell'iframe con fallback automatico
|
|
282
|
-
loadIframeContent(ifrm, srcTileDesk
|
|
291
|
+
loadIframeContent(ifrm, srcTileDesk);
|
|
283
292
|
|
|
284
293
|
|
|
285
294
|
}
|
package/tsconfig.json
CHANGED
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
<div class="lyrics-container">
|
|
2
|
-
|
|
3
|
-
<audio
|
|
4
|
-
#audioPlayer
|
|
5
|
-
[src]="message?.metadata?.src"
|
|
6
|
-
(timeupdate)="onTimeUpdate()"
|
|
7
|
-
style="display:none">
|
|
8
|
-
</audio>
|
|
9
|
-
|
|
10
|
-
<p class="lyrics message_innerhtml marked" #transcriptBox [style.color]="color">
|
|
11
|
-
<span
|
|
12
|
-
*ngFor="let w of words; let i = index; trackBy: trackByIndex"
|
|
13
|
-
class="word"
|
|
14
|
-
[ngClass]="w.state">
|
|
15
|
-
{{ w.text }}
|
|
16
|
-
</span>
|
|
17
|
-
</p>
|
|
18
|
-
|
|
19
|
-
</div>
|
|
@@ -1,65 +0,0 @@
|
|
|
1
|
-
:host {
|
|
2
|
-
display: block;
|
|
3
|
-
font-size: var(--font-size-bubble-message, 14px);
|
|
4
|
-
}
|
|
5
|
-
|
|
6
|
-
/* Allineato a text.component.scss (.message_innerhtml, p) */
|
|
7
|
-
.message_innerhtml {
|
|
8
|
-
margin: 0;
|
|
9
|
-
|
|
10
|
-
&.marked {
|
|
11
|
-
padding: 12px 16px;
|
|
12
|
-
margin-block-start: 0em !important;
|
|
13
|
-
margin-block-end: 0em !important;
|
|
14
|
-
}
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
.lyrics {
|
|
18
|
-
font-size: inherit;
|
|
19
|
-
margin: 0;
|
|
20
|
-
line-height: 1.4em;
|
|
21
|
-
font-style: normal;
|
|
22
|
-
letter-spacing: normal;
|
|
23
|
-
font-stretch: normal;
|
|
24
|
-
font-variant: normal;
|
|
25
|
-
font-weight: 300;
|
|
26
|
-
overflow: hidden;
|
|
27
|
-
|
|
28
|
-
display: flex;
|
|
29
|
-
flex-wrap: wrap;
|
|
30
|
-
gap: 6px;
|
|
31
|
-
/* Colore bubble: da [style.color] / @Input() color — ereditato dalle .word */
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/* base word */
|
|
35
|
-
.word {
|
|
36
|
-
transition:
|
|
37
|
-
transform 0.3s cubic-bezier(0.2, 0.8, 0.2, 1),
|
|
38
|
-
color 0.3s ease,
|
|
39
|
-
opacity 0.3s ease,
|
|
40
|
-
filter 0.3s ease;
|
|
41
|
-
|
|
42
|
-
will-change: transform;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/* FUTURE */
|
|
46
|
-
.word.future {
|
|
47
|
-
opacity: 0;
|
|
48
|
-
transform: scale(0.98);
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
/* PAST: stesso colore del testo bubble (@Input color sul <p>) */
|
|
52
|
-
.word.past {
|
|
53
|
-
opacity: 1;
|
|
54
|
-
color: inherit;
|
|
55
|
-
transform: scale(1);
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/* ACTIVE (solo momentaneo, tipo “karaoke flash”) */
|
|
59
|
-
.word.active {
|
|
60
|
-
opacity: 1;
|
|
61
|
-
color: #00c3ff;
|
|
62
|
-
font-weight: 700;
|
|
63
|
-
transform: scale(1.18);
|
|
64
|
-
text-shadow: 0 0 10px rgba(0, 195, 255, 0.35);
|
|
65
|
-
}
|
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
|
2
|
-
|
|
3
|
-
import { AudioSyncComponent } from './audio-sync.component';
|
|
4
|
-
|
|
5
|
-
describe('AudioSyncComponent', () => {
|
|
6
|
-
let component: AudioSyncComponent;
|
|
7
|
-
let fixture: ComponentFixture<AudioSyncComponent>;
|
|
8
|
-
|
|
9
|
-
beforeEach(async () => {
|
|
10
|
-
await TestBed.configureTestingModule({
|
|
11
|
-
imports: [AudioSyncComponent]
|
|
12
|
-
})
|
|
13
|
-
.compileComponents();
|
|
14
|
-
|
|
15
|
-
fixture = TestBed.createComponent(AudioSyncComponent);
|
|
16
|
-
component = fixture.componentInstance;
|
|
17
|
-
fixture.detectChanges();
|
|
18
|
-
});
|
|
19
|
-
|
|
20
|
-
it('should create', () => {
|
|
21
|
-
expect(component).toBeTruthy();
|
|
22
|
-
});
|
|
23
|
-
});
|
|
@@ -1,197 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
AfterViewInit,
|
|
3
|
-
ChangeDetectorRef,
|
|
4
|
-
Component,
|
|
5
|
-
ElementRef,
|
|
6
|
-
Input,
|
|
7
|
-
OnChanges,
|
|
8
|
-
OnDestroy,
|
|
9
|
-
SimpleChanges,
|
|
10
|
-
ViewChild,
|
|
11
|
-
} from '@angular/core';
|
|
12
|
-
import { MessageModel } from 'src/chat21-core/models/message';
|
|
13
|
-
|
|
14
|
-
/** HAVE_METADATA: metadati già disponibili (tipico audio servito da cache). */
|
|
15
|
-
const HAVE_METADATA = 1;
|
|
16
|
-
|
|
17
|
-
@Component({
|
|
18
|
-
selector: 'chat-audio-sync',
|
|
19
|
-
templateUrl: './audio-sync.component.html',
|
|
20
|
-
styleUrl: './audio-sync.component.scss',
|
|
21
|
-
})
|
|
22
|
-
export class AudioSyncComponent implements AfterViewInit, OnChanges, OnDestroy {
|
|
23
|
-
@Input() message: MessageModel | null = null;
|
|
24
|
-
@Input() color?: string;
|
|
25
|
-
|
|
26
|
-
@ViewChild('audioPlayer') audioRef!: ElementRef<HTMLAudioElement>;
|
|
27
|
-
@ViewChild('transcriptBox') transcriptBox!: ElementRef<HTMLElement>;
|
|
28
|
-
|
|
29
|
-
words: {
|
|
30
|
-
text: string;
|
|
31
|
-
start: number;
|
|
32
|
-
end: number;
|
|
33
|
-
state: 'future' | 'active' | 'past';
|
|
34
|
-
}[] = [];
|
|
35
|
-
|
|
36
|
-
currentTime = 0;
|
|
37
|
-
duration = 1;
|
|
38
|
-
activeIndex = -1;
|
|
39
|
-
|
|
40
|
-
private timingReady = false;
|
|
41
|
-
private onMetadataLoaded: () => void;
|
|
42
|
-
private onPlaybackEnded: () => void;
|
|
43
|
-
|
|
44
|
-
constructor(private readonly cdr: ChangeDetectorRef) {}
|
|
45
|
-
|
|
46
|
-
/** `false` = messaggio già in storico: niente autoplay / karaoke. Da `message.isJustRecived`. */
|
|
47
|
-
private get skipSyncAnimation(): boolean {
|
|
48
|
-
return this.message?.isJustRecived === false;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
ngOnChanges(changes: SimpleChanges): void {
|
|
52
|
-
if (!changes['message']) {
|
|
53
|
-
return;
|
|
54
|
-
}
|
|
55
|
-
if (this.audioRef?.nativeElement && this.timingReady) {
|
|
56
|
-
this.duration = this.audioRef.nativeElement.duration || 1;
|
|
57
|
-
this.buildFakeTiming();
|
|
58
|
-
if (this.skipSyncAnimation) {
|
|
59
|
-
this.markAllWordsPast();
|
|
60
|
-
} else {
|
|
61
|
-
this.syncStatesFromCurrentTime();
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
ngAfterViewInit(): void {
|
|
67
|
-
const audio = this.audioRef.nativeElement;
|
|
68
|
-
|
|
69
|
-
this.onPlaybackEnded = () => {
|
|
70
|
-
if (this.skipSyncAnimation) {
|
|
71
|
-
return;
|
|
72
|
-
}
|
|
73
|
-
this.markAllWordsPast();
|
|
74
|
-
if (this.message) {
|
|
75
|
-
this.message.isJustRecived = false;
|
|
76
|
-
}
|
|
77
|
-
this.cdr.detectChanges();
|
|
78
|
-
};
|
|
79
|
-
|
|
80
|
-
this.onMetadataLoaded = () => {
|
|
81
|
-
if (this.timingReady) {
|
|
82
|
-
return;
|
|
83
|
-
}
|
|
84
|
-
this.timingReady = true;
|
|
85
|
-
this.duration = audio.duration || 1;
|
|
86
|
-
this.buildFakeTiming();
|
|
87
|
-
if (this.skipSyncAnimation) {
|
|
88
|
-
this.markAllWordsPast();
|
|
89
|
-
this.cdr.detectChanges();
|
|
90
|
-
return;
|
|
91
|
-
}
|
|
92
|
-
this.syncStatesFromCurrentTime();
|
|
93
|
-
this.cdr.detectChanges();
|
|
94
|
-
|
|
95
|
-
setTimeout(() => {
|
|
96
|
-
audio.play().catch(() => {
|
|
97
|
-
this.syncStatesFromCurrentTime();
|
|
98
|
-
this.cdr.detectChanges();
|
|
99
|
-
});
|
|
100
|
-
}, 200);
|
|
101
|
-
};
|
|
102
|
-
|
|
103
|
-
audio.addEventListener('loadedmetadata', this.onMetadataLoaded);
|
|
104
|
-
audio.addEventListener('ended', this.onPlaybackEnded);
|
|
105
|
-
|
|
106
|
-
if (audio.readyState >= HAVE_METADATA) {
|
|
107
|
-
this.onMetadataLoaded();
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
ngOnDestroy(): void {
|
|
112
|
-
const audio = this.audioRef?.nativeElement;
|
|
113
|
-
if (!audio) {
|
|
114
|
-
return;
|
|
115
|
-
}
|
|
116
|
-
if (this.onMetadataLoaded) {
|
|
117
|
-
audio.removeEventListener('loadedmetadata', this.onMetadataLoaded);
|
|
118
|
-
}
|
|
119
|
-
if (this.onPlaybackEnded) {
|
|
120
|
-
audio.removeEventListener('ended', this.onPlaybackEnded);
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
private markAllWordsPast(): void {
|
|
125
|
-
this.words.forEach((w) => {
|
|
126
|
-
w.state = 'past';
|
|
127
|
-
});
|
|
128
|
-
this.activeIndex = -1;
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
buildFakeTiming(): void {
|
|
132
|
-
const rawWords = (this.message?.text || '')
|
|
133
|
-
.trim()
|
|
134
|
-
.split(/\s+/)
|
|
135
|
-
.filter((w) => w.length > 0);
|
|
136
|
-
if (rawWords.length === 0) {
|
|
137
|
-
this.words = [];
|
|
138
|
-
return;
|
|
139
|
-
}
|
|
140
|
-
const step = this.duration / rawWords.length;
|
|
141
|
-
|
|
142
|
-
this.words = rawWords.map((w, i) => ({
|
|
143
|
-
text: w,
|
|
144
|
-
start: i * step,
|
|
145
|
-
end: (i + 1) * step,
|
|
146
|
-
state: 'future' as const,
|
|
147
|
-
}));
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
syncStatesFromCurrentTime(): void {
|
|
151
|
-
if (this.skipSyncAnimation) {
|
|
152
|
-
return;
|
|
153
|
-
}
|
|
154
|
-
const audio = this.audioRef?.nativeElement;
|
|
155
|
-
if (!audio || this.words.length === 0) {
|
|
156
|
-
return;
|
|
157
|
-
}
|
|
158
|
-
this.currentTime = audio.currentTime;
|
|
159
|
-
let newActiveIndex = -1;
|
|
160
|
-
|
|
161
|
-
this.words.forEach((w, i) => {
|
|
162
|
-
if (this.currentTime >= w.end) {
|
|
163
|
-
w.state = 'past';
|
|
164
|
-
} else if (this.currentTime >= w.start && this.currentTime < w.end) {
|
|
165
|
-
w.state = 'active';
|
|
166
|
-
newActiveIndex = i;
|
|
167
|
-
} else {
|
|
168
|
-
w.state = 'future';
|
|
169
|
-
}
|
|
170
|
-
});
|
|
171
|
-
|
|
172
|
-
if (newActiveIndex !== this.activeIndex) {
|
|
173
|
-
this.activeIndex = newActiveIndex;
|
|
174
|
-
this.scrollToActive();
|
|
175
|
-
}
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
onTimeUpdate(): void {
|
|
179
|
-
this.syncStatesFromCurrentTime();
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
scrollToActive(): void {
|
|
183
|
-
const container = this.transcriptBox?.nativeElement;
|
|
184
|
-
const active = container?.querySelector('.active') as HTMLElement;
|
|
185
|
-
|
|
186
|
-
if (active) {
|
|
187
|
-
active.scrollIntoView({
|
|
188
|
-
behavior: 'smooth',
|
|
189
|
-
block: 'center',
|
|
190
|
-
});
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
trackByIndex(index: number): number {
|
|
195
|
-
return index;
|
|
196
|
-
}
|
|
197
|
-
}
|
|
@@ -1,12 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,171 +0,0 @@
|
|
|
1
|
-
import { HttpClient, HttpErrorResponse, HttpHeaders } from '@angular/common/http';
|
|
2
|
-
import { Injectable } from '@angular/core';
|
|
3
|
-
import { firstValueFrom } from 'rxjs';
|
|
4
|
-
import { environment } from 'src/environments/environment';
|
|
5
|
-
|
|
6
|
-
import type { OpenAiVoiceEnvironmentConfig } from './openai-voice.config';
|
|
7
|
-
import {
|
|
8
|
-
SpeechToTextProvider,
|
|
9
|
-
TextToSpeechProvider,
|
|
10
|
-
type SpeechToTextRequest,
|
|
11
|
-
type SpeechToTextResult,
|
|
12
|
-
type TextToSpeechRequest,
|
|
13
|
-
type TextToSpeechResult,
|
|
14
|
-
} from './speech-provider.abstract';
|
|
15
|
-
import { AppConfigService } from '../../app-config.service';
|
|
16
|
-
|
|
17
|
-
const DEFAULT_BASE = 'https://api.openai.com/v1';
|
|
18
|
-
const DEFAULT_TRANSCRIPTION_MODEL = 'whisper-1';
|
|
19
|
-
const DEFAULT_TTS_MODEL = 'tts-1';
|
|
20
|
-
const DEFAULT_VOICE = 'alloy';
|
|
21
|
-
const DEFAULT_FORMAT = 'mp3';
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
* Provider OpenAI unico: STT (Whisper) + TTS, entrambi via {@link HttpClient}.
|
|
25
|
-
*/
|
|
26
|
-
@Injectable({ providedIn: 'root' })
|
|
27
|
-
export class OpenAiVoiceProviderService extends SpeechToTextProvider implements TextToSpeechProvider {
|
|
28
|
-
constructor(
|
|
29
|
-
private readonly httpClient: HttpClient,
|
|
30
|
-
private readonly appConfig: AppConfigService
|
|
31
|
-
) {
|
|
32
|
-
super();
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
async transcribe(request: SpeechToTextRequest): Promise<SpeechToTextResult> {
|
|
36
|
-
const cfg = this.getConfig();
|
|
37
|
-
const apiKey = cfg.apiKey?.trim();
|
|
38
|
-
if (!apiKey) {
|
|
39
|
-
return { text: '' };
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
const base = (cfg.baseUrl ?? DEFAULT_BASE).replace(/\/$/, '');
|
|
43
|
-
const model = cfg.transcriptionModel ?? DEFAULT_TRANSCRIPTION_MODEL;
|
|
44
|
-
const url = `${base}/audio/transcriptions`;
|
|
45
|
-
|
|
46
|
-
const ext = this.extensionForMime(request.mimeType);
|
|
47
|
-
const file = new File([request.audio], `segment.${ext}`, { type: request.mimeType });
|
|
48
|
-
|
|
49
|
-
const form = new FormData();
|
|
50
|
-
form.append('file', file);
|
|
51
|
-
form.append('model', model);
|
|
52
|
-
if (request.language) {
|
|
53
|
-
form.append('language', request.language);
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
const headers = new HttpHeaders({
|
|
57
|
-
Authorization: `Bearer ${apiKey}`,
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
try {
|
|
61
|
-
const data = await firstValueFrom(
|
|
62
|
-
this.httpClient.post<{ text?: string }>(url, form, { headers }),
|
|
63
|
-
);
|
|
64
|
-
return { text: (data.text ?? '').trim() };
|
|
65
|
-
} catch (e) {
|
|
66
|
-
if (e instanceof HttpErrorResponse && e.error instanceof Blob) {
|
|
67
|
-
const errText = await e.error.text();
|
|
68
|
-
throw new Error(`OpenAI transcription ${e.status}: ${errText || e.statusText}`);
|
|
69
|
-
}
|
|
70
|
-
throw this.mapOpenAiHttpError(e);
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
async synthesize(request: TextToSpeechRequest): Promise<TextToSpeechResult> {
|
|
75
|
-
const cfg = this.getConfig();
|
|
76
|
-
const apiKey = cfg.apiKey?.trim();
|
|
77
|
-
if (!apiKey) {
|
|
78
|
-
throw new Error('OpenAI API key not configured (environment.openAiVoice.apiKey)');
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
const base = (cfg.baseUrl ?? DEFAULT_BASE).replace(/\/$/, '');
|
|
82
|
-
const model = cfg.ttsModel ?? DEFAULT_TTS_MODEL;
|
|
83
|
-
const voice = request.voice ?? cfg.ttsVoice ?? DEFAULT_VOICE;
|
|
84
|
-
const responseFormat =
|
|
85
|
-
(request.responseFormat as 'mp3' | 'opus' | 'aac' | 'flac' | undefined) ?? DEFAULT_FORMAT;
|
|
86
|
-
const url = `${base}/audio/speech`;
|
|
87
|
-
|
|
88
|
-
const body = {
|
|
89
|
-
model,
|
|
90
|
-
voice,
|
|
91
|
-
input: request.text,
|
|
92
|
-
response_format: responseFormat,
|
|
93
|
-
};
|
|
94
|
-
|
|
95
|
-
const headers = new HttpHeaders({
|
|
96
|
-
Authorization: `Bearer ${apiKey}`,
|
|
97
|
-
'Content-Type': 'application/json',
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
try {
|
|
101
|
-
const blob = await firstValueFrom(
|
|
102
|
-
this.httpClient.post(url, body, {
|
|
103
|
-
headers,
|
|
104
|
-
responseType: 'blob',
|
|
105
|
-
}),
|
|
106
|
-
);
|
|
107
|
-
return { audio: blob, mimeType: this.mimeForFormat(responseFormat) };
|
|
108
|
-
} catch (e) {
|
|
109
|
-
if (e instanceof HttpErrorResponse && e.error instanceof Blob) {
|
|
110
|
-
const errText = await e.error.text();
|
|
111
|
-
throw new Error(`OpenAI TTS ${e.status}: ${errText || e.statusText}`);
|
|
112
|
-
}
|
|
113
|
-
if (e instanceof HttpErrorResponse) {
|
|
114
|
-
throw new Error(`OpenAI TTS ${e.status}: ${e.message || e.statusText}`);
|
|
115
|
-
}
|
|
116
|
-
throw e;
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
private getConfig(): OpenAiVoiceEnvironmentConfig {
|
|
121
|
-
return this.appConfig.getConfig().openAiKey ?? {};
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
private mapOpenAiHttpError(e: unknown): Error {
|
|
125
|
-
if (!(e instanceof HttpErrorResponse)) {
|
|
126
|
-
return e instanceof Error ? e : new Error(String(e));
|
|
127
|
-
}
|
|
128
|
-
const label = 'OpenAI transcription';
|
|
129
|
-
if (e.error instanceof Blob) {
|
|
130
|
-
return new Error(`${label} ${e.status}: ${e.statusText}`);
|
|
131
|
-
}
|
|
132
|
-
if (typeof e.error === 'object' && e.error !== null && 'error' in e.error) {
|
|
133
|
-
const err = (e.error as { error?: { message?: string } }).error;
|
|
134
|
-
return new Error(`${label} ${e.status}: ${err?.message ?? JSON.stringify(e.error)}`);
|
|
135
|
-
}
|
|
136
|
-
if (typeof e.error === 'string') {
|
|
137
|
-
return new Error(`${label} ${e.status}: ${e.error}`);
|
|
138
|
-
}
|
|
139
|
-
return new Error(`${label} ${e.status}: ${e.message || e.statusText}`);
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
private extensionForMime(mime: string): string {
|
|
143
|
-
if (mime.includes('webm')) {
|
|
144
|
-
return 'webm';
|
|
145
|
-
}
|
|
146
|
-
if (mime.includes('mp4') || mime.includes('m4a')) {
|
|
147
|
-
return 'm4a';
|
|
148
|
-
}
|
|
149
|
-
if (mime.includes('wav')) {
|
|
150
|
-
return 'wav';
|
|
151
|
-
}
|
|
152
|
-
if (mime.includes('mpeg') || mime.includes('mp3')) {
|
|
153
|
-
return 'mp3';
|
|
154
|
-
}
|
|
155
|
-
return 'webm';
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
private mimeForFormat(fmt: string): string {
|
|
159
|
-
switch (fmt) {
|
|
160
|
-
case 'opus':
|
|
161
|
-
return 'audio/opus';
|
|
162
|
-
case 'aac':
|
|
163
|
-
return 'audio/aac';
|
|
164
|
-
case 'flac':
|
|
165
|
-
return 'audio/flac';
|
|
166
|
-
case 'mp3':
|
|
167
|
-
default:
|
|
168
|
-
return 'audio/mpeg';
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
}
|
|
@@ -1,39 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Tipi condivisi per cattura microfono, VAD e registrazione (WebM).
|
|
3
|
-
*/
|
|
4
|
-
|
|
5
|
-
export const DEFAULT_VOICE_AUDIO_CONSTRAINTS: MediaTrackConstraints = {
|
|
6
|
-
echoCancellation: true,
|
|
7
|
-
noiseSuppression: true,
|
|
8
|
-
autoGainControl: true,
|
|
9
|
-
};
|
|
10
|
-
|
|
11
|
-
export const DEFAULT_VOICE_MEDIA_STREAM_CONSTRAINTS: MediaStreamConstraints = {
|
|
12
|
-
audio: DEFAULT_VOICE_AUDIO_CONSTRAINTS,
|
|
13
|
-
};
|
|
14
|
-
|
|
15
|
-
export interface VoiceRecordedBlob {
|
|
16
|
-
blob: Blob;
|
|
17
|
-
mimeType: string;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
/**
|
|
21
|
-
* Segmento audio dopo VAD; può includere `transcript` se STT è configurato e abilitato.
|
|
22
|
-
*/
|
|
23
|
-
export interface VoiceSegmentPayload extends VoiceRecordedBlob {
|
|
24
|
-
transcript?: string;
|
|
25
|
-
transcriptionError?: string;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
export interface VoiceSessionStartOptions {
|
|
29
|
-
/** Opzionale se usi solo {@link VoiceService.audioSegment$}. */
|
|
30
|
-
onRecordingComplete?: (result: VoiceSegmentPayload) => void;
|
|
31
|
-
constraints?: MediaStreamConstraints;
|
|
32
|
-
/** Default `true`. Se `false`, non viene chiamato lo STT sul segmento. */
|
|
33
|
-
enableTranscription?: boolean;
|
|
34
|
-
}
|