huri-cli 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.
huri_cli/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """Reference command-line client for HuRI, built on the client SDK."""
huri_cli/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from huri_cli.main import main
2
+
3
+ main()
huri_cli/audio.py ADDED
@@ -0,0 +1,160 @@
1
+ import asyncio
2
+ import logging
3
+ import os
4
+ import wave
5
+ from datetime import datetime
6
+ from typing import Any, Callable, List, Optional
7
+
8
+ import numpy as np
9
+ from huri_events.audio import Audio
10
+ from scipy.signal import resample
11
+
12
+ logger = logging.getLogger("huri_cli.audio")
13
+
14
+
15
+ def _open_output(sample_rate: int) -> Any:
16
+ import sounddevice as sd
17
+
18
+ stream = sd.OutputStream(samplerate=sample_rate, channels=1, dtype="int16")
19
+ stream.start()
20
+ return stream
21
+
22
+
23
+ class AudioPlayer:
24
+ """Plays `huri.audio.out` on the default output device, optionally saving
25
+ each utterance as a WAV file.
26
+
27
+ The SDK hands chunks over in order, one at a time; the blocking device
28
+ write runs in a worker thread so the event loop keeps receiving meanwhile.
29
+ The device is opened on the first chunk, at the TTS sample rate when the
30
+ device supports it, else at `sample_rate` with resampling.
31
+ """
32
+
33
+ def __init__(
34
+ self,
35
+ save_audio_dir: str = "",
36
+ sample_rate: int = 48000,
37
+ incoming_sample_rate: int = 16000,
38
+ open_output: Callable[[int], Any] = _open_output,
39
+ ):
40
+ self.sample_rate = sample_rate
41
+ self.incoming_sample_rate = incoming_sample_rate
42
+ self._open_output = open_output
43
+ self.stream: Any = None
44
+ self._stream_sr: Optional[int] = None
45
+ self._resample_fallback = False
46
+
47
+ self.save_audio_dir = save_audio_dir
48
+ self._audio_buf: List[np.ndarray] = []
49
+ self._audio_sr: Optional[int] = None
50
+ self._audio_idx = 0
51
+ if save_audio_dir:
52
+ os.makedirs(save_audio_dir, exist_ok=True)
53
+
54
+ def _resample(self, audio: np.ndarray) -> np.ndarray:
55
+ """Resample float32 audio to the output device's rate."""
56
+ return np.asarray(
57
+ resample(
58
+ audio,
59
+ int(len(audio) * self.sample_rate / self.incoming_sample_rate),
60
+ ),
61
+ dtype=np.float32,
62
+ )
63
+
64
+ def _ensure_stream_for(self, sample_rate: int) -> None:
65
+ """Run the output device at the TTS rate when it can."""
66
+ if sample_rate == self._stream_sr:
67
+ return
68
+ if self._resample_fallback:
69
+ if self.stream is None:
70
+ self.stream = self._open_output(self.sample_rate)
71
+ self._stream_sr = self.sample_rate
72
+ return
73
+ try:
74
+ stream = self._open_output(sample_rate)
75
+ except Exception as e: # noqa: BLE001 - device capability probe
76
+ logger.warning(
77
+ "output device will not run at %sHz (%s); resampling to %sHz "
78
+ "instead (expect chunk clicks)",
79
+ sample_rate,
80
+ e,
81
+ self.sample_rate,
82
+ )
83
+ self._resample_fallback = True
84
+ self._ensure_stream_for(sample_rate)
85
+ return
86
+ self.close()
87
+ self.stream = stream
88
+ self._stream_sr = sample_rate
89
+ logger.info("audio output opened at %sHz", sample_rate)
90
+
91
+ def close(self) -> None:
92
+ if self.stream is not None:
93
+ try:
94
+ self.stream.stop()
95
+ self.stream.close()
96
+ except Exception: # noqa: BLE001
97
+ pass
98
+ self.stream = None
99
+
100
+ def _collect_audio(self, samples: np.ndarray, sample_rate: int, end: bool) -> None:
101
+ if samples.size:
102
+ self._audio_buf.append(samples)
103
+ self._audio_sr = sample_rate
104
+ if end:
105
+ self._flush_audio()
106
+
107
+ def _flush_audio(self) -> None:
108
+ if not self._audio_buf or self._audio_sr is None:
109
+ self._audio_buf = []
110
+ return
111
+ audio = np.concatenate(self._audio_buf)
112
+ stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
113
+ path = os.path.join(
114
+ self.save_audio_dir, f"utt-{self._audio_idx:03d}-{stamp}.wav"
115
+ )
116
+ self._write_wav(path, audio, self._audio_sr)
117
+ logger.info(
118
+ "saved audio: %s (%d samples, ~%.2fs @ %dHz)",
119
+ path,
120
+ audio.size,
121
+ audio.size / self._audio_sr,
122
+ self._audio_sr,
123
+ )
124
+ self._audio_idx += 1
125
+ self._audio_buf = []
126
+
127
+ @staticmethod
128
+ def _write_wav(path: str, audio: np.ndarray, sample_rate: int) -> None:
129
+ # float32 [-1, 1] -> 16-bit PCM, clipped to avoid wraparound on overshoot.
130
+ pcm = np.clip(audio, -1.0, 1.0)
131
+ pcm = (pcm * 32767.0).astype("<i2")
132
+ with wave.open(path, "wb") as wav:
133
+ wav.setnchannels(1)
134
+ wav.setsampwidth(2)
135
+ wav.setframerate(sample_rate)
136
+ wav.writeframes(pcm.tobytes())
137
+
138
+ async def __call__(self, data: Audio) -> None:
139
+ logger.debug(
140
+ "audio: pts=%.3fs samples=%d @ %dHz end=%s",
141
+ data.pts,
142
+ data.data.size,
143
+ data.sample_rate,
144
+ bool(data.end),
145
+ )
146
+ samples = np.asarray(data.data, dtype=np.float32)
147
+ if samples.size:
148
+ self.incoming_sample_rate = data.sample_rate
149
+ try:
150
+ self._ensure_stream_for(data.sample_rate)
151
+ if self._stream_sr != data.sample_rate:
152
+ samples = self._resample(samples)
153
+ pcm = (np.clip(samples, -1.0, 1.0) * 32767.0).astype(np.int16)
154
+ await asyncio.to_thread(self.stream.write, pcm.reshape(-1, 1))
155
+ except Exception as e: # noqa: BLE001 - never kill the session on audio
156
+ logger.error("audio playback failed: %s: %s", type(e).__name__, e)
157
+
158
+ if self.save_audio_dir:
159
+ # The incoming chunk, not the resampled one: saved at its own rate.
160
+ self._collect_audio(data.data, data.sample_rate, bool(data.end))
huri_cli/main.py ADDED
@@ -0,0 +1,159 @@
1
+ """huri-cli: talk to a HuRI server from a terminal.
2
+
3
+ huri-cli --config config/client_full.yaml [--log-level DEBUG] [--log-file cli.log]
4
+
5
+ The client config's `send:` and `subscribe:` pick among the topics below; its
6
+ `cli:` section holds the device settings:
7
+
8
+ cli: # every key is optional; these are the defaults
9
+ audio_in: {sample_rate: 16000, frame_duration: 0.030}
10
+ audio_out: {sample_rate: 48000, incoming_sample_rate: 16000, save_audio_dir: ""}
11
+ motion: {save_motion_dir: ""}
12
+ """
13
+
14
+ import argparse
15
+ import asyncio
16
+ import logging
17
+ from typing import Any, Awaitable, Callable, Dict, List, Mapping, Optional, Tuple, Type
18
+
19
+ from huri_core.core.client import (
20
+ HuRIClient,
21
+ HuRISessionError,
22
+ load_raw_client_config,
23
+ )
24
+ from huri_core.core.dataclasses.config import ClientConfig
25
+ from huri_core.core.events import EventData
26
+ from huri_events.audio import Audio
27
+ from huri_events.motion import Motion
28
+ from huri_events.question import RAGQuestion
29
+ from huri_events.text import Token
30
+
31
+ from .audio import AudioPlayer
32
+ from .senders import send_microphone, send_prompt
33
+ from .viewer import MotionViewer
34
+
35
+ logger = logging.getLogger("huri_cli")
36
+
37
+
38
+ async def print_question(data: RAGQuestion) -> None:
39
+ print(
40
+ "<<", data.transcript.text, f"(emotion: {data.emotion})" if data.emotion else ""
41
+ )
42
+
43
+
44
+ async def print_token(data: Token) -> None:
45
+ """The RAG's streamed reply, token by token; newline at end of utterance."""
46
+ if data.end:
47
+ print()
48
+ else:
49
+ print(data.text, end="", flush=True)
50
+
51
+
52
+ Receiver = Callable[[Any], Awaitable[None]]
53
+
54
+ # topic -> (event class, factory building the handler from the `cli:` settings)
55
+ RECEIVERS: Dict[str, Tuple[Type[EventData], Callable[[Mapping], Receiver]]] = {
56
+ "huri.audio.out": (Audio, lambda cli: AudioPlayer(**cli.get("audio_out", {}))),
57
+ "huri.motion": (Motion, lambda cli: MotionViewer(**cli.get("motion", {}))),
58
+ "huri.question": (RAGQuestion, lambda cli: print_question),
59
+ "huri.token": (Token, lambda cli: print_token),
60
+ }
61
+
62
+ # topic -> coroutine sending it until it returns or is cancelled
63
+ SENDERS: Dict[str, Callable[[HuRIClient, str, Mapping], Awaitable[None]]] = {
64
+ "huri.audio.in": lambda client, topic, cli: send_microphone(
65
+ client, topic, **cli.get("audio_in", {})
66
+ ),
67
+ "huri.question": lambda client, topic, cli: send_prompt(client, topic),
68
+ }
69
+
70
+
71
+ def build_client(config: ClientConfig, cli: Mapping) -> HuRIClient:
72
+ unsupported = [t for t in config.send if t not in SENDERS]
73
+ unsupported += [t for t in config.subscribe if t not in RECEIVERS]
74
+ if unsupported:
75
+ raise ValueError(
76
+ f"huri-cli cannot handle topic(s) {unsupported}. It sends "
77
+ f"{sorted(SENDERS)} and receives {sorted(RECEIVERS)}"
78
+ )
79
+ client = HuRIClient.from_config(config)
80
+ for topic in config.subscribe:
81
+ event_cls, make = RECEIVERS[topic]
82
+ client.on(topic, event_cls, make(cli))
83
+ return client
84
+
85
+
86
+ async def run(config: ClientConfig, cli: Optional[Mapping] = None) -> int:
87
+ cli = cli or {}
88
+ client = build_client(config, cli)
89
+ try:
90
+ await client.connect()
91
+ except HuRISessionError as e:
92
+ logger.error("HuRI rejected this client config: %s", e.error)
93
+ if e.server_modules:
94
+ logger.error(" server registered : %s", ", ".join(e.server_modules))
95
+ logger.error(" this config wants : %s", ", ".join(config.modules))
96
+ return 1
97
+ except OSError as e:
98
+ logger.error(
99
+ "cannot reach HuRI at %s: %s. Is the server deployed? (with "
100
+ "huri-modules' installer: .huri-local/status.sh; replica logs in "
101
+ "/tmp/ray/session_latest/logs/serve/)",
102
+ config.huri_url,
103
+ e,
104
+ )
105
+ return 1
106
+ logger.info("session started, user_id=%s", client.user_id)
107
+ try:
108
+ loops: List[asyncio.Future] = [
109
+ asyncio.ensure_future(SENDERS[topic](client, topic, cli))
110
+ for topic in config.send
111
+ ]
112
+ closed = asyncio.ensure_future(client.wait_closed())
113
+ # The session ends when a sender returns (`\exit` at the prompt) or
114
+ # the server closes the connection.
115
+ done, pending = await asyncio.wait(
116
+ loops + [closed], return_when=asyncio.FIRST_COMPLETED
117
+ )
118
+ for task in pending:
119
+ task.cancel()
120
+ for task in done:
121
+ if task is not closed and task.exception() is not None:
122
+ logger.error("sender failed: %r", task.exception())
123
+ return 1
124
+ if closed in done:
125
+ logger.info("the server closed the session")
126
+ return 0
127
+ finally:
128
+ await client.close()
129
+
130
+
131
+ def setup_logging(level: str, log_file: Optional[str]) -> None:
132
+ handlers: List[logging.Handler] = [logging.StreamHandler()]
133
+ if log_file:
134
+ handlers.append(logging.FileHandler(log_file))
135
+ logging.basicConfig(
136
+ level=level.upper(),
137
+ format="%(asctime)s %(levelname)-7s %(name)s: %(message)s",
138
+ datefmt="%H:%M:%S",
139
+ handlers=handlers,
140
+ )
141
+
142
+
143
+ def main(argv: Optional[List[str]] = None) -> None:
144
+ parser = argparse.ArgumentParser(
145
+ prog="huri-cli", description=__doc__.split("\n\n")[0]
146
+ )
147
+ parser.add_argument("--config", required=True, help="client config file (YAML)")
148
+ parser.add_argument("--log-level", default="INFO", help="DEBUG, INFO, WARNING...")
149
+ parser.add_argument("--log-file", help="also write the logs to this file")
150
+ args = parser.parse_args(argv)
151
+
152
+ setup_logging(args.log_level, args.log_file)
153
+ raw = load_raw_client_config(args.config)
154
+ config = ClientConfig.from_dict(raw)
155
+ try:
156
+ code = asyncio.run(run(config, dict(raw.get("cli") or {})))
157
+ except KeyboardInterrupt:
158
+ code = 0
159
+ raise SystemExit(code)
huri_cli/senders.py ADDED
@@ -0,0 +1,61 @@
1
+ import asyncio
2
+ import logging
3
+
4
+ import numpy as np
5
+ from huri_core.core.client import HuRIClient
6
+ from huri_core.core.events import BytesEvent
7
+ from huri_events.question import RAGQuestion
8
+ from huri_events.speech import Transcript
9
+
10
+ logger = logging.getLogger("huri_cli")
11
+
12
+
13
+ async def send_microphone(
14
+ client: HuRIClient,
15
+ topic: str,
16
+ sample_rate: int = 16000,
17
+ frame_duration: float = 0.030,
18
+ ) -> None:
19
+ """Streams 16-bit mono microphone frames until cancelled."""
20
+
21
+ import sounddevice as sd
22
+
23
+ loop = asyncio.get_running_loop()
24
+ queue: asyncio.Queue[np.ndarray] = asyncio.Queue()
25
+
26
+ def callback(indata: np.ndarray, frames, time, status):
27
+ if status:
28
+ logger.warning("microphone: %s", status)
29
+ loop.call_soon_threadsafe(queue.put_nowait, indata.copy())
30
+
31
+ logger.info("microphone: %s", sd.query_devices(kind="input")["name"])
32
+ with sd.InputStream(
33
+ samplerate=sample_rate,
34
+ channels=1,
35
+ dtype="int16",
36
+ callback=callback,
37
+ blocksize=int(sample_rate * frame_duration),
38
+ ):
39
+ while True:
40
+ chunk = await queue.get()
41
+ await client.send(topic, BytesEvent(data=chunk.tobytes()))
42
+
43
+
44
+ async def send_prompt(client: HuRIClient, topic: str) -> None:
45
+ """Sends each typed line as a question; returns on `\\exit` or EOF."""
46
+
47
+ from prompt_toolkit import PromptSession
48
+ from prompt_toolkit.patch_stdout import patch_stdout
49
+
50
+ print("'\\exit' or CTRL+D/C to exit.")
51
+ session: PromptSession = PromptSession()
52
+ try:
53
+ while True:
54
+ with patch_stdout():
55
+ text = await session.prompt_async(">> ")
56
+ if text == "\\exit":
57
+ return
58
+ if text.strip():
59
+ await client.send(topic, RAGQuestion(Transcript(text, True), None))
60
+ except (EOFError, KeyboardInterrupt):
61
+ pass
huri_cli/viewer.py ADDED
@@ -0,0 +1,90 @@
1
+ """A terminal view of the gesture stream (`huri.motion`).
2
+
3
+ Each Motion chunk carries SMPL-X poses: 55 joints x 3 axis-angle values per
4
+ frame. The viewer groups the joints by body part and shows how much each part
5
+ moves (mean absolute change of its joint rotations between frames), so one can
6
+ see at a glance whether gestures follow the speech. Optionally every chunk is
7
+ saved as .npz for offline rendering with the EMAGE/SMPL-X tooling.
8
+ """
9
+
10
+ import logging
11
+ import os
12
+ from typing import Dict, List, Optional, Sequence
13
+
14
+ import numpy as np
15
+ from huri_events.motion import Motion
16
+
17
+ logger = logging.getLogger("huri_cli.motion")
18
+
19
+ # SMPL-X joint indices (55-joint body + jaw/eyes + hands layout).
20
+ BODY_PARTS: Dict[str, Sequence[int]] = {
21
+ "head": (12, 15, 22, 23, 24),
22
+ "torso": (0, 3, 6, 9),
23
+ "l_arm": (13, 16, 18, 20),
24
+ "r_arm": (14, 17, 19, 21),
25
+ "hands": tuple(range(25, 55)),
26
+ "legs": (1, 2, 4, 5, 7, 8, 10, 11),
27
+ }
28
+ _BAR = " ▁▂▃▄▅▆▇█"
29
+
30
+
31
+ def part_activity(
32
+ poses: np.ndarray, previous: Optional[np.ndarray] = None
33
+ ) -> Dict[str, float]:
34
+ """Mean absolute rotation change per frame (radians) of each body part."""
35
+
36
+ frames = poses.reshape(len(poses), 55, 3)
37
+ if previous is not None:
38
+ frames = np.concatenate([previous.reshape(1, 55, 3), frames])
39
+ if len(frames) < 2:
40
+ return {part: 0.0 for part in BODY_PARTS}
41
+ delta = np.abs(np.diff(frames, axis=0)) # (t-1, 55, 3)
42
+ return {
43
+ part: float(delta[:, list(joints), :].mean())
44
+ for part, joints in BODY_PARTS.items()
45
+ }
46
+
47
+
48
+ def bar(value: float, full_scale: float = 0.05) -> str:
49
+ level = min(int(value / full_scale * (len(_BAR) - 1)), len(_BAR) - 1)
50
+ return _BAR[max(level, 0)]
51
+
52
+
53
+ class MotionViewer:
54
+ """Logs one line per Motion chunk: timing plus a bar per body part."""
55
+
56
+ def __init__(self, save_motion_dir: str = "", full_scale: float = 0.05):
57
+ self.full_scale = full_scale
58
+ self.save_motion_dir = save_motion_dir
59
+ self._last_pose: Optional[np.ndarray] = None
60
+ self._index = 0
61
+ if save_motion_dir:
62
+ os.makedirs(save_motion_dir, exist_ok=True)
63
+
64
+ def render(self, motion: Motion) -> str:
65
+ activity = part_activity(motion.poses, self._last_pose)
66
+ parts: List[str] = [
67
+ f"{part} {bar(value, self.full_scale)}" for part, value in activity.items()
68
+ ]
69
+ n = motion.poses.shape[0]
70
+ return (
71
+ f"motion pts={motion.pts:7.3f}s {n:3d}f @{motion.fps}fps "
72
+ f"({n / max(motion.fps, 1):.2f}s) | " + " ".join(parts)
73
+ )
74
+
75
+ async def __call__(self, motion: Motion) -> None:
76
+ if motion.poses.shape[0] == 0:
77
+ return
78
+ logger.info(self.render(motion))
79
+ self._last_pose = motion.poses[-1]
80
+ if self.save_motion_dir:
81
+ path = os.path.join(self.save_motion_dir, f"motion-{self._index:04d}.npz")
82
+ np.savez(
83
+ path,
84
+ poses=motion.poses,
85
+ expressions=motion.expressions,
86
+ trans=motion.trans,
87
+ fps=motion.fps,
88
+ pts=motion.pts,
89
+ )
90
+ self._index += 1