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 +5 -0
- meetdown/chunks.py +405 -0
- meetdown/cli.py +507 -0
- meetdown/clova.py +101 -0
- meetdown/json_types.py +33 -0
- meetdown/markdown.py +143 -0
- meetdown/providers.py +462 -0
- meetdown-0.1.0.dist-info/METADATA +135 -0
- meetdown-0.1.0.dist-info/RECORD +11 -0
- meetdown-0.1.0.dist-info/WHEEL +4 -0
- meetdown-0.1.0.dist-info/entry_points.txt +3 -0
meetdown/__init__.py
ADDED
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
|