huri-module-stt 0.1.0__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.
@@ -0,0 +1,40 @@
1
+ Metadata-Version: 2.4
2
+ Name: huri-module-stt
3
+ Version: 0.1.0
4
+ Summary: HuRI module: Speech to text (faster-whisper) as a Ray Serve deployment
5
+ Requires-Python: >=3.10
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: huri-core[server]<0.5,>=0.4
8
+ Requires-Dist: huri-events<0.2,>=0.1
9
+ Requires-Dist: numpy<2,>=1.26
10
+ Requires-Dist: faster-whisper<1.3,>=1.2.1
11
+
12
+ # huri-module-stt
13
+
14
+ Speech to text (faster-whisper) as a Ray Serve deployment.
15
+
16
+ | Module | Topics | What it does |
17
+ |---|---|---|
18
+ | `stt` | `huri.voice` → `huri.transcript` | Transcribes speech with faster-whisper (CPU or GPU) |
19
+
20
+ ```sh
21
+ pip install -e packages/huri-module-stt # from a huri-modules checkout, next to huri-core and huri-events
22
+ HURI_MODULES=stt ... # enable it on the server
23
+ ```
24
+
25
+ Hardware-specific pins: `requirements-amd.txt`, `requirements-nvidia.txt` (install them before the package).
26
+
27
+ A distribution of
28
+ [huri-modules](https://github.com/Lucy-Robotics/huri-modules), versioned in its
29
+ `pyproject.toml` (see RELEASING.md there). It depends on `huri-core` and
30
+ `huri-events`, which are not on PyPI: without a checkout, install them from git
31
+ in the same pip command, so pip never looks them up on an index:
32
+
33
+ ```sh
34
+ ref=0.1.0 # a huri-modules release tag
35
+ core=0.4.0 # HURI_CORE_REF in that release's versions.env
36
+ pip install \
37
+ "huri-core[server] @ git+https://github.com/Lucy-Robotics/huri-core.git@$core" \
38
+ "huri-events @ git+https://github.com/Lucy-Robotics/huri-modules.git@$ref#subdirectory=packages/huri-events" \
39
+ "huri-module-stt @ git+https://github.com/Lucy-Robotics/huri-modules.git@$ref#subdirectory=packages/huri-module-stt"
40
+ ```
@@ -0,0 +1,29 @@
1
+ # huri-module-stt
2
+
3
+ Speech to text (faster-whisper) as a Ray Serve deployment.
4
+
5
+ | Module | Topics | What it does |
6
+ |---|---|---|
7
+ | `stt` | `huri.voice` → `huri.transcript` | Transcribes speech with faster-whisper (CPU or GPU) |
8
+
9
+ ```sh
10
+ pip install -e packages/huri-module-stt # from a huri-modules checkout, next to huri-core and huri-events
11
+ HURI_MODULES=stt ... # enable it on the server
12
+ ```
13
+
14
+ Hardware-specific pins: `requirements-amd.txt`, `requirements-nvidia.txt` (install them before the package).
15
+
16
+ A distribution of
17
+ [huri-modules](https://github.com/Lucy-Robotics/huri-modules), versioned in its
18
+ `pyproject.toml` (see RELEASING.md there). It depends on `huri-core` and
19
+ `huri-events`, which are not on PyPI: without a checkout, install them from git
20
+ in the same pip command, so pip never looks them up on an index:
21
+
22
+ ```sh
23
+ ref=0.1.0 # a huri-modules release tag
24
+ core=0.4.0 # HURI_CORE_REF in that release's versions.env
25
+ pip install \
26
+ "huri-core[server] @ git+https://github.com/Lucy-Robotics/huri-core.git@$core" \
27
+ "huri-events @ git+https://github.com/Lucy-Robotics/huri-modules.git@$ref#subdirectory=packages/huri-events" \
28
+ "huri-module-stt @ git+https://github.com/Lucy-Robotics/huri-modules.git@$ref#subdirectory=packages/huri-module-stt"
29
+ ```
@@ -0,0 +1,40 @@
1
+ Metadata-Version: 2.4
2
+ Name: huri-module-stt
3
+ Version: 0.1.0
4
+ Summary: HuRI module: Speech to text (faster-whisper) as a Ray Serve deployment
5
+ Requires-Python: >=3.10
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: huri-core[server]<0.5,>=0.4
8
+ Requires-Dist: huri-events<0.2,>=0.1
9
+ Requires-Dist: numpy<2,>=1.26
10
+ Requires-Dist: faster-whisper<1.3,>=1.2.1
11
+
12
+ # huri-module-stt
13
+
14
+ Speech to text (faster-whisper) as a Ray Serve deployment.
15
+
16
+ | Module | Topics | What it does |
17
+ |---|---|---|
18
+ | `stt` | `huri.voice` → `huri.transcript` | Transcribes speech with faster-whisper (CPU or GPU) |
19
+
20
+ ```sh
21
+ pip install -e packages/huri-module-stt # from a huri-modules checkout, next to huri-core and huri-events
22
+ HURI_MODULES=stt ... # enable it on the server
23
+ ```
24
+
25
+ Hardware-specific pins: `requirements-amd.txt`, `requirements-nvidia.txt` (install them before the package).
26
+
27
+ A distribution of
28
+ [huri-modules](https://github.com/Lucy-Robotics/huri-modules), versioned in its
29
+ `pyproject.toml` (see RELEASING.md there). It depends on `huri-core` and
30
+ `huri-events`, which are not on PyPI: without a checkout, install them from git
31
+ in the same pip command, so pip never looks them up on an index:
32
+
33
+ ```sh
34
+ ref=0.1.0 # a huri-modules release tag
35
+ core=0.4.0 # HURI_CORE_REF in that release's versions.env
36
+ pip install \
37
+ "huri-core[server] @ git+https://github.com/Lucy-Robotics/huri-core.git@$core" \
38
+ "huri-events @ git+https://github.com/Lucy-Robotics/huri-modules.git@$ref#subdirectory=packages/huri-events" \
39
+ "huri-module-stt @ git+https://github.com/Lucy-Robotics/huri-modules.git@$ref#subdirectory=packages/huri-module-stt"
40
+ ```
@@ -0,0 +1,12 @@
1
+ README.md
2
+ pyproject.toml
3
+ huri_module_stt.egg-info/PKG-INFO
4
+ huri_module_stt.egg-info/SOURCES.txt
5
+ huri_module_stt.egg-info/dependency_links.txt
6
+ huri_module_stt.egg-info/entry_points.txt
7
+ huri_module_stt.egg-info/requires.txt
8
+ huri_module_stt.egg-info/top_level.txt
9
+ huri_stt/__init__.py
10
+ huri_stt/huri_module.toml
11
+ huri_stt/module.py
12
+ tests/test_stt.py
@@ -0,0 +1,2 @@
1
+ [huri.modules]
2
+ stt = huri_stt.module:STT
@@ -0,0 +1,4 @@
1
+ huri-core[server]<0.5,>=0.4
2
+ huri-events<0.2,>=0.1
3
+ numpy<2,>=1.26
4
+ faster-whisper<1.3,>=1.2.1
File without changes
@@ -0,0 +1,7 @@
1
+ name = "stt"
2
+ summary = "Transcribes speech with faster-whisper (CPU or GPU)"
3
+ input = { topic = "huri.voice", event = "huri_events.speech:Voice" }
4
+ output = { topic = "huri.transcript", event = "huri_events.speech:Transcript" }
5
+ events = ["huri-events>=0.1,<0.2"]
6
+ requires = { gpu = false, actuation = false }
7
+ huri_core = ">=0.4,<0.5"
@@ -0,0 +1,209 @@
1
+ import asyncio
2
+ import os
3
+ from concurrent.futures import ThreadPoolExecutor
4
+ from typing import AsyncGenerator, List
5
+
6
+ import numpy as np
7
+ from huri_events.speech import Transcript, Voice
8
+ from ray import serve
9
+ from ray.serve import handle
10
+
11
+ from huri_core.core.module import ModuleWithHandle
12
+
13
+ _MODEL_PATH = os.environ.get("HURI_STT_MODEL_PATH", "base")
14
+ _NUM_WORKERS = int(os.environ.get("HURI_STT_NUM_WORKERS", "2"))
15
+ # Set by the generated Serve config (scripts/install_local.sh) from the install
16
+ # plan's STT decision, already translated into faster-whisper's vocabulary:
17
+ # "cpu" | "cuda" | "auto" for the device, "int8"/"float16"/"auto" for the type.
18
+ # Defaulting to "auto" here keeps the hand-written configs in config/ working.
19
+ _DEVICE = os.environ.get("HURI_STT_DEVICE", "auto")
20
+ _COMPUTE_TYPE = os.environ.get("HURI_STT_COMPUTE_TYPE", "auto")
21
+
22
+
23
+ @serve.deployment(name="STTHandle", max_ongoing_requests=8)
24
+ class STTHandle:
25
+ """faster-whisper model wrapper.
26
+
27
+ Holds the WhisperModel and runs transcription on its own Ray Serve actor,
28
+ off the HuRI master actor — model load and GPU inference no longer block the
29
+ websocket ingress / per-session router. Pinned to a GPU worker via
30
+ ray_actor_options in the Serve config (see deploy values.yaml).
31
+
32
+ Stateless across calls: the per-session sliding-window buffering lives in the
33
+ STT module, so this deployment is shared across all sessions.
34
+
35
+ :model: path to (or size name of) the faster-whisper model. Defaults to the
36
+ HURI_STT_MODEL_PATH env var, falling back to "base".
37
+ :device: "cpu", "cuda", or "auto".
38
+ :compute_type: e.g. "int8", "float16", or "auto".
39
+ """
40
+
41
+ def __init__(
42
+ self,
43
+ model: str = _MODEL_PATH,
44
+ device: str = _DEVICE,
45
+ compute_type: str = _COMPUTE_TYPE,
46
+ num_workers: int = _NUM_WORKERS,
47
+ ):
48
+ from faster_whisper import WhisperModel
49
+
50
+ # num_workers lets CTranslate2 service several transcriptions at once on
51
+ # this single replica — each "worker" is an independent inference slot
52
+ # over the shared (read-only) weights. Combined with the thread pool
53
+ # below, N sessions are transcribed concurrently while staying fully
54
+ # independent: no per-call client state is ever held here (the sliding
55
+ # window lives in the per-session STT module).
56
+ self.model_faster = WhisperModel(
57
+ model,
58
+ device=device,
59
+ compute_type=compute_type,
60
+ num_workers=num_workers,
61
+ )
62
+ # Run the *blocking* faster-whisper call off the actor's asyncio loop.
63
+ # transcribe() used to be an async method that called the synchronous
64
+ # model inline, blocking the replica's event loop for the whole inference
65
+ # — serialising every session on the replica and stalling Serve health
66
+ # checks. Offloading to a thread pool (sized to num_workers) keeps the
67
+ # loop free to accept other sessions' requests while inference runs.
68
+ self._executor = ThreadPoolExecutor(
69
+ max_workers=num_workers, thread_name_prefix="stt-transcribe"
70
+ )
71
+
72
+ async def transcribe(self, audio: np.ndarray, language: str = "en") -> str:
73
+ loop = asyncio.get_running_loop()
74
+ return await loop.run_in_executor(
75
+ self._executor, self._transcribe_sync, audio, language
76
+ )
77
+
78
+ def _transcribe_sync(self, audio: np.ndarray, language: str) -> str:
79
+ segments, _ = self.model_faster.transcribe(
80
+ audio,
81
+ language=language,
82
+ beam_size=1, # faster for realtime
83
+ )
84
+ return " ".join([seg.text for seg in segments]).strip()
85
+
86
+
87
+ class STT(ModuleWithHandle):
88
+ """STT Module
89
+
90
+ Transcribe voice using Faster_Whisper.
91
+
92
+ Holds the per-session sliding-window buffer and delegates the actual
93
+ transcription to a handle-backed STTHandle, so the Whisper model runs
94
+ off the HuRI master node.
95
+
96
+ input: huri.voice,
97
+ output: huri.transcript
98
+
99
+ :language: language spoken in the audio. It should be a language code such
100
+ as "en" or "fr".
101
+ :sample_rate: size of received voice audio. Usually 8000, 16000 or 48000.
102
+ :block_duration: size of received voice audio (in s).
103
+ :transcribe_window: duration of audio per transcription (in s).
104
+ :transcribe_step: overlap between consecutive transcription windows (in s).
105
+ """
106
+
107
+ _handle_cls = STTHandle
108
+ input_type = "huri.voice"
109
+ output_type = "huri.transcript"
110
+ input_event = Voice
111
+ output_event = Transcript
112
+
113
+ def __init__(
114
+ self,
115
+ _handle: handle.DeploymentHandle,
116
+ language: str = "en",
117
+ sample_rate: int = 16000,
118
+ block_duration: float = 0.020, # s
119
+ transcribe_window: float = 2.0, # s
120
+ transcribe_step: float = 1.0, # s
121
+ **kwargs,
122
+ ):
123
+ super().__init__(_handle=_handle, **kwargs)
124
+
125
+ self.language = language
126
+
127
+ self.sample_rate = sample_rate
128
+ self.window_size: int = int(transcribe_window / block_duration)
129
+ self.step_size: int = int(transcribe_step / block_duration)
130
+
131
+ self.buffer: List[np.ndarray] = []
132
+ # Number of leading frames in `buffer` already covered by a previous
133
+ # transcription (the overlap kept when the window slides). Frames past
134
+ # it are audio Whisper has not heard yet.
135
+ self._covered: int = 0
136
+
137
+ self.running = False
138
+ self.lock: asyncio.Lock = asyncio.Lock()
139
+
140
+ # Set when the end of the voice is received. Remembered if a
141
+ # transcription is in flight, so that call handles it once it finishes.
142
+ self._end_requested: bool = False
143
+
144
+ async def process(self, voice: Voice) -> AsyncGenerator[Transcript, None]:
145
+ async with self.lock:
146
+ if voice.data is None:
147
+ self._end_requested = True
148
+ else:
149
+ self.buffer.append(voice.data)
150
+ if self.running:
151
+ return
152
+ self.running = True
153
+
154
+ try:
155
+ async for transcript in self._transcribe_pending():
156
+ yield transcript
157
+ finally:
158
+ self.running = False
159
+
160
+ async def _transcribe_pending(self) -> AsyncGenerator[Transcript, None]:
161
+ """Run transcriptions until there is nothing left to do.
162
+
163
+ Loops so that an end-of-turn that arrives while a window is being
164
+ transcribed is handled right after it, in this same call. Each pass is
165
+ yielded as its own Transcript.
166
+ """
167
+
168
+ while True:
169
+ async with self.lock:
170
+ end = self._end_requested
171
+ if end:
172
+ self._end_requested = False
173
+ chunks = list(self.buffer)
174
+ if len(chunks) <= self._covered:
175
+ # Nothing new since the last window (only the already
176
+ # transcribed overlap is left): re-running Whisper on it
177
+ # would only repeat itself or invite hallucinations.
178
+ self.buffer = []
179
+ self._covered = 0
180
+ chunks = []
181
+ else:
182
+ if len(self.buffer) < self.window_size:
183
+ return
184
+ chunks = self.buffer[: self.window_size]
185
+
186
+ if end and not chunks:
187
+ yield Transcript("", True)
188
+ return
189
+
190
+ audio = np.concatenate(chunks, axis=0)
191
+ text = await self._handle.transcribe.remote(audio, self.language)
192
+
193
+ async with self.lock:
194
+ if end:
195
+ # Whole remaining buffer was transcribed: reset per-turn
196
+ # state so the NEXT utterance is independent. Frames that
197
+ # arrived during the await belong to the next turn.
198
+ self.buffer = self.buffer[len(chunks) :]
199
+ self._covered = 0
200
+ else:
201
+ # Mid-utterance: slide the window forward, keeping the
202
+ # overlap.
203
+ processed = self.window_size - self.step_size
204
+ self.buffer = self.buffer[processed:]
205
+ self._covered = max(0, len(chunks) - processed)
206
+
207
+ yield Transcript(text, end)
208
+ if not self._end_requested:
209
+ return
@@ -0,0 +1,25 @@
1
+ [build-system]
2
+ requires = ["setuptools>=64,<82"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "huri-module-stt"
7
+ version = "0.1.0"
8
+ description = "HuRI module: Speech to text (faster-whisper) as a Ray Serve deployment"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ dependencies = [
12
+ "huri-core[server]>=0.4,<0.5",
13
+ "huri-events>=0.1,<0.2",
14
+ "numpy>=1.26,<2",
15
+ "faster-whisper>=1.2.1,<1.3",
16
+ ]
17
+
18
+ [project.entry-points."huri.modules"]
19
+ stt = "huri_stt.module:STT"
20
+
21
+ [tool.setuptools.packages.find]
22
+ include = ["huri_stt*"]
23
+
24
+ [tool.setuptools.package-data]
25
+ huri_stt = ["huri_module.toml", "manifests/*/huri_module.toml"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,105 @@
1
+ import asyncio
2
+
3
+ import numpy as np
4
+ from huri_events.speech import Transcript, Voice
5
+
6
+ from huri_core.testing import LocalHandle, ModuleHarness
7
+
8
+
9
+ class FakeWhisper:
10
+ """Stands in for STTHandle: one scripted text per call; can block the first."""
11
+
12
+ def __init__(self, texts, gate=None):
13
+ self.texts = list(texts)
14
+ self.gate = gate
15
+ self.calls = []
16
+
17
+ async def transcribe(self, audio, language):
18
+ self.calls.append(len(audio))
19
+ if self.gate is not None and len(self.calls) == 1:
20
+ await self.gate.wait()
21
+ return self.texts.pop(0)
22
+
23
+
24
+ def _stt(whisper):
25
+ from huri_stt.module import STT
26
+
27
+ # 20 ms blocks -> window 10 frames, step 5 frames (fast tests).
28
+ return ModuleHarness(
29
+ STT, handle=LocalHandle(whisper), transcribe_window=0.2, transcribe_step=0.1
30
+ )
31
+
32
+
33
+ def _voice():
34
+ return Voice(np.zeros(4, dtype=np.float32))
35
+
36
+
37
+ async def _feed(stt, n):
38
+ return await stt.feed_all(*[_voice() for _ in range(n)])
39
+
40
+
41
+ def test_end_event_during_inflight_transcription_is_not_lost():
42
+ async def run():
43
+ gate = asyncio.Event()
44
+ h = FakeWhisper(["what is the", "the weather"], gate)
45
+ stt = _stt(h)
46
+ first = asyncio.create_task(_feed(stt, 10))
47
+ await asyncio.sleep(0)
48
+ await asyncio.sleep(0)
49
+ # In-flight call is blocked: more speech arrives, then the VAD
50
+ # reports end of turn.
51
+ for _ in range(12):
52
+ assert await stt.feed(_voice()) == []
53
+ assert await stt.feed(Voice(None)) == []
54
+ gate.set()
55
+ out = await first
56
+ # STT does not merge: one Transcript per pass, in order (TAG stitches
57
+ # them, see huri-module-tag's tests).
58
+ assert out == [
59
+ Transcript("what is the", False),
60
+ Transcript("the weather", True),
61
+ ]
62
+
63
+ asyncio.run(run())
64
+
65
+
66
+ def test_tail_beyond_first_window_is_transcribed_at_end():
67
+ async def run():
68
+ h = FakeWhisper(["b c d e f"])
69
+ stt = _stt(h)
70
+ # Backlog: more frames buffered than one window, then the turn ends.
71
+ stt.module.buffer = [np.zeros(4, np.float32)] * 14
72
+ out = await stt.feed(Voice(None))
73
+ assert out == [Transcript("b c d e f", True)]
74
+ assert h.calls == [14 * 4] # whole buffer, not just first window
75
+ assert stt.module.buffer == [] and stt.module._covered == 0
76
+
77
+ asyncio.run(run())
78
+
79
+
80
+ def test_end_with_no_new_audio_skips_whisper():
81
+ async def run():
82
+ h = FakeWhisper(["hello world", "should not be called"])
83
+ stt = _stt(h)
84
+ out = await _feed(stt, 10)
85
+ assert out == [Transcript("hello world", False)]
86
+ stt.module.buffer = stt.module.buffer[
87
+ : stt.module._covered
88
+ ] # only overlap left
89
+ out = await stt.feed(Voice(None))
90
+ assert out == [Transcript("", True)]
91
+ assert len(h.calls) == 1
92
+
93
+ asyncio.run(run())
94
+
95
+
96
+ def test_short_utterance_below_one_window_is_still_transcribed():
97
+ async def run():
98
+ h = FakeWhisper(["yes"])
99
+ stt = _stt(h)
100
+ out = await _feed(stt, 3) # far less than one window / overlap
101
+ assert out == []
102
+ out = await stt.feed(Voice(None))
103
+ assert out == [Transcript("yes", True)]
104
+
105
+ asyncio.run(run())