@hoanghong/speaking-core 0.3.0 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/README.md +39 -1
- package/dist/gateway.d.ts +2 -0
- package/dist/gateway.js +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/microphone.d.ts +6 -0
- package/dist/microphone.js +7 -1
- package/dist/pronunciation.d.ts +58 -0
- package/dist/pronunciation.js +52 -0
- package/dist/public-service.d.ts +2 -0
- package/dist/public-service.js +17 -4
- package/dist/realtime.d.ts +1 -0
- package/dist/realtime.js +18 -4
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
# 0.4.1
|
|
2
|
+
|
|
3
|
+
- CMS-managed application keys share PostgreSQL quota, rotation and revocation.
|
|
4
|
+
- Optional public-service baseUrl for private deployments and local integration.
|
|
5
|
+
- Includes microphone selection and realtime audio backpressure fixes (web SDK).
|
|
6
|
+
|
|
7
|
+
## 0.4.0
|
|
8
|
+
|
|
9
|
+
- Add US English pronunciation assessment client: 120-second audio, reference passage, optional IPA tokens and uncalibrated phoneme diagnostics.
|
|
10
|
+
- Share existing gateway/public application sessions; no service credentials in client apps.
|
|
11
|
+
- Local test release; requires server pronunciation endpoint and model provisioning.
|
|
12
|
+
|
|
1
13
|
# Changelog
|
|
2
14
|
|
|
3
15
|
## 0.3.0
|
package/README.md
CHANGED
|
@@ -1,6 +1,28 @@
|
|
|
1
1
|
# @hoanghong/speaking-core
|
|
2
2
|
|
|
3
|
-
SDK online cho người tự làm UI.
|
|
3
|
+
SDK online cho người tự làm UI. Bản test 0.4.1 cài bằng `npm install ./vendor/hoanghong-speaking-core-0.4.1.tgz` từ bộ ZIP, chưa publish registry. Angular dùng @hoanghong/speaking-angular với component có sẵn; Flutter dùng fsel_speaking. MIT.
|
|
4
|
+
|
|
5
|
+
## Đánh giá phát âm tiếng Anh Mỹ
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
import { PronunciationClient, createSpeakingService } from '@hoanghong/speaking-core';
|
|
9
|
+
const pronunciation = new PronunciationClient(createSpeakingService().pronunciation);
|
|
10
|
+
export async function assess(file: File) {
|
|
11
|
+
return pronunciation.assessAudio(file, {
|
|
12
|
+
referenceText: 'cat', expectedPhonemes: ['k', 'æ', 't'], filename: file.name,
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Audio tối đa 120 giây/25 MiB, reference tối đa 4096 ký tự; timeout client 11 phút.
|
|
18
|
+
Đọc cả đoạn: thay referenceText bằng đoạn mẫu; bỏ expectedPhonemes khi server có eSpeak.
|
|
19
|
+
Các âm tùy chọn là token vocabulary, không tách IPA theo ký tự. Mỗi lần gọi cấp phiên riêng,
|
|
20
|
+
không tự retry. AbortSignal được hỗ trợ. Server phải triển khai endpoint/model phát âm;
|
|
21
|
+
bộ cài này chưa xác nhận hosted endpoint đã sẵn sàng. Gateway riêng dùng cấu hình
|
|
22
|
+
`createSpeakingGateway(...).pronunciation`, giữ credential của service ở backend.
|
|
23
|
+
Response typed PronunciationAssessment: âm mẫu/nhận dạng, comparisons, timestamp ước lượng,
|
|
24
|
+
formant/pitch/intensity/PSD và warnings. `calibrated=false`, không dùng similarity làm điểm
|
|
25
|
+
phát âm chính thức. Các API realtime/upload dưới đây giữ nguyên.
|
|
4
26
|
|
|
5
27
|
## Kết nối tự động, không cấu hình URL/token
|
|
6
28
|
|
|
@@ -54,3 +76,19 @@ createSpeakingGateway và các constructor cấu hình thủ công cũ vẫn đ
|
|
|
54
76
|
## Key ứng dụng
|
|
55
77
|
|
|
56
78
|
Dùng `createSpeakingService({ appKey: "KEY_DUOC_FSEL_CAP" })` thay cho lời gọi không tham số. SDK gửi key khi cấp phiên; quota quản lý phía server, không có URL cần cấu hình. Key này là định danh ứng dụng có thể quan sát ở frontend, không có quyền quản trị. INVALID_APP_KEY/401 nghĩa là sai hoặc đã bị khóa; không tự fallback ẩn danh.
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
## CMS application keys (0.4.1)
|
|
82
|
+
|
|
83
|
+
Create an application key in CMS > API Keys, then pass it as `appKey`.
|
|
84
|
+
The CMS backend and SDK endpoint must share the same PostgreSQL DATABASE_URL.
|
|
85
|
+
Quota changes, key rotation and disabling are enforced by the backend. Existing
|
|
86
|
+
sessions are also checked against key state. App keys identify quota; they do not
|
|
87
|
+
grant CMS administrator access and are visible in browser/mobile applications.
|
|
88
|
+
|
|
89
|
+
The hosted default remains https://ai-service.fsel.edu.vn/sdk. A custom deployment
|
|
90
|
+
can set `baseUrl` to its absolute /sdk endpoint (never /admin/api).
|
|
91
|
+
|
|
92
|
+
```ts
|
|
93
|
+
createSpeakingService({ appKey: 'KEY_FROM_CMS', baseUrl: 'https://your-service.example/sdk' });
|
|
94
|
+
```
|
package/dist/gateway.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { BatchOptions } from './batch.js';
|
|
2
2
|
import { MicrophoneOptions } from './microphone.js';
|
|
3
3
|
import { RealtimeOptions } from './realtime.js';
|
|
4
|
+
import { PronunciationOptions } from './pronunciation.js';
|
|
4
5
|
export interface SpeakingGatewayOptions {
|
|
5
6
|
/** Application gateway, e.g. /api/speaking. It must relay /v1 HTTP + WebSocket. */
|
|
6
7
|
baseUrl?: string;
|
|
@@ -13,4 +14,5 @@ export declare function createSpeakingGateway(options?: SpeakingGatewayOptions):
|
|
|
13
14
|
batch: BatchOptions;
|
|
14
15
|
realtime: RealtimeOptions;
|
|
15
16
|
microphone: MicrophoneOptions;
|
|
17
|
+
pronunciation: PronunciationOptions;
|
|
16
18
|
};
|
package/dist/gateway.js
CHANGED
|
@@ -12,6 +12,7 @@ export function createSpeakingGateway(options = {}) {
|
|
|
12
12
|
socket.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
13
13
|
return {
|
|
14
14
|
batch: { baseUrl, headers, fetch: request },
|
|
15
|
+
pronunciation: { baseUrl, headers, fetch: request },
|
|
15
16
|
realtime: {
|
|
16
17
|
websocketUrl: socket.href,
|
|
17
18
|
ticketProvider: async (start) => {
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
package/dist/microphone.d.ts
CHANGED
|
@@ -3,6 +3,12 @@ import { RecordedSpeakingFinal, StartOptions } from './contracts.js';
|
|
|
3
3
|
export interface MicrophoneOptions {
|
|
4
4
|
/** Host the packaged pcm-worklet.js with your app's normal CSP; no inline/blob worker. */
|
|
5
5
|
workletUrl: string;
|
|
6
|
+
/** Select the same device as the host application's microphone diagnostic. */
|
|
7
|
+
deviceId?: string;
|
|
8
|
+
/** Disable processing when a driver/browser suppresses speech unexpectedly. */
|
|
9
|
+
echoCancellation?: boolean;
|
|
10
|
+
noiseSuppression?: boolean;
|
|
11
|
+
autoGainControl?: boolean;
|
|
6
12
|
}
|
|
7
13
|
export declare class SpeakingMicrophone {
|
|
8
14
|
readonly client: RealtimeSpeakingClient;
|
package/dist/microphone.js
CHANGED
|
@@ -30,7 +30,13 @@ export class SpeakingMicrophone {
|
|
|
30
30
|
this.recording.clear();
|
|
31
31
|
try {
|
|
32
32
|
const stream = await navigator.mediaDevices.getUserMedia({
|
|
33
|
-
audio: {
|
|
33
|
+
audio: {
|
|
34
|
+
channelCount: 1,
|
|
35
|
+
...(this.options.deviceId ? { deviceId: { exact: this.options.deviceId } } : {}),
|
|
36
|
+
echoCancellation: this.options.echoCancellation ?? true,
|
|
37
|
+
noiseSuppression: this.options.noiseSuppression ?? true,
|
|
38
|
+
autoGainControl: this.options.autoGainControl ?? true,
|
|
39
|
+
},
|
|
34
40
|
});
|
|
35
41
|
if (generation !== this.generation) {
|
|
36
42
|
stream.getTracks().forEach(track => track.stop());
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
export interface PronunciationOptions {
|
|
2
|
+
baseUrl: string;
|
|
3
|
+
/** Short-lived app/gateway authentication; never embed a service credential. */
|
|
4
|
+
headers: () => Promise<HeadersInit>;
|
|
5
|
+
fetch?: typeof globalThis.fetch;
|
|
6
|
+
timeoutMs?: number;
|
|
7
|
+
}
|
|
8
|
+
export interface PronunciationPhone {
|
|
9
|
+
phoneme: string;
|
|
10
|
+
start: number;
|
|
11
|
+
end: number;
|
|
12
|
+
}
|
|
13
|
+
export interface PronunciationComparison {
|
|
14
|
+
operation: 'match' | 'substitution' | 'deletion' | 'insertion';
|
|
15
|
+
expected: string | null;
|
|
16
|
+
observed: string | null;
|
|
17
|
+
start: number | null;
|
|
18
|
+
end: number | null;
|
|
19
|
+
}
|
|
20
|
+
export interface PronunciationAcousticFrame {
|
|
21
|
+
time: number;
|
|
22
|
+
formants_hz: Array<number | null>;
|
|
23
|
+
delta_formants_hz: Array<number | null>;
|
|
24
|
+
f2_minus_f1_hz: number | null;
|
|
25
|
+
pitch_hz: number | null;
|
|
26
|
+
intensity_db: number | null;
|
|
27
|
+
psd_power_per_hz: number[];
|
|
28
|
+
}
|
|
29
|
+
export interface PronunciationAssessment {
|
|
30
|
+
reference_text: string;
|
|
31
|
+
language: 'en-US';
|
|
32
|
+
expected_phonemes: string[];
|
|
33
|
+
observed_phonemes: PronunciationPhone[];
|
|
34
|
+
comparisons: PronunciationComparison[];
|
|
35
|
+
phoneme_error_rate: number;
|
|
36
|
+
phoneme_similarity: number;
|
|
37
|
+
calibrated: false;
|
|
38
|
+
timing_method: 'ctc_greedy_approximate';
|
|
39
|
+
duration: number;
|
|
40
|
+
acoustic_frames: PronunciationAcousticFrame[];
|
|
41
|
+
psd_frequencies_hz: number[];
|
|
42
|
+
model: string;
|
|
43
|
+
warnings: string[];
|
|
44
|
+
}
|
|
45
|
+
export interface AssessPronunciationOptions {
|
|
46
|
+
referenceText: string;
|
|
47
|
+
/** Vocabulary tokens, not individual IPA characters. Optional when server has eSpeak. */
|
|
48
|
+
expectedPhonemes?: string[];
|
|
49
|
+
filename?: string;
|
|
50
|
+
signal?: AbortSignal;
|
|
51
|
+
}
|
|
52
|
+
export declare class PronunciationClient {
|
|
53
|
+
private readonly options;
|
|
54
|
+
private readonly request;
|
|
55
|
+
constructor(options: PronunciationOptions);
|
|
56
|
+
/** US English, <=120 seconds and <=25 MiB. No automatic retries or duplicate quota charges. */
|
|
57
|
+
assessAudio(audio: Blob, options: AssessPronunciationOptions): Promise<PronunciationAssessment>;
|
|
58
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { SpeakingError } from './contracts.js';
|
|
2
|
+
export class PronunciationClient {
|
|
3
|
+
options;
|
|
4
|
+
request;
|
|
5
|
+
constructor(options) {
|
|
6
|
+
this.options = options;
|
|
7
|
+
this.request = options.fetch ?? globalThis.fetch.bind(globalThis);
|
|
8
|
+
}
|
|
9
|
+
/** US English, <=120 seconds and <=25 MiB. No automatic retries or duplicate quota charges. */
|
|
10
|
+
async assessAudio(audio, options) {
|
|
11
|
+
if (!options.referenceText.trim() || options.referenceText.length > 4096) {
|
|
12
|
+
throw new SpeakingError('INVALID_REFERENCE', 'Provide reference text of 1 to 4096 characters');
|
|
13
|
+
}
|
|
14
|
+
if (!audio.size || audio.size > 25 * 1024 * 1024) {
|
|
15
|
+
throw new SpeakingError('INVALID_AUDIO', 'Audio must be nonempty and at most 25 MiB');
|
|
16
|
+
}
|
|
17
|
+
const controller = new AbortController();
|
|
18
|
+
const abort = () => controller.abort(options.signal?.reason);
|
|
19
|
+
if (options.signal?.aborted)
|
|
20
|
+
abort();
|
|
21
|
+
else
|
|
22
|
+
options.signal?.addEventListener('abort', abort, { once: true });
|
|
23
|
+
const timer = setTimeout(() => controller.abort(), this.options.timeoutMs ?? 660_000);
|
|
24
|
+
try {
|
|
25
|
+
if (controller.signal.aborted)
|
|
26
|
+
throw new SpeakingError('CANCELLED', 'Assessment was cancelled');
|
|
27
|
+
const headers = new Headers(await this.options.headers());
|
|
28
|
+
headers.delete('Content-Type');
|
|
29
|
+
const form = new FormData();
|
|
30
|
+
form.append('file', audio, options.filename ?? 'recording.wav');
|
|
31
|
+
form.append('reference_text', options.referenceText);
|
|
32
|
+
form.append('language', 'en-US');
|
|
33
|
+
if (options.expectedPhonemes !== undefined) {
|
|
34
|
+
form.append('expected_phonemes', JSON.stringify(options.expectedPhonemes));
|
|
35
|
+
}
|
|
36
|
+
const response = await this.request(this.options.baseUrl.replace(/\/$/, '') + '/v1/pronunciation-assessments', { method: 'POST', credentials: 'same-origin', headers, body: form, signal: controller.signal });
|
|
37
|
+
const body = await response.json().catch(() => null);
|
|
38
|
+
if (!response.ok) {
|
|
39
|
+
throw new SpeakingError(body?.code ?? 'HTTP_ERROR', body?.message ?? 'Assessment failed', response.status);
|
|
40
|
+
}
|
|
41
|
+
if (!body || body.language !== 'en-US' || body.calibrated !== false ||
|
|
42
|
+
typeof body.phoneme_similarity !== 'number' || !Array.isArray(body.comparisons)) {
|
|
43
|
+
throw new SpeakingError('INVALID_RESPONSE', 'Invalid pronunciation assessment response');
|
|
44
|
+
}
|
|
45
|
+
return body;
|
|
46
|
+
}
|
|
47
|
+
finally {
|
|
48
|
+
clearTimeout(timer);
|
|
49
|
+
options.signal?.removeEventListener('abort', abort);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
package/dist/public-service.d.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
/** Hosted public service. The SDK handles short-lived capabilities internally. */
|
|
2
2
|
export declare function createSpeakingService(options?: {
|
|
3
3
|
appKey?: string;
|
|
4
|
+
baseUrl?: string;
|
|
4
5
|
}): {
|
|
5
6
|
batch: import("./batch.js").BatchOptions;
|
|
6
7
|
realtime: import("./realtime.js").RealtimeOptions;
|
|
7
8
|
microphone: import("./microphone.js").MicrophoneOptions;
|
|
9
|
+
pronunciation: import("./pronunciation.js").PronunciationOptions;
|
|
8
10
|
};
|
package/dist/public-service.js
CHANGED
|
@@ -5,9 +5,20 @@ const SERVICE = 'https://ai-service.fsel.edu.vn/sdk';
|
|
|
5
5
|
export function createSpeakingService(options = {}) {
|
|
6
6
|
if (options.appKey !== undefined && !options.appKey.trim())
|
|
7
7
|
throw new SpeakingError('INVALID_CONFIG', 'Application key cannot be empty');
|
|
8
|
+
let endpoint;
|
|
9
|
+
try {
|
|
10
|
+
endpoint = new URL(options.baseUrl ?? SERVICE);
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
throw new SpeakingError('INVALID_CONFIG', 'Use an absolute HTTP(S) service URL');
|
|
14
|
+
}
|
|
15
|
+
if (!['http:', 'https:'].includes(endpoint.protocol) || endpoint.username || endpoint.password || endpoint.search || endpoint.hash) {
|
|
16
|
+
throw new SpeakingError('INVALID_CONFIG', 'Use an HTTP(S) service URL without credentials, query or fragment');
|
|
17
|
+
}
|
|
18
|
+
const service = endpoint.href.replace(/\/$/, '');
|
|
8
19
|
const request = globalThis.fetch.bind(globalThis);
|
|
9
20
|
const session = async () => {
|
|
10
|
-
const response = await request(
|
|
21
|
+
const response = await request(service + '/session', {
|
|
11
22
|
method: 'POST', credentials: 'omit',
|
|
12
23
|
headers: options.appKey ? { 'X-Speaking-Key': options.appKey } : undefined,
|
|
13
24
|
});
|
|
@@ -21,13 +32,13 @@ export function createSpeakingService(options = {}) {
|
|
|
21
32
|
let token;
|
|
22
33
|
let previousKey;
|
|
23
34
|
const config = createSpeakingGateway({
|
|
24
|
-
baseUrl:
|
|
35
|
+
baseUrl: service,
|
|
25
36
|
headers: async () => {
|
|
26
37
|
if (!token)
|
|
27
38
|
throw new SpeakingError('INVALID_STATE', 'Submit audio before reading a public job');
|
|
28
39
|
return { Authorization: 'Bearer ' + token };
|
|
29
40
|
},
|
|
30
|
-
workletUrl:
|
|
41
|
+
workletUrl: service + '/pcm-worklet.js',
|
|
31
42
|
});
|
|
32
43
|
config.batch.beforeSubmit = async (key) => {
|
|
33
44
|
if (!token || key !== previousKey) {
|
|
@@ -35,7 +46,9 @@ export function createSpeakingService(options = {}) {
|
|
|
35
46
|
previousKey = key;
|
|
36
47
|
}
|
|
37
48
|
};
|
|
38
|
-
const realtime = createSpeakingGateway({ baseUrl:
|
|
49
|
+
const realtime = createSpeakingGateway({ baseUrl: service, headers: async () => ({ Authorization: 'Bearer ' + await session() }) });
|
|
39
50
|
config.realtime = realtime.realtime;
|
|
51
|
+
// Every synchronous assessment consumes its own short-lived, single-use session.
|
|
52
|
+
config.pronunciation.headers = async () => ({ Authorization: 'Bearer ' + await session() });
|
|
40
53
|
return config;
|
|
41
54
|
}
|
package/dist/realtime.d.ts
CHANGED
package/dist/realtime.js
CHANGED
|
@@ -7,6 +7,7 @@ export class RealtimeSpeakingClient {
|
|
|
7
7
|
waiters = new Set();
|
|
8
8
|
queue = Promise.resolve();
|
|
9
9
|
queuedBytes = 0;
|
|
10
|
+
pendingAudio = [];
|
|
10
11
|
seq = 0;
|
|
11
12
|
bytes = 0;
|
|
12
13
|
started;
|
|
@@ -90,6 +91,7 @@ export class RealtimeSpeakingClient {
|
|
|
90
91
|
this.seq = 0;
|
|
91
92
|
this.bytes = 0;
|
|
92
93
|
this.queuedBytes = 0;
|
|
94
|
+
this.pendingAudio = [];
|
|
93
95
|
this.final = undefined;
|
|
94
96
|
this.failure = undefined;
|
|
95
97
|
this.stopPromise = undefined;
|
|
@@ -172,19 +174,31 @@ export class RealtimeSpeakingClient {
|
|
|
172
174
|
if (this.state !== 'recording' || !pcm.length || pcm.length % 2) {
|
|
173
175
|
return Promise.reject(new SpeakingError('INVALID_AUDIO', 'An active session and PCM16 samples are required'));
|
|
174
176
|
}
|
|
175
|
-
if (this.queuedBytes + pcm.length > (this.options.maxQueuedBytes ??
|
|
177
|
+
if (this.queuedBytes + pcm.length > (this.options.maxQueuedBytes ?? 320000)) {
|
|
176
178
|
const error = new SpeakingError('BACKPRESSURE', 'Speaking connection cannot keep up with audio');
|
|
177
179
|
this.fail(error);
|
|
178
180
|
return Promise.reject(error);
|
|
179
181
|
}
|
|
180
|
-
|
|
182
|
+
this.pendingAudio.push(pcm.slice());
|
|
181
183
|
const generation = this.generation;
|
|
182
|
-
this.queuedBytes +=
|
|
184
|
+
this.queuedBytes += pcm.length;
|
|
185
|
+
let consumed = 0;
|
|
183
186
|
const operation = this.queue.then(async () => {
|
|
184
187
|
if (generation !== this.generation)
|
|
185
188
|
throw new SpeakingError('CANCELLED', 'Session changed');
|
|
186
189
|
if (this.failure)
|
|
187
190
|
throw this.failure;
|
|
191
|
+
if (!this.pendingAudio.length)
|
|
192
|
+
return;
|
|
193
|
+
const chunks = this.pendingAudio;
|
|
194
|
+
this.pendingAudio = [];
|
|
195
|
+
consumed = chunks.reduce((size, chunk) => size + chunk.length, 0);
|
|
196
|
+
const owned = new Uint8Array(consumed);
|
|
197
|
+
let at = 0;
|
|
198
|
+
for (const chunk of chunks) {
|
|
199
|
+
owned.set(chunk, at);
|
|
200
|
+
at += chunk.length;
|
|
201
|
+
}
|
|
188
202
|
const max = this.started.max_chunk_bytes;
|
|
189
203
|
for (let offset = 0; offset < owned.length; offset += max) {
|
|
190
204
|
const chunk = owned.subarray(offset, offset + max);
|
|
@@ -215,7 +229,7 @@ export class RealtimeSpeakingClient {
|
|
|
215
229
|
this.fail(error instanceof SpeakingError ? error : new SpeakingError('CONNECTION_LOST', 'Speaking audio failed'));
|
|
216
230
|
throw error;
|
|
217
231
|
}).finally(() => { if (generation === this.generation)
|
|
218
|
-
this.queuedBytes -=
|
|
232
|
+
this.queuedBytes -= consumed; });
|
|
219
233
|
this.queue = operation.catch(() => { });
|
|
220
234
|
return operation;
|
|
221
235
|
}
|