meetdown 0.1.0__py3-none-any.whl

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.
meetdown/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Markdown transcript generation for meeting audio."""
2
+
3
+ __all__ = ["__version__"]
4
+
5
+ __version__ = "0.1.0"
meetdown/chunks.py ADDED
@@ -0,0 +1,405 @@
1
+ import copy
2
+ import importlib
3
+ import re
4
+ import shutil
5
+ import subprocess
6
+ from dataclasses import dataclass
7
+ from functools import lru_cache
8
+ from pathlib import Path
9
+ from typing import Protocol, cast
10
+
11
+ from meetdown.json_types import JsonObject, as_json_object, as_number, as_object_list
12
+
13
+
14
+ class ChunkingError(RuntimeError):
15
+ """Raised when media chunking fails before transcription."""
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class MediaChunk:
20
+ path: Path
21
+ offset_ms: int
22
+
23
+
24
+ class _ImageioFfmpegModule(Protocol):
25
+ def get_ffmpeg_exe(self) -> str: ...
26
+
27
+
28
+ _DURATION_RE = re.compile(r"^(?P<value>\d+(?:\.\d+)?)(?P<unit>s|m|h)?$")
29
+ _CHUNK_FORMATS: dict[str, tuple[str, list[str]]] = {
30
+ "flac": (".flac", ["-acodec", "flac", "-compression_level", "8"]),
31
+ "mp3": (".mp3", ["-acodec", "libmp3lame", "-b:a", "64k"]),
32
+ "wav": (".wav", ["-acodec", "pcm_s16le"]),
33
+ }
34
+ _FFMPEG_DURATION_RE = re.compile(
35
+ r"Duration:\s*(?P<hours>\d+):(?P<minutes>\d+):(?P<seconds>\d+(?:\.\d+)?)"
36
+ )
37
+
38
+
39
+ def parse_time_seconds(value: str) -> float:
40
+ text = value.strip().lower()
41
+ if ":" in text:
42
+ parts = text.split(":")
43
+ if len(parts) not in (2, 3):
44
+ raise ValueError("time must look like 600, 10m, 01:23, or 01:02:03")
45
+
46
+ try:
47
+ numbers = [float(part) for part in parts]
48
+ except ValueError as exc:
49
+ raise ValueError(
50
+ "time must look like 600, 10m, 01:23, or 01:02:03"
51
+ ) from exc
52
+
53
+ if any(number < 0 for number in numbers):
54
+ raise ValueError("time must not be negative")
55
+ if any(number >= 60 for number in numbers[1:]):
56
+ raise ValueError("minute and second fields must be less than 60")
57
+
58
+ if len(numbers) == 2:
59
+ minutes, seconds = numbers
60
+ return minutes * 60 + seconds
61
+
62
+ hours, minutes, seconds = numbers
63
+ return hours * 3600 + minutes * 60 + seconds
64
+
65
+ match = _DURATION_RE.match(text)
66
+ if not match:
67
+ raise ValueError("time must look like 600, 600s, 10m, 01:23, or 01:02:03")
68
+
69
+ amount = float(match.group("value"))
70
+ unit = match.group("unit") or "s"
71
+ multiplier = {"s": 1, "m": 60, "h": 3600}[unit]
72
+ seconds = amount * multiplier
73
+ if seconds < 0:
74
+ raise ValueError("time must not be negative")
75
+ return seconds
76
+
77
+
78
+ def parse_duration_seconds(value: str) -> float:
79
+ seconds = parse_time_seconds(value)
80
+ if seconds <= 0:
81
+ raise ValueError("duration must be greater than zero")
82
+ return seconds
83
+
84
+
85
+ def normalize_chunk_format(value: str) -> str:
86
+ chunk_format = value.strip().lower()
87
+ if chunk_format not in _CHUNK_FORMATS:
88
+ supported = ", ".join(sorted(_CHUNK_FORMATS))
89
+ raise ValueError(f"chunk format must be one of: {supported}")
90
+ return chunk_format
91
+
92
+
93
+ def chunk_extension(chunk_format: str) -> str:
94
+ normalized_format = normalize_chunk_format(chunk_format)
95
+ extension, _ = _CHUNK_FORMATS[normalized_format]
96
+ return extension
97
+
98
+
99
+ def validate_time_range(start_seconds: float, end_seconds: float | None) -> None:
100
+ if start_seconds < 0:
101
+ raise ValueError("start time must not be negative")
102
+ if end_seconds is not None and end_seconds <= start_seconds:
103
+ raise ValueError("end time must be greater than start time")
104
+
105
+
106
+ @lru_cache(maxsize=1)
107
+ def ffmpeg_executable() -> str:
108
+ system_ffmpeg = shutil.which("ffmpeg")
109
+ if system_ffmpeg:
110
+ return system_ffmpeg
111
+
112
+ try:
113
+ imageio_ffmpeg = cast(
114
+ _ImageioFfmpegModule,
115
+ importlib.import_module("imageio_ffmpeg"),
116
+ )
117
+ except ImportError as exc:
118
+ raise ChunkingError(
119
+ "ffmpeg is required for media extraction. Install ffmpeg or install "
120
+ "meetdown with the imageio-ffmpeg dependency."
121
+ ) from exc
122
+
123
+ return imageio_ffmpeg.get_ffmpeg_exe()
124
+
125
+
126
+ def ffprobe_executable() -> str | None:
127
+ system_ffprobe = shutil.which("ffprobe")
128
+ if system_ffprobe:
129
+ return system_ffprobe
130
+
131
+ ffmpeg_path = Path(ffmpeg_executable())
132
+ for candidate in (
133
+ ffmpeg_path.with_name("ffprobe.exe"),
134
+ ffmpeg_path.with_name("ffprobe"),
135
+ ):
136
+ if candidate.is_file():
137
+ return str(candidate)
138
+ return None
139
+
140
+
141
+ def _run_ffmpeg(command: list[str], *, action: str) -> None:
142
+ command = [ffmpeg_executable(), *command]
143
+ try:
144
+ completed = subprocess.run(command, check=False, capture_output=True, text=True)
145
+ except OSError as exc:
146
+ raise ChunkingError(f"ffmpeg failed to start for {action}: {exc}") from exc
147
+
148
+ if completed.returncode != 0:
149
+ stderr = completed.stderr.strip() or "unknown ffmpeg error"
150
+ raise ChunkingError(f"ffmpeg failed to {action}: {stderr}")
151
+
152
+
153
+ def _parse_ffmpeg_duration(stderr: str) -> float:
154
+ match = _FFMPEG_DURATION_RE.search(stderr)
155
+ if not match:
156
+ raise ChunkingError("ffmpeg could not read media duration")
157
+
158
+ hours = float(match.group("hours"))
159
+ minutes = float(match.group("minutes"))
160
+ seconds = float(match.group("seconds"))
161
+ duration = hours * 3600 + minutes * 60 + seconds
162
+ if duration <= 0:
163
+ raise ChunkingError("media duration must be greater than zero")
164
+ return duration
165
+
166
+
167
+ def _probe_media_duration_with_ffmpeg(source: Path) -> float:
168
+ command = [
169
+ ffmpeg_executable(),
170
+ "-hide_banner",
171
+ "-i",
172
+ str(source),
173
+ "-t",
174
+ "0.001",
175
+ "-f",
176
+ "null",
177
+ "-",
178
+ ]
179
+ try:
180
+ completed = subprocess.run(command, check=False, capture_output=True, text=True)
181
+ except OSError as exc:
182
+ raise ChunkingError(
183
+ f"ffmpeg failed to start for duration probing: {exc}"
184
+ ) from exc
185
+
186
+ return _parse_ffmpeg_duration(completed.stderr)
187
+
188
+
189
+ def probe_media_duration_seconds(input_path: str | Path) -> float:
190
+ source = Path(input_path)
191
+ ffprobe = ffprobe_executable()
192
+ if ffprobe is None:
193
+ return _probe_media_duration_with_ffmpeg(source)
194
+
195
+ command = [
196
+ ffprobe,
197
+ "-v",
198
+ "error",
199
+ "-show_entries",
200
+ "format=duration",
201
+ "-of",
202
+ "default=noprint_wrappers=1:nokey=1",
203
+ str(source),
204
+ ]
205
+ try:
206
+ completed = subprocess.run(command, check=False, capture_output=True, text=True)
207
+ except OSError:
208
+ return _probe_media_duration_with_ffmpeg(source)
209
+
210
+ if completed.returncode != 0:
211
+ return _probe_media_duration_with_ffmpeg(source)
212
+
213
+ try:
214
+ duration = float(completed.stdout.strip())
215
+ except ValueError as exc:
216
+ raise ChunkingError("ffprobe could not read media duration") from exc
217
+
218
+ if duration <= 0:
219
+ raise ChunkingError("media duration must be greater than zero")
220
+ return duration
221
+
222
+
223
+ def _input_args(source: Path, start_seconds: float) -> list[str]:
224
+ args: list[str] = []
225
+ if start_seconds:
226
+ args.extend(["-ss", str(start_seconds)])
227
+ args.extend(["-i", str(source)])
228
+ return args
229
+
230
+
231
+ def _duration_args(start_seconds: float, end_seconds: float | None) -> list[str]:
232
+ if end_seconds is None:
233
+ return []
234
+ return ["-t", str(end_seconds - start_seconds)]
235
+
236
+
237
+ def extract_media(
238
+ input_path: str | Path,
239
+ output_path: str | Path,
240
+ *,
241
+ start_seconds: float = 0,
242
+ end_seconds: float | None = None,
243
+ chunk_format: str = "flac",
244
+ ) -> MediaChunk:
245
+ source = Path(input_path)
246
+ if not source.is_file():
247
+ raise FileNotFoundError(f"audio file not found: {source}")
248
+
249
+ validate_time_range(start_seconds, end_seconds)
250
+ normalized_format = normalize_chunk_format(chunk_format)
251
+ _, codec_args = _CHUNK_FORMATS[normalized_format]
252
+
253
+ destination = Path(output_path)
254
+ destination.parent.mkdir(parents=True, exist_ok=True)
255
+ command = [
256
+ "-hide_banner",
257
+ "-loglevel",
258
+ "error",
259
+ "-y",
260
+ *_input_args(source, start_seconds),
261
+ "-vn",
262
+ "-map",
263
+ "0:a:0",
264
+ "-ac",
265
+ "1",
266
+ "-ar",
267
+ "16000",
268
+ *codec_args,
269
+ *_duration_args(start_seconds, end_seconds),
270
+ str(destination),
271
+ ]
272
+ _run_ffmpeg(command, action="extract selected media")
273
+ return MediaChunk(path=destination, offset_ms=int(round(start_seconds * 1000)))
274
+
275
+
276
+ def split_media(
277
+ input_path: str | Path,
278
+ output_dir: str | Path,
279
+ chunk_seconds: float,
280
+ *,
281
+ chunk_format: str = "flac",
282
+ start_seconds: float = 0,
283
+ end_seconds: float | None = None,
284
+ ) -> list[MediaChunk]:
285
+ source = Path(input_path)
286
+ if not source.is_file():
287
+ raise FileNotFoundError(f"audio file not found: {source}")
288
+
289
+ validate_time_range(start_seconds, end_seconds)
290
+ normalized_format = normalize_chunk_format(chunk_format)
291
+ extension, _ = _CHUNK_FORMATS[normalized_format]
292
+ destination = Path(output_dir)
293
+ destination.mkdir(parents=True, exist_ok=True)
294
+
295
+ base_offset_seconds = start_seconds
296
+ segment_source = source
297
+ if start_seconds or end_seconds is not None:
298
+ selected_path = destination / f"selected{extension}"
299
+ extract_media(
300
+ source,
301
+ selected_path,
302
+ start_seconds=start_seconds,
303
+ end_seconds=end_seconds,
304
+ chunk_format=chunk_format,
305
+ )
306
+ segment_source = selected_path
307
+
308
+ duration = probe_media_duration_seconds(segment_source)
309
+ chunks: list[MediaChunk] = []
310
+ local_start = 0.0
311
+ index = 0
312
+ while local_start < duration - 0.001:
313
+ local_end = min(local_start + chunk_seconds, duration)
314
+ chunk_path = destination / f"chunk_{index:05d}{extension}"
315
+ extract_media(
316
+ segment_source,
317
+ chunk_path,
318
+ start_seconds=local_start,
319
+ end_seconds=local_end,
320
+ chunk_format=normalized_format,
321
+ )
322
+ chunks.append(
323
+ MediaChunk(
324
+ path=chunk_path,
325
+ offset_ms=int(round((base_offset_seconds + local_start) * 1000)),
326
+ )
327
+ )
328
+ local_start += chunk_seconds
329
+ index += 1
330
+
331
+ if not chunks:
332
+ raise ChunkingError("ffmpeg did not create any chunks")
333
+ return chunks
334
+
335
+
336
+ def _offset_numeric_field(mapping: JsonObject, key: str, offset_ms: int) -> None:
337
+ value = as_number(mapping.get(key))
338
+ if value is not None:
339
+ mapping[key] = value + offset_ms
340
+
341
+
342
+ def offset_response_times(response: JsonObject, offset_ms: int) -> JsonObject:
343
+ adjusted = copy.deepcopy(response)
344
+
345
+ for item in as_object_list(adjusted.get("segments")) or []:
346
+ segment = as_json_object(item)
347
+ if segment is None:
348
+ continue
349
+ for key in ("start", "end"):
350
+ _offset_numeric_field(segment, key, offset_ms)
351
+ for word_item in as_object_list(segment.get("words")) or []:
352
+ word = as_object_list(word_item)
353
+ if word is None or len(word) < 2:
354
+ continue
355
+ start = as_number(word[0])
356
+ end = as_number(word[1])
357
+ if start is not None:
358
+ word[0] = start + offset_ms
359
+ if end is not None:
360
+ word[1] = end + offset_ms
361
+
362
+ for item in as_object_list(adjusted.get("events")) or []:
363
+ event = as_json_object(item)
364
+ if event is None:
365
+ continue
366
+ for key in ("start", "end"):
367
+ _offset_numeric_field(event, key, offset_ms)
368
+
369
+ return adjusted
370
+
371
+
372
+ def merge_responses(responses: list[JsonObject]) -> JsonObject:
373
+ adjusted_segments: list[object] = []
374
+ adjusted_events: list[object] = []
375
+ texts: list[str] = []
376
+ confidences: list[float] = []
377
+
378
+ for response in responses:
379
+ text = str(response.get("text") or "").strip()
380
+ if text:
381
+ texts.append(text)
382
+
383
+ segments = as_object_list(response.get("segments"))
384
+ if segments is not None:
385
+ adjusted_segments.extend(segments)
386
+
387
+ events = as_object_list(response.get("events"))
388
+ if events is not None:
389
+ adjusted_events.extend(events)
390
+
391
+ confidence = as_number(response.get("confidence"))
392
+ if confidence is not None:
393
+ confidences.append(float(confidence))
394
+
395
+ merged: JsonObject = {
396
+ "result": "COMPLETED",
397
+ "message": "Merged chunked transcription",
398
+ "text": " ".join(texts),
399
+ "segments": adjusted_segments,
400
+ }
401
+ if adjusted_events:
402
+ merged["events"] = adjusted_events
403
+ if confidences:
404
+ merged["confidence"] = sum(confidences) / len(confidences)
405
+ return merged