bolo-torch 0.1.0__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.
bolo_torch/__init__.py ADDED
@@ -0,0 +1,20 @@
1
+ """bolo-torch: the PyTorch engine plugin of the bolo speech server.
2
+
3
+ bolo never imports PyTorch. It starts this package as a separate process (``python -m bolo_torch``) and talks to
4
+ it over the plugin protocol (bolo's docs/plugins.md). This module is what bolo's entry-point discovery imports in
5
+ the *server* process, so it must stay free of heavy imports: no torch here.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import sys
11
+
12
+ __version__ = "0.1.0"
13
+
14
+
15
+ def plugin() -> list[str]:
16
+ """The command line bolo runs to start the plugin (entry point ``bolo.engines: torch``)."""
17
+ return [sys.executable, "-m", "bolo_torch"]
18
+
19
+
20
+ __all__ = ["__version__", "plugin"]
bolo_torch/__main__.py ADDED
@@ -0,0 +1,8 @@
1
+ """``python -m bolo_torch``: serve the plugin protocol on stdin/stdout (what bolo starts)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from bolo_torch.cli import main
6
+
7
+ if __name__ == "__main__":
8
+ raise SystemExit(main(["serve"]))
bolo_torch/cli.py ADDED
@@ -0,0 +1,163 @@
1
+ """``bolo-torch``: serve the plugin (what bolo runs), check the environment, or separate a recording directly.
2
+
3
+ bolo-torch serve # the plugin protocol on stdin/stdout (bolo starts this)
4
+ bolo-torch doctor # torch build, devices, which model families are installed
5
+ bolo-torch separate mix.wav -o out/ # speech separation without bolo (SepFormer / PixIT)
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import importlib.util
12
+ import json
13
+ import logging
14
+ import os
15
+ import sys
16
+ import time
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+ from bolo_torch import __version__
21
+
22
+ #: separation models for ``bolo-torch separate`` (the same files as bolo's catalog entries)
23
+ SEPARATION_MODELS: dict[str, dict[str, Any]] = {
24
+ "sepformer-whamr": {
25
+ "kind": "sepformer", "repo": "speechbrain/sepformer-whamr",
26
+ "revision": "9950fd8055b38679d2af5bef2366b5dba600ec66",
27
+ "allow": ["hyperparams.yaml", "masknet.ckpt", "encoder.ckpt", "decoder.ckpt"],
28
+ "about": "SpeechBrain SepFormer, WHAMR! (noisy, reverberant), 8 kHz, 2 speakers. Apache-2.0; WSJ0-derived data",
29
+ },
30
+ "sepformer-whamr16k": {
31
+ "kind": "sepformer", "repo": "speechbrain/sepformer-whamr16k",
32
+ "revision": "21a5b500c6f52fddc387c5d9e5fb13ffd6f039c5",
33
+ "allow": ["hyperparams.yaml", "masknet.ckpt", "encoder.ckpt", "decoder.ckpt"],
34
+ "about": "SpeechBrain SepFormer, WHAMR! at 16 kHz, 2 speakers. Apache-2.0; WSJ0-derived data",
35
+ },
36
+ "pixit-ami": {
37
+ "kind": "pixit", "repo": "pyannote/speech-separation-ami-1.0",
38
+ "revision": "9486b106945ae0cc0784041a08bfcdba5edadfb9", "allow": None,
39
+ "about": "pyannote PixIT, joint diarization and separation, AMI meetings, 16 kHz. MIT, gated",
40
+ },
41
+ } # fmt: skip
42
+
43
+ FAMILIES = {
44
+ "parler": ["parler_tts", "transformers"],
45
+ "f5": ["transformers", "vocos", "x_transformers", "torchdiffeq", "pydub", "jieba", "pypinyin"],
46
+ "orpheus": ["transformers", "snac"],
47
+ "sepformer": ["speechbrain", "torchaudio"],
48
+ "pyannote": ["pyannote.audio"],
49
+ }
50
+
51
+
52
+ def _installed(module: str) -> bool:
53
+ try:
54
+ return importlib.util.find_spec(module) is not None
55
+ except (ImportError, ValueError):
56
+ return False
57
+
58
+
59
+ def doctor() -> dict[str, Any]:
60
+ import torch
61
+
62
+ from bolo_torch.runtime import hf_token, pick_device
63
+
64
+ mps = getattr(torch.backends, "mps", None)
65
+ return {
66
+ "bolo_torch": __version__,
67
+ "python": sys.version.split()[0],
68
+ "torch": torch.__version__,
69
+ "cuda_build": torch.version.cuda,
70
+ "cuda_available": torch.cuda.is_available(),
71
+ "cuda_devices": [torch.cuda.get_device_name(i) for i in range(torch.cuda.device_count())],
72
+ "mps_available": bool(mps is not None and mps.is_available()),
73
+ "auto_device": pick_device("auto"),
74
+ "threads": torch.get_num_threads(),
75
+ "hf_token": bool(hf_token()),
76
+ "families": {name: all(_installed(m) for m in mods) for name, mods in FAMILIES.items()},
77
+ }
78
+
79
+
80
+ def separate(args: argparse.Namespace) -> dict[str, Any]:
81
+ import numpy as np
82
+ import soundfile as sf
83
+ from bolo.engines.base import LoadOptions, SeparationModel
84
+
85
+ from bolo_torch.engine import TorchEngine
86
+ from bolo_torch.models.separation import SR, resample
87
+ from bolo_torch.runtime import hub_snapshot
88
+
89
+ entry = SEPARATION_MODELS[args.model]
90
+ t0 = time.perf_counter()
91
+ root = hub_snapshot(entry["repo"], entry["revision"], entry["allow"])
92
+ t_download = time.perf_counter() - t0
93
+ t0 = time.perf_counter()
94
+ model = TorchEngine().load(
95
+ entry["kind"],
96
+ "separation",
97
+ root,
98
+ {"model_id": args.model},
99
+ LoadOptions(threads=args.threads, device=args.device),
100
+ )
101
+ t_load = time.perf_counter() - t0
102
+ assert isinstance(model, SeparationModel)
103
+ samples, rate = sf.read(args.input, dtype="float32", always_2d=True)
104
+ mono = resample(samples.mean(axis=1).astype(np.float32), int(rate), SR)
105
+ t0 = time.perf_counter()
106
+ tracks = model.separate(mono)
107
+ t_run = time.perf_counter() - t0
108
+ out = Path(args.output)
109
+ out.mkdir(parents=True, exist_ok=True)
110
+ files = []
111
+ for i, track in enumerate(tracks):
112
+ path = out / f"SPEAKER_{i:02d}.wav"
113
+ sf.write(str(path), track, SR)
114
+ files.append(str(path))
115
+ duration = len(mono) / SR
116
+ turns = getattr(model, "last_turns", [])
117
+ return {
118
+ "model": args.model, "device": args.device, "threads": args.threads, "audio_s": round(duration, 2),
119
+ "download_s": round(t_download, 2), "load_s": round(t_load, 2), "separate_s": round(t_run, 2),
120
+ "rtf": round(t_run / duration, 3) if duration else None, "tracks": files,
121
+ "turns": [[round(s, 2), round(e, 2), k] for s, e, k in turns],
122
+ } # fmt: skip
123
+
124
+
125
+ def main(argv: list[str] | None = None) -> int:
126
+ parser = argparse.ArgumentParser(
127
+ prog="bolo-torch", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
128
+ )
129
+ parser.add_argument("--version", action="version", version=f"bolo-torch {__version__}")
130
+ sub = parser.add_subparsers(dest="command")
131
+ sub.add_parser("serve", help="serve the plugin protocol on stdin/stdout")
132
+ sub.add_parser("doctor", help="report torch, devices and installed model families")
133
+ sep = sub.add_parser("separate", help="split a recording into one track per speaker")
134
+ sep.add_argument("input")
135
+ sep.add_argument("-o", "--output", default="separated")
136
+ sep.add_argument("-m", "--model", default="sepformer-whamr16k", choices=sorted(SEPARATION_MODELS))
137
+ sep.add_argument("--device", default=os.environ.get("BOLO_TORCH_DEVICE", "auto"))
138
+ sep.add_argument("--threads", type=int, default=0)
139
+ args = parser.parse_args(argv)
140
+ if args.command in (None, "serve"):
141
+ logging.basicConfig(level=logging.INFO, stream=sys.stderr, format="%(name)s: %(message)s")
142
+ from bolo_torch.engine import TorchEngine
143
+ from bolo_torch.server import serve
144
+
145
+ return serve(TorchEngine())
146
+ if args.command == "doctor":
147
+ print(json.dumps(doctor(), indent=2))
148
+ return 0
149
+ if args.command == "separate":
150
+ from bolo_torch.runtime import PluginError
151
+
152
+ try:
153
+ print(json.dumps(separate(args), indent=2, ensure_ascii=False))
154
+ except PluginError as exc:
155
+ print(f"error ({exc.code}): {exc}", file=sys.stderr)
156
+ return 2
157
+ return 0
158
+ parser.print_help()
159
+ return 1
160
+
161
+
162
+ if __name__ == "__main__":
163
+ raise SystemExit(main())
bolo_torch/engine.py ADDED
@@ -0,0 +1,82 @@
1
+ """The plugin's engine: model kinds, and the device/dtype/thread choices made at load time."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib
6
+ import logging
7
+ import time
8
+ from collections.abc import Callable
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from bolo.engines.base import Engine, EngineStatus, LoadOptions, Model
13
+
14
+ from bolo_torch import __version__
15
+ from bolo_torch.runtime import InvalidRequest, LoadContext, pick_device, pick_dtype, set_threads
16
+
17
+ log = logging.getLogger("bolo_torch")
18
+
19
+ Loader = Callable[[LoadContext], Model]
20
+
21
+ #: kind -> (module, function); modules are imported only when a model of that kind is loaded
22
+ LOADERS: dict[str, tuple[str, str]] = {
23
+ "parler-tts": ("bolo_torch.models.parler", "load"),
24
+ "f5-tts": ("bolo_torch.models.f5", "load"),
25
+ "orpheus-snac": ("bolo_torch.models.orpheus", "load"),
26
+ "indic-speak": ("bolo_torch.models.indic_speak", "load"),
27
+ "sepformer": ("bolo_torch.models.separation", "load_sepformer"),
28
+ "pixit": ("bolo_torch.models.separation", "load_pixit"),
29
+ "dicow": ("bolo_torch.models.dicow", "load"),
30
+ }
31
+ #: kinds the catalog lists for this plugin that are not implemented yet
32
+ NOT_IMPLEMENTED = {
33
+ "sravaani": "SraVaani (TorchScript STT)",
34
+ "indicconformer-official": "IndicConformer official",
35
+ "diarizen": "DiariZen diarization (the plugin protocol has no diarization request yet)",
36
+ "spark-tts": "Spark-TTS (no packaged inference code)",
37
+ "maskgct": "MaskGCT (no packaged inference code)",
38
+ }
39
+
40
+
41
+ class TorchEngine(Engine):
42
+ id = "torch"
43
+ name = "torch"
44
+ license = "Apache-2.0"
45
+ url = "https://github.com/cb-Sigma/bolo/tree/main/plugins/bolo-torch"
46
+
47
+ def __init__(self, loaders: dict[str, Loader | tuple[str, str]] | None = None) -> None:
48
+ self.loaders: dict[str, Loader | tuple[str, str]] = dict(LOADERS if loaders is None else loaders)
49
+ self.kinds = tuple(self.loaders)
50
+
51
+ def status(self) -> EngineStatus:
52
+ return EngineStatus(self.id, True, __version__, None)
53
+
54
+ def _loader(self, kind: str) -> Loader:
55
+ entry = self.loaders.get(kind)
56
+ if entry is None:
57
+ if kind in NOT_IMPLEMENTED:
58
+ raise InvalidRequest(f"bolo-torch {__version__} cannot load {NOT_IMPLEMENTED[kind]} models yet")
59
+ raise InvalidRequest(
60
+ f"bolo-torch {__version__} has no model kind {kind!r} (it has {', '.join(self.kinds)})"
61
+ )
62
+ if isinstance(entry, tuple):
63
+ module, func = entry
64
+ return getattr(importlib.import_module(module), func) # type: ignore[no-any-return]
65
+ return entry
66
+
67
+ def load(self, kind: str, task: str, path: Path, spec: dict[str, Any], opts: LoadOptions) -> Model:
68
+ loader = self._loader(kind)
69
+ device = pick_device(opts.device, spec)
70
+ dtype = pick_dtype(device, spec)
71
+ threads = set_threads(opts.threads)
72
+ ctx = LoadContext(
73
+ kind, task, path, dict(spec), device=device, dtype=dtype, threads=threads,
74
+ model_id=str(spec.get("model_id", path.name)),
75
+ ) # fmt: skip
76
+ t0 = time.perf_counter()
77
+ model = loader(ctx)
78
+ log.info(
79
+ "loaded %s (%s) on %s, %s, %d threads in %.1f s",
80
+ ctx.model_id, kind, device, str(dtype).replace("torch.", ""), threads, time.perf_counter() - t0,
81
+ ) # fmt: skip
82
+ return model
@@ -0,0 +1,30 @@
1
+ """Model families served by the plugin. Each module imports its libraries only when a model is loaded."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterable
6
+ from typing import Any
7
+
8
+ import numpy as np
9
+ from bolo.engines.base import FloatArray, Voice
10
+
11
+
12
+ def voices_from_options(options: dict[str, Any]) -> list[dict[str, Any]]:
13
+ raw = options.get("voices") or []
14
+ return [dict(v) for v in raw if isinstance(v, dict) and v.get("id")]
15
+
16
+
17
+ def to_voices(entries: Iterable[dict[str, Any]]) -> tuple[Voice, ...]:
18
+ return tuple(
19
+ Voice(str(v["id"]), str(v.get("name", v["id"])), str(v.get("language", "")), str(v.get("gender", "")), i)
20
+ for i, v in enumerate(entries)
21
+ )
22
+
23
+
24
+ def peak_safe(audio: Any) -> FloatArray:
25
+ """float32 mono, scaled down only if it would clip."""
26
+ out = np.asarray(audio, dtype=np.float32).reshape(-1)
27
+ peak = float(np.max(np.abs(out))) if out.size else 0.0
28
+ if peak > 1.0:
29
+ out = out / peak
30
+ return out