s2t 0.1.2__py3-none-any.whl → 0.1.3__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.
s2t/cli.py CHANGED
@@ -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=opts.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="Translate to English instead of transcribing in source language",
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,
s2t/config.py CHANGED
@@ -13,6 +13,7 @@ class SessionOptions:
13
13
  model: str
14
14
  lang: str | None
15
15
  translate: bool
16
+ translate_to: list[str]
16
17
  native_segmentation: bool
17
18
  verbose: bool
18
19
  edit: bool
@@ -0,0 +1,9 @@
1
+ from __future__ import annotations
2
+
3
+ from .argos_backend import ArgosTranslator, ensure_packages_background, translate_result_segments
4
+
5
+ __all__ = [
6
+ "ArgosTranslator",
7
+ "ensure_packages_background",
8
+ "translate_result_segments",
9
+ ]
@@ -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
s2t/types.py CHANGED
@@ -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
s2t/whisper_engine.py CHANGED
@@ -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
- return {"text": text_c, "segments": segs_typed}
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
- return {
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.2
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
 
@@ -0,0 +1,16 @@
1
+ s2t/__init__.py,sha256=wV4E9i-7KrUn1dOtLUQB3ZGEKx9gRWH3hPHlpw-ZdWc,332
2
+ s2t/cli.py,sha256=Qf6Hz0Ew9ncLbQQoCPDG7ZiYWeGbwBcZMZi_WbEu54w,20018
3
+ s2t/config.py,sha256=lFc_x5fIx_q0JpTcI4Lm4aubxhIXVH34foBvLMUNFGs,437
4
+ s2t/outputs.py,sha256=Lo8VcARZ7QPuuQQNu8myD5J4c4NO1Rs0L1DLnzLe9tM,1546
5
+ s2t/py.typed,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
6
+ s2t/recorder.py,sha256=uBD9mYf-uUCkRJw8fQitVnDrX6PwRNXJycyY4dBfXL0,8076
7
+ s2t/types.py,sha256=jBiRN-tr0qVw-lhaXvnsyKrVGDyLkqEbxs9qkQ6qGqI,339
8
+ s2t/utils.py,sha256=YU6YhiuONmqhrKte4DY5tiC5PP-yFExJMMBzFUiA8qA,3416
9
+ s2t/whisper_engine.py,sha256=x-V7ST9e3JnwMWdbMh4C7dHjA420jaOtXH2-igeh7vc,6492
10
+ s2t/translator/__init__.py,sha256=K-MKves7kZ4-62POfrmWeOcBaTjsTzeFSu8QNHqYuus,239
11
+ s2t/translator/argos_backend.py,sha256=VW_OYFFBuNZgcWM-fbvR6XGokuxS2fptkCMFIO9MD1I,19068
12
+ s2t-0.1.3.dist-info/METADATA,sha256=V0l2MbvH4Kd5nt9Qk3jAoLQIoo1If0w2pSjjG901CQA,4642
13
+ s2t-0.1.3.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
14
+ s2t-0.1.3.dist-info/entry_points.txt,sha256=JISIUlZAJ3DX1dB6zT3X_E3vcXI-eWEQKwHiT35fPKs,37
15
+ s2t-0.1.3.dist-info/top_level.txt,sha256=o8N0JcuHdIrfX3iGHvntHiDC2XgN7__joyNu08ZOh0s,4
16
+ s2t-0.1.3.dist-info/RECORD,,
@@ -1,14 +0,0 @@
1
- s2t/__init__.py,sha256=wV4E9i-7KrUn1dOtLUQB3ZGEKx9gRWH3hPHlpw-ZdWc,332
2
- s2t/cli.py,sha256=_7zIhcdI7DI_3Dxs2EcvQkE-fSGclkJ2TjCvDYlI65E,15871
3
- s2t/config.py,sha256=mzz6ljGEupNDAzlUwf5kvl0iKqO8WZ4TWsU4nSVtp0M,409
4
- s2t/outputs.py,sha256=Lo8VcARZ7QPuuQQNu8myD5J4c4NO1Rs0L1DLnzLe9tM,1546
5
- s2t/py.typed,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
6
- s2t/recorder.py,sha256=uBD9mYf-uUCkRJw8fQitVnDrX6PwRNXJycyY4dBfXL0,8076
7
- s2t/types.py,sha256=BuMyWuueS7EZbk7I_CkIWSb69Yi6g9-wr7CZLAZKflw,242
8
- s2t/utils.py,sha256=YU6YhiuONmqhrKte4DY5tiC5PP-yFExJMMBzFUiA8qA,3416
9
- s2t/whisper_engine.py,sha256=Y7kTYnB-LtsVj2KvF60VcI37MNAVfmMcGSJMMxFyZgA,6220
10
- s2t-0.1.2.dist-info/METADATA,sha256=YPufi9Xb9aqCARVeRkzrDQ_b2UNi7Pkq1dJAJ7vkr68,4557
11
- s2t-0.1.2.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
12
- s2t-0.1.2.dist-info/entry_points.txt,sha256=JISIUlZAJ3DX1dB6zT3X_E3vcXI-eWEQKwHiT35fPKs,37
13
- s2t-0.1.2.dist-info/top_level.txt,sha256=o8N0JcuHdIrfX3iGHvntHiDC2XgN7__joyNu08ZOh0s,4
14
- s2t-0.1.2.dist-info/RECORD,,
File without changes