@scribeberry/sdk 0.2.1 → 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/README.md +60 -0
- package/dist/index.js +3 -3
- package/dist/react.d.mts +120 -0
- package/dist/react.d.ts +120 -0
- package/dist/react.js +956 -0
- package/dist/react.mjs +919 -0
- package/package.json +33 -9
package/dist/react.mjs
ADDED
|
@@ -0,0 +1,919 @@
|
|
|
1
|
+
// src/react/use-transcription.ts
|
|
2
|
+
import { useCallback, useRef, useState } from "react";
|
|
3
|
+
|
|
4
|
+
// src/lib/errors.ts
|
|
5
|
+
var ScribeberryError = class extends Error {
|
|
6
|
+
constructor(message, code, status) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.code = code;
|
|
9
|
+
this.status = status;
|
|
10
|
+
this.name = "ScribeberryError";
|
|
11
|
+
}
|
|
12
|
+
};
|
|
13
|
+
var AuthenticationError = class extends ScribeberryError {
|
|
14
|
+
constructor(message = "Invalid or expired API key") {
|
|
15
|
+
super(message, "AUTHENTICATION_ERROR", 401);
|
|
16
|
+
this.name = "AuthenticationError";
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
var RateLimitError = class extends ScribeberryError {
|
|
20
|
+
constructor(message = "Rate limit exceeded") {
|
|
21
|
+
super(message, "RATE_LIMIT_EXCEEDED", 429);
|
|
22
|
+
this.name = "RateLimitError";
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// src/lib/runtime.ts
|
|
27
|
+
var isBrowser = () => typeof window !== "undefined" && typeof window.navigator !== "undefined";
|
|
28
|
+
|
|
29
|
+
// src/client.ts
|
|
30
|
+
var DEFAULT_BASE_URL = "https://api.scribeberry.com";
|
|
31
|
+
var DEFAULT_TIMEOUT = 3e4;
|
|
32
|
+
var TOKEN_REFRESH_BUFFER_MS = 6e4;
|
|
33
|
+
var HttpClient = class {
|
|
34
|
+
constructor(config) {
|
|
35
|
+
/** Current realtime token (fetched via callback or static). */
|
|
36
|
+
this.currentToken = null;
|
|
37
|
+
/** When the current token expires. */
|
|
38
|
+
this.tokenExpiresAt = null;
|
|
39
|
+
/** Active refresh timer. */
|
|
40
|
+
this.refreshTimer = null;
|
|
41
|
+
/** Listeners notified on token refresh. */
|
|
42
|
+
this.tokenRefreshListeners = /* @__PURE__ */ new Set();
|
|
43
|
+
if (!config.apiKey && !config.getRealtimeToken) {
|
|
44
|
+
throw new ScribeberryError(
|
|
45
|
+
"Either apiKey or getRealtimeToken is required",
|
|
46
|
+
"MISSING_AUTH"
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
this.getRealtimeToken = config.getRealtimeToken;
|
|
50
|
+
this.hasTokenProvider = !!config.getRealtimeToken;
|
|
51
|
+
if (config.apiKey) {
|
|
52
|
+
const validPrefixes = ["sk_test_", "sk_live_", "sb_rt_"];
|
|
53
|
+
if (!validPrefixes.some((p) => config.apiKey.startsWith(p))) {
|
|
54
|
+
throw new ScribeberryError(
|
|
55
|
+
"API key must start with sk_test_, sk_live_, or sb_rt_",
|
|
56
|
+
"INVALID_API_KEY_FORMAT"
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
this.isTemporaryToken = !!config.getRealtimeToken || !!config.apiKey?.startsWith("sb_rt_");
|
|
61
|
+
if (isBrowser() && config.apiKey && !config.apiKey.startsWith("sb_rt_") && !config.getRealtimeToken) {
|
|
62
|
+
console.warn(
|
|
63
|
+
"[Scribeberry] WARNING: Using a permanent API key in the browser is a security risk.\nUse getRealtimeToken callback or sb.realtime.createToken() on your server.\nSee: https://scribeberry.com/docs/realtime#browser-usage"
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
this.apiKey = config.apiKey;
|
|
67
|
+
this.baseUrl = (config.baseUrl || DEFAULT_BASE_URL).replace(/\/$/, "");
|
|
68
|
+
this.timeout = config.timeout || DEFAULT_TIMEOUT;
|
|
69
|
+
if (config.apiKey?.startsWith("sb_rt_")) {
|
|
70
|
+
this.currentToken = config.apiKey;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* The WebSocket URL derived from the base URL.
|
|
75
|
+
*/
|
|
76
|
+
get wsUrl() {
|
|
77
|
+
return this.baseUrl.replace("https://", "wss://").replace("http://", "ws://");
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Get the current realtime token for WebSocket connections.
|
|
81
|
+
* If a getRealtimeToken callback is configured, fetches/refreshes as needed.
|
|
82
|
+
* Otherwise returns the static apiKey.
|
|
83
|
+
*/
|
|
84
|
+
async getWsToken() {
|
|
85
|
+
if (this.getRealtimeToken) {
|
|
86
|
+
if (this.currentToken && this.tokenExpiresAt && Date.now() < this.tokenExpiresAt - TOKEN_REFRESH_BUFFER_MS) {
|
|
87
|
+
return this.currentToken;
|
|
88
|
+
}
|
|
89
|
+
return this.refreshToken();
|
|
90
|
+
}
|
|
91
|
+
if (!this.apiKey) {
|
|
92
|
+
throw new ScribeberryError(
|
|
93
|
+
"No API key or token available",
|
|
94
|
+
"MISSING_AUTH"
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
return this.apiKey;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Fetch a fresh token from the callback and schedule auto-refresh.
|
|
101
|
+
*/
|
|
102
|
+
async refreshToken() {
|
|
103
|
+
if (!this.getRealtimeToken) {
|
|
104
|
+
throw new ScribeberryError(
|
|
105
|
+
"No token provider configured",
|
|
106
|
+
"MISSING_AUTH"
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
try {
|
|
110
|
+
const result = await this.getRealtimeToken();
|
|
111
|
+
if (!result.token || !result.expiresAt) {
|
|
112
|
+
throw new ScribeberryError(
|
|
113
|
+
"getRealtimeToken must return { token, expiresAt }",
|
|
114
|
+
"INVALID_TOKEN_RESPONSE"
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
this.currentToken = result.token;
|
|
118
|
+
this.tokenExpiresAt = new Date(result.expiresAt).getTime();
|
|
119
|
+
this.scheduleRefresh();
|
|
120
|
+
return result.token;
|
|
121
|
+
} catch (err) {
|
|
122
|
+
if (err instanceof ScribeberryError) throw err;
|
|
123
|
+
throw new ScribeberryError(
|
|
124
|
+
`Failed to fetch realtime token: ${err instanceof Error ? err.message : String(err)}`,
|
|
125
|
+
"TOKEN_FETCH_FAILED"
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Register a listener that's called when the token is refreshed.
|
|
131
|
+
* Used by RealtimeTranscriptionSession to reconnect with the new token.
|
|
132
|
+
*/
|
|
133
|
+
onTokenRefresh(listener) {
|
|
134
|
+
this.tokenRefreshListeners.add(listener);
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Remove a token refresh listener.
|
|
138
|
+
*/
|
|
139
|
+
offTokenRefresh(listener) {
|
|
140
|
+
this.tokenRefreshListeners.delete(listener);
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Schedule an automatic token refresh before the current token expires.
|
|
144
|
+
*/
|
|
145
|
+
scheduleRefresh() {
|
|
146
|
+
if (this.refreshTimer) {
|
|
147
|
+
clearTimeout(this.refreshTimer);
|
|
148
|
+
}
|
|
149
|
+
if (!this.tokenExpiresAt || !this.getRealtimeToken) return;
|
|
150
|
+
const msUntilExpiry = this.tokenExpiresAt - Date.now();
|
|
151
|
+
const refreshIn = Math.max(msUntilExpiry - TOKEN_REFRESH_BUFFER_MS, 5e3);
|
|
152
|
+
this.refreshTimer = setTimeout(async () => {
|
|
153
|
+
try {
|
|
154
|
+
const token = await this.refreshToken();
|
|
155
|
+
for (const listener of this.tokenRefreshListeners) {
|
|
156
|
+
try {
|
|
157
|
+
listener(token);
|
|
158
|
+
} catch {
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
} catch {
|
|
162
|
+
}
|
|
163
|
+
}, refreshIn);
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Make an authenticated HTTP request to the Scribeberry API.
|
|
167
|
+
*/
|
|
168
|
+
async request(method, path, options) {
|
|
169
|
+
if (this.isTemporaryToken && !this.apiKey?.startsWith("sk_")) {
|
|
170
|
+
throw new ScribeberryError(
|
|
171
|
+
"Temporary tokens (sb_rt_*) can only be used for realtime WebSocket connections. Use a full API key (sk_test_*/sk_live_*) for REST API calls.",
|
|
172
|
+
"INVALID_TOKEN_SCOPE"
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
const url = new URL(`${this.baseUrl}/api/v1${path}`);
|
|
176
|
+
if (options?.params) {
|
|
177
|
+
Object.entries(options.params).forEach(([key, value]) => {
|
|
178
|
+
if (value !== void 0) {
|
|
179
|
+
url.searchParams.set(key, String(value));
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
const controller = new AbortController();
|
|
184
|
+
const timeoutId = setTimeout(
|
|
185
|
+
() => controller.abort(),
|
|
186
|
+
options?.timeout || this.timeout
|
|
187
|
+
);
|
|
188
|
+
try {
|
|
189
|
+
const fetchFn = await this.getFetch();
|
|
190
|
+
const response = await fetchFn(url.toString(), {
|
|
191
|
+
method,
|
|
192
|
+
headers: {
|
|
193
|
+
"Authorization": `Bearer ${this.apiKey}`,
|
|
194
|
+
"Content-Type": "application/json",
|
|
195
|
+
"User-Agent": "scribeberry-sdk/0.2.0"
|
|
196
|
+
},
|
|
197
|
+
body: options?.body ? JSON.stringify(options.body) : void 0,
|
|
198
|
+
signal: controller.signal
|
|
199
|
+
});
|
|
200
|
+
if (!response.ok) {
|
|
201
|
+
await this.handleError(response);
|
|
202
|
+
}
|
|
203
|
+
if (response.status === 204 || response.headers.get("content-length") === "0") {
|
|
204
|
+
return {};
|
|
205
|
+
}
|
|
206
|
+
return await response.json();
|
|
207
|
+
} catch (error) {
|
|
208
|
+
if (error instanceof ScribeberryError) throw error;
|
|
209
|
+
if (error instanceof DOMException && error.name === "AbortError") {
|
|
210
|
+
throw new ScribeberryError("Request timed out", "TIMEOUT", 408);
|
|
211
|
+
}
|
|
212
|
+
throw new ScribeberryError(
|
|
213
|
+
`Network error: ${error instanceof Error ? error.message : "Unknown"}`,
|
|
214
|
+
"NETWORK_ERROR"
|
|
215
|
+
);
|
|
216
|
+
} finally {
|
|
217
|
+
clearTimeout(timeoutId);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
async getFetch() {
|
|
221
|
+
if (typeof globalThis.fetch !== "undefined") {
|
|
222
|
+
return globalThis.fetch;
|
|
223
|
+
}
|
|
224
|
+
try {
|
|
225
|
+
const { default: crossFetch } = await import("cross-fetch");
|
|
226
|
+
return crossFetch;
|
|
227
|
+
} catch {
|
|
228
|
+
throw new ScribeberryError(
|
|
229
|
+
'fetch is not available. Upgrade to Node.js >= 18 or install "cross-fetch".',
|
|
230
|
+
"FETCH_UNAVAILABLE"
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
async handleError(response) {
|
|
235
|
+
let body;
|
|
236
|
+
try {
|
|
237
|
+
body = await response.json();
|
|
238
|
+
} catch {
|
|
239
|
+
body = { message: response.statusText };
|
|
240
|
+
}
|
|
241
|
+
const message = body.message || body.error || response.statusText;
|
|
242
|
+
switch (response.status) {
|
|
243
|
+
case 401:
|
|
244
|
+
throw new AuthenticationError(message);
|
|
245
|
+
case 429:
|
|
246
|
+
throw new RateLimitError(message);
|
|
247
|
+
default:
|
|
248
|
+
throw new ScribeberryError(
|
|
249
|
+
message,
|
|
250
|
+
body.code || `HTTP_${response.status}`,
|
|
251
|
+
response.status
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
// src/resources/templates.ts
|
|
258
|
+
var Templates = class {
|
|
259
|
+
constructor(http) {
|
|
260
|
+
this.http = http;
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* List all templates (paginated).
|
|
264
|
+
*/
|
|
265
|
+
async list(options) {
|
|
266
|
+
return this.http.request("GET", "/templates", {
|
|
267
|
+
params: {
|
|
268
|
+
page: options?.page,
|
|
269
|
+
pageSize: options?.pageSize,
|
|
270
|
+
sortBy: options?.sortBy,
|
|
271
|
+
sortOrder: options?.sortOrder
|
|
272
|
+
}
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* Get a template by ID.
|
|
277
|
+
*/
|
|
278
|
+
async get(templateId) {
|
|
279
|
+
return this.http.request("GET", `/templates/${templateId}`);
|
|
280
|
+
}
|
|
281
|
+
/**
|
|
282
|
+
* Create a new template.
|
|
283
|
+
*/
|
|
284
|
+
async create(options) {
|
|
285
|
+
return this.http.request("POST", "/templates", { body: options });
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* Delete a template.
|
|
289
|
+
*/
|
|
290
|
+
async delete(templateId) {
|
|
291
|
+
return this.http.request("DELETE", `/templates/${templateId}`);
|
|
292
|
+
}
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
// src/resources/notes.ts
|
|
296
|
+
var Notes = class {
|
|
297
|
+
constructor(http) {
|
|
298
|
+
this.http = http;
|
|
299
|
+
}
|
|
300
|
+
/**
|
|
301
|
+
* Generate a note from conversation text or audio.
|
|
302
|
+
*
|
|
303
|
+
* @param options - Note generation options.
|
|
304
|
+
* @returns The generated note with optional transcript data.
|
|
305
|
+
*/
|
|
306
|
+
async generate(options) {
|
|
307
|
+
const body = {
|
|
308
|
+
template_id: options.templateId
|
|
309
|
+
};
|
|
310
|
+
if (options.conversationText) {
|
|
311
|
+
body.conversation_text = options.conversationText;
|
|
312
|
+
}
|
|
313
|
+
if (options.audioUrl) {
|
|
314
|
+
body.audioUrl = options.audioUrl;
|
|
315
|
+
}
|
|
316
|
+
if (options.sourceLanguage) {
|
|
317
|
+
body.sourceLanguage = options.sourceLanguage;
|
|
318
|
+
}
|
|
319
|
+
if (options.transcriptionQuality) {
|
|
320
|
+
body.transcriptionQuality = options.transcriptionQuality;
|
|
321
|
+
}
|
|
322
|
+
if (options.context) {
|
|
323
|
+
body.context = options.context;
|
|
324
|
+
}
|
|
325
|
+
const response = await this.http.request(
|
|
326
|
+
"POST",
|
|
327
|
+
"/notes",
|
|
328
|
+
{ body, timeout: 12e4 }
|
|
329
|
+
// Notes may take longer (transcription + LLM)
|
|
330
|
+
);
|
|
331
|
+
return {
|
|
332
|
+
note: {
|
|
333
|
+
markdown: response.note?.markdown || response.markdown || "",
|
|
334
|
+
text: response.note?.text || response.text || "",
|
|
335
|
+
structured: response.note?.structured || response.structured || {}
|
|
336
|
+
},
|
|
337
|
+
transcript: response.transcript ? {
|
|
338
|
+
text: response.transcript.text,
|
|
339
|
+
confidence: response.transcript.confidence,
|
|
340
|
+
duration: response.transcript.duration,
|
|
341
|
+
sourceLanguage: response.transcript.sourceLanguage
|
|
342
|
+
} : void 0,
|
|
343
|
+
template: {
|
|
344
|
+
id: response.template?.id || options.templateId,
|
|
345
|
+
name: response.template?.name || ""
|
|
346
|
+
}
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
// src/lib/ws-client.ts
|
|
352
|
+
async function createWebSocket(url, protocols) {
|
|
353
|
+
if (typeof WebSocket !== "undefined") {
|
|
354
|
+
return new WebSocket(url, protocols);
|
|
355
|
+
}
|
|
356
|
+
try {
|
|
357
|
+
const { default: WS } = await import("ws");
|
|
358
|
+
return new WS(url, protocols);
|
|
359
|
+
} catch {
|
|
360
|
+
throw new Error(
|
|
361
|
+
'WebSocket is not available. Install the "ws" package: npm install ws'
|
|
362
|
+
);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// src/realtime/session.ts
|
|
367
|
+
var RealtimeTranscriptionSession = class {
|
|
368
|
+
constructor(http, config) {
|
|
369
|
+
this.http = http;
|
|
370
|
+
this.config = config;
|
|
371
|
+
this.ws = null;
|
|
372
|
+
this._sessionId = null;
|
|
373
|
+
this._state = "idle";
|
|
374
|
+
this.segments = [];
|
|
375
|
+
this.durationSeconds = 0;
|
|
376
|
+
this.stopResolver = null;
|
|
377
|
+
// Event handlers
|
|
378
|
+
this.handlers = /* @__PURE__ */ new Map();
|
|
379
|
+
}
|
|
380
|
+
/** Current session state. */
|
|
381
|
+
get state() {
|
|
382
|
+
return this._state;
|
|
383
|
+
}
|
|
384
|
+
/** Session ID (available after `'started'` event). */
|
|
385
|
+
get sessionId() {
|
|
386
|
+
return this._sessionId;
|
|
387
|
+
}
|
|
388
|
+
/**
|
|
389
|
+
* Get the full accumulated transcript text at any point during the session.
|
|
390
|
+
*
|
|
391
|
+
* @returns Concatenated text from all confirmed segments.
|
|
392
|
+
*/
|
|
393
|
+
getTranscript() {
|
|
394
|
+
return this.segments.map((s) => s.text).join(" ");
|
|
395
|
+
}
|
|
396
|
+
/**
|
|
397
|
+
* Get all confirmed transcript segments.
|
|
398
|
+
*
|
|
399
|
+
* @returns A copy of the segments array.
|
|
400
|
+
*/
|
|
401
|
+
getSegments() {
|
|
402
|
+
return [...this.segments];
|
|
403
|
+
}
|
|
404
|
+
/**
|
|
405
|
+
* Connect to the realtime transcription server.
|
|
406
|
+
*
|
|
407
|
+
* Called automatically by `sb.realtime.transcribe()`. You don't usually
|
|
408
|
+
* need to call this directly.
|
|
409
|
+
*
|
|
410
|
+
* @throws {ScribeberryError} If the session has already been started
|
|
411
|
+
*/
|
|
412
|
+
async connect() {
|
|
413
|
+
if (this._state !== "idle") {
|
|
414
|
+
throw new ScribeberryError(
|
|
415
|
+
"Session already started",
|
|
416
|
+
"SESSION_STATE_ERROR"
|
|
417
|
+
);
|
|
418
|
+
}
|
|
419
|
+
this._state = "connecting";
|
|
420
|
+
return new Promise(async (resolve, reject) => {
|
|
421
|
+
try {
|
|
422
|
+
const token = await this.http.getWsToken();
|
|
423
|
+
const wsUrl = `${this.http.wsUrl}/ws/realtime?token=${encodeURIComponent(token)}`;
|
|
424
|
+
this.ws = await createWebSocket(wsUrl);
|
|
425
|
+
} catch (err) {
|
|
426
|
+
this._state = "idle";
|
|
427
|
+
const error = new ScribeberryError(
|
|
428
|
+
`Failed to create WebSocket: ${err instanceof Error ? err.message : String(err)}`,
|
|
429
|
+
"CONNECTION_ERROR"
|
|
430
|
+
);
|
|
431
|
+
reject(error);
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
const connectTimeout = setTimeout(() => {
|
|
435
|
+
if (this._state === "connecting") {
|
|
436
|
+
const error = new ScribeberryError(
|
|
437
|
+
"Connect timeout \u2014 no session:started received",
|
|
438
|
+
"CONNECT_TIMEOUT"
|
|
439
|
+
);
|
|
440
|
+
reject(error);
|
|
441
|
+
this.emit("error", error);
|
|
442
|
+
this.cleanup();
|
|
443
|
+
}
|
|
444
|
+
}, 1e4);
|
|
445
|
+
this.ws.onopen = () => {
|
|
446
|
+
this.send({
|
|
447
|
+
type: "start",
|
|
448
|
+
config: {
|
|
449
|
+
language: this.config.language,
|
|
450
|
+
enableDiarization: this.config.enableDiarization,
|
|
451
|
+
templateId: this.config.templateId
|
|
452
|
+
}
|
|
453
|
+
});
|
|
454
|
+
};
|
|
455
|
+
this.ws.onmessage = (event) => {
|
|
456
|
+
const raw = typeof event.data === "string" ? event.data : String(event.data);
|
|
457
|
+
let data;
|
|
458
|
+
try {
|
|
459
|
+
data = JSON.parse(raw);
|
|
460
|
+
} catch {
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
if (data.type === "session:started") {
|
|
464
|
+
clearTimeout(connectTimeout);
|
|
465
|
+
}
|
|
466
|
+
this.handleMessage(data, resolve);
|
|
467
|
+
};
|
|
468
|
+
this.ws.onerror = (event) => {
|
|
469
|
+
clearTimeout(connectTimeout);
|
|
470
|
+
const error = new ScribeberryError(
|
|
471
|
+
"WebSocket connection error",
|
|
472
|
+
"CONNECTION_ERROR"
|
|
473
|
+
);
|
|
474
|
+
if (this._state === "connecting") {
|
|
475
|
+
reject(error);
|
|
476
|
+
}
|
|
477
|
+
this.emit("error", error);
|
|
478
|
+
};
|
|
479
|
+
this.ws.onclose = (event) => {
|
|
480
|
+
clearTimeout(connectTimeout);
|
|
481
|
+
if (this._state === "connecting") {
|
|
482
|
+
reject(
|
|
483
|
+
new ScribeberryError(
|
|
484
|
+
`Connection closed: ${event?.code || "unknown"}`,
|
|
485
|
+
"CONNECTION_CLOSED"
|
|
486
|
+
)
|
|
487
|
+
);
|
|
488
|
+
}
|
|
489
|
+
if (this._state !== "stopped") {
|
|
490
|
+
this._state = "stopped";
|
|
491
|
+
}
|
|
492
|
+
};
|
|
493
|
+
});
|
|
494
|
+
}
|
|
495
|
+
/**
|
|
496
|
+
* Send an audio chunk to the server for transcription.
|
|
497
|
+
*
|
|
498
|
+
* **Required format:** PCM 16-bit signed little-endian, 16kHz, mono.
|
|
499
|
+
*
|
|
500
|
+
* @param data - Raw audio data
|
|
501
|
+
* @throws {ScribeberryError} If the session is not active
|
|
502
|
+
*/
|
|
503
|
+
sendAudio(data) {
|
|
504
|
+
if (this._state !== "active") {
|
|
505
|
+
throw new ScribeberryError(
|
|
506
|
+
'Session is not active. Wait for the "started" event before sending audio.',
|
|
507
|
+
"SESSION_STATE_ERROR"
|
|
508
|
+
);
|
|
509
|
+
}
|
|
510
|
+
this.ws?.send(data);
|
|
511
|
+
}
|
|
512
|
+
/**
|
|
513
|
+
* Stream audio from an async iterable source (e.g., microphone stream).
|
|
514
|
+
*
|
|
515
|
+
* @param stream - Async iterable of audio chunks.
|
|
516
|
+
*/
|
|
517
|
+
async sendStream(stream) {
|
|
518
|
+
for await (const chunk of stream) {
|
|
519
|
+
if (this._state !== "active") break;
|
|
520
|
+
this.sendAudio(chunk);
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
/**
|
|
524
|
+
* Pause audio streaming. The WebSocket connection stays alive.
|
|
525
|
+
*/
|
|
526
|
+
pause() {
|
|
527
|
+
this.send({ type: "pause" });
|
|
528
|
+
}
|
|
529
|
+
/**
|
|
530
|
+
* Resume audio streaming after a pause.
|
|
531
|
+
*/
|
|
532
|
+
resume() {
|
|
533
|
+
this.send({ type: "resume" });
|
|
534
|
+
}
|
|
535
|
+
/**
|
|
536
|
+
* Request finalization of the current audio buffer.
|
|
537
|
+
* Forces the server to emit any pending partial results as final.
|
|
538
|
+
*/
|
|
539
|
+
finalize() {
|
|
540
|
+
this.send({ type: "finalize" });
|
|
541
|
+
}
|
|
542
|
+
/**
|
|
543
|
+
* Stop the session gracefully.
|
|
544
|
+
*
|
|
545
|
+
* Waits for the server to confirm the stop and (if configured) for
|
|
546
|
+
* note generation to complete.
|
|
547
|
+
*
|
|
548
|
+
* @returns Final transcript, segments, duration, and optional note.
|
|
549
|
+
*/
|
|
550
|
+
async stop() {
|
|
551
|
+
if (this._state === "stopped") {
|
|
552
|
+
return this.buildResult();
|
|
553
|
+
}
|
|
554
|
+
this._state = "stopping";
|
|
555
|
+
return new Promise((resolve) => {
|
|
556
|
+
this.stopResolver = resolve;
|
|
557
|
+
this.send({ type: "stop" });
|
|
558
|
+
setTimeout(() => {
|
|
559
|
+
if (this.stopResolver) {
|
|
560
|
+
this.stopResolver(this.buildResult());
|
|
561
|
+
this.stopResolver = null;
|
|
562
|
+
this.cleanup();
|
|
563
|
+
}
|
|
564
|
+
}, 3e4);
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
/**
|
|
568
|
+
* Register an event handler.
|
|
569
|
+
*
|
|
570
|
+
* @param event - Event name
|
|
571
|
+
* @param handler - Event handler function
|
|
572
|
+
* @returns `this` for chaining
|
|
573
|
+
*
|
|
574
|
+
* @example
|
|
575
|
+
* ```typescript
|
|
576
|
+
* session
|
|
577
|
+
* .on('partial', (text) => console.log('Interim:', text))
|
|
578
|
+
* .on('final', (segment) => console.log('Confirmed:', segment.text))
|
|
579
|
+
* .on('error', (err) => console.error(err));
|
|
580
|
+
* ```
|
|
581
|
+
*/
|
|
582
|
+
on(event, handler) {
|
|
583
|
+
if (!this.handlers.has(event)) {
|
|
584
|
+
this.handlers.set(event, /* @__PURE__ */ new Set());
|
|
585
|
+
}
|
|
586
|
+
this.handlers.get(event).add(handler);
|
|
587
|
+
return this;
|
|
588
|
+
}
|
|
589
|
+
/**
|
|
590
|
+
* Remove an event handler.
|
|
591
|
+
*
|
|
592
|
+
* @param event - Event name
|
|
593
|
+
* @param handler - Handler to remove
|
|
594
|
+
* @returns `this` for chaining
|
|
595
|
+
*/
|
|
596
|
+
off(event, handler) {
|
|
597
|
+
this.handlers.get(event)?.delete(handler);
|
|
598
|
+
return this;
|
|
599
|
+
}
|
|
600
|
+
// --- Internal ---
|
|
601
|
+
emit(event, ...args) {
|
|
602
|
+
this.handlers.get(event)?.forEach((handler) => {
|
|
603
|
+
try {
|
|
604
|
+
handler(...args);
|
|
605
|
+
} catch {
|
|
606
|
+
}
|
|
607
|
+
});
|
|
608
|
+
}
|
|
609
|
+
send(message) {
|
|
610
|
+
if (this.ws?.readyState === 1) {
|
|
611
|
+
this.ws.send(JSON.stringify(message));
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
handleMessage(data, connectResolve) {
|
|
615
|
+
switch (data.type) {
|
|
616
|
+
case "session:started":
|
|
617
|
+
this._sessionId = data.sessionId;
|
|
618
|
+
this._state = "active";
|
|
619
|
+
this.emit("started", data.sessionId);
|
|
620
|
+
connectResolve?.();
|
|
621
|
+
break;
|
|
622
|
+
case "transcript:partial":
|
|
623
|
+
this.emit("partial", data.text, data.speaker);
|
|
624
|
+
break;
|
|
625
|
+
case "transcript:final": {
|
|
626
|
+
const segment = {
|
|
627
|
+
text: data.text,
|
|
628
|
+
speaker: data.speaker,
|
|
629
|
+
startMs: data.startMs,
|
|
630
|
+
endMs: data.endMs
|
|
631
|
+
};
|
|
632
|
+
this.segments.push(segment);
|
|
633
|
+
this.emit("final", segment);
|
|
634
|
+
break;
|
|
635
|
+
}
|
|
636
|
+
case "endpoint":
|
|
637
|
+
this.emit("endpoint");
|
|
638
|
+
break;
|
|
639
|
+
case "transcript:full":
|
|
640
|
+
this.durationSeconds = (data.durationMs || 0) / 1e3;
|
|
641
|
+
break;
|
|
642
|
+
case "note:generating":
|
|
643
|
+
break;
|
|
644
|
+
case "note:complete":
|
|
645
|
+
this.note = data.note;
|
|
646
|
+
this.emit("note", data.note);
|
|
647
|
+
this.resolveStop();
|
|
648
|
+
break;
|
|
649
|
+
case "session:stopped":
|
|
650
|
+
this.durationSeconds = data.durationSeconds || this.durationSeconds;
|
|
651
|
+
if (!this.config.templateId) {
|
|
652
|
+
this.resolveStop();
|
|
653
|
+
}
|
|
654
|
+
break;
|
|
655
|
+
case "error":
|
|
656
|
+
this.emit(
|
|
657
|
+
"error",
|
|
658
|
+
new ScribeberryError(
|
|
659
|
+
data.message || "Unknown error",
|
|
660
|
+
data.code || "REALTIME_ERROR"
|
|
661
|
+
)
|
|
662
|
+
);
|
|
663
|
+
break;
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
resolveStop() {
|
|
667
|
+
this._state = "stopped";
|
|
668
|
+
const result = this.buildResult();
|
|
669
|
+
this.emit("stopped", result);
|
|
670
|
+
if (this.stopResolver) {
|
|
671
|
+
this.stopResolver(result);
|
|
672
|
+
this.stopResolver = null;
|
|
673
|
+
}
|
|
674
|
+
this.cleanup();
|
|
675
|
+
}
|
|
676
|
+
buildResult() {
|
|
677
|
+
return {
|
|
678
|
+
transcript: this.getTranscript(),
|
|
679
|
+
segments: this.getSegments(),
|
|
680
|
+
durationSeconds: this.durationSeconds,
|
|
681
|
+
note: this.note
|
|
682
|
+
};
|
|
683
|
+
}
|
|
684
|
+
cleanup() {
|
|
685
|
+
try {
|
|
686
|
+
this.ws?.close();
|
|
687
|
+
} catch {
|
|
688
|
+
}
|
|
689
|
+
this.ws = null;
|
|
690
|
+
}
|
|
691
|
+
};
|
|
692
|
+
|
|
693
|
+
// src/realtime/index.ts
|
|
694
|
+
var Realtime = class {
|
|
695
|
+
constructor(http) {
|
|
696
|
+
this.http = http;
|
|
697
|
+
}
|
|
698
|
+
/**
|
|
699
|
+
* Create a temporary realtime token for browser-side WebSocket access.
|
|
700
|
+
*
|
|
701
|
+
* **Server-side only.** Call this from your backend, then pass the token
|
|
702
|
+
* to your frontend for use with `new Scribeberry({ apiKey: token })`.
|
|
703
|
+
*
|
|
704
|
+
* @param options - Token creation options.
|
|
705
|
+
* @returns A temporary token and the WebSocket URL.
|
|
706
|
+
*
|
|
707
|
+
* @example
|
|
708
|
+
* ```typescript
|
|
709
|
+
* // On your server
|
|
710
|
+
* const sb = new Scribeberry({ apiKey: 'sk_live_...' });
|
|
711
|
+
* const { token, wsUrl, expiresAt } = await sb.realtime.createToken({
|
|
712
|
+
* expiresInSeconds: 3600,
|
|
713
|
+
* });
|
|
714
|
+
*
|
|
715
|
+
* // Return token to the browser client
|
|
716
|
+
* res.json({ token, wsUrl, expiresAt });
|
|
717
|
+
* ```
|
|
718
|
+
*
|
|
719
|
+
* @throws {ScribeberryError} If called in a browser environment
|
|
720
|
+
* @throws {ScribeberryError} If called with a temporary token (sb_rt_*)
|
|
721
|
+
*/
|
|
722
|
+
async createToken(options = {}) {
|
|
723
|
+
if (isBrowser()) {
|
|
724
|
+
throw new ScribeberryError(
|
|
725
|
+
"createToken() is only available in Node.js server environments. Call this from your backend and pass the resulting token to the browser.",
|
|
726
|
+
"SERVER_ONLY"
|
|
727
|
+
);
|
|
728
|
+
}
|
|
729
|
+
if (this.http.isTemporaryToken) {
|
|
730
|
+
throw new ScribeberryError(
|
|
731
|
+
"Cannot create a token using a temporary token. Use a full API key (sk_test_*/sk_live_*).",
|
|
732
|
+
"INVALID_TOKEN_SCOPE"
|
|
733
|
+
);
|
|
734
|
+
}
|
|
735
|
+
return this.http.request("POST", "/realtime/tokens", {
|
|
736
|
+
body: {
|
|
737
|
+
expiresInSeconds: options.expiresInSeconds
|
|
738
|
+
}
|
|
739
|
+
});
|
|
740
|
+
}
|
|
741
|
+
/**
|
|
742
|
+
* Start a new realtime transcription session.
|
|
743
|
+
*
|
|
744
|
+
* The session connects via WebSocket and streams transcription results
|
|
745
|
+
* back as audio is sent. Optionally generates a note when stopped.
|
|
746
|
+
*
|
|
747
|
+
* **Audio format:** PCM 16-bit signed little-endian, 16kHz, mono.
|
|
748
|
+
*
|
|
749
|
+
* @param config - Session configuration.
|
|
750
|
+
* @returns A realtime transcription session.
|
|
751
|
+
*
|
|
752
|
+
* @example
|
|
753
|
+
* ```typescript
|
|
754
|
+
* const session = sb.realtime.transcribe({
|
|
755
|
+
* language: 'en-US',
|
|
756
|
+
* enableDiarization: true,
|
|
757
|
+
* templateId: 'template-id',
|
|
758
|
+
* });
|
|
759
|
+
*
|
|
760
|
+
* session.on('partial', (text) => console.log('Partial:', text));
|
|
761
|
+
* session.on('final', (segment) => console.log('Final:', segment.text));
|
|
762
|
+
* session.on('stopped', (result) => console.log('Transcript:', result.transcript));
|
|
763
|
+
*
|
|
764
|
+
* // Stream audio chunks from your microphone
|
|
765
|
+
* session.sendAudio(audioChunk);
|
|
766
|
+
*
|
|
767
|
+
* // When done
|
|
768
|
+
* const result = await session.stop();
|
|
769
|
+
* ```
|
|
770
|
+
*/
|
|
771
|
+
transcribe(config = {}) {
|
|
772
|
+
const session = new RealtimeTranscriptionSession(this.http, config);
|
|
773
|
+
session.connect().catch(() => {
|
|
774
|
+
});
|
|
775
|
+
return session;
|
|
776
|
+
}
|
|
777
|
+
};
|
|
778
|
+
|
|
779
|
+
// src/index.ts
|
|
780
|
+
var Scribeberry = class {
|
|
781
|
+
constructor(config) {
|
|
782
|
+
this.http = new HttpClient(config);
|
|
783
|
+
this.templates = new Templates(this.http);
|
|
784
|
+
this.notes = new Notes(this.http);
|
|
785
|
+
this.realtime = new Realtime(this.http);
|
|
786
|
+
}
|
|
787
|
+
};
|
|
788
|
+
|
|
789
|
+
// src/react/use-transcription.ts
|
|
790
|
+
var PCM_WORKLET_SOURCE = `
|
|
791
|
+
class ScribeberryPcmProcessor extends AudioWorkletProcessor {
|
|
792
|
+
process(inputs) {
|
|
793
|
+
const input = inputs[0]?.[0];
|
|
794
|
+
if (!input) return true;
|
|
795
|
+
const int16 = new Int16Array(input.length);
|
|
796
|
+
for (let i = 0; i < input.length; i++) {
|
|
797
|
+
int16[i] = Math.max(-32768, Math.min(32767, Math.round(input[i] * 32767)));
|
|
798
|
+
}
|
|
799
|
+
this.port.postMessage(int16.buffer, [int16.buffer]);
|
|
800
|
+
return true;
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
registerProcessor("scribeberry-pcm", ScribeberryPcmProcessor);
|
|
804
|
+
`;
|
|
805
|
+
var INITIAL_STATE = {
|
|
806
|
+
status: "idle",
|
|
807
|
+
segments: [],
|
|
808
|
+
partial: "",
|
|
809
|
+
transcript: "",
|
|
810
|
+
durationSeconds: null,
|
|
811
|
+
error: null
|
|
812
|
+
};
|
|
813
|
+
function useTranscription(options) {
|
|
814
|
+
const [state, setState] = useState(INITIAL_STATE);
|
|
815
|
+
const sessionRef = useRef(null);
|
|
816
|
+
const mediaStreamRef = useRef(null);
|
|
817
|
+
const audioContextRef = useRef(null);
|
|
818
|
+
const workletNodeRef = useRef(null);
|
|
819
|
+
const sourceRef = useRef(null);
|
|
820
|
+
const cleanup = useCallback(() => {
|
|
821
|
+
workletNodeRef.current?.disconnect();
|
|
822
|
+
workletNodeRef.current = null;
|
|
823
|
+
sourceRef.current?.disconnect();
|
|
824
|
+
sourceRef.current = null;
|
|
825
|
+
audioContextRef.current?.close();
|
|
826
|
+
audioContextRef.current = null;
|
|
827
|
+
mediaStreamRef.current?.getTracks().forEach((t) => t.stop());
|
|
828
|
+
mediaStreamRef.current = null;
|
|
829
|
+
sessionRef.current = null;
|
|
830
|
+
}, []);
|
|
831
|
+
const start = useCallback(async () => {
|
|
832
|
+
try {
|
|
833
|
+
setState((s) => ({ ...s, status: "connecting", error: null }));
|
|
834
|
+
const sb = new Scribeberry({
|
|
835
|
+
getRealtimeToken: options.getRealtimeToken
|
|
836
|
+
});
|
|
837
|
+
const stream = await navigator.mediaDevices.getUserMedia({
|
|
838
|
+
audio: {
|
|
839
|
+
channelCount: 1,
|
|
840
|
+
sampleRate: 16e3,
|
|
841
|
+
echoCancellation: true,
|
|
842
|
+
noiseSuppression: true
|
|
843
|
+
}
|
|
844
|
+
});
|
|
845
|
+
mediaStreamRef.current = stream;
|
|
846
|
+
const audioCtx = new AudioContext({ sampleRate: 16e3 });
|
|
847
|
+
audioContextRef.current = audioCtx;
|
|
848
|
+
const blob = new Blob([PCM_WORKLET_SOURCE], {
|
|
849
|
+
type: "application/javascript"
|
|
850
|
+
});
|
|
851
|
+
const workletUrl = URL.createObjectURL(blob);
|
|
852
|
+
await audioCtx.audioWorklet.addModule(workletUrl);
|
|
853
|
+
URL.revokeObjectURL(workletUrl);
|
|
854
|
+
const source = audioCtx.createMediaStreamSource(stream);
|
|
855
|
+
sourceRef.current = source;
|
|
856
|
+
const workletNode = new AudioWorkletNode(audioCtx, "scribeberry-pcm");
|
|
857
|
+
workletNodeRef.current = workletNode;
|
|
858
|
+
workletNode.port.onmessage = (e) => {
|
|
859
|
+
try {
|
|
860
|
+
sessionRef.current?.sendAudio(e.data);
|
|
861
|
+
} catch {
|
|
862
|
+
}
|
|
863
|
+
};
|
|
864
|
+
const session = sb.realtime.transcribe({
|
|
865
|
+
language: options.language ?? "en-US",
|
|
866
|
+
enableDiarization: options.enableDiarization ?? true
|
|
867
|
+
});
|
|
868
|
+
sessionRef.current = session;
|
|
869
|
+
session.on("started", () => {
|
|
870
|
+
source.connect(workletNode);
|
|
871
|
+
workletNode.connect(audioCtx.destination);
|
|
872
|
+
setState((s) => ({ ...s, status: "recording" }));
|
|
873
|
+
});
|
|
874
|
+
session.on("partial", (text) => {
|
|
875
|
+
setState((s) => ({ ...s, partial: text }));
|
|
876
|
+
});
|
|
877
|
+
session.on("final", (segment) => {
|
|
878
|
+
setState((s) => ({
|
|
879
|
+
...s,
|
|
880
|
+
segments: [...s.segments, segment],
|
|
881
|
+
transcript: s.transcript + (s.transcript ? " " : "") + segment.text,
|
|
882
|
+
partial: ""
|
|
883
|
+
}));
|
|
884
|
+
});
|
|
885
|
+
session.on("error", (err) => {
|
|
886
|
+
setState((s) => ({ ...s, status: "error", error: err.message }));
|
|
887
|
+
cleanup();
|
|
888
|
+
});
|
|
889
|
+
session.on("stopped", (result) => {
|
|
890
|
+
setState((s) => ({
|
|
891
|
+
...s,
|
|
892
|
+
status: "idle",
|
|
893
|
+
partial: "",
|
|
894
|
+
durationSeconds: result.durationSeconds
|
|
895
|
+
}));
|
|
896
|
+
});
|
|
897
|
+
} catch (err) {
|
|
898
|
+
const message = err instanceof Error ? err.message : "Failed to start transcription";
|
|
899
|
+
setState((s) => ({ ...s, status: "error", error: message }));
|
|
900
|
+
cleanup();
|
|
901
|
+
}
|
|
902
|
+
}, [options.getRealtimeToken, options.language, options.enableDiarization, cleanup]);
|
|
903
|
+
const stop = useCallback(async () => {
|
|
904
|
+
if (!sessionRef.current) return;
|
|
905
|
+
try {
|
|
906
|
+
await sessionRef.current.stop();
|
|
907
|
+
} finally {
|
|
908
|
+
cleanup();
|
|
909
|
+
}
|
|
910
|
+
}, [cleanup]);
|
|
911
|
+
const clear = useCallback(() => {
|
|
912
|
+
cleanup();
|
|
913
|
+
setState(INITIAL_STATE);
|
|
914
|
+
}, [cleanup]);
|
|
915
|
+
return { ...state, start, stop, clear };
|
|
916
|
+
}
|
|
917
|
+
export {
|
|
918
|
+
useTranscription
|
|
919
|
+
};
|