huri-module-vad 0.1.0.dev0__tar.gz → 0.2.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.
@@ -1,11 +1,11 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: huri-module-vad
3
- Version: 0.1.0.dev0
3
+ Version: 0.2.0
4
4
  Summary: HuRI module: Voice activity detection (WebRTC VAD): microphone frames in, speech segments out
5
5
  Requires-Python: >=3.10
6
6
  Description-Content-Type: text/markdown
7
- Requires-Dist: huri-core[server]<0.5,>=0.4.0.dev0
8
- Requires-Dist: huri-events<0.2,>=0.1.0.dev0
7
+ Requires-Dist: huri-core[server]<0.5,>=0.4
8
+ Requires-Dist: huri-events<0.3,>=0.1
9
9
  Requires-Dist: numpy<2,>=1.26
10
10
  Requires-Dist: webrtcvad==2.0.10
11
11
  Requires-Dist: setuptools<82
@@ -1,11 +1,11 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: huri-module-vad
3
- Version: 0.1.0.dev0
3
+ Version: 0.2.0
4
4
  Summary: HuRI module: Voice activity detection (WebRTC VAD): microphone frames in, speech segments out
5
5
  Requires-Python: >=3.10
6
6
  Description-Content-Type: text/markdown
7
- Requires-Dist: huri-core[server]<0.5,>=0.4.0.dev0
8
- Requires-Dist: huri-events<0.2,>=0.1.0.dev0
7
+ Requires-Dist: huri-core[server]<0.5,>=0.4
8
+ Requires-Dist: huri-events<0.3,>=0.1
9
9
  Requires-Dist: numpy<2,>=1.26
10
10
  Requires-Dist: webrtcvad==2.0.10
11
11
  Requires-Dist: setuptools<82
@@ -9,4 +9,5 @@ huri_module_vad.egg-info/top_level.txt
9
9
  huri_vad/__init__.py
10
10
  huri_vad/huri_module.toml
11
11
  huri_vad/module.py
12
- tests/test_vad.py
12
+ tests/test_vad.py
13
+ tests/test_vad_detector.py
@@ -0,0 +1,5 @@
1
+ huri-core[server]<0.5,>=0.4
2
+ huri-events<0.3,>=0.1
3
+ numpy<2,>=1.26
4
+ webrtcvad==2.0.10
5
+ setuptools<82
@@ -2,6 +2,6 @@ name = "vad"
2
2
  summary = "Detects speech in microphone frames (WebRTC VAD)"
3
3
  input = { topic = "huri.audio.in", event = "huri_core.core.events:BytesEvent" }
4
4
  output = { topic = "huri.voice", event = "huri_events.speech:Voice" }
5
- events = ["huri-events>=0.1,<0.2"]
5
+ events = ["huri-events>=0.1,<0.3"]
6
6
  requires = { gpu = false, actuation = false }
7
- huri_core = ">=0.4.0.dev0,<0.5"
7
+ huri_core = ">=0.4,<0.5"
@@ -1,4 +1,4 @@
1
- from typing import Optional
1
+ from typing import AsyncGenerator, Optional
2
2
 
3
3
  import numpy as np
4
4
  import webrtcvad
@@ -19,8 +19,8 @@ class VAD(Module):
19
19
  :vad_agressiveness: from 0 (low) to 3 (high, can distord audio).
20
20
  :silence_duration: how many seconds will a no voice be considered a silence.
21
21
  :sample_rate: size of received chunk of audio. Usually 8000, 16000 or 48000.
22
- :block_duration: size of received chunk of audio (in s).
23
- Can only be 0.010, 0.020 and 0.030.
22
+ :block_duration: size of the frames WebRTC analyses (in s), 0.010, 0.020
23
+ or 0.030. Received chunks of any size are re-cut to it.
24
24
  """
25
25
 
26
26
  # Inbound microphone frames travel on their own topic so the TTS-output
@@ -50,12 +50,26 @@ class VAD(Module):
50
50
  self.silence_frames_count: int = -1
51
51
 
52
52
  self.vad = webrtcvad.Vad(vad_agressiveness)
53
-
54
- async def process(self, data: BytesEvent) -> Optional[Voice]:
55
- if self.vad.is_speech(data.data, self.sample_rate) is True:
53
+ # WebRTC only takes frames of exactly block_size samples; a client may
54
+ # send any size, so the bytes are re-cut here and a partial frame waits
55
+ # for the next chunk.
56
+ self._pending = b""
57
+
58
+ async def process(self, data: BytesEvent) -> AsyncGenerator[Voice, None]:
59
+ self._pending += data.data
60
+ frame_bytes = self.block_size * 2 # int16
61
+ while len(self._pending) >= frame_bytes:
62
+ frame = self._pending[:frame_bytes]
63
+ self._pending = self._pending[frame_bytes:]
64
+ voice = self._frame(frame)
65
+ if voice is not None:
66
+ yield voice
67
+
68
+ def _frame(self, frame: bytes) -> Optional[Voice]:
69
+ if self.vad.is_speech(frame, self.sample_rate) is True:
56
70
  self.silence_frames_count = 0
57
71
 
58
- audio_array = np.frombuffer(data.data, dtype=np.int16)
72
+ audio_array = np.frombuffer(frame, dtype=np.int16)
59
73
  audio_array_float = audio_array.astype(np.float32) / 32768.0
60
74
 
61
75
  return Voice(audio_array_float)
@@ -4,13 +4,13 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "huri-module-vad"
7
- version = "0.1.0.dev0"
7
+ version = "0.2.0"
8
8
  description = "HuRI module: Voice activity detection (WebRTC VAD): microphone frames in, speech segments out"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
11
11
  dependencies = [
12
- "huri-core[server]>=0.4.0.dev0,<0.5",
13
- "huri-events>=0.1.0.dev0,<0.2",
12
+ "huri-core[server]>=0.4,<0.5",
13
+ "huri-events>=0.1,<0.3",
14
14
  "numpy>=1.26,<2",
15
15
  "webrtcvad==2.0.10",
16
16
  # webrtcvad imports pkg_resources, removed in setuptools 82
@@ -49,3 +49,40 @@ def test_block_duration_must_be_one_webrtc_accepts():
49
49
  def test_real_detector_runs_on_a_frame():
50
50
  out = ModuleHarness(VAD).run(BytesEvent(bytes(640))) # digital silence
51
51
  assert out == [] or isinstance(out[0], Voice)
52
+
53
+
54
+ class Recording(Scripted):
55
+ def __init__(self, decisions):
56
+ super().__init__(decisions)
57
+ self.frames = []
58
+
59
+ def is_speech(self, frame, sample_rate):
60
+ self.frames.append(len(frame))
61
+ return super().is_speech(frame, sample_rate)
62
+
63
+
64
+ def test_chunks_of_any_size_are_recut_into_frames_webrtc_accepts():
65
+ """WebRTC raises on any other frame size; a client may send any chunk
66
+ size, down to an odd number of bytes."""
67
+ harness = ModuleHarness(VAD, silence_duration=0.05)
68
+ harness.module.vad = detector = Recording([True] * 4)
69
+ audio = FRAME * 3 + FRAME[:100]
70
+
71
+ out = harness.run(
72
+ *(BytesEvent(audio[i : i + 333]) for i in range(0, len(audio), 333))
73
+ )
74
+
75
+ assert detector.frames == [640, 640, 640] # the last 100 bytes wait
76
+ assert [v.data.size for v in out] == [320, 320, 320]
77
+ harness.run(BytesEvent(FRAME[100:])) # completes the 4th frame
78
+ assert detector.frames == [640] * 4
79
+
80
+
81
+ def test_one_chunk_holding_speech_then_silence_ends_the_utterance():
82
+ out = vad([True] + [False] * 6).run(BytesEvent(FRAME * 7))
83
+ assert [v.data is None for v in out] == [False, True]
84
+
85
+
86
+ def test_real_detector_takes_an_odd_sized_chunk():
87
+ out = ModuleHarness(VAD).run(BytesEvent(bytes(1001)), BytesEvent(bytes(279)))
88
+ assert out == [] or all(isinstance(v, Voice) for v in out)
@@ -0,0 +1,65 @@
1
+ """VAD with the real WebRTC detector, on synthetic speech (speech_signals.py).
2
+
3
+ test_vad.py scripts the detector's decisions; these tests let it decide, so
4
+ they also guard that the synthetic signal still reads as speech to it. What
5
+ the rest of the pipeline relies on: ``Voice(None)`` exactly once per
6
+ utterance (EMO, and through EAG/QAG the whole turn, depend on it) and nothing
7
+ at all before anyone spoke.
8
+ """
9
+
10
+ from huri_events.speech import Voice
11
+ from huri_vad.module import VAD
12
+ from speech_signals import SAMPLE_RATE, frames, silence, speech_like
13
+
14
+ from huri_core.core.events import BytesEvent
15
+ from huri_core.testing import ModuleHarness
16
+
17
+ BLOCK_DURATION = 0.030
18
+ BLOCK = int(BLOCK_DURATION * SAMPLE_RATE)
19
+
20
+
21
+ def _vad(**kwargs):
22
+ params = dict(
23
+ vad_agressiveness=3,
24
+ silence_duration=0.3,
25
+ sample_rate=SAMPLE_RATE,
26
+ block_duration=BLOCK_DURATION,
27
+ )
28
+ params.update(kwargs)
29
+ return ModuleHarness(VAD, **params)
30
+
31
+
32
+ def _feed(vad, *audios):
33
+ events = [BytesEvent(f.tobytes()) for audio in audios for f in frames(audio, BLOCK)]
34
+ return vad.run(*events)
35
+
36
+
37
+ def _markers(out):
38
+ return [v for v in out if v.data is None]
39
+
40
+
41
+ def test_synthetic_speech_reads_as_speech():
42
+ out = _feed(_vad(), speech_like(1.0))
43
+ voiced = [v for v in out if v.data is not None]
44
+ assert len(voiced) > len(frames(speech_like(1.0), BLOCK)) * 0.8, (
45
+ "the synthetic signal must read as speech to webrtcvad, or every test "
46
+ "in this file passes vacuously"
47
+ )
48
+ assert all(isinstance(v, Voice) and v.data.dtype.name == "float32" for v in voiced)
49
+
50
+
51
+ def test_end_of_turn_is_emitted_exactly_once():
52
+ out = _feed(_vad(), speech_like(0.6), silence(2.0))
53
+ assert len(_markers(out)) == 1
54
+
55
+
56
+ def test_no_end_of_turn_before_any_speech():
57
+ assert _feed(_vad(), silence(3.0)) == []
58
+
59
+
60
+ def test_two_turns_each_get_their_own_marker():
61
+ out = _feed(_vad(), speech_like(0.6), silence(2.0), speech_like(0.6), silence(2.0))
62
+ assert len(_markers(out)) == 2
63
+ # Speech, marker, speech, marker: the markers close their own turn.
64
+ kinds = ["end" if v.data is None else "voice" for v in out]
65
+ assert kinds.index("end") < len(kinds) - 1 - kinds[::-1].index("voice")
@@ -1,5 +0,0 @@
1
- huri-core[server]<0.5,>=0.4.0.dev0
2
- huri-events<0.2,>=0.1.0.dev0
3
- numpy<2,>=1.26
4
- webrtcvad==2.0.10
5
- setuptools<82