@drakulavich/parakeet-cli 0.1.3 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drakulavich/parakeet-cli",
3
- "version": "0.1.3",
3
+ "version": "0.2.0",
4
4
  "description": "Fast multilingual speech-to-text CLI powered by NVIDIA Parakeet ONNX models",
5
5
  "type": "module",
6
6
  "bin": {
@@ -44,7 +44,8 @@
44
44
  "bun": ">=1.3.0"
45
45
  },
46
46
  "devDependencies": {
47
- "@types/bun": "latest"
47
+ "@types/bun": "latest",
48
+ "typescript": "^6.0.2"
48
49
  },
49
50
  "dependencies": {
50
51
  "onnxruntime-node": "^1.24.0"
@@ -1,8 +1,10 @@
1
1
  import { describe, test, expect } from "bun:test";
2
2
  import { convertToFloat32PCM } from "../audio";
3
- import { existsSync } from "fs";
3
+ import { spawnSync } from "child_process";
4
4
 
5
- describe("audio", () => {
5
+ const hasFfmpeg = spawnSync("which", ["ffmpeg"]).status === 0;
6
+
7
+ describe.skipIf(!hasFfmpeg)("audio", () => {
6
8
  test("converts WAV to 16kHz mono Float32Array", async () => {
7
9
  const buffer = await convertToFloat32PCM("fixtures/silence.wav");
8
10
  expect(buffer).toBeInstanceOf(Float32Array);
@@ -1,5 +1,5 @@
1
1
  import { describe, test, expect } from "bun:test";
2
- import { greedyDecode, type DecoderSession } from "../decoder";
2
+ import { beamDecode, type DecoderSession } from "../decoder";
3
3
 
4
4
  function mockSession(responses: Array<{ tokenLogits: number[]; durationLogits: number[] }>): DecoderSession {
5
5
  let callIndex = 0;
@@ -24,7 +24,8 @@ describe("decoder", () => {
24
24
  { tokenLogits: [0, 10, 0, -10], durationLogits: [10, 0] },
25
25
  { tokenLogits: [0, 0, 0, 10], durationLogits: [10, 0] },
26
26
  ]);
27
- const tokens = await greedyDecode(session, 3);
27
+ const encoderData = new Float32Array(3);
28
+ const tokens = await beamDecode(session, 3, encoderData, 1, 1);
28
29
  expect(tokens).toEqual([0, 1]);
29
30
  });
30
31
 
@@ -34,24 +35,16 @@ describe("decoder", () => {
34
35
  { tokenLogits: [0, 10, 0, -10], durationLogits: [10, 0, 0] },
35
36
  { tokenLogits: [0, 0, 0, 10], durationLogits: [10, 0, 0] },
36
37
  ]);
37
- const tokens = await greedyDecode(session, 5);
38
+ const encoderData = new Float32Array(5);
39
+ const tokens = await beamDecode(session, 5, encoderData, 1, 1);
38
40
  expect(tokens).toEqual([0, 1]);
39
41
  });
40
42
 
41
- test("handles max_tokens_per_step limit", async () => {
42
- const session = mockSession([
43
- { tokenLogits: [10, 0, 0, -10], durationLogits: [10, 0] },
44
- ]);
45
- const tokens = await greedyDecode(session, 2);
46
- expect(tokens.length).toBeLessThanOrEqual(20);
47
- expect(tokens.length).toBeGreaterThan(0);
48
- });
49
-
50
43
  test("returns empty for zero-length encoder output", async () => {
51
44
  const session = mockSession([
52
45
  { tokenLogits: [0, 0, 0, 10], durationLogits: [10, 0] },
53
46
  ]);
54
- const tokens = await greedyDecode(session, 0);
47
+ const tokens = await beamDecode(session, 0, new Float32Array(0), 1);
55
48
  expect(tokens).toEqual([]);
56
49
  });
57
50
  });
@@ -1,16 +1,11 @@
1
1
  import { describe, test, expect } from "bun:test";
2
- import { getModelDir, MODEL_FILES, HF_REPOS } from "../models";
2
+ import { getModelDir, MODEL_FILES, HF_REPO } from "../models";
3
3
  import { join } from "path";
4
4
  import { homedir } from "os";
5
5
 
6
6
  describe("models", () => {
7
- test("getModelDir returns correct cache path for v2", () => {
8
- const dir = getModelDir("v2");
9
- expect(dir).toBe(join(homedir(), ".cache", "parakeet", "v2"));
10
- });
11
-
12
- test("getModelDir returns correct cache path for v3", () => {
13
- const dir = getModelDir("v3");
7
+ test("getModelDir returns correct cache path", () => {
8
+ const dir = getModelDir();
14
9
  expect(dir).toBe(join(homedir(), ".cache", "parakeet", "v3"));
15
10
  });
16
11
 
@@ -22,8 +17,7 @@ describe("models", () => {
22
17
  expect(MODEL_FILES).toContain("vocab.txt");
23
18
  });
24
19
 
25
- test("HF_REPOS maps versions to repo IDs", () => {
26
- expect(HF_REPOS.v2).toBe("istupakov/parakeet-tdt-0.6b-v2-onnx");
27
- expect(HF_REPOS.v3).toBe("istupakov/parakeet-tdt-0.6b-v3-onnx");
20
+ test("HF_REPO points to v3 ONNX repo", () => {
21
+ expect(HF_REPO).toBe("istupakov/parakeet-tdt-0.6b-v3-onnx");
28
22
  });
29
23
  });
@@ -38,8 +38,4 @@ describe("tokenizer", () => {
38
38
  expect(text).toBe("cats");
39
39
  });
40
40
 
41
- test("isAsciiDominant returns true for ASCII tokens", async () => {
42
- const tok = await Tokenizer.fromFile("fixtures/test-vocab.txt");
43
- expect(tok.isAsciiDominant([0, 1, 2])).toBe(true);
44
- });
45
41
  });
package/src/audio.ts CHANGED
@@ -30,6 +30,7 @@ export async function convertToFloat32PCM(inputPath: string): Promise<Float32Arr
30
30
  const raw = await Bun.file(tmpPath).arrayBuffer();
31
31
  return new Float32Array(raw);
32
32
  } finally {
33
+ // Best-effort cleanup; file may already be gone
33
34
  try { unlinkSync(tmpPath); } catch {}
34
35
  }
35
36
  }
package/src/cli.ts CHANGED
@@ -28,8 +28,9 @@ async function main(): Promise<void> {
28
28
  try {
29
29
  const text = await transcribe(file, { noCache });
30
30
  if (text) process.stdout.write(text + "\n");
31
- } catch (err: any) {
32
- console.error(`Error: ${err.message}`);
31
+ } catch (err: unknown) {
32
+ const message = err instanceof Error ? err.message : String(err);
33
+ console.error(`Error: ${message}`);
33
34
  process.exit(1);
34
35
  }
35
36
  }
package/src/decoder.ts CHANGED
@@ -2,84 +2,105 @@ import * as ort from "onnxruntime-node";
2
2
  import { join } from "path";
3
3
  import { ensureOrtBackend } from "./ort-backend-fix";
4
4
 
5
+ // TDT allows multiple tokens per encoder frame; cap to prevent runaway decoding
5
6
  const MAX_TOKENS_PER_STEP = 10;
6
7
 
8
+ type F32 = Float32Array<ArrayBufferLike>;
9
+
7
10
  export interface DecoderSession {
8
11
  decode(
9
- encoderFrame: Float32Array,
12
+ encoderFrame: F32,
10
13
  targets: number[],
11
14
  targetLength: number,
12
- state1: Float32Array,
13
- state2: Float32Array
14
- ): Promise<{ output: Float32Array; state1: Float32Array; state2: Float32Array }>;
15
+ state1: F32,
16
+ state2: F32
17
+ ): Promise<{ output: F32; state1: F32; state2: F32 }>;
15
18
  vocabSize: number;
16
19
  blankId: number;
17
20
  stateDims: { layers: number; hidden: number };
18
21
  }
19
22
 
20
- export async function greedyDecode(
23
+ const DEFAULT_BEAM_WIDTH = 4;
24
+
25
+ interface Beam {
26
+ tokens: number[];
27
+ score: number;
28
+ lastToken: number;
29
+ state1: F32;
30
+ state2: F32;
31
+ t: number;
32
+ }
33
+
34
+ export async function beamDecode(
21
35
  session: DecoderSession,
22
36
  encoderLength: number,
23
- encoderData?: Float32Array,
24
- encoderDim?: number
37
+ encoderData: Float32Array,
38
+ encoderDim: number,
39
+ beamWidth: number = DEFAULT_BEAM_WIDTH,
25
40
  ): Promise<number[]> {
26
41
  if (encoderLength === 0) return [];
27
42
 
28
- const tokens: number[] = [];
29
43
  const stateSize = session.stateDims.layers * session.stateDims.hidden;
30
- let state1 = new Float32Array(stateSize);
31
- let state2 = new Float32Array(stateSize);
32
- let lastToken = session.blankId;
33
-
34
- let t = 0;
35
- while (t < encoderLength) {
36
- let tokensThisStep = 0;
37
-
38
- while (tokensThisStep < MAX_TOKENS_PER_STEP) {
39
- let frame: Float32Array;
40
- if (encoderData && encoderDim) {
41
- // Must copy — ort.Tensor doesn't work with subarray views under Bun
42
- frame = encoderData.slice(t * encoderDim, (t + 1) * encoderDim);
43
- } else {
44
- frame = new Float32Array(1);
45
- }
46
44
 
47
- const result = await session.decode(frame, [lastToken], 1, state1, state2);
45
+ let beams: Beam[] = [{
46
+ tokens: [],
47
+ score: 0,
48
+ lastToken: session.blankId,
49
+ state1: new Float32Array(stateSize),
50
+ state2: new Float32Array(stateSize),
51
+ t: 0,
52
+ }];
53
+
54
+ const maxSteps = encoderLength * MAX_TOKENS_PER_STEP;
55
+
56
+ for (let step = 0; step < maxSteps; step++) {
57
+ const active = beams.filter(b => b.t < encoderLength);
58
+ if (active.length === 0) break;
59
+
60
+ const candidates: Beam[] = [];
61
+
62
+ for (const beam of active) {
63
+ // Must copy — ort.Tensor doesn't work with subarray views under Bun
64
+ const frame = encoderData.slice(beam.t * encoderDim, (beam.t + 1) * encoderDim);
65
+ const result = await session.decode(frame, [beam.lastToken], 1, beam.state1, beam.state2);
48
66
  const output = result.output;
49
67
 
50
68
  const tokenLogits = output.slice(0, session.vocabSize);
51
69
  const durationLogits = output.slice(session.vocabSize);
52
-
53
- const tokenId = argmax(tokenLogits);
54
70
  const duration = argmax(durationLogits);
55
71
 
56
- state1 = result.state1;
57
- state2 = result.state2;
58
-
59
- if (tokenId === session.blankId) {
60
- t += 1;
61
- break;
62
- }
63
-
64
- tokens.push(tokenId);
65
- lastToken = tokenId;
66
- tokensThisStep++;
72
+ // Blank option: advance one frame, keep same tokens
73
+ candidates.push({
74
+ tokens: beam.tokens,
75
+ score: beam.score + tokenLogits[session.blankId],
76
+ lastToken: beam.lastToken,
77
+ state1: result.state1,
78
+ state2: result.state2,
79
+ t: beam.t + 1,
80
+ });
67
81
 
68
- if (duration > 0) {
69
- t += duration;
70
- break;
82
+ // Top non-blank token options
83
+ const topK = topKIndices(tokenLogits, beamWidth, session.blankId);
84
+ for (const tokenId of topK) {
85
+ candidates.push({
86
+ tokens: [...beam.tokens, tokenId],
87
+ score: beam.score + tokenLogits[tokenId],
88
+ lastToken: tokenId,
89
+ state1: result.state1,
90
+ state2: result.state2,
91
+ t: duration > 0 ? beam.t + duration : beam.t,
92
+ });
71
93
  }
72
94
  }
73
95
 
74
- if (tokensThisStep >= MAX_TOKENS_PER_STEP) {
75
- t += 1;
76
- }
96
+ candidates.sort((a, b) => b.score - a.score);
97
+ beams = candidates.slice(0, beamWidth);
77
98
  }
78
99
 
79
- return tokens;
100
+ return beams[0].tokens;
80
101
  }
81
102
 
82
- function argmax(arr: Float32Array): number {
103
+ function argmax(arr: F32): number {
83
104
  let maxIdx = 0;
84
105
  let maxVal = arr[0];
85
106
  for (let i = 1; i < arr.length; i++) {
@@ -91,6 +112,15 @@ function argmax(arr: Float32Array): number {
91
112
  return maxIdx;
92
113
  }
93
114
 
115
+ function topKIndices(arr: F32, k: number, excludeId: number): number[] {
116
+ const indexed: [number, number][] = [];
117
+ for (let i = 0; i < arr.length; i++) {
118
+ if (i !== excludeId) indexed.push([arr[i], i]);
119
+ }
120
+ indexed.sort((a, b) => b[0] - a[0]);
121
+ return indexed.slice(0, k).map(([, i]) => i);
122
+ }
123
+
94
124
  let onnxSession: ort.InferenceSession | null = null;
95
125
 
96
126
  export async function initDecoder(modelDir: string): Promise<void> {
@@ -129,7 +159,3 @@ export function createOnnxDecoderSession(
129
159
  },
130
160
  };
131
161
  }
132
-
133
- export function releaseDecoder(): void {
134
- onnxSession = null;
135
- }
package/src/encoder.ts CHANGED
@@ -26,7 +26,3 @@ export async function encode(
26
26
 
27
27
  return { encoderOutput, encodedLength };
28
28
  }
29
-
30
- export function releaseEncoder(): void {
31
- session = null;
32
- }
package/src/models.ts CHANGED
@@ -2,12 +2,7 @@ import { join } from "path";
2
2
  import { homedir } from "os";
3
3
  import { existsSync, mkdirSync } from "fs";
4
4
 
5
- export type ModelVersion = "v2" | "v3";
6
-
7
- export const HF_REPOS: Record<ModelVersion, string> = {
8
- v2: "istupakov/parakeet-tdt-0.6b-v2-onnx",
9
- v3: "istupakov/parakeet-tdt-0.6b-v3-onnx",
10
- };
5
+ export const HF_REPO = "istupakov/parakeet-tdt-0.6b-v3-onnx";
11
6
 
12
7
  export const MODEL_FILES = [
13
8
  "encoder-model.onnx",
@@ -17,28 +12,26 @@ export const MODEL_FILES = [
17
12
  "vocab.txt",
18
13
  ];
19
14
 
20
- export function getModelDir(version: ModelVersion): string {
21
- return join(homedir(), ".cache", "parakeet", version);
15
+ export function getModelDir(): string {
16
+ return join(homedir(), ".cache", "parakeet", "v3");
22
17
  }
23
18
 
24
- export function isModelCached(version: ModelVersion): boolean {
25
- const dir = getModelDir(version);
19
+ export function isModelCached(): boolean {
20
+ const dir = getModelDir();
26
21
  return MODEL_FILES.every((f) => existsSync(join(dir, f)));
27
22
  }
28
23
 
29
- export async function ensureModel(version: ModelVersion, noCache = false): Promise<string> {
30
- const dir = getModelDir(version);
24
+ export async function ensureModel(noCache = false): Promise<string> {
25
+ const dir = getModelDir();
31
26
 
32
- if (!noCache && isModelCached(version)) {
27
+ if (!noCache && isModelCached()) {
33
28
  return dir;
34
29
  }
35
30
 
36
31
  mkdirSync(dir, { recursive: true });
37
32
 
38
- const repo = HF_REPOS[version];
39
-
40
33
  for (const file of MODEL_FILES) {
41
- const url = `https://huggingface.co/${repo}/resolve/main/${file}`;
34
+ const url = `https://huggingface.co/${HF_REPO}/resolve/main/${file}`;
42
35
  const dest = join(dir, file);
43
36
 
44
37
  if (!noCache && existsSync(dest)) continue;
package/src/preprocess.ts CHANGED
@@ -2,6 +2,8 @@ import * as ort from "onnxruntime-node";
2
2
  import { join } from "path";
3
3
  import { ensureOrtBackend } from "./ort-backend-fix";
4
4
 
5
+ const NORM_EPSILON = 1e-10;
6
+
5
7
  let session: ort.InferenceSession | null = null;
6
8
 
7
9
  export async function initPreprocessor(modelDir: string): Promise<void> {
@@ -41,7 +43,7 @@ export async function preprocess(audio: Float32Array): Promise<{ features: ort.T
41
43
 
42
44
  const mean = sum / actualLength;
43
45
  const variance = sumSq / actualLength - mean * mean;
44
- const std = Math.sqrt(Math.max(variance, 1e-10));
46
+ const std = Math.sqrt(Math.max(variance, NORM_EPSILON));
45
47
 
46
48
  for (let t = 0; t < T; t++) {
47
49
  normalized[f * T + t] = t < actualLength ? (melData[f * T + t] - mean) / std : 0;
@@ -53,7 +55,3 @@ export async function preprocess(audio: Float32Array): Promise<{ features: ort.T
53
55
 
54
56
  return { features: featureTensor, length: outputLength };
55
57
  }
56
-
57
- export function releasePreprocessor(): void {
58
- session = null;
59
- }
package/src/tokenizer.ts CHANGED
@@ -40,20 +40,4 @@ export class Tokenizer {
40
40
  }
41
41
  return pieces.join("").replaceAll("\u2581", " ").trim();
42
42
  }
43
-
44
- isAsciiDominant(tokenIds: number[], threshold = 0.9): boolean {
45
- const nonBlank = tokenIds.filter((id) => id !== this.blankId);
46
- if (nonBlank.length === 0) return false;
47
-
48
- let asciiCount = 0;
49
- for (const id of nonBlank) {
50
- const token = this.idToToken.get(id) ?? "";
51
- const cleaned = token.replaceAll("\u2581", "");
52
- if (cleaned.length > 0 && /^[\x00-\x7F]+$/.test(cleaned)) {
53
- asciiCount++;
54
- }
55
- }
56
-
57
- return asciiCount / nonBlank.length >= threshold;
58
- }
59
43
  }
package/src/transcribe.ts CHANGED
@@ -5,11 +5,21 @@ import { initEncoder, encode } from "./encoder";
5
5
  import {
6
6
  initDecoder,
7
7
  createOnnxDecoderSession,
8
- greedyDecode,
8
+ beamDecode,
9
9
  } from "./decoder";
10
10
  import { Tokenizer } from "./tokenizer";
11
11
  import { join } from "path";
12
12
 
13
+ function transpose2D(data: Float32Array, rows: number, cols: number): Float32Array {
14
+ const out = new Float32Array(cols * rows);
15
+ for (let c = 0; c < cols; c++) {
16
+ for (let r = 0; r < rows; r++) {
17
+ out[c * rows + r] = data[r * cols + c];
18
+ }
19
+ }
20
+ return out;
21
+ }
22
+
13
23
  // Parakeet TDT 0.6B decoder state dimensions (from ONNX model input shapes)
14
24
  const DECODER_LAYERS = 2;
15
25
  const DECODER_HIDDEN = 640;
@@ -18,15 +28,18 @@ export interface TranscribeOptions {
18
28
  noCache?: boolean;
19
29
  }
20
30
 
31
+ // Minimum 0.1s of audio at 16kHz to produce meaningful output
32
+ const MIN_AUDIO_SAMPLES = 1600;
33
+
21
34
  export async function transcribe(audioPath: string, opts: TranscribeOptions = {}): Promise<string> {
22
35
  const audio = await convertToFloat32PCM(audioPath);
23
36
 
24
- if (audio.length < 1600) {
37
+ if (audio.length < MIN_AUDIO_SAMPLES) {
25
38
  return "";
26
39
  }
27
40
 
28
41
  const noCache = opts.noCache ?? false;
29
- const modelDir = await ensureModel("v3", noCache);
42
+ const modelDir = await ensureModel(noCache);
30
43
  const tokenizer = await Tokenizer.fromFile(join(modelDir, "vocab.txt"));
31
44
 
32
45
  await initPreprocessor(modelDir);
@@ -41,13 +54,7 @@ export async function transcribe(audioPath: string, opts: TranscribeOptions = {}
41
54
  const D = dims[1];
42
55
  const T = dims[2];
43
56
 
44
- // Transpose from [1, D, T] to [T, D] so each frame is contiguous
45
- const transposed = new Float32Array(T * D);
46
- for (let t = 0; t < T; t++) {
47
- for (let d = 0; d < D; d++) {
48
- transposed[t * D + d] = encoderData[d * T + t];
49
- }
50
- }
57
+ const transposed = transpose2D(encoderData, D, T);
51
58
 
52
59
  const session = createOnnxDecoderSession(
53
60
  tokenizer.vocabSize,
@@ -56,6 +63,6 @@ export async function transcribe(audioPath: string, opts: TranscribeOptions = {}
56
63
  DECODER_HIDDEN,
57
64
  );
58
65
 
59
- const tokens = await greedyDecode(session, encodedLength, transposed, D);
66
+ const tokens = await beamDecode(session, encodedLength, transposed, D);
60
67
  return tokenizer.detokenize(tokens);
61
68
  }
package/tsconfig.json CHANGED
@@ -7,7 +7,7 @@
7
7
  "strict": true,
8
8
  "skipLibCheck": true,
9
9
  "outDir": "./dist",
10
- "rootDir": "./src"
10
+ "rootDir": "."
11
11
  },
12
12
  "include": ["src/**/*.ts", "tests/**/*.ts"]
13
13
  }