@hoanghong/speaking-core 0.1.0 → 0.3.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,22 @@
1
+ # Changelog
2
+
3
+ ## 0.3.0
4
+
5
+ - Per-application keys with server-managed quota; appKey setup without a service URL.
6
+
7
+ - Hosted public service configuration with automatic short-lived sessions and quota errors.
8
+ - Select realtime/upload using serviceType; no URL or token setup for hosted use.
9
+ - Completed callback includes text and audio: recorded PCM as WAV, or original uploaded file.
10
+ - Bounded five-minute recording buffer and preserved final residual audio.
11
+
12
+
13
+ ## 0.2.0
14
+
15
+ - Add createSpeakingGateway for shared HTTP/WebSocket/ticket configuration.
16
+ - Validate gateway URLs and malformed ticket responses.
17
+ - Add complete microphone/upload examples and gateway/asset setup instructions.
18
+ - Preserve existing low-level 0.1 APIs.
19
+
20
+ ## 0.1.0
21
+
22
+ - Initial batch, realtime and browser microphone SDK.
package/README.md CHANGED
@@ -1,19 +1,56 @@
1
1
  # @hoanghong/speaking-core
2
2
 
3
- TypeScript SDK cho batch ASR và realtime WebSocket v1. Không nhúng model hay service credential.
3
+ SDK online cho người tự làm UI. Cài `npm install @hoanghong/speaking-core@^0.3.0`. Angular dùng @hoanghong/speaking-angular với component có sẵn; Flutter dùng fsel_speaking. MIT.
4
4
 
5
- Exports: `BatchSpeakingClient`, `RealtimeSpeakingClient`, `SpeakingMicrophone`, contracts và errors.
6
- Microphone dùng AudioWorklet; copy asset `assets/pcm-worklet.js` vào app và truyền `workletUrl`.
5
+ ## Kết nối tự động, không cấu hình URL/token
7
6
 
8
- ```bash
9
- npm ci
10
- npm test
11
- npm pack
7
+ ```ts
8
+ import { createSpeakingService, RealtimeSpeakingClient, SpeakingMicrophone } from '@hoanghong/speaking-core';
9
+ const config = createSpeakingService();
10
+ const client = new RealtimeSpeakingClient(config.realtime);
11
+ const mic = new SpeakingMicrophone(client, config.microphone);
12
+ const unsubscribe = client.onEvent(event => {
13
+ if (event.type === 'partial') console.log(event.text);
14
+ if (event.type === 'clientError') console.error(event.error);
15
+ });
16
+ // Gọi từ handler nút bắt đầu; không tự ghi âm khi load trang:
17
+ export async function start() { await mic.start({ language: 'vi' }); }
18
+ export async function stop() {
19
+ const result = await mic.stop();
20
+ return { text: result.result?.text ?? '', audio: result.audio };
21
+ }
22
+ export async function cancel() { await mic.cancel(); }
23
+ export async function dispose() { unsubscribe(); await mic.dispose(); }
12
24
  ```
13
25
 
14
- Realtime `ticketProvider` gọi backend ứng dụng để lấy ticket một lần, không gọi private ASR ticket API
15
- bằng service token trong browser. Batch `headers` cung cấp token người dùng cho gateway.
16
- `stop()` chờ ACK toàn bộ audio; `SpeakingMicrophone.stop()` flush phần PCM cuối trước finalize.
17
- Mất mạng/quá backlog trả lỗi rõ ràng, không tự reconnect/resume hay fallback.
26
+ Audio là File WAV PCM16 mono 16 kHz, gồm phần audio cuối khi dừng. Dùng URL.createObjectURL để nghe/tải rồi revokeObjectURL; hoặc FormData để lưu. Cần browser AudioWorklet/getUserMedia và HTTPS/localhost. Public worklet được tải tự động; không copy asset. Gọi dispose ở lifecycle unmount.
18
27
 
19
- Hướng dẫn cấu hình, ví dụ và contract: `docs/realtime-and-sdk.md` ở repository root.
28
+ ## Upload
29
+
30
+ ```ts
31
+ import { BatchSpeakingClient, createSpeakingService } from '@hoanghong/speaking-core';
32
+ const batch = new BatchSpeakingClient(createSpeakingService().batch);
33
+ export async function upload(file: File) {
34
+ if (file.size > 25 * 1024 * 1024) throw new Error('File tối đa 25 MiB');
35
+ let job = await batch.submitAudio(file, 'vi', crypto.randomUUID(), 'short_answer', file.name);
36
+ const deadline = Date.now() + 600000;
37
+ while (!['succeeded', 'no_speech', 'failed', 'cancelled'].includes(job.status)) {
38
+ if (Date.now() > deadline) throw new Error('Hết thời gian chờ');
39
+ await new Promise(resolve => setTimeout(resolve, 1500));
40
+ job = await batch.getResult(job.job_id);
41
+ }
42
+ if (job.error) throw new Error(job.error.message);
43
+ if (job.status === 'cancelled') throw new Error('Đã hủy');
44
+ return { text: job.result?.text ?? '', audio: file, language: 'vi', serviceType: 'upload' };
45
+ }
46
+ ```
47
+
48
+ Public client quản lý capability cho một job tại một thời điểm; tạo client/config riêng nếu xử lý nhiều job song song. Giữ client khi cần getResult/cancelJob cho job đó. Không chia sẻ capability hay serialize audio vào JSON.
49
+
50
+ Ngôn ngữ en/ja/ko/zh/vi. Public service mặc định 5 phiên/IP/ngày, 100 phiên/ngày/instance (UTC); có thể được FSEL điều chỉnh. Mỗi phiên mới tính lượt kể cả khi lỗi; 429/QUOTA_EXCEEDED là hết lượt. SDK không tự retry/replay. Realtime tối đa 300 giây với buffer PCM 9,6 MB; dọn buffer khi lỗi/hủy. Upload không tự lưu vĩnh viễn file trên S3. Mạng/CSP cần cho phép truy cập dịch vụ FSEL. 404/503 có thể do server chưa triển khai public SDK/model. Cần server hỗ trợ bản 0.3 trước khi sử dụng.
51
+
52
+ createSpeakingGateway và các constructor cấu hình thủ công cũ vẫn được giữ cho nhu cầu nâng cao; không cần dùng trong luồng public trên.
53
+
54
+ ## Key ứng dụng
55
+
56
+ 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.
@@ -0,0 +1,9 @@
1
+ /** Five minutes of mono PCM16 at 16 kHz; bounded independently of server limits. */
2
+ export declare const MAX_RECORDING_BYTES: number;
3
+ export declare class SpeakingAudioBuffer {
4
+ private chunks;
5
+ private size;
6
+ append(pcm: Uint8Array): void;
7
+ file(): File;
8
+ clear(): void;
9
+ }
package/dist/audio.js ADDED
@@ -0,0 +1,36 @@
1
+ import { SpeakingError } from './contracts.js';
2
+ /** Five minutes of mono PCM16 at 16 kHz; bounded independently of server limits. */
3
+ export const MAX_RECORDING_BYTES = 16000 * 2 * 300;
4
+ export class SpeakingAudioBuffer {
5
+ chunks = [];
6
+ size = 0;
7
+ append(pcm) {
8
+ if (pcm.byteLength % 2)
9
+ throw new SpeakingError('INCOMPLETE_AUDIO', 'PCM16 requires complete samples');
10
+ if (this.size + pcm.byteLength > MAX_RECORDING_BYTES)
11
+ throw new SpeakingError('AUDIO_TOO_LONG', 'Recording exceeds five minutes');
12
+ this.chunks.push(pcm.slice());
13
+ this.size += pcm.byteLength;
14
+ }
15
+ file() {
16
+ const header = new ArrayBuffer(44);
17
+ const view = new DataView(header);
18
+ const text = (at, value) => { for (let i = 0; i < value.length; ++i)
19
+ view.setUint8(at + i, value.charCodeAt(i)); };
20
+ text(0, 'RIFF');
21
+ view.setUint32(4, 36 + this.size, true);
22
+ text(8, 'WAVE');
23
+ text(12, 'fmt ');
24
+ view.setUint32(16, 16, true);
25
+ view.setUint16(20, 1, true);
26
+ view.setUint16(22, 1, true);
27
+ view.setUint32(24, 16000, true);
28
+ view.setUint32(28, 32000, true);
29
+ view.setUint16(32, 2, true);
30
+ view.setUint16(34, 16, true);
31
+ text(36, 'data');
32
+ view.setUint32(40, this.size, true);
33
+ return new File([header, ...this.chunks], 'speaking.wav', { type: 'audio/wav' });
34
+ }
35
+ clear() { this.chunks = []; this.size = 0; }
36
+ }
package/dist/batch.d.ts CHANGED
@@ -4,6 +4,7 @@ export interface BatchOptions {
4
4
  /** User/gateway auth only. Never distribute an ASR service credential to an app. */
5
5
  headers: () => Promise<HeadersInit>;
6
6
  fetch?: typeof globalThis.fetch;
7
+ beforeSubmit?: (idempotencyKey: string) => Promise<void>;
7
8
  }
8
9
  export declare class BatchSpeakingClient {
9
10
  private readonly options;
package/dist/batch.js CHANGED
@@ -17,6 +17,7 @@ export class BatchSpeakingClient {
17
17
  async submitAudio(audio, language, idempotencyKey, audioProfile = 'short_answer', filename = 'recording.wav', signal) {
18
18
  if (!idempotencyKey.trim())
19
19
  throw new SpeakingError('INVALID_REQUEST', 'An idempotency key is required');
20
+ await this.options.beforeSubmit?.(idempotencyKey);
20
21
  const form = new FormData();
21
22
  form.append('file', audio, filename);
22
23
  form.append('language', language);
@@ -1,4 +1,14 @@
1
1
  export type Language = 'en' | 'ja' | 'ko' | 'zh' | 'vi';
2
+ export type SpeakingServiceType = 'realtime' | 'upload';
3
+ export type SpeakingResult = (RealtimeFinal | Job) & {
4
+ text: string;
5
+ audio: File;
6
+ language: Language;
7
+ serviceType: SpeakingServiceType;
8
+ };
9
+ export type RecordedSpeakingFinal = RealtimeFinal & {
10
+ audio: File;
11
+ };
2
12
  export type AudioProfile = 'short_answer' | 'passage';
3
13
  export type SpeakingState = 'idle' | 'connecting' | 'recording' | 'stopping' | 'stopped' | 'cancelled' | 'failed';
4
14
  export interface Transcript {
@@ -0,0 +1,16 @@
1
+ import { BatchOptions } from './batch.js';
2
+ import { MicrophoneOptions } from './microphone.js';
3
+ import { RealtimeOptions } from './realtime.js';
4
+ export interface SpeakingGatewayOptions {
5
+ /** Application gateway, e.g. /api/speaking. It must relay /v1 HTTP + WebSocket. */
6
+ baseUrl?: string;
7
+ /** Optional short-lived application auth. Never embed the ASR service token. */
8
+ headers?: () => Promise<HeadersInit>;
9
+ workletUrl?: string;
10
+ fetch?: typeof globalThis.fetch;
11
+ }
12
+ export declare function createSpeakingGateway(options?: SpeakingGatewayOptions): {
13
+ batch: BatchOptions;
14
+ realtime: RealtimeOptions;
15
+ microphone: MicrophoneOptions;
16
+ };
@@ -0,0 +1,35 @@
1
+ import { SpeakingError } from './contracts.js';
2
+ export function createSpeakingGateway(options = {}) {
3
+ const origin = typeof location === 'undefined' ? undefined : location.origin;
4
+ const url = new URL(options.baseUrl ?? '/api/speaking', origin);
5
+ if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) {
6
+ throw new SpeakingError('INVALID_CONFIG', 'Use an HTTP(S) gateway URL without credentials, query or hash');
7
+ }
8
+ const baseUrl = url.href.replace(/\/$/, '');
9
+ const headers = options.headers ?? (async () => ({}));
10
+ const request = options.fetch ?? globalThis.fetch.bind(globalThis);
11
+ const socket = new URL(baseUrl + '/v1/realtime');
12
+ socket.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
13
+ return {
14
+ batch: { baseUrl, headers, fetch: request },
15
+ realtime: {
16
+ websocketUrl: socket.href,
17
+ ticketProvider: async (start) => {
18
+ const auth = new Headers(await headers());
19
+ auth.set('Content-Type', 'application/json');
20
+ const response = await request(baseUrl + '/v1/realtime-tickets', {
21
+ method: 'POST', headers: auth, credentials: 'same-origin',
22
+ body: JSON.stringify({ language: start.language, audio_profile: start.audioProfile ?? 'short_answer' }),
23
+ });
24
+ const decoded = await response.json().catch(() => ({}));
25
+ const body = decoded && typeof decoded === 'object' ? decoded : {};
26
+ if (!response.ok)
27
+ throw new SpeakingError(body.code ?? 'HTTP_ERROR', body.message ?? 'Cannot create speaking ticket', response.status);
28
+ if (typeof body.ticket !== 'string' || !body.ticket)
29
+ throw new SpeakingError('INVALID_RESPONSE', 'Gateway returned no ticket');
30
+ return body.ticket;
31
+ },
32
+ },
33
+ microphone: { workletUrl: options.workletUrl ?? 'assets/speaking/pcm-worklet.js' },
34
+ };
35
+ }
package/dist/index.d.ts CHANGED
@@ -2,3 +2,5 @@ export * from './contracts.js';
2
2
  export * from './batch.js';
3
3
  export * from './realtime.js';
4
4
  export * from './microphone.js';
5
+ export * from './gateway.js';
6
+ export * from './public-service.js';
package/dist/index.js CHANGED
@@ -2,3 +2,5 @@ export * from './contracts.js';
2
2
  export * from './batch.js';
3
3
  export * from './realtime.js';
4
4
  export * from './microphone.js';
5
+ export * from './gateway.js';
6
+ export * from './public-service.js';
@@ -1,5 +1,5 @@
1
1
  import { RealtimeSpeakingClient } from './realtime.js';
2
- import { RealtimeFinal, StartOptions } from './contracts.js';
2
+ 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;
@@ -18,9 +18,10 @@ export declare class SpeakingMicrophone {
18
18
  private starting;
19
19
  private generation;
20
20
  private stopPromise?;
21
+ private readonly recording;
21
22
  constructor(client: RealtimeSpeakingClient, options: MicrophoneOptions);
22
23
  start(options: StartOptions): Promise<void>;
23
- stop(): Promise<RealtimeFinal>;
24
+ stop(): Promise<RecordedSpeakingFinal>;
24
25
  private release;
25
26
  cancel(): Promise<void>;
26
27
  dispose(): Promise<void>;
@@ -1,4 +1,5 @@
1
1
  import { SpeakingError } from './contracts.js';
2
+ import { SpeakingAudioBuffer } from './audio.js';
2
3
  export class SpeakingMicrophone {
3
4
  client;
4
5
  options;
@@ -13,6 +14,7 @@ export class SpeakingMicrophone {
13
14
  starting = false;
14
15
  generation = 0;
15
16
  stopPromise;
17
+ recording = new SpeakingAudioBuffer();
16
18
  constructor(client, options) {
17
19
  this.client = client;
18
20
  this.options = options;
@@ -25,6 +27,7 @@ export class SpeakingMicrophone {
25
27
  this.stopPromise = undefined;
26
28
  this.audioError = undefined;
27
29
  this.sends = Promise.resolve();
30
+ this.recording.clear();
28
31
  try {
29
32
  const stream = await navigator.mediaDevices.getUserMedia({
30
33
  audio: { channelCount: 1, echoCancellation: true, noiseSuppression: true },
@@ -55,6 +58,15 @@ export class SpeakingMicrophone {
55
58
  if (generation !== this.generation)
56
59
  return;
57
60
  if (data.type === 'pcm') {
61
+ try {
62
+ this.recording.append(new Uint8Array(data.buffer));
63
+ }
64
+ catch (error) {
65
+ this.audioError = error;
66
+ this.client.audioFailed(this.audioError);
67
+ void this.release();
68
+ return;
69
+ }
58
70
  this.client.audioLevel(data.level);
59
71
  // Enqueue immediately to let the transport enforce a bounded audio backlog.
60
72
  const pending = this.client.pushAudio(new Uint8Array(data.buffer));
@@ -106,7 +118,10 @@ export class SpeakingMicrophone {
106
118
  throw new SpeakingError('CANCELLED', 'Microphone session changed');
107
119
  if (this.audioError)
108
120
  throw this.audioError;
109
- return await this.client.stop();
121
+ const result = await this.client.stop();
122
+ if (generation !== this.generation)
123
+ throw new SpeakingError('CANCELLED', 'Microphone session changed');
124
+ return { ...result, audio: this.recording.file() };
110
125
  }
111
126
  catch (error) {
112
127
  if (generation === this.generation)
@@ -121,6 +136,7 @@ export class SpeakingMicrophone {
121
136
  return this.stopPromise;
122
137
  }
123
138
  async release() {
139
+ this.recording.clear();
124
140
  this.unsubscribe?.();
125
141
  this.unsubscribe = undefined;
126
142
  this.source?.disconnect();
@@ -0,0 +1,8 @@
1
+ /** Hosted public service. The SDK handles short-lived capabilities internally. */
2
+ export declare function createSpeakingService(options?: {
3
+ appKey?: string;
4
+ }): {
5
+ batch: import("./batch.js").BatchOptions;
6
+ realtime: import("./realtime.js").RealtimeOptions;
7
+ microphone: import("./microphone.js").MicrophoneOptions;
8
+ };
@@ -0,0 +1,41 @@
1
+ import { createSpeakingGateway } from './gateway.js';
2
+ import { SpeakingError } from './contracts.js';
3
+ const SERVICE = 'https://ai-service.fsel.edu.vn/sdk';
4
+ /** Hosted public service. The SDK handles short-lived capabilities internally. */
5
+ export function createSpeakingService(options = {}) {
6
+ if (options.appKey !== undefined && !options.appKey.trim())
7
+ throw new SpeakingError('INVALID_CONFIG', 'Application key cannot be empty');
8
+ const request = globalThis.fetch.bind(globalThis);
9
+ const session = async () => {
10
+ const response = await request(SERVICE + '/session', {
11
+ method: 'POST', credentials: 'omit',
12
+ headers: options.appKey ? { 'X-Speaking-Key': options.appKey } : undefined,
13
+ });
14
+ const body = await response.json().catch(() => ({}));
15
+ if (!response.ok)
16
+ throw new SpeakingError(body?.code ?? 'HTTP_ERROR', body?.message ?? 'Speaking service unavailable', response.status);
17
+ if (typeof body?.access_token !== 'string' || !body.access_token)
18
+ throw new SpeakingError('INVALID_RESPONSE', 'Speaking session unavailable');
19
+ return body.access_token;
20
+ };
21
+ let token;
22
+ let previousKey;
23
+ const config = createSpeakingGateway({
24
+ baseUrl: SERVICE,
25
+ headers: async () => {
26
+ if (!token)
27
+ throw new SpeakingError('INVALID_STATE', 'Submit audio before reading a public job');
28
+ return { Authorization: 'Bearer ' + token };
29
+ },
30
+ workletUrl: SERVICE + '/pcm-worklet.js',
31
+ });
32
+ config.batch.beforeSubmit = async (key) => {
33
+ if (!token || key !== previousKey) {
34
+ token = await session();
35
+ previousKey = key;
36
+ }
37
+ };
38
+ const realtime = createSpeakingGateway({ baseUrl: SERVICE, headers: async () => ({ Authorization: 'Bearer ' + await session() }) });
39
+ config.realtime = realtime.realtime;
40
+ return config;
41
+ }
@@ -1,4 +1,4 @@
1
- import { RealtimeFinal, SpeakingEvent, SpeakingState, Started, StartOptions } from './contracts.js';
1
+ import { RealtimeFinal, SpeakingError, SpeakingEvent, SpeakingState, Started, StartOptions } from './contracts.js';
2
2
  export interface RealtimeOptions {
3
3
  websocketUrl: string;
4
4
  /** Ask an authenticated application backend for a fresh single-use ticket per start. */
@@ -30,6 +30,7 @@ export declare class RealtimeSpeakingClient {
30
30
  private emit;
31
31
  private setState;
32
32
  audioLevel(level: number): void;
33
+ audioFailed(error: SpeakingError): void;
33
34
  private wait;
34
35
  private rejectWaiters;
35
36
  private fail;
package/dist/realtime.js CHANGED
@@ -35,6 +35,7 @@ export class RealtimeSpeakingClient {
35
35
  this.emit({ type: 'state', state });
36
36
  }
37
37
  audioLevel(level) { this.emit({ type: 'audioLevel', level }); }
38
+ audioFailed(error) { this.fail(error); }
38
39
  wait(type, timeout) {
39
40
  return new Promise((resolve, reject) => {
40
41
  const waiter = { type, resolve, reject, timer: setTimeout(() => {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@hoanghong/speaking-core",
3
- "version": "0.1.0",
4
- "description": "FSEL batch and native realtime speaking SDK for browsers",
3
+ "version": "0.3.0",
4
+ "description": "Online speaking SDK: gateway configuration, realtime microphone and audio upload",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
7
7
  "types": "dist/index.d.ts",
@@ -15,7 +15,8 @@
15
15
  "files": [
16
16
  "dist",
17
17
  "assets",
18
- "README.md"
18
+ "README.md",
19
+ "CHANGELOG.md"
19
20
  ],
20
21
  "scripts": {
21
22
  "build": "tsc -p tsconfig.json",