@hoanghong/speaking-core 0.0.0-stage → 0.1.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/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,19 @@
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-core
2
+
3
+ TypeScript SDK cho batch ASR và realtime WebSocket v1. Không nhúng model hay service credential.
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`.
7
+
8
+ ```bash
9
+ npm ci
10
+ npm test
11
+ npm pack
12
+ ```
13
+
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.
18
+
19
+ Hướng dẫn cấu hình, ví dụ và contract: `docs/realtime-and-sdk.md` ở repository root.
@@ -0,0 +1,52 @@
1
+ // Streaming sample-rate conversion with fractional carry between render quanta.
2
+ // Averaging provides a simple anti-alias filter; no sample loss at noninteger ratios.
3
+ class FselPcm16 extends AudioWorkletProcessor {
4
+ constructor() {
5
+ super();
6
+ this.ratio = sampleRate / 16000;
7
+ this.weight = 0; this.sum = 0;
8
+ this.samples = []; this.running = true; this.energy = 0;
9
+ this.port.onmessage = ({ data }) => {
10
+ if (data.type !== 'flush') return;
11
+ this.running = false;
12
+ if (this.weight > 0) this.samples.push(this.sum / this.weight);
13
+ this.weight = 0; this.sum = 0;
14
+ this.emit();
15
+ this.port.postMessage({ type: 'flushed' });
16
+ };
17
+ }
18
+ emit() {
19
+ if (!this.samples.length) return;
20
+ const buffer = new ArrayBuffer(this.samples.length * 2);
21
+ const view = new DataView(buffer);
22
+ let energy = 0;
23
+ this.samples.forEach((value, index) => {
24
+ const clamped = Math.max(-1, Math.min(1, value));
25
+ view.setInt16(index * 2, Math.round(clamped * (clamped < 0 ? 32768 : 32767)), true);
26
+ energy += clamped * clamped;
27
+ });
28
+ this.port.postMessage({ type: 'pcm', buffer, level: Math.sqrt(energy / this.samples.length) }, [buffer]);
29
+ this.samples = [];
30
+ }
31
+ process(inputs) {
32
+ if (!this.running) return true;
33
+ const channels = inputs[0];
34
+ if (!channels?.length) return true;
35
+ for (let i = 0; i < channels[0].length; i++) {
36
+ let value = 0;
37
+ for (const channel of channels) value += channel[i] / channels.length;
38
+ let remaining = 1;
39
+ while (remaining > 1e-9) {
40
+ const take = Math.min(remaining, this.ratio - this.weight);
41
+ this.sum += value * take; this.weight += take; remaining -= take;
42
+ if (this.weight >= this.ratio - 1e-9) {
43
+ this.samples.push(this.sum / this.ratio);
44
+ this.weight = 0; this.sum = 0;
45
+ if (this.samples.length >= 3200) this.emit();
46
+ }
47
+ }
48
+ }
49
+ return true;
50
+ }
51
+ }
52
+ registerProcessor('fsel-pcm16', FselPcm16);
@@ -0,0 +1,16 @@
1
+ import { AudioProfile, Job, Language } from './contracts.js';
2
+ export interface BatchOptions {
3
+ baseUrl: string;
4
+ /** User/gateway auth only. Never distribute an ASR service credential to an app. */
5
+ headers: () => Promise<HeadersInit>;
6
+ fetch?: typeof globalThis.fetch;
7
+ }
8
+ export declare class BatchSpeakingClient {
9
+ private readonly options;
10
+ private readonly request;
11
+ constructor(options: BatchOptions);
12
+ private call;
13
+ submitAudio(audio: Blob, language: Language, idempotencyKey: string, audioProfile?: AudioProfile, filename?: string, signal?: AbortSignal): Promise<Job>;
14
+ getResult(jobId: string, signal?: AbortSignal): Promise<Job>;
15
+ cancelJob(jobId: string): Promise<Job>;
16
+ }
package/dist/batch.js ADDED
@@ -0,0 +1,39 @@
1
+ import { SpeakingError } from './contracts.js';
2
+ export class BatchSpeakingClient {
3
+ options;
4
+ request;
5
+ constructor(options) {
6
+ this.options = options;
7
+ this.request = options.fetch ?? globalThis.fetch.bind(globalThis);
8
+ }
9
+ async call(path, init) {
10
+ const response = await this.request(this.options.baseUrl.replace(/\/$/, '') + path, init);
11
+ if (!response.ok) {
12
+ const error = await response.json().catch(() => ({}));
13
+ throw new SpeakingError(error.code ?? 'HTTP_ERROR', error.message ?? 'Speaking request failed', response.status);
14
+ }
15
+ return response.json();
16
+ }
17
+ async submitAudio(audio, language, idempotencyKey, audioProfile = 'short_answer', filename = 'recording.wav', signal) {
18
+ if (!idempotencyKey.trim())
19
+ throw new SpeakingError('INVALID_REQUEST', 'An idempotency key is required');
20
+ const form = new FormData();
21
+ form.append('file', audio, filename);
22
+ form.append('language', language);
23
+ form.append('audio_profile', audioProfile);
24
+ const headers = new Headers(await this.options.headers());
25
+ headers.delete('Content-Type');
26
+ headers.set('Idempotency-Key', idempotencyKey);
27
+ return this.call('/v1/transcription-jobs', { method: 'POST', headers, body: form, signal });
28
+ }
29
+ async getResult(jobId, signal) {
30
+ return this.call('/v1/transcription-jobs/' + encodeURIComponent(jobId), {
31
+ headers: await this.options.headers(), signal,
32
+ });
33
+ }
34
+ async cancelJob(jobId) {
35
+ return this.call('/v1/transcription-jobs/' + encodeURIComponent(jobId), {
36
+ method: 'DELETE', headers: await this.options.headers(),
37
+ });
38
+ }
39
+ }
@@ -0,0 +1,107 @@
1
+ export type Language = 'en' | 'ja' | 'ko' | 'zh' | 'vi';
2
+ export type AudioProfile = 'short_answer' | 'passage';
3
+ export type SpeakingState = 'idle' | 'connecting' | 'recording' | 'stopping' | 'stopped' | 'cancelled' | 'failed';
4
+ export interface Transcript {
5
+ text: string;
6
+ audio_duration_seconds: number;
7
+ timestamp_granularity: 'none' | 'segment' | 'word';
8
+ segments: Array<{
9
+ text: string;
10
+ start: number | null;
11
+ end: number | null;
12
+ words: Array<{
13
+ word: string;
14
+ start: number;
15
+ end: number;
16
+ probability?: number | null;
17
+ }>;
18
+ }>;
19
+ timing: {
20
+ queue_ms: number;
21
+ decode_ms: number;
22
+ inference_ms: number;
23
+ };
24
+ }
25
+ export interface Job {
26
+ job_id: string;
27
+ status: 'queued' | 'running' | 'cancelling' | 'cancelled' | 'succeeded' | 'no_speech' | 'failed';
28
+ language: Language;
29
+ audio_profile: AudioProfile;
30
+ model_id: string;
31
+ model_revision: string;
32
+ runtime: string;
33
+ execution_profile: string;
34
+ config_hash: string;
35
+ result: Transcript | null;
36
+ error: {
37
+ code: string;
38
+ message: string;
39
+ request_id: string;
40
+ } | null;
41
+ }
42
+ export interface StartOptions {
43
+ language: Language;
44
+ audioProfile?: AudioProfile;
45
+ correlationId?: string;
46
+ }
47
+ export interface RealtimeFinal {
48
+ type: 'final';
49
+ session_id: string;
50
+ status: 'succeeded' | 'no_speech';
51
+ language: Language;
52
+ audio_profile: AudioProfile;
53
+ correlation_id: string | null;
54
+ model_id: string;
55
+ model_revision: string;
56
+ runtime: string;
57
+ execution_profile: string;
58
+ config_hash: string;
59
+ last_seq: number;
60
+ total_bytes: number;
61
+ result: Transcript | null;
62
+ }
63
+ export interface Started {
64
+ type: 'started';
65
+ protocol_version: 1;
66
+ session_id: string;
67
+ audio_format: 'pcm_s16le';
68
+ sample_rate: 16000;
69
+ channels: 1;
70
+ max_chunk_bytes: number;
71
+ max_audio_seconds: number;
72
+ }
73
+ export type ServerEvent = Started | RealtimeFinal | {
74
+ type: 'ack';
75
+ session_id: string;
76
+ last_seq: number;
77
+ total_bytes: number;
78
+ } | {
79
+ type: 'partial';
80
+ session_id: string;
81
+ text: string;
82
+ last_seq: number;
83
+ } | {
84
+ type: 'stopped' | 'cancelled';
85
+ session_id: string;
86
+ } | {
87
+ type: 'error';
88
+ code: string;
89
+ message: string;
90
+ request_id: string;
91
+ session_id: string;
92
+ };
93
+ export type SpeakingEvent = ServerEvent | {
94
+ type: 'state';
95
+ state: SpeakingState;
96
+ } | {
97
+ type: 'audioLevel';
98
+ level: number;
99
+ } | {
100
+ type: 'clientError';
101
+ error: SpeakingError;
102
+ };
103
+ export declare class SpeakingError extends Error {
104
+ readonly code: string;
105
+ readonly status?: number | undefined;
106
+ constructor(code: string, message: string, status?: number | undefined);
107
+ }
@@ -0,0 +1,10 @@
1
+ export class SpeakingError extends Error {
2
+ code;
3
+ status;
4
+ constructor(code, message, status) {
5
+ super(message);
6
+ this.code = code;
7
+ this.status = status;
8
+ this.name = 'SpeakingError';
9
+ }
10
+ }
@@ -0,0 +1,4 @@
1
+ export * from './contracts.js';
2
+ export * from './batch.js';
3
+ export * from './realtime.js';
4
+ export * from './microphone.js';
package/dist/index.js ADDED
@@ -0,0 +1,4 @@
1
+ export * from './contracts.js';
2
+ export * from './batch.js';
3
+ export * from './realtime.js';
4
+ export * from './microphone.js';
@@ -0,0 +1,27 @@
1
+ import { RealtimeSpeakingClient } from './realtime.js';
2
+ import { RealtimeFinal, StartOptions } from './contracts.js';
3
+ export interface MicrophoneOptions {
4
+ /** Host the packaged pcm-worklet.js with your app's normal CSP; no inline/blob worker. */
5
+ workletUrl: string;
6
+ }
7
+ export declare class SpeakingMicrophone {
8
+ readonly client: RealtimeSpeakingClient;
9
+ private readonly options;
10
+ private stream?;
11
+ private context?;
12
+ private source?;
13
+ private worklet?;
14
+ private sends;
15
+ private audioError?;
16
+ private flush?;
17
+ private unsubscribe?;
18
+ private starting;
19
+ private generation;
20
+ private stopPromise?;
21
+ constructor(client: RealtimeSpeakingClient, options: MicrophoneOptions);
22
+ start(options: StartOptions): Promise<void>;
23
+ stop(): Promise<RealtimeFinal>;
24
+ private release;
25
+ cancel(): Promise<void>;
26
+ dispose(): Promise<void>;
27
+ }
@@ -0,0 +1,139 @@
1
+ import { SpeakingError } from './contracts.js';
2
+ export class SpeakingMicrophone {
3
+ client;
4
+ options;
5
+ stream;
6
+ context;
7
+ source;
8
+ worklet;
9
+ sends = Promise.resolve();
10
+ audioError;
11
+ flush;
12
+ unsubscribe;
13
+ starting = false;
14
+ generation = 0;
15
+ stopPromise;
16
+ constructor(client, options) {
17
+ this.client = client;
18
+ this.options = options;
19
+ }
20
+ async start(options) {
21
+ if (this.starting || this.stream)
22
+ throw new SpeakingError('INVALID_STATE', 'Microphone is already active');
23
+ const generation = ++this.generation;
24
+ this.starting = true;
25
+ this.stopPromise = undefined;
26
+ this.audioError = undefined;
27
+ this.sends = Promise.resolve();
28
+ try {
29
+ const stream = await navigator.mediaDevices.getUserMedia({
30
+ audio: { channelCount: 1, echoCancellation: true, noiseSuppression: true },
31
+ });
32
+ if (generation !== this.generation) {
33
+ stream.getTracks().forEach(track => track.stop());
34
+ throw new SpeakingError('CANCELLED', 'Microphone start cancelled');
35
+ }
36
+ this.stream = stream;
37
+ this.context = new AudioContext();
38
+ await this.context.resume();
39
+ await this.context.audioWorklet.addModule(this.options.workletUrl);
40
+ if (generation !== this.generation)
41
+ throw new SpeakingError('CANCELLED', 'Microphone start cancelled');
42
+ await this.client.start(options);
43
+ if (generation !== this.generation)
44
+ throw new SpeakingError('CANCELLED', 'Microphone start cancelled');
45
+ this.unsubscribe = this.client.onEvent(event => {
46
+ if (generation !== this.generation)
47
+ return;
48
+ if (event.type === 'clientError') {
49
+ this.audioError = event.error;
50
+ void this.release();
51
+ }
52
+ });
53
+ this.worklet = new AudioWorkletNode(this.context, 'fsel-pcm16');
54
+ this.worklet.port.onmessage = ({ data }) => {
55
+ if (generation !== this.generation)
56
+ return;
57
+ if (data.type === 'pcm') {
58
+ this.client.audioLevel(data.level);
59
+ // Enqueue immediately to let the transport enforce a bounded audio backlog.
60
+ const pending = this.client.pushAudio(new Uint8Array(data.buffer));
61
+ this.sends = Promise.all([this.sends, pending]).then(() => { }).catch(error => {
62
+ if (generation !== this.generation)
63
+ return;
64
+ this.audioError = error instanceof SpeakingError ? error : new SpeakingError('AUDIO_FAILED', 'Audio capture failed');
65
+ void this.release();
66
+ });
67
+ }
68
+ else if (data.type === 'flushed')
69
+ this.flush?.();
70
+ };
71
+ this.source = this.context.createMediaStreamSource(stream);
72
+ this.source.connect(this.worklet);
73
+ this.worklet.connect(this.context.destination);
74
+ }
75
+ catch (error) {
76
+ this.client.cancel();
77
+ await this.release();
78
+ throw error;
79
+ }
80
+ finally {
81
+ this.starting = false;
82
+ }
83
+ }
84
+ stop() {
85
+ if (this.stopPromise)
86
+ return this.stopPromise;
87
+ this.stopPromise = (async () => {
88
+ const generation = this.generation;
89
+ if (!this.worklet || this.client.state !== 'recording')
90
+ throw this.audioError ?? new SpeakingError('INVALID_STATE', 'Microphone is not recording');
91
+ this.source?.disconnect();
92
+ this.stream?.getTracks().forEach(track => track.stop());
93
+ try {
94
+ await new Promise((resolve, reject) => {
95
+ const done = () => { clearTimeout(timer); if (this.flush === done)
96
+ this.flush = undefined; resolve(); };
97
+ const timer = setTimeout(() => { if (this.flush === done)
98
+ this.flush = undefined; reject(new SpeakingError('TIMEOUT', 'Microphone flush timed out')); }, 2000);
99
+ this.flush = done;
100
+ this.worklet.port.postMessage({ type: 'flush' });
101
+ });
102
+ if (generation !== this.generation)
103
+ throw new SpeakingError('CANCELLED', 'Microphone session changed');
104
+ await this.sends;
105
+ if (generation !== this.generation)
106
+ throw new SpeakingError('CANCELLED', 'Microphone session changed');
107
+ if (this.audioError)
108
+ throw this.audioError;
109
+ return await this.client.stop();
110
+ }
111
+ catch (error) {
112
+ if (generation === this.generation)
113
+ this.client.cancel();
114
+ throw error;
115
+ }
116
+ finally {
117
+ if (generation === this.generation)
118
+ await this.release();
119
+ }
120
+ })();
121
+ return this.stopPromise;
122
+ }
123
+ async release() {
124
+ this.unsubscribe?.();
125
+ this.unsubscribe = undefined;
126
+ this.source?.disconnect();
127
+ this.source = undefined;
128
+ this.worklet?.disconnect();
129
+ this.worklet = undefined;
130
+ this.stream?.getTracks().forEach(track => track.stop());
131
+ this.stream = undefined;
132
+ const context = this.context;
133
+ this.context = undefined;
134
+ if (context && context.state !== 'closed')
135
+ await context.close();
136
+ }
137
+ async cancel() { ++this.generation; this.client.cancel(); await this.release(); }
138
+ async dispose() { await this.cancel(); this.client.dispose(); }
139
+ }
@@ -0,0 +1,42 @@
1
+ import { RealtimeFinal, SpeakingEvent, SpeakingState, Started, StartOptions } from './contracts.js';
2
+ export interface RealtimeOptions {
3
+ websocketUrl: string;
4
+ /** Ask an authenticated application backend for a fresh single-use ticket per start. */
5
+ ticketProvider: (options: StartOptions) => Promise<string>;
6
+ socketFactory?: (url: string) => WebSocket;
7
+ startTimeoutMs?: number;
8
+ ticketTimeoutMs?: number;
9
+ ackTimeoutMs?: number;
10
+ maxQueuedBytes?: number;
11
+ }
12
+ export declare class RealtimeSpeakingClient {
13
+ private readonly options;
14
+ state: SpeakingState;
15
+ private socket?;
16
+ private listeners;
17
+ private waiters;
18
+ private queue;
19
+ private queuedBytes;
20
+ private seq;
21
+ private bytes;
22
+ private started?;
23
+ private final?;
24
+ private failure?;
25
+ private stopPromise?;
26
+ private generation;
27
+ private cancelTicket?;
28
+ constructor(options: RealtimeOptions);
29
+ onEvent(listener: (event: SpeakingEvent) => void): () => void;
30
+ private emit;
31
+ private setState;
32
+ audioLevel(level: number): void;
33
+ private wait;
34
+ private rejectWaiters;
35
+ private fail;
36
+ private acquireTicket;
37
+ start(options: StartOptions): Promise<Started>;
38
+ pushAudio(pcm: Uint8Array): Promise<void>;
39
+ stop(): Promise<RealtimeFinal>;
40
+ cancel(): void;
41
+ dispose(): void;
42
+ }
@@ -0,0 +1,269 @@
1
+ import { SpeakingError } from './contracts.js';
2
+ export class RealtimeSpeakingClient {
3
+ options;
4
+ state = 'idle';
5
+ socket;
6
+ listeners = new Set();
7
+ waiters = new Set();
8
+ queue = Promise.resolve();
9
+ queuedBytes = 0;
10
+ seq = 0;
11
+ bytes = 0;
12
+ started;
13
+ final;
14
+ failure;
15
+ stopPromise;
16
+ generation = 0;
17
+ cancelTicket;
18
+ constructor(options) {
19
+ this.options = options;
20
+ }
21
+ onEvent(listener) {
22
+ this.listeners.add(listener);
23
+ return () => this.listeners.delete(listener);
24
+ }
25
+ emit(event) {
26
+ for (const listener of this.listeners) {
27
+ try {
28
+ listener(event);
29
+ }
30
+ catch { /* Consumer callbacks must not break transport cleanup. */ }
31
+ }
32
+ }
33
+ setState(state) {
34
+ this.state = state;
35
+ this.emit({ type: 'state', state });
36
+ }
37
+ audioLevel(level) { this.emit({ type: 'audioLevel', level }); }
38
+ wait(type, timeout) {
39
+ return new Promise((resolve, reject) => {
40
+ const waiter = { type, resolve, reject, timer: setTimeout(() => {
41
+ this.waiters.delete(waiter);
42
+ const error = new SpeakingError('TIMEOUT', 'Speaking connection timed out');
43
+ reject(error);
44
+ this.fail(error);
45
+ }, timeout) };
46
+ this.waiters.add(waiter);
47
+ });
48
+ }
49
+ rejectWaiters(error) {
50
+ for (const waiter of this.waiters) {
51
+ clearTimeout(waiter.timer);
52
+ waiter.reject(error);
53
+ }
54
+ this.waiters.clear();
55
+ }
56
+ fail(error) {
57
+ if (['failed', 'cancelled', 'stopped'].includes(this.state))
58
+ return;
59
+ this.failure = error;
60
+ this.setState('failed');
61
+ this.rejectWaiters(error);
62
+ this.socket?.close();
63
+ this.emit({ type: 'clientError', error });
64
+ }
65
+ async acquireTicket(options) {
66
+ let timer;
67
+ let cancel;
68
+ try {
69
+ return await Promise.race([
70
+ this.options.ticketProvider(options),
71
+ new Promise((_, reject) => {
72
+ cancel = () => reject(new SpeakingError('CANCELLED', 'Ticket request cancelled'));
73
+ this.cancelTicket = cancel;
74
+ timer = setTimeout(() => reject(new SpeakingError('TIMEOUT', 'Ticket request timed out')), this.options.ticketTimeoutMs ?? 30000);
75
+ }),
76
+ ]);
77
+ }
78
+ finally {
79
+ clearTimeout(timer);
80
+ if (this.cancelTicket === cancel)
81
+ this.cancelTicket = undefined;
82
+ }
83
+ }
84
+ async start(options) {
85
+ if (['connecting', 'recording', 'stopping'].includes(this.state)) {
86
+ throw new SpeakingError('INVALID_STATE', 'Speaking session is already active');
87
+ }
88
+ const generation = ++this.generation;
89
+ this.seq = 0;
90
+ this.bytes = 0;
91
+ this.queuedBytes = 0;
92
+ this.final = undefined;
93
+ this.failure = undefined;
94
+ this.stopPromise = undefined;
95
+ this.queue = Promise.resolve();
96
+ this.setState('connecting');
97
+ try {
98
+ const ticket = await this.acquireTicket(options);
99
+ if (generation !== this.generation || this.state !== 'connecting') {
100
+ throw new SpeakingError('CANCELLED', 'Speaking start was cancelled');
101
+ }
102
+ const socket = (this.options.socketFactory ?? (url => new WebSocket(url)))(this.options.websocketUrl);
103
+ this.socket = socket;
104
+ const ready = this.wait('started', this.options.startTimeoutMs ?? 210000);
105
+ socket.onopen = () => socket.send(JSON.stringify({ type: 'authenticate', protocol_version: 1, ticket }));
106
+ socket.onmessage = event => {
107
+ if (generation !== this.generation)
108
+ return;
109
+ try {
110
+ const message = JSON.parse(String(event.data));
111
+ const allowed = ['started', 'ack', 'partial', 'final', 'stopped', 'cancelled', 'error'];
112
+ if (!allowed.includes(message.type))
113
+ throw new Error('Unknown event');
114
+ if (message.type === 'error') {
115
+ this.emit(message);
116
+ this.fail(new SpeakingError(message.code, message.message));
117
+ return;
118
+ }
119
+ if (message.type === 'final')
120
+ this.final = message;
121
+ if (message.type === 'stopped') {
122
+ if (!this.final || this.final.last_seq !== this.seq || this.final.total_bytes !== this.bytes) {
123
+ throw new Error('Incomplete final result');
124
+ }
125
+ this.setState('stopped');
126
+ }
127
+ for (const waiter of this.waiters) {
128
+ if (waiter.type === message.type) {
129
+ clearTimeout(waiter.timer);
130
+ this.waiters.delete(waiter);
131
+ waiter.resolve(message);
132
+ }
133
+ }
134
+ this.emit(message);
135
+ }
136
+ catch {
137
+ this.fail(new SpeakingError('PROTOCOL_ERROR', 'Invalid speaking event'));
138
+ }
139
+ };
140
+ socket.onerror = () => {
141
+ if (generation === this.generation)
142
+ this.fail(new SpeakingError('CONNECTION_LOST', 'Speaking connection failed'));
143
+ };
144
+ socket.onclose = () => {
145
+ if (generation === this.generation && !['stopped', 'cancelled', 'failed'].includes(this.state)) {
146
+ this.fail(new SpeakingError('CONNECTION_LOST', 'Speaking connection closed before completion'));
147
+ }
148
+ };
149
+ const started = await ready;
150
+ if (generation !== this.generation || this.state !== 'connecting') {
151
+ throw new SpeakingError('CANCELLED', 'Speaking start was cancelled');
152
+ }
153
+ if (started.protocol_version !== 1 || started.sample_rate !== 16000 || started.channels !== 1 ||
154
+ started.audio_format !== 'pcm_s16le' || !Number.isInteger(started.max_chunk_bytes) ||
155
+ started.max_chunk_bytes < 2 || started.max_chunk_bytes % 2 ||
156
+ !Number.isInteger(started.max_audio_seconds) || started.max_audio_seconds < 1) {
157
+ throw new SpeakingError('PROTOCOL_ERROR', 'Unsupported speaking audio contract');
158
+ }
159
+ this.started = started;
160
+ this.setState('recording');
161
+ return started;
162
+ }
163
+ catch (error) {
164
+ if (generation === this.generation)
165
+ this.fail(error instanceof SpeakingError ? error :
166
+ new SpeakingError('START_FAILED', 'Could not start speaking session'));
167
+ throw error;
168
+ }
169
+ }
170
+ pushAudio(pcm) {
171
+ if (this.state !== 'recording' || !pcm.length || pcm.length % 2) {
172
+ return Promise.reject(new SpeakingError('INVALID_AUDIO', 'An active session and PCM16 samples are required'));
173
+ }
174
+ if (this.queuedBytes + pcm.length > (this.options.maxQueuedBytes ?? 64000)) {
175
+ const error = new SpeakingError('BACKPRESSURE', 'Speaking connection cannot keep up with audio');
176
+ this.fail(error);
177
+ return Promise.reject(error);
178
+ }
179
+ const owned = pcm.slice();
180
+ const generation = this.generation;
181
+ this.queuedBytes += owned.length;
182
+ const operation = this.queue.then(async () => {
183
+ if (generation !== this.generation)
184
+ throw new SpeakingError('CANCELLED', 'Session changed');
185
+ if (this.failure)
186
+ throw this.failure;
187
+ const max = this.started.max_chunk_bytes;
188
+ for (let offset = 0; offset < owned.length; offset += max) {
189
+ const chunk = owned.subarray(offset, offset + max);
190
+ if (this.bytes + chunk.length > this.started.max_audio_seconds * 32000) {
191
+ throw new SpeakingError('AUDIO_TOO_LONG', 'Speaking duration limit reached');
192
+ }
193
+ const frame = new Uint8Array(4 + chunk.length);
194
+ new DataView(frame.buffer).setUint32(0, this.seq + 1, false);
195
+ frame.set(chunk, 4);
196
+ const ack = this.wait('ack', this.options.ackTimeoutMs ?? 15000);
197
+ try {
198
+ this.socket.send(frame);
199
+ }
200
+ catch {
201
+ this.fail(new SpeakingError('CONNECTION_LOST', 'Could not send speaking audio'));
202
+ }
203
+ const received = await ack;
204
+ if (generation !== this.generation)
205
+ throw new SpeakingError('CANCELLED', 'Session changed');
206
+ if (received.type !== 'ack' || received.last_seq !== this.seq + 1 || received.total_bytes !== this.bytes + chunk.length) {
207
+ throw new SpeakingError('PROTOCOL_ERROR', 'Audio acknowledgement does not match');
208
+ }
209
+ this.seq++;
210
+ this.bytes += chunk.length;
211
+ }
212
+ }).catch(error => {
213
+ if (generation === this.generation)
214
+ this.fail(error instanceof SpeakingError ? error : new SpeakingError('CONNECTION_LOST', 'Speaking audio failed'));
215
+ throw error;
216
+ }).finally(() => { if (generation === this.generation)
217
+ this.queuedBytes -= owned.length; });
218
+ this.queue = operation.catch(() => { });
219
+ return operation;
220
+ }
221
+ stop() {
222
+ if (this.stopPromise)
223
+ return this.stopPromise;
224
+ if (this.state !== 'recording')
225
+ return Promise.reject(this.failure ?? new SpeakingError('INVALID_STATE', 'No active speaking session'));
226
+ const generation = this.generation;
227
+ this.setState('stopping');
228
+ this.stopPromise = (async () => {
229
+ await this.queue;
230
+ if (generation !== this.generation)
231
+ throw new SpeakingError('CANCELLED', 'Session changed');
232
+ if (this.failure)
233
+ throw this.failure;
234
+ const stopped = this.wait('stopped', this.options.ackTimeoutMs ?? 15000);
235
+ try {
236
+ this.socket.send(JSON.stringify({ type: 'stop', last_seq: this.seq, total_bytes: this.bytes }));
237
+ }
238
+ catch {
239
+ this.fail(new SpeakingError('CONNECTION_LOST', 'Could not finalize speaking'));
240
+ }
241
+ await stopped;
242
+ if (generation !== this.generation)
243
+ throw new SpeakingError('CANCELLED', 'Session changed');
244
+ if (!this.final || this.final.last_seq !== this.seq || this.final.total_bytes !== this.bytes) {
245
+ const error = new SpeakingError('INCOMPLETE_AUDIO', 'Final transcript does not match audio');
246
+ this.fail(error);
247
+ throw error;
248
+ }
249
+ this.setState('stopped');
250
+ this.socket?.close();
251
+ return this.final;
252
+ })();
253
+ return this.stopPromise;
254
+ }
255
+ cancel() {
256
+ ++this.generation;
257
+ this.cancelTicket?.();
258
+ this.setState('cancelled');
259
+ this.rejectWaiters(new SpeakingError('CANCELLED', 'Speaking session cancelled'));
260
+ try {
261
+ if (this.socket?.readyState === 1)
262
+ this.socket.send(JSON.stringify({ type: 'cancel' }));
263
+ }
264
+ finally {
265
+ this.socket?.close();
266
+ }
267
+ }
268
+ dispose() { this.cancel(); this.listeners.clear(); }
269
+ }
package/package.json CHANGED
@@ -1,6 +1,35 @@
1
- {
2
- "name": "@hoanghong/speaking-core",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
1
+ {
2
+ "name": "@hoanghong/speaking-core",
3
+ "version": "0.1.0",
4
+ "description": "FSEL batch and native realtime speaking SDK for browsers",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ },
13
+ "./pcm-worklet.js": "./assets/pcm-worklet.js"
14
+ },
15
+ "files": [
16
+ "dist",
17
+ "assets",
18
+ "README.md"
19
+ ],
20
+ "scripts": {
21
+ "build": "tsc -p tsconfig.json",
22
+ "test": "npm run build && node --test test/*.test.mjs"
23
+ },
24
+ "devDependencies": {
25
+ "typescript": "~5.5.4"
26
+ },
27
+ "engines": {
28
+ "node": ">=18"
29
+ },
30
+ "license": "MIT",
31
+ "publishConfig": {
32
+ "access": "public",
33
+ "registry": "https://registry.npmjs.org/"
34
+ }
35
+ }