s2t 0.1.2__tar.gz → 0.1.3__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.
- {s2t-0.1.2 → s2t-0.1.3}/.gitignore +1 -0
- {s2t-0.1.2 → s2t-0.1.3}/Makefile +3 -3
- {s2t-0.1.2 → s2t-0.1.3}/PKG-INFO +3 -1
- {s2t-0.1.2 → s2t-0.1.3}/pyproject.toml +4 -0
- {s2t-0.1.2 → s2t-0.1.3}/src/s2t/cli.py +90 -2
- {s2t-0.1.2 → s2t-0.1.3}/src/s2t/config.py +1 -0
- s2t-0.1.3/src/s2t/translator/__init__.py +9 -0
- s2t-0.1.3/src/s2t/translator/argos_backend.py +472 -0
- {s2t-0.1.2 → s2t-0.1.3}/src/s2t/types.py +3 -1
- {s2t-0.1.2 → s2t-0.1.3}/src/s2t/whisper_engine.py +9 -2
- {s2t-0.1.2 → s2t-0.1.3}/src/s2t.egg-info/PKG-INFO +3 -1
- {s2t-0.1.2 → s2t-0.1.3}/src/s2t.egg-info/SOURCES.txt +3 -1
- {s2t-0.1.2 → s2t-0.1.3}/src/s2t.egg-info/requires.txt +3 -0
- {s2t-0.1.2 → s2t-0.1.3}/.pre-commit-config.yaml +0 -0
- {s2t-0.1.2 → s2t-0.1.3}/AGENTS.md +0 -0
- {s2t-0.1.2 → s2t-0.1.3}/CONTRIBUTING.md +0 -0
- {s2t-0.1.2 → s2t-0.1.3}/MANIFEST.in +0 -0
- {s2t-0.1.2 → s2t-0.1.3}/README.md +0 -0
- {s2t-0.1.2 → s2t-0.1.3}/docs/RELEASING.md +0 -0
- {s2t-0.1.2 → s2t-0.1.3}/docs/SESSION_STATE.md +0 -0
- {s2t-0.1.2 → s2t-0.1.3}/scripts/bench_transcribe.py +0 -0
- {s2t-0.1.2 → s2t-0.1.3}/setup.cfg +0 -0
- {s2t-0.1.2 → s2t-0.1.3}/src/s2t/__init__.py +0 -0
- {s2t-0.1.2 → s2t-0.1.3}/src/s2t/outputs.py +0 -0
- {s2t-0.1.2 → s2t-0.1.3}/src/s2t/py.typed +0 -0
- {s2t-0.1.2 → s2t-0.1.3}/src/s2t/recorder.py +0 -0
- {s2t-0.1.2 → s2t-0.1.3}/src/s2t/utils.py +0 -0
- {s2t-0.1.2 → s2t-0.1.3}/src/s2t.egg-info/dependency_links.txt +0 -0
- {s2t-0.1.2 → s2t-0.1.3}/src/s2t.egg-info/entry_points.txt +0 -0
- {s2t-0.1.2 → s2t-0.1.3}/src/s2t.egg-info/top_level.txt +0 -0
{s2t-0.1.2 → s2t-0.1.3}/Makefile
RENAMED
@@ -117,8 +117,8 @@ precommit-install: guard-venv ensure-dev
|
|
117
117
|
pre-commit install --install-hooks
|
118
118
|
|
119
119
|
guard-venv:
|
120
|
-
@if [ -n "$$VIRTUAL_ENV" ] && [ "$$VIRTUAL_ENV" != "$$PWD/.
|
121
|
-
echo "Error: active venv ($$VIRTUAL_ENV) differs from project .venv ($$PWD/.
|
122
|
-
echo "Please 'deactivate' or use the project venv (.
|
120
|
+
@if [ -n "$$VIRTUAL_ENV" ] && [ "$$VIRTUAL_ENV" != "$$PWD/.venv312" ]; then \
|
121
|
+
echo "Error: active venv ($$VIRTUAL_ENV) differs from project .venv ($$PWD/.venv312)."; \
|
122
|
+
echo "Please 'deactivate' or use the project venv (.venv312)."; \
|
123
123
|
exit 1; \
|
124
124
|
fi
|
{s2t-0.1.2 → s2t-0.1.3}/PKG-INFO
RENAMED
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.4
|
2
2
|
Name: s2t
|
3
|
-
Version: 0.1.
|
3
|
+
Version: 0.1.3
|
4
4
|
Summary: Speech to Text (s2t): Record audio, run Whisper, export formats, and copy transcript to clipboard.
|
5
5
|
Author: Maintainers
|
6
6
|
License-Expression: LicenseRef-Proprietary
|
@@ -23,6 +23,8 @@ Requires-Dist: mypy>=1.7; extra == "dev"
|
|
23
23
|
Requires-Dist: build>=1; extra == "dev"
|
24
24
|
Requires-Dist: setuptools-scm>=8; extra == "dev"
|
25
25
|
Requires-Dist: twine>=4; extra == "dev"
|
26
|
+
Provides-Extra: translate
|
27
|
+
Requires-Dist: argostranslate>=1.9.0; extra == "translate"
|
26
28
|
|
27
29
|
# s2t
|
28
30
|
|
@@ -40,6 +40,11 @@ from . import __version__
|
|
40
40
|
from .config import SessionOptions
|
41
41
|
from .outputs import concat_audio, write_final_outputs
|
42
42
|
from .recorder import Recorder
|
43
|
+
from .translator.argos_backend import (
|
44
|
+
ArgosTranslator,
|
45
|
+
ensure_packages_background,
|
46
|
+
translate_result_segments,
|
47
|
+
)
|
43
48
|
from .types import TranscriptionResult
|
44
49
|
from .utils import (
|
45
50
|
convert_wav_to_mp3,
|
@@ -62,7 +67,7 @@ def run_session(opts: SessionOptions) -> int:
|
|
62
67
|
|
63
68
|
engine = WhisperEngine(
|
64
69
|
model_name=opts.model,
|
65
|
-
translate=
|
70
|
+
translate=False, # translation handled as post-processing
|
66
71
|
language=opts.lang,
|
67
72
|
native_segmentation=opts.native_segmentation,
|
68
73
|
session_dir=session_dir,
|
@@ -73,6 +78,27 @@ def run_session(opts: SessionOptions) -> int:
|
|
73
78
|
)
|
74
79
|
ex, fut = engine.preload()
|
75
80
|
|
81
|
+
# Determine translation target languages from options
|
82
|
+
target_langs: list[str] = []
|
83
|
+
if opts.translate_to:
|
84
|
+
target_langs = list(dict.fromkeys([s.strip().lower() for s in opts.translate_to if s]))
|
85
|
+
elif opts.translate:
|
86
|
+
target_langs = ["en"]
|
87
|
+
|
88
|
+
# Background auto-install/update Argos packages as early as possible
|
89
|
+
detected_lang: dict[str, str | None] = {"code": None}
|
90
|
+
detected_lang_event = threading.Event()
|
91
|
+
translator: ArgosTranslator | None = None
|
92
|
+
if target_langs:
|
93
|
+
translator = ArgosTranslator(verbose=opts.verbose)
|
94
|
+
ensure_packages_background(
|
95
|
+
translator,
|
96
|
+
src_lang_hint=(opts.lang.lower() if opts.lang else None),
|
97
|
+
target_langs=target_langs,
|
98
|
+
detected_lang_event=detected_lang_event,
|
99
|
+
detected_lang_holder=detected_lang,
|
100
|
+
)
|
101
|
+
|
76
102
|
tx_q: queue.Queue[tuple[int, Path, int, float]] = queue.Queue()
|
77
103
|
cumulative_text = ""
|
78
104
|
next_to_emit = 1
|
@@ -134,6 +160,12 @@ def run_session(opts: SessionOptions) -> int:
|
|
134
160
|
# Build latest-ready prompt based on already finished chunks
|
135
161
|
prompt = _build_latest_ready_prompt(idx, finished_texts)
|
136
162
|
res = engine.transcribe_chunk(model, path, frames, initial_prompt=prompt)
|
163
|
+
# Record detected language once (for translator preload if needed)
|
164
|
+
if target_langs and detected_lang["code"] is None:
|
165
|
+
lang_code = str(res.get("language") or "").strip().lower()
|
166
|
+
if lang_code:
|
167
|
+
detected_lang["code"] = lang_code
|
168
|
+
detected_lang_event.set()
|
137
169
|
engine.write_chunk_outputs(res, path)
|
138
170
|
text_i = (res.get("text", "") or "").strip()
|
139
171
|
with agg_lock:
|
@@ -260,6 +292,55 @@ def run_session(opts: SessionOptions) -> int:
|
|
260
292
|
print("=" * 60)
|
261
293
|
print(text_final.rstrip("\n"))
|
262
294
|
|
295
|
+
# Post-processing: translate outputs for requested target languages
|
296
|
+
if target_langs and translator is not None:
|
297
|
+
# Decide source language: CLI hint takes precedence; else detected; else skip with warning
|
298
|
+
src_lang = (opts.lang.lower() if opts.lang else (detected_lang["code"] or "")).strip()
|
299
|
+
if not src_lang:
|
300
|
+
if opts.verbose:
|
301
|
+
print(
|
302
|
+
"Warning: Could not determine source language for translation; skipping post-translation.",
|
303
|
+
file=sys.stderr,
|
304
|
+
)
|
305
|
+
else:
|
306
|
+
# Skip identical language targets
|
307
|
+
effective_targets = [t for t in target_langs if t.lower() != src_lang.lower()]
|
308
|
+
# Ensure required packages if missing; perform synchronous install as needed
|
309
|
+
for tgt in effective_targets:
|
310
|
+
if not translator.has_package(src_lang, tgt):
|
311
|
+
print(
|
312
|
+
f"Ensuring Argos translation package for '{src_lang}->{tgt}' (may download 50–250 MB)…",
|
313
|
+
file=sys.stderr,
|
314
|
+
)
|
315
|
+
ok = False
|
316
|
+
try:
|
317
|
+
ok = translator.ensure_package(src_lang, tgt)
|
318
|
+
except Exception as e:
|
319
|
+
print(
|
320
|
+
f"Warning: could not install '{src_lang}->{tgt}' package: {e}",
|
321
|
+
file=sys.stderr,
|
322
|
+
)
|
323
|
+
if not ok and not translator.has_package(src_lang, tgt):
|
324
|
+
print(
|
325
|
+
f"Warning: translation package unavailable or failed for '{src_lang}->{tgt}'. Skipping.",
|
326
|
+
file=sys.stderr,
|
327
|
+
)
|
328
|
+
continue
|
329
|
+
try:
|
330
|
+
translated = translate_result_segments(translator, merged, src_lang, tgt)
|
331
|
+
# Write translated outputs with language suffix by passing a suffixed base path
|
332
|
+
suffixed = base_audio_path.with_name(
|
333
|
+
f"{base_audio_path.stem}.{tgt}{base_audio_path.suffix}"
|
334
|
+
)
|
335
|
+
write_final_outputs(translated, session_dir, suffixed)
|
336
|
+
if opts.verbose:
|
337
|
+
print(f"Created translated outputs for '{tgt}'.", file=sys.stderr)
|
338
|
+
except Exception as e:
|
339
|
+
print(
|
340
|
+
f"Warning: failed to translate to '{tgt}': {e}",
|
341
|
+
file=sys.stderr,
|
342
|
+
)
|
343
|
+
|
263
344
|
if opts.profile:
|
264
345
|
try:
|
265
346
|
prof_path = session_dir / "profile.json"
|
@@ -329,7 +410,13 @@ def main(argv: list[str] | None = None) -> int:
|
|
329
410
|
"-t",
|
330
411
|
"--translate",
|
331
412
|
action="store_true",
|
332
|
-
help="
|
413
|
+
help="After transcription, translate all outputs to English (post-processing)",
|
414
|
+
)
|
415
|
+
parser.add_argument(
|
416
|
+
"--translate-to",
|
417
|
+
action="append",
|
418
|
+
default=None,
|
419
|
+
help="After transcription, translate all outputs to the given language (can be repeated)",
|
333
420
|
)
|
334
421
|
parser.add_argument(
|
335
422
|
"-v",
|
@@ -404,6 +491,7 @@ def main(argv: list[str] | None = None) -> int:
|
|
404
491
|
model=args.model,
|
405
492
|
lang=args.lang,
|
406
493
|
translate=args.translate,
|
494
|
+
translate_to=(args.translate_to or []),
|
407
495
|
native_segmentation=getattr(args, "native_segmentation", False),
|
408
496
|
verbose=args.verbose,
|
409
497
|
edit=args.edit,
|
@@ -0,0 +1,472 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
import os
|
4
|
+
import platform
|
5
|
+
import threading
|
6
|
+
import time
|
7
|
+
from collections.abc import Iterable
|
8
|
+
from pathlib import Path
|
9
|
+
|
10
|
+
from ..types import SegmentDict, TranscriptionResult
|
11
|
+
|
12
|
+
# Global install coordination to avoid duplicate downloads in parallel
|
13
|
+
_install_lock = threading.Lock()
|
14
|
+
_inflight: dict[tuple[str, str], threading.Event] = {}
|
15
|
+
|
16
|
+
|
17
|
+
class ArgosTranslator:
|
18
|
+
"""Thin wrapper around argostranslate for install + translate.
|
19
|
+
|
20
|
+
This module performs automatic package installation (network required once)
|
21
|
+
and then translates text fully offline.
|
22
|
+
"""
|
23
|
+
|
24
|
+
def __init__(self, verbose: bool = False) -> None:
|
25
|
+
self.verbose = verbose
|
26
|
+
|
27
|
+
def _debug(self, msg: str) -> None:
|
28
|
+
if self.verbose:
|
29
|
+
print(msg)
|
30
|
+
|
31
|
+
@staticmethod
|
32
|
+
def _guess_packages_dir() -> str:
|
33
|
+
try:
|
34
|
+
system = platform.system().lower()
|
35
|
+
home = Path.home()
|
36
|
+
candidates: list[Path] = []
|
37
|
+
if system == "darwin":
|
38
|
+
candidates.append(
|
39
|
+
home / "Library" / "Application Support" / "argos-translate" / "packages"
|
40
|
+
)
|
41
|
+
candidates.append(home / ".local" / "share" / "argos-translate" / "packages")
|
42
|
+
elif system == "windows":
|
43
|
+
appdata = os.environ.get("APPDATA") or str(home / "AppData" / "Roaming")
|
44
|
+
localapp = os.environ.get("LOCALAPPDATA") or str(home / "AppData" / "Local")
|
45
|
+
candidates.append(Path(appdata) / "argos-translate" / "packages")
|
46
|
+
candidates.append(Path(localapp) / "argos-translate" / "packages")
|
47
|
+
else:
|
48
|
+
candidates.append(home / ".local" / "share" / "argos-translate" / "packages")
|
49
|
+
for p in candidates:
|
50
|
+
if p.exists():
|
51
|
+
return str(p)
|
52
|
+
return str(candidates[0]) if candidates else "(unknown)"
|
53
|
+
except Exception as e:
|
54
|
+
return f"(unknown: {type(e).__name__}: {e})"
|
55
|
+
|
56
|
+
def ensure_package(self, src_lang: str, dst_lang: str) -> bool:
|
57
|
+
"""Ensure Argos package for src->dst is installed. Returns True if ready.
|
58
|
+
|
59
|
+
Attempts to install automatically if missing.
|
60
|
+
"""
|
61
|
+
try:
|
62
|
+
# Avoid importing unless needed so users without argostranslate can still run ASR-only.
|
63
|
+
import argostranslate.package as argos_pkg
|
64
|
+
except Exception: # pragma: no cover - external dep
|
65
|
+
# Keep core tool functional if argostranslate is not present
|
66
|
+
self._debug(
|
67
|
+
"Argos: argostranslate not installed; cannot auto-install translation packages."
|
68
|
+
)
|
69
|
+
return False
|
70
|
+
|
71
|
+
src = src_lang.lower()
|
72
|
+
dst = dst_lang.lower()
|
73
|
+
if src == dst:
|
74
|
+
return True
|
75
|
+
|
76
|
+
# Fast path: already installed (direct or pivot path)
|
77
|
+
if self.has_package(src, dst):
|
78
|
+
return True
|
79
|
+
|
80
|
+
# Coordinate installs to avoid duplicate downloads across threads
|
81
|
+
pair = (src, dst)
|
82
|
+
with _install_lock:
|
83
|
+
ev = _inflight.get(pair)
|
84
|
+
if ev is None:
|
85
|
+
ev = threading.Event()
|
86
|
+
_inflight[pair] = ev
|
87
|
+
starter = True
|
88
|
+
else:
|
89
|
+
starter = False
|
90
|
+
|
91
|
+
if not starter:
|
92
|
+
# Another thread is installing this pair; wait for completion
|
93
|
+
if self.verbose:
|
94
|
+
self._debug(f"Argos: waiting for ongoing install {src}->{dst} to finish…")
|
95
|
+
ev.wait(timeout=600.0)
|
96
|
+
return self.has_package(src, dst)
|
97
|
+
|
98
|
+
try:
|
99
|
+
# We are the installer for this pair
|
100
|
+
packages_dir = self._guess_packages_dir()
|
101
|
+
|
102
|
+
# Update package index once per install attempt
|
103
|
+
try:
|
104
|
+
if self.verbose:
|
105
|
+
self._debug("Argos: updating package index…")
|
106
|
+
argos_pkg.update_package_index()
|
107
|
+
except Exception as e_upd:
|
108
|
+
if self.verbose:
|
109
|
+
self._debug(
|
110
|
+
f"Argos: update_package_index failed: {type(e_upd).__name__}: {e_upd}"
|
111
|
+
)
|
112
|
+
|
113
|
+
available = []
|
114
|
+
try:
|
115
|
+
available = argos_pkg.get_available_packages()
|
116
|
+
except Exception as e_av:
|
117
|
+
if self.verbose:
|
118
|
+
self._debug(
|
119
|
+
f"Argos: get_available_packages failed: {type(e_av).__name__}: {e_av}"
|
120
|
+
)
|
121
|
+
|
122
|
+
# Attempt direct first
|
123
|
+
cand = next(
|
124
|
+
(
|
125
|
+
p
|
126
|
+
for p in available
|
127
|
+
if getattr(p, 'from_code', None) == src and getattr(p, 'to_code', None) == dst
|
128
|
+
),
|
129
|
+
None,
|
130
|
+
)
|
131
|
+
if cand is not None:
|
132
|
+
if self.verbose:
|
133
|
+
self._debug(
|
134
|
+
f"Argos: downloading package {src}->{dst} -> install into {packages_dir}"
|
135
|
+
)
|
136
|
+
try:
|
137
|
+
path = cand.download()
|
138
|
+
if self.verbose:
|
139
|
+
self._debug(f"Argos: downloaded file for {src}->{dst}: {path}")
|
140
|
+
argos_pkg.install_from_path(path)
|
141
|
+
if self.verbose:
|
142
|
+
self._debug(f"Argos: installed package {src}->{dst}")
|
143
|
+
except Exception as e_dir:
|
144
|
+
if self.verbose:
|
145
|
+
self._debug(
|
146
|
+
f"Argos: install {src}->{dst} failed: {type(e_dir).__name__}: {e_dir}"
|
147
|
+
)
|
148
|
+
|
149
|
+
# If still not available, try pivot via English
|
150
|
+
if not self.has_package(src, dst):
|
151
|
+
pivot = "en"
|
152
|
+
if self.verbose:
|
153
|
+
self._debug(
|
154
|
+
f"Argos: no direct package {src}->{dst} in index; trying pivot via {pivot} [{src}->{pivot}, {pivot}->{dst}]"
|
155
|
+
)
|
156
|
+
cand1 = next(
|
157
|
+
(
|
158
|
+
p
|
159
|
+
for p in available
|
160
|
+
if getattr(p, 'from_code', None) == src
|
161
|
+
and getattr(p, 'to_code', None) == pivot
|
162
|
+
),
|
163
|
+
None,
|
164
|
+
)
|
165
|
+
cand2 = next(
|
166
|
+
(
|
167
|
+
p
|
168
|
+
for p in available
|
169
|
+
if getattr(p, 'from_code', None) == pivot
|
170
|
+
and getattr(p, 'to_code', None) == dst
|
171
|
+
),
|
172
|
+
None,
|
173
|
+
)
|
174
|
+
if cand1 is None and cand2 is None and self.verbose:
|
175
|
+
self._debug(
|
176
|
+
f"Argos: no direct or pivot packages available for {src}->{dst} in index"
|
177
|
+
)
|
178
|
+
if cand1 is not None:
|
179
|
+
# coordinate concrete edge (src->pivot)
|
180
|
+
edge = (src, pivot)
|
181
|
+
with _install_lock:
|
182
|
+
ev_edge = _inflight.get(edge)
|
183
|
+
if ev_edge is None:
|
184
|
+
ev_edge = threading.Event()
|
185
|
+
_inflight[edge] = ev_edge
|
186
|
+
edge_starter = True
|
187
|
+
else:
|
188
|
+
edge_starter = False
|
189
|
+
if not edge_starter:
|
190
|
+
if self.verbose:
|
191
|
+
self._debug(
|
192
|
+
f"Argos: waiting for ongoing install {src}->{pivot} to finish…"
|
193
|
+
)
|
194
|
+
ev_edge.wait(timeout=600.0)
|
195
|
+
else:
|
196
|
+
try:
|
197
|
+
if self.verbose:
|
198
|
+
self._debug(
|
199
|
+
f"Argos: downloading package {src}->{pivot} -> install into {packages_dir}"
|
200
|
+
)
|
201
|
+
path1 = cand1.download()
|
202
|
+
if self.verbose:
|
203
|
+
self._debug(f"Argos: downloaded file for {src}->{pivot}: {path1}")
|
204
|
+
argos_pkg.install_from_path(path1)
|
205
|
+
if self.verbose:
|
206
|
+
self._debug(f"Argos: installed package {src}->{pivot}")
|
207
|
+
except Exception as e1:
|
208
|
+
if self.verbose:
|
209
|
+
self._debug(
|
210
|
+
f"Argos: install {src}->{pivot} failed: {type(e1).__name__}: {e1}"
|
211
|
+
)
|
212
|
+
finally:
|
213
|
+
with _install_lock:
|
214
|
+
ev_done = _inflight.get(edge)
|
215
|
+
if ev_done is not None:
|
216
|
+
ev_done.set()
|
217
|
+
_inflight.pop(edge, None)
|
218
|
+
if cand2 is not None:
|
219
|
+
# coordinate concrete edge (pivot->dst)
|
220
|
+
edge = (pivot, dst)
|
221
|
+
with _install_lock:
|
222
|
+
ev_edge = _inflight.get(edge)
|
223
|
+
if ev_edge is None:
|
224
|
+
ev_edge = threading.Event()
|
225
|
+
_inflight[edge] = ev_edge
|
226
|
+
edge_starter = True
|
227
|
+
else:
|
228
|
+
edge_starter = False
|
229
|
+
if not edge_starter:
|
230
|
+
if self.verbose:
|
231
|
+
self._debug(
|
232
|
+
f"Argos: waiting for ongoing install {pivot}->{dst} to finish…"
|
233
|
+
)
|
234
|
+
ev_edge.wait(timeout=600.0)
|
235
|
+
else:
|
236
|
+
try:
|
237
|
+
if self.verbose:
|
238
|
+
self._debug(
|
239
|
+
f"Argos: downloading package {pivot}->{dst} -> install into {packages_dir}"
|
240
|
+
)
|
241
|
+
path2 = cand2.download()
|
242
|
+
if self.verbose:
|
243
|
+
self._debug(f"Argos: downloaded file for {pivot}->{dst}: {path2}")
|
244
|
+
argos_pkg.install_from_path(path2)
|
245
|
+
if self.verbose:
|
246
|
+
self._debug(f"Argos: installed package {pivot}->{dst}")
|
247
|
+
except Exception as e2:
|
248
|
+
if self.verbose:
|
249
|
+
self._debug(
|
250
|
+
f"Argos: install {pivot}->{dst} failed: {type(e2).__name__}: {e2}"
|
251
|
+
)
|
252
|
+
finally:
|
253
|
+
with _install_lock:
|
254
|
+
ev_done = _inflight.get(edge)
|
255
|
+
if ev_done is not None:
|
256
|
+
ev_done.set()
|
257
|
+
_inflight.pop(edge, None)
|
258
|
+
|
259
|
+
# Final check (direct or pivot should now be available)
|
260
|
+
return self.has_package(src, dst)
|
261
|
+
finally:
|
262
|
+
with _install_lock:
|
263
|
+
ev = _inflight.get(pair)
|
264
|
+
if ev is not None:
|
265
|
+
ev.set()
|
266
|
+
_inflight.pop(pair, None)
|
267
|
+
|
268
|
+
def translate_texts(self, texts: Iterable[str], src_lang: str, dst_lang: str) -> list[str]:
|
269
|
+
import argostranslate.translate as argos_tr
|
270
|
+
|
271
|
+
src = src_lang.lower()
|
272
|
+
dst = dst_lang.lower()
|
273
|
+
installed = argos_tr.get_installed_languages()
|
274
|
+
src_lang_obj = next((lang for lang in installed if getattr(lang, "code", "") == src), None)
|
275
|
+
dst_lang_obj = next((lang for lang in installed if getattr(lang, "code", "") == dst), None)
|
276
|
+
if not (src_lang_obj and dst_lang_obj):
|
277
|
+
raise RuntimeError(
|
278
|
+
f"Argos package not installed for {src}->{dst}. Installation should have been attempted earlier."
|
279
|
+
)
|
280
|
+
|
281
|
+
# Try direct translation first
|
282
|
+
t_direct = None
|
283
|
+
try:
|
284
|
+
t_direct = src_lang_obj.get_translation(dst_lang_obj)
|
285
|
+
except Exception:
|
286
|
+
t_direct = None
|
287
|
+
|
288
|
+
if t_direct is not None:
|
289
|
+
out: list[str] = []
|
290
|
+
for s in texts:
|
291
|
+
out.append(t_direct.translate(s or ""))
|
292
|
+
return out
|
293
|
+
|
294
|
+
# Fallback: pivot via English if both edges exist
|
295
|
+
pivot_code = "en"
|
296
|
+
pivot_lang_obj = next(
|
297
|
+
(lang for lang in installed if getattr(lang, "code", "") == pivot_code), None
|
298
|
+
)
|
299
|
+
if pivot_lang_obj is None:
|
300
|
+
raise RuntimeError(
|
301
|
+
f"Argos package not installed for {src}->{dst} and no pivot via {pivot_code} available."
|
302
|
+
)
|
303
|
+
try:
|
304
|
+
t_src_pivot = src_lang_obj.get_translation(pivot_lang_obj)
|
305
|
+
t_pivot_dst = pivot_lang_obj.get_translation(dst_lang_obj)
|
306
|
+
except Exception as e:
|
307
|
+
raise RuntimeError(
|
308
|
+
f"Argos direct translator {src}->{dst} missing and cannot pivot via {pivot_code}: {e}"
|
309
|
+
) from e
|
310
|
+
|
311
|
+
if self.verbose:
|
312
|
+
packages_dir = self._guess_packages_dir()
|
313
|
+
self._debug(
|
314
|
+
f"Argos: using pivot via {pivot_code} (packages dir: {packages_dir}) for {src}->{dst}"
|
315
|
+
)
|
316
|
+
# First hop src->pivot, then pivot->dst
|
317
|
+
mid: list[str] = []
|
318
|
+
for s in texts:
|
319
|
+
mid.append(t_src_pivot.translate(s or ""))
|
320
|
+
out2: list[str] = []
|
321
|
+
for m in mid:
|
322
|
+
out2.append(t_pivot_dst.translate(m or ""))
|
323
|
+
return out2
|
324
|
+
|
325
|
+
def has_package(self, src_lang: str, dst_lang: str) -> bool:
|
326
|
+
"""Return True if a translation from src->dst is currently installed."""
|
327
|
+
try:
|
328
|
+
import argostranslate.translate as argos_tr
|
329
|
+
|
330
|
+
src = src_lang.lower()
|
331
|
+
dst = dst_lang.lower()
|
332
|
+
installed = argos_tr.get_installed_languages()
|
333
|
+
src_lang_obj = next(
|
334
|
+
(lang for lang in installed if getattr(lang, "code", "") == src), None
|
335
|
+
)
|
336
|
+
dst_lang_obj = next(
|
337
|
+
(lang for lang in installed if getattr(lang, "code", "") == dst), None
|
338
|
+
)
|
339
|
+
if not (src_lang_obj and dst_lang_obj):
|
340
|
+
return False
|
341
|
+
# True if direct exists or if a pivot path via 'en' exists
|
342
|
+
try:
|
343
|
+
_ = src_lang_obj.get_translation(dst_lang_obj)
|
344
|
+
return True
|
345
|
+
except Exception:
|
346
|
+
# Check pivot path
|
347
|
+
pivot_code = "en"
|
348
|
+
pivot_lang_obj = next(
|
349
|
+
(lang for lang in installed if getattr(lang, "code", "") == pivot_code), None
|
350
|
+
)
|
351
|
+
if not pivot_lang_obj:
|
352
|
+
return False
|
353
|
+
try:
|
354
|
+
_ = src_lang_obj.get_translation(pivot_lang_obj)
|
355
|
+
_ = pivot_lang_obj.get_translation(dst_lang_obj)
|
356
|
+
return True
|
357
|
+
except Exception:
|
358
|
+
return False
|
359
|
+
except Exception:
|
360
|
+
return False
|
361
|
+
|
362
|
+
|
363
|
+
def ensure_packages_background(
|
364
|
+
translator: ArgosTranslator,
|
365
|
+
src_lang_hint: str | None,
|
366
|
+
target_langs: list[str],
|
367
|
+
detected_lang_event: threading.Event | None = None,
|
368
|
+
detected_lang_holder: dict[str, str | None] | None = None,
|
369
|
+
) -> None:
|
370
|
+
"""Start background thread to ensure Argos packages exist.
|
371
|
+
|
372
|
+
- If src_lang_hint is provided, install immediately for that source.
|
373
|
+
- Otherwise, wait for detected_lang_event to fire and then install for the detected source.
|
374
|
+
"""
|
375
|
+
|
376
|
+
def _runner() -> None:
|
377
|
+
# First, try to update index early to avoid later blocking.
|
378
|
+
try:
|
379
|
+
import argostranslate.package as argos_pkg
|
380
|
+
|
381
|
+
argos_pkg.update_package_index()
|
382
|
+
except Exception:
|
383
|
+
# offline is OK; will skip install later
|
384
|
+
pass
|
385
|
+
|
386
|
+
src = (src_lang_hint or "").strip().lower()
|
387
|
+
if not src:
|
388
|
+
if detected_lang_event is not None and detected_lang_holder is not None:
|
389
|
+
detected_lang_event.wait(timeout=300.0)
|
390
|
+
src = (detected_lang_holder.get("code") or "").strip().lower()
|
391
|
+
if not src:
|
392
|
+
# Could not determine source language; give up silently
|
393
|
+
return
|
394
|
+
for tgt in dict.fromkeys([t.lower() for t in target_langs if t]):
|
395
|
+
if tgt == src:
|
396
|
+
continue
|
397
|
+
try:
|
398
|
+
translator.ensure_package(src, tgt)
|
399
|
+
except Exception:
|
400
|
+
# swallow background errors
|
401
|
+
pass
|
402
|
+
|
403
|
+
t = threading.Thread(target=_runner, daemon=True)
|
404
|
+
t.start()
|
405
|
+
|
406
|
+
|
407
|
+
def translate_result_segments(
|
408
|
+
translator: ArgosTranslator,
|
409
|
+
result: TranscriptionResult,
|
410
|
+
src_lang: str,
|
411
|
+
dst_lang: str,
|
412
|
+
) -> TranscriptionResult:
|
413
|
+
"""Translate a merged TranscriptionResult segment-wise, preserving timing.
|
414
|
+
|
415
|
+
Returns a new TranscriptionResult with translated text and segments.
|
416
|
+
"""
|
417
|
+
segs = result.get("segments", []) or []
|
418
|
+
if segs:
|
419
|
+
orig_texts: list[str] = [str(s.get("text") or "") for s in segs]
|
420
|
+
translated = translator.translate_texts(orig_texts, src_lang, dst_lang)
|
421
|
+
new_segs: list[SegmentDict] = []
|
422
|
+
for s, tt in zip(segs, translated, strict=False):
|
423
|
+
seg_out: SegmentDict = {}
|
424
|
+
if "start" in s:
|
425
|
+
seg_out["start"] = float(s["start"]) # s['start'] is float in SegmentDict
|
426
|
+
if "end" in s:
|
427
|
+
seg_out["end"] = float(s["end"]) # s['end'] is float in SegmentDict
|
428
|
+
seg_out["text"] = str(tt)
|
429
|
+
new_segs.append(seg_out)
|
430
|
+
joined_text = "".join([str(s.get("text", "")) for s in new_segs])
|
431
|
+
return {"text": joined_text, "segments": new_segs}
|
432
|
+
# Fallback: no segments, translate whole text
|
433
|
+
whole = result.get("text", "")
|
434
|
+
tt = translator.translate_texts([whole], src_lang, dst_lang)[0]
|
435
|
+
return {"text": tt, "segments": []}
|
436
|
+
|
437
|
+
|
438
|
+
def wait_for_packages(
|
439
|
+
translator: ArgosTranslator,
|
440
|
+
src_lang: str,
|
441
|
+
targets: list[str],
|
442
|
+
max_wait_s: float = 120.0,
|
443
|
+
verbose: bool = False,
|
444
|
+
) -> set[str]:
|
445
|
+
"""Wait until required packages are installed or timeout expires.
|
446
|
+
|
447
|
+
Returns the set of target codes that are ready. This function polls the
|
448
|
+
translator for installed status, while a background installer may be running.
|
449
|
+
"""
|
450
|
+
start = time.perf_counter()
|
451
|
+
ready: set[str] = set()
|
452
|
+
targets_norm = [t.lower() for t in targets]
|
453
|
+
|
454
|
+
while True:
|
455
|
+
for t in targets_norm:
|
456
|
+
if t in ready:
|
457
|
+
continue
|
458
|
+
if translator.has_package(src_lang, t):
|
459
|
+
ready.add(t)
|
460
|
+
if set(targets_norm) == ready:
|
461
|
+
break
|
462
|
+
elapsed = time.perf_counter() - start
|
463
|
+
if elapsed >= max_wait_s:
|
464
|
+
break
|
465
|
+
if verbose:
|
466
|
+
pending = ", ".join(sorted(set(targets_norm) - ready))
|
467
|
+
print(
|
468
|
+
f"Waiting for Argos packages ({src_lang}->[{pending}])… {int(elapsed)}s",
|
469
|
+
flush=True,
|
470
|
+
)
|
471
|
+
time.sleep(1.0)
|
472
|
+
return ready
|
@@ -9,6 +9,8 @@ class SegmentDict(TypedDict, total=False):
|
|
9
9
|
text: str
|
10
10
|
|
11
11
|
|
12
|
-
class TranscriptionResult(TypedDict):
|
12
|
+
class TranscriptionResult(TypedDict, total=False):
|
13
13
|
text: str
|
14
14
|
segments: list[SegmentDict]
|
15
|
+
# Optional: Whisper-detected language code (e.g., 'de', 'en')
|
16
|
+
language: str
|
@@ -111,6 +111,7 @@ class WhisperEngine:
|
|
111
111
|
t1 = time.perf_counter()
|
112
112
|
self.profile["transcribe_sec"] = self.profile.get("transcribe_sec", 0.0) + (t1 - t0)
|
113
113
|
text_c = str(res.get("text", "") or "").strip()
|
114
|
+
lang_code = str(res.get("language", "") or "")
|
114
115
|
if self.native_segmentation:
|
115
116
|
segs_raw = res.get("segments", []) or []
|
116
117
|
segs_typed: list[SegmentDict] = []
|
@@ -122,15 +123,21 @@ class WhisperEngine:
|
|
122
123
|
segs_typed.append({"start": start, "end": end, "text": text})
|
123
124
|
except Exception:
|
124
125
|
continue
|
125
|
-
|
126
|
+
out: TranscriptionResult = {"text": text_c, "segments": segs_typed}
|
127
|
+
if lang_code:
|
128
|
+
out["language"] = lang_code
|
129
|
+
return out
|
126
130
|
# Collapsed single segment per chunk
|
127
131
|
segs_raw = res.get("segments", []) or []
|
128
132
|
start = float(segs_raw[0].get("start", 0.0)) if segs_raw else 0.0
|
129
133
|
end = float(segs_raw[-1].get("end", 0.0)) if segs_raw else (frames / float(self.samplerate))
|
130
|
-
|
134
|
+
out2: TranscriptionResult = {
|
131
135
|
"text": text_c,
|
132
136
|
"segments": ([{"start": start, "end": end, "text": text_c}] if text_c else []),
|
133
137
|
}
|
138
|
+
if lang_code:
|
139
|
+
out2["language"] = lang_code
|
140
|
+
return out2
|
134
141
|
|
135
142
|
def write_chunk_outputs(self, result: TranscriptionResult, audio_path: Path) -> None:
|
136
143
|
try:
|
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.4
|
2
2
|
Name: s2t
|
3
|
-
Version: 0.1.
|
3
|
+
Version: 0.1.3
|
4
4
|
Summary: Speech to Text (s2t): Record audio, run Whisper, export formats, and copy transcript to clipboard.
|
5
5
|
Author: Maintainers
|
6
6
|
License-Expression: LicenseRef-Proprietary
|
@@ -23,6 +23,8 @@ Requires-Dist: mypy>=1.7; extra == "dev"
|
|
23
23
|
Requires-Dist: build>=1; extra == "dev"
|
24
24
|
Requires-Dist: setuptools-scm>=8; extra == "dev"
|
25
25
|
Requires-Dist: twine>=4; extra == "dev"
|
26
|
+
Provides-Extra: translate
|
27
|
+
Requires-Dist: argostranslate>=1.9.0; extra == "translate"
|
26
28
|
|
27
29
|
# s2t
|
28
30
|
|
@@ -23,4 +23,6 @@ src/s2t.egg-info/SOURCES.txt
|
|
23
23
|
src/s2t.egg-info/dependency_links.txt
|
24
24
|
src/s2t.egg-info/entry_points.txt
|
25
25
|
src/s2t.egg-info/requires.txt
|
26
|
-
src/s2t.egg-info/top_level.txt
|
26
|
+
src/s2t.egg-info/top_level.txt
|
27
|
+
src/s2t/translator/__init__.py
|
28
|
+
src/s2t/translator/argos_backend.py
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|