escribano 0.1.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 +297 -0
- package/dist/0_types.js +279 -0
- package/dist/actions/classify-session.js +77 -0
- package/dist/actions/create-contexts.js +44 -0
- package/dist/actions/create-topic-blocks.js +68 -0
- package/dist/actions/extract-metadata.js +24 -0
- package/dist/actions/generate-artifact-v3.js +296 -0
- package/dist/actions/generate-artifact.js +61 -0
- package/dist/actions/generate-summary-v3.js +260 -0
- package/dist/actions/outline-index.js +204 -0
- package/dist/actions/process-recording-v2.js +494 -0
- package/dist/actions/process-recording-v3.js +412 -0
- package/dist/actions/process-session.js +183 -0
- package/dist/actions/publish-summary-v3.js +303 -0
- package/dist/actions/sync-to-outline.js +196 -0
- package/dist/adapters/audio.silero.adapter.js +69 -0
- package/dist/adapters/cap.adapter.js +94 -0
- package/dist/adapters/capture.cap.adapter.js +107 -0
- package/dist/adapters/capture.filesystem.adapter.js +124 -0
- package/dist/adapters/embedding.ollama.adapter.js +141 -0
- package/dist/adapters/intelligence.adapter.js +202 -0
- package/dist/adapters/intelligence.mlx.adapter.js +395 -0
- package/dist/adapters/intelligence.ollama.adapter.js +741 -0
- package/dist/adapters/publishing.outline.adapter.js +75 -0
- package/dist/adapters/storage.adapter.js +81 -0
- package/dist/adapters/storage.fs.adapter.js +83 -0
- package/dist/adapters/transcription.whisper.adapter.js +206 -0
- package/dist/adapters/video.ffmpeg.adapter.js +405 -0
- package/dist/adapters/whisper.adapter.js +168 -0
- package/dist/batch-context.js +329 -0
- package/dist/db/helpers.js +50 -0
- package/dist/db/index.js +95 -0
- package/dist/db/migrate.js +80 -0
- package/dist/db/repositories/artifact.sqlite.js +77 -0
- package/dist/db/repositories/cluster.sqlite.js +92 -0
- package/dist/db/repositories/context.sqlite.js +75 -0
- package/dist/db/repositories/index.js +10 -0
- package/dist/db/repositories/observation.sqlite.js +70 -0
- package/dist/db/repositories/recording.sqlite.js +56 -0
- package/dist/db/repositories/subject.sqlite.js +64 -0
- package/dist/db/repositories/topic-block.sqlite.js +45 -0
- package/dist/db/types.js +4 -0
- package/dist/domain/classification.js +60 -0
- package/dist/domain/context.js +97 -0
- package/dist/domain/index.js +2 -0
- package/dist/domain/observation.js +17 -0
- package/dist/domain/recording.js +41 -0
- package/dist/domain/segment.js +93 -0
- package/dist/domain/session.js +93 -0
- package/dist/domain/time-range.js +38 -0
- package/dist/domain/transcript.js +79 -0
- package/dist/index.js +173 -0
- package/dist/pipeline/context.js +162 -0
- package/dist/pipeline/events.js +2 -0
- package/dist/prerequisites.js +226 -0
- package/dist/scripts/rebuild-index.js +53 -0
- package/dist/scripts/seed-fixtures.js +290 -0
- package/dist/services/activity-segmentation.js +333 -0
- package/dist/services/activity-segmentation.test.js +191 -0
- package/dist/services/app-normalization.js +212 -0
- package/dist/services/cluster-merge.js +69 -0
- package/dist/services/clustering.js +237 -0
- package/dist/services/debug.js +58 -0
- package/dist/services/frame-sampling.js +318 -0
- package/dist/services/signal-extraction.js +106 -0
- package/dist/services/subject-grouping.js +342 -0
- package/dist/services/temporal-alignment.js +99 -0
- package/dist/services/vlm-enrichment.js +84 -0
- package/dist/services/vlm-service.js +130 -0
- package/dist/stats/index.js +3 -0
- package/dist/stats/observer.js +65 -0
- package/dist/stats/repository.js +36 -0
- package/dist/stats/resource-tracker.js +86 -0
- package/dist/stats/types.js +1 -0
- package/dist/test-classification-prompts.js +181 -0
- package/dist/tests/cap.adapter.test.js +75 -0
- package/dist/tests/capture.cap.adapter.test.js +69 -0
- package/dist/tests/classify-session.test.js +140 -0
- package/dist/tests/db/repositories.test.js +243 -0
- package/dist/tests/domain/time-range.test.js +31 -0
- package/dist/tests/integration.test.js +84 -0
- package/dist/tests/intelligence.adapter.test.js +102 -0
- package/dist/tests/intelligence.ollama.adapter.test.js +178 -0
- package/dist/tests/process-v2.test.js +90 -0
- package/dist/tests/services/clustering.test.js +112 -0
- package/dist/tests/services/frame-sampling.test.js +152 -0
- package/dist/tests/utils/ocr.test.js +76 -0
- package/dist/tests/utils/parallel.test.js +57 -0
- package/dist/tests/visual-observer.test.js +175 -0
- package/dist/utils/id-normalization.js +15 -0
- package/dist/utils/index.js +9 -0
- package/dist/utils/model-detector.js +154 -0
- package/dist/utils/ocr.js +80 -0
- package/dist/utils/parallel.js +32 -0
- package/migrations/001_initial.sql +109 -0
- package/migrations/002_clusters.sql +41 -0
- package/migrations/003_observations_vlm_fields.sql +14 -0
- package/migrations/004_observations_unique.sql +18 -0
- package/migrations/005_processing_stats.sql +29 -0
- package/migrations/006_vlm_raw_response.sql +6 -0
- package/migrations/007_subjects.sql +23 -0
- package/migrations/008_artifacts_recording.sql +6 -0
- package/migrations/009_artifact_subjects.sql +10 -0
- package/package.json +82 -0
- package/prompts/action-items.md +55 -0
- package/prompts/blog-draft.md +54 -0
- package/prompts/blog-research.md +87 -0
- package/prompts/card.md +54 -0
- package/prompts/classify-segment.md +38 -0
- package/prompts/classify.md +37 -0
- package/prompts/code-snippets.md +163 -0
- package/prompts/extract-metadata.md +149 -0
- package/prompts/notes.md +83 -0
- package/prompts/runbook.md +123 -0
- package/prompts/standup.md +50 -0
- package/prompts/step-by-step.md +125 -0
- package/prompts/subject-grouping.md +31 -0
- package/prompts/summary-v3.md +89 -0
- package/prompts/summary.md +77 -0
- package/prompts/topic-classifier.md +24 -0
- package/prompts/topic-extract.md +13 -0
- package/prompts/vlm-batch.md +21 -0
- package/prompts/vlm-single.md +19 -0
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Escribano - Outline Publishing Adapter
|
|
3
|
+
*
|
|
4
|
+
* Implements PublishingService for Outline wiki.
|
|
5
|
+
*/
|
|
6
|
+
export function createOutlinePublishingService(config) {
|
|
7
|
+
const baseUrl = config.url.replace(/\/$/, '');
|
|
8
|
+
async function apiCall(endpoint, body) {
|
|
9
|
+
const response = await fetch(`${baseUrl}/api/${endpoint}`, {
|
|
10
|
+
method: 'POST',
|
|
11
|
+
headers: {
|
|
12
|
+
Authorization: `Bearer ${config.token}`,
|
|
13
|
+
'Content-Type': 'application/json',
|
|
14
|
+
},
|
|
15
|
+
body: JSON.stringify(body),
|
|
16
|
+
});
|
|
17
|
+
const data = await response.json();
|
|
18
|
+
if (!response.ok) {
|
|
19
|
+
throw new Error(`Outline API error: ${response.status} ${JSON.stringify(data)}`);
|
|
20
|
+
}
|
|
21
|
+
return data;
|
|
22
|
+
}
|
|
23
|
+
return {
|
|
24
|
+
async ensureCollection(name) {
|
|
25
|
+
const list = await apiCall('collections.list', {});
|
|
26
|
+
const existing = list.data.find((c) => c.name === name);
|
|
27
|
+
if (existing) {
|
|
28
|
+
return { id: existing.id };
|
|
29
|
+
}
|
|
30
|
+
const created = await apiCall('collections.create', { name });
|
|
31
|
+
return { id: created.data.id };
|
|
32
|
+
},
|
|
33
|
+
async createDocument(params) {
|
|
34
|
+
const result = await apiCall('documents.create', {
|
|
35
|
+
collectionId: params.collectionId,
|
|
36
|
+
parentDocumentId: params.parentDocumentId,
|
|
37
|
+
title: params.title,
|
|
38
|
+
text: params.content,
|
|
39
|
+
publish: params.publish ?? true,
|
|
40
|
+
});
|
|
41
|
+
return {
|
|
42
|
+
id: result.data.id,
|
|
43
|
+
url: `${baseUrl}/doc/${result.data.id}`,
|
|
44
|
+
};
|
|
45
|
+
},
|
|
46
|
+
async updateDocument(id, params) {
|
|
47
|
+
await apiCall('documents.update', {
|
|
48
|
+
id,
|
|
49
|
+
title: params.title,
|
|
50
|
+
text: params.content,
|
|
51
|
+
});
|
|
52
|
+
},
|
|
53
|
+
async findDocumentByTitle(collectionId, title) {
|
|
54
|
+
// Note: Outline search or list could be used. list is safer for exact title match in collection.
|
|
55
|
+
const list = await apiCall('documents.list', { collectionId });
|
|
56
|
+
const doc = list.data.find((d) => d.title === title);
|
|
57
|
+
if (doc) {
|
|
58
|
+
return {
|
|
59
|
+
id: doc.id,
|
|
60
|
+
url: `${baseUrl}/doc/${doc.id}`,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
return null;
|
|
64
|
+
},
|
|
65
|
+
async listDocuments(collectionId) {
|
|
66
|
+
const list = await apiCall('documents.list', { collectionId });
|
|
67
|
+
return list.data.map((d) => ({
|
|
68
|
+
id: d.id,
|
|
69
|
+
title: d.title,
|
|
70
|
+
parentDocumentId: d.parentDocumentId,
|
|
71
|
+
url: `${baseUrl}/doc/${d.id}`,
|
|
72
|
+
}));
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Escribano - Storage Adapter
|
|
3
|
+
*
|
|
4
|
+
* Saves and loads sessions from filesystem
|
|
5
|
+
*/
|
|
6
|
+
import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises';
|
|
7
|
+
import os from 'node:os';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
const SESSIONS_DIR = join(os.homedir(), '.escribano', 'sessions');
|
|
10
|
+
export function createStorageService() {
|
|
11
|
+
return {
|
|
12
|
+
saveSession,
|
|
13
|
+
loadSession,
|
|
14
|
+
listSessions,
|
|
15
|
+
saveArtifact,
|
|
16
|
+
loadArtifacts,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
async function ensureSessionsDir() {
|
|
20
|
+
await mkdir(SESSIONS_DIR, { recursive: true });
|
|
21
|
+
}
|
|
22
|
+
async function saveSession(session) {
|
|
23
|
+
await ensureSessionsDir();
|
|
24
|
+
const sessionPath = join(SESSIONS_DIR, `${session.id}.json`);
|
|
25
|
+
await writeFile(sessionPath, JSON.stringify(session, null, 2), 'utf-8');
|
|
26
|
+
}
|
|
27
|
+
async function loadSession(sessionId) {
|
|
28
|
+
await ensureSessionsDir();
|
|
29
|
+
const sessionPath = join(SESSIONS_DIR, `${sessionId}.json`);
|
|
30
|
+
try {
|
|
31
|
+
const content = await readFile(sessionPath, 'utf-8');
|
|
32
|
+
return JSON.parse(content);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
async function listSessions() {
|
|
39
|
+
await ensureSessionsDir();
|
|
40
|
+
const files = await readdir(SESSIONS_DIR);
|
|
41
|
+
const jsonFiles = files.filter((file) => file.endsWith('.json'));
|
|
42
|
+
const sessions = [];
|
|
43
|
+
for (const file of jsonFiles) {
|
|
44
|
+
const content = await readFile(join(SESSIONS_DIR, file), 'utf-8');
|
|
45
|
+
sessions.push(JSON.parse(content));
|
|
46
|
+
}
|
|
47
|
+
return sessions;
|
|
48
|
+
}
|
|
49
|
+
async function saveArtifact(sessionId, artifact) {
|
|
50
|
+
const artifactsDir = join(SESSIONS_DIR, sessionId, 'artifacts');
|
|
51
|
+
await mkdir(artifactsDir, { recursive: true });
|
|
52
|
+
const timestamp = new Date().toISOString().replace(/:/g, '-').split('.')[0];
|
|
53
|
+
const filename = `${artifact.type}-${timestamp}.${artifact.format}`;
|
|
54
|
+
const artifactPath = join(artifactsDir, filename);
|
|
55
|
+
await writeFile(artifactPath, artifact.content, 'utf-8');
|
|
56
|
+
}
|
|
57
|
+
async function loadArtifacts(sessionId) {
|
|
58
|
+
const artifactsDir = join(SESSIONS_DIR, sessionId, 'artifacts');
|
|
59
|
+
try {
|
|
60
|
+
const files = await readdir(artifactsDir);
|
|
61
|
+
const artifacts = [];
|
|
62
|
+
for (const file of files) {
|
|
63
|
+
const content = await readFile(join(artifactsDir, file), 'utf-8');
|
|
64
|
+
const match = file.match(/^(\w+)-(.+)\.md$/);
|
|
65
|
+
if (!match)
|
|
66
|
+
continue;
|
|
67
|
+
const [, type] = match;
|
|
68
|
+
artifacts.push({
|
|
69
|
+
id: `${sessionId}-${file.replace('.md', '')}`,
|
|
70
|
+
type: type,
|
|
71
|
+
content,
|
|
72
|
+
format: 'markdown',
|
|
73
|
+
createdAt: new Date(),
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
return artifacts;
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return [];
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Escribano - Storage Adapter
|
|
3
|
+
*
|
|
4
|
+
* Saves and loads sessions from filesystem
|
|
5
|
+
*/
|
|
6
|
+
import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises';
|
|
7
|
+
import os from 'node:os';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
const SESSIONS_DIR = join(os.homedir(), '.escribano', 'sessions');
|
|
10
|
+
export function createFsStorageService() {
|
|
11
|
+
return {
|
|
12
|
+
saveSession,
|
|
13
|
+
loadSession,
|
|
14
|
+
listSessions,
|
|
15
|
+
saveArtifact,
|
|
16
|
+
loadArtifacts,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
async function ensureSessionsDir() {
|
|
20
|
+
await mkdir(SESSIONS_DIR, { recursive: true });
|
|
21
|
+
}
|
|
22
|
+
async function saveSession(session) {
|
|
23
|
+
await ensureSessionsDir();
|
|
24
|
+
const sessionPath = join(SESSIONS_DIR, `${session.id}.json`);
|
|
25
|
+
await writeFile(sessionPath, JSON.stringify(session, null, 2), 'utf-8');
|
|
26
|
+
}
|
|
27
|
+
async function loadSession(sessionId) {
|
|
28
|
+
await ensureSessionsDir();
|
|
29
|
+
const sessionPath = join(SESSIONS_DIR, `${sessionId}.json`);
|
|
30
|
+
try {
|
|
31
|
+
const content = await readFile(sessionPath, 'utf-8');
|
|
32
|
+
return JSON.parse(content);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
async function listSessions() {
|
|
39
|
+
await ensureSessionsDir();
|
|
40
|
+
const files = await readdir(SESSIONS_DIR);
|
|
41
|
+
const jsonFiles = files.filter((file) => file.endsWith('.json'));
|
|
42
|
+
const sessions = [];
|
|
43
|
+
for (const file of jsonFiles) {
|
|
44
|
+
const content = await readFile(join(SESSIONS_DIR, file), 'utf-8');
|
|
45
|
+
sessions.push(JSON.parse(content));
|
|
46
|
+
}
|
|
47
|
+
// Sort by date descending (newest first)
|
|
48
|
+
return sessions.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
|
49
|
+
}
|
|
50
|
+
async function saveArtifact(sessionId, artifact) {
|
|
51
|
+
const artifactsDir = join(SESSIONS_DIR, sessionId, 'artifacts');
|
|
52
|
+
await mkdir(artifactsDir, { recursive: true });
|
|
53
|
+
const timestamp = new Date().toISOString().replace(/:/g, '-').split('.')[0];
|
|
54
|
+
const extension = artifact.format === 'markdown' ? 'md' : artifact.format;
|
|
55
|
+
const filename = `${artifact.type}-${timestamp}.${extension}`;
|
|
56
|
+
const artifactPath = join(artifactsDir, filename);
|
|
57
|
+
await writeFile(artifactPath, artifact.content, 'utf-8');
|
|
58
|
+
}
|
|
59
|
+
async function loadArtifacts(sessionId) {
|
|
60
|
+
const artifactsDir = join(SESSIONS_DIR, sessionId, 'artifacts');
|
|
61
|
+
try {
|
|
62
|
+
const files = await readdir(artifactsDir);
|
|
63
|
+
const artifacts = [];
|
|
64
|
+
for (const file of files) {
|
|
65
|
+
const content = await readFile(join(artifactsDir, file), 'utf-8');
|
|
66
|
+
const match = file.match(/^(\w+)-(.+)\.md$/);
|
|
67
|
+
if (!match)
|
|
68
|
+
continue;
|
|
69
|
+
const [, type] = match;
|
|
70
|
+
artifacts.push({
|
|
71
|
+
id: `${sessionId}-${file.replace('.md', '')}`,
|
|
72
|
+
type: type,
|
|
73
|
+
content,
|
|
74
|
+
format: 'markdown',
|
|
75
|
+
createdAt: new Date(),
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
return artifacts;
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
return [];
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whisper Adapter
|
|
3
|
+
*
|
|
4
|
+
* Transcribes audio using whisper.cpp or OpenAI's whisper CLI.
|
|
5
|
+
* Shells out to the whisper binary for simplicity.
|
|
6
|
+
*
|
|
7
|
+
* Prerequisites:
|
|
8
|
+
* - whisper.cpp installed: brew install whisper-cpp
|
|
9
|
+
* - ffmpeg installed: brew install ffmpeg (for audio format conversion)
|
|
10
|
+
* - Or Python whisper: pip install openai-whisper
|
|
11
|
+
*/
|
|
12
|
+
import { exec } from 'node:child_process';
|
|
13
|
+
import { readFile, unlink } from 'node:fs/promises';
|
|
14
|
+
import { promisify } from 'node:util';
|
|
15
|
+
const execAsync = promisify(exec);
|
|
16
|
+
const HALLUCINATION_PATTERNS = [
|
|
17
|
+
/untertitel.*amara\.org/i,
|
|
18
|
+
/www\.amara\.org/i,
|
|
19
|
+
/thanks for watching/i,
|
|
20
|
+
/please subscribe/i,
|
|
21
|
+
/like and subscribe/i,
|
|
22
|
+
/(.{20,})\1{4,}/, // Repetition loops
|
|
23
|
+
];
|
|
24
|
+
export function filterHallucinations(text) {
|
|
25
|
+
let filtered = text;
|
|
26
|
+
for (const pattern of HALLUCINATION_PATTERNS) {
|
|
27
|
+
filtered = filtered.replace(pattern, '');
|
|
28
|
+
}
|
|
29
|
+
return filtered.trim();
|
|
30
|
+
}
|
|
31
|
+
async function convertToWavIfNeeded(audioPath) {
|
|
32
|
+
const ext = audioPath.toLowerCase().split('.').pop();
|
|
33
|
+
if (['wav', 'flac', 'mp3'].includes(ext || '')) {
|
|
34
|
+
return audioPath;
|
|
35
|
+
}
|
|
36
|
+
const outputPath = `${audioPath}.converted.wav`;
|
|
37
|
+
try {
|
|
38
|
+
console.log(`Converting ${audioPath} to WAV format...`);
|
|
39
|
+
await execAsync(`ffmpeg -i "${audioPath}" -f wav -ar 16000 -ac 1 "${outputPath}" -y`, { timeout: 10 * 60 * 1000 });
|
|
40
|
+
console.log(`Conversion complete: ${outputPath}`);
|
|
41
|
+
return outputPath;
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
console.error(`Audio conversion failed for ${audioPath}`);
|
|
45
|
+
throw new Error(`Failed to convert audio to WAV: ${error.message}`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Creates a TranscriptionService that uses whisper CLI
|
|
50
|
+
*/
|
|
51
|
+
export function createWhisperTranscriptionService(config = {}) {
|
|
52
|
+
const resolvedConfig = {
|
|
53
|
+
binaryPath: config.binaryPath ?? 'whisper-cpp',
|
|
54
|
+
model: config.model ?? 'base',
|
|
55
|
+
outputFormat: config.outputFormat ?? 'json',
|
|
56
|
+
language: config.language,
|
|
57
|
+
};
|
|
58
|
+
return {
|
|
59
|
+
transcribe: (audioPath) => transcribeWithWhisper(audioPath, resolvedConfig),
|
|
60
|
+
transcribeSegment: async (audioPath) => {
|
|
61
|
+
try {
|
|
62
|
+
const transcript = await transcribeWithWhisper(audioPath, resolvedConfig, { silent: true });
|
|
63
|
+
if (!transcript || !transcript.fullText) {
|
|
64
|
+
return '';
|
|
65
|
+
}
|
|
66
|
+
return filterHallucinations(transcript.fullText);
|
|
67
|
+
}
|
|
68
|
+
catch (error) {
|
|
69
|
+
console.warn(`Whisper segment transcription failed: ${error.message}`);
|
|
70
|
+
return '';
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Transcribe audio file using whisper CLI
|
|
77
|
+
*/
|
|
78
|
+
async function transcribeWithWhisper(audioPath, config, options) {
|
|
79
|
+
const audioToProcess = await convertToWavIfNeeded(audioPath);
|
|
80
|
+
const args = [
|
|
81
|
+
`-m ${config.model}`,
|
|
82
|
+
`-f "${audioToProcess}"`,
|
|
83
|
+
'-oj', // Output JSON
|
|
84
|
+
config.language ? `-l ${config.language}` : '',
|
|
85
|
+
].filter(Boolean);
|
|
86
|
+
const command = `${config.binaryPath} ${args.join(' ')}`;
|
|
87
|
+
try {
|
|
88
|
+
let tick;
|
|
89
|
+
if (!options?.silent) {
|
|
90
|
+
tick = setInterval(() => {
|
|
91
|
+
process.stdout.write('.');
|
|
92
|
+
}, 30000); // Print a dot every 30 seconds to indicate progress
|
|
93
|
+
}
|
|
94
|
+
const { stdout, stderr } = await execAsync(command, {
|
|
95
|
+
cwd: config.cwd,
|
|
96
|
+
maxBuffer: 50 * 1024 * 1024, // 50MB buffer for large transcripts
|
|
97
|
+
timeout: 10 * 60 * 1000, // 10 minute timeout
|
|
98
|
+
});
|
|
99
|
+
if (tick) {
|
|
100
|
+
clearInterval(tick);
|
|
101
|
+
process.stdout.write('\n');
|
|
102
|
+
}
|
|
103
|
+
const hasError = stderr.includes('error:') ||
|
|
104
|
+
stderr.includes('Error:') ||
|
|
105
|
+
stderr.includes('failed to');
|
|
106
|
+
if (hasError) {
|
|
107
|
+
if (audioToProcess !== audioPath) {
|
|
108
|
+
await unlink(audioToProcess).catch(() => { });
|
|
109
|
+
}
|
|
110
|
+
throw new Error(`Whisper transcription failed:\n${stderr}`);
|
|
111
|
+
}
|
|
112
|
+
// whisper-cpp outputs JSON to a file named <input>.json
|
|
113
|
+
const jsonOutputPath = `${audioToProcess}.json`;
|
|
114
|
+
try {
|
|
115
|
+
const jsonContent = await readFile(jsonOutputPath, 'utf-8');
|
|
116
|
+
const whisperOutput = JSON.parse(jsonContent);
|
|
117
|
+
// Clean up the temp JSON file and converted audio
|
|
118
|
+
await unlink(jsonOutputPath).catch(() => { });
|
|
119
|
+
if (audioToProcess !== audioPath) {
|
|
120
|
+
await unlink(audioToProcess).catch(() => { });
|
|
121
|
+
}
|
|
122
|
+
return parseWhisperOutput(whisperOutput);
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
// Fallback: try to parse stdout as the transcript
|
|
126
|
+
return parseWhisperStdout(stdout);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
catch (error) {
|
|
130
|
+
if (audioToProcess && audioToProcess !== audioPath) {
|
|
131
|
+
await unlink(audioToProcess).catch(() => { });
|
|
132
|
+
}
|
|
133
|
+
throw new Error(`Whisper transcription failed: ${error.message}`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Parse whisper.cpp JSON output into our Transcript format
|
|
138
|
+
*/
|
|
139
|
+
function parseWhisperOutput(output) {
|
|
140
|
+
const segments = output.transcription.map((seg, index) => ({
|
|
141
|
+
id: `seg-${index}`,
|
|
142
|
+
start: seg.offsets.from / 1000, // Convert ms to seconds
|
|
143
|
+
end: seg.offsets.to / 1000,
|
|
144
|
+
text: seg.text.trim(),
|
|
145
|
+
speaker: null,
|
|
146
|
+
}));
|
|
147
|
+
const fullText = segments.map((s) => s.text).join(' ');
|
|
148
|
+
const duration = segments.length > 0 ? segments[segments.length - 1].end : 0;
|
|
149
|
+
return {
|
|
150
|
+
fullText,
|
|
151
|
+
segments,
|
|
152
|
+
language: 'en', // whisper.cpp doesn't always report language in JSON
|
|
153
|
+
duration,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Fallback: parse whisper stdout (plain text with timestamps)
|
|
158
|
+
*/
|
|
159
|
+
function parseWhisperStdout(stdout) {
|
|
160
|
+
// Example format: "[00:00:00.000 --> 00:00:05.000] Hello world"
|
|
161
|
+
const lines = stdout.split('\n').filter((l) => l.trim());
|
|
162
|
+
const segments = [];
|
|
163
|
+
const timestampRegex = /\[(\d{2}:\d{2}:\d{2}\.\d{3})\s*-->\s*(\d{2}:\d{2}:\d{2}\.\d{3})\]\s*(.*)/;
|
|
164
|
+
for (const line of lines) {
|
|
165
|
+
const match = line.match(timestampRegex);
|
|
166
|
+
if (match) {
|
|
167
|
+
const [, startStr, endStr, text] = match;
|
|
168
|
+
segments.push({
|
|
169
|
+
id: `seg-${segments.length}`,
|
|
170
|
+
start: parseTimestamp(startStr),
|
|
171
|
+
end: parseTimestamp(endStr),
|
|
172
|
+
text: text.trim(),
|
|
173
|
+
speaker: null,
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
// If no timestamps found, treat entire output as single segment
|
|
178
|
+
if (segments.length === 0 && stdout.trim()) {
|
|
179
|
+
segments.push({
|
|
180
|
+
id: 'seg-0',
|
|
181
|
+
start: 0,
|
|
182
|
+
end: 0,
|
|
183
|
+
text: stdout.trim(),
|
|
184
|
+
speaker: null,
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
const fullText = segments.map((s) => s.text).join(' ');
|
|
188
|
+
const duration = segments.length > 0 ? segments[segments.length - 1].end : 0;
|
|
189
|
+
return {
|
|
190
|
+
fullText,
|
|
191
|
+
segments,
|
|
192
|
+
language: 'en',
|
|
193
|
+
duration,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Parse timestamp string "00:00:00.000" to seconds
|
|
198
|
+
*/
|
|
199
|
+
function parseTimestamp(timestamp) {
|
|
200
|
+
const [hours, minutes, rest] = timestamp.split(':');
|
|
201
|
+
const [seconds, ms] = rest.split('.');
|
|
202
|
+
return (parseInt(hours, 10) * 3600 +
|
|
203
|
+
parseInt(minutes, 10) * 60 +
|
|
204
|
+
parseInt(seconds, 10) +
|
|
205
|
+
parseInt(ms, 10) / 1000);
|
|
206
|
+
}
|