web추가
This commit is contained in:
parent
be2e72b3aa
commit
b53910ca4f
|
|
@ -2,6 +2,7 @@
|
|||
# The first implementation receives one active sender and plays it to the default audio output.
|
||||
|
||||
listen_port = 4860
|
||||
web_port = 4861
|
||||
sample_rate = 48000
|
||||
channels = 1
|
||||
frame_ms = 10
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
#include <array>
|
||||
#include <atomic>
|
||||
#include <cerrno>
|
||||
#include <chrono>
|
||||
#include <csignal>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
|
|
@ -9,16 +10,20 @@
|
|||
#include <iostream>
|
||||
#include <limits>
|
||||
#include <map>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <optional>
|
||||
#include <sstream>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
#include <arpa/inet.h>
|
||||
#include <netinet/in.h>
|
||||
#include <sys/select.h>
|
||||
#include <sys/socket.h>
|
||||
#include <sys/time.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#define MINIAUDIO_IMPLEMENTATION
|
||||
|
|
@ -38,6 +43,8 @@ constexpr std::uint16_t kDefaultFrameMs = 10;
|
|||
constexpr std::uint16_t kDefaultChannels = 1;
|
||||
constexpr std::uint32_t kDefaultJitterBufferMs = 120;
|
||||
constexpr std::uint32_t kDefaultLogIntervalPackets = 100;
|
||||
constexpr std::uint16_t kDefaultWebPort = 4861;
|
||||
constexpr const char* kWebHost = "127.0.0.1";
|
||||
|
||||
std::atomic<bool> g_running{true};
|
||||
|
||||
|
|
@ -75,6 +82,7 @@ struct Config {
|
|||
std::uint16_t sender_id = 0;
|
||||
std::uint32_t jitter_buffer_ms = kDefaultJitterBufferMs;
|
||||
std::uint32_t log_interval_packets = kDefaultLogIntervalPackets;
|
||||
std::uint16_t web_port = kDefaultWebPort;
|
||||
std::string audio_output = "default";
|
||||
};
|
||||
|
||||
|
|
@ -149,6 +157,12 @@ bool load_config_file(const std::string& path, Config& config) {
|
|||
throw std::runtime_error("Invalid log_interval_packets: " + value);
|
||||
}
|
||||
config.log_interval_packets = *parsed;
|
||||
} else if (key == "web_port") {
|
||||
const auto parsed = parse_u32(value);
|
||||
if (!parsed || *parsed == 0 || *parsed > 65535) {
|
||||
throw std::runtime_error("Invalid web_port: " + value);
|
||||
}
|
||||
config.web_port = static_cast<std::uint16_t>(*parsed);
|
||||
} else if (key == "audio_output") {
|
||||
config.audio_output = value;
|
||||
} else {
|
||||
|
|
@ -383,8 +397,111 @@ struct SenderStats {
|
|||
std::uint64_t packets = 0;
|
||||
std::uint64_t dropped = 0;
|
||||
std::uint64_t out_of_order = 0;
|
||||
std::uint64_t last_packet_ms = 0;
|
||||
};
|
||||
|
||||
struct SenderControl {
|
||||
bool muted = false;
|
||||
double gain = 1.0;
|
||||
std::string name;
|
||||
};
|
||||
|
||||
struct SharedState {
|
||||
mutable std::mutex mutex;
|
||||
std::string config_path;
|
||||
Config config;
|
||||
std::string udp_state = "stopped";
|
||||
std::string udp_error;
|
||||
std::string audio_state = "stopped";
|
||||
std::string audio_error;
|
||||
std::uint16_t active_sender_id = 0;
|
||||
bool output_muted = false;
|
||||
std::map<std::uint16_t, SenderStats> senders;
|
||||
std::map<std::uint16_t, SenderControl> controls;
|
||||
std::uint64_t buffered_samples = 0;
|
||||
std::uint64_t overflows = 0;
|
||||
std::uint64_t underflows = 0;
|
||||
};
|
||||
|
||||
std::uint64_t now_ms() {
|
||||
const auto now = std::chrono::system_clock::now().time_since_epoch();
|
||||
return static_cast<std::uint64_t>(
|
||||
std::chrono::duration_cast<std::chrono::milliseconds>(now).count());
|
||||
}
|
||||
|
||||
std::string json_escape(const std::string& value) {
|
||||
std::ostringstream out;
|
||||
for (const char ch : value) {
|
||||
switch (ch) {
|
||||
case '\\':
|
||||
out << "\\\\";
|
||||
break;
|
||||
case '"':
|
||||
out << "\\\"";
|
||||
break;
|
||||
case '\n':
|
||||
out << "\\n";
|
||||
break;
|
||||
case '\r':
|
||||
out << "\\r";
|
||||
break;
|
||||
case '\t':
|
||||
out << "\\t";
|
||||
break;
|
||||
default:
|
||||
out << ch;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return out.str();
|
||||
}
|
||||
|
||||
std::string make_status_json(const SharedState& state) {
|
||||
std::lock_guard<std::mutex> lock(state.mutex);
|
||||
std::ostringstream out;
|
||||
out << "{";
|
||||
out << "\"config_path\":\"" << json_escape(state.config_path) << "\",";
|
||||
out << "\"listen_port\":" << state.config.listen_port << ",";
|
||||
out << "\"web_host\":\"" << kWebHost << "\",";
|
||||
out << "\"web_port\":" << state.config.web_port << ",";
|
||||
out << "\"sample_rate\":" << state.config.sample_rate << ",";
|
||||
out << "\"channels\":" << state.config.channels << ",";
|
||||
out << "\"frame_ms\":" << state.config.frame_ms << ",";
|
||||
out << "\"udp_state\":\"" << json_escape(state.udp_state) << "\",";
|
||||
out << "\"udp_error\":\"" << json_escape(state.udp_error) << "\",";
|
||||
out << "\"audio_state\":\"" << json_escape(state.audio_state) << "\",";
|
||||
out << "\"audio_error\":\"" << json_escape(state.audio_error) << "\",";
|
||||
out << "\"active_sender_id\":" << state.active_sender_id << ",";
|
||||
out << "\"output_muted\":" << (state.output_muted ? "true" : "false") << ",";
|
||||
out << "\"buffered_samples\":" << state.buffered_samples << ",";
|
||||
out << "\"overflows\":" << state.overflows << ",";
|
||||
out << "\"underflows\":" << state.underflows << ",";
|
||||
out << "\"now_ms\":" << now_ms() << ",";
|
||||
out << "\"senders\":[";
|
||||
bool first = true;
|
||||
for (const auto& [sender_id, stats] : state.senders) {
|
||||
const auto control_it = state.controls.find(sender_id);
|
||||
const SenderControl control = control_it == state.controls.end() ? SenderControl{} : control_it->second;
|
||||
if (!first) {
|
||||
out << ",";
|
||||
}
|
||||
first = false;
|
||||
out << "{";
|
||||
out << "\"sender_id\":" << sender_id << ",";
|
||||
out << "\"name\":\"" << json_escape(control.name) << "\",";
|
||||
out << "\"muted\":" << (control.muted ? "true" : "false") << ",";
|
||||
out << "\"gain\":" << control.gain << ",";
|
||||
out << "\"session_id\":" << stats.session_id << ",";
|
||||
out << "\"packets\":" << stats.packets << ",";
|
||||
out << "\"dropped\":" << stats.dropped << ",";
|
||||
out << "\"out_of_order\":" << stats.out_of_order << ",";
|
||||
out << "\"last_packet_ms\":" << stats.last_packet_ms;
|
||||
out << "}";
|
||||
}
|
||||
out << "]}";
|
||||
return out.str();
|
||||
}
|
||||
|
||||
bool validate_header(const PacketHeader& header, const Config& config, std::size_t received_size) {
|
||||
if (header.version != kVersion || header.header_len != kHeaderSize ||
|
||||
header.packet_type != kPacketTypeAudio) {
|
||||
|
|
@ -416,6 +533,7 @@ void update_stats(SenderStats& stats, const PacketHeader& header) {
|
|||
stats.session_id = header.session_id;
|
||||
stats.expected_sequence = header.sequence + 1;
|
||||
stats.packets = 1;
|
||||
stats.last_packet_ms = now_ms();
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -429,6 +547,7 @@ void update_stats(SenderStats& stats, const PacketHeader& header) {
|
|||
}
|
||||
|
||||
++stats.packets;
|
||||
stats.last_packet_ms = now_ms();
|
||||
}
|
||||
|
||||
std::vector<std::int16_t> decode_pcm_s16le(const std::uint8_t* payload, std::size_t payload_bytes) {
|
||||
|
|
@ -453,6 +572,14 @@ int make_udp_socket(std::uint16_t listen_port) {
|
|||
throw std::runtime_error("setsockopt(SO_REUSEADDR) failed: " + std::string(std::strerror(errno)));
|
||||
}
|
||||
|
||||
timeval timeout{};
|
||||
timeout.tv_sec = 1;
|
||||
timeout.tv_usec = 0;
|
||||
if (::setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout)) != 0) {
|
||||
::close(fd);
|
||||
throw std::runtime_error("setsockopt(SO_RCVTIMEO) failed: " + std::string(std::strerror(errno)));
|
||||
}
|
||||
|
||||
sockaddr_in address{};
|
||||
address.sin_family = AF_INET;
|
||||
address.sin_addr.s_addr = htonl(INADDR_ANY);
|
||||
|
|
@ -485,13 +612,424 @@ private:
|
|||
int fd_ = -1;
|
||||
};
|
||||
|
||||
void receive_loop(const Config& config, AudioRingBuffer& audio_buffer) {
|
||||
const char* index_html() {
|
||||
return R"HTML(<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>mic server</title>
|
||||
<style>
|
||||
:root { color-scheme: light dark; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
|
||||
body { margin: 0; background: #f6f7f8; color: #16181d; }
|
||||
header { padding: 16px 20px; border-bottom: 1px solid #d8dde3; background: #ffffff; }
|
||||
h1 { margin: 0; font-size: 20px; font-weight: 650; }
|
||||
main { padding: 18px 20px 28px; }
|
||||
.grid { display: grid; grid-template-columns: repeat(4, minmax(150px, 1fr)); gap: 10px; margin-bottom: 16px; }
|
||||
.panel { background: #ffffff; border: 1px solid #d8dde3; border-radius: 6px; padding: 12px; }
|
||||
.label { color: #5c6675; font-size: 12px; margin-bottom: 4px; }
|
||||
.value { font-size: 18px; font-weight: 650; word-break: break-word; }
|
||||
.ok { color: #127a42; }
|
||||
.bad { color: #b42318; }
|
||||
.warn { color: #9a5b00; }
|
||||
.toolbar { display: flex; gap: 8px; align-items: center; margin-bottom: 12px; flex-wrap: wrap; }
|
||||
button { border: 1px solid #aeb7c2; background: #ffffff; border-radius: 6px; padding: 8px 10px; font: inherit; cursor: pointer; }
|
||||
button:hover { background: #eef2f6; }
|
||||
table { width: 100%; border-collapse: collapse; background: #ffffff; border: 1px solid #d8dde3; }
|
||||
th, td { border-bottom: 1px solid #e3e7ec; padding: 8px 9px; text-align: left; font-size: 13px; white-space: nowrap; }
|
||||
th { background: #f0f3f6; color: #3f4854; font-weight: 650; }
|
||||
input[type="text"] { width: 120px; }
|
||||
input[type="number"] { width: 72px; }
|
||||
.empty { padding: 18px; background: #ffffff; border: 1px solid #d8dde3; border-top: 0; color: #5c6675; }
|
||||
@media (max-width: 760px) {
|
||||
.grid { grid-template-columns: repeat(2, minmax(120px, 1fr)); }
|
||||
main { padding: 12px; }
|
||||
.table-wrap { overflow-x: auto; }
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body { background: #15181d; color: #eef2f6; }
|
||||
header, .panel, table, .empty, button { background: #20242b; border-color: #38404a; color: #eef2f6; }
|
||||
th { background: #2a3038; color: #d9e0e8; }
|
||||
th, td { border-bottom-color: #333b45; }
|
||||
button:hover { background: #2d3440; }
|
||||
.label { color: #aeb7c2; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header><h1>mic server</h1></header>
|
||||
<main>
|
||||
<section class="grid">
|
||||
<div class="panel"><div class="label">UDP</div><div id="udp" class="value">-</div></div>
|
||||
<div class="panel"><div class="label">Audio</div><div id="audio" class="value">-</div></div>
|
||||
<div class="panel"><div class="label">Senders</div><div id="senders" class="value">0</div></div>
|
||||
<div class="panel"><div class="label">Format</div><div id="format" class="value">-</div></div>
|
||||
</section>
|
||||
<div class="toolbar">
|
||||
<button id="outputMute">Output mute</button>
|
||||
<button id="resetStats">Reset stats</button>
|
||||
<span id="errors"></span>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th><th>Name</th><th>Mute</th><th>Gain</th><th>Packets</th>
|
||||
<th>Dropped</th><th>Out of order</th><th>Last seen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="senderRows"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div id="empty" class="empty">No sender packets received yet.</div>
|
||||
</main>
|
||||
<script>
|
||||
let lastStatus = null;
|
||||
|
||||
function cls(state) {
|
||||
if (state === "listening" || state === "running") return "ok";
|
||||
if (state === "error") return "bad";
|
||||
return "warn";
|
||||
}
|
||||
|
||||
async function post(path, body) {
|
||||
await fetch(path, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body || {}) });
|
||||
await refresh();
|
||||
}
|
||||
|
||||
function ageText(now, last) {
|
||||
if (!last) return "-";
|
||||
const seconds = Math.max(0, Math.round((now - last) / 1000));
|
||||
return seconds + "s ago";
|
||||
}
|
||||
|
||||
function render(data) {
|
||||
lastStatus = data;
|
||||
const udp = document.getElementById("udp");
|
||||
const audio = document.getElementById("audio");
|
||||
udp.textContent = data.udp_state + " :" + data.listen_port;
|
||||
udp.className = "value " + cls(data.udp_state);
|
||||
audio.textContent = data.audio_state;
|
||||
audio.className = "value " + cls(data.audio_state);
|
||||
document.getElementById("senders").textContent = data.senders.length;
|
||||
document.getElementById("format").textContent = data.sample_rate + " Hz / " + data.channels + " ch / " + data.frame_ms + " ms";
|
||||
document.getElementById("outputMute").textContent = data.output_muted ? "Output muted" : "Output mute";
|
||||
const errors = [];
|
||||
if (data.udp_error) errors.push("UDP: " + data.udp_error);
|
||||
if (data.audio_error) errors.push("Audio: " + data.audio_error);
|
||||
document.getElementById("errors").textContent = errors.join(" | ");
|
||||
|
||||
const rows = document.getElementById("senderRows");
|
||||
rows.innerHTML = "";
|
||||
for (const sender of data.senders) {
|
||||
const tr = document.createElement("tr");
|
||||
tr.innerHTML =
|
||||
"<td>" + sender.sender_id + "</td>" +
|
||||
"<td><input type='text' value='" + escapeHtml(sender.name) + "' data-name='" + sender.sender_id + "'></td>" +
|
||||
"<td><button data-mute='" + sender.sender_id + "'>" + (sender.muted ? "Muted" : "Mute") + "</button></td>" +
|
||||
"<td><input type='number' min='0' max='4' step='0.1' value='" + sender.gain + "' data-gain='" + sender.sender_id + "'></td>" +
|
||||
"<td>" + sender.packets + "</td>" +
|
||||
"<td>" + sender.dropped + "</td>" +
|
||||
"<td>" + sender.out_of_order + "</td>" +
|
||||
"<td>" + ageText(data.now_ms, sender.last_packet_ms) + "</td>";
|
||||
rows.appendChild(tr);
|
||||
}
|
||||
document.getElementById("empty").style.display = data.senders.length ? "none" : "block";
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value || "").replace(/[&<>"']/g, ch => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[ch]));
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
const res = await fetch("/api/status");
|
||||
render(await res.json());
|
||||
}
|
||||
|
||||
document.addEventListener("click", async (event) => {
|
||||
if (event.target.id === "outputMute" && lastStatus) {
|
||||
await post("/api/output/mute", { muted: !lastStatus.output_muted });
|
||||
}
|
||||
if (event.target.id === "resetStats") {
|
||||
await post("/api/stats/reset", {});
|
||||
}
|
||||
if (event.target.dataset.mute && lastStatus) {
|
||||
const id = Number(event.target.dataset.mute);
|
||||
const sender = lastStatus.senders.find(item => item.sender_id === id);
|
||||
await post("/api/senders/" + id + "/mute", { muted: !(sender && sender.muted) });
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener("change", async (event) => {
|
||||
if (event.target.dataset.gain) {
|
||||
await post("/api/senders/" + event.target.dataset.gain + "/gain", { gain: Number(event.target.value) });
|
||||
}
|
||||
if (event.target.dataset.name) {
|
||||
await post("/api/senders/" + event.target.dataset.name + "/name", { name: event.target.value });
|
||||
}
|
||||
});
|
||||
|
||||
refresh();
|
||||
setInterval(refresh, 1000);
|
||||
</script>
|
||||
</body>
|
||||
</html>)HTML";
|
||||
}
|
||||
|
||||
std::string http_response(const std::string& content_type, const std::string& body, const std::string& status = "200 OK") {
|
||||
std::ostringstream out;
|
||||
out << "HTTP/1.1 " << status << "\r\n";
|
||||
out << "Content-Type: " << content_type << "\r\n";
|
||||
out << "Content-Length: " << body.size() << "\r\n";
|
||||
out << "Connection: close\r\n\r\n";
|
||||
out << body;
|
||||
return out.str();
|
||||
}
|
||||
|
||||
bool body_bool(const std::string& body, const std::string& key, bool fallback) {
|
||||
const auto key_pos = body.find("\"" + key + "\"");
|
||||
if (key_pos == std::string::npos) {
|
||||
return fallback;
|
||||
}
|
||||
const auto true_pos = body.find("true", key_pos);
|
||||
const auto false_pos = body.find("false", key_pos);
|
||||
if (true_pos != std::string::npos && (false_pos == std::string::npos || true_pos < false_pos)) {
|
||||
return true;
|
||||
}
|
||||
if (false_pos != std::string::npos) {
|
||||
return false;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
double body_double(const std::string& body, const std::string& key, double fallback) {
|
||||
const auto key_pos = body.find("\"" + key + "\"");
|
||||
if (key_pos == std::string::npos) {
|
||||
return fallback;
|
||||
}
|
||||
const auto colon = body.find(':', key_pos);
|
||||
if (colon == std::string::npos) {
|
||||
return fallback;
|
||||
}
|
||||
try {
|
||||
return std::stod(body.substr(colon + 1));
|
||||
} catch (...) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
std::string body_string(const std::string& body, const std::string& key) {
|
||||
const auto key_pos = body.find("\"" + key + "\"");
|
||||
if (key_pos == std::string::npos) {
|
||||
return {};
|
||||
}
|
||||
const auto colon = body.find(':', key_pos);
|
||||
const auto first = body.find('"', colon == std::string::npos ? key_pos : colon);
|
||||
if (first == std::string::npos) {
|
||||
return {};
|
||||
}
|
||||
const auto second = body.find('"', first + 1);
|
||||
if (second == std::string::npos) {
|
||||
return {};
|
||||
}
|
||||
return body.substr(first + 1, second - first - 1);
|
||||
}
|
||||
|
||||
std::optional<std::uint16_t> sender_id_from_path(const std::string& path, const std::string& suffix) {
|
||||
constexpr const char* prefix = "/api/senders/";
|
||||
if (path.rfind(prefix, 0) != 0 || path.size() <= std::strlen(prefix) + suffix.size()) {
|
||||
return std::nullopt;
|
||||
}
|
||||
if (path.substr(path.size() - suffix.size()) != suffix) {
|
||||
return std::nullopt;
|
||||
}
|
||||
const auto id_text = path.substr(std::strlen(prefix), path.size() - std::strlen(prefix) - suffix.size());
|
||||
const auto parsed = parse_u32(id_text);
|
||||
if (!parsed || *parsed == 0 || *parsed > 65535) {
|
||||
return std::nullopt;
|
||||
}
|
||||
return static_cast<std::uint16_t>(*parsed);
|
||||
}
|
||||
|
||||
void reset_stats(SharedState& state) {
|
||||
std::lock_guard<std::mutex> lock(state.mutex);
|
||||
for (auto& [sender_id, stats] : state.senders) {
|
||||
stats.packets = 0;
|
||||
stats.dropped = 0;
|
||||
stats.out_of_order = 0;
|
||||
}
|
||||
}
|
||||
|
||||
std::string handle_http_request(const std::string& request, SharedState& state) {
|
||||
std::istringstream stream(request);
|
||||
std::string method;
|
||||
std::string path;
|
||||
stream >> method >> path;
|
||||
const auto body_pos = request.find("\r\n\r\n");
|
||||
const std::string body = body_pos == std::string::npos ? std::string{} : request.substr(body_pos + 4);
|
||||
|
||||
if (method == "GET" && (path == "/" || path == "/index.html")) {
|
||||
return http_response("text/html; charset=utf-8", index_html());
|
||||
}
|
||||
if (method == "GET" && path == "/api/status") {
|
||||
return http_response("application/json", make_status_json(state));
|
||||
}
|
||||
if (method == "POST" && path == "/api/output/mute") {
|
||||
std::lock_guard<std::mutex> lock(state.mutex);
|
||||
state.output_muted = body_bool(body, "muted", state.output_muted);
|
||||
return http_response("application/json", "{\"ok\":true}");
|
||||
}
|
||||
if (method == "POST" && path == "/api/stats/reset") {
|
||||
reset_stats(state);
|
||||
return http_response("application/json", "{\"ok\":true}");
|
||||
}
|
||||
if (method == "POST") {
|
||||
if (const auto id = sender_id_from_path(path, "/mute")) {
|
||||
std::lock_guard<std::mutex> lock(state.mutex);
|
||||
state.controls[*id].muted = body_bool(body, "muted", state.controls[*id].muted);
|
||||
return http_response("application/json", "{\"ok\":true}");
|
||||
}
|
||||
if (const auto id = sender_id_from_path(path, "/gain")) {
|
||||
std::lock_guard<std::mutex> lock(state.mutex);
|
||||
const auto gain = std::max(0.0, std::min(4.0, body_double(body, "gain", state.controls[*id].gain)));
|
||||
state.controls[*id].gain = gain;
|
||||
return http_response("application/json", "{\"ok\":true}");
|
||||
}
|
||||
if (const auto id = sender_id_from_path(path, "/name")) {
|
||||
std::lock_guard<std::mutex> lock(state.mutex);
|
||||
state.controls[*id].name = body_string(body, "name");
|
||||
return http_response("application/json", "{\"ok\":true}");
|
||||
}
|
||||
}
|
||||
|
||||
return http_response("text/plain; charset=utf-8", "not found\n", "404 Not Found");
|
||||
}
|
||||
|
||||
int make_web_socket(std::uint16_t web_port) {
|
||||
const int fd = ::socket(AF_INET, SOCK_STREAM, 0);
|
||||
if (fd < 0) {
|
||||
throw std::runtime_error("web socket failed: " + std::string(std::strerror(errno)));
|
||||
}
|
||||
|
||||
int reuse = 1;
|
||||
if (::setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse)) != 0) {
|
||||
::close(fd);
|
||||
throw std::runtime_error("web setsockopt(SO_REUSEADDR) failed: " + std::string(std::strerror(errno)));
|
||||
}
|
||||
|
||||
sockaddr_in address{};
|
||||
address.sin_family = AF_INET;
|
||||
address.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
|
||||
address.sin_port = htons(web_port);
|
||||
|
||||
if (::bind(fd, reinterpret_cast<sockaddr*>(&address), sizeof(address)) != 0) {
|
||||
::close(fd);
|
||||
throw std::runtime_error("web bind failed on 127.0.0.1:" + std::to_string(web_port) + ": " +
|
||||
std::string(std::strerror(errno)));
|
||||
}
|
||||
if (::listen(fd, 16) != 0) {
|
||||
::close(fd);
|
||||
throw std::runtime_error("web listen failed: " + std::string(std::strerror(errno)));
|
||||
}
|
||||
|
||||
return fd;
|
||||
}
|
||||
|
||||
void web_loop(SharedState& state) {
|
||||
FileDescriptor listener(make_web_socket(state.config.web_port));
|
||||
std::cout << "Web UI listening on http://" << kWebHost << ":" << state.config.web_port << '\n';
|
||||
|
||||
while (g_running.load()) {
|
||||
fd_set read_set;
|
||||
FD_ZERO(&read_set);
|
||||
FD_SET(listener.get(), &read_set);
|
||||
timeval timeout{};
|
||||
timeout.tv_sec = 1;
|
||||
timeout.tv_usec = 0;
|
||||
const int ready = ::select(listener.get() + 1, &read_set, nullptr, nullptr, &timeout);
|
||||
if (ready <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
sockaddr_in peer{};
|
||||
socklen_t peer_len = sizeof(peer);
|
||||
const int client = ::accept(listener.get(), reinterpret_cast<sockaddr*>(&peer), &peer_len);
|
||||
if (client < 0) {
|
||||
if (errno == EINTR) {
|
||||
continue;
|
||||
}
|
||||
std::cerr << "web accept failed: " << std::strerror(errno) << '\n';
|
||||
continue;
|
||||
}
|
||||
|
||||
char buffer[8192];
|
||||
const auto received = ::recv(client, buffer, sizeof(buffer) - 1, 0);
|
||||
if (received > 0) {
|
||||
buffer[received] = '\0';
|
||||
const auto response = handle_http_request(std::string(buffer, static_cast<std::size_t>(received)), state);
|
||||
const char* data = response.data();
|
||||
std::size_t remaining = response.size();
|
||||
while (remaining > 0) {
|
||||
const auto sent = ::send(client, data, remaining, 0);
|
||||
if (sent <= 0) {
|
||||
break;
|
||||
}
|
||||
data += sent;
|
||||
remaining -= static_cast<std::size_t>(sent);
|
||||
}
|
||||
}
|
||||
::close(client);
|
||||
}
|
||||
}
|
||||
|
||||
void update_buffer_state(SharedState& state, const AudioRingBuffer& audio_buffer) {
|
||||
std::lock_guard<std::mutex> lock(state.mutex);
|
||||
state.buffered_samples = audio_buffer.filled();
|
||||
state.overflows = audio_buffer.overflow_count();
|
||||
state.underflows = audio_buffer.underflow_count();
|
||||
}
|
||||
|
||||
SenderControl sender_control_for(SharedState& state, std::uint16_t sender_id) {
|
||||
std::lock_guard<std::mutex> lock(state.mutex);
|
||||
return state.controls[sender_id];
|
||||
}
|
||||
|
||||
bool output_muted(const SharedState& state) {
|
||||
std::lock_guard<std::mutex> lock(state.mutex);
|
||||
return state.output_muted;
|
||||
}
|
||||
|
||||
void apply_control(std::vector<std::int16_t>& pcm, const SenderControl& control, bool output_is_muted) {
|
||||
if (output_is_muted || control.muted) {
|
||||
std::fill(pcm.begin(), pcm.end(), 0);
|
||||
return;
|
||||
}
|
||||
if (control.gain == 1.0) {
|
||||
return;
|
||||
}
|
||||
for (auto& sample : pcm) {
|
||||
const auto scaled = static_cast<int>(static_cast<double>(sample) * control.gain);
|
||||
sample = static_cast<std::int16_t>(std::max(-32768, std::min(32767, scaled)));
|
||||
}
|
||||
}
|
||||
|
||||
void receive_loop(const Config& config, AudioRingBuffer& audio_buffer, SharedState& state) {
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(state.mutex);
|
||||
state.udp_state = "starting";
|
||||
state.udp_error.clear();
|
||||
}
|
||||
|
||||
FileDescriptor socket(make_udp_socket(config.listen_port));
|
||||
std::vector<std::uint8_t> packet(65536);
|
||||
std::map<std::uint16_t, SenderStats> stats_by_sender;
|
||||
std::uint16_t active_sender_id = config.sender_id;
|
||||
|
||||
std::cout << "Listening on UDP port " << config.listen_port << '\n';
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(state.mutex);
|
||||
state.udp_state = "listening";
|
||||
state.active_sender_id = active_sender_id;
|
||||
}
|
||||
if (active_sender_id == 0) {
|
||||
std::cout << "Accepting the first valid sender_id as the active sender\n";
|
||||
} else {
|
||||
|
|
@ -507,6 +1045,10 @@ void receive_loop(const Config& config, AudioRingBuffer& audio_buffer) {
|
|||
if (errno == EINTR) {
|
||||
continue;
|
||||
}
|
||||
if (errno == EAGAIN || errno == EWOULDBLOCK) {
|
||||
update_buffer_state(state, audio_buffer);
|
||||
continue;
|
||||
}
|
||||
throw std::runtime_error("recvfrom failed: " + std::string(std::strerror(errno)));
|
||||
}
|
||||
|
||||
|
|
@ -519,31 +1061,45 @@ void receive_loop(const Config& config, AudioRingBuffer& audio_buffer) {
|
|||
if (active_sender_id == 0) {
|
||||
active_sender_id = header->sender_id;
|
||||
std::cout << "Active sender_id selected: " << active_sender_id << '\n';
|
||||
std::lock_guard<std::mutex> lock(state.mutex);
|
||||
state.active_sender_id = active_sender_id;
|
||||
}
|
||||
|
||||
if (header->sender_id != active_sender_id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
auto& stats = stats_by_sender[header->sender_id];
|
||||
update_stats(stats, *header);
|
||||
SenderStats stats_snapshot;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(state.mutex);
|
||||
auto& stats = state.senders[header->sender_id];
|
||||
update_stats(stats, *header);
|
||||
stats_snapshot = stats;
|
||||
}
|
||||
|
||||
const auto* payload = packet.data() + header->header_len;
|
||||
auto pcm = decode_pcm_s16le(payload, header->payload_bytes);
|
||||
apply_control(pcm, sender_control_for(state, header->sender_id), output_muted(state));
|
||||
audio_buffer.write(pcm.data(), pcm.size());
|
||||
update_buffer_state(state, audio_buffer);
|
||||
|
||||
if (stats.packets % config.log_interval_packets == 0) {
|
||||
if (stats_snapshot.packets % config.log_interval_packets == 0) {
|
||||
std::cout << "sender_id=" << header->sender_id
|
||||
<< " session_id=" << header->session_id
|
||||
<< " packets=" << stats.packets
|
||||
<< " dropped=" << stats.dropped
|
||||
<< " out_of_order=" << stats.out_of_order
|
||||
<< " packets=" << stats_snapshot.packets
|
||||
<< " dropped=" << stats_snapshot.dropped
|
||||
<< " out_of_order=" << stats_snapshot.out_of_order
|
||||
<< " buffered_samples=" << audio_buffer.filled()
|
||||
<< " overflows=" << audio_buffer.overflow_count()
|
||||
<< " underflows=" << audio_buffer.underflow_count()
|
||||
<< '\n';
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(state.mutex);
|
||||
state.udp_state = "stopped";
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
|
@ -562,18 +1118,67 @@ int main(int argc, char** argv) {
|
|||
std::cerr << "audio_output selection is not implemented yet; using default output\n";
|
||||
}
|
||||
|
||||
SharedState shared_state;
|
||||
shared_state.config = config;
|
||||
shared_state.config_path = config_path;
|
||||
|
||||
const auto buffer_frames = (static_cast<std::size_t>(config.sample_rate) *
|
||||
config.jitter_buffer_ms) /
|
||||
1000;
|
||||
AudioRingBuffer audio_buffer(buffer_frames * config.channels);
|
||||
AudioDevice audio_device(audio_buffer, config);
|
||||
std::unique_ptr<AudioDevice> audio_device;
|
||||
|
||||
std::cout << "Config: " << config_path << '\n';
|
||||
std::cout << "Audio: " << config.sample_rate << " Hz, "
|
||||
<< config.channels << " channel(s), frame_ms="
|
||||
<< config.frame_ms << ", jitter_buffer_ms="
|
||||
<< config.jitter_buffer_ms << '\n';
|
||||
receive_loop(config, audio_buffer);
|
||||
|
||||
std::thread web_thread([&shared_state]() {
|
||||
try {
|
||||
web_loop(shared_state);
|
||||
} catch (const std::exception& error) {
|
||||
std::cerr << "web error: " << error.what() << '\n';
|
||||
g_running.store(false);
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(shared_state.mutex);
|
||||
shared_state.audio_state = "starting";
|
||||
shared_state.audio_error.clear();
|
||||
}
|
||||
audio_device = std::make_unique<AudioDevice>(audio_buffer, config);
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(shared_state.mutex);
|
||||
shared_state.audio_state = "running";
|
||||
}
|
||||
} catch (const std::exception& error) {
|
||||
std::cerr << "audio error: " << error.what() << '\n';
|
||||
std::lock_guard<std::mutex> lock(shared_state.mutex);
|
||||
shared_state.audio_state = "error";
|
||||
shared_state.audio_error = error.what();
|
||||
}
|
||||
|
||||
try {
|
||||
receive_loop(config, audio_buffer, shared_state);
|
||||
} catch (const std::exception& error) {
|
||||
std::cerr << "udp error: " << error.what() << '\n';
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(shared_state.mutex);
|
||||
shared_state.udp_state = "error";
|
||||
shared_state.udp_error = error.what();
|
||||
}
|
||||
while (g_running.load()) {
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(200));
|
||||
}
|
||||
}
|
||||
|
||||
g_running.store(false);
|
||||
if (web_thread.joinable()) {
|
||||
web_thread.join();
|
||||
}
|
||||
} catch (const std::exception& error) {
|
||||
std::cerr << "mic_server error: " << error.what() << '\n';
|
||||
return 1;
|
||||
|
|
|
|||
Loading…
Reference in New Issue