@hoanghong/speaking-angular 0.0.0-stage → 0.2.0

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/CHANGELOG.md ADDED
@@ -0,0 +1,12 @@
1
+ # Changelog
2
+
3
+ ## 0.2.0
4
+
5
+ - Add standalone FselSpeakingComponent and provideSpeakingGateway.
6
+ - Include microphone, upload, five languages, cancellation and result/error outputs.
7
+ - Document installation, worklet assets, application bootstrap, gateway contract and troubleshooting with full code.
8
+ - Preserve existing low-level 0.1 APIs.
9
+
10
+ ## 0.1.0
11
+
12
+ - Initial Angular service and core SDK integration.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 hoanghong
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,175 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
1
+ # @hoanghong/speaking-angular
2
+
3
+ Component Angular dùng ngay để nhận dạng lời nói **online**, gồm microphone realtime, upload audio, chọn 5 ngôn ngữ và hiển thị kết quả. MIT. Angular **18.x**, RxJS 7.8, trình duyệt có AudioWorklet. Model chạy trên server, không tải model xuống máy người dùng.
4
+
5
+ ## 1. Chuẩn bị kết nối một lần
6
+
7
+ Ứng dụng cần gateway của backend, ví dụ `https://your-app.example/api/speaking`. Gateway xác thực người dùng bằng phiên đăng nhập của ứng dụng và chuyển tiếp HTTP/WebSocket tới FSEL ASR. Service token chỉ nằm trên backend. Người dùng cuối **không nhập token**.
8
+
9
+ Đây là SDK kết nối dịch vụ, không kèm dịch vụ ASR miễn phí. Địa chỉ gateway vẫn nhìn thấy trong Network của trình duyệt; không thể giấu địa chỉ mạng trong thư viện frontend. Không nhúng service token hay thông tin S3 vào Angular.
10
+
11
+ Backend cần chuyển tiếp các đường dẫn bên dưới, bỏ prefix `/api/speaking` trước khi gửi tới ASR:
12
+
13
+ | Đường dẫn phía gateway | Chức năng |
14
+ | --- | --- |
15
+ | `POST /api/speaking/v1/realtime-tickets` | Tạo ticket ngắn hạn; body `{"language":"vi","audio_profile":"short_answer"}`; trả JSON có `ticket` |
16
+ | `WS /api/speaking/v1/realtime` | WebSocket realtime, hỗ trợ Upgrade và kết nối dài |
17
+ | `POST /api/speaking/v1/transcription-jobs` | Upload multipart audio |
18
+ | `GET /api/speaking/v1/transcription-jobs/{id}` | Trạng thái job |
19
+ | `DELETE /api/speaking/v1/transcription-jobs/{id}` | Hủy job |
20
+
21
+ Đây phải là relay tương thích giao thức FSEL ASR, không phải API speech tùy ý. Bảo vệ các route bằng đăng nhập, kiểm tra quyền sở hữu job, quota/rate limit; không mở proxy công khai có service token. Trình duyệt nên dùng gateway cùng origin để phiên cookie hoạt động. HTTPS cho production và WSS cho WebSocket. Server cần có weights cho ngôn ngữ được chọn; cài npm không tự cài model server.
22
+
23
+ ## 2. Cài thư viện
24
+
25
+ Trong project Angular 18:
26
+
27
+ ```bash
28
+ npm install @hoanghong/speaking-angular@^0.2.0
29
+ ```
30
+
31
+ `@hoanghong/speaking-core` được cài tự động. Với project mới: `npx @angular/cli@18 new speaking-demo --standalone --routing=false --style=css`, sau đó `cd speaking-demo` và chạy lệnh cài trên.
32
+
33
+ ## 3. Copy file microphone bằng angular.json
34
+
35
+ Trong `projects.<tên-project>.architect.build.options.assets`, giữ asset hiện có và thêm object sau:
36
+
37
+ ```json
38
+ {
39
+ "glob": "pcm-worklet.js",
40
+ "input": "node_modules/@hoanghong/speaking-core/assets",
41
+ "output": "assets/speaking"
42
+ }
43
+ ```
44
+
45
+ Sau khi build, URL `assets/speaking/pcm-worklet.js` phải trả JavaScript, không phải trang HTML. Nếu app deploy dưới thư mục con, giữ `base href` đúng; có thể chỉ định `workletUrl` ở bước tiếp theo.
46
+
47
+ ## 4. Cấu hình app.config.ts
48
+
49
+ `src/app/app.config.ts`:
50
+
51
+ ```ts
52
+ import { ApplicationConfig } from '@angular/core';
53
+ import { provideSpeakingGateway } from '@hoanghong/speaking-angular';
54
+
55
+ export const appConfig: ApplicationConfig = {
56
+ providers: [
57
+ provideSpeakingGateway({ baseUrl: '/api/speaking' }),
58
+ ],
59
+ };
60
+ ```
61
+
62
+ Giữ các provider router/HTTP hiện có nếu app đã có. `baseUrl` là prefix gateway, **không** thêm `/v1/realtime`. Mặc định là `/api/speaking`. Provider dùng trong browser; nếu app SSR, chỉ render màn Speaking phía browser.
63
+
64
+ ## 5. Dùng component có sẵn
65
+
66
+ Thay nội dung `src/app/app.component.ts` bằng ví dụ đầy đủ này:
67
+
68
+ ```ts
69
+ import { Component } from '@angular/core';
70
+ import {
71
+ FselSpeakingComponent, Job, RealtimeFinal,
72
+ } from '@hoanghong/speaking-angular';
73
+
74
+ @Component({
75
+ selector: 'app-root',
76
+ standalone: true,
77
+ imports: [FselSpeakingComponent],
78
+ template: `
79
+ <main style="max-width:800px;margin:24px auto;padding:16px">
80
+ <h1>Luyện nói</h1>
81
+ <fsel-speaking
82
+ initialLanguage="vi"
83
+ [uploadEnabled]="true"
84
+ (completed)="onCompleted($event)"
85
+ (failed)="onFailed($event)"
86
+ />
87
+ <p aria-live="polite">{{ message }}</p>
88
+ </main>
89
+ `,
90
+ })
91
+ export class AppComponent {
92
+ message = '';
93
+ onCompleted(value: Job | RealtimeFinal): void {
94
+ this.message = value.result?.text ?? 'Không có lời nói.';
95
+ }
96
+ onFailed(error: Error): void {
97
+ this.message = error.message;
98
+ }
99
+ }
100
+ ```
101
+
102
+ `src/main.ts`:
103
+
104
+ ```ts
105
+ import { bootstrapApplication } from '@angular/platform-browser';
106
+ import { AppComponent } from './app/app.component';
107
+ import { appConfig } from './app/app.config';
108
+
109
+ bootstrapApplication(AppComponent, appConfig).catch(console.error);
110
+ ```
111
+
112
+ ## 6. Chạy và thử
113
+
114
+ 1. Khởi động backend/gateway đã có ASR và model.
115
+ 2. Chạy `npx ng serve`, mở URL được CLI in ra.
116
+ 3. Đăng nhập ứng dụng nếu gateway yêu cầu phiên đăng nhập.
117
+ 4. Chọn ngôn ngữ → **Bắt đầu nói** → cho phép microphone → chờ “Đang nghe…”.
118
+ 5. Nói một câu, bấm **Dừng và lấy kết quả**. Văn bản tạm xuất hiện trong lúc nói; kết quả cuối phát qua `completed`.
119
+ 6. Hoặc chọn file audio để tự upload, chờ job hoàn tất. Bấm **Hủy** để dừng phiên/job đang chạy.
120
+
121
+ `ng serve` không tự tạo gateway. Khi frontend và gateway khác port, cần reverse proxy của môi trường phát triển hỗ trợ HTTP **và WebSocket**, hoặc phục vụ frontend từ cùng origin gateway. Không đưa service token vào `environment.ts`.
122
+
123
+ ### Chạy local trực tiếp với ASR đã có (chỉ dành cho developer)
124
+
125
+ Nếu chưa có gateway ứng dụng, dùng Angular dev proxy trên máy của bạn. Tạo file `proxy.speaking.cjs` ở gốc project:
126
+
127
+ ```js
128
+ const token = process.env.ASR_SERVICE_TOKEN;
129
+ if (!token) throw new Error('Set ASR_SERVICE_TOKEN on the development server');
130
+ module.exports = {
131
+ '/v1/**': {
132
+ target: process.env.ASR_TARGET || 'http://127.0.0.1:8000',
133
+ secure: true,
134
+ changeOrigin: true,
135
+ ws: true,
136
+ headers: { Authorization: `Bearer ${token}` },
137
+ },
138
+ };
139
+ ```
140
+
141
+ Đổi provider thành `provideSpeakingGateway({ baseUrl: '/' })`. Đặt `ASR_SERVICE_TOKEN` trong môi trường terminal chạy Node (token do quản trị ASR cấp; không ghi vào source). PowerShell:
142
+
143
+ ```powershell
144
+ $env:ASR_SERVICE_TOKEN = Read-Host 'ASR token (trusted local development only)'
145
+ npx ng serve --host 127.0.0.1 --proxy-config proxy.speaking.cjs
146
+ ```
147
+
148
+ Mở `http://127.0.0.1:4200`. Node dev server thêm header; browser không nhận service token. Chỉ dùng trên loopback, không public `ng serve` hoặc proxy này. Khi production, chuyển về gateway có đăng nhập/quyền người dùng ở bước 1; dev proxy không thay thế gateway production. Dừng và chạy lại dev server khi đổi môi trường/proxy.
149
+
150
+ ## API component
151
+
152
+ | Thuộc tính | Mặc định | Ý nghĩa |
153
+ | --- | --- | --- |
154
+ | `initialLanguage` | `vi` | `en`, `ja`, `ko`, `zh`, `vi` |
155
+ | `uploadEnabled` | `true` | Hiện/ẩn upload file |
156
+ | `completed` | event | `RealtimeFinal` hoặc `Job`, lấy text tại `value.result?.text` |
157
+ | `failed` | event | `Error` để ứng dụng hiển thị/thống kê |
158
+
159
+ UI có tiếng Việt; model hỗ trợ Anh, Nhật, Hàn, Trung, Việt tùy weights server. Mỗi component có phiên riêng, tự giải phóng microphone khi bị hủy. Realtime tối đa 5 phút theo giao thức server; upload UI chặn trên 25 MiB, polling mỗi 1,5 giây và dừng chờ sau 10 phút. Server có thể đặt giới hạn thấp hơn. Rời màn hình ngừng polling; job upload đã được server nhận có thể vẫn chạy. Không có tự reconnect/replay audio sau khi mất mạng.
160
+
161
+ Nếu muốn tự thiết kế UI, các API `SpeakingService`, `provideSpeaking`, `BatchSpeakingClient`, `RealtimeSpeakingClient` vẫn được export. Với `provideSpeakingGateway`, tự cung cấp `SpeakingService` tại component của bạn nếu inject trực tiếp; component có sẵn đã làm việc này. Bản 0.2 giữ API cấu hình thủ công 0.1.
162
+
163
+ ## Lỗi thường gặp
164
+
165
+ | Lỗi | Cách kiểm tra |
166
+ | --- | --- |
167
+ | 401/403 | Phiên đăng nhập và quyền gateway; service token nằm trên backend |
168
+ | 404 worklet / lỗi MIME | Kiểm tra asset bước 3, base href và workletUrl |
169
+ | 404 ticket/job | Sai prefix gateway hoặc chưa rewrite `/api/speaking` |
170
+ | 503 / model unavailable | Server chưa có model hoặc không đủ tài nguyên CPU/GPU |
171
+ | Microphone bị từ chối | Bật quyền microphone của site; dùng HTTPS hoặc localhost |
172
+ | WS không kết nối | Proxy phải bật Upgrade; HTTPS phải dùng WSS; kiểm tra timeout |
173
+ | CORS | Ưu tiên cùng origin; nếu khác origin, backend phải cho phép origin và auth phù hợp |
174
+
175
+ Không ghi âm tự động. Chỉ bắt đầu sau thao tác người dùng và quyền microphone. Không chứa weights, khóa S3 hoặc service token.
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Generated bundle index. Do not edit.
3
+ */
4
+ export * from './public-api';
5
+ //# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiaG9hbmdob25nLXNwZWFraW5nLWFuZ3VsYXIuanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyIuLi8uLi9zcmMvaG9hbmdob25nLXNwZWFraW5nLWFuZ3VsYXIudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IkFBQUE7O0dBRUc7QUFFSCxjQUFjLGNBQWMsQ0FBQyIsInNvdXJjZXNDb250ZW50IjpbIi8qKlxuICogR2VuZXJhdGVkIGJ1bmRsZSBpbmRleC4gRG8gbm90IGVkaXQuXG4gKi9cblxuZXhwb3J0ICogZnJvbSAnLi9wdWJsaWMtYXBpJztcbiJdfQ==
@@ -0,0 +1,3 @@
1
+ export * from './speaking.service';
2
+ export * from './speaking.component';
3
+ //# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoicHVibGljLWFwaS5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbIi4uLy4uL3NyYy9wdWJsaWMtYXBpLnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUFBLGNBQWMsb0JBQW9CLENBQUM7QUFDbkMsY0FBYyxzQkFBc0IsQ0FBQyIsInNvdXJjZXNDb250ZW50IjpbImV4cG9ydCAqIGZyb20gJy4vc3BlYWtpbmcuc2VydmljZSc7XHJcbmV4cG9ydCAqIGZyb20gJy4vc3BlYWtpbmcuY29tcG9uZW50JztcclxuIl19
@@ -0,0 +1,206 @@
1
+ import { Component, DestroyRef, EventEmitter, Input, Output, inject } from '@angular/core';
2
+ import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
3
+ import { SpeakingService } from './speaking.service';
4
+ import * as i0 from "@angular/core";
5
+ export class FselSpeakingComponent {
6
+ initialLanguage = 'vi';
7
+ uploadEnabled = true;
8
+ completed = new EventEmitter();
9
+ failed = new EventEmitter();
10
+ speaking = inject(SpeakingService);
11
+ destroy = inject(DestroyRef);
12
+ languages = [{ code: 'en', name: 'Tiếng Anh' }, { code: 'ja', name: 'Tiếng Nhật' }, { code: 'ko', name: 'Tiếng Hàn' }, { code: 'zh', name: 'Tiếng Trung' }, { code: 'vi', name: 'Tiếng Việt' }];
13
+ language = 'vi';
14
+ busy = false;
15
+ recording = false;
16
+ uploading = false;
17
+ text = '';
18
+ error = '';
19
+ status = 'Chọn ngôn ngữ, sau đó bấm nói hoặc chọn file.';
20
+ job;
21
+ generation = 0;
22
+ abort;
23
+ constructor() {
24
+ this.speaking.events$.pipe(takeUntilDestroyed(this.destroy)).subscribe((event) => {
25
+ if (event.type === 'partial')
26
+ this.text = event.text;
27
+ if (event.type === 'state')
28
+ this.recording = event.state === 'recording';
29
+ if (event.type === 'clientError') {
30
+ this.report(event.error);
31
+ this.busy = false;
32
+ this.recording = false;
33
+ }
34
+ });
35
+ this.destroy.onDestroy(() => { ++this.generation; this.abort?.abort(); });
36
+ }
37
+ ngOnInit() { this.language = this.initialLanguage; }
38
+ selectLanguage(event) { this.language = event.target.value; }
39
+ report(error) {
40
+ const value = error instanceof Error ? error : new Error('Speaking failed');
41
+ this.error = value.message;
42
+ this.failed.emit(value);
43
+ }
44
+ async start() {
45
+ if (this.busy)
46
+ return;
47
+ const generation = ++this.generation;
48
+ this.busy = true;
49
+ this.uploading = false;
50
+ this.error = '';
51
+ this.text = '';
52
+ this.status = 'Đang mở microphone và chuẩn bị model…';
53
+ try {
54
+ await this.speaking.start({ language: this.language });
55
+ if (generation === this.generation)
56
+ this.status = 'Đang nghe…';
57
+ }
58
+ catch (error) {
59
+ if (generation === this.generation) {
60
+ this.report(error);
61
+ this.busy = false;
62
+ }
63
+ }
64
+ }
65
+ async stop() {
66
+ if (!this.recording)
67
+ return;
68
+ const generation = this.generation;
69
+ this.recording = false;
70
+ this.status = 'Đang lấy kết quả…';
71
+ try {
72
+ const result = await this.speaking.stop();
73
+ if (generation !== this.generation)
74
+ return;
75
+ this.text = result.result?.text ?? '';
76
+ this.status = result.status === 'no_speech' ? 'Không nhận thấy lời nói.' : 'Đã hoàn tất.';
77
+ this.completed.emit(result);
78
+ }
79
+ catch (error) {
80
+ if (generation === this.generation)
81
+ this.report(error);
82
+ }
83
+ finally {
84
+ if (generation === this.generation)
85
+ this.busy = false;
86
+ }
87
+ }
88
+ async cancel() {
89
+ ++this.generation;
90
+ this.abort?.abort();
91
+ try {
92
+ if (this.uploading && this.job)
93
+ await this.speaking.batch.cancelJob(this.job.job_id);
94
+ else
95
+ await this.speaking.cancel();
96
+ this.status = 'Đã hủy.';
97
+ }
98
+ catch (error) {
99
+ this.report(error);
100
+ }
101
+ finally {
102
+ this.busy = false;
103
+ this.recording = false;
104
+ this.uploading = false;
105
+ }
106
+ }
107
+ async upload(event) {
108
+ const input = event.target;
109
+ const file = input.files?.[0];
110
+ input.value = '';
111
+ if (!file || this.busy)
112
+ return;
113
+ if (file.size > 25 * 1024 * 1024) {
114
+ this.report(new Error('File tối đa 25 MB.'));
115
+ return;
116
+ }
117
+ const generation = ++this.generation;
118
+ this.abort = new AbortController();
119
+ this.busy = true;
120
+ this.uploading = true;
121
+ this.job = undefined;
122
+ this.text = '';
123
+ this.error = '';
124
+ this.status = 'Đang upload…';
125
+ try {
126
+ let job = await this.speaking.batch.submitAudio(file, this.language, crypto.randomUUID(), 'short_answer', file.name, this.abort.signal);
127
+ const deadline = Date.now() + 10 * 60 * 1000;
128
+ while (generation === this.generation) {
129
+ this.job = job;
130
+ if (['succeeded', 'no_speech', 'failed', 'cancelled'].includes(job.status))
131
+ break;
132
+ if (Date.now() > deadline)
133
+ throw new Error('Hết thời gian chờ; job có thể vẫn đang chạy trên server.');
134
+ this.status = 'Đang nhận dạng file…';
135
+ await new Promise(resolve => setTimeout(resolve, 1500));
136
+ if (generation !== this.generation)
137
+ return;
138
+ job = await this.speaking.batch.getResult(job.job_id, this.abort.signal);
139
+ }
140
+ if (generation !== this.generation)
141
+ return;
142
+ if (job.error)
143
+ throw new Error(job.error.message);
144
+ this.text = job.result?.text ?? '';
145
+ this.status = job.status === 'no_speech' ? 'Không nhận thấy lời nói.' : job.status === 'cancelled' ? 'Đã hủy.' : 'Đã hoàn tất.';
146
+ this.completed.emit(job);
147
+ }
148
+ catch (error) {
149
+ if (generation === this.generation)
150
+ this.report(error);
151
+ }
152
+ finally {
153
+ if (generation === this.generation) {
154
+ this.busy = false;
155
+ this.uploading = false;
156
+ }
157
+ }
158
+ }
159
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: FselSpeakingComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
160
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.2.14", type: FselSpeakingComponent, isStandalone: true, selector: "fsel-speaking", inputs: { initialLanguage: "initialLanguage", uploadEnabled: "uploadEnabled" }, outputs: { completed: "completed", failed: "failed" }, providers: [SpeakingService], ngImport: i0, template: `
161
+ <section aria-label="FSEL Speaking">
162
+ <label>Ngôn ngữ <select [value]="language" [disabled]="busy" (change)="selectLanguage($event)">
163
+ @for (item of languages; track item.code) { <option [value]="item.code" [selected]="item.code === language">{{ item.name }}</option> }
164
+ </select></label>
165
+ <div class="actions">
166
+ <button type="button" (click)="start()" [disabled]="busy">Bắt đầu nói</button>
167
+ <button type="button" (click)="stop()" [disabled]="!recording">Dừng và lấy kết quả</button>
168
+ <button type="button" (click)="cancel()" [disabled]="!busy || (uploading && !job)">Hủy</button>
169
+ </div>
170
+ @if (uploadEnabled) { <label>Hoặc chọn file audio (tối đa 25 MB)
171
+ <input type="file" accept="audio/*,.wav,.mp3,.m4a,.ogg,.flac,.webm" [disabled]="busy" (change)="upload($event)">
172
+ </label> }
173
+ <p role="status">{{ status }}</p>
174
+ @if (error) { <p role="alert" class="error">{{ error }}</p> }
175
+ <label>Văn bản nhận dạng <textarea rows="7" readonly [value]="text"></textarea></label>
176
+ </section>`, isInline: true, styles: [":host{display:block;font:inherit;color:inherit}section{border:1px solid #b9c1bc;padding:20px;border-radius:4px}label{display:grid;gap:8px;margin-bottom:16px}select,input,textarea,button{font:inherit;box-sizing:border-box;max-width:100%}select,input,textarea{padding:10px;border:1px solid #929c95;border-radius:3px;background:#fff;color:#202723}textarea{width:100%;resize:vertical}.actions{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:16px}button{min-height:44px;padding:8px 14px;background:#365448;color:#fff;border:1px solid #365448;border-radius:4px;cursor:pointer}button:disabled{opacity:.5;cursor:default}button:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible{outline:3px solid #a66a15;outline-offset:2px}.error{color:#a02020;overflow-wrap:anywhere}\n"] });
177
+ }
178
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: FselSpeakingComponent, decorators: [{
179
+ type: Component,
180
+ args: [{ selector: 'fsel-speaking', standalone: true, providers: [SpeakingService], template: `
181
+ <section aria-label="FSEL Speaking">
182
+ <label>Ngôn ngữ <select [value]="language" [disabled]="busy" (change)="selectLanguage($event)">
183
+ @for (item of languages; track item.code) { <option [value]="item.code" [selected]="item.code === language">{{ item.name }}</option> }
184
+ </select></label>
185
+ <div class="actions">
186
+ <button type="button" (click)="start()" [disabled]="busy">Bắt đầu nói</button>
187
+ <button type="button" (click)="stop()" [disabled]="!recording">Dừng và lấy kết quả</button>
188
+ <button type="button" (click)="cancel()" [disabled]="!busy || (uploading && !job)">Hủy</button>
189
+ </div>
190
+ @if (uploadEnabled) { <label>Hoặc chọn file audio (tối đa 25 MB)
191
+ <input type="file" accept="audio/*,.wav,.mp3,.m4a,.ogg,.flac,.webm" [disabled]="busy" (change)="upload($event)">
192
+ </label> }
193
+ <p role="status">{{ status }}</p>
194
+ @if (error) { <p role="alert" class="error">{{ error }}</p> }
195
+ <label>Văn bản nhận dạng <textarea rows="7" readonly [value]="text"></textarea></label>
196
+ </section>`, styles: [":host{display:block;font:inherit;color:inherit}section{border:1px solid #b9c1bc;padding:20px;border-radius:4px}label{display:grid;gap:8px;margin-bottom:16px}select,input,textarea,button{font:inherit;box-sizing:border-box;max-width:100%}select,input,textarea{padding:10px;border:1px solid #929c95;border-radius:3px;background:#fff;color:#202723}textarea{width:100%;resize:vertical}.actions{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:16px}button{min-height:44px;padding:8px 14px;background:#365448;color:#fff;border:1px solid #365448;border-radius:4px;cursor:pointer}button:disabled{opacity:.5;cursor:default}button:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible{outline:3px solid #a66a15;outline-offset:2px}.error{color:#a02020;overflow-wrap:anywhere}\n"] }]
197
+ }], ctorParameters: () => [], propDecorators: { initialLanguage: [{
198
+ type: Input
199
+ }], uploadEnabled: [{
200
+ type: Input
201
+ }], completed: [{
202
+ type: Output
203
+ }], failed: [{
204
+ type: Output
205
+ }] } });
206
+ //# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoic3BlYWtpbmcuY29tcG9uZW50LmpzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsiLi4vLi4vc3JjL3NwZWFraW5nLmNvbXBvbmVudC50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiQUFBQSxPQUFPLEVBQUUsU0FBUyxFQUFFLFVBQVUsRUFBRSxZQUFZLEVBQUUsS0FBSyxFQUFVLE1BQU0sRUFBRSxNQUFNLEVBQUUsTUFBTSxlQUFlLENBQUM7QUFDbkcsT0FBTyxFQUFFLGtCQUFrQixFQUFFLE1BQU0sNEJBQTRCLENBQUM7QUFFaEUsT0FBTyxFQUFFLGVBQWUsRUFBRSxNQUFNLG9CQUFvQixDQUFDOztBQXVCckQsTUFBTSxPQUFPLHFCQUFxQjtJQUN2QixlQUFlLEdBQWEsSUFBSSxDQUFDO0lBQ2pDLGFBQWEsR0FBRyxJQUFJLENBQUM7SUFDcEIsU0FBUyxHQUFHLElBQUksWUFBWSxFQUF1QixDQUFDO0lBQ3BELE1BQU0sR0FBRyxJQUFJLFlBQVksRUFBUyxDQUFDO0lBQ3BDLFFBQVEsR0FBRyxNQUFNLENBQUMsZUFBZSxDQUFDLENBQUM7SUFDM0IsT0FBTyxHQUFHLE1BQU0sQ0FBQyxVQUFVLENBQUMsQ0FBQztJQUNyQyxTQUFTLEdBQUcsQ0FBQyxFQUFFLElBQUksRUFBRSxJQUFJLEVBQUUsSUFBSSxFQUFFLFdBQVcsRUFBRSxFQUFFLEVBQUUsSUFBSSxFQUFFLElBQUksRUFBRSxJQUFJLEVBQUUsWUFBWSxFQUFFLEVBQUUsRUFBRSxJQUFJLEVBQUUsSUFBSSxFQUFFLElBQUksRUFBRSxXQUFXLEVBQUUsRUFBRSxFQUFFLElBQUksRUFBRSxJQUFJLEVBQUUsSUFBSSxFQUFFLGFBQWEsRUFBRSxFQUFFLEVBQUUsSUFBSSxFQUFFLElBQUksRUFBRSxJQUFJLEVBQUUsWUFBWSxFQUFFLENBQUMsQ0FBQztJQUN6TSxRQUFRLEdBQWEsSUFBSSxDQUFDO0lBQUMsSUFBSSxHQUFHLEtBQUssQ0FBQztJQUFDLFNBQVMsR0FBRyxLQUFLLENBQUM7SUFBQyxTQUFT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QmpDLFNBQVM7K0JBQ0UsZUFBZSxjQUFjLElBQUksYUFBYSxDQUFDLGVBQWUsQ0FBQyxZQUMvRDs7Ozs7Ozs7Ozs7Ozs7OztlQWdCRzt3REFJSixlQUFlO3NCQUF2QixLQUFLO2dCQUNHLGFBQWE7c0JBQXJCLEtBQUs7Z0JBQ0ksU0FBUztzQkFBbEIsTUFBTTtnQkFDRyxNQUFNO3NCQUFmLE1BQU0iLCJzb3VyY2VzQ29udGVudCI6WyJpbXBvcnQgeyBDb21wb25lbnQsIERlc3Ryb3lSZWYsIEV2ZW50RW1pdHRlciwgSW5wdXQsIE9uSW5pdCwgT3V0cHV0LCBpbmplY3QgfSBmcm9tICdAYW5ndWxhci9jb3JlJztcclxuaW1wb3J0IHsgdGFrZVVudGlsRGVzdHJveWVkIH0gZnJvbSAnQGFuZ3VsYXIvY29yZS9yeGpzLWludGVyb3AnO1xyXG5pbXBvcnQgeyBKb2IsIExhbmd1YWdlLCBSZWFsdGltZUZpbmFsLCBTcGVha2luZ0V2ZW50IH0gZnJvbSAnQGhvYW5naG9uZy9zcGVha2luZy1jb3JlJztcclxuaW1wb3J0IHsgU3BlYWtpbmdTZXJ2aWNlIH0gZnJvbSAnLi9zcGVha2luZy5zZXJ2aWNlJztcclxuXHJcbkBDb21wb25lbnQoe1xyXG4gIHNlbGVjdG9yOiAnZnNlbC1zcGVha2luZycsIHN0YW5kYWxvbmU6IHRydWUsIHByb3ZpZGVyczogW1NwZWFraW5nU2VydmljZV0sXHJcbiAgdGVtcGxhdGU6IGBcclxuICAgIDxzZWN0aW9uIGFyaWEtbGFiZWw9XCJGU0VMIFNwZWFraW5nXCI+XHJcbiAgICAgIDxsYWJlbD5OZ8O0biBuZ+G7ryA8c2VsZWN0IFt2YWx1ZV09XCJsYW5ndWFnZVwiIFtkaXNhYmxlZF09XCJidXN5XCIgKGNoYW5nZSk9XCJzZWxlY3RMYW5ndWFnZSgkZXZlbnQpXCI+XHJcbiAgICAgICAgQGZvciAoaXRlbSBvZiBsYW5ndWFnZXM7IHRyYWNrIGl0ZW0uY29kZSkgeyA8b3B0aW9uIFt2YWx1ZV09XCJpdGVtLmNvZGVcIiBbc2VsZWN0ZWRdPVwiaXRlbS5jb2RlID09PSBsYW5ndWFnZVwiPnt7IGl0ZW0ubmFtZSB9fTwvb3B0aW9uPiB9XHJcbiAgICAgIDwvc2VsZWN0PjwvbGFiZWw+XHJcbiAgICAgIDxkaXYgY2xhc3M9XCJhY3Rpb25zXCI+XHJcbiAgICAgICAgPGJ1dHRvbiB0eXBlPVwiYnV0dG9uXCIgKGNsaWNrKT1cInN0YXJ0KClcIiBbZGlzYWJsZWRdPVwiYnVzeVwiPkLhuq90IMSR4bqndSBuw7NpPC9idXR0b24+XHJcbiAgICAgICAgPGJ1dHRvbiB0eXBlPVwiYnV0dG9uXCIgKGNsaWNrKT1cInN0b3AoKVwiIFtkaXNhYmxlZF09XCIhcmVjb3JkaW5nXCI+ROG7q25nIHbDoCBs4bqleSBr4bq/dCBxdeG6ozwvYnV0dG9uPlxyXG4gICAgICAgIDxidXR0b24gdHlwZT1cImJ1dHRvblwiIChjbGljayk9XCJjYW5jZWwoKVwiIFtkaXNhYmxlZF09XCIhYnVzeSB8fCAodXBsb2FkaW5nICYmICFqb2IpXCI+SOG7p3k8L2J1dHRvbj5cclxuICAgICAgPC9kaXY+XHJcbiAgICAgIEBpZiAodXBsb2FkRW5hYmxlZCkgeyA8bGFiZWw+SG/hurdjIGNo4buNbiBmaWxlIGF1ZGlvICh04buRaSDEkWEgMjUgTUIpXHJcbiAgICAgICAgPGlucHV0IHR5cGU9XCJmaWxlXCIgYWNjZXB0PVwiYXVkaW8vKiwud2F2LC5tcDMsLm00YSwub2dnLC5mbGFjLC53ZWJtXCIgW2Rpc2FibGVkXT1cImJ1c3lcIiAoY2hhbmdlKT1cInVwbG9hZCgkZXZlbnQpXCI+XHJcbiAgICAgIDwvbGFiZWw+IH1cclxuICAgICAgPHAgcm9sZT1cInN0YXR1c1wiPnt7IHN0YXR1cyB9fTwvcD5cclxuICAgICAgQGlmIChlcnJvcikgeyA8cCByb2xlPVwiYWxlcnRcIiBjbGFzcz1cImVycm9yXCI+e3sgZXJyb3IgfX08L3A+IH1cclxuICAgICAgPGxhYmVsPlbEg24gYuG6o24gbmjhuq1uIGThuqFuZyA8dGV4dGFyZWEgcm93cz1cIjdcIiByZWFkb25seSBbdmFsdWVdPVwidGV4dFwiPjwvdGV4dGFyZWE+PC9sYWJlbD5cclxuICAgIDwvc2VjdGlvbj5gLFxyXG4gIHN0eWxlczogW2A6aG9zdHtkaXNwbGF5OmJsb2NrO2ZvbnQ6aW5oZXJpdDtjb2xvcjppbmhlcml0fXNlY3Rpb257Ym9yZGVyOjFweCBzb2xpZCAjYjljMWJjO3BhZGRpbmc6MjBweDtib3JkZXItcmFkaXVzOjRweH1sYWJlbHtkaXNwbGF5OmdyaWQ7Z2FwOjhweDttYXJnaW4tYm90dG9tOjE2cHh9c2VsZWN0LGlucHV0LHRleHRhcmVhLGJ1dHRvbntmb250OmluaGVyaXQ7Ym94LXNpemluZzpib3JkZXItYm94O21heC13aWR0aDoxMDAlfXNlbGVjdCxpbnB1dCx0ZXh0YXJlYXtwYWRkaW5nOjEwcHg7Ym9yZGVyOjFweCBzb2xpZCAjOTI5Yzk1O2JvcmRlci1yYWRpdXM6M3B4O2JhY2tncm91bmQ6I2ZmZjtjb2xvcjojMjAyNzIzfXRleHRhcmVhe3dpZHRoOjEwMCU7cmVzaXplOnZlcnRpY2FsfS5hY3Rpb25ze2Rpc3BsYXk6ZmxleDtnYXA6OHB4O2ZsZXgtd3JhcDp3cmFwO21hcmdpbi1ib3R0b206MTZweH1idXR0b257bWluLWhlaWdodDo0NHB4O3BhZGRpbmc6OHB4IDE0cHg7YmFja2dyb3VuZDojMzY1NDQ4O2NvbG9yOndoaXRlO2JvcmRlcjoxcHggc29saWQgIzM2NTQ0ODtib3JkZXItcmFkaXVzOjRweDtjdXJzb3I6cG9pbnRlcn1idXR0b246ZGlzYWJsZWR7b3BhY2l0eTouNTtjdXJzb3I6ZGVmYXVsdH1idXR0b246Zm9jdXMtdmlzaWJsZSxpbnB1dDpmb2N1cy12aXNpYmxlLHNlbGVjdDpmb2N1cy12aXNpYmxlLHRleHRhcmVhOmZvY3VzLXZpc2libGV7b3V0bGluZTozcHggc29saWQgI2E2NmExNTtvdXRsaW5lLW9mZnNldDoycHh9LmVycm9ye2NvbG9yOiNhMDIwMjA7b3ZlcmZsb3ctd3JhcDphbnl3aGVyZX1gXSxcclxufSlcclxuZXhwb3J0IGNsYXNzIEZzZWxTcGVha2luZ0NvbXBvbmVudCBpbXBsZW1lbnRzIE9uSW5pdCB7XHJcbiAgQElucHV0KCkgaW5pdGlhbExhbmd1YWdlOiBMYW5ndWFnZSA9ICd2aSc7XHJcbiAgQElucHV0KCkgdXBsb2FkRW5hYmxlZCA9IHRydWU7XHJcbiAgQE91dHB1dCgpIGNvbXBsZXRlZCA9IG5ldyBFdmVudEVtaXR0ZXI8UmVhbHRpbWVGaW5hbCB8IEpvYj4oKTtcclxuICBAT3V0cHV0KCkgZmFpbGVkID0gbmV3IEV2ZW50RW1pdHRlcjxFcnJvcj4oKTtcclxuICByZWFkb25seSBzcGVha2luZyA9IGluamVjdChTcGVha2luZ1NlcnZpY2UpO1xyXG4gIHByaXZhdGUgcmVhZG9ubHkgZGVzdHJveSA9IGluamVjdChEZXN0cm95UmVmKTtcclxuICByZWFkb25seSBsYW5ndWFnZXMgPSBbeyBjb2RlOiAnZW4nLCBuYW1lOiAnVGnhur9uZyBBbmgnIH0sIHsgY29kZTogJ2phJywgbmFtZTogJ1Rp4bq/bmcgTmjhuq10JyB9LCB7IGNvZGU6ICdrbycsIG5hbWU6ICdUaeG6v25nIEjDoG4nIH0sIHsgY29kZTogJ3poJywgbmFtZTogJ1Rp4bq/bmcgVHJ1bmcnIH0sIHsgY29kZTogJ3ZpJywgbmFtZTogJ1Rp4bq/bmcgVmnhu4d0JyB9XTtcclxuICBsYW5ndWFnZTogTGFuZ3VhZ2UgPSAndmknOyBidXN5ID0gZmFsc2U7IHJlY29yZGluZyA9IGZhbHNlOyB1cGxvYWRpbmcgPSBmYWxzZTtcclxuICB0ZXh0ID0gJyc7IGVycm9yID0gJyc7IHN0YXR1cyA9ICdDaOG7jW4gbmfDtG4gbmfhu68sIHNhdSDEkcOzIGLhuqVtIG7Ds2kgaG/hurdjIGNo4buNbiBmaWxlLic7XHJcbiAgam9iPzogSm9iOyBwcml2YXRlIGdlbmVyYXRpb24gPSAwOyBwcml2YXRlIGFib3J0PzogQWJvcnRDb250cm9sbGVyO1xyXG4gIGNvbnN0cnVjdG9yKCkge1xyXG4gICAgdGhpcy5zcGVha2luZy5ldmVudHMkLnBpcGUodGFrZVVudGlsRGVzdHJveWVkKHRoaXMuZGVzdHJveSkpLnN1YnNjcmliZSgoZXZlbnQ6IFNwZWFraW5nRXZlbnQpID0+IHtcclxuICAgICAgaWYgKGV2ZW50LnR5cGUgPT09ICdwYXJ0aWFsJykgdGhpcy50ZXh0ID0gZXZlbnQudGV4dDtcclxuICAgICAgaWYgKGV2ZW50LnR5cGUgPT09ICdzdGF0ZScpIHRoaXMucmVjb3JkaW5nID0gZXZlbnQuc3RhdGUgPT09ICdyZWNvcmRpbmcnO1xyXG4gICAgICBpZiAoZXZlbnQudHlwZSA9PT0gJ2NsaWVudEVycm9yJykgeyB0aGlzLnJlcG9ydChldmVudC5lcnJvcik7IHRoaXMuYnVzeSA9IGZhbHNlOyB0aGlzLnJlY29yZGluZyA9IGZhbHNlOyB9XHJcbiAgICB9KTtcclxuICAgIHRoaXMuZGVzdHJveS5vbkRlc3Ryb3koKCkgPT4geyArK3RoaXMuZ2VuZXJhdGlvbjsgdGhpcy5hYm9ydD8uYWJvcnQoKTsgfSk7XHJcbiAgfVxyXG4gIG5nT25Jbml0KCk6IHZvaWQgeyB0aGlzLmxhbmd1YWdlID0gdGhpcy5pbml0aWFsTGFuZ3VhZ2U7IH1cclxuICBzZWxlY3RMYW5ndWFnZShldmVudDogRXZlbnQpOiB2b2lkIHsgdGhpcy5sYW5ndWFnZSA9IChldmVudC50YXJnZXQgYXMgSFRNTFNlbGVjdEVsZW1lbnQpLnZhbHVlIGFzIExhbmd1YWdlOyB9XHJcbiAgcHJpdmF0ZSByZXBvcnQoZXJyb3I6IHVua25vd24pOiB2b2lkIHtcclxuICAgIGNvbnN0IHZhbHVlID0gZXJyb3IgaW5zdGFuY2VvZiBFcnJvciA/IGVycm9yIDogbmV3IEVycm9yKCdTcGVha2luZyBmYWlsZWQnKTtcclxuICAgIHRoaXMuZXJyb3IgPSB2YWx1ZS5tZXNzYWdlOyB0aGlzLmZhaWxlZC5lbWl0KHZhbHVlKTtcclxuICB9XHJcbiAgYXN5bmMgc3RhcnQoKTogUHJvbWlzZTx2b2lkPiB7XHJcbiAgICBpZiAodGhpcy5idXN5KSByZXR1cm47XHJcbiAgICBjb25zdCBnZW5lcmF0aW9uID0gKyt0aGlzLmdlbmVyYXRpb247XHJcbiAgICB0aGlzLmJ1c3kgPSB0cnVlOyB0aGlzLnVwbG9hZGluZyA9IGZhbHNlOyB0aGlzLmVycm9yID0gJyc7IHRoaXMudGV4dCA9ICcnOyB0aGlzLnN0YXR1cyA9ICfEkGFuZyBt4bufIG1pY3JvcGhvbmUgdsOgIGNodeG6qW4gYuG7iyBtb2RlbOKApic7XHJcbiAgICB0cnkgeyBhd2FpdCB0aGlzLnNwZWFraW5nLnN0YXJ0KHsgbGFuZ3VhZ2U6IHRoaXMubGFuZ3VhZ2UgfSk7IGlmIChnZW5lcmF0aW9uID09PSB0aGlzLmdlbmVyYXRpb24pIHRoaXMuc3RhdHVzID0gJ8SQYW5nIG5naGXigKYnOyB9XHJcbiAgICBjYXRjaCAoZXJyb3IpIHsgaWYgKGdlbmVyYXRpb24gPT09IHRoaXMuZ2VuZXJhdGlvbikgeyB0aGlzLnJlcG9ydChlcnJvcik7IHRoaXMuYnVzeSA9IGZhbHNlOyB9IH1cclxuICB9XHJcbiAgYXN5bmMgc3RvcCgpOiBQcm9taXNlPHZvaWQ+IHtcclxuICAgIGlmICghdGhpcy5yZWNvcmRpbmcpIHJldHVybjtcclxuICAgIGNvbnN0IGdlbmVyYXRpb24gPSB0aGlzLmdlbmVyYXRpb247XHJcbiAgICB0aGlzLnJlY29yZGluZyA9IGZhbHNlOyB0aGlzLnN0YXR1cyA9ICfEkGFuZyBs4bqleSBr4bq/dCBxdeG6o+KApic7XHJcbiAgICB0cnkge1xyXG4gICAgICBjb25zdCByZXN1bHQgPSBhd2FpdCB0aGlzLnNwZWFraW5nLnN0b3AoKTtcclxuICAgICAgaWYgKGdlbmVyYXRpb24gIT09IHRoaXMuZ2VuZXJhdGlvbikgcmV0dXJuO1xyXG4gICAgICB0aGlzLnRleHQgPSByZXN1bHQucmVzdWx0Py50ZXh0ID8/ICcnOyB0aGlzLnN0YXR1cyA9IHJlc3VsdC5zdGF0dXMgPT09ICdub19zcGVlY2gnID8gJ0tow7RuZyBuaOG6rW4gdGjhuqV5IGzhu51pIG7Ds2kuJyA6ICfEkMOjIGhvw6BuIHThuqV0Lic7IHRoaXMuY29tcGxldGVkLmVtaXQocmVzdWx0KTtcclxuICAgIH0gY2F0Y2ggKGVycm9yKSB7IGlmIChnZW5lcmF0aW9uID09PSB0aGlzLmdlbmVyYXRpb24pIHRoaXMucmVwb3J0KGVycm9yKTsgfVxyXG4gICAgZmluYWxseSB7IGlmIChnZW5lcmF0aW9uID09PSB0aGlzLmdlbmVyYXRpb24pIHRoaXMuYnVzeSA9IGZhbHNlOyB9XHJcbiAgfVxyXG4gIGFzeW5jIGNhbmNlbCgpOiBQcm9taXNlPHZvaWQ+IHtcclxuICAgICsrdGhpcy5nZW5lcmF0aW9uOyB0aGlzLmFib3J0Py5hYm9ydCgpO1xyXG4gICAgdHJ5IHsgaWYgKHRoaXMudXBsb2FkaW5nICYmIHRoaXMuam9iKSBhd2FpdCB0aGlzLnNwZWFraW5nLmJhdGNoLmNhbmNlbEpvYih0aGlzLmpvYi5qb2JfaWQpOyBlbHNlIGF3YWl0IHRoaXMuc3BlYWtpbmcuY2FuY2VsKCk7IHRoaXMuc3RhdHVzID0gJ8SQw6MgaOG7p3kuJzsgfVxyXG4gICAgY2F0Y2ggKGVycm9yKSB7IHRoaXMucmVwb3J0KGVycm9yKTsgfVxyXG4gICAgZmluYWxseSB7IHRoaXMuYnVzeSA9IGZhbHNlOyB0aGlzLnJlY29yZGluZyA9IGZhbHNlOyB0aGlzLnVwbG9hZGluZyA9IGZhbHNlOyB9XHJcbiAgfVxyXG4gIGFzeW5jIHVwbG9hZChldmVudDogRXZlbnQpOiBQcm9taXNlPHZvaWQ+IHtcclxuICAgIGNvbnN0IGlucHV0ID0gZXZlbnQudGFyZ2V0IGFzIEhUTUxJbnB1dEVsZW1lbnQ7IGNvbnN0IGZpbGUgPSBpbnB1dC5maWxlcz8uWzBdOyBpbnB1dC52YWx1ZSA9ICcnO1xyXG4gICAgaWYgKCFmaWxlIHx8IHRoaXMuYnVzeSkgcmV0dXJuO1xyXG4gICAgaWYgKGZpbGUuc2l6ZSA+IDI1ICogMTAyNCAqIDEwMjQpIHsgdGhpcy5yZXBvcnQobmV3IEVycm9yKCdGaWxlIHThu5FpIMSRYSAyNSBNQi4nKSk7IHJldHVybjsgfVxyXG4gICAgY29uc3QgZ2VuZXJhdGlvbiA9ICsrdGhpcy5nZW5lcmF0aW9uOyB0aGlzLmFib3J0ID0gbmV3IEFib3J0Q29udHJvbGxlcigpO1xyXG4gICAgdGhpcy5idXN5ID0gdHJ1ZTsgdGhpcy51cGxvYWRpbmcgPSB0cnVlOyB0aGlzLmpvYiA9IHVuZGVmaW5lZDsgdGhpcy50ZXh0ID0gJyc7IHRoaXMuZXJyb3IgPSAnJzsgdGhpcy5zdGF0dXMgPSAnxJBhbmcgdXBsb2Fk4oCmJztcclxuICAgIHRyeSB7XHJcbiAgICAgIGxldCBqb2IgPSBhd2FpdCB0aGlzLnNwZWFraW5nLmJhdGNoLnN1Ym1pdEF1ZGlvKGZpbGUsIHRoaXMubGFuZ3VhZ2UsIGNyeXB0by5yYW5kb21VVUlEKCksICdzaG9ydF9hbnN3ZXInLCBmaWxlLm5hbWUsIHRoaXMuYWJvcnQuc2lnbmFsKTtcclxuICAgICAgY29uc3QgZGVhZGxpbmUgPSBEYXRlLm5vdygpICsgMTAgKiA2MCAqIDEwMDA7XHJcbiAgICAgIHdoaWxlIChnZW5lcmF0aW9uID09PSB0aGlzLmdlbmVyYXRpb24pIHtcclxuICAgICAgICB0aGlzLmpvYiA9IGpvYjtcclxuICAgICAgICBpZiAoWydzdWNjZWVkZWQnLCAnbm9fc3BlZWNoJywgJ2ZhaWxlZCcsICdjYW5jZWxsZWQnXS5pbmNsdWRlcyhqb2Iuc3RhdHVzKSkgYnJlYWs7XHJcbiAgICAgICAgaWYgKERhdGUubm93KCkgPiBkZWFkbGluZSkgdGhyb3cgbmV3IEVycm9yKCdI4bq/dCB0aOG7nWkgZ2lhbiBjaOG7nTsgam9iIGPDsyB0aOG7gyB24bqrbiDEkWFuZyBjaOG6oXkgdHLDqm4gc2VydmVyLicpO1xyXG4gICAgICAgIHRoaXMuc3RhdHVzID0gJ8SQYW5nIG5o4bqtbiBk4bqhbmcgZmlsZeKApic7XHJcbiAgICAgICAgYXdhaXQgbmV3IFByb21pc2UocmVzb2x2ZSA9PiBzZXRUaW1lb3V0KHJlc29sdmUsIDE1MDApKTtcclxuICAgICAgICBpZiAoZ2VuZXJhdGlvbiAhPT0gdGhpcy5nZW5lcmF0aW9uKSByZXR1cm47XHJcbiAgICAgICAgam9iID0gYXdhaXQgdGhpcy5zcGVha2luZy5iYXRjaC5nZXRSZXN1bHQoam9iLmpvYl9pZCwgdGhpcy5hYm9ydC5zaWduYWwpO1xyXG4gICAgICB9XHJcbiAgICAgIGlmIChnZW5lcmF0aW9uICE9PSB0aGlzLmdlbmVyYXRpb24pIHJldHVybjtcclxuICAgICAgaWYgKGpvYi5lcnJvcikgdGhyb3cgbmV3IEVycm9yKGpvYi5lcnJvci5tZXNzYWdlKTtcclxuICAgICAgdGhpcy50ZXh0ID0gam9iLnJlc3VsdD8udGV4dCA/PyAnJzsgdGhpcy5zdGF0dXMgPSBqb2Iuc3RhdHVzID09PSAnbm9fc3BlZWNoJyA/ICdLaMO0bmcgbmjhuq1uIHRo4bqleSBs4budaSBuw7NpLicgOiBqb2Iuc3RhdHVzID09PSAnY2FuY2VsbGVkJyA/ICfEkMOjIGjhu6d5LicgOiAnxJDDoyBob8OgbiB04bqldC4nOyB0aGlzLmNvbXBsZXRlZC5lbWl0KGpvYik7XHJcbiAgICB9IGNhdGNoIChlcnJvcikgeyBpZiAoZ2VuZXJhdGlvbiA9PT0gdGhpcy5nZW5lcmF0aW9uKSB0aGlzLnJlcG9ydChlcnJvcik7IH1cclxuICAgIGZpbmFsbHkgeyBpZiAoZ2VuZXJhdGlvbiA9PT0gdGhpcy5nZW5lcmF0aW9uKSB7IHRoaXMuYnVzeSA9IGZhbHNlOyB0aGlzLnVwbG9hZGluZyA9IGZhbHNlOyB9IH1cclxuICB9XHJcbn1cclxuIl19
@@ -0,0 +1,49 @@
1
+ import { Inject, Injectable, InjectionToken } from '@angular/core';
2
+ import { Subject } from 'rxjs';
3
+ import { BatchSpeakingClient, RealtimeSpeakingClient, SpeakingMicrophone, createSpeakingGateway, } from '@hoanghong/speaking-core';
4
+ import * as i0 from "@angular/core";
5
+ export const SPEAKING_CONFIG = new InjectionToken('FSEL_SPEAKING_CONFIG');
6
+ /** Provide at the screen/component level to give each speaking screen its own lifecycle. */
7
+ export class SpeakingService {
8
+ zone;
9
+ batch;
10
+ realtime;
11
+ eventSubject = new Subject();
12
+ events$ = this.eventSubject.asObservable();
13
+ microphone;
14
+ unsubscribe;
15
+ constructor(config, zone) {
16
+ this.zone = zone;
17
+ this.batch = new BatchSpeakingClient(config.batch);
18
+ this.realtime = new RealtimeSpeakingClient(config.realtime);
19
+ this.microphone = new SpeakingMicrophone(this.realtime, config.microphone);
20
+ this.unsubscribe = this.realtime.onEvent(event => this.zone.run(() => this.eventSubject.next(event)));
21
+ }
22
+ start(options) {
23
+ return this.zone.runOutsideAngular(() => this.microphone.start(options));
24
+ }
25
+ stop() { return this.microphone.stop(); }
26
+ cancel() { return this.microphone.cancel(); }
27
+ ngOnDestroy() {
28
+ this.unsubscribe();
29
+ this.eventSubject.complete();
30
+ void this.microphone.dispose();
31
+ }
32
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: SpeakingService, deps: [{ token: SPEAKING_CONFIG }, { token: i0.NgZone }], target: i0.ɵɵFactoryTarget.Injectable });
33
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: SpeakingService });
34
+ }
35
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: SpeakingService, decorators: [{
36
+ type: Injectable
37
+ }], ctorParameters: () => [{ type: undefined, decorators: [{
38
+ type: Inject,
39
+ args: [SPEAKING_CONFIG]
40
+ }] }, { type: i0.NgZone }] });
41
+ export function provideSpeaking(config) {
42
+ return [{ provide: SPEAKING_CONFIG, useValue: config }, SpeakingService];
43
+ }
44
+ /** Configure once at app bootstrap; every fsel-speaking component owns its session. */
45
+ export function provideSpeakingGateway(options = {}) {
46
+ return [{ provide: SPEAKING_CONFIG, useFactory: () => createSpeakingGateway(options) }];
47
+ }
48
+ export * from '@hoanghong/speaking-core';
49
+ //# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoic3BlYWtpbmcuc2VydmljZS5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbIi4uLy4uL3NyYy9zcGVha2luZy5zZXJ2aWNlLnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUFBLE9BQU8sRUFBRSxNQUFNLEVBQUUsVUFBVSxFQUFFLGNBQWMsRUFBK0IsTUFBTSxlQUFlLENBQUM7QUFDaEcsT0FBTyxFQUFFLE9BQU8sRUFBRSxNQUFNLE1BQU0sQ0FBQztBQUMvQixPQUFPLEVBQ1MsbUJBQW1CLEVBQ2pDLHNCQUFzQixFQUFpQixrQkFBa0IsRUFDakMscUJBQXFCLEdBQzlDLE1BQU0sMEJBQTBCLENBQUM7O0FBUWxDLE1BQU0sQ0FBQyxNQUFNLGVBQWUsR0FBRyxJQUFJLGNBQWMsQ0FBaUIsc0JBQXNCLENBQUMsQ0FBQztBQUUxRiw0RkFBNEY7QUFFNUYsTUFBTSxPQUFPLGVBQWU7SUFRb0Q7SUFQckUsS0FBSyxDQUFzQjtJQUMzQixRQUFRLENBQXlCO0lBQ3pCLFlBQVksR0FBRyxJQUFJLE9BQU8sRUFBaUIsQ0FBQztJQUNwRCxPQUFPLEdBQUcsSUFBSSxDQUFDLFlBQVksQ0FBQyxZQUFZLEVBQUUsQ0FBQztJQUNuQyxVQUFVLENBQXFCO0lBQy9CLFdBQVcsQ0FBYTtJQUV6QyxZQUFxQyxNQUFzQixFQUFtQixJQUFZO1FBQVosU0FBSSxHQUFKLElBQUksQ0FBUTtRQUN4RixJQUFJLENBQUMsS0FBSyxHQUFHLElBQUksbUJBQW1CLENBQUMsTUFBTSxDQUFDLEtBQUssQ0FBQyxDQUFDO1FBQ25ELElBQUksQ0FBQyxRQUFRLEdBQUcsSUFBSSxzQkFBc0IsQ0FBQyxNQUFNLENBQUMsUUFBUSxDQUFDLENBQUM7UUFDNUQsSUFBSSxDQUFDLFVBQVUsR0FBRyxJQUFJLGtCQUFrQixDQUFDLElBQUksQ0FBQyxRQUFRLEVBQUUsTUFBTSxDQUFDLFVBQVUsQ0FBQyxDQUFDO1FBQzNFLElBQUksQ0FBQyxXQUFXLEdBQUcsSUFBSSxDQUFDLFFBQVEsQ0FBQyxPQUFPLENBQUMsS0FBSyxDQUFDLEVBQUUsQ0FBQyxJQUFJLENBQUMsSUFBSSxDQUFDLEdBQUcsQ0FBQyxHQUFHLEVBQUUsQ0FBQyxJQUFJLENBQUMsWUFBWSxDQUFDLElBQUksQ0FBQyxLQUFLLENBQUMsQ0FBQyxDQUFDLENBQUM7SUFDeEcsQ0FBQztJQUVELEtBQUssQ0FBQyxPQUFxQjtRQUN6QixPQUFPLElBQUksQ0FBQyxJQUFJLENBQUMsaUJBQWlCLENBQUMsR0FBRyxFQUFFLENBQUMsSUFBSSxDQUFDLFVBQVUsQ0FBQyxLQUFLLENBQUMsT0FBTyxDQUFDLENBQUMsQ0FBQztJQUMzRSxDQUFDO0lBRUQsSUFBSSxLQUE2QixPQUFPLElBQUksQ0FBQyxVQUFVLENBQUMsSUFBSSxFQUFFLENBQUMsQ0FBQyxDQUFDO0lBQ2pFLE1BQU0sS0FBb0IsT0FBTyxJQUFJLENBQUMsVUFBVSxDQUFDLE1BQU0sRUFBRSxDQUFDLENBQUMsQ0FBQztJQUU1RCxXQUFXO1FBQ1QsSUFBSSxDQUFDLFdBQVcsRUFBRSxDQUFDO1FBQ25CLElBQUksQ0FBQyxZQUFZLENBQUMsUUFBUSxFQUFFLENBQUM7UUFDN0IsS0FBSyxJQUFJLENBQUMsVUFBVSxDQUFDLE9BQU8sRUFBRSxDQUFDO0lBQ2pDLENBQUM7d0dBMUJVLGVBQWUsa0JBUU4sZUFBZTs0R0FSeEIsZUFBZTs7NEZBQWYsZUFBZTtrQkFEM0IsVUFBVTs7MEJBU0ksTUFBTTsyQkFBQyxlQUFlOztBQXFCckMsTUFBTSxVQUFVLGVBQWUsQ0FBQyxNQUFzQjtJQUNwRCxPQUFPLENBQUMsRUFBRSxPQUFPLEVBQUUsZUFBZSxFQUFFLFFBQVEsRUFBRSxNQUFNLEVBQUUsRUFBRSxlQUFlLENBQUMsQ0FBQztBQUMzRSxDQUFDO0FBRUQsdUZBQXVGO0FBQ3ZGLE1BQU0sVUFBVSxzQkFBc0IsQ0FBQyxVQUFrQyxFQUFFO0lBQ3pFLE9BQU8sQ0FBQyxFQUFFLE9BQU8sRUFBRSxlQUFlLEVBQUUsVUFBVSxFQUFFLEdBQUcsRUFBRSxDQUFDLHFCQUFxQixDQUFDLE9BQU8sQ0FBQyxFQUFFLENBQUMsQ0FBQztBQUMxRixDQUFDO0FBSUQsY0FBYywwQkFBMEIsQ0FBQyIsInNvdXJjZXNDb250ZW50IjpbImltcG9ydCB7IEluamVjdCwgSW5qZWN0YWJsZSwgSW5qZWN0aW9uVG9rZW4sIE5nWm9uZSwgT25EZXN0cm95LCBQcm92aWRlciB9IGZyb20gJ0Bhbmd1bGFyL2NvcmUnO1xyXG5pbXBvcnQgeyBTdWJqZWN0IH0gZnJvbSAncnhqcyc7XHJcbmltcG9ydCB7XHJcbiAgQmF0Y2hPcHRpb25zLCBCYXRjaFNwZWFraW5nQ2xpZW50LCBNaWNyb3Bob25lT3B0aW9ucywgUmVhbHRpbWVGaW5hbCwgUmVhbHRpbWVPcHRpb25zLFxyXG4gIFJlYWx0aW1lU3BlYWtpbmdDbGllbnQsIFNwZWFraW5nRXZlbnQsIFNwZWFraW5nTWljcm9waG9uZSwgU3RhcnRPcHRpb25zLFxyXG4gIFNwZWFraW5nR2F0ZXdheU9wdGlvbnMsIGNyZWF0ZVNwZWFraW5nR2F0ZXdheSxcclxufSBmcm9tICdAaG9hbmdob25nL3NwZWFraW5nLWNvcmUnO1xyXG5cclxuZXhwb3J0IGludGVyZmFjZSBTcGVha2luZ0NvbmZpZyB7XHJcbiAgYmF0Y2g6IEJhdGNoT3B0aW9ucztcclxuICByZWFsdGltZTogUmVhbHRpbWVPcHRpb25zO1xyXG4gIG1pY3JvcGhvbmU6IE1pY3JvcGhvbmVPcHRpb25zO1xyXG59XHJcblxyXG5leHBvcnQgY29uc3QgU1BFQUtJTkdfQ09ORklHID0gbmV3IEluamVjdGlvblRva2VuPFNwZWFraW5nQ29uZmlnPignRlNFTF9TUEVBS0lOR19DT05GSUcnKTtcclxuXHJcbi8qKiBQcm92aWRlIGF0IHRoZSBzY3JlZW4vY29tcG9uZW50IGxldmVsIHRvIGdpdmUgZWFjaCBzcGVha2luZyBzY3JlZW4gaXRzIG93biBsaWZlY3ljbGUuICovXHJcbkBJbmplY3RhYmxlKClcclxuZXhwb3J0IGNsYXNzIFNwZWFraW5nU2VydmljZSBpbXBsZW1lbnRzIE9uRGVzdHJveSB7XHJcbiAgcmVhZG9ubHkgYmF0Y2g6IEJhdGNoU3BlYWtpbmdDbGllbnQ7XHJcbiAgcmVhZG9ubHkgcmVhbHRpbWU6IFJlYWx0aW1lU3BlYWtpbmdDbGllbnQ7XHJcbiAgcHJpdmF0ZSByZWFkb25seSBldmVudFN1YmplY3QgPSBuZXcgU3ViamVjdDxTcGVha2luZ0V2ZW50PigpO1xyXG4gIHJlYWRvbmx5IGV2ZW50cyQgPSB0aGlzLmV2ZW50U3ViamVjdC5hc09ic2VydmFibGUoKTtcclxuICBwcml2YXRlIHJlYWRvbmx5IG1pY3JvcGhvbmU6IFNwZWFraW5nTWljcm9waG9uZTtcclxuICBwcml2YXRlIHJlYWRvbmx5IHVuc3Vic2NyaWJlOiAoKSA9PiB2b2lkO1xyXG5cclxuICBjb25zdHJ1Y3RvcihASW5qZWN0KFNQRUFLSU5HX0NPTkZJRykgY29uZmlnOiBTcGVha2luZ0NvbmZpZywgcHJpdmF0ZSByZWFkb25seSB6b25lOiBOZ1pvbmUpIHtcclxuICAgIHRoaXMuYmF0Y2ggPSBuZXcgQmF0Y2hTcGVha2luZ0NsaWVudChjb25maWcuYmF0Y2gpO1xyXG4gICAgdGhpcy5yZWFsdGltZSA9IG5ldyBSZWFsdGltZVNwZWFraW5nQ2xpZW50KGNvbmZpZy5yZWFsdGltZSk7XHJcbiAgICB0aGlzLm1pY3JvcGhvbmUgPSBuZXcgU3BlYWtpbmdNaWNyb3Bob25lKHRoaXMucmVhbHRpbWUsIGNvbmZpZy5taWNyb3Bob25lKTtcclxuICAgIHRoaXMudW5zdWJzY3JpYmUgPSB0aGlzLnJlYWx0aW1lLm9uRXZlbnQoZXZlbnQgPT4gdGhpcy56b25lLnJ1bigoKSA9PiB0aGlzLmV2ZW50U3ViamVjdC5uZXh0KGV2ZW50KSkpO1xyXG4gIH1cclxuXHJcbiAgc3RhcnQob3B0aW9uczogU3RhcnRPcHRpb25zKTogUHJvbWlzZTx2b2lkPiB7XHJcbiAgICByZXR1cm4gdGhpcy56b25lLnJ1bk91dHNpZGVBbmd1bGFyKCgpID0+IHRoaXMubWljcm9waG9uZS5zdGFydChvcHRpb25zKSk7XHJcbiAgfVxyXG5cclxuICBzdG9wKCk6IFByb21pc2U8UmVhbHRpbWVGaW5hbD4geyByZXR1cm4gdGhpcy5taWNyb3Bob25lLnN0b3AoKTsgfVxyXG4gIGNhbmNlbCgpOiBQcm9taXNlPHZvaWQ+IHsgcmV0dXJuIHRoaXMubWljcm9waG9uZS5jYW5jZWwoKTsgfVxyXG5cclxuICBuZ09uRGVzdHJveSgpOiB2b2lkIHtcclxuICAgIHRoaXMudW5zdWJzY3JpYmUoKTtcclxuICAgIHRoaXMuZXZlbnRTdWJqZWN0LmNvbXBsZXRlKCk7XHJcbiAgICB2b2lkIHRoaXMubWljcm9waG9uZS5kaXNwb3NlKCk7XHJcbiAgfVxyXG59XHJcblxyXG5leHBvcnQgZnVuY3Rpb24gcHJvdmlkZVNwZWFraW5nKGNvbmZpZzogU3BlYWtpbmdDb25maWcpOiBQcm92aWRlcltdIHtcclxuICByZXR1cm4gW3sgcHJvdmlkZTogU1BFQUtJTkdfQ09ORklHLCB1c2VWYWx1ZTogY29uZmlnIH0sIFNwZWFraW5nU2VydmljZV07XHJcbn1cclxuXHJcbi8qKiBDb25maWd1cmUgb25jZSBhdCBhcHAgYm9vdHN0cmFwOyBldmVyeSBmc2VsLXNwZWFraW5nIGNvbXBvbmVudCBvd25zIGl0cyBzZXNzaW9uLiAqL1xyXG5leHBvcnQgZnVuY3Rpb24gcHJvdmlkZVNwZWFraW5nR2F0ZXdheShvcHRpb25zOiBTcGVha2luZ0dhdGV3YXlPcHRpb25zID0ge30pOiBQcm92aWRlcltdIHtcclxuICByZXR1cm4gW3sgcHJvdmlkZTogU1BFQUtJTkdfQ09ORklHLCB1c2VGYWN0b3J5OiAoKSA9PiBjcmVhdGVTcGVha2luZ0dhdGV3YXkob3B0aW9ucykgfV07XHJcbn1cclxuXHJcblxyXG5cclxuZXhwb3J0ICogZnJvbSAnQGhvYW5naG9uZy9zcGVha2luZy1jb3JlJztcclxuIl19
@@ -0,0 +1,259 @@
1
+ import * as i0 from '@angular/core';
2
+ import { InjectionToken, Inject, Injectable, EventEmitter, inject, DestroyRef, Output, Input, Component } from '@angular/core';
3
+ import { Subject } from 'rxjs';
4
+ import { BatchSpeakingClient, RealtimeSpeakingClient, SpeakingMicrophone, createSpeakingGateway } from '@hoanghong/speaking-core';
5
+ export * from '@hoanghong/speaking-core';
6
+ import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
7
+
8
+ const SPEAKING_CONFIG = new InjectionToken('FSEL_SPEAKING_CONFIG');
9
+ /** Provide at the screen/component level to give each speaking screen its own lifecycle. */
10
+ class SpeakingService {
11
+ zone;
12
+ batch;
13
+ realtime;
14
+ eventSubject = new Subject();
15
+ events$ = this.eventSubject.asObservable();
16
+ microphone;
17
+ unsubscribe;
18
+ constructor(config, zone) {
19
+ this.zone = zone;
20
+ this.batch = new BatchSpeakingClient(config.batch);
21
+ this.realtime = new RealtimeSpeakingClient(config.realtime);
22
+ this.microphone = new SpeakingMicrophone(this.realtime, config.microphone);
23
+ this.unsubscribe = this.realtime.onEvent(event => this.zone.run(() => this.eventSubject.next(event)));
24
+ }
25
+ start(options) {
26
+ return this.zone.runOutsideAngular(() => this.microphone.start(options));
27
+ }
28
+ stop() { return this.microphone.stop(); }
29
+ cancel() { return this.microphone.cancel(); }
30
+ ngOnDestroy() {
31
+ this.unsubscribe();
32
+ this.eventSubject.complete();
33
+ void this.microphone.dispose();
34
+ }
35
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: SpeakingService, deps: [{ token: SPEAKING_CONFIG }, { token: i0.NgZone }], target: i0.ɵɵFactoryTarget.Injectable });
36
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: SpeakingService });
37
+ }
38
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: SpeakingService, decorators: [{
39
+ type: Injectable
40
+ }], ctorParameters: () => [{ type: undefined, decorators: [{
41
+ type: Inject,
42
+ args: [SPEAKING_CONFIG]
43
+ }] }, { type: i0.NgZone }] });
44
+ function provideSpeaking(config) {
45
+ return [{ provide: SPEAKING_CONFIG, useValue: config }, SpeakingService];
46
+ }
47
+ /** Configure once at app bootstrap; every fsel-speaking component owns its session. */
48
+ function provideSpeakingGateway(options = {}) {
49
+ return [{ provide: SPEAKING_CONFIG, useFactory: () => createSpeakingGateway(options) }];
50
+ }
51
+
52
+ class FselSpeakingComponent {
53
+ initialLanguage = 'vi';
54
+ uploadEnabled = true;
55
+ completed = new EventEmitter();
56
+ failed = new EventEmitter();
57
+ speaking = inject(SpeakingService);
58
+ destroy = inject(DestroyRef);
59
+ languages = [{ code: 'en', name: 'Tiếng Anh' }, { code: 'ja', name: 'Tiếng Nhật' }, { code: 'ko', name: 'Tiếng Hàn' }, { code: 'zh', name: 'Tiếng Trung' }, { code: 'vi', name: 'Tiếng Việt' }];
60
+ language = 'vi';
61
+ busy = false;
62
+ recording = false;
63
+ uploading = false;
64
+ text = '';
65
+ error = '';
66
+ status = 'Chọn ngôn ngữ, sau đó bấm nói hoặc chọn file.';
67
+ job;
68
+ generation = 0;
69
+ abort;
70
+ constructor() {
71
+ this.speaking.events$.pipe(takeUntilDestroyed(this.destroy)).subscribe((event) => {
72
+ if (event.type === 'partial')
73
+ this.text = event.text;
74
+ if (event.type === 'state')
75
+ this.recording = event.state === 'recording';
76
+ if (event.type === 'clientError') {
77
+ this.report(event.error);
78
+ this.busy = false;
79
+ this.recording = false;
80
+ }
81
+ });
82
+ this.destroy.onDestroy(() => { ++this.generation; this.abort?.abort(); });
83
+ }
84
+ ngOnInit() { this.language = this.initialLanguage; }
85
+ selectLanguage(event) { this.language = event.target.value; }
86
+ report(error) {
87
+ const value = error instanceof Error ? error : new Error('Speaking failed');
88
+ this.error = value.message;
89
+ this.failed.emit(value);
90
+ }
91
+ async start() {
92
+ if (this.busy)
93
+ return;
94
+ const generation = ++this.generation;
95
+ this.busy = true;
96
+ this.uploading = false;
97
+ this.error = '';
98
+ this.text = '';
99
+ this.status = 'Đang mở microphone và chuẩn bị model…';
100
+ try {
101
+ await this.speaking.start({ language: this.language });
102
+ if (generation === this.generation)
103
+ this.status = 'Đang nghe…';
104
+ }
105
+ catch (error) {
106
+ if (generation === this.generation) {
107
+ this.report(error);
108
+ this.busy = false;
109
+ }
110
+ }
111
+ }
112
+ async stop() {
113
+ if (!this.recording)
114
+ return;
115
+ const generation = this.generation;
116
+ this.recording = false;
117
+ this.status = 'Đang lấy kết quả…';
118
+ try {
119
+ const result = await this.speaking.stop();
120
+ if (generation !== this.generation)
121
+ return;
122
+ this.text = result.result?.text ?? '';
123
+ this.status = result.status === 'no_speech' ? 'Không nhận thấy lời nói.' : 'Đã hoàn tất.';
124
+ this.completed.emit(result);
125
+ }
126
+ catch (error) {
127
+ if (generation === this.generation)
128
+ this.report(error);
129
+ }
130
+ finally {
131
+ if (generation === this.generation)
132
+ this.busy = false;
133
+ }
134
+ }
135
+ async cancel() {
136
+ ++this.generation;
137
+ this.abort?.abort();
138
+ try {
139
+ if (this.uploading && this.job)
140
+ await this.speaking.batch.cancelJob(this.job.job_id);
141
+ else
142
+ await this.speaking.cancel();
143
+ this.status = 'Đã hủy.';
144
+ }
145
+ catch (error) {
146
+ this.report(error);
147
+ }
148
+ finally {
149
+ this.busy = false;
150
+ this.recording = false;
151
+ this.uploading = false;
152
+ }
153
+ }
154
+ async upload(event) {
155
+ const input = event.target;
156
+ const file = input.files?.[0];
157
+ input.value = '';
158
+ if (!file || this.busy)
159
+ return;
160
+ if (file.size > 25 * 1024 * 1024) {
161
+ this.report(new Error('File tối đa 25 MB.'));
162
+ return;
163
+ }
164
+ const generation = ++this.generation;
165
+ this.abort = new AbortController();
166
+ this.busy = true;
167
+ this.uploading = true;
168
+ this.job = undefined;
169
+ this.text = '';
170
+ this.error = '';
171
+ this.status = 'Đang upload…';
172
+ try {
173
+ let job = await this.speaking.batch.submitAudio(file, this.language, crypto.randomUUID(), 'short_answer', file.name, this.abort.signal);
174
+ const deadline = Date.now() + 10 * 60 * 1000;
175
+ while (generation === this.generation) {
176
+ this.job = job;
177
+ if (['succeeded', 'no_speech', 'failed', 'cancelled'].includes(job.status))
178
+ break;
179
+ if (Date.now() > deadline)
180
+ throw new Error('Hết thời gian chờ; job có thể vẫn đang chạy trên server.');
181
+ this.status = 'Đang nhận dạng file…';
182
+ await new Promise(resolve => setTimeout(resolve, 1500));
183
+ if (generation !== this.generation)
184
+ return;
185
+ job = await this.speaking.batch.getResult(job.job_id, this.abort.signal);
186
+ }
187
+ if (generation !== this.generation)
188
+ return;
189
+ if (job.error)
190
+ throw new Error(job.error.message);
191
+ this.text = job.result?.text ?? '';
192
+ this.status = job.status === 'no_speech' ? 'Không nhận thấy lời nói.' : job.status === 'cancelled' ? 'Đã hủy.' : 'Đã hoàn tất.';
193
+ this.completed.emit(job);
194
+ }
195
+ catch (error) {
196
+ if (generation === this.generation)
197
+ this.report(error);
198
+ }
199
+ finally {
200
+ if (generation === this.generation) {
201
+ this.busy = false;
202
+ this.uploading = false;
203
+ }
204
+ }
205
+ }
206
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: FselSpeakingComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
207
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.2.14", type: FselSpeakingComponent, isStandalone: true, selector: "fsel-speaking", inputs: { initialLanguage: "initialLanguage", uploadEnabled: "uploadEnabled" }, outputs: { completed: "completed", failed: "failed" }, providers: [SpeakingService], ngImport: i0, template: `
208
+ <section aria-label="FSEL Speaking">
209
+ <label>Ngôn ngữ <select [value]="language" [disabled]="busy" (change)="selectLanguage($event)">
210
+ @for (item of languages; track item.code) { <option [value]="item.code" [selected]="item.code === language">{{ item.name }}</option> }
211
+ </select></label>
212
+ <div class="actions">
213
+ <button type="button" (click)="start()" [disabled]="busy">Bắt đầu nói</button>
214
+ <button type="button" (click)="stop()" [disabled]="!recording">Dừng và lấy kết quả</button>
215
+ <button type="button" (click)="cancel()" [disabled]="!busy || (uploading && !job)">Hủy</button>
216
+ </div>
217
+ @if (uploadEnabled) { <label>Hoặc chọn file audio (tối đa 25 MB)
218
+ <input type="file" accept="audio/*,.wav,.mp3,.m4a,.ogg,.flac,.webm" [disabled]="busy" (change)="upload($event)">
219
+ </label> }
220
+ <p role="status">{{ status }}</p>
221
+ @if (error) { <p role="alert" class="error">{{ error }}</p> }
222
+ <label>Văn bản nhận dạng <textarea rows="7" readonly [value]="text"></textarea></label>
223
+ </section>`, isInline: true, styles: [":host{display:block;font:inherit;color:inherit}section{border:1px solid #b9c1bc;padding:20px;border-radius:4px}label{display:grid;gap:8px;margin-bottom:16px}select,input,textarea,button{font:inherit;box-sizing:border-box;max-width:100%}select,input,textarea{padding:10px;border:1px solid #929c95;border-radius:3px;background:#fff;color:#202723}textarea{width:100%;resize:vertical}.actions{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:16px}button{min-height:44px;padding:8px 14px;background:#365448;color:#fff;border:1px solid #365448;border-radius:4px;cursor:pointer}button:disabled{opacity:.5;cursor:default}button:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible{outline:3px solid #a66a15;outline-offset:2px}.error{color:#a02020;overflow-wrap:anywhere}\n"] });
224
+ }
225
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: FselSpeakingComponent, decorators: [{
226
+ type: Component,
227
+ args: [{ selector: 'fsel-speaking', standalone: true, providers: [SpeakingService], template: `
228
+ <section aria-label="FSEL Speaking">
229
+ <label>Ngôn ngữ <select [value]="language" [disabled]="busy" (change)="selectLanguage($event)">
230
+ @for (item of languages; track item.code) { <option [value]="item.code" [selected]="item.code === language">{{ item.name }}</option> }
231
+ </select></label>
232
+ <div class="actions">
233
+ <button type="button" (click)="start()" [disabled]="busy">Bắt đầu nói</button>
234
+ <button type="button" (click)="stop()" [disabled]="!recording">Dừng và lấy kết quả</button>
235
+ <button type="button" (click)="cancel()" [disabled]="!busy || (uploading && !job)">Hủy</button>
236
+ </div>
237
+ @if (uploadEnabled) { <label>Hoặc chọn file audio (tối đa 25 MB)
238
+ <input type="file" accept="audio/*,.wav,.mp3,.m4a,.ogg,.flac,.webm" [disabled]="busy" (change)="upload($event)">
239
+ </label> }
240
+ <p role="status">{{ status }}</p>
241
+ @if (error) { <p role="alert" class="error">{{ error }}</p> }
242
+ <label>Văn bản nhận dạng <textarea rows="7" readonly [value]="text"></textarea></label>
243
+ </section>`, styles: [":host{display:block;font:inherit;color:inherit}section{border:1px solid #b9c1bc;padding:20px;border-radius:4px}label{display:grid;gap:8px;margin-bottom:16px}select,input,textarea,button{font:inherit;box-sizing:border-box;max-width:100%}select,input,textarea{padding:10px;border:1px solid #929c95;border-radius:3px;background:#fff;color:#202723}textarea{width:100%;resize:vertical}.actions{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:16px}button{min-height:44px;padding:8px 14px;background:#365448;color:#fff;border:1px solid #365448;border-radius:4px;cursor:pointer}button:disabled{opacity:.5;cursor:default}button:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible{outline:3px solid #a66a15;outline-offset:2px}.error{color:#a02020;overflow-wrap:anywhere}\n"] }]
244
+ }], ctorParameters: () => [], propDecorators: { initialLanguage: [{
245
+ type: Input
246
+ }], uploadEnabled: [{
247
+ type: Input
248
+ }], completed: [{
249
+ type: Output
250
+ }], failed: [{
251
+ type: Output
252
+ }] } });
253
+
254
+ /**
255
+ * Generated bundle index. Do not edit.
256
+ */
257
+
258
+ export { FselSpeakingComponent, SPEAKING_CONFIG, SpeakingService, provideSpeaking, provideSpeakingGateway };
259
+ //# sourceMappingURL=hoanghong-speaking-angular.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"hoanghong-speaking-angular.mjs","sources":["../../src/speaking.service.ts","../../src/speaking.component.ts","../../src/hoanghong-speaking-angular.ts"],"sourcesContent":["import { Inject, Injectable, InjectionToken, NgZone, OnDestroy, Provider } from '@angular/core';\r\nimport { Subject } from 'rxjs';\r\nimport {\r\n BatchOptions, BatchSpeakingClient, MicrophoneOptions, RealtimeFinal, RealtimeOptions,\r\n RealtimeSpeakingClient, SpeakingEvent, SpeakingMicrophone, StartOptions,\r\n SpeakingGatewayOptions, createSpeakingGateway,\r\n} from '@hoanghong/speaking-core';\r\n\r\nexport interface SpeakingConfig {\r\n batch: BatchOptions;\r\n realtime: RealtimeOptions;\r\n microphone: MicrophoneOptions;\r\n}\r\n\r\nexport const SPEAKING_CONFIG = new InjectionToken<SpeakingConfig>('FSEL_SPEAKING_CONFIG');\r\n\r\n/** Provide at the screen/component level to give each speaking screen its own lifecycle. */\r\n@Injectable()\r\nexport class SpeakingService implements OnDestroy {\r\n readonly batch: BatchSpeakingClient;\r\n readonly realtime: RealtimeSpeakingClient;\r\n private readonly eventSubject = new Subject<SpeakingEvent>();\r\n readonly events$ = this.eventSubject.asObservable();\r\n private readonly microphone: SpeakingMicrophone;\r\n private readonly unsubscribe: () => void;\r\n\r\n constructor(@Inject(SPEAKING_CONFIG) config: SpeakingConfig, private readonly zone: NgZone) {\r\n this.batch = new BatchSpeakingClient(config.batch);\r\n this.realtime = new RealtimeSpeakingClient(config.realtime);\r\n this.microphone = new SpeakingMicrophone(this.realtime, config.microphone);\r\n this.unsubscribe = this.realtime.onEvent(event => this.zone.run(() => this.eventSubject.next(event)));\r\n }\r\n\r\n start(options: StartOptions): Promise<void> {\r\n return this.zone.runOutsideAngular(() => this.microphone.start(options));\r\n }\r\n\r\n stop(): Promise<RealtimeFinal> { return this.microphone.stop(); }\r\n cancel(): Promise<void> { return this.microphone.cancel(); }\r\n\r\n ngOnDestroy(): void {\r\n this.unsubscribe();\r\n this.eventSubject.complete();\r\n void this.microphone.dispose();\r\n }\r\n}\r\n\r\nexport function provideSpeaking(config: SpeakingConfig): Provider[] {\r\n return [{ provide: SPEAKING_CONFIG, useValue: config }, SpeakingService];\r\n}\r\n\r\n/** Configure once at app bootstrap; every fsel-speaking component owns its session. */\r\nexport function provideSpeakingGateway(options: SpeakingGatewayOptions = {}): Provider[] {\r\n return [{ provide: SPEAKING_CONFIG, useFactory: () => createSpeakingGateway(options) }];\r\n}\r\n\r\n\r\n\r\nexport * from '@hoanghong/speaking-core';\r\n","import { Component, DestroyRef, EventEmitter, Input, OnInit, Output, inject } from '@angular/core';\r\nimport { takeUntilDestroyed } from '@angular/core/rxjs-interop';\r\nimport { Job, Language, RealtimeFinal, SpeakingEvent } from '@hoanghong/speaking-core';\r\nimport { SpeakingService } from './speaking.service';\r\n\r\n@Component({\r\n selector: 'fsel-speaking', standalone: true, providers: [SpeakingService],\r\n template: `\r\n <section aria-label=\"FSEL Speaking\">\r\n <label>Ngôn ngữ <select [value]=\"language\" [disabled]=\"busy\" (change)=\"selectLanguage($event)\">\r\n @for (item of languages; track item.code) { <option [value]=\"item.code\" [selected]=\"item.code === language\">{{ item.name }}</option> }\r\n </select></label>\r\n <div class=\"actions\">\r\n <button type=\"button\" (click)=\"start()\" [disabled]=\"busy\">Bắt đầu nói</button>\r\n <button type=\"button\" (click)=\"stop()\" [disabled]=\"!recording\">Dừng và lấy kết quả</button>\r\n <button type=\"button\" (click)=\"cancel()\" [disabled]=\"!busy || (uploading && !job)\">Hủy</button>\r\n </div>\r\n @if (uploadEnabled) { <label>Hoặc chọn file audio (tối đa 25 MB)\r\n <input type=\"file\" accept=\"audio/*,.wav,.mp3,.m4a,.ogg,.flac,.webm\" [disabled]=\"busy\" (change)=\"upload($event)\">\r\n </label> }\r\n <p role=\"status\">{{ status }}</p>\r\n @if (error) { <p role=\"alert\" class=\"error\">{{ error }}</p> }\r\n <label>Văn bản nhận dạng <textarea rows=\"7\" readonly [value]=\"text\"></textarea></label>\r\n </section>`,\r\n styles: [`:host{display:block;font:inherit;color:inherit}section{border:1px solid #b9c1bc;padding:20px;border-radius:4px}label{display:grid;gap:8px;margin-bottom:16px}select,input,textarea,button{font:inherit;box-sizing:border-box;max-width:100%}select,input,textarea{padding:10px;border:1px solid #929c95;border-radius:3px;background:#fff;color:#202723}textarea{width:100%;resize:vertical}.actions{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:16px}button{min-height:44px;padding:8px 14px;background:#365448;color:white;border:1px solid #365448;border-radius:4px;cursor:pointer}button:disabled{opacity:.5;cursor:default}button:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible{outline:3px solid #a66a15;outline-offset:2px}.error{color:#a02020;overflow-wrap:anywhere}`],\r\n})\r\nexport class FselSpeakingComponent implements OnInit {\r\n @Input() initialLanguage: Language = 'vi';\r\n @Input() uploadEnabled = true;\r\n @Output() completed = new EventEmitter<RealtimeFinal | Job>();\r\n @Output() failed = new EventEmitter<Error>();\r\n readonly speaking = inject(SpeakingService);\r\n private readonly destroy = inject(DestroyRef);\r\n readonly languages = [{ code: 'en', name: 'Tiếng Anh' }, { code: 'ja', name: 'Tiếng Nhật' }, { code: 'ko', name: 'Tiếng Hàn' }, { code: 'zh', name: 'Tiếng Trung' }, { code: 'vi', name: 'Tiếng Việt' }];\r\n language: Language = 'vi'; busy = false; recording = false; uploading = false;\r\n text = ''; error = ''; status = 'Chọn ngôn ngữ, sau đó bấm nói hoặc chọn file.';\r\n job?: Job; private generation = 0; private abort?: AbortController;\r\n constructor() {\r\n this.speaking.events$.pipe(takeUntilDestroyed(this.destroy)).subscribe((event: SpeakingEvent) => {\r\n if (event.type === 'partial') this.text = event.text;\r\n if (event.type === 'state') this.recording = event.state === 'recording';\r\n if (event.type === 'clientError') { this.report(event.error); this.busy = false; this.recording = false; }\r\n });\r\n this.destroy.onDestroy(() => { ++this.generation; this.abort?.abort(); });\r\n }\r\n ngOnInit(): void { this.language = this.initialLanguage; }\r\n selectLanguage(event: Event): void { this.language = (event.target as HTMLSelectElement).value as Language; }\r\n private report(error: unknown): void {\r\n const value = error instanceof Error ? error : new Error('Speaking failed');\r\n this.error = value.message; this.failed.emit(value);\r\n }\r\n async start(): Promise<void> {\r\n if (this.busy) return;\r\n const generation = ++this.generation;\r\n this.busy = true; this.uploading = false; this.error = ''; this.text = ''; this.status = 'Đang mở microphone và chuẩn bị model…';\r\n try { await this.speaking.start({ language: this.language }); if (generation === this.generation) this.status = 'Đang nghe…'; }\r\n catch (error) { if (generation === this.generation) { this.report(error); this.busy = false; } }\r\n }\r\n async stop(): Promise<void> {\r\n if (!this.recording) return;\r\n const generation = this.generation;\r\n this.recording = false; this.status = 'Đang lấy kết quả…';\r\n try {\r\n const result = await this.speaking.stop();\r\n if (generation !== this.generation) return;\r\n this.text = result.result?.text ?? ''; this.status = result.status === 'no_speech' ? 'Không nhận thấy lời nói.' : 'Đã hoàn tất.'; this.completed.emit(result);\r\n } catch (error) { if (generation === this.generation) this.report(error); }\r\n finally { if (generation === this.generation) this.busy = false; }\r\n }\r\n async cancel(): Promise<void> {\r\n ++this.generation; this.abort?.abort();\r\n try { if (this.uploading && this.job) await this.speaking.batch.cancelJob(this.job.job_id); else await this.speaking.cancel(); this.status = 'Đã hủy.'; }\r\n catch (error) { this.report(error); }\r\n finally { this.busy = false; this.recording = false; this.uploading = false; }\r\n }\r\n async upload(event: Event): Promise<void> {\r\n const input = event.target as HTMLInputElement; const file = input.files?.[0]; input.value = '';\r\n if (!file || this.busy) return;\r\n if (file.size > 25 * 1024 * 1024) { this.report(new Error('File tối đa 25 MB.')); return; }\r\n const generation = ++this.generation; this.abort = new AbortController();\r\n this.busy = true; this.uploading = true; this.job = undefined; this.text = ''; this.error = ''; this.status = 'Đang upload…';\r\n try {\r\n let job = await this.speaking.batch.submitAudio(file, this.language, crypto.randomUUID(), 'short_answer', file.name, this.abort.signal);\r\n const deadline = Date.now() + 10 * 60 * 1000;\r\n while (generation === this.generation) {\r\n this.job = job;\r\n if (['succeeded', 'no_speech', 'failed', 'cancelled'].includes(job.status)) break;\r\n if (Date.now() > deadline) throw new Error('Hết thời gian chờ; job có thể vẫn đang chạy trên server.');\r\n this.status = 'Đang nhận dạng file…';\r\n await new Promise(resolve => setTimeout(resolve, 1500));\r\n if (generation !== this.generation) return;\r\n job = await this.speaking.batch.getResult(job.job_id, this.abort.signal);\r\n }\r\n if (generation !== this.generation) return;\r\n if (job.error) throw new Error(job.error.message);\r\n this.text = job.result?.text ?? ''; this.status = job.status === 'no_speech' ? 'Không nhận thấy lời nói.' : job.status === 'cancelled' ? 'Đã hủy.' : 'Đã hoàn tất.'; this.completed.emit(job);\r\n } catch (error) { if (generation === this.generation) this.report(error); }\r\n finally { if (generation === this.generation) { this.busy = false; this.uploading = false; } }\r\n }\r\n}\r\n","/**\n * Generated bundle index. Do not edit.\n */\n\nexport * from './public-api';\n"],"names":[],"mappings":"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"}
package/index.d.ts ADDED
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Generated bundle index. Do not edit.
3
+ */
4
+ /// <amd-module name="@hoanghong/speaking-angular" />
5
+ export * from './public-api';
package/package.json CHANGED
@@ -1,6 +1,34 @@
1
1
  {
2
2
  "name": "@hoanghong/speaking-angular",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.2.0",
4
+ "description": "Ready-to-use online speaking: five languages, microphone realtime and audio upload",
5
+ "type": "module",
6
+ "peerDependencies": {
7
+ "@angular/common": ">=18 <19",
8
+ "@angular/core": ">=18 <19",
9
+ "rxjs": "^7.8.0"
10
+ },
11
+ "sideEffects": false,
12
+ "license": "MIT",
13
+ "dependencies": {
14
+ "@hoanghong/speaking-core": "^0.2.0",
15
+ "tslib": "^2.3.0"
16
+ },
17
+ "publishConfig": {
18
+ "access": "public",
19
+ "registry": "https://registry.npmjs.org/"
20
+ },
21
+ "module": "fesm2022/hoanghong-speaking-angular.mjs",
22
+ "typings": "index.d.ts",
23
+ "exports": {
24
+ "./package.json": {
25
+ "default": "./package.json"
26
+ },
27
+ ".": {
28
+ "types": "./index.d.ts",
29
+ "esm2022": "./esm2022/hoanghong-speaking-angular.mjs",
30
+ "esm": "./esm2022/hoanghong-speaking-angular.mjs",
31
+ "default": "./fesm2022/hoanghong-speaking-angular.mjs"
32
+ }
33
+ }
6
34
  }
@@ -0,0 +1,2 @@
1
+ export * from './speaking.service';
2
+ export * from './speaking.component';
@@ -0,0 +1,36 @@
1
+ import { EventEmitter, OnInit } from '@angular/core';
2
+ import { Job, Language, RealtimeFinal } from '@hoanghong/speaking-core';
3
+ import { SpeakingService } from './speaking.service';
4
+ import * as i0 from "@angular/core";
5
+ export declare class FselSpeakingComponent implements OnInit {
6
+ initialLanguage: Language;
7
+ uploadEnabled: boolean;
8
+ completed: EventEmitter<RealtimeFinal | Job>;
9
+ failed: EventEmitter<Error>;
10
+ readonly speaking: SpeakingService;
11
+ private readonly destroy;
12
+ readonly languages: {
13
+ code: string;
14
+ name: string;
15
+ }[];
16
+ language: Language;
17
+ busy: boolean;
18
+ recording: boolean;
19
+ uploading: boolean;
20
+ text: string;
21
+ error: string;
22
+ status: string;
23
+ job?: Job;
24
+ private generation;
25
+ private abort?;
26
+ constructor();
27
+ ngOnInit(): void;
28
+ selectLanguage(event: Event): void;
29
+ private report;
30
+ start(): Promise<void>;
31
+ stop(): Promise<void>;
32
+ cancel(): Promise<void>;
33
+ upload(event: Event): Promise<void>;
34
+ static ɵfac: i0.ɵɵFactoryDeclaration<FselSpeakingComponent, never>;
35
+ static ɵcmp: i0.ɵɵComponentDeclaration<FselSpeakingComponent, "fsel-speaking", never, { "initialLanguage": { "alias": "initialLanguage"; "required": false; }; "uploadEnabled": { "alias": "uploadEnabled"; "required": false; }; }, { "completed": "completed"; "failed": "failed"; }, never, never, true, never>;
36
+ }
@@ -0,0 +1,30 @@
1
+ import { InjectionToken, NgZone, OnDestroy, Provider } from '@angular/core';
2
+ import { BatchOptions, BatchSpeakingClient, MicrophoneOptions, RealtimeFinal, RealtimeOptions, RealtimeSpeakingClient, SpeakingEvent, StartOptions, SpeakingGatewayOptions } from '@hoanghong/speaking-core';
3
+ import * as i0 from "@angular/core";
4
+ export interface SpeakingConfig {
5
+ batch: BatchOptions;
6
+ realtime: RealtimeOptions;
7
+ microphone: MicrophoneOptions;
8
+ }
9
+ export declare const SPEAKING_CONFIG: InjectionToken<SpeakingConfig>;
10
+ /** Provide at the screen/component level to give each speaking screen its own lifecycle. */
11
+ export declare class SpeakingService implements OnDestroy {
12
+ private readonly zone;
13
+ readonly batch: BatchSpeakingClient;
14
+ readonly realtime: RealtimeSpeakingClient;
15
+ private readonly eventSubject;
16
+ readonly events$: import("rxjs").Observable<SpeakingEvent>;
17
+ private readonly microphone;
18
+ private readonly unsubscribe;
19
+ constructor(config: SpeakingConfig, zone: NgZone);
20
+ start(options: StartOptions): Promise<void>;
21
+ stop(): Promise<RealtimeFinal>;
22
+ cancel(): Promise<void>;
23
+ ngOnDestroy(): void;
24
+ static ɵfac: i0.ɵɵFactoryDeclaration<SpeakingService, never>;
25
+ static ɵprov: i0.ɵɵInjectableDeclaration<SpeakingService>;
26
+ }
27
+ export declare function provideSpeaking(config: SpeakingConfig): Provider[];
28
+ /** Configure once at app bootstrap; every fsel-speaking component owns its session. */
29
+ export declare function provideSpeakingGateway(options?: SpeakingGatewayOptions): Provider[];
30
+ export * from '@hoanghong/speaking-core';