@hoanghong/speaking-core 0.4.0 → 0.4.2

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,13 @@
1
+ ## 0.4.2
2
+
3
+ - Simplify public integration documentation; no runtime changes.
4
+
5
+ # 0.4.1
6
+
7
+ - Application keys support quota updates, rotation and revocation.
8
+ - Optional public-service baseUrl for private deployments and local integration.
9
+ - Includes microphone selection and realtime audio backpressure fixes (web SDK).
10
+
1
11
  ## 0.4.0
2
12
 
3
13
  - 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.2 cài bằng `npm install ./vendor/hoanghong-speaking-core-0.4.2.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,17 @@ 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
+ ## Application key and service URL
82
+
83
+ Pass the application key provided by FSEL as `appKey`. Quotas and key status are
84
+ managed by the service. App keys identify an application and are visible in
85
+ browser/mobile applications.
86
+
87
+ The default service is https://ai-service.fsel.edu.vn/sdk. For a private deployment,
88
+ set `baseUrl` to the absolute /sdk endpoint supplied by your service operator.
89
+
90
+ ```ts
91
+ createSpeakingService({ appKey: 'YOUR_APP_KEY', baseUrl: 'https://your-service.example/sdk' });
92
+ ```
@@ -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.2",
4
4
  "description": "Online speaking SDK: gateway configuration, realtime microphone and audio upload",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",