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 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()))
@@ -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
+ }