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.
Files changed (124) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +297 -0
  3. package/dist/0_types.js +279 -0
  4. package/dist/actions/classify-session.js +77 -0
  5. package/dist/actions/create-contexts.js +44 -0
  6. package/dist/actions/create-topic-blocks.js +68 -0
  7. package/dist/actions/extract-metadata.js +24 -0
  8. package/dist/actions/generate-artifact-v3.js +296 -0
  9. package/dist/actions/generate-artifact.js +61 -0
  10. package/dist/actions/generate-summary-v3.js +260 -0
  11. package/dist/actions/outline-index.js +204 -0
  12. package/dist/actions/process-recording-v2.js +494 -0
  13. package/dist/actions/process-recording-v3.js +412 -0
  14. package/dist/actions/process-session.js +183 -0
  15. package/dist/actions/publish-summary-v3.js +303 -0
  16. package/dist/actions/sync-to-outline.js +196 -0
  17. package/dist/adapters/audio.silero.adapter.js +69 -0
  18. package/dist/adapters/cap.adapter.js +94 -0
  19. package/dist/adapters/capture.cap.adapter.js +107 -0
  20. package/dist/adapters/capture.filesystem.adapter.js +124 -0
  21. package/dist/adapters/embedding.ollama.adapter.js +141 -0
  22. package/dist/adapters/intelligence.adapter.js +202 -0
  23. package/dist/adapters/intelligence.mlx.adapter.js +395 -0
  24. package/dist/adapters/intelligence.ollama.adapter.js +741 -0
  25. package/dist/adapters/publishing.outline.adapter.js +75 -0
  26. package/dist/adapters/storage.adapter.js +81 -0
  27. package/dist/adapters/storage.fs.adapter.js +83 -0
  28. package/dist/adapters/transcription.whisper.adapter.js +206 -0
  29. package/dist/adapters/video.ffmpeg.adapter.js +405 -0
  30. package/dist/adapters/whisper.adapter.js +168 -0
  31. package/dist/batch-context.js +329 -0
  32. package/dist/db/helpers.js +50 -0
  33. package/dist/db/index.js +95 -0
  34. package/dist/db/migrate.js +80 -0
  35. package/dist/db/repositories/artifact.sqlite.js +77 -0
  36. package/dist/db/repositories/cluster.sqlite.js +92 -0
  37. package/dist/db/repositories/context.sqlite.js +75 -0
  38. package/dist/db/repositories/index.js +10 -0
  39. package/dist/db/repositories/observation.sqlite.js +70 -0
  40. package/dist/db/repositories/recording.sqlite.js +56 -0
  41. package/dist/db/repositories/subject.sqlite.js +64 -0
  42. package/dist/db/repositories/topic-block.sqlite.js +45 -0
  43. package/dist/db/types.js +4 -0
  44. package/dist/domain/classification.js +60 -0
  45. package/dist/domain/context.js +97 -0
  46. package/dist/domain/index.js +2 -0
  47. package/dist/domain/observation.js +17 -0
  48. package/dist/domain/recording.js +41 -0
  49. package/dist/domain/segment.js +93 -0
  50. package/dist/domain/session.js +93 -0
  51. package/dist/domain/time-range.js +38 -0
  52. package/dist/domain/transcript.js +79 -0
  53. package/dist/index.js +173 -0
  54. package/dist/pipeline/context.js +162 -0
  55. package/dist/pipeline/events.js +2 -0
  56. package/dist/prerequisites.js +226 -0
  57. package/dist/scripts/rebuild-index.js +53 -0
  58. package/dist/scripts/seed-fixtures.js +290 -0
  59. package/dist/services/activity-segmentation.js +333 -0
  60. package/dist/services/activity-segmentation.test.js +191 -0
  61. package/dist/services/app-normalization.js +212 -0
  62. package/dist/services/cluster-merge.js +69 -0
  63. package/dist/services/clustering.js +237 -0
  64. package/dist/services/debug.js +58 -0
  65. package/dist/services/frame-sampling.js +318 -0
  66. package/dist/services/signal-extraction.js +106 -0
  67. package/dist/services/subject-grouping.js +342 -0
  68. package/dist/services/temporal-alignment.js +99 -0
  69. package/dist/services/vlm-enrichment.js +84 -0
  70. package/dist/services/vlm-service.js +130 -0
  71. package/dist/stats/index.js +3 -0
  72. package/dist/stats/observer.js +65 -0
  73. package/dist/stats/repository.js +36 -0
  74. package/dist/stats/resource-tracker.js +86 -0
  75. package/dist/stats/types.js +1 -0
  76. package/dist/test-classification-prompts.js +181 -0
  77. package/dist/tests/cap.adapter.test.js +75 -0
  78. package/dist/tests/capture.cap.adapter.test.js +69 -0
  79. package/dist/tests/classify-session.test.js +140 -0
  80. package/dist/tests/db/repositories.test.js +243 -0
  81. package/dist/tests/domain/time-range.test.js +31 -0
  82. package/dist/tests/integration.test.js +84 -0
  83. package/dist/tests/intelligence.adapter.test.js +102 -0
  84. package/dist/tests/intelligence.ollama.adapter.test.js +178 -0
  85. package/dist/tests/process-v2.test.js +90 -0
  86. package/dist/tests/services/clustering.test.js +112 -0
  87. package/dist/tests/services/frame-sampling.test.js +152 -0
  88. package/dist/tests/utils/ocr.test.js +76 -0
  89. package/dist/tests/utils/parallel.test.js +57 -0
  90. package/dist/tests/visual-observer.test.js +175 -0
  91. package/dist/utils/id-normalization.js +15 -0
  92. package/dist/utils/index.js +9 -0
  93. package/dist/utils/model-detector.js +154 -0
  94. package/dist/utils/ocr.js +80 -0
  95. package/dist/utils/parallel.js +32 -0
  96. package/migrations/001_initial.sql +109 -0
  97. package/migrations/002_clusters.sql +41 -0
  98. package/migrations/003_observations_vlm_fields.sql +14 -0
  99. package/migrations/004_observations_unique.sql +18 -0
  100. package/migrations/005_processing_stats.sql +29 -0
  101. package/migrations/006_vlm_raw_response.sql +6 -0
  102. package/migrations/007_subjects.sql +23 -0
  103. package/migrations/008_artifacts_recording.sql +6 -0
  104. package/migrations/009_artifact_subjects.sql +10 -0
  105. package/package.json +82 -0
  106. package/prompts/action-items.md +55 -0
  107. package/prompts/blog-draft.md +54 -0
  108. package/prompts/blog-research.md +87 -0
  109. package/prompts/card.md +54 -0
  110. package/prompts/classify-segment.md +38 -0
  111. package/prompts/classify.md +37 -0
  112. package/prompts/code-snippets.md +163 -0
  113. package/prompts/extract-metadata.md +149 -0
  114. package/prompts/notes.md +83 -0
  115. package/prompts/runbook.md +123 -0
  116. package/prompts/standup.md +50 -0
  117. package/prompts/step-by-step.md +125 -0
  118. package/prompts/subject-grouping.md +31 -0
  119. package/prompts/summary-v3.md +89 -0
  120. package/prompts/summary.md +77 -0
  121. package/prompts/topic-classifier.md +24 -0
  122. package/prompts/topic-extract.md +13 -0
  123. package/prompts/vlm-batch.md +21 -0
  124. 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
+ }