loqalkit 1.6.2__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.
- loqalkit/__init__.py +197 -0
- loqalkit/_client.py +61 -0
- loqalkit/accent.py +86 -0
- loqalkit/audio_chunk.py +190 -0
- loqalkit/bilingual.py +225 -0
- loqalkit/canonical.py +312 -0
- loqalkit/codes.py +71 -0
- loqalkit/data/silero_vad.onnx +0 -0
- loqalkit/detect.py +986 -0
- loqalkit/disambiguate.py +413 -0
- loqalkit/furigana.py +225 -0
- loqalkit/gender.py +414 -0
- loqalkit/languages.json +1435 -0
- loqalkit/localize_document.py +865 -0
- loqalkit/prompts.py +112 -0
- loqalkit/refine.py +184 -0
- loqalkit/registry.py +130 -0
- loqalkit/romanize.py +486 -0
- loqalkit/sample_text.py +72 -0
- loqalkit/script_info.py +42 -0
- loqalkit/segment_turns.py +624 -0
- loqalkit/segmentation.py +197 -0
- loqalkit/server/__init__.py +0 -0
- loqalkit/server/app.py +1406 -0
- loqalkit/speakers.py +117 -0
- loqalkit/transcribe.py +430 -0
- loqalkit/transcribe_document.py +571 -0
- loqalkit/translate.py +356 -0
- loqalkit/tts.py +201 -0
- loqalkit/vad.py +320 -0
- loqalkit/vad_timing.py +84 -0
- loqalkit/vocalize.py +133 -0
- loqalkit-1.6.2.dist-info/METADATA +104 -0
- loqalkit-1.6.2.dist-info/RECORD +38 -0
- loqalkit-1.6.2.dist-info/WHEEL +4 -0
- loqalkit-1.6.2.dist-info/entry_points.txt +2 -0
- loqalkit-1.6.2.dist-info/licenses/LICENSE +23 -0
- loqalkit-1.6.2.dist-info/licenses/THIRD_PARTY_NOTICES.md +33 -0
loqalkit/__init__.py
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""LoqalKit — unified language SDK.
|
|
2
|
+
|
|
3
|
+
Detection, transcription, romanization, translation, vocalization,
|
|
4
|
+
bilingual segmentation, and script metadata. Single source of truth
|
|
5
|
+
for all Loqal language tech.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from .accent import (
|
|
9
|
+
ACCENT_MAPS,
|
|
10
|
+
has_accent_map,
|
|
11
|
+
map_accent,
|
|
12
|
+
map_accent_or_default,
|
|
13
|
+
)
|
|
14
|
+
from .bilingual import (
|
|
15
|
+
BILINGUAL_TAG_RE,
|
|
16
|
+
has_bilingual_markers,
|
|
17
|
+
parse_bilingual_segments,
|
|
18
|
+
resolve_marker,
|
|
19
|
+
strip_bilingual_markers,
|
|
20
|
+
tag_bilingual_async,
|
|
21
|
+
)
|
|
22
|
+
from .canonical import (
|
|
23
|
+
canonical,
|
|
24
|
+
coerce_detected_code,
|
|
25
|
+
contains_cantonese,
|
|
26
|
+
same_language,
|
|
27
|
+
)
|
|
28
|
+
from .codes import ALL_BCP47_CODES, LANG_TO_BCP47, normalize_bcp47
|
|
29
|
+
from .detect import (
|
|
30
|
+
detect_language_from_audio,
|
|
31
|
+
detect_language_from_text,
|
|
32
|
+
detect_language_from_text_async,
|
|
33
|
+
)
|
|
34
|
+
from .disambiguate import (
|
|
35
|
+
DISAMBIGUATION_FAMILIES,
|
|
36
|
+
disambiguate_within_family,
|
|
37
|
+
disambiguate_within_family_async,
|
|
38
|
+
family_for_code,
|
|
39
|
+
)
|
|
40
|
+
from .furigana import (
|
|
41
|
+
align_reading_to_surface,
|
|
42
|
+
annotate as furigana_annotate,
|
|
43
|
+
contains_kanji,
|
|
44
|
+
)
|
|
45
|
+
from .prompts import LANG_NAMES, language_prompt
|
|
46
|
+
from .refine import (
|
|
47
|
+
Refiner,
|
|
48
|
+
is_refinable,
|
|
49
|
+
refine_segments,
|
|
50
|
+
refine_text,
|
|
51
|
+
)
|
|
52
|
+
from .registry import (
|
|
53
|
+
LANGUAGE_LABELS,
|
|
54
|
+
LANGUAGES,
|
|
55
|
+
get_language,
|
|
56
|
+
has_grammatical_gender,
|
|
57
|
+
is_rtl,
|
|
58
|
+
language_label,
|
|
59
|
+
uses_non_latin_script,
|
|
60
|
+
)
|
|
61
|
+
from .romanize import (
|
|
62
|
+
LANG_ROMANIZATION_INSTRUCTIONS,
|
|
63
|
+
japanese_readings,
|
|
64
|
+
romanize_batch,
|
|
65
|
+
romanize_batch_as_dict,
|
|
66
|
+
romanize_batch_async,
|
|
67
|
+
romanize_segments,
|
|
68
|
+
romanize_segments_async,
|
|
69
|
+
)
|
|
70
|
+
from .script_info import (
|
|
71
|
+
supports_romanization,
|
|
72
|
+
uses_non_latin_script,
|
|
73
|
+
)
|
|
74
|
+
from .localize_document import (
|
|
75
|
+
CONTENT_TYPES as LOCALIZE_CONTENT_TYPES,
|
|
76
|
+
LocalizeResult,
|
|
77
|
+
SUPPORTED_EXTENSIONS as LOCALIZE_SUPPORTED_EXTENSIONS,
|
|
78
|
+
content_type_for as localize_content_type_for,
|
|
79
|
+
localize_document,
|
|
80
|
+
localize_document_sync,
|
|
81
|
+
)
|
|
82
|
+
from .gender import (
|
|
83
|
+
DisambiguationResult,
|
|
84
|
+
GenderInference,
|
|
85
|
+
Replacement,
|
|
86
|
+
disambiguate as gender_disambiguate,
|
|
87
|
+
detect_gendered_language,
|
|
88
|
+
has_arabic,
|
|
89
|
+
has_hebrew,
|
|
90
|
+
infer_gender,
|
|
91
|
+
preprocess_for_tts,
|
|
92
|
+
)
|
|
93
|
+
from .segment_turns import segment_turns
|
|
94
|
+
from .segmentation import split_long_segments
|
|
95
|
+
from .speakers import extract_speaker_info
|
|
96
|
+
from .transcribe import transcribe, transcribe_async
|
|
97
|
+
from .transcribe_document import (
|
|
98
|
+
DocumentCancelled,
|
|
99
|
+
transcribe_document,
|
|
100
|
+
transcribe_document_async,
|
|
101
|
+
transliterate_name,
|
|
102
|
+
transliterate_name_for_reader,
|
|
103
|
+
)
|
|
104
|
+
from .translate import (
|
|
105
|
+
LANGUAGE_NAMES,
|
|
106
|
+
translate_json_content,
|
|
107
|
+
translate_strings_to_locales,
|
|
108
|
+
translate_text,
|
|
109
|
+
)
|
|
110
|
+
from .sample_text import generate_sample_text
|
|
111
|
+
from .tts import synthesize as tts_synthesize, synthesize_async as tts_synthesize_async
|
|
112
|
+
from .vocalize import vocalize_batch
|
|
113
|
+
|
|
114
|
+
__all__ = [
|
|
115
|
+
"ACCENT_MAPS",
|
|
116
|
+
"ALL_BCP47_CODES",
|
|
117
|
+
"BILINGUAL_TAG_RE",
|
|
118
|
+
"DISAMBIGUATION_FAMILIES",
|
|
119
|
+
"LANG_NAMES",
|
|
120
|
+
"LANG_ROMANIZATION_INSTRUCTIONS",
|
|
121
|
+
"LANG_TO_BCP47",
|
|
122
|
+
"LANGUAGES",
|
|
123
|
+
"LANGUAGE_LABELS",
|
|
124
|
+
"LANGUAGE_NAMES",
|
|
125
|
+
"LOCALIZE_CONTENT_TYPES",
|
|
126
|
+
"LOCALIZE_SUPPORTED_EXTENSIONS",
|
|
127
|
+
"LocalizeResult",
|
|
128
|
+
"uses_non_latin_script",
|
|
129
|
+
"DisambiguationResult",
|
|
130
|
+
"GenderInference",
|
|
131
|
+
"Replacement",
|
|
132
|
+
"align_reading_to_surface",
|
|
133
|
+
"canonical",
|
|
134
|
+
"coerce_detected_code",
|
|
135
|
+
"contains_cantonese",
|
|
136
|
+
"contains_kanji",
|
|
137
|
+
"furigana_annotate",
|
|
138
|
+
"detect_gendered_language",
|
|
139
|
+
"detect_language_from_audio",
|
|
140
|
+
"detect_language_from_text",
|
|
141
|
+
"detect_language_from_text_async",
|
|
142
|
+
"disambiguate_within_family",
|
|
143
|
+
"disambiguate_within_family_async",
|
|
144
|
+
"has_accent_map",
|
|
145
|
+
"extract_speaker_info",
|
|
146
|
+
"gender_disambiguate",
|
|
147
|
+
"generate_sample_text",
|
|
148
|
+
"family_for_code",
|
|
149
|
+
"get_language",
|
|
150
|
+
"has_arabic",
|
|
151
|
+
"has_grammatical_gender",
|
|
152
|
+
"has_hebrew",
|
|
153
|
+
"is_refinable",
|
|
154
|
+
"is_rtl",
|
|
155
|
+
"japanese_readings",
|
|
156
|
+
"language_label",
|
|
157
|
+
"language_prompt",
|
|
158
|
+
"localize_content_type_for",
|
|
159
|
+
"localize_document",
|
|
160
|
+
"localize_document_sync",
|
|
161
|
+
"map_accent",
|
|
162
|
+
"map_accent_or_default",
|
|
163
|
+
"infer_gender",
|
|
164
|
+
"normalize_bcp47",
|
|
165
|
+
"has_bilingual_markers",
|
|
166
|
+
"parse_bilingual_segments",
|
|
167
|
+
"resolve_marker",
|
|
168
|
+
"Refiner",
|
|
169
|
+
"refine_segments",
|
|
170
|
+
"refine_text",
|
|
171
|
+
"romanize_batch",
|
|
172
|
+
"romanize_batch_as_dict",
|
|
173
|
+
"romanize_batch_async",
|
|
174
|
+
"romanize_segments",
|
|
175
|
+
"romanize_segments_async",
|
|
176
|
+
"same_language",
|
|
177
|
+
"segment_turns",
|
|
178
|
+
"split_long_segments",
|
|
179
|
+
"strip_bilingual_markers",
|
|
180
|
+
"supports_romanization",
|
|
181
|
+
"tag_bilingual_async",
|
|
182
|
+
"transcribe",
|
|
183
|
+
"transcribe_async",
|
|
184
|
+
"DocumentCancelled",
|
|
185
|
+
"transcribe_document",
|
|
186
|
+
"transcribe_document_async",
|
|
187
|
+
"transliterate_name",
|
|
188
|
+
"transliterate_name_for_reader",
|
|
189
|
+
"preprocess_for_tts",
|
|
190
|
+
"translate_json_content",
|
|
191
|
+
"translate_strings_to_locales",
|
|
192
|
+
"translate_text",
|
|
193
|
+
"tts_synthesize",
|
|
194
|
+
"tts_synthesize_async",
|
|
195
|
+
"uses_non_latin_script",
|
|
196
|
+
"vocalize_batch",
|
|
197
|
+
]
|
loqalkit/_client.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Internal OpenAI client accessor for loqalkit modules.
|
|
2
|
+
|
|
3
|
+
Two paths:
|
|
4
|
+
1. When imported inside ManabuDashboard (`shared/` on sys.path), we
|
|
5
|
+
reuse the Dashboard's `shared.manabu_openai.get_client` so cost
|
|
6
|
+
tracking and the shared secret discovery stay consistent.
|
|
7
|
+
2. When imported standalone (pip install loqalkit), we fall back to a
|
|
8
|
+
self-contained client that reads MANABU_APP_SECRET from the
|
|
9
|
+
environment and points at the Cloudflare proxy.
|
|
10
|
+
|
|
11
|
+
Every loqalkit module (detect, transcribe, romanize, translate, etc.)
|
|
12
|
+
uses `from ._client import get_client` and the runtime decides which
|
|
13
|
+
variant to use.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import os
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
_PROXY_BASE_URL = os.environ.get(
|
|
22
|
+
"MANABU_PROXY_BASE_URL",
|
|
23
|
+
"https://manabu-openai-proxy.aviah.workers.dev",
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _fallback_get_client(*, cost_tracking: bool = True):
|
|
28
|
+
"""Standalone client — used when shared.manabu_openai isn't available."""
|
|
29
|
+
try:
|
|
30
|
+
from openai import OpenAI
|
|
31
|
+
except ImportError as e: # pragma: no cover
|
|
32
|
+
raise ImportError(
|
|
33
|
+
"openai package is required; install loqalkit with "
|
|
34
|
+
"its base deps or install 'openai>=1.0.0' manually."
|
|
35
|
+
) from e
|
|
36
|
+
|
|
37
|
+
secret = os.environ.get("MANABU_APP_SECRET")
|
|
38
|
+
if not secret:
|
|
39
|
+
raise RuntimeError(
|
|
40
|
+
"MANABU_APP_SECRET is not set — loqalkit cannot reach the "
|
|
41
|
+
"OpenAI proxy. Set the env var on the sidecar deployment."
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
return OpenAI(
|
|
45
|
+
base_url=f"{_PROXY_BASE_URL}/v1",
|
|
46
|
+
api_key="proxied",
|
|
47
|
+
default_headers={"X-App-Secret": secret},
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
try:
|
|
52
|
+
# Prefer Dashboard's implementation when available — keeps cost
|
|
53
|
+
# tracking + secret discovery consistent with the rest of the repo.
|
|
54
|
+
from shared.manabu_openai import get_client as _shared_get_client
|
|
55
|
+
|
|
56
|
+
def get_client(*, cost_tracking: bool = True):
|
|
57
|
+
return _shared_get_client(cost_tracking=cost_tracking)
|
|
58
|
+
|
|
59
|
+
except ImportError:
|
|
60
|
+
def get_client(*, cost_tracking: bool = True): # type: ignore[no-redef]
|
|
61
|
+
return _fallback_get_client(cost_tracking=cost_tracking)
|
loqalkit/accent.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Accent-to-BCP47 mapping for audio-based accent detection.
|
|
2
|
+
|
|
3
|
+
Maps accent classifier output labels to TTS-supported BCP-47 codes.
|
|
4
|
+
Each language that has an accent classifier gets its own mapping table.
|
|
5
|
+
Labels that don't map to a TTS-supported code are folded into the nearest
|
|
6
|
+
supported variant (e.g. "scotland" → "en-GB").
|
|
7
|
+
|
|
8
|
+
Adding a new language:
|
|
9
|
+
1. Train or find an accent classifier (see LoqalKitGPU/app/asr/accent_classifier.py).
|
|
10
|
+
2. Add a mapping table to ACCENT_MAPS below.
|
|
11
|
+
3. Done — detect.py picks it up automatically.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from typing import Optional
|
|
17
|
+
|
|
18
|
+
# Maps (iso_language, accent_label) → BCP-47 code that has TTS support.
|
|
19
|
+
#
|
|
20
|
+
# Structure: ACCENT_MAPS[iso639_3] = {model_label: bcp47_code}
|
|
21
|
+
#
|
|
22
|
+
# Only codes listed in extra_languages.txt / languages.txt should appear
|
|
23
|
+
# as values — these are the codes the TTS pipeline can actually serve.
|
|
24
|
+
# Labels that map to the same code are intentional folds (e.g. Scotland
|
|
25
|
+
# and Wales fold into en-GB because we don't have separate TTS for them).
|
|
26
|
+
|
|
27
|
+
ACCENT_MAPS: dict[str, dict[str, str]] = {
|
|
28
|
+
"eng": {
|
|
29
|
+
"us": "en-US",
|
|
30
|
+
"england": "en-GB",
|
|
31
|
+
"australia": "en-AU",
|
|
32
|
+
"indian": "en-IN",
|
|
33
|
+
"canada": "en-CA",
|
|
34
|
+
"ireland": "en-IE",
|
|
35
|
+
"newzealand": "en-NZ",
|
|
36
|
+
"philippines": "en-PH",
|
|
37
|
+
"singapore": "en-SG",
|
|
38
|
+
"hongkong": "en-HK",
|
|
39
|
+
"african": "en-ZA",
|
|
40
|
+
"scotland": "en-GB",
|
|
41
|
+
"wales": "en-GB",
|
|
42
|
+
"bermuda": "en-US",
|
|
43
|
+
"southatlandtic": "en-GB",
|
|
44
|
+
"malaysia": "en-SG",
|
|
45
|
+
},
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
# Default code per language when accent detection fails or returns unknown.
|
|
49
|
+
ACCENT_DEFAULTS: dict[str, str] = {
|
|
50
|
+
"eng": "en-US",
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def map_accent(iso_language: str, accent_label: str) -> Optional[str]:
|
|
55
|
+
"""Map a model's accent label to a TTS-supported BCP-47 code.
|
|
56
|
+
|
|
57
|
+
Returns None if no mapping exists for this language or label.
|
|
58
|
+
"""
|
|
59
|
+
lang_map = ACCENT_MAPS.get(iso_language)
|
|
60
|
+
if not lang_map:
|
|
61
|
+
return None
|
|
62
|
+
return lang_map.get(accent_label)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def map_accent_or_default(iso_language: str, accent_label: str) -> Optional[str]:
|
|
66
|
+
"""Like map_accent but falls back to the language default."""
|
|
67
|
+
code = map_accent(iso_language, accent_label)
|
|
68
|
+
if code:
|
|
69
|
+
return code
|
|
70
|
+
return ACCENT_DEFAULTS.get(iso_language)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def has_accent_map(iso_language: str) -> bool:
|
|
74
|
+
"""Whether we have accent mappings for this language."""
|
|
75
|
+
return iso_language in ACCENT_MAPS
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def supported_languages() -> list[str]:
|
|
79
|
+
"""ISO 639-3 codes that have accent mapping tables."""
|
|
80
|
+
return sorted(ACCENT_MAPS.keys())
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def codes_for_language(iso_language: str) -> list[str]:
|
|
84
|
+
"""All distinct BCP-47 codes reachable from a language's accent map."""
|
|
85
|
+
lang_map = ACCENT_MAPS.get(iso_language, {})
|
|
86
|
+
return sorted(set(lang_map.values()))
|
loqalkit/audio_chunk.py
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""Input chunking for long/large audio, inside loqalkit core.
|
|
2
|
+
|
|
3
|
+
gpt-4o-transcribe rejects any single request over 25 MB OR longer than ~1400 s
|
|
4
|
+
("…is the maximum for this model"), and a long diarized clip can also overrun an
|
|
5
|
+
upstream proxy timeout. So a long recording must be split *before* it reaches
|
|
6
|
+
the model — and "long" means either trigger: a low-bitrate mobile recording can
|
|
7
|
+
sail under the size cap while still blowing the duration cap. This is the single
|
|
8
|
+
place that decision lives; every caller (mobile, the token server, omnisite's
|
|
9
|
+
BFF, any future client) forwards whole audio and gets the same treatment without
|
|
10
|
+
re-implementing it.
|
|
11
|
+
|
|
12
|
+
We transcode with ffmpeg to speech-grade 16 kHz mono 32 kbps MP3 (~8× smaller,
|
|
13
|
+
strips video), cut into fixed-duration chunks, transcribe each independently,
|
|
14
|
+
and stitch the segments back with offset timecodes.
|
|
15
|
+
|
|
16
|
+
`merge_chunk_transcripts` is pure (no ffmpeg, no I/O) so the offset/concat logic
|
|
17
|
+
is unit-tested in isolation.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import logging
|
|
21
|
+
import os
|
|
22
|
+
import re
|
|
23
|
+
import shutil
|
|
24
|
+
import subprocess
|
|
25
|
+
import tempfile
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger(__name__)
|
|
29
|
+
|
|
30
|
+
# Inputs at or below this size keep the single-shot path (already < the 25 MB cap).
|
|
31
|
+
CHUNK_THRESHOLD_BYTES = 24 * 1024 * 1024
|
|
32
|
+
|
|
33
|
+
# gpt-4o-transcribe also rejects any single request whose audio runs longer than
|
|
34
|
+
# 1400 s ("…is the maximum for this model"), INDEPENDENT of byte size. A low-bitrate
|
|
35
|
+
# recording — e.g. 31 min of 32 kbps mobile AAC ≈ 7 MB — slips under the size cap
|
|
36
|
+
# above yet still blows that duration cap, so length is a second, independent reason
|
|
37
|
+
# to chunk. Kept with a safe margin below the 1400 s hard cap.
|
|
38
|
+
MAX_SINGLE_SHOT_SECONDS = 1200 # 20 min (model hard cap is 1400 s)
|
|
39
|
+
|
|
40
|
+
# 3 min @ 32 kbps mono ≈ 0.7 MB — far under the 25 MB cap, and short enough that
|
|
41
|
+
# diarized transcription (~2× real-time, ≈90 s) stays well under upstream timeouts.
|
|
42
|
+
SEGMENT_SECONDS = 180
|
|
43
|
+
|
|
44
|
+
# Refuse absurd inputs rather than spawning hundreds of requests. 60 × 3 min = 3 h.
|
|
45
|
+
MAX_CHUNKS = 60
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def needs_chunking(audio_path: Path) -> bool:
|
|
49
|
+
"""True when the file needs transcode + segmentation before the model.
|
|
50
|
+
|
|
51
|
+
Two independent triggers — either is enough:
|
|
52
|
+
- size > CHUNK_THRESHOLD_BYTES (gpt-4o-transcribe's 25 MB per-request cap)
|
|
53
|
+
- length > MAX_SINGLE_SHOT_SECONDS (its ~1400 s per-request duration cap)
|
|
54
|
+
|
|
55
|
+
Checking size alone misses the "small but long" case: a low-bitrate mobile
|
|
56
|
+
recording (e.g. 31 min of 32 kbps AAC ≈ 7 MB) slips under the size cap yet
|
|
57
|
+
still blows the duration cap, and the model 400s with "audio duration … is
|
|
58
|
+
longer than 1400 seconds which is the maximum for this model".
|
|
59
|
+
"""
|
|
60
|
+
try:
|
|
61
|
+
if os.path.getsize(audio_path) > CHUNK_THRESHOLD_BYTES:
|
|
62
|
+
return True
|
|
63
|
+
except OSError:
|
|
64
|
+
return False
|
|
65
|
+
# Only reached when under the size cap. The duration probe is a cheap header
|
|
66
|
+
# read; an unknown duration (0.0 — no header / ffprobe unavailable) degrades to
|
|
67
|
+
# size-only, leaving MAX_CHUNKS to bound truly pathological inputs. No regression.
|
|
68
|
+
return _audio_duration_seconds(audio_path) > MAX_SINGLE_SHOT_SECONDS
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _audio_duration_seconds(audio_path: Path) -> float:
|
|
72
|
+
"""Audio duration in seconds via ffprobe, or 0.0 if it can't be determined.
|
|
73
|
+
|
|
74
|
+
A header read (no decode), so it's cheap even for a large file. 0.0 means
|
|
75
|
+
"unknown" — the caller treats that as "no duration trigger". Mirrors
|
|
76
|
+
detect._audio_duration so duration is read the same way across the package.
|
|
77
|
+
"""
|
|
78
|
+
try:
|
|
79
|
+
out = subprocess.run(
|
|
80
|
+
[
|
|
81
|
+
"ffprobe", "-v", "error", "-show_entries", "format=duration",
|
|
82
|
+
"-of", "default=nokey=1:noprint_wrappers=1", str(audio_path),
|
|
83
|
+
],
|
|
84
|
+
capture_output=True, text=True, check=True,
|
|
85
|
+
)
|
|
86
|
+
return float(out.stdout.strip())
|
|
87
|
+
except Exception: # noqa: BLE001 - missing/odd ffprobe → fall back to size-only
|
|
88
|
+
return 0.0
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class AudioChunk:
|
|
92
|
+
__slots__ = ("path", "offset_seconds")
|
|
93
|
+
|
|
94
|
+
def __init__(self, path: Path, offset_seconds: float):
|
|
95
|
+
self.path = path
|
|
96
|
+
self.offset_seconds = offset_seconds
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def chunk_audio(audio_path: Path) -> tuple[list[AudioChunk], "Path"]:
|
|
100
|
+
"""Transcode + segment an audio/video file into speech-grade MP3 chunks.
|
|
101
|
+
|
|
102
|
+
Returns (chunks, work_dir). The caller MUST remove work_dir when done
|
|
103
|
+
(shutil.rmtree). Raises if the input would exceed MAX_CHUNKS or ffmpeg fails.
|
|
104
|
+
"""
|
|
105
|
+
work_dir = Path(tempfile.mkdtemp(prefix="loqalkit-chunk-"))
|
|
106
|
+
out_pattern = str(work_dir / "chunk_%04d.mp3")
|
|
107
|
+
try:
|
|
108
|
+
subprocess.run(
|
|
109
|
+
[
|
|
110
|
+
"ffmpeg", "-hide_banner", "-loglevel", "error",
|
|
111
|
+
"-i", str(audio_path),
|
|
112
|
+
"-vn", "-ac", "1", "-ar", "16000",
|
|
113
|
+
"-c:a", "libmp3lame", "-b:a", "32k",
|
|
114
|
+
"-f", "segment", "-segment_time", str(SEGMENT_SECONDS),
|
|
115
|
+
"-reset_timestamps", "1",
|
|
116
|
+
out_pattern,
|
|
117
|
+
],
|
|
118
|
+
capture_output=True,
|
|
119
|
+
check=True,
|
|
120
|
+
)
|
|
121
|
+
except subprocess.CalledProcessError as e:
|
|
122
|
+
shutil.rmtree(work_dir, ignore_errors=True)
|
|
123
|
+
stderr = (e.stderr or b"").decode("utf-8", "replace")[-2000:]
|
|
124
|
+
raise RuntimeError(f"ffmpeg segmentation failed: {stderr.strip()}") from e
|
|
125
|
+
|
|
126
|
+
files = sorted(
|
|
127
|
+
f for f in os.listdir(work_dir)
|
|
128
|
+
if re.fullmatch(r"chunk_\d{4}\.mp3", f)
|
|
129
|
+
)
|
|
130
|
+
if not files:
|
|
131
|
+
shutil.rmtree(work_dir, ignore_errors=True)
|
|
132
|
+
raise RuntimeError("ffmpeg produced no audio chunks (unsupported or empty input)")
|
|
133
|
+
if len(files) > MAX_CHUNKS:
|
|
134
|
+
shutil.rmtree(work_dir, ignore_errors=True)
|
|
135
|
+
raise RuntimeError(
|
|
136
|
+
f"Recording too long: {len(files)} chunks exceeds the {MAX_CHUNKS}-chunk "
|
|
137
|
+
f"limit (~{MAX_CHUNKS * SEGMENT_SECONDS // 3600} h)."
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
chunks = [
|
|
141
|
+
AudioChunk(work_dir / name, i * SEGMENT_SECONDS)
|
|
142
|
+
for i, name in enumerate(files)
|
|
143
|
+
]
|
|
144
|
+
return chunks, work_dir
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def merge_chunk_transcripts(parts: list[dict]) -> dict:
|
|
148
|
+
"""Stitch per-chunk transcribe() dicts into one. Pure — no ffmpeg, no I/O.
|
|
149
|
+
|
|
150
|
+
Each part is a transcribe() result plus an "offset_seconds" key. Text is
|
|
151
|
+
joined in chunk order; every segment's start/end is shifted by its chunk's
|
|
152
|
+
offset; speaker_genders maps are merged (earlier chunks win on conflict).
|
|
153
|
+
|
|
154
|
+
Acoustic speaker IDs ("Speaker 1"…) are per-chunk and NOT guaranteed to mean
|
|
155
|
+
the same person across chunk boundaries — acceptable for the single/few-
|
|
156
|
+
speaker long-recording case this path targets.
|
|
157
|
+
"""
|
|
158
|
+
ordered = sorted(parts, key=lambda p: p.get("offset_seconds", 0))
|
|
159
|
+
|
|
160
|
+
text = "\n".join(
|
|
161
|
+
(p.get("text") or "").strip()
|
|
162
|
+
for p in ordered
|
|
163
|
+
if (p.get("text") or "").strip()
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
segments: list[dict] = []
|
|
167
|
+
for part in ordered:
|
|
168
|
+
offset = part.get("offset_seconds", 0)
|
|
169
|
+
for seg in part.get("segments") or []:
|
|
170
|
+
new_seg = dict(seg)
|
|
171
|
+
if isinstance(seg.get("start"), (int, float)):
|
|
172
|
+
new_seg["start"] = round(seg["start"] + offset, 2)
|
|
173
|
+
if isinstance(seg.get("end"), (int, float)):
|
|
174
|
+
new_seg["end"] = round(seg["end"] + offset, 2)
|
|
175
|
+
segments.append(new_seg)
|
|
176
|
+
|
|
177
|
+
speaker_genders: dict = {}
|
|
178
|
+
for part in ordered:
|
|
179
|
+
pg = part.get("speaker_genders") or {}
|
|
180
|
+
for k, v in pg.items():
|
|
181
|
+
speaker_genders.setdefault(k, v)
|
|
182
|
+
|
|
183
|
+
language = next((p.get("language") for p in ordered if p.get("language")), None)
|
|
184
|
+
|
|
185
|
+
return {
|
|
186
|
+
"text": text,
|
|
187
|
+
"segments": segments,
|
|
188
|
+
"speaker_genders": speaker_genders,
|
|
189
|
+
"language": language,
|
|
190
|
+
}
|