@hoanghong/speaking-core 0.0.0-stage → 0.2.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 +12 -0
- package/LICENSE +21 -0
- package/README.md +120 -3
- package/assets/pcm-worklet.js +52 -0
- package/dist/batch.d.ts +16 -0
- package/dist/batch.js +39 -0
- package/dist/contracts.d.ts +107 -0
- package/dist/contracts.js +10 -0
- package/dist/gateway.d.ts +16 -0
- package/dist/gateway.js +35 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/microphone.d.ts +27 -0
- package/dist/microphone.js +139 -0
- package/dist/realtime.d.ts +42 -0
- package/dist/realtime.js +269 -0
- package/package.json +36 -6
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.2.0
|
|
4
|
+
|
|
5
|
+
- Add createSpeakingGateway for shared HTTP/WebSocket/ticket configuration.
|
|
6
|
+
- Validate gateway URLs and malformed ticket responses.
|
|
7
|
+
- Add complete microphone/upload examples and gateway/asset setup instructions.
|
|
8
|
+
- Preserve existing low-level 0.1 APIs.
|
|
9
|
+
|
|
10
|
+
## 0.1.0
|
|
11
|
+
|
|
12
|
+
- Initial batch, realtime and browser microphone SDK.
|
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,120 @@
|
|
|
1
|
-
#
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
# @hoanghong/speaking-core
|
|
2
|
+
|
|
3
|
+
TypeScript SDK cho FSEL ASR online: batch upload, WebSocket realtime, microphone AudioWorklet. MIT. Ngôn ngữ `en`, `ja`, `ko`, `zh`, `vi`. Không chứa model hoặc service credential.
|
|
4
|
+
|
|
5
|
+
Muốn UI dùng ngay: cài [@hoanghong/speaking-angular](https://www.npmjs.com/package/@hoanghong/speaking-angular). Flutter dùng [fsel_speaking](https://pub.dev/packages/fsel_speaking). Core dành cho người tự thiết kế giao diện browser.
|
|
6
|
+
|
|
7
|
+
## 1. Cài đặt
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install @hoanghong/speaking-core@^0.2.0
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Cần browser có WebSocket, AudioWorklet, getUserMedia, HTTPS hoặc localhost. Node >=18 dùng cho tooling/batch; microphone chỉ chạy trong browser. Thư viện là ESM.
|
|
14
|
+
|
|
15
|
+
## 2. Chuẩn bị gateway và asset
|
|
16
|
+
|
|
17
|
+
Backend ứng dụng cung cấp prefix như `/api/speaking`, xác thực người dùng và relay giao thức FSEL ASR tới server. Chỉ backend giữ service token. Không nhúng khóa S3/service token vào bundle. Địa chỉ gateway quan sát được qua Network; không thể giấu URL mạng trong SDK. Package không kèm server ASR hoặc tài khoản miễn phí; server phải có weights cho ngôn ngữ được chọn.
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
Gateway forward `POST /v1/realtime-tickets`, `WS /v1/realtime`, `POST /v1/transcription-jobs`, `GET/DELETE /v1/transcription-jobs/{id}` sau khi bỏ prefix. Bảo vệ route bằng đăng nhập, quyền sở hữu job và quota; không mở relay công khai dùng service token.
|
|
21
|
+
|
|
22
|
+
Copy `node_modules/@hoanghong/speaking-core/assets/pcm-worklet.js` sang static asset `assets/speaking/pcm-worklet.js`. Với Angular thêm vào `angular.json` assets:
|
|
23
|
+
|
|
24
|
+
```json
|
|
25
|
+
{"glob":"pcm-worklet.js","input":"node_modules/@hoanghong/speaking-core/assets","output":"assets/speaking"}
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Với bundler khác, dùng chức năng copy static assets tương ứng; URL phải trả JavaScript. Không dùng một HTML fallback làm worklet.
|
|
29
|
+
|
|
30
|
+
## 3. Ví dụ microphone đầy đủ
|
|
31
|
+
|
|
32
|
+
HTML của trang:
|
|
33
|
+
|
|
34
|
+
```html
|
|
35
|
+
<button id="start">Bắt đầu</button>
|
|
36
|
+
<button id="stop" disabled>Dừng</button>
|
|
37
|
+
<button id="cancel" disabled>Hủy</button>
|
|
38
|
+
<pre id="transcript" aria-live="polite"></pre>
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Module JavaScript/TypeScript được bundler xử lý sau khi DOM đã có:
|
|
42
|
+
|
|
43
|
+
```js
|
|
44
|
+
import {
|
|
45
|
+
createSpeakingGateway, RealtimeSpeakingClient, SpeakingMicrophone,
|
|
46
|
+
} from '@hoanghong/speaking-core';
|
|
47
|
+
|
|
48
|
+
const config = createSpeakingGateway({ baseUrl: '/api/speaking' });
|
|
49
|
+
const client = new RealtimeSpeakingClient(config.realtime);
|
|
50
|
+
const microphone = new SpeakingMicrophone(client, config.microphone);
|
|
51
|
+
const start = document.querySelector('#start');
|
|
52
|
+
const stop = document.querySelector('#stop');
|
|
53
|
+
const cancel = document.querySelector('#cancel');
|
|
54
|
+
const transcript = document.querySelector('#transcript');
|
|
55
|
+
function idle() { start.disabled = false; stop.disabled = true; cancel.disabled = true; }
|
|
56
|
+
const unsubscribe = client.onEvent(event => {
|
|
57
|
+
if (event.type === 'partial') transcript.textContent = event.text;
|
|
58
|
+
if (event.type === 'clientError') { transcript.textContent = event.error.message; idle(); }
|
|
59
|
+
});
|
|
60
|
+
start.onclick = async () => {
|
|
61
|
+
start.disabled = true;
|
|
62
|
+
transcript.textContent = 'Đang chuẩn bị…';
|
|
63
|
+
try {
|
|
64
|
+
await microphone.start({ language: 'vi' });
|
|
65
|
+
transcript.textContent = 'Đang nghe…';
|
|
66
|
+
stop.disabled = false; cancel.disabled = false;
|
|
67
|
+
} catch (error) { transcript.textContent = String(error); idle(); }
|
|
68
|
+
};
|
|
69
|
+
stop.onclick = async () => {
|
|
70
|
+
stop.disabled = true;
|
|
71
|
+
try {
|
|
72
|
+
const final = await microphone.stop();
|
|
73
|
+
transcript.textContent = final.result?.text ?? 'Không có lời nói.';
|
|
74
|
+
} catch (error) { transcript.textContent = String(error); }
|
|
75
|
+
finally { idle(); }
|
|
76
|
+
};
|
|
77
|
+
cancel.onclick = async () => {
|
|
78
|
+
try { await microphone.cancel(); transcript.textContent = 'Đã hủy.'; }
|
|
79
|
+
catch (error) { transcript.textContent = String(error); }
|
|
80
|
+
finally { idle(); }
|
|
81
|
+
};
|
|
82
|
+
window.addEventListener('pagehide', () => {
|
|
83
|
+
unsubscribe();
|
|
84
|
+
void microphone.dispose();
|
|
85
|
+
}, { once: true });
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Không tự bắt đầu ghi âm khi load trang. Chờ quyền microphone và `start()` hoàn tất trước khi nói. Khi dùng trong SPA, gọi unsubscribe/dispose ở lifecycle unmount thay vì chỉ pagehide. Không tự reconnect/replay audio sau mất mạng.
|
|
89
|
+
|
|
90
|
+
## 4. Ví dụ upload đầy đủ
|
|
91
|
+
|
|
92
|
+
```js
|
|
93
|
+
import { BatchSpeakingClient, createSpeakingGateway } from '@hoanghong/speaking-core';
|
|
94
|
+
|
|
95
|
+
const batch = new BatchSpeakingClient(createSpeakingGateway().batch);
|
|
96
|
+
export async function transcribeFile(file, signal) {
|
|
97
|
+
if (file.size > 25 * 1024 * 1024) throw new Error('File tối đa 25 MiB');
|
|
98
|
+
let job = await batch.submitAudio(file, 'vi', crypto.randomUUID(), 'short_answer', file.name, signal);
|
|
99
|
+
const deadline = Date.now() + 10 * 60 * 1000;
|
|
100
|
+
while (!['succeeded', 'no_speech', 'failed', 'cancelled'].includes(job.status)) {
|
|
101
|
+
if (Date.now() > deadline) throw new Error('Hết thời gian chờ; job có thể còn chạy');
|
|
102
|
+
if (signal?.aborted) throw new DOMException('Aborted', 'AbortError');
|
|
103
|
+
await new Promise(resolve => setTimeout(resolve, 1500));
|
|
104
|
+
job = await batch.getResult(job.job_id, signal);
|
|
105
|
+
}
|
|
106
|
+
if (job.error) throw new Error(job.error.message);
|
|
107
|
+
return job;
|
|
108
|
+
}
|
|
109
|
+
// Truyền File lấy từ input[type=file] vào transcribeFile(file).
|
|
110
|
+
// Chủ động hủy server job: await batch.cancelJob(jobId).
|
|
111
|
+
// AbortSignal chỉ dừng request phía client, không tự hủy job trên server.
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
## Cấu hình và xử lý lỗi
|
|
115
|
+
|
|
116
|
+
`createSpeakingGateway({baseUrl, headers, workletUrl, fetch})` trả ba nhóm `batch`, `realtime`, `microphone`. Mặc định base `/api/speaking`, worklet `assets/speaking/pcm-worklet.js`. Relative base dùng origin trang hiện tại; ngoài browser truyền URL HTTP(S) tuyệt đối. HTTPS tự dùng WSS. Không truyền base có userinfo/query/hash. `headers` là callback Promise trả HeadersInit, dùng auth của người dùng ứng dụng nếu cần, không dùng service token dài hạn. Cùng origin sử dụng cookie của ứng dụng; auth khác origin/CORS cần backend cấu hình tương ứng.
|
|
117
|
+
|
|
118
|
+
Realtime ticket được xin tự động qua POST JSON `language`/`audio_profile`; ticket dùng để authenticate WebSocket. SDK xử lý queue audio có giới hạn, ACK, stop/final flush; microphone thu PCM mono 16 kHz. Realtime tối đa 300 giây theo server. Các constructor cấu hình thủ công 0.1 vẫn dùng được.
|
|
119
|
+
|
|
120
|
+
401/403: kiểm tra auth ứng dụng/gateway. 404: prefix/rewrite/asset. 503: model hoặc tài nguyên server. Worklet MIME/404: kiểm tra copy asset. Microphone denied: quyền và HTTPS. WS timeout: proxy Upgrade/WSS và timeout. `SpeakingError` có `code`, `message`, `status` khi có HTTP status. Không log credential hoặc ticket.
|
|
@@ -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);
|
package/dist/batch.d.ts
ADDED
|
@@ -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,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
|
+
};
|
package/dist/gateway.js
ADDED
|
@@ -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
ADDED
package/dist/index.js
ADDED
|
@@ -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
|
+
}
|
package/dist/realtime.js
ADDED
|
@@ -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,36 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@hoanghong/speaking-core",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
1
|
+
{
|
|
2
|
+
"name": "@hoanghong/speaking-core",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Online speaking SDK: gateway configuration, realtime microphone and audio upload",
|
|
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
|
+
"CHANGELOG.md"
|
|
20
|
+
],
|
|
21
|
+
"scripts": {
|
|
22
|
+
"build": "tsc -p tsconfig.json",
|
|
23
|
+
"test": "npm run build && node --test test/*.test.mjs"
|
|
24
|
+
},
|
|
25
|
+
"devDependencies": {
|
|
26
|
+
"typescript": "~5.5.4"
|
|
27
|
+
},
|
|
28
|
+
"engines": {
|
|
29
|
+
"node": ">=18"
|
|
30
|
+
},
|
|
31
|
+
"license": "MIT",
|
|
32
|
+
"publishConfig": {
|
|
33
|
+
"access": "public",
|
|
34
|
+
"registry": "https://registry.npmjs.org/"
|
|
35
|
+
}
|
|
36
|
+
}
|