speechrevolutions 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/LICENSE +21 -0
- package/README.md +183 -0
- package/dist/cjs/client.js +786 -0
- package/dist/cjs/exceptions.js +77 -0
- package/dist/cjs/index.js +28 -0
- package/dist/cjs/package.json +1 -0
- package/dist/cjs/progress.js +130 -0
- package/dist/cjs/sse.js +56 -0
- package/dist/cjs/transcript.js +303 -0
- package/dist/cjs/types.js +31 -0
- package/dist/cjs/upload.js +44 -0
- package/dist/esm/client.d.ts +94 -0
- package/dist/esm/client.js +748 -0
- package/dist/esm/exceptions.d.ts +46 -0
- package/dist/esm/exceptions.js +66 -0
- package/dist/esm/index.d.ts +7 -0
- package/dist/esm/index.js +5 -0
- package/dist/esm/package.json +1 -0
- package/dist/esm/progress.d.ts +58 -0
- package/dist/esm/progress.js +125 -0
- package/dist/esm/sse.d.ts +7 -0
- package/dist/esm/sse.js +53 -0
- package/dist/esm/transcript.d.ts +51 -0
- package/dist/esm/transcript.js +267 -0
- package/dist/esm/types.d.ts +73 -0
- package/dist/esm/types.js +26 -0
- package/dist/esm/upload.d.ts +26 -0
- package/dist/esm/upload.js +39 -0
- package/package.json +57 -0
- package/src/client.ts +950 -0
- package/src/exceptions.ts +92 -0
- package/src/index.ts +20 -0
- package/src/progress.ts +147 -0
- package/src/sse.ts +61 -0
- package/src/transcript.ts +334 -0
- package/src/types.ts +114 -0
- package/src/upload.ts +50 -0
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/** SDK exception hierarchy.
|
|
2
|
+
*
|
|
3
|
+
* Every error carries the HTTP `statusCode` and the server `requestId` (from the
|
|
4
|
+
* response headers, when present) so failures can be correlated with server
|
|
5
|
+
* logs. `RateLimitError` also exposes `retryAfter` seconds.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export interface STTErrorOptions {
|
|
9
|
+
statusCode?: number;
|
|
10
|
+
requestId?: string;
|
|
11
|
+
body?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export class STTError extends Error {
|
|
15
|
+
statusCode?: number;
|
|
16
|
+
requestId?: string;
|
|
17
|
+
body?: string;
|
|
18
|
+
|
|
19
|
+
constructor(message: string, opts?: STTErrorOptions) {
|
|
20
|
+
super(opts?.requestId ? `${message} (request_id=${opts.requestId})` : message);
|
|
21
|
+
this.name = "STTError";
|
|
22
|
+
this.statusCode = opts?.statusCode;
|
|
23
|
+
this.requestId = opts?.requestId;
|
|
24
|
+
this.body = opts?.body;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export class AuthenticationError extends STTError {
|
|
29
|
+
constructor(message = "Unauthorized — check your API key", opts?: STTErrorOptions) {
|
|
30
|
+
super(message, opts);
|
|
31
|
+
this.name = "AuthenticationError";
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export class RateLimitError extends STTError {
|
|
36
|
+
retryAfter?: number;
|
|
37
|
+
|
|
38
|
+
constructor(
|
|
39
|
+
message = "Rate limit exceeded — try again shortly",
|
|
40
|
+
opts?: STTErrorOptions & { retryAfter?: number },
|
|
41
|
+
) {
|
|
42
|
+
super(message, opts);
|
|
43
|
+
this.name = "RateLimitError";
|
|
44
|
+
this.retryAfter = opts?.retryAfter;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export class JobNotFoundError extends STTError {
|
|
49
|
+
constructor(
|
|
50
|
+
message = "Job not found or upload session expired",
|
|
51
|
+
opts?: STTErrorOptions,
|
|
52
|
+
) {
|
|
53
|
+
super(message, opts);
|
|
54
|
+
this.name = "JobNotFoundError";
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export class JobFailedError extends STTError {
|
|
59
|
+
step?: string;
|
|
60
|
+
reason?: string;
|
|
61
|
+
|
|
62
|
+
constructor(
|
|
63
|
+
message: string,
|
|
64
|
+
opts?: STTErrorOptions & { step?: string; reason?: string },
|
|
65
|
+
) {
|
|
66
|
+
super(message, opts);
|
|
67
|
+
this.name = "JobFailedError";
|
|
68
|
+
this.step = opts?.step;
|
|
69
|
+
this.reason = opts?.reason;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export class UploadError extends STTError {
|
|
74
|
+
constructor(message: string, opts?: STTErrorOptions) {
|
|
75
|
+
super(message, opts);
|
|
76
|
+
this.name = "UploadError";
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export class TimeoutError extends STTError {
|
|
81
|
+
constructor(message: string, opts?: STTErrorOptions) {
|
|
82
|
+
super(message, opts);
|
|
83
|
+
this.name = "TimeoutError";
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export class APIError extends STTError {
|
|
88
|
+
constructor(message: string, opts?: STTErrorOptions) {
|
|
89
|
+
super(message, opts);
|
|
90
|
+
this.name = "APIError";
|
|
91
|
+
}
|
|
92
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export {
|
|
2
|
+
SpeechRevolutions,
|
|
3
|
+
SpeechRevolutionsClient,
|
|
4
|
+
STTClient,
|
|
5
|
+
} from "./client.js";
|
|
6
|
+
export * from "./exceptions.js";
|
|
7
|
+
export type { LanguageSegment, Transcript, Utterance, Word } from "./transcript.js";
|
|
8
|
+
export { parseTranscript } from "./transcript.js";
|
|
9
|
+
export { ProgressPrinter } from "./progress.js";
|
|
10
|
+
export { computePercent } from "./types.js";
|
|
11
|
+
export type {
|
|
12
|
+
JobStatus,
|
|
13
|
+
OutputType,
|
|
14
|
+
ProcessingTier,
|
|
15
|
+
ProgressCallback,
|
|
16
|
+
ProgressEvent,
|
|
17
|
+
STTClientOptions,
|
|
18
|
+
TranscribeOptions,
|
|
19
|
+
UploadJob,
|
|
20
|
+
} from "./types.js";
|
package/src/progress.ts
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Optional console progress rendering for upload + transcription jobs.
|
|
3
|
+
*
|
|
4
|
+
* Neither AssemblyAI nor Deepgram surfaces live percentage progress for
|
|
5
|
+
* pre-recorded transcription — our pipeline is chunked and emits SSE progress
|
|
6
|
+
* events, so this is a Speech Revolutions extra. The same renderer also drives
|
|
7
|
+
* the byte-level *upload* bar.
|
|
8
|
+
*
|
|
9
|
+
* There is no tqdm in JS, so this is a tiny built-in renderer: a single line
|
|
10
|
+
* written to stderr, updated in place with a carriage return, showing a
|
|
11
|
+
* percentage and a `[####----]`-style bar. It always forwards each event to a
|
|
12
|
+
* user-supplied callback, so programmatic access via `onProgress` /
|
|
13
|
+
* `onUploadProgress` is unaffected whether or not the console display is on.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { ProgressCallback, ProgressEvent } from "./types.js";
|
|
17
|
+
|
|
18
|
+
const DEFAULT_LABEL = "Transcribing";
|
|
19
|
+
// Minimum ms between redraws (a byte upload fires many events).
|
|
20
|
+
const MIN_REDRAW_INTERVAL_MS = 80;
|
|
21
|
+
const BAR_WIDTH = 30;
|
|
22
|
+
|
|
23
|
+
function formatBytes(n: number): string {
|
|
24
|
+
const units = ["B", "KB", "MB", "GB", "TB"];
|
|
25
|
+
let size = n;
|
|
26
|
+
let i = 0;
|
|
27
|
+
while (size >= 1024 && i < units.length - 1) {
|
|
28
|
+
size /= 1024;
|
|
29
|
+
i += 1;
|
|
30
|
+
}
|
|
31
|
+
return `${size.toFixed(i === 0 ? 0 : 1)}${units[i]}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function stderr(): NodeJS.WriteStream | undefined {
|
|
35
|
+
if (typeof process === "undefined" || !process.stderr) return undefined;
|
|
36
|
+
return process.stderr;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* A progress callback that renders to the console and forwards events.
|
|
41
|
+
*
|
|
42
|
+
* `bytesMode` renders sizes (e.g. `2.5MB/6.0MB`) alongside the bar. For
|
|
43
|
+
* transcription the volatile step name (preprocess / chunk:N / aggregation) is
|
|
44
|
+
* deliberately kept off the bar — chunks finish out of order and made the label
|
|
45
|
+
* jump around; callers who want it read `event.step` in their callback.
|
|
46
|
+
*/
|
|
47
|
+
export class ProgressPrinter {
|
|
48
|
+
private readonly forward?: ProgressCallback;
|
|
49
|
+
private readonly label: string;
|
|
50
|
+
private readonly bytesMode: boolean;
|
|
51
|
+
private lastDraw = 0;
|
|
52
|
+
private lastPct = 0;
|
|
53
|
+
private drewAny = false;
|
|
54
|
+
private finished = false;
|
|
55
|
+
private closed = false;
|
|
56
|
+
|
|
57
|
+
constructor(opts: {
|
|
58
|
+
forward?: ProgressCallback;
|
|
59
|
+
label?: string;
|
|
60
|
+
bytesMode?: boolean;
|
|
61
|
+
} = {}) {
|
|
62
|
+
this.forward = opts.forward;
|
|
63
|
+
this.label = opts.label ?? DEFAULT_LABEL;
|
|
64
|
+
this.bytesMode = opts.bytesMode ?? false;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Use as the `onProgress` callback — renders, then forwards. */
|
|
68
|
+
readonly handle: ProgressCallback = (event: ProgressEvent): void => {
|
|
69
|
+
try {
|
|
70
|
+
this.render(event);
|
|
71
|
+
} finally {
|
|
72
|
+
this.forward?.(event);
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
private render(event: ProgressEvent): void {
|
|
77
|
+
const pct = event.percent;
|
|
78
|
+
if (pct === undefined) return;
|
|
79
|
+
this.draw(pct, event.completed, event.total);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
private draw(
|
|
83
|
+
pct: number,
|
|
84
|
+
completed?: number,
|
|
85
|
+
total?: number,
|
|
86
|
+
force = false,
|
|
87
|
+
): void {
|
|
88
|
+
const out = stderr();
|
|
89
|
+
if (!out) return;
|
|
90
|
+
const complete = pct >= 100;
|
|
91
|
+
const now = Date.now();
|
|
92
|
+
// Throttle redraws (uploads emit many events); always draw the final 100%.
|
|
93
|
+
if (
|
|
94
|
+
!force &&
|
|
95
|
+
!complete &&
|
|
96
|
+
this.lastDraw !== 0 &&
|
|
97
|
+
now - this.lastDraw < MIN_REDRAW_INTERVAL_MS
|
|
98
|
+
) {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
this.lastDraw = now;
|
|
102
|
+
this.lastPct = pct;
|
|
103
|
+
|
|
104
|
+
const filled = Math.round((BAR_WIDTH * pct) / 100);
|
|
105
|
+
const bar = "#".repeat(filled) + "-".repeat(BAR_WIDTH - filled);
|
|
106
|
+
let line = `\r${this.label}: ${String(Math.round(pct)).padStart(3)}% [${bar}]`;
|
|
107
|
+
if (this.bytesMode && total) {
|
|
108
|
+
line += ` ${formatBytes(completed ?? 0)}/${formatBytes(total)}`;
|
|
109
|
+
}
|
|
110
|
+
out.write(line);
|
|
111
|
+
this.drewAny = true;
|
|
112
|
+
if (complete) {
|
|
113
|
+
out.write("\n");
|
|
114
|
+
this.finished = true;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Finish the bar. Safe to call more than once. Always leaves 100% drawn. */
|
|
119
|
+
close(): void {
|
|
120
|
+
if (this.closed) return;
|
|
121
|
+
this.closed = true;
|
|
122
|
+
if (this.drewAny && !this.finished) {
|
|
123
|
+
this.draw(100, undefined, undefined, true);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Build the effective progress callback for an upload/transcription phase.
|
|
130
|
+
*
|
|
131
|
+
* When `show` is true, wrap `onProgress` in a {@link ProgressPrinter} that
|
|
132
|
+
* renders to the console and still forwards to the user callback. The returned
|
|
133
|
+
* printer (or `undefined`) must have `.close()` called when done.
|
|
134
|
+
*/
|
|
135
|
+
export function resolveProgress(
|
|
136
|
+
onProgress: ProgressCallback | undefined,
|
|
137
|
+
show: boolean,
|
|
138
|
+
opts: { label?: string; bytesMode?: boolean } = {},
|
|
139
|
+
): { callback?: ProgressCallback; printer?: ProgressPrinter } {
|
|
140
|
+
if (!show) return { callback: onProgress };
|
|
141
|
+
const printer = new ProgressPrinter({
|
|
142
|
+
forward: onProgress,
|
|
143
|
+
label: opts.label,
|
|
144
|
+
bytesMode: opts.bytesMode,
|
|
145
|
+
});
|
|
146
|
+
return { callback: printer.handle, printer };
|
|
147
|
+
}
|
package/src/sse.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/** Minimal SSE line parser for fetch ReadableStreams. */
|
|
2
|
+
|
|
3
|
+
export interface SSEEvent {
|
|
4
|
+
id?: string;
|
|
5
|
+
event?: string;
|
|
6
|
+
data?: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export async function* parseSSEStream(
|
|
10
|
+
reader: ReadableStreamDefaultReader<Uint8Array>,
|
|
11
|
+
): AsyncGenerator<SSEEvent> {
|
|
12
|
+
const decoder = new TextDecoder();
|
|
13
|
+
let buffer = "";
|
|
14
|
+
let current: SSEEvent = {};
|
|
15
|
+
let dataLines: string[] = [];
|
|
16
|
+
|
|
17
|
+
const flush = (): SSEEvent | undefined => {
|
|
18
|
+
if (!dataLines.length) return undefined;
|
|
19
|
+
const event = { ...current, data: dataLines.join("\n") };
|
|
20
|
+
dataLines = [];
|
|
21
|
+
return event;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
while (true) {
|
|
25
|
+
const { done, value } = await reader.read();
|
|
26
|
+
if (done) break;
|
|
27
|
+
|
|
28
|
+
buffer += decoder.decode(value, { stream: true });
|
|
29
|
+
const lines = buffer.split(/\r?\n/);
|
|
30
|
+
buffer = lines.pop() ?? "";
|
|
31
|
+
|
|
32
|
+
for (const rawLine of lines) {
|
|
33
|
+
if (rawLine === "") {
|
|
34
|
+
const event = flush();
|
|
35
|
+
if (event) yield event;
|
|
36
|
+
current = {};
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (rawLine.startsWith(":")) continue;
|
|
41
|
+
|
|
42
|
+
const colon = rawLine.indexOf(":");
|
|
43
|
+
let field: string;
|
|
44
|
+
let valueStr: string;
|
|
45
|
+
if (colon === -1) {
|
|
46
|
+
field = rawLine;
|
|
47
|
+
valueStr = "";
|
|
48
|
+
} else {
|
|
49
|
+
field = rawLine.slice(0, colon);
|
|
50
|
+
valueStr = rawLine.slice(colon + 1).replace(/^ /, "");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (field === "id") current.id = valueStr;
|
|
54
|
+
else if (field === "event") current.event = valueStr;
|
|
55
|
+
else if (field === "data") dataLines.push(valueStr);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const event = flush();
|
|
60
|
+
if (event) yield event;
|
|
61
|
+
}
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transcript models + Deepgram / AssemblyAI-style adapters.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export interface Word {
|
|
6
|
+
word: string;
|
|
7
|
+
text: string;
|
|
8
|
+
start?: number;
|
|
9
|
+
end?: number;
|
|
10
|
+
speaker?: string;
|
|
11
|
+
confidence?: number;
|
|
12
|
+
language?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** A contiguous time range spoken in a single detected language. */
|
|
16
|
+
export interface LanguageSegment {
|
|
17
|
+
start: number;
|
|
18
|
+
end: number;
|
|
19
|
+
language: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface Utterance {
|
|
23
|
+
text: string;
|
|
24
|
+
transcript: string;
|
|
25
|
+
speaker?: string;
|
|
26
|
+
start?: number;
|
|
27
|
+
end?: number;
|
|
28
|
+
words: Word[];
|
|
29
|
+
confidence?: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface Transcript {
|
|
33
|
+
jobId: string;
|
|
34
|
+
outputType: string;
|
|
35
|
+
content: Uint8Array;
|
|
36
|
+
downloadUrl: string;
|
|
37
|
+
words: Word[];
|
|
38
|
+
utterances: Utterance[];
|
|
39
|
+
languages: LanguageSegment[];
|
|
40
|
+
raw: Record<string, unknown> | null;
|
|
41
|
+
/** Full transcript text (AssemblyAI / ElevenLabs-style). */
|
|
42
|
+
readonly text: string;
|
|
43
|
+
/** Deepgram-compatible alias for text. */
|
|
44
|
+
readonly transcript: string;
|
|
45
|
+
/** Write raw content to disk (Node). Appends `.outputType` if path has no extension. */
|
|
46
|
+
save(path: string): Promise<string>;
|
|
47
|
+
toDict(): Record<string, unknown>;
|
|
48
|
+
toDeepgram(): Record<string, unknown>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function joinWords(words: Word[]): string {
|
|
52
|
+
const parts: string[] = [];
|
|
53
|
+
for (const w of words) {
|
|
54
|
+
const token = w.word;
|
|
55
|
+
if (!token) continue;
|
|
56
|
+
if (parts.length && ".,!?;:%)]}'\"".includes(token[0]!)) {
|
|
57
|
+
parts[parts.length - 1] = parts[parts.length - 1]! + token;
|
|
58
|
+
} else {
|
|
59
|
+
parts.push(token);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return parts.join(" ");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function asNumber(value: unknown): number | undefined {
|
|
66
|
+
if (value === undefined || value === null) return undefined;
|
|
67
|
+
const n = Number(value);
|
|
68
|
+
return Number.isFinite(n) ? n : undefined;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function parseWord(data: Record<string, unknown>): Word {
|
|
72
|
+
const word = String(data.word ?? data.text ?? "");
|
|
73
|
+
return {
|
|
74
|
+
word,
|
|
75
|
+
text: word,
|
|
76
|
+
start: asNumber(data.start),
|
|
77
|
+
end: asNumber(data.end),
|
|
78
|
+
speaker: data.speaker != null ? String(data.speaker) : undefined,
|
|
79
|
+
confidence: asNumber(data.confidence),
|
|
80
|
+
language: data.language != null ? String(data.language) : undefined,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function parseLanguageSegment(data: Record<string, unknown>): LanguageSegment {
|
|
85
|
+
return {
|
|
86
|
+
start: asNumber(data.start) ?? 0,
|
|
87
|
+
end: asNumber(data.end) ?? 0,
|
|
88
|
+
language: String(data.language ?? ""),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function utteranceFromGroup(group: Word[]): Utterance {
|
|
93
|
+
const text = joinWords(group);
|
|
94
|
+
return {
|
|
95
|
+
text,
|
|
96
|
+
transcript: text,
|
|
97
|
+
speaker: group[0]?.speaker,
|
|
98
|
+
start: group[0]?.start,
|
|
99
|
+
end: group[group.length - 1]?.end,
|
|
100
|
+
words: group,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function utterancesFromWords(words: Word[]): Utterance[] {
|
|
105
|
+
if (!words.length) return [];
|
|
106
|
+
if (words.every((w) => w.speaker == null)) {
|
|
107
|
+
return [utteranceFromGroup(words)];
|
|
108
|
+
}
|
|
109
|
+
const out: Utterance[] = [];
|
|
110
|
+
let current: Word[] = [words[0]!];
|
|
111
|
+
for (let i = 1; i < words.length; i++) {
|
|
112
|
+
const w = words[i]!;
|
|
113
|
+
if (w.speaker === current[0]!.speaker) current.push(w);
|
|
114
|
+
else {
|
|
115
|
+
out.push(utteranceFromGroup(current));
|
|
116
|
+
current = [w];
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
out.push(utteranceFromGroup(current));
|
|
120
|
+
return out;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Prefers the server's diarization segments, which separate turns the speaker
|
|
125
|
+
* labels alone cannot (the same speaker talking twice). Falls back to grouping
|
|
126
|
+
* consecutive words by speaker.
|
|
127
|
+
*/
|
|
128
|
+
function utterancesFromDiarization(words: Word[], diarization: unknown): Utterance[] {
|
|
129
|
+
if (!Array.isArray(diarization) || diarization.length === 0) {
|
|
130
|
+
return utterancesFromWords(words);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const out: Utterance[] = [];
|
|
134
|
+
for (const seg of diarization as Record<string, unknown>[]) {
|
|
135
|
+
const start = asNumber(seg?.start);
|
|
136
|
+
const end = asNumber(seg?.end);
|
|
137
|
+
if (start === undefined || end === undefined) continue;
|
|
138
|
+
|
|
139
|
+
const segWords = wordsWithin(words, start, end);
|
|
140
|
+
const text = joinWords(segWords);
|
|
141
|
+
out.push({
|
|
142
|
+
text,
|
|
143
|
+
transcript: text,
|
|
144
|
+
speaker: seg.speaker == null ? undefined : String(seg.speaker),
|
|
145
|
+
start,
|
|
146
|
+
end,
|
|
147
|
+
words: segWords,
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
return out.length ? out : utterancesFromWords(words);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Words a segment covers, falling back to a midpoint test for words that
|
|
155
|
+
* straddle the boundary.
|
|
156
|
+
*/
|
|
157
|
+
function wordsWithin(words: Word[], start: number, end: number): Word[] {
|
|
158
|
+
const eps = 1e-3;
|
|
159
|
+
const timed = words.filter((w) => w.start !== undefined && w.end !== undefined);
|
|
160
|
+
const inside = timed.filter((w) => w.start! >= start - eps && w.end! <= end + eps);
|
|
161
|
+
if (inside.length) return inside;
|
|
162
|
+
return timed.filter((w) => {
|
|
163
|
+
const mid = (w.start! + w.end!) / 2;
|
|
164
|
+
return mid >= start && mid <= end;
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function speakerIndex(speaker?: string): number | string | undefined {
|
|
169
|
+
if (speaker == null) return undefined;
|
|
170
|
+
if (speaker.toUpperCase().startsWith("SPEAKER_")) {
|
|
171
|
+
const n = Number(speaker.split("_")[1]);
|
|
172
|
+
return Number.isFinite(n) ? n : speaker;
|
|
173
|
+
}
|
|
174
|
+
if (speaker.length === 1 && /[a-zA-Z]/.test(speaker)) {
|
|
175
|
+
return speaker.toUpperCase().charCodeAt(0) - "A".charCodeAt(0);
|
|
176
|
+
}
|
|
177
|
+
const n = Number(speaker);
|
|
178
|
+
return Number.isFinite(n) ? n : speaker;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function parseTranscript(opts: {
|
|
182
|
+
jobId: string;
|
|
183
|
+
content: Uint8Array;
|
|
184
|
+
outputType: string;
|
|
185
|
+
downloadUrl?: string;
|
|
186
|
+
}): Transcript {
|
|
187
|
+
const { jobId, content, outputType } = opts;
|
|
188
|
+
const downloadUrl = opts.downloadUrl ?? "";
|
|
189
|
+
|
|
190
|
+
if (outputType !== "json") {
|
|
191
|
+
let text = "";
|
|
192
|
+
try {
|
|
193
|
+
text = new TextDecoder().decode(content);
|
|
194
|
+
} catch {
|
|
195
|
+
text = "";
|
|
196
|
+
}
|
|
197
|
+
return makeTranscript({
|
|
198
|
+
jobId,
|
|
199
|
+
outputType,
|
|
200
|
+
content,
|
|
201
|
+
downloadUrl,
|
|
202
|
+
words: [],
|
|
203
|
+
utterances: [],
|
|
204
|
+
languages: [],
|
|
205
|
+
raw: null,
|
|
206
|
+
text,
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
let raw: Record<string, unknown>;
|
|
211
|
+
try {
|
|
212
|
+
raw = JSON.parse(new TextDecoder().decode(content)) as Record<string, unknown>;
|
|
213
|
+
} catch {
|
|
214
|
+
return makeTranscript({
|
|
215
|
+
jobId,
|
|
216
|
+
outputType,
|
|
217
|
+
content,
|
|
218
|
+
downloadUrl,
|
|
219
|
+
words: [],
|
|
220
|
+
utterances: [],
|
|
221
|
+
languages: [],
|
|
222
|
+
raw: null,
|
|
223
|
+
text: "",
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const words = Array.isArray(raw.words)
|
|
228
|
+
? (raw.words as Record<string, unknown>[]).map(parseWord)
|
|
229
|
+
: [];
|
|
230
|
+
const utterances = utterancesFromDiarization(words, raw.diarization);
|
|
231
|
+
const languages = Array.isArray(raw.languages)
|
|
232
|
+
? (raw.languages as Record<string, unknown>[]).map(parseLanguageSegment)
|
|
233
|
+
: [];
|
|
234
|
+
const text = joinWords(words);
|
|
235
|
+
|
|
236
|
+
return makeTranscript({
|
|
237
|
+
jobId,
|
|
238
|
+
outputType,
|
|
239
|
+
content,
|
|
240
|
+
downloadUrl,
|
|
241
|
+
words,
|
|
242
|
+
utterances,
|
|
243
|
+
languages,
|
|
244
|
+
raw,
|
|
245
|
+
text,
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function makeTranscript(args: {
|
|
250
|
+
jobId: string;
|
|
251
|
+
outputType: string;
|
|
252
|
+
content: Uint8Array;
|
|
253
|
+
downloadUrl: string;
|
|
254
|
+
words: Word[];
|
|
255
|
+
utterances: Utterance[];
|
|
256
|
+
languages: LanguageSegment[];
|
|
257
|
+
raw: Record<string, unknown> | null;
|
|
258
|
+
text: string;
|
|
259
|
+
}): Transcript {
|
|
260
|
+
const textValue = args.text;
|
|
261
|
+
return {
|
|
262
|
+
jobId: args.jobId,
|
|
263
|
+
outputType: args.outputType,
|
|
264
|
+
content: args.content,
|
|
265
|
+
downloadUrl: args.downloadUrl,
|
|
266
|
+
words: args.words,
|
|
267
|
+
utterances: args.utterances,
|
|
268
|
+
languages: args.languages,
|
|
269
|
+
raw: args.raw,
|
|
270
|
+
get text() {
|
|
271
|
+
return textValue;
|
|
272
|
+
},
|
|
273
|
+
get transcript() {
|
|
274
|
+
return textValue;
|
|
275
|
+
},
|
|
276
|
+
async save(path: string): Promise<string> {
|
|
277
|
+
const name = path.includes("/") ? path.slice(path.lastIndexOf("/") + 1) : path;
|
|
278
|
+
const out = name.includes(".") ? path : `${path}.${args.outputType}`;
|
|
279
|
+
const { writeFile } = await import("node:fs/promises");
|
|
280
|
+
await writeFile(out, args.content);
|
|
281
|
+
return out;
|
|
282
|
+
},
|
|
283
|
+
toDict() {
|
|
284
|
+
const dict: Record<string, unknown> = {
|
|
285
|
+
id: args.jobId,
|
|
286
|
+
status: "completed",
|
|
287
|
+
text: textValue,
|
|
288
|
+
words: args.words,
|
|
289
|
+
utterances: args.utterances,
|
|
290
|
+
output_type: args.outputType,
|
|
291
|
+
};
|
|
292
|
+
if (args.languages.length) dict.languages = args.languages;
|
|
293
|
+
return dict;
|
|
294
|
+
},
|
|
295
|
+
toDeepgram() {
|
|
296
|
+
return {
|
|
297
|
+
metadata: { request_id: args.jobId, channels: 1 },
|
|
298
|
+
results: {
|
|
299
|
+
channels: [
|
|
300
|
+
{
|
|
301
|
+
alternatives: [
|
|
302
|
+
{
|
|
303
|
+
transcript: textValue,
|
|
304
|
+
confidence: 1.0,
|
|
305
|
+
words: args.words.map((w) => ({
|
|
306
|
+
word: w.word.replace(/[.,!?;:]+$/g, "").toLowerCase(),
|
|
307
|
+
punctuated_word: w.word,
|
|
308
|
+
start: w.start,
|
|
309
|
+
end: w.end,
|
|
310
|
+
speaker: speakerIndex(w.speaker),
|
|
311
|
+
})),
|
|
312
|
+
},
|
|
313
|
+
],
|
|
314
|
+
},
|
|
315
|
+
],
|
|
316
|
+
utterances: args.utterances.map((u) => ({
|
|
317
|
+
transcript: u.text,
|
|
318
|
+
channel: 0,
|
|
319
|
+
start: u.start,
|
|
320
|
+
end: u.end,
|
|
321
|
+
speaker: speakerIndex(u.speaker),
|
|
322
|
+
words: u.words.map((w) => ({
|
|
323
|
+
word: w.word,
|
|
324
|
+
punctuated_word: w.word,
|
|
325
|
+
start: w.start,
|
|
326
|
+
end: w.end,
|
|
327
|
+
speaker: speakerIndex(w.speaker),
|
|
328
|
+
})),
|
|
329
|
+
})),
|
|
330
|
+
},
|
|
331
|
+
};
|
|
332
|
+
},
|
|
333
|
+
};
|
|
334
|
+
}
|