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.
- meetdown-0.1.0/PKG-INFO +135 -0
- meetdown-0.1.0/README.md +125 -0
- meetdown-0.1.0/pyproject.toml +21 -0
- meetdown-0.1.0/src/meetdown/__init__.py +5 -0
- meetdown-0.1.0/src/meetdown/chunks.py +405 -0
- meetdown-0.1.0/src/meetdown/cli.py +507 -0
- meetdown-0.1.0/src/meetdown/clova.py +101 -0
- meetdown-0.1.0/src/meetdown/json_types.py +33 -0
- meetdown-0.1.0/src/meetdown/markdown.py +143 -0
- meetdown-0.1.0/src/meetdown/providers.py +462 -0
meetdown-0.1.0/PKG-INFO
ADDED
|
@@ -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.
|
meetdown-0.1.0/README.md
ADDED
|
@@ -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,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
|