@hoanghong/speaking-core 0.4.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 CHANGED
@@ -1,3 +1,9 @@
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
+
1
7
  ## 0.4.0
2
8
 
3
9
  - Add US English pronunciation assessment client: 120-second audio, reference passage, optional IPA tokens and uncalibrated phoneme diagnostics.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @hoanghong/speaking-core
2
2
 
3
- SDK online cho người tự làm UI. Bản test 0.4.0 cài bằng `npm install ./vendor/hoanghong-speaking-core-0.4.0.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.
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
4
 
5
5
  ## Đánh giá phát âm tiếng Anh Mỹ
6
6
 
@@ -76,3 +76,19 @@ createSpeakingGateway và các constructor cấu hình thủ công cũ vẫn đ
76
76
  ## Key ứng dụng
77
77
 
78
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
+ ```
@@ -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;
@@ -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: { channelCount: 1, echoCancellation: true, noiseSuppression: true },
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());
@@ -1,6 +1,7 @@
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;
@@ -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(SERVICE + '/session', {
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: SERVICE,
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: SERVICE + '/pcm-worklet.js',
41
+ workletUrl: service + '/pcm-worklet.js',
31
42
  });
32
43
  config.batch.beforeSubmit = async (key) => {
33
44
  if (!token || key !== previousKey) {
@@ -35,7 +46,7 @@ export function createSpeakingService(options = {}) {
35
46
  previousKey = key;
36
47
  }
37
48
  };
38
- const realtime = createSpeakingGateway({ baseUrl: SERVICE, headers: async () => ({ Authorization: 'Bearer ' + await session() }) });
49
+ const realtime = createSpeakingGateway({ baseUrl: service, headers: async () => ({ Authorization: 'Bearer ' + await session() }) });
39
50
  config.realtime = realtime.realtime;
40
51
  // Every synchronous assessment consumes its own short-lived, single-use session.
41
52
  config.pronunciation.headers = async () => ({ Authorization: 'Bearer ' + await session() });
@@ -17,6 +17,7 @@ export declare class RealtimeSpeakingClient {
17
17
  private waiters;
18
18
  private queue;
19
19
  private queuedBytes;
20
+ private pendingAudio;
20
21
  private seq;
21
22
  private bytes;
22
23
  private started?;
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 ?? 64000)) {
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
- const owned = pcm.slice();
182
+ this.pendingAudio.push(pcm.slice());
181
183
  const generation = this.generation;
182
- this.queuedBytes += owned.length;
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 -= owned.length; });
232
+ this.queuedBytes -= consumed; });
219
233
  this.queue = operation.catch(() => { });
220
234
  return operation;
221
235
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hoanghong/speaking-core",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "description": "Online speaking SDK: gateway configuration, realtime microphone and audio upload",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",