s2t 0.1.2__py3-none-any.whl → 0.1.3.post1.dev1__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
s2t/recorder.py CHANGED
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import os
3
4
  import queue
4
5
  import select
5
6
  import sys
@@ -46,6 +47,8 @@ class Recorder:
46
47
  raise RuntimeError("sounddevice/soundfile required for recording.") from e
47
48
 
48
49
  evt_q: queue.Queue[str] = queue.Queue()
50
+ # Control queue is separate from audio frames to avoid control backpressure.
51
+ ctrl_q: queue.Queue[str] = queue.Queue()
49
52
  stop_evt = threading.Event()
50
53
 
51
54
  def key_reader() -> None:
@@ -61,10 +64,14 @@ class Recorder:
61
64
  ms = cast(_MSVCRT, msvcrt)
62
65
 
63
66
  last_space = 0.0
67
+ if self.verbose:
68
+ print("[key] using msvcrt (Windows)", file=sys.stderr)
64
69
  while not stop_evt.is_set():
65
70
  if ms.kbhit():
66
71
  ch = ms.getwch()
67
72
  if ch in ("\r", "\n"):
73
+ if self.verbose:
74
+ print("[key] ENTER", file=sys.stderr)
68
75
  evt_q.put("ENTER")
69
76
  break
70
77
  if ch == " ":
@@ -74,33 +81,118 @@ class Recorder:
74
81
  ):
75
82
  continue
76
83
  last_space = now
84
+ if self.verbose:
85
+ print("[key] SPACE", file=sys.stderr)
77
86
  evt_q.put("SPACE")
78
87
  time.sleep(0.01)
79
88
  else:
80
- fd = sys.stdin.fileno()
81
- old = termios.tcgetattr(fd)
82
- tty.setcbreak(fd)
83
- last_space = 0.0
89
+ # Prefer sys.stdin when it's a TTY (original, proven path). If not a TTY, try /dev/tty, else fallback to stdin line reads.
84
90
  try:
85
- while not stop_evt.is_set():
86
- r, _, _ = select.select([sys.stdin], [], [], 0.05)
87
- if r:
88
- ch = sys.stdin.read(1)
89
- if ch in ("\n", "\r"):
90
- evt_q.put("ENTER")
91
- break
92
- if ch == " ":
93
- now = time.perf_counter()
94
- if self.debounce_ms and (now - last_space) < (
95
- self.debounce_ms / 1000.0
96
- ):
91
+ if sys.stdin.isatty():
92
+ fd = sys.stdin.fileno()
93
+ if self.verbose:
94
+ print("[key] using sys.stdin (isatty, fd read)", file=sys.stderr)
95
+ old = termios.tcgetattr(fd)
96
+ tty.setcbreak(fd)
97
+ last_space = 0.0
98
+ try:
99
+ while not stop_evt.is_set():
100
+ r, _, _ = select.select([fd], [], [], 0.05)
101
+ if r:
102
+ try:
103
+ ch_b = os.read(fd, 1)
104
+ except BlockingIOError:
105
+ continue
106
+ if not ch_b:
107
+ continue
108
+ ch = ch_b.decode(errors="ignore")
109
+ if ch in ("\n", "\r"):
110
+ if self.verbose:
111
+ print("[key] ENTER", file=sys.stderr)
112
+ evt_q.put("ENTER")
113
+ break
114
+ if ch == " ":
115
+ now = time.perf_counter()
116
+ if self.debounce_ms and (now - last_space) < (
117
+ self.debounce_ms / 1000.0
118
+ ):
119
+ continue
120
+ last_space = now
121
+ if self.verbose:
122
+ print("[key] SPACE", file=sys.stderr)
123
+ evt_q.put("SPACE")
124
+ finally:
125
+ termios.tcsetattr(fd, termios.TCSADRAIN, old)
126
+ else:
127
+ # Try /dev/tty when stdin is not a TTY
128
+ using_devtty = False
129
+ fd = None
130
+ try:
131
+ fd = os.open("/dev/tty", os.O_RDONLY)
132
+ using_devtty = True
133
+ if self.verbose:
134
+ print("[key] using /dev/tty (stdin not TTY)", file=sys.stderr)
135
+ old = termios.tcgetattr(fd)
136
+ tty.setcbreak(fd)
137
+ last_space = 0.0
138
+ try:
139
+ while not stop_evt.is_set():
140
+ r, _, _ = select.select([fd], [], [], 0.05)
141
+ if r:
142
+ ch_b = os.read(fd, 1)
143
+ if not ch_b:
144
+ continue
145
+ ch = ch_b.decode(errors="ignore")
146
+ if ch in ("\n", "\r"):
147
+ if self.verbose:
148
+ print("[key] ENTER", file=sys.stderr)
149
+ evt_q.put("ENTER")
150
+ break
151
+ if ch == " ":
152
+ now = time.perf_counter()
153
+ if self.debounce_ms and (now - last_space) < (
154
+ self.debounce_ms / 1000.0
155
+ ):
156
+ continue
157
+ last_space = now
158
+ if self.verbose:
159
+ print("[key] SPACE", file=sys.stderr)
160
+ evt_q.put("SPACE")
161
+ finally:
162
+ termios.tcsetattr(fd, termios.TCSADRAIN, old)
163
+ except Exception:
164
+ if using_devtty and fd is not None:
165
+ try:
166
+ os.close(fd)
167
+ except Exception:
168
+ pass
169
+ print(
170
+ "Warning: no TTY for key input; falling back to stdin line mode.",
171
+ file=sys.stderr,
172
+ )
173
+ # Last resort: line-buffered stdin; Enter will still end.
174
+ while not stop_evt.is_set():
175
+ line = sys.stdin.readline()
176
+ if not line:
177
+ time.sleep(0.05)
97
178
  continue
98
- last_space = now
99
- evt_q.put("SPACE")
100
- finally:
101
- termios.tcsetattr(fd, termios.TCSADRAIN, old)
102
- except Exception:
103
- pass
179
+ # If user hits Enter on empty line, treat as ENTER
180
+ if line == "\n" or line == "\r\n":
181
+ if self.verbose:
182
+ print("[key] ENTER (line mode)", file=sys.stderr)
183
+ evt_q.put("ENTER")
184
+ break
185
+ # If first non-empty char is space, treat as SPACE
186
+ if line and line[0] == " ":
187
+ if self.verbose:
188
+ print("[key] SPACE (line mode)", file=sys.stderr)
189
+ evt_q.put("SPACE")
190
+ except Exception as e:
191
+ print(f"Warning: key reader failed: {e}", file=sys.stderr)
192
+
193
+ except Exception as e:
194
+ # Log unexpected key reader errors to aid debugging, but keep recording running.
195
+ print(f"Warning: key reader stopped unexpectedly: {e}", file=sys.stderr)
104
196
 
105
197
  audio_q: queue.Queue[tuple[str, Any]] = queue.Queue(maxsize=128)
106
198
  chunk_index = 1
@@ -117,67 +209,106 @@ class Recorder:
117
209
  str(cur_path), mode="w", samplerate=self.samplerate, channels=self.channels
118
210
  )
119
211
  while True:
120
- kind, payload = audio_q.get()
212
+ # First, handle any pending control commands so SPACE/ENTER are never blocked by frames backlog.
213
+ try:
214
+ while True:
215
+ cmd = ctrl_q.get_nowait()
216
+ if cmd == "split":
217
+ fh.flush()
218
+ fh.close()
219
+ if frames_written > 0:
220
+ dur = frames_written / float(self.samplerate)
221
+ chunk_paths.append(cur_path)
222
+ chunk_frames.append(frames_written)
223
+ chunk_offsets.append(offset_seconds_total)
224
+ offset_seconds_total += dur
225
+ if self.verbose:
226
+ print(
227
+ f"Saved chunk: {cur_path.name} ({dur:.2f}s)",
228
+ file=sys.stderr,
229
+ )
230
+ tx_queue.put(
231
+ (chunk_index, cur_path, frames_written, chunk_offsets[-1])
232
+ )
233
+ else:
234
+ try:
235
+ cur_path.unlink(missing_ok=True)
236
+ except Exception:
237
+ pass
238
+ frames_written = 0
239
+ chunk_index += 1
240
+ if (
241
+ self.pause_after_first_chunk
242
+ and chunk_index == 2
243
+ and self.resume_event is not None
244
+ ):
245
+ self._paused = True
246
+ self.resume_event.wait()
247
+ self._paused = False
248
+ cur_path = self.session_dir / f"chunk_{chunk_index:04d}{self.ext}"
249
+ fh = sf.SoundFile(
250
+ str(cur_path),
251
+ mode="w",
252
+ samplerate=self.samplerate,
253
+ channels=self.channels,
254
+ )
255
+ elif cmd == "finish":
256
+ fh.flush()
257
+ fh.close()
258
+ if frames_written > 0:
259
+ dur = frames_written / float(self.samplerate)
260
+ chunk_paths.append(cur_path)
261
+ chunk_frames.append(frames_written)
262
+ chunk_offsets.append(offset_seconds_total)
263
+ offset_seconds_total += dur
264
+ if self.verbose:
265
+ print(
266
+ f"Saved chunk: {cur_path.name} ({dur:.2f}s)",
267
+ file=sys.stderr,
268
+ )
269
+ tx_queue.put(
270
+ (chunk_index, cur_path, frames_written, chunk_offsets[-1])
271
+ )
272
+ else:
273
+ try:
274
+ cur_path.unlink(missing_ok=True)
275
+ except Exception:
276
+ pass
277
+ tx_queue.put((-1, Path(), 0, 0.0))
278
+ return
279
+ except queue.Empty:
280
+ pass
281
+
282
+ # Then, write frames if available; short timeout to re-check control queue regularly.
283
+ try:
284
+ kind, payload = audio_q.get(timeout=0.05)
285
+ except queue.Empty:
286
+ continue
121
287
  if kind == "frames":
122
288
  data = payload
123
289
  fh.write(data)
124
290
  frames_written += len(data)
125
- elif kind == "split":
126
- fh.flush()
127
- fh.close()
128
- if frames_written > 0:
129
- dur = frames_written / float(self.samplerate)
130
- chunk_paths.append(cur_path)
131
- chunk_frames.append(frames_written)
132
- chunk_offsets.append(offset_seconds_total)
133
- offset_seconds_total += dur
134
- if self.verbose:
135
- print(f"Saved chunk: {cur_path.name} ({dur:.2f}s)", file=sys.stderr)
136
- tx_queue.put((chunk_index, cur_path, frames_written, chunk_offsets[-1]))
137
- else:
138
- try:
139
- cur_path.unlink(missing_ok=True)
140
- except Exception:
141
- pass
142
- frames_written = 0
143
- chunk_index += 1
144
- if (
145
- self.pause_after_first_chunk
146
- and chunk_index == 2
147
- and self.resume_event is not None
148
- ):
149
- self._paused = True
150
- self.resume_event.wait()
151
- self._paused = False
152
- cur_path = self.session_dir / f"chunk_{chunk_index:04d}{self.ext}"
153
- fh = sf.SoundFile(
154
- str(cur_path), mode="w", samplerate=self.samplerate, channels=self.channels
155
- )
156
- elif kind == "finish":
157
- fh.flush()
158
- fh.close()
159
- if frames_written > 0:
160
- dur = frames_written / float(self.samplerate)
161
- chunk_paths.append(cur_path)
162
- chunk_frames.append(frames_written)
163
- chunk_offsets.append(offset_seconds_total)
164
- offset_seconds_total += dur
165
- if self.verbose:
166
- print(f"Saved chunk: {cur_path.name} ({dur:.2f}s)", file=sys.stderr)
167
- tx_queue.put((chunk_index, cur_path, frames_written, chunk_offsets[-1]))
168
- else:
169
- try:
170
- cur_path.unlink(missing_ok=True)
171
- except Exception:
172
- pass
173
- break
174
291
  tx_queue.put((-1, Path(), 0, 0.0))
175
292
 
293
+ # Timestamp of last dropped-frame warning (throttling for verbose mode)
294
+ last_drop_log = 0.0
295
+
176
296
  def cb(indata: Any, frames: int, time_info: Any, status: Any) -> None:
297
+ nonlocal last_drop_log
177
298
  if status:
178
299
  print(status, file=sys.stderr)
179
300
  if not self._paused:
180
- audio_q.put(("frames", indata.copy()))
301
+ try:
302
+ audio_q.put_nowait(("frames", indata.copy()))
303
+ except queue.Full:
304
+ # Drop frame if the queue is saturated; throttle warnings.
305
+ now = time.perf_counter()
306
+ if self.verbose and (now - last_drop_log) > 1.0:
307
+ print(
308
+ "Warning: audio queue full; dropping input frames.",
309
+ file=sys.stderr,
310
+ )
311
+ last_drop_log = now
181
312
 
182
313
  key_t = threading.Thread(target=key_reader, daemon=True)
183
314
  writer_t = threading.Thread(target=writer_fn, daemon=True)
@@ -197,9 +328,9 @@ class Recorder:
197
328
  except queue.Empty:
198
329
  continue
199
330
  if evt == "SPACE":
200
- audio_q.put(("split", None))
331
+ ctrl_q.put("split")
201
332
  elif evt == "ENTER":
202
- audio_q.put(("finish", None))
333
+ ctrl_q.put("finish")
203
334
  break
204
335
  writer_t.join()
205
336
  return chunk_paths, chunk_frames, chunk_offsets
@@ -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.post1.dev1
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=0sw1UJqQIRdiJO5dugUxRjTN5kFU0CBETVjoQz99a8E,16055
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.post1.dev1.dist-info/METADATA,sha256=zSKU9KAPs8fX1KTZF3WNLnz3FaqxWHecCrV5SE6JsEA,4653
13
+ s2t-0.1.3.post1.dev1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
14
+ s2t-0.1.3.post1.dev1.dist-info/entry_points.txt,sha256=JISIUlZAJ3DX1dB6zT3X_E3vcXI-eWEQKwHiT35fPKs,37
15
+ s2t-0.1.3.post1.dev1.dist-info/top_level.txt,sha256=o8N0JcuHdIrfX3iGHvntHiDC2XgN7__joyNu08ZOh0s,4
16
+ s2t-0.1.3.post1.dev1.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,,