@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.
- package/LICENSE +224 -0
- package/dist/.tsbuildinfo +1 -0
- package/dist/audio/ffmpeg.d.ts +12 -0
- package/dist/audio/ffmpeg.js +81 -0
- package/dist/diarize/sherpaDiarizer.d.ts +26 -0
- package/dist/diarize/sherpaDiarizer.js +64 -0
- package/dist/index.d.ts +33 -0
- package/dist/index.js +19 -0
- package/dist/llm/anthropic.d.ts +36 -0
- package/dist/llm/anthropic.js +92 -0
- package/dist/llm/claudeCli.d.ts +32 -0
- package/dist/llm/claudeCli.js +63 -0
- package/dist/llm/llamaCpp.d.ts +38 -0
- package/dist/llm/llamaCpp.js +90 -0
- package/dist/llm/models.d.ts +23 -0
- package/dist/llm/models.js +97 -0
- package/dist/llm/openAiCompatible.d.ts +35 -0
- package/dist/llm/openAiCompatible.js +84 -0
- package/dist/process/pager.d.ts +36 -0
- package/dist/process/pager.js +69 -0
- package/dist/process/run.d.ts +35 -0
- package/dist/process/run.js +101 -0
- package/dist/provision/download.d.ts +19 -0
- package/dist/provision/download.js +80 -0
- package/dist/provision/llamaInstall.d.ts +38 -0
- package/dist/provision/llamaInstall.js +75 -0
- package/dist/provision/packageManager.d.ts +50 -0
- package/dist/provision/packageManager.js +66 -0
- package/dist/provision/sherpaInstall.d.ts +34 -0
- package/dist/provision/sherpaInstall.js +78 -0
- package/dist/provision/whisperInstall.d.ts +61 -0
- package/dist/provision/whisperInstall.js +89 -0
- package/dist/store/sqliteStore.d.ts +64 -0
- package/dist/store/sqliteStore.js +433 -0
- package/dist/stt/whisperCpp.d.ts +50 -0
- package/dist/stt/whisperCpp.js +116 -0
- package/dist/system/nodeFs.d.ts +14 -0
- package/dist/system/nodeFs.js +80 -0
- package/dist/system/systemClock.d.ts +20 -0
- package/dist/system/systemClock.js +55 -0
- package/dist/vad/whisperVad.d.ts +14 -0
- package/dist/vad/whisperVad.js +58 -0
- 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
|
+
}
|