add r1 robot project

This commit is contained in:
Jaroslav Vizner
2026-08-28 10:10:18 +02:00
parent 27aea1146d
commit 8d88495f6f
82 changed files with 22935 additions and 0 deletions
+101
View File
@@ -0,0 +1,101 @@
#include "r1_voice/conversation.hh"
#include <chrono>
#include <iostream>
#include <thread>
namespace r1_voice {
Conversation::Conversation(
ConversationConfig config)
: config_(std::move(config))
, stt_(config_.stt)
, llm_(config_.llm)
, tts_(config_.tts)
{
}
bool Conversation::init(
const std::string& network_interface)
{
return tts_.init(
network_interface
);
}
void Conversation::run()
{
std::cout
<< "\n"
<< "====================================\n"
<< " R1 Voice Assistant\n"
<< "====================================\n"
<< "Speak to R1.\n"
<< "Press Ctrl+C to exit.\n"
<< '\n';
tts_.speak(
"Ahoj. Jsem připraven."
);
// Give TTS time to finish before listening.
std::this_thread::sleep_for(
std::chrono::seconds(2)
);
while (true) {
const std::string user =
stt_.listen();
if (user.empty())
continue;
try {
const std::string response =
llm_.chat(user);
if (response.empty())
continue;
tts_.speak(response);
/*
* Avoid immediately hearing the robot's
* own TTS output as the next user utterance.
*
* Later we can replace this with proper
* audio echo suppression / playback state.
*/
const int estimated_ms =
1000 +
static_cast<int>(
response.size() * 50
);
std::this_thread::sleep_for(
std::chrono::milliseconds(
estimated_ms
)
);
}
catch (const std::exception& e) {
std::cerr
<< "Conversation error: "
<< e.what()
<< '\n';
tts_.speak(
"Promiň, při zpracování odpovědi nastala chyba."
);
std::this_thread::sleep_for(
std::chrono::seconds(2)
);
}
}
}
} // namespace r1_voice
+391
View File
@@ -0,0 +1,391 @@
#include "r1_voice/llm.hh"
#include <curl/curl.h>
#include <iostream>
#include <sstream>
#include <stdexcept>
namespace r1_voice {
namespace {
size_t writeCallback(
void* contents,
size_t size,
size_t nmemb,
void* user)
{
const size_t total = size * nmemb;
auto* output =
static_cast<std::string*>(user);
output->append(
static_cast<char*>(contents),
total
);
return total;
}
} // namespace
LLM::LLM(LlmConfig config)
: config_(std::move(config))
{
curl_global_init(CURL_GLOBAL_DEFAULT);
}
std::string LLM::escapeJson(
const std::string& text) const
{
std::string result;
result.reserve(text.size() + 16);
for (const char c : text) {
switch (c) {
case '"':
result += "\\\"";
break;
case '\\':
result += "\\\\";
break;
case '\n':
result += "\\n";
break;
case '\r':
result += "\\r";
break;
case '\t':
result += "\\t";
break;
default:
result += c;
break;
}
}
return result;
}
std::string LLM::httpPost(
const std::string& body) const
{
CURL* curl = curl_easy_init();
if (!curl) {
throw std::runtime_error(
"curl_easy_init() failed"
);
}
std::string response;
curl_slist* headers = nullptr;
headers = curl_slist_append(
headers,
"Content-Type: application/json"
);
curl_easy_setopt(
curl,
CURLOPT_URL,
config_.url.c_str()
);
curl_easy_setopt(
curl,
CURLOPT_HTTPHEADER,
headers
);
curl_easy_setopt(
curl,
CURLOPT_POST,
1L
);
curl_easy_setopt(
curl,
CURLOPT_POSTFIELDS,
body.c_str()
);
curl_easy_setopt(
curl,
CURLOPT_WRITEFUNCTION,
writeCallback
);
curl_easy_setopt(
curl,
CURLOPT_WRITEDATA,
&response
);
curl_easy_setopt(
curl,
CURLOPT_CONNECTTIMEOUT,
5L
);
curl_easy_setopt(
curl,
CURLOPT_TIMEOUT,
120L
);
const CURLcode ret =
curl_easy_perform(curl);
long status = 0;
curl_easy_getinfo(
curl,
CURLINFO_RESPONSE_CODE,
&status
);
curl_slist_free_all(headers);
curl_easy_cleanup(curl);
if (ret != CURLE_OK) {
throw std::runtime_error(
std::string("HTTP request failed: ") +
curl_easy_strerror(ret)
);
}
if (status < 200 || status >= 300) {
throw std::runtime_error(
"LLM HTTP status " +
std::to_string(status) +
"\nResponse:\n" +
response
);
}
return response;
}
std::string LLM::parseContent(
const std::string& json) const
{
const std::string key = "\"content\"";
const size_t key_pos =
json.find(key);
if (key_pos == std::string::npos)
return {};
const size_t colon =
json.find(
':',
key_pos + key.size()
);
if (colon == std::string::npos)
return {};
const size_t quote =
json.find(
'"',
colon + 1
);
if (quote == std::string::npos)
return {};
std::string result;
result.reserve(256);
bool escaped = false;
for (size_t i = quote + 1;
i < json.size();
++i) {
const char c = json[i];
if (escaped) {
switch (c) {
case 'n':
result += '\n';
break;
case 'r':
result += '\r';
break;
case 't':
result += '\t';
break;
case '"':
result += '"';
break;
case '\\':
result += '\\';
break;
case '/':
result += '/';
break;
default:
result += c;
break;
}
escaped = false;
continue;
}
if (c == '\\') {
escaped = true;
continue;
}
if (c == '"')
break;
result += c;
}
return result;
}
static std::string removeThinkBlocks(
std::string text)
{
for (;;) {
const size_t begin =
text.find("<think>");
if (begin == std::string::npos)
break;
const size_t end =
text.find(
"</think>",
begin
);
if (end == std::string::npos) {
text.erase(begin);
break;
}
const size_t len =
end +
std::string("</think>").size() -
begin;
text.erase(begin, len);
}
return text;
}
std::string LLM::chat(
const std::string& user)
{
history_.push_back({
"user",
user
});
std::ostringstream body;
body
<< '{'
<< "\"model\":\""
<< escapeJson(config_.model)
<< "\","
<< "\"messages\":["
<< '{'
<< "\"role\":\"system\","
<< "\"content\":\""
<< escapeJson(config_.system_prompt)
<< "\""
<< '}';
const size_t begin =
history_.size() > 12
? history_.size() - 12
: 0;
for (size_t i = begin;
i < history_.size();
++i) {
body
<< ",{"
<< "\"role\":\""
<< escapeJson(
history_[i].role
)
<< "\","
<< "\"content\":\""
<< escapeJson(
history_[i].content
)
<< "\""
<< '}';
}
body
<< "],"
<< "\"max_tokens\":"
<< config_.max_tokens
<< ','
<< "\"temperature\":"
<< config_.temperature
<< ','
<< "\"chat_template_kwargs\":{"
<< "\"enable_thinking\":false"
<< '}'
<< '}';
const std::string response =
httpPost(body.str());
std::string answer =
parseContent(response);
answer =
removeThinkBlocks(answer);
if (answer.empty()) {
std::cerr
<< "LLM returned empty content:\n"
<< response
<< '\n';
return {};
}
history_.push_back({
"assistant",
answer
});
return answer;
}
} // namespace r1_voice
+205
View File
@@ -0,0 +1,205 @@
#include <array>
#include <iostream>
#include <string>
#include <vector>
#include <unistd.h>
#include <opencv2/opencv.hpp>
#include <unitree/robot/go2/video/video_client.hpp>
#include "r1_vision/robot_common.hpp"
#include "r1_vision/kinematics.hpp"
using namespace r1_vision;
struct ClickState {
bool clicked = false;
cv::Point point{};
};
static void MouseCallback(int event, int x, int y, int, void* user) {
auto* s = static_cast<ClickState*>(user);
if (event == cv::EVENT_LBUTTONDOWN) {
s->clicked = true;
s->point = cv::Point(x, y);
std::cout << "[CLICK] " << x << ", " << y << std::endl;
}
}
static cv::Mat GetFrame(unitree::robot::go2::VideoClient& camera) {
std::vector<uint8_t> jpeg;
if (camera.GetImageSample(jpeg) != 0 || jpeg.empty()) return {};
cv::Mat encoded(1, static_cast<int>(jpeg.size()), CV_8UC1, jpeg.data());
return cv::imdecode(encoded, cv::IMREAD_COLOR);
}
int main(int argc, char** argv) {
if (argc < 3) {
std::cerr << "Usage: r1_hand_calibrate <network_interface> <camera_intrinsics.yaml>\n";
return 1;
}
cv::FileStorage fs(argv[2], cv::FILE_STORAGE_READ);
if (!fs.isOpened()) {
std::cerr << "Cannot open intrinsics: " << argv[2] << std::endl;
return 1;
}
cv::Mat K, D;
fs["camera_matrix"] >> K;
fs["distortion"] >> D;
fs.release();
if (K.empty() || D.empty()) {
std::cerr << "Invalid camera calibration file." << std::endl;
return 1;
}
pinocchio::Model model;
pinocchio::urdf::buildModel(
"/home/jvizner/unitree_ros/robots/r1_description/R1.urdf",
model);
pinocchio::Data data(model);
RobotController robot(argv[1]);
unitree::robot::go2::VideoClient camera;
camera.SetTimeout(1.0f);
camera.Init();
const std::string window = "R1 Wrist Extrinsic Calibration";
cv::namedWindow(window, cv::WINDOW_NORMAL);
ClickState click;
cv::setMouseCallback(window, MouseCallback, &click);
std::vector<cv::Point3f> robot_points;
std::vector<cv::Point2f> image_points;
std::cout << "\nControls:\n"
<< " left click = mark LEFT wrist\n"
<< " c = capture point\n"
<< " p/P = left shoulder pitch +/- 0.08 rad\n"
<< " r/R = left shoulder roll +/- 0.08 rad\n"
<< " y/Y = left shoulder yaw +/- 0.08 rad\n"
<< " e/E = left elbow +/- 0.08 rad\n"
<< " q = solve and quit\n";
while (true) {
cv::Mat frame = GetFrame(camera);
if (frame.empty()) {
usleep(10000);
continue;
}
const auto q = robot.CurrentQ();
const Eigen::Vector3d wrist = FramePosition(
model, data, q, "left_wrist_roll_link");
cv::Mat display = frame.clone();
cv::putText(display,
"samples=" + std::to_string(image_points.size()),
{20, 35}, cv::FONT_HERSHEY_SIMPLEX, 0.8,
{0, 255, 0}, 2);
cv::putText(display,
"wrist R1: " + std::to_string(wrist.x()) + ", " +
std::to_string(wrist.y()) + ", " +
std::to_string(wrist.z()),
{20, 70}, cv::FONT_HERSHEY_SIMPLEX, 0.55,
{255, 255, 0}, 2);
if (click.clicked)
cv::circle(display, click.point, 7, {0, 0, 255}, -1);
cv::imshow(window, display);
const int key = cv::waitKey(10) & 0xff;
if (key == 'q') break;
if (key == 'c') {
if (!click.clicked) {
std::cout << "[WARN] Click the wrist first." << std::endl;
continue;
}
image_points.emplace_back(
static_cast<float>(click.point.x),
static_cast<float>(click.point.y));
robot_points.emplace_back(
static_cast<float>(wrist.x()),
static_cast<float>(wrist.y()),
static_cast<float>(wrist.z()));
std::cout << "[CAPTURE] #" << image_points.size()
<< " pixel=" << click.point.x << "," << click.point.y
<< " robot=" << wrist.transpose() << std::endl;
click.clicked = false;
}
constexpr float step = 0.08f;
switch (key) {
case 'p': robot.MoveJointRelative(0, +step); break;
case 'P': robot.MoveJointRelative(0, -step); break;
case 'r': robot.MoveJointRelative(1, +step); break;
case 'R': robot.MoveJointRelative(1, -step); break;
case 'y': robot.MoveJointRelative(2, +step); break;
case 'Y': robot.MoveJointRelative(2, -step); break;
case 'e': robot.MoveJointRelative(3, +step); break;
case 'E': robot.MoveJointRelative(3, -step); break;
default: break;
}
}
cv::destroyAllWindows();
if (image_points.size() < 8) {
std::cerr << "Need at least 8 captured points; got "
<< image_points.size() << std::endl;
return 1;
}
cv::Mat rvec, tvec;
std::vector<int> inliers;
if (!cv::solvePnPRansac(
robot_points, image_points, K, D,
rvec, tvec, false, 200, 5.0, 0.99,
inliers, cv::SOLVEPNP_ITERATIVE)) {
std::cerr << "solvePnPRansac failed." << std::endl;
return 1;
}
cv::Mat R;
cv::Rodrigues(rvec, R);
std::vector<cv::Point2f> projected;
cv::projectPoints(robot_points, rvec, tvec, K, D, projected);
double sum_sq = 0.0;
for (size_t i = 0; i < image_points.size(); ++i) {
const double dx = image_points[i].x - projected[i].x;
const double dy = image_points[i].y - projected[i].y;
sum_sq += dx * dx + dy * dy;
}
const double rms = std::sqrt(sum_sq / image_points.size());
std::cout << "\n========================================\n"
<< "EXTRINSIC CALIBRATION\n"
<< "========================================\n"
<< "R robot->camera:\n" << R << "\n"
<< "t robot->camera:\n" << tvec << "\n"
<< "RMS reprojection error = " << rms << " px\n"
<< "Inliers = " << inliers.size() << " / " << image_points.size()
<< std::endl;
const std::string output =
"/home/jvizner/r1_camera_calibration/camera_extrinsics.yaml";
cv::FileStorage out(output, cv::FILE_STORAGE_WRITE);
out << "camera_matrix" << K;
out << "distortion" << D;
out << "rotation_robot_to_camera" << R;
out << "translation_robot_to_camera" << tvec;
out << "rms_reprojection_error" << rms;
out.release();
std::cout << "[SYS] Saved: " << output << std::endl;
return 0;
}
+306
View File
@@ -0,0 +1,306 @@
#include <arpa/inet.h>
#include <cstring>
#include <fstream>
#include <ifaddrs.h>
#include <iostream>
#include <netdb.h>
#include <netinet/in.h>
#include <string>
#include <sys/socket.h>
#include <unistd.h>
#include <vector>
constexpr const char* GROUP_IP = "239.168.123.161";
constexpr int PORT = 5555;
constexpr int SAMPLE_RATE = 16000;
constexpr int SECONDS = 5;
constexpr int PACKET_BYTES = 5120;
constexpr int TOTAL_BYTES =
SAMPLE_RATE * 2 * SECONDS;
static std::string get_local_ip(
const std::string& interface)
{
ifaddrs* ifaddr = nullptr;
if (getifaddrs(&ifaddr) != 0)
return {};
std::string result;
for (ifaddrs* ifa = ifaddr;
ifa;
ifa = ifa->ifa_next) {
if (!ifa->ifa_addr)
continue;
if (interface != ifa->ifa_name)
continue;
if (ifa->ifa_addr->sa_family != AF_INET)
continue;
char host[NI_MAXHOST]{};
if (getnameinfo(
ifa->ifa_addr,
sizeof(sockaddr_in),
host,
sizeof(host),
nullptr,
0,
NI_NUMERICHOST) == 0) {
result = host;
break;
}
}
freeifaddrs(ifaddr);
return result;
}
static void write_wav(
const std::string& path,
const std::vector<int16_t>& pcm)
{
std::ofstream f(
path,
std::ios::binary
);
const uint32_t data_size =
pcm.size() * sizeof(int16_t);
const uint32_t riff_size =
36 + data_size;
const uint16_t format = 1;
const uint16_t channels = 1;
const uint16_t bits = 16;
const uint32_t sample_rate = 16000;
const uint16_t block_align =
channels * bits / 8;
const uint32_t byte_rate =
sample_rate * block_align;
f.write("RIFF", 4);
f.write(
reinterpret_cast<const char*>(&riff_size),
4
);
f.write("WAVE", 4);
f.write("fmt ", 4);
const uint32_t fmt_size = 16;
f.write(
reinterpret_cast<const char*>(&fmt_size),
4
);
f.write(
reinterpret_cast<const char*>(&format),
2
);
f.write(
reinterpret_cast<const char*>(&channels),
2
);
f.write(
reinterpret_cast<const char*>(&sample_rate),
4
);
f.write(
reinterpret_cast<const char*>(&byte_rate),
4
);
f.write(
reinterpret_cast<const char*>(&block_align),
2
);
f.write(
reinterpret_cast<const char*>(&bits),
2
);
f.write("data", 4);
f.write(
reinterpret_cast<const char*>(&data_size),
4
);
f.write(
reinterpret_cast<const char*>(pcm.data()),
data_size
);
}
int main(int argc, char** argv)
{
if (argc != 2) {
std::cerr
<< "Usage: "
<< argv[0]
<< " <interface>\n";
return 1;
}
const std::string interface = argv[1];
const std::string local_ip =
get_local_ip(interface);
std::cout
<< "Interface: "
<< interface
<< '\n'
<< "Local IP: "
<< local_ip
<< '\n';
int sock =
socket(AF_INET, SOCK_DGRAM, 0);
if (sock < 0) {
perror("socket");
return 1;
}
int reuse = 1;
setsockopt(
sock,
SOL_SOCKET,
SO_REUSEADDR,
&reuse,
sizeof(reuse)
);
sockaddr_in addr{};
addr.sin_family = AF_INET;
addr.sin_port = htons(PORT);
addr.sin_addr.s_addr = INADDR_ANY;
if (bind(
sock,
reinterpret_cast<sockaddr*>(&addr),
sizeof(addr)) != 0) {
perror("bind");
close(sock);
return 1;
}
ip_mreq mreq{};
inet_pton(
AF_INET,
GROUP_IP,
&mreq.imr_multiaddr
);
inet_pton(
AF_INET,
local_ip.c_str(),
&mreq.imr_interface
);
if (setsockopt(
sock,
IPPROTO_IP,
IP_ADD_MEMBERSHIP,
&mreq,
sizeof(mreq)) != 0) {
perror("IP_ADD_MEMBERSHIP");
close(sock);
return 1;
}
std::vector<int16_t> pcm;
pcm.reserve(SAMPLE_RATE * SECONDS);
int total_bytes = 0;
std::cout
<< "\nSpeak for 5 seconds...\n";
while (total_bytes < TOTAL_BYTES) {
char buffer[PACKET_BYTES];
const ssize_t len =
recvfrom(
sock,
buffer,
sizeof(buffer),
0,
nullptr,
nullptr
);
if (len <= 0)
continue;
const size_t samples =
len / sizeof(int16_t);
const int16_t* p =
reinterpret_cast<
const int16_t*
>(buffer);
pcm.insert(
pcm.end(),
p,
p + samples
);
total_bytes += len;
std::cout
<< "\r"
<< total_bytes
<< " / "
<< TOTAL_BYTES
<< " bytes"
<< std::flush;
}
std::cout << '\n';
write_wav(
"/tmp/r1_raw_test.wav",
pcm
);
std::cout
<< "Written /tmp/r1_raw_test.wav\n";
setsockopt(
sock,
IPPROTO_IP,
IP_DROP_MEMBERSHIP,
&mreq,
sizeof(mreq)
);
close(sock);
return 0;
}
+658
View File
@@ -0,0 +1,658 @@
#include "r1_voice/stt.hh"
#include <arpa/inet.h>
#include <cerrno>
#include <cmath>
#include <cstdio>
#include <cstring>
#include <fstream>
#include <ifaddrs.h>
#include <iostream>
#include <netdb.h>
#include <netinet/in.h>
#include <sstream>
#include <stdexcept>
#include <string>
#include <sys/socket.h>
#include <unistd.h>
#include <vector>
namespace r1_voice {
namespace {
constexpr const char* GROUP_IP =
"239.168.123.161";
constexpr uint16_t PORT = 5555;
constexpr int SAMPLE_RATE = 16000;
std::string findR1InterfaceAddress(
const std::string& interface_name)
{
struct ifaddrs* ifaddr = nullptr;
if (getifaddrs(&ifaddr) != 0)
return {};
std::string result;
for (auto* ifa = ifaddr;
ifa != nullptr;
ifa = ifa->ifa_next) {
if (!ifa->ifa_addr)
continue;
if (interface_name != ifa->ifa_name)
continue;
if (ifa->ifa_addr->sa_family != AF_INET)
continue;
char host[NI_MAXHOST] = {};
if (getnameinfo(
ifa->ifa_addr,
sizeof(sockaddr_in),
host,
sizeof(host),
nullptr,
0,
NI_NUMERICHOST) == 0) {
result = host;
break;
}
}
freeifaddrs(ifaddr);
return result;
}
float rms(
const int16_t* samples,
size_t count)
{
if (count == 0)
return 0.0f;
double sum = 0.0;
for (size_t i = 0; i < count; ++i) {
const float sample =
static_cast<float>(samples[i]) /
32768.0f;
sum +=
static_cast<double>(sample) *
static_cast<double>(sample);
}
return static_cast<float>(
std::sqrt(sum / count)
);
}
void writeWav(
const std::string& filename,
const std::vector<int16_t>& pcm)
{
std::ofstream file(
filename,
std::ios::binary
);
if (!file)
throw std::runtime_error(
"Cannot open WAV file: " + filename
);
const uint16_t audio_format = 1;
const uint16_t channels = 1;
const uint16_t bits_per_sample = 16;
const uint32_t sample_rate = SAMPLE_RATE;
const uint16_t block_align =
channels * bits_per_sample / 8;
const uint32_t byte_rate =
sample_rate * block_align;
const uint32_t data_size =
static_cast<uint32_t>(
pcm.size() * sizeof(int16_t)
);
const uint32_t riff_size =
36 + data_size;
file.write(
"RIFF",
4
);
file.write(
reinterpret_cast<const char*>(&riff_size),
sizeof(riff_size)
);
file.write(
"WAVE",
4
);
file.write(
"fmt ",
4
);
const uint32_t fmt_size = 16;
file.write(
reinterpret_cast<const char*>(&fmt_size),
sizeof(fmt_size)
);
file.write(
reinterpret_cast<const char*>(&audio_format),
sizeof(audio_format)
);
file.write(
reinterpret_cast<const char*>(&channels),
sizeof(channels)
);
file.write(
reinterpret_cast<const char*>(&sample_rate),
sizeof(sample_rate)
);
file.write(
reinterpret_cast<const char*>(&byte_rate),
sizeof(byte_rate)
);
file.write(
reinterpret_cast<const char*>(&block_align),
sizeof(block_align)
);
file.write(
reinterpret_cast<const char*>(&bits_per_sample),
sizeof(bits_per_sample)
);
file.write(
"data",
4
);
file.write(
reinterpret_cast<const char*>(&data_size),
sizeof(data_size)
);
if (!pcm.empty()) {
file.write(
reinterpret_cast<const char*>(pcm.data()),
static_cast<std::streamsize>(
data_size
)
);
}
}
} // namespace
SpeechToText::SpeechToText(
SttConfig config)
: config_(std::move(config))
{
}
std::string SpeechToText::listen()
{
constexpr const char* WAV_PATH =
"/tmp/r1_voice_record.wav";
if (!recordToWav(WAV_PATH))
return {};
return transcribe(WAV_PATH);
}
bool SpeechToText::recordToWav(
const std::string& path)
{
const int sock =
socket(
AF_INET,
SOCK_DGRAM,
0
);
if (sock < 0) {
std::cerr
<< "socket() failed: "
<< std::strerror(errno)
<< '\n';
return false;
}
int reuse = 1;
if (setsockopt(
sock,
SOL_SOCKET,
SO_REUSEADDR,
&reuse,
sizeof(reuse)) != 0) {
std::cerr
<< "SO_REUSEADDR failed: "
<< std::strerror(errno)
<< '\n';
close(sock);
return false;
}
sockaddr_in local_addr{};
local_addr.sin_family =
AF_INET;
local_addr.sin_port =
htons(PORT);
local_addr.sin_addr.s_addr =
htonl(INADDR_ANY);
if (bind(
sock,
reinterpret_cast<sockaddr*>(&local_addr),
sizeof(local_addr)) != 0) {
std::cerr
<< "bind() failed: "
<< std::strerror(errno)
<< '\n';
close(sock);
return false;
}
const char* env_interface =
std::getenv(
"R1_NETWORK_INTERFACE"
);
if (!env_interface) {
std::cerr
<< "R1_NETWORK_INTERFACE is not set\n"
<< "Example:\n"
<< " export R1_NETWORK_INTERFACE=enp3s0\n";
close(sock);
return false;
}
const std::string interface =
env_interface;
const std::string local_ip =
findR1InterfaceAddress(
interface
);
if (local_ip.empty()) {
std::cerr
<< "Could not find IPv4 address for "
<< interface
<< '\n';
close(sock);
return false;
}
ip_mreq mreq{};
if (inet_pton(
AF_INET,
GROUP_IP,
&mreq.imr_multiaddr) != 1) {
close(sock);
return false;
}
if (inet_pton(
AF_INET,
local_ip.c_str(),
&mreq.imr_interface) != 1) {
close(sock);
return false;
}
if (setsockopt(
sock,
IPPROTO_IP,
IP_ADD_MEMBERSHIP,
&mreq,
sizeof(mreq)) != 0) {
std::cerr
<< "IP_ADD_MEMBERSHIP failed: "
<< std::strerror(errno)
<< '\n';
close(sock);
return false;
}
/*
* R1 sends 5120 byte packets.
*/
constexpr size_t PACKET_SIZE = 5120;
std::vector<int16_t> audio;
const size_t reserve_samples =
static_cast<size_t>(
SAMPLE_RATE *
config_.max_record_ms /
1000
);
audio.reserve(reserve_samples);
std::vector<int16_t> preroll;
const size_t preroll_samples =
static_cast<size_t>(
SAMPLE_RATE *
config_.preroll_ms /
1000
);
preroll.reserve(preroll_samples);
char buffer[PACKET_SIZE];
bool speaking = false;
int speech_ms = 0;
int silence_ms = 0;
int total_ms = 0;
std::cout
<< "\nListening...\n";
while (total_ms <
config_.max_record_ms) {
const ssize_t len =
recvfrom(
sock,
buffer,
sizeof(buffer),
0,
nullptr,
nullptr
);
if (len <= 0) {
continue;
}
if ((len % 2) != 0)
continue;
const auto* samples =
reinterpret_cast<
const int16_t*
>(buffer);
const size_t sample_count =
static_cast<size_t>(len) / 2;
const int packet_ms =
static_cast<int>(
sample_count * 1000 /
SAMPLE_RATE
);
total_ms += packet_ms;
const float level =
rms(
samples,
sample_count
);
if (!speaking) {
preroll.insert(
preroll.end(),
samples,
samples + sample_count
);
if (preroll.size() >
preroll_samples) {
const size_t remove =
preroll.size() -
preroll_samples;
preroll.erase(
preroll.begin(),
preroll.begin() +
static_cast<
std::ptrdiff_t
>(remove)
);
}
if (level >=
config_.speech_threshold) {
speaking = true;
audio.insert(
audio.end(),
preroll.begin(),
preroll.end()
);
preroll.clear();
speech_ms += packet_ms;
std::cout
<< "Speech detected\n";
}
}
else {
audio.insert(
audio.end(),
samples,
samples + sample_count
);
speech_ms += packet_ms;
if (level <
config_.speech_threshold) {
silence_ms += packet_ms;
}
else {
silence_ms = 0;
}
if (
speech_ms >=
config_.min_speech_ms &&
silence_ms >=
config_.silence_ms
) {
break;
}
}
}
setsockopt(
sock,
IPPROTO_IP,
IP_DROP_MEMBERSHIP,
&mreq,
sizeof(mreq)
);
close(sock);
if (!speaking ||
audio.empty()) {
std::cout
<< "No speech detected.\n";
return false;
}
try {
writeWav(
path,
audio
);
}
catch (const std::exception& e) {
std::cerr
<< "WAV write failed: "
<< e.what()
<< '\n';
return false;
}
std::cout
<< "Recorded "
<< audio.size() /
static_cast<double>(
SAMPLE_RATE
)
<< " seconds\n";
return true;
}
std::string SpeechToText::transcribe(
const std::string& wav_path)
{
std::ostringstream command;
command
<< '"'
<< config_.whisper_cli
<< '"'
<< " -m "
<< '"'
<< config_.whisper_model
<< '"'
<< " -f "
<< '"'
<< wav_path
<< '"'
<< " -l "
<< config_.language
<< " -t "
<< config_.threads
<< " --no-timestamps"
<< " -nt"
<< " 2>/dev/null";
std::cout
<< "Transcribing...\n";
FILE* pipe =
popen(
command.str().c_str(),
"r"
);
if (!pipe) {
std::cerr
<< "Failed to start whisper-cli\n";
return {};
}
char buffer[4096];
std::string output;
while (fgets(
buffer,
sizeof(buffer),
pipe)) {
output += buffer;
}
const int status =
pclose(pipe);
if (status != 0) {
std::cerr
<< "whisper-cli failed: "
<< status
<< '\n';
return {};
}
const std::string text =
trim(output);
if (!text.empty()) {
std::cout
<< "You: "
<< text
<< '\n';
}
return text;
}
std::string SpeechToText::trim(
const std::string& text)
{
const auto first =
text.find_first_not_of(
" \t\r\n"
);
if (first == std::string::npos)
return {};
const auto last =
text.find_last_not_of(
" \t\r\n"
);
return text.substr(
first,
last - first + 1
);
}
} // namespace r1_voice
+672
View File
@@ -0,0 +1,672 @@
#include "r1_voice/tts.hh"
#include <algorithm>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <fstream>
#include <iostream>
#include <sstream>
#include <stdexcept>
#include <vector>
#include <chrono>
#include <thread>
#include <sys/wait.h>
#include <unitree/common/time/time_tool.hpp>
#include <unitree/robot/channel/channel_factory.hpp>
namespace r1_voice {
namespace {
bool readU16(
std::ifstream& f,
uint16_t& value)
{
f.read(
reinterpret_cast<char*>(&value),
sizeof(value)
);
return static_cast<bool>(f);
}
bool readU32(
std::ifstream& f,
uint32_t& value)
{
f.read(
reinterpret_cast<char*>(&value),
sizeof(value)
);
return static_cast<bool>(f);
}
bool runCommand(
const std::string& command)
{
const int status =
std::system(command.c_str());
if (status == -1)
return false;
return WIFEXITED(status) &&
WEXITSTATUS(status) == 0;
}
} // namespace
TextToSpeech::TextToSpeech(
TtsConfig config)
: config_(std::move(config))
{
}
bool TextToSpeech::init(
const std::string& network_interface)
{
if (network_interface.empty()) {
std::cerr
<< "TTS: network interface is empty\n";
return false;
}
try {
/*
* ChannelFactory MUST be initialized before
* constructing AudioClient.
*/
unitree::robot::ChannelFactory::
Instance()->Init(
0,
network_interface
);
client_ =
std::make_unique<
unitree::robot::r1::AudioClient
>();
client_->Init();
client_->SetTimeout(10.0f);
const int32_t ret =
client_->SetVolume(
static_cast<uint8_t>(
config_.volume
)
);
if (ret != 0) {
std::cerr
<< "TTS: SetVolume failed: "
<< ret
<< '\n';
}
initialized_ = true;
std::cout
<< "TTS initialized\n"
<< " Piper model: "
<< config_.model
<< '\n'
<< " R1 interface: "
<< network_interface
<< '\n';
return true;
}
catch (const std::exception& e) {
std::cerr
<< "TTS init failed: "
<< e.what()
<< '\n';
client_.reset();
initialized_ = false;
return false;
}
}
bool TextToSpeech::synthesize(
const std::string& text,
const std::string& wav_path)
{
const std::string text_path =
"/tmp/r1_tts_input.txt";
{
std::ofstream out(
text_path,
std::ios::binary
);
if (!out) {
std::cerr
<< "Cannot create "
<< text_path
<< '\n';
return false;
}
out << text;
}
std::ostringstream command;
command
<< "python -m piper"
<< " --model "
<< '\''
<< config_.model
<< '\''
<< " --output_file "
<< '\''
<< wav_path
<< '\''
<< " < "
<< '\''
<< text_path
<< '\''
<< " >/dev/null 2>&1";
std::cout
<< "Synthesizing Czech speech...\n";
if (!runCommand(command.str())) {
std::cerr
<< "Piper failed\n";
return false;
}
return true;
}
bool TextToSpeech::readWavPcm(
const std::string& wav_path,
std::vector<uint8_t>& pcm,
uint32_t& sample_rate,
uint16_t& channels,
uint16_t& bits_per_sample)
{
std::ifstream f(
wav_path,
std::ios::binary
);
if (!f) {
std::cerr
<< "Cannot open WAV: "
<< wav_path
<< '\n';
return false;
}
char riff[4];
char wave[4];
f.read(riff, 4);
if (!f ||
std::memcmp(riff, "RIFF", 4) != 0) {
std::cerr
<< "Invalid RIFF file\n";
return false;
}
uint32_t riff_size = 0;
if (!readU32(f, riff_size))
return false;
(void)riff_size;
f.read(wave, 4);
if (!f ||
std::memcmp(wave, "WAVE", 4) != 0) {
std::cerr
<< "Invalid WAVE file\n";
return false;
}
bool found_fmt = false;
bool found_data = false;
uint16_t audio_format = 0;
uint32_t data_size = 0;
std::streampos data_position{};
while (f && (!found_fmt || !found_data)) {
char chunk_id[4];
uint32_t chunk_size = 0;
f.read(
chunk_id,
sizeof(chunk_id)
);
if (!f)
break;
if (!readU32(f, chunk_size))
return false;
if (std::memcmp(
chunk_id,
"fmt ",
4) == 0) {
if (chunk_size < 16) {
std::cerr
<< "Invalid fmt chunk\n";
return false;
}
if (!readU16(
f,
audio_format))
return false;
if (!readU16(
f,
channels))
return false;
if (!readU32(
f,
sample_rate))
return false;
uint32_t byte_rate = 0;
uint16_t block_align = 0;
if (!readU32(
f,
byte_rate))
return false;
if (!readU16(
f,
block_align))
return false;
if (!readU16(
f,
bits_per_sample))
return false;
(void)byte_rate;
(void)block_align;
if (chunk_size > 16) {
f.seekg(
chunk_size - 16,
std::ios::cur
);
}
found_fmt = true;
}
else if (
std::memcmp(
chunk_id,
"data",
4) == 0) {
data_size = chunk_size;
data_position = f.tellg();
f.seekg(
chunk_size,
std::ios::cur
);
found_data = true;
}
else {
f.seekg(
chunk_size,
std::ios::cur
);
}
if (chunk_size & 1)
f.seekg(
1,
std::ios::cur
);
}
if (!found_fmt ||
!found_data) {
std::cerr
<< "WAV missing fmt/data chunk\n";
return false;
}
if (audio_format != 1) {
std::cerr
<< "WAV is not PCM\n";
return false;
}
if (channels != 1) {
std::cerr
<< "WAV must be mono, got "
<< channels
<< '\n';
return false;
}
if (bits_per_sample != 16) {
std::cerr
<< "WAV must be 16-bit PCM, got "
<< bits_per_sample
<< '\n';
return false;
}
f.seekg(data_position);
pcm.resize(data_size);
f.read(
reinterpret_cast<char*>(pcm.data()),
static_cast<std::streamsize>(
data_size
)
);
if (!f) {
std::cerr
<< "Failed reading PCM data\n";
return false;
}
return true;
}
bool TextToSpeech::playPcm(
const std::vector<uint8_t>& pcm)
{
if (!client_ || pcm.empty())
return false;
// 16 kHz, mono, signed 16-bit PCM:
// 16000 samples/s * 2 bytes = 32000 bytes/s
constexpr double BYTES_PER_SECOND = 32000.0;
// Same chunk size as Unitree's R1 audio example:
// 96000 bytes = 3 seconds.
constexpr size_t CHUNK_SIZE = 96000;
// Feed chunks faster than playback so R1 keeps
// an audio buffer and does not underrun.
constexpr int INTER_CHUNK_DELAY_MS = 1000;
// Extra time after predicted playback completion before
// enabling the microphone again.
constexpr int PLAYBACK_MARGIN_MS = 600;
const std::string stream_id =
std::to_string(
unitree::common::GetCurrentTimeMillisecond()
);
const auto playback_start =
std::chrono::steady_clock::now();
size_t offset = 0;
size_t chunk_index = 0;
while (offset < pcm.size()) {
const size_t remaining =
pcm.size() - offset;
const size_t chunk_size =
std::min(
CHUNK_SIZE,
remaining
);
std::vector<uint8_t> chunk(
pcm.begin() +
static_cast<std::ptrdiff_t>(offset),
pcm.begin() +
static_cast<std::ptrdiff_t>(
offset + chunk_size
)
);
std::cout
<< "Sending TTS chunk "
<< chunk_index
<< ": "
<< chunk_size
<< " bytes\n";
const int32_t ret =
client_->PlayStream(
config_.app_name,
stream_id,
chunk
);
std::cout
<< "PlayStream returned "
<< ret
<< '\n';
if (ret != 0) {
std::cerr
<< "PlayStream failed on chunk "
<< chunk_index
<< '\n';
return false;
}
offset += chunk_size;
++chunk_index;
// Keep the R1 buffer filled ahead of playback.
if (offset < pcm.size()) {
std::this_thread::sleep_for(
std::chrono::milliseconds(
INTER_CHUNK_DELAY_MS
)
);
}
}
std::cout
<< "All TTS chunks submitted\n";
/*
* Work out when the complete utterance should finish
* relative to when we submitted its first chunk.
*/
const auto total_audio_ms =
std::chrono::milliseconds(
static_cast<int64_t>(
(static_cast<double>(pcm.size()) /
BYTES_PER_SECOND) *
1000.0
)
);
const auto now =
std::chrono::steady_clock::now();
const auto elapsed =
std::chrono::duration_cast<
std::chrono::milliseconds
>(now - playback_start);
auto remaining_playback =
total_audio_ms - elapsed;
if (remaining_playback.count() < 0) {
remaining_playback =
std::chrono::milliseconds(0);
}
const auto final_wait =
remaining_playback +
std::chrono::milliseconds(
PLAYBACK_MARGIN_MS
);
std::cout
<< "Waiting "
<< final_wait.count()
<< " ms for R1 playback to finish\n";
std::this_thread::sleep_for(
final_wait
);
/*
* IMPORTANT:
* Don't PlayStop() here.
*
* We let the R1 finish the buffered stream naturally.
*/
std::cout
<< "TTS playback finished\n";
return true;
}
bool TextToSpeech::speak(
const std::string& text)
{
if (!initialized_) {
std::cerr
<< "TTS is not initialized\n";
return false;
}
if (text.empty())
return true;
std::cout
<< "R1: "
<< text
<< '\n';
const std::string piper_wav =
config_.output_wav;
const std::string output_wav_16k =
"/tmp/r1_tts_16k.wav";
if (!synthesize(
text,
piper_wav)) {
return false;
}
/*
* Piper's Czech model produces 22050 Hz.
*
* R1 PlayStream requires 16000 Hz mono PCM.
*
* Resample with ffmpeg.
*/
std::ostringstream resample;
resample
<< "ffmpeg -y -loglevel error"
<< " -i "
<< '\''
<< piper_wav
<< '\''
<< " -ar 16000"
<< " -ac 1"
<< " -c:a pcm_s16le"
<< " "
<< '\''
<< output_wav_16k
<< '\''
<< " >/dev/null 2>&1";
if (!runCommand(
resample.str())) {
std::cerr
<< "ffmpeg resampling failed\n";
return false;
}
std::vector<uint8_t> pcm;
uint32_t sample_rate = 0;
uint16_t channels = 0;
uint16_t bits = 0;
if (!readWavPcm(
output_wav_16k,
pcm,
sample_rate,
channels,
bits)) {
return false;
}
std::cout
<< "TTS WAV: "
<< sample_rate
<< " Hz, "
<< channels
<< " channel, "
<< bits
<< " bit, "
<< pcm.size()
<< " PCM bytes\n";
if (sample_rate != 16000 ||
channels != 1 ||
bits != 16) {
std::cerr
<< "Resampled WAV has unexpected format\n";
return false;
}
return playPcm(pcm);
}
} // namespace r1_voice
+54
View File
@@ -0,0 +1,54 @@
#include <cstdio>
#include <fstream>
#include <iostream>
#include <string>
#include <thread>
#include <vector>
#include <chrono>
#include <unitree/robot/go2/video/video_client.hpp>
#include <unitree/robot/channel/channel_factory.hpp>
int main(int argc, char** argv) {
if (argc < 2) {
std::cerr << "Usage: vision_node <network_interface>\n";
return 1;
}
const std::string frame_path = "/dev/shm/robot_frame.jpg";
const std::string temp_path = "/dev/shm/robot_frame.tmp.jpg";
unitree::robot::ChannelFactory::Instance()->Init(0, argv[1]);
unitree::robot::go2::VideoClient video;
video.SetTimeout(1.0f);
video.Init();
std::cout << "[SYS] Writing camera frames to " << frame_path << std::endl;
std::vector<uint8_t> image;
while (true) {
const int ret = video.GetImageSample(image);
if (ret != 0 || image.empty()) {
std::cerr << "[WARN] GetImageSample failed: " << ret << std::endl;
std::this_thread::sleep_for(std::chrono::milliseconds(33));
continue;
}
bool ok = false;
{
std::ofstream f(temp_path, std::ios::binary | std::ios::trunc);
if (f) {
f.write(reinterpret_cast<const char*>(image.data()),
static_cast<std::streamsize>(image.size()));
f.flush();
ok = f.good();
}
}
if (ok && std::rename(temp_path.c_str(), frame_path.c_str()) != 0) {
std::perror("[ERROR] rename");
}
std::this_thread::sleep_for(std::chrono::milliseconds(33));
}
}
+127
View File
@@ -0,0 +1,127 @@
#include "r1_voice/conversation.hh"
#include <cstdlib>
#include <iostream>
#include <string>
static void printUsage(
const char* program)
{
std::cerr
<< "Usage:\n\n"
<< " "
<< program
<< " <network_interface> "
<< "[whisper_cli] "
<< "[whisper_model] "
<< "[llm_url]\n\n"
<< "Example:\n\n"
<< " "
<< program
<< " enp3s0 "
<< "/home/jvizner/esarobotech/whisper.cpp/build/bin/whisper-cli "
<< "/home/jvizner/esarobotech/whisper.cpp/models/ggml-medium.bin "
<< "http://127.0.0.1:8080/v1/chat/completions\n";
}
int main(
int argc,
char** argv)
{
if (argc < 2) {
printUsage(argv[0]);
return EXIT_FAILURE;
}
const std::string interface =
argv[1];
r1_voice::ConversationConfig config;
// ---------------------------------------------------------
// Whisper STT
// ---------------------------------------------------------
config.stt.whisper_cli =
argc >= 3
? argv[2]
: "/home/jvizner/esarobotech/whisper.cpp/build/bin/whisper-cli";
config.stt.whisper_model =
argc >= 4
? argv[3]
: "/home/jvizner/esarobotech/whisper.cpp/models/ggml-medium.bin";
// Czech speech
config.stt.language = "cs";
config.stt.threads = 8;
// Better protection against clipping the first words.
config.stt.speech_threshold = 0.010f;
config.stt.preroll_ms = 800;
config.stt.min_speech_ms = 250;
config.stt.silence_ms = 900;
config.stt.max_record_ms = 10000;
// ---------------------------------------------------------
// Qwen LLM
// ---------------------------------------------------------
config.llm.url =
argc >= 5
? argv[4]
: "http://127.0.0.1:8080/v1/chat/completions";
config.llm.model = "local";
config.llm.temperature = 0.4f;
config.llm.max_tokens = 80;
config.llm.system_prompt =
"Jsi R1, humanoidní robot. "
"Mluv česky, pokud uživatel mluví česky. "
"Odpovídej stručně a přirozeně, obvykle jednou až dvěma větami. "
"Pokud je vstup zjevně nesrozumitelný nebo poškozený rozpoznáváním řeči, "
"nevymýšlej jeho význam a jednoduše požádej uživatele, aby větu zopakoval. "
"Neopakuj stále stejnou odpověď. "
"Nepoužívej Markdown. "
"Nevypisuj uvažování ani <think> bloky.";
// ---------------------------------------------------------
// Czech Piper TTS
// ---------------------------------------------------------
config.tts.model =
"/home/jvizner/esarobotech/piper/cs_medium.onnx";
config.tts.output_wav =
"/tmp/r1_tts.wav";
config.tts.volume = 100;
config.tts.app_name =
"example";
// ---------------------------------------------------------
// Conversation
// ---------------------------------------------------------
r1_voice::Conversation conversation(
config
);
if (!conversation.init(interface)) {
std::cerr
<< "Failed to initialize voice system\n";
return EXIT_FAILURE;
}
conversation.run();
return EXIT_SUCCESS;
}