@ailoud/providers 1.0.0-dev.1

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.
Files changed (43) hide show
  1. package/LICENSE +224 -0
  2. package/dist/.tsbuildinfo +1 -0
  3. package/dist/audio/ffmpeg.d.ts +12 -0
  4. package/dist/audio/ffmpeg.js +81 -0
  5. package/dist/diarize/sherpaDiarizer.d.ts +26 -0
  6. package/dist/diarize/sherpaDiarizer.js +64 -0
  7. package/dist/index.d.ts +33 -0
  8. package/dist/index.js +19 -0
  9. package/dist/llm/anthropic.d.ts +36 -0
  10. package/dist/llm/anthropic.js +92 -0
  11. package/dist/llm/claudeCli.d.ts +32 -0
  12. package/dist/llm/claudeCli.js +63 -0
  13. package/dist/llm/llamaCpp.d.ts +38 -0
  14. package/dist/llm/llamaCpp.js +90 -0
  15. package/dist/llm/models.d.ts +23 -0
  16. package/dist/llm/models.js +97 -0
  17. package/dist/llm/openAiCompatible.d.ts +35 -0
  18. package/dist/llm/openAiCompatible.js +84 -0
  19. package/dist/process/pager.d.ts +36 -0
  20. package/dist/process/pager.js +69 -0
  21. package/dist/process/run.d.ts +35 -0
  22. package/dist/process/run.js +101 -0
  23. package/dist/provision/download.d.ts +19 -0
  24. package/dist/provision/download.js +80 -0
  25. package/dist/provision/llamaInstall.d.ts +38 -0
  26. package/dist/provision/llamaInstall.js +75 -0
  27. package/dist/provision/packageManager.d.ts +50 -0
  28. package/dist/provision/packageManager.js +66 -0
  29. package/dist/provision/sherpaInstall.d.ts +34 -0
  30. package/dist/provision/sherpaInstall.js +78 -0
  31. package/dist/provision/whisperInstall.d.ts +61 -0
  32. package/dist/provision/whisperInstall.js +89 -0
  33. package/dist/store/sqliteStore.d.ts +64 -0
  34. package/dist/store/sqliteStore.js +433 -0
  35. package/dist/stt/whisperCpp.d.ts +50 -0
  36. package/dist/stt/whisperCpp.js +116 -0
  37. package/dist/system/nodeFs.d.ts +14 -0
  38. package/dist/system/nodeFs.js +80 -0
  39. package/dist/system/systemClock.d.ts +20 -0
  40. package/dist/system/systemClock.js +55 -0
  41. package/dist/vad/whisperVad.d.ts +14 -0
  42. package/dist/vad/whisperVad.js +58 -0
  43. package/package.json +48 -0
@@ -0,0 +1,50 @@
1
+ import type { RawSegment, TranscriptionProvider } from '@ailoud/core';
2
+ import { run as defaultRunner } from '../process/run.js';
3
+ /**
4
+ * Pulls the language out of `whisper-cli -dl` output. The line arrives on
5
+ * stderr amid backend chatter, so this matches rather than reads a field.
6
+ */
7
+ export declare function parseDetectedLanguage(output: string): string;
8
+ /**
9
+ * Pure parser for whisper-cli's "-oj" JSON output.
10
+ *
11
+ * Whisper prefixes every segment's text with a leading space, and emits
12
+ * silent stretches of audio as a segment with blank (or whitespace-only)
13
+ * text. Trimming and dropping those here keeps the quirk out of the
14
+ * database, where it would otherwise resurface in every export and every
15
+ * prompt built from stored segments.
16
+ */
17
+ export declare function parseWhisperJson(raw: string): {
18
+ language: string;
19
+ segments: RawSegment[];
20
+ };
21
+ export interface WhisperCppOptions {
22
+ readonly binary: string;
23
+ readonly modelPath: string;
24
+ readonly runner?: typeof defaultRunner;
25
+ readonly readFile?: (path: string) => Promise<string>;
26
+ }
27
+ export declare class WhisperCppProvider implements TranscriptionProvider {
28
+ private readonly options;
29
+ readonly name = "whisper-cpp";
30
+ readonly capabilities: {
31
+ readonly maxBytes: null;
32
+ readonly supportsDiarization: false;
33
+ readonly supportsLanguageHint: true;
34
+ readonly supportsLanguageDetection: true;
35
+ };
36
+ private readonly runner;
37
+ private readonly readFile;
38
+ constructor(options: WhisperCppOptions);
39
+ transcribe(audioPath: string, opts: {
40
+ readonly language?: string;
41
+ readonly model?: string;
42
+ }): Promise<{
43
+ language: string;
44
+ model: string;
45
+ segments: RawSegment[];
46
+ }>;
47
+ detectLanguage(audioPath: string, opts?: {
48
+ readonly model?: string;
49
+ }): Promise<string>;
50
+ }
@@ -0,0 +1,116 @@
1
+ import { readFile as readFileFromDisk } from 'node:fs/promises';
2
+ import { basename, dirname, extname, join } from 'node:path';
3
+ import { FailureError } from '@ailoud/core';
4
+ import { run as defaultRunner } from '../process/run.js';
5
+ /**
6
+ * Pulls the language out of `whisper-cli -dl` output. The line arrives on
7
+ * stderr amid backend chatter, so this matches rather than reads a field.
8
+ */
9
+ export function parseDetectedLanguage(output) {
10
+ const match = /auto-detected language:\s*([a-z]{2,3})\b/i.exec(output);
11
+ if (match?.[1] === undefined) {
12
+ throw new FailureError('whisper did not report a detected language; the output format may have changed');
13
+ }
14
+ return match[1].toLowerCase();
15
+ }
16
+ /**
17
+ * Pure parser for whisper-cli's "-oj" JSON output.
18
+ *
19
+ * Whisper prefixes every segment's text with a leading space, and emits
20
+ * silent stretches of audio as a segment with blank (or whitespace-only)
21
+ * text. Trimming and dropping those here keeps the quirk out of the
22
+ * database, where it would otherwise resurface in every export and every
23
+ * prompt built from stored segments.
24
+ */
25
+ export function parseWhisperJson(raw) {
26
+ const parsed = JSON.parse(raw);
27
+ if (!Array.isArray(parsed.transcription)) {
28
+ throw new FailureError('whisper produced no "transcription" array; the output format changed');
29
+ }
30
+ const segments = [];
31
+ for (const entry of parsed.transcription) {
32
+ const text = (entry.text ?? '').trim();
33
+ if (text === '')
34
+ continue;
35
+ segments.push({
36
+ startMs: entry.offsets?.from ?? 0,
37
+ endMs: entry.offsets?.to ?? 0,
38
+ text,
39
+ });
40
+ }
41
+ return { language: parsed.result?.language ?? 'unknown', segments };
42
+ }
43
+ /**
44
+ * Builds the whisper-cli argument array for one transcription run.
45
+ *
46
+ * NOT VERIFIED AGAINST A REAL BUILD: these flags (-m model path, -f input
47
+ * file, -l language or "auto", -oj JSON output, -of output base path) are
48
+ * written against whisper.cpp's documented command-line interface. No
49
+ * whisper-cli binary is available in this environment to confirm them
50
+ * against an actual build. The end-to-end suite runs against a real binary
51
+ * and is where this argument list gets confirmed; if a flag turns out to
52
+ * differ there, fix it here in this one place.
53
+ */
54
+ function buildWhisperArgs(modelPath, audioPath, language, outputBase) {
55
+ return ['-m', modelPath, '-f', audioPath, '-l', language ?? 'auto', '-oj', '-of', outputBase];
56
+ }
57
+ export class WhisperCppProvider {
58
+ options;
59
+ name = 'whisper-cpp';
60
+ capabilities = {
61
+ maxBytes: null,
62
+ supportsDiarization: false,
63
+ supportsLanguageHint: true,
64
+ supportsLanguageDetection: true,
65
+ };
66
+ runner;
67
+ readFile;
68
+ constructor(options) {
69
+ this.options = options;
70
+ this.runner = options.runner ?? defaultRunner;
71
+ this.readFile = options.readFile ?? ((path) => readFileFromDisk(path, 'utf8'));
72
+ }
73
+ async transcribe(audioPath, opts) {
74
+ // whisper-cli writes <outputBase>.json rather than printing to stdout.
75
+ // Derived from the filename component only (node:path), not a bare regex
76
+ // on the whole path: a regex anchored on "last dot in the string" matches
77
+ // a dot inside a directory name too, so an extension-less file inside a
78
+ // directory like "ailoud-1.2" would collapse to a sibling path outside that
79
+ // directory and silently collide with another recording's output.
80
+ // NOT VERIFIED AGAINST A REAL BUILD: that whisper-cli writes exactly
81
+ // "<outputBase>.json" (not, say, "<outputBase>.json.txt" or a name that
82
+ // depends on other flags) is likewise taken from documentation, not
83
+ // confirmed against a real run; see the warning on buildWhisperArgs.
84
+ const outputBase = join(dirname(audioPath), basename(audioPath, extname(audioPath)));
85
+ const modelPath = opts.model ?? this.options.modelPath;
86
+ const args = buildWhisperArgs(modelPath, audioPath, opts.language, outputBase);
87
+ // Six hours, not the run helper's half-hour default: a long recording on
88
+ // CPU-only whisper is genuinely slow, and the default would kill real work.
89
+ const result = await this.runner(this.options.binary, args, { timeoutMs: 6 * 60 * 60_000 });
90
+ if (result.code !== 0) {
91
+ throw new FailureError(`whisper failed: ${result.stderr.trim() || `exit ${result.code}`}`);
92
+ }
93
+ const outputPath = `${outputBase}.json`;
94
+ let raw;
95
+ try {
96
+ raw = await this.readFile(outputPath);
97
+ }
98
+ catch (error) {
99
+ const reason = error instanceof Error ? error.message : String(error);
100
+ throw new FailureError(`whisper reported success but ${outputPath} could not be read: ${reason}`);
101
+ }
102
+ const parsed = parseWhisperJson(raw);
103
+ return { ...parsed, model: basename(modelPath) };
104
+ }
105
+ async detectLanguage(audioPath, opts = {}) {
106
+ // -dl exits after detecting, without transcribing. It costs about the
107
+ // same as a short transcription because almost all of it is loading the
108
+ // model; detection itself does not grow with clip length.
109
+ const modelPath = opts.model ?? this.options.modelPath;
110
+ const result = await this.runner(this.options.binary, ['-m', modelPath, '-f', audioPath, '-dl'], { timeoutMs: 10 * 60_000 });
111
+ if (result.code !== 0) {
112
+ throw new FailureError(`whisper could not detect a language: ${result.stderr.trim() || `exit ${result.code}`}`);
113
+ }
114
+ return parseDetectedLanguage(`${result.stdout}\n${result.stderr}`);
115
+ }
116
+ }
@@ -0,0 +1,14 @@
1
+ import type { Fs, TempDir, TempFile } from '@ailoud/core';
2
+ export declare class NodeFs implements Fs {
3
+ exists(path: string): Promise<boolean>;
4
+ ensureDir(path: string): Promise<void>;
5
+ sha256(path: string): Promise<string>;
6
+ copyFile(source: string, destination: string): Promise<void>;
7
+ removeFile(path: string): Promise<void>;
8
+ listFiles(directory: string): Promise<string[]>;
9
+ isDirectory(path: string): Promise<boolean>;
10
+ tempFile(extension: string): Promise<TempFile>;
11
+ tempDir(): Promise<TempDir>;
12
+ writeTextFile(path: string, content: string): Promise<void>;
13
+ readTextFile(path: string): Promise<string>;
14
+ }
@@ -0,0 +1,80 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { createReadStream } from 'node:fs';
3
+ import { copyFile, mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ /** True for the one stat failure that legitimately means "does not exist". */
7
+ function isNotFound(error) {
8
+ return error.code === 'ENOENT';
9
+ }
10
+ export class NodeFs {
11
+ async exists(path) {
12
+ try {
13
+ await stat(path);
14
+ return true;
15
+ }
16
+ catch (error) {
17
+ if (isNotFound(error))
18
+ return false;
19
+ throw error; // permission-denied and friends are real errors, not "missing"
20
+ }
21
+ }
22
+ async ensureDir(path) {
23
+ await mkdir(path, { recursive: true });
24
+ }
25
+ async sha256(path) {
26
+ const hash = createHash('sha256');
27
+ // node:fs's read stream types its chunk as `string | Buffer` because the
28
+ // encoding option can request strings; this stream never sets one, so
29
+ // the chunk is always a Buffer.
30
+ for await (const chunk of createReadStream(path))
31
+ hash.update(chunk);
32
+ return hash.digest('hex');
33
+ }
34
+ async copyFile(source, destination) {
35
+ await copyFile(source, destination);
36
+ }
37
+ async removeFile(path) {
38
+ // force: a file already gone is the outcome the caller asked for, and
39
+ // failing on it would make deletion non-idempotent for no gain.
40
+ await rm(path, { force: true });
41
+ }
42
+ async listFiles(directory) {
43
+ const entries = await readdir(directory, { withFileTypes: true });
44
+ return entries
45
+ .filter((e) => e.isFile())
46
+ .map((e) => join(directory, e.name))
47
+ .sort();
48
+ }
49
+ async isDirectory(path) {
50
+ try {
51
+ return (await stat(path)).isDirectory();
52
+ }
53
+ catch (error) {
54
+ if (isNotFound(error))
55
+ return false;
56
+ throw error;
57
+ }
58
+ }
59
+ async tempFile(extension) {
60
+ const dir = await mkdtemp(join(tmpdir(), 'ailoud-'));
61
+ const path = join(dir, `audio${extension}`);
62
+ return {
63
+ path,
64
+ // Removes the whole directory, not just this file: a provider (for
65
+ // example whisper-cli) can write other output alongside it, and that
66
+ // must not outlive the temp file it was derived from.
67
+ remove: () => rm(dir, { force: true, recursive: true }),
68
+ };
69
+ }
70
+ async tempDir() {
71
+ const dir = await mkdtemp(join(tmpdir(), 'ailoud-'));
72
+ return { path: dir, remove: () => rm(dir, { force: true, recursive: true }) };
73
+ }
74
+ async writeTextFile(path, content) {
75
+ await writeFile(path, content, 'utf8');
76
+ }
77
+ async readTextFile(path) {
78
+ return readFile(path, 'utf8');
79
+ }
80
+ }
@@ -0,0 +1,20 @@
1
+ import type { Clock, Ids } from '@ailoud/core';
2
+ export declare class SystemClock implements Clock {
3
+ nowIso(): string;
4
+ }
5
+ /**
6
+ * Monotonic ULID generator.
7
+ *
8
+ * `encodeUlid(Date.now(), randomBytes)` alone can invert two ids minted in
9
+ * the same millisecond, since they then differ only by independent random
10
+ * draws. This class remembers the last timestamp and random block; when
11
+ * `Date.now()` has not advanced past it, it reuses that millisecond and
12
+ * increments the previous random block as a big-endian integer instead of
13
+ * drawing fresh randomness, so same-millisecond ids still sort in the order
14
+ * they were generated.
15
+ */
16
+ export declare class UlidIds implements Ids {
17
+ private lastTimeMs;
18
+ private lastRandom;
19
+ next(): string;
20
+ }
@@ -0,0 +1,55 @@
1
+ import { encodeUlid } from '@ailoud/core';
2
+ export class SystemClock {
3
+ nowIso() {
4
+ return new Date().toISOString();
5
+ }
6
+ }
7
+ const RANDOM_BYTES = 10;
8
+ /**
9
+ * Monotonic ULID generator.
10
+ *
11
+ * `encodeUlid(Date.now(), randomBytes)` alone can invert two ids minted in
12
+ * the same millisecond, since they then differ only by independent random
13
+ * draws. This class remembers the last timestamp and random block; when
14
+ * `Date.now()` has not advanced past it, it reuses that millisecond and
15
+ * increments the previous random block as a big-endian integer instead of
16
+ * drawing fresh randomness, so same-millisecond ids still sort in the order
17
+ * they were generated.
18
+ */
19
+ export class UlidIds {
20
+ lastTimeMs = -1;
21
+ lastRandom = new Uint8Array(RANDOM_BYTES);
22
+ next() {
23
+ const now = Date.now();
24
+ if (now > this.lastTimeMs) {
25
+ this.lastTimeMs = now;
26
+ this.lastRandom = randomBytes();
27
+ }
28
+ else {
29
+ incrementBigEndian(this.lastRandom);
30
+ }
31
+ return encodeUlid(this.lastTimeMs, this.lastRandom);
32
+ }
33
+ }
34
+ // No return-type annotation: `Uint8Array` unparameterized defaults to the
35
+ // wider `Uint8Array<ArrayBufferLike>`, whereas the type TypeScript infers
36
+ // here from `crypto.getRandomValues(new Uint8Array(n))` is the narrower
37
+ // `Uint8Array<ArrayBuffer>` -- annotating this would widen it right back.
38
+ function randomBytes() {
39
+ return crypto.getRandomValues(new Uint8Array(RANDOM_BYTES));
40
+ }
41
+ function incrementBigEndian(bytes) {
42
+ for (let i = bytes.length - 1; i >= 0; i -= 1) {
43
+ const value = bytes[i];
44
+ if (value === undefined)
45
+ continue;
46
+ if (value === 255) {
47
+ bytes[i] = 0;
48
+ continue;
49
+ }
50
+ bytes[i] = value + 1;
51
+ return;
52
+ }
53
+ // All 80 bits wrapped to zero: would need 2**80 ids minted within a single
54
+ // millisecond, which is not reachable in practice.
55
+ }
@@ -0,0 +1,14 @@
1
+ import type { SpeechSegmenter, SpeechSpan } from '@ailoud/core';
2
+ import { run as defaultRunner } from '../process/run.js';
3
+ export declare function parseVadSegments(output: string): SpeechSpan[];
4
+ export interface WhisperVadOptions {
5
+ readonly binary: string;
6
+ readonly vadModelPath: string;
7
+ readonly runner?: typeof defaultRunner;
8
+ }
9
+ export declare class WhisperVadSegmenter implements SpeechSegmenter {
10
+ private readonly options;
11
+ private readonly runner;
12
+ constructor(options: WhisperVadOptions);
13
+ segments(audioPath: string): Promise<SpeechSpan[]>;
14
+ }
@@ -0,0 +1,58 @@
1
+ import { FailureError } from '@ailoud/core';
2
+ import { run as defaultRunner } from '../process/run.js';
3
+ // Explicit, not left to fall through to the run helper's own default, even
4
+ // though the two currently happen to be the same 30 minutes: segmentation
5
+ // reads the whole recording once and is comparatively fast, so it does not
6
+ // need whisper-cli's own six-hour transcription ceiling (see whisperCpp.ts).
7
+ // Spelling it out here means a future change to the helper's default cannot
8
+ // silently change this call's timeout out from under it.
9
+ const VAD_TIMEOUT_MS = 30 * 60_000;
10
+ /**
11
+ * Matches one segment line from whisper-vad-speech-segments' stdout.
12
+ *
13
+ * NOT the shape the task brief assumed ("0.00 - 175.00"): a real run against
14
+ * fixtures/mixed-short.wav with -np produced lines shaped like
15
+ * "Speech segment 0: start = 0.00, end = 346.00", preceded by a
16
+ * "Detected N speech segments:" header. Both captures are centiseconds per
17
+ * the tool's own help text, so both are multiplied by ten to reach
18
+ * milliseconds.
19
+ */
20
+ const SEGMENT_LINE = /^\s*Speech segment \d+:\s*start\s*=\s*(\d+(?:\.\d+)?)\s*,\s*end\s*=\s*(\d+(?:\.\d+)?)\s*$/;
21
+ export function parseVadSegments(output) {
22
+ const spans = [];
23
+ for (const line of output.split('\n')) {
24
+ const match = SEGMENT_LINE.exec(line);
25
+ if (match?.[1] === undefined || match[2] === undefined)
26
+ continue;
27
+ spans.push({
28
+ startMs: Math.round(Number(match[1]) * 10),
29
+ endMs: Math.round(Number(match[2]) * 10),
30
+ });
31
+ }
32
+ return spans;
33
+ }
34
+ export class WhisperVadSegmenter {
35
+ options;
36
+ runner;
37
+ constructor(options) {
38
+ this.options = options;
39
+ this.runner = options.runner ?? defaultRunner;
40
+ }
41
+ async segments(audioPath) {
42
+ const result = await this.runner(this.options.binary, ['-f', audioPath, '-vm', this.options.vadModelPath, '-np'], { timeoutMs: VAD_TIMEOUT_MS });
43
+ if (result.code !== 0) {
44
+ throw new FailureError(`speech segmentation failed: ${result.stderr.trim() || `exit ${result.code}`}`);
45
+ }
46
+ const spans = parseVadSegments(result.stdout);
47
+ if (spans.length === 0) {
48
+ // Not `audioPath`: that names the pipeline's own scratch wav, which
49
+ // `Fs.tempFile`'s cleanup has already removed by the time this
50
+ // message reaches a user -- pointing them at a directory that no
51
+ // longer exists. Nothing else here identifies the source recording
52
+ // (see transcribe.ts's own "no speech" message for that), so this
53
+ // stays generic instead of naming a path at all.
54
+ throw new FailureError('no speech found; transcribe it without --multilingual');
55
+ }
56
+ return spans;
57
+ }
58
+ }
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "@ailoud/providers",
3
+ "version": "1.0.0-dev.1",
4
+ "type": "module",
5
+ "main": "./dist/index.js",
6
+ "types": "./dist/index.d.ts",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./dist/index.d.ts",
10
+ "default": "./dist/index.js"
11
+ }
12
+ },
13
+ "dependencies": {
14
+ "@ailoud/core": "1.0.0-dev.1"
15
+ },
16
+ "description": "AILoud port implementations: ffmpeg, sqlite, whisper.cpp, and the LLM adapters",
17
+ "author": "Lorem Dev <contact@lorem.dev>",
18
+ "license": "Apache-2.0",
19
+ "homepage": "https://lorem-dev.github.io/ailoud/",
20
+ "bugs": {
21
+ "url": "https://github.com/lorem-dev/ailoud/issues"
22
+ },
23
+ "engines": {
24
+ "node": ">=24"
25
+ },
26
+ "keywords": [
27
+ "ailoud",
28
+ "whisper",
29
+ "ffmpeg",
30
+ "sqlite"
31
+ ],
32
+ "repository": {
33
+ "type": "git",
34
+ "url": "git+https://github.com/lorem-dev/ailoud.git",
35
+ "directory": "packages/providers"
36
+ },
37
+ "files": [
38
+ "dist",
39
+ "!dist/**/*.test.*"
40
+ ],
41
+ "publishConfig": {
42
+ "access": "public"
43
+ },
44
+ "scripts": {
45
+ "build": "tsc -b tsconfig.build.json",
46
+ "typecheck": "tsc -p tsconfig.json --noEmit"
47
+ }
48
+ }