meetdown 0.1.0__tar.gz

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.
@@ -0,0 +1,135 @@
1
+ Metadata-Version: 2.4
2
+ Name: meetdown
3
+ Version: 0.1.0
4
+ Summary: Generate Markdown meeting transcripts from speech-to-text JSON.
5
+ License-Expression: MIT
6
+ Requires-Dist: httpx>=0.27.0
7
+ Requires-Dist: imageio-ffmpeg>=0.6.0
8
+ Requires-Python: >=3.12
9
+ Description-Content-Type: text/markdown
10
+
11
+ # meetdown
12
+
13
+ `meetdown` is a small Python CLI for turning meeting audio into a Markdown transcript.
14
+
15
+ The default provider is NAVER Cloud Platform CLOVA Speech. The CLI sends a local
16
+ audio or video file to a speech provider, normalizes the response, and writes a
17
+ `.md` file with the full text and speaker-by-speaker transcript when the provider
18
+ returns speaker information.
19
+
20
+ ## Install and run
21
+
22
+ Run it directly with `uvx`; cloning this repository is not required:
23
+
24
+ ```powershell
25
+ uvx meetdown meeting.m4a -o meeting.md --api-url "https://your.invoke.url" --api-key "your-secret-key"
26
+ ```
27
+
28
+ For local development:
29
+
30
+ ```powershell
31
+ uv run meetdown meeting.m4a -o meeting.md --api-url "https://your.invoke.url" --api-key "your-secret-key"
32
+ ```
33
+
34
+ Set credentials with environment variables:
35
+
36
+ ```powershell
37
+ $env:CLOVA_SPEECH_INVOKE_URL="https://your.invoke.url"
38
+ $env:CLOVA_SPEECH_SECRET_KEY="your-secret-key"
39
+ ```
40
+
41
+ You can also pass them directly:
42
+
43
+ ```powershell
44
+ uvx meetdown meeting.m4a -o meeting.md --api-url "https://your.invoke.url" --api-key "your-secret-key"
45
+ ```
46
+
47
+ ## Providers
48
+
49
+ The default provider is CLOVA Speech:
50
+
51
+ ```powershell
52
+ uvx meetdown meeting.m4a -o meeting.md --provider clova --api-url "https://your.invoke.url" --api-key "your-secret-key"
53
+ ```
54
+
55
+ For CLOVA, `--api-url` is the CLOVA Speech Invoke URL from NAVER Cloud Platform.
56
+ You may pass either the base Invoke URL or a URL that already ends with
57
+ `/recognizer/upload`.
58
+
59
+ OpenAI and Gemini can be selected with `--provider`. Use `--api-key` directly or
60
+ set the provider environment variable. Use `--api-url` when you need to override
61
+ the provider endpoint or base URL:
62
+
63
+ ```powershell
64
+ $env:OPENAI_API_KEY="your-openai-key"
65
+ uvx meetdown meeting.m4a -o meeting.md --provider openai --chunk-duration 10m
66
+ uvx meetdown meeting.m4a -o meeting.md --provider openai --api-url "https://api.openai.com/v1"
67
+
68
+ $env:GEMINI_API_KEY="your-gemini-key"
69
+ uvx meetdown meeting.m4a -o meeting.md --provider gemini --chunk-duration 10m
70
+ ```
71
+
72
+ Use `--model` to override the provider default model. CLOVA does not support
73
+ `--model`; the CLOVA Speech model is determined by the Invoke URL/domain.
74
+ OpenAI defaults to a diarization-capable transcription model when diarization is
75
+ enabled, and to a smaller transcription model when `--no-diarization` is used.
76
+ Gemini uses inline audio requests, so use `--chunk-duration` for larger files.
77
+
78
+ ## Chunk long recordings
79
+
80
+ Use `--chunk-duration` when a recording is too long for one sync upload. The
81
+ CLI extracts audio chunks with `ffmpeg`, sends each chunk to the selected provider,
82
+ shifts segment timestamps back to the original file timeline, and writes one Markdown file.
83
+ If your PC does not have system `ffmpeg`, meetdown falls back to the bundled
84
+ `imageio-ffmpeg` executable installed with the package. If system `ffprobe` is
85
+ missing, meetdown reads duration metadata through ffmpeg instead.
86
+
87
+ ```powershell
88
+ uvx meetdown meeting.mp4 -o meeting.md --api-url "https://your.invoke.url" --api-key "your-secret-key" --chunk-duration 10m
89
+ ```
90
+
91
+ Accepted duration forms include `600`, `600s`, `10m`, and `1h`. Uploads default
92
+ to `--compress smallest`, which extracts audio and uploads a 64 kbps MP3 copy.
93
+ Use `--compress lossless` for FLAC or `--compress none` to avoid compression.
94
+ When chunking or range extraction is used, `--compress none` creates WAV chunks:
95
+
96
+ ```powershell
97
+ uvx meetdown meeting.mp4 -o meeting.md --api-url "https://your.invoke.url" --api-key "your-secret-key" --chunk-duration 10m --compress lossless
98
+ uvx meetdown meeting.mp4 -o meeting.md --api-url "https://your.invoke.url" --api-key "your-secret-key" --chunk-duration 10m --compress none
99
+ ```
100
+
101
+ You can still force the exact temporary upload format with `--chunk-format mp3`,
102
+ `--chunk-format flac`, or `--chunk-format wav`.
103
+
104
+ You can transcribe only part of the source file. Timestamps in the Markdown stay
105
+ on the original media timeline:
106
+
107
+ ```powershell
108
+ uvx meetdown meeting.mp4 -o meeting.md --api-url "https://your.invoke.url" --api-key "your-secret-key" --start 10m --end 45m
109
+ uvx meetdown meeting.mp4 -o meeting.md --api-url "https://your.invoke.url" --api-key "your-secret-key" --start 00:10:00 --end 00:45:00 --chunk-duration 10m
110
+ ```
111
+
112
+ ## Offline conversion
113
+
114
+ If you already have a CLOVA Speech JSON response, convert it without calling the API:
115
+
116
+ ```powershell
117
+ uvx meetdown --from-json examples/clova_response.sample.json -o sample.md --title "Sample Meeting"
118
+ ```
119
+
120
+ Generated Markdown includes a `처리 옵션` section with the provider, model, language,
121
+ compression, chunking, range, timeout settings, and a replay command for the run.
122
+ Raw API keys are never written. They are stored only in masked form, such as
123
+ `sk-p********7890`. Replay commands use `<...>` placeholders for secret values;
124
+ replace them before running the command.
125
+
126
+ When a provider returns unnamed speakers, meetdown writes speaker placeholders
127
+ like `[[SPEAKER_1]]` and `[[SPEAKER_2]]`. These are intentionally easy to find
128
+ with Replace All and change to real names after you identify the speakers.
129
+
130
+ ## Initial scope
131
+
132
+ This project intentionally starts small. It supports one local file, CLOVA Speech sync
133
+ recognition, full text output, speaker-segment output, and Markdown file generation.
134
+ Summaries, decisions, action items, database storage, web UI, async callbacks, and batch
135
+ processing are later extensions.
@@ -0,0 +1,125 @@
1
+ # meetdown
2
+
3
+ `meetdown` is a small Python CLI for turning meeting audio into a Markdown transcript.
4
+
5
+ The default provider is NAVER Cloud Platform CLOVA Speech. The CLI sends a local
6
+ audio or video file to a speech provider, normalizes the response, and writes a
7
+ `.md` file with the full text and speaker-by-speaker transcript when the provider
8
+ returns speaker information.
9
+
10
+ ## Install and run
11
+
12
+ Run it directly with `uvx`; cloning this repository is not required:
13
+
14
+ ```powershell
15
+ uvx meetdown meeting.m4a -o meeting.md --api-url "https://your.invoke.url" --api-key "your-secret-key"
16
+ ```
17
+
18
+ For local development:
19
+
20
+ ```powershell
21
+ uv run meetdown meeting.m4a -o meeting.md --api-url "https://your.invoke.url" --api-key "your-secret-key"
22
+ ```
23
+
24
+ Set credentials with environment variables:
25
+
26
+ ```powershell
27
+ $env:CLOVA_SPEECH_INVOKE_URL="https://your.invoke.url"
28
+ $env:CLOVA_SPEECH_SECRET_KEY="your-secret-key"
29
+ ```
30
+
31
+ You can also pass them directly:
32
+
33
+ ```powershell
34
+ uvx meetdown meeting.m4a -o meeting.md --api-url "https://your.invoke.url" --api-key "your-secret-key"
35
+ ```
36
+
37
+ ## Providers
38
+
39
+ The default provider is CLOVA Speech:
40
+
41
+ ```powershell
42
+ uvx meetdown meeting.m4a -o meeting.md --provider clova --api-url "https://your.invoke.url" --api-key "your-secret-key"
43
+ ```
44
+
45
+ For CLOVA, `--api-url` is the CLOVA Speech Invoke URL from NAVER Cloud Platform.
46
+ You may pass either the base Invoke URL or a URL that already ends with
47
+ `/recognizer/upload`.
48
+
49
+ OpenAI and Gemini can be selected with `--provider`. Use `--api-key` directly or
50
+ set the provider environment variable. Use `--api-url` when you need to override
51
+ the provider endpoint or base URL:
52
+
53
+ ```powershell
54
+ $env:OPENAI_API_KEY="your-openai-key"
55
+ uvx meetdown meeting.m4a -o meeting.md --provider openai --chunk-duration 10m
56
+ uvx meetdown meeting.m4a -o meeting.md --provider openai --api-url "https://api.openai.com/v1"
57
+
58
+ $env:GEMINI_API_KEY="your-gemini-key"
59
+ uvx meetdown meeting.m4a -o meeting.md --provider gemini --chunk-duration 10m
60
+ ```
61
+
62
+ Use `--model` to override the provider default model. CLOVA does not support
63
+ `--model`; the CLOVA Speech model is determined by the Invoke URL/domain.
64
+ OpenAI defaults to a diarization-capable transcription model when diarization is
65
+ enabled, and to a smaller transcription model when `--no-diarization` is used.
66
+ Gemini uses inline audio requests, so use `--chunk-duration` for larger files.
67
+
68
+ ## Chunk long recordings
69
+
70
+ Use `--chunk-duration` when a recording is too long for one sync upload. The
71
+ CLI extracts audio chunks with `ffmpeg`, sends each chunk to the selected provider,
72
+ shifts segment timestamps back to the original file timeline, and writes one Markdown file.
73
+ If your PC does not have system `ffmpeg`, meetdown falls back to the bundled
74
+ `imageio-ffmpeg` executable installed with the package. If system `ffprobe` is
75
+ missing, meetdown reads duration metadata through ffmpeg instead.
76
+
77
+ ```powershell
78
+ uvx meetdown meeting.mp4 -o meeting.md --api-url "https://your.invoke.url" --api-key "your-secret-key" --chunk-duration 10m
79
+ ```
80
+
81
+ Accepted duration forms include `600`, `600s`, `10m`, and `1h`. Uploads default
82
+ to `--compress smallest`, which extracts audio and uploads a 64 kbps MP3 copy.
83
+ Use `--compress lossless` for FLAC or `--compress none` to avoid compression.
84
+ When chunking or range extraction is used, `--compress none` creates WAV chunks:
85
+
86
+ ```powershell
87
+ uvx meetdown meeting.mp4 -o meeting.md --api-url "https://your.invoke.url" --api-key "your-secret-key" --chunk-duration 10m --compress lossless
88
+ uvx meetdown meeting.mp4 -o meeting.md --api-url "https://your.invoke.url" --api-key "your-secret-key" --chunk-duration 10m --compress none
89
+ ```
90
+
91
+ You can still force the exact temporary upload format with `--chunk-format mp3`,
92
+ `--chunk-format flac`, or `--chunk-format wav`.
93
+
94
+ You can transcribe only part of the source file. Timestamps in the Markdown stay
95
+ on the original media timeline:
96
+
97
+ ```powershell
98
+ uvx meetdown meeting.mp4 -o meeting.md --api-url "https://your.invoke.url" --api-key "your-secret-key" --start 10m --end 45m
99
+ uvx meetdown meeting.mp4 -o meeting.md --api-url "https://your.invoke.url" --api-key "your-secret-key" --start 00:10:00 --end 00:45:00 --chunk-duration 10m
100
+ ```
101
+
102
+ ## Offline conversion
103
+
104
+ If you already have a CLOVA Speech JSON response, convert it without calling the API:
105
+
106
+ ```powershell
107
+ uvx meetdown --from-json examples/clova_response.sample.json -o sample.md --title "Sample Meeting"
108
+ ```
109
+
110
+ Generated Markdown includes a `처리 옵션` section with the provider, model, language,
111
+ compression, chunking, range, timeout settings, and a replay command for the run.
112
+ Raw API keys are never written. They are stored only in masked form, such as
113
+ `sk-p********7890`. Replay commands use `<...>` placeholders for secret values;
114
+ replace them before running the command.
115
+
116
+ When a provider returns unnamed speakers, meetdown writes speaker placeholders
117
+ like `[[SPEAKER_1]]` and `[[SPEAKER_2]]`. These are intentionally easy to find
118
+ with Replace All and change to real names after you identify the speakers.
119
+
120
+ ## Initial scope
121
+
122
+ This project intentionally starts small. It supports one local file, CLOVA Speech sync
123
+ recognition, full text output, speaker-segment output, and Markdown file generation.
124
+ Summaries, decisions, action items, database storage, web UI, async callbacks, and batch
125
+ processing are later extensions.
@@ -0,0 +1,21 @@
1
+ [project]
2
+ name = "meetdown"
3
+ version = "0.1.0"
4
+ description = "Generate Markdown meeting transcripts from speech-to-text JSON."
5
+ readme = "README.md"
6
+ requires-python = ">=3.12"
7
+ license = "MIT"
8
+ dependencies = ["httpx>=0.27.0", "imageio-ffmpeg>=0.6.0"]
9
+
10
+ [project.scripts]
11
+ meetdown = "meetdown.cli:main"
12
+
13
+ [dependency-groups]
14
+ dev = ["pytest>=8.0.0", "ruff>=0.8.0"]
15
+
16
+ [build-system]
17
+ requires = ["uv_build>=0.11.12,<0.12"]
18
+ build-backend = "uv_build"
19
+
20
+ [tool.pyright]
21
+ typeCheckingMode = "strict"
@@ -0,0 +1,5 @@
1
+ """Markdown transcript generation for meeting audio."""
2
+
3
+ __all__ = ["__version__"]
4
+
5
+ __version__ = "0.1.0"
@@ -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