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.
- huri_module_stt-0.1.0/PKG-INFO +40 -0
- huri_module_stt-0.1.0/README.md +29 -0
- huri_module_stt-0.1.0/huri_module_stt.egg-info/PKG-INFO +40 -0
- huri_module_stt-0.1.0/huri_module_stt.egg-info/SOURCES.txt +12 -0
- huri_module_stt-0.1.0/huri_module_stt.egg-info/dependency_links.txt +1 -0
- huri_module_stt-0.1.0/huri_module_stt.egg-info/entry_points.txt +2 -0
- huri_module_stt-0.1.0/huri_module_stt.egg-info/requires.txt +4 -0
- huri_module_stt-0.1.0/huri_module_stt.egg-info/top_level.txt +1 -0
- huri_module_stt-0.1.0/huri_stt/__init__.py +0 -0
- huri_module_stt-0.1.0/huri_stt/huri_module.toml +7 -0
- huri_module_stt-0.1.0/huri_stt/module.py +209 -0
- huri_module_stt-0.1.0/pyproject.toml +25 -0
- huri_module_stt-0.1.0/setup.cfg +4 -0
- huri_module_stt-0.1.0/tests/test_stt.py +105 -0
|
@@ -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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
huri_stt
|
|
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,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())
|