@scribeberry/sdk 0.2.0 → 0.3.0

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