server, client 기본 작업 완료
This commit is contained in:
parent
282d02cbc2
commit
a5207214f4
|
|
@ -1,6 +1,6 @@
|
|||
# mic
|
||||
|
||||
Orange Pi Zero 2W를 사용하는 Wi-Fi 라발리에 마이크 송신기와 Mac 수신/믹서 프로젝트입니다.
|
||||
Orange Pi Zero 2W를 사용하는 Wi-Fi 라발리에 마이크 송신기와 수신/믹서 서버 프로젝트입니다.
|
||||
|
||||
## 먼저 읽어야 할 문서
|
||||
|
||||
|
|
@ -11,13 +11,13 @@ Orange Pi Zero 2W를 사용하는 Wi-Fi 라발리에 마이크 송신기와 Mac
|
|||
특히 사용자가 계획을 확정하고 실행을 지시하기 전까지는 코드를 만들지 않습니다. 구현 언어는 C++입니다.
|
||||
|
||||
```text
|
||||
USB 라발리에 마이크 -> Orange Pi Zero 2W -> 2.4/5 GHz Wi-Fi 공유기 -> Mac 믹서
|
||||
USB 라발리에 마이크 -> Orange Pi Zero 2W -> 2.4/5 GHz Wi-Fi 공유기 -> 수신/믹서 서버
|
||||
```
|
||||
|
||||
## 프로젝트 구조
|
||||
|
||||
- `client/`: Orange Pi에서 실행할 마이크 송신기 소프트웨어
|
||||
- `server/`: Mac에서 실행할 수신/믹서 소프트웨어
|
||||
- `server/`: 수신/믹서 서버 소프트웨어
|
||||
- `os/`: Orange Pi에 설치할 OS 이미지 보관 위치
|
||||
- `docs/`: 보드 설정과 실행 절차 문서
|
||||
- `plans/`: AI 작업 플랜 기록 위치
|
||||
|
|
@ -26,10 +26,13 @@ USB 라발리에 마이크 -> Orange Pi Zero 2W -> 2.4/5 GHz Wi-Fi 공유기 ->
|
|||
|
||||
- 임베디드 보드: Orange Pi Zero 2W
|
||||
- OS 기준: Orange Pi 공식 Debian 12 Bookworm Server, Linux 6.1
|
||||
- 서버 기준: macOS, Ubuntu, Rocky Linux 9, Windows WSL2
|
||||
- 구현 언어: C++
|
||||
|
||||
## 문서
|
||||
|
||||
- `docs/00-ai-collaboration-rules.md`: AI 협업 기본 규칙
|
||||
- `docs/01-orange-pi-os.md`: Orange Pi Zero 2W OS 설치 준비
|
||||
- `docs/02-client-direction.md`: Orange Pi 클라이언트 초기 방향성
|
||||
- `docs/03-server-direction.md`: 서버 수신/믹서 초기 방향성
|
||||
- `plans/README.md`: 플랜 파일 작성 형식
|
||||
|
|
|
|||
|
|
@ -0,0 +1,18 @@
|
|||
cmake_minimum_required(VERSION 3.16)
|
||||
|
||||
project(mic_client LANGUAGES CXX)
|
||||
|
||||
add_executable(mic-client
|
||||
src/main.cpp
|
||||
)
|
||||
|
||||
target_compile_features(mic-client PRIVATE cxx_std_17)
|
||||
|
||||
if(CMAKE_CXX_COMPILER_ID MATCHES "Clang|GNU")
|
||||
target_compile_options(mic-client PRIVATE -Wall -Wextra -Wpedantic)
|
||||
endif()
|
||||
|
||||
find_package(ALSA REQUIRED)
|
||||
|
||||
target_include_directories(mic-client PRIVATE ${ALSA_INCLUDE_DIRS})
|
||||
target_link_libraries(mic-client PRIVATE ${ALSA_LIBRARIES})
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
# 클라이언트
|
||||
|
||||
Orange Pi Zero 2W에서 실행할 USB 마이크 송신기입니다.
|
||||
|
||||
초기 목표는 USB 마이크 입력을 ALSA로 캡처하고, Mac 수신기로 UDP 패킷을 보내 실제 소리가 나오는지 확인하는 것입니다.
|
||||
|
||||
## 현재 범위
|
||||
|
||||
- C++17
|
||||
- ALSA 기반 캡처
|
||||
- UDP 송신
|
||||
- 단일 송신기
|
||||
- 무압축 signed 16-bit little endian PCM
|
||||
- 설정 파일 기반 Mac 수신기 주소 지정
|
||||
|
||||
자동 검색, 압축, 다중 송신기, 동기화, 지연 시간 최적화는 이후 단계에서 다룹니다.
|
||||
|
||||
## 필요 패키지
|
||||
|
||||
Orange Pi 공식 Debian 12 Bookworm Server 기준으로 다음 패키지가 필요합니다.
|
||||
|
||||
```bash
|
||||
sudo apt update
|
||||
sudo apt install build-essential cmake libasound2-dev
|
||||
```
|
||||
|
||||
## 빌드
|
||||
|
||||
```bash
|
||||
cd client
|
||||
cmake -S . -B build
|
||||
cmake --build build
|
||||
```
|
||||
|
||||
## 설정
|
||||
|
||||
예시 설정 파일을 복사해서 사용합니다.
|
||||
|
||||
```bash
|
||||
cp client.conf.example client.conf
|
||||
```
|
||||
|
||||
주요 값:
|
||||
|
||||
```text
|
||||
server_host = 192.168.0.10
|
||||
server_port = 4860
|
||||
alsa_device = default
|
||||
sample_rate = 48000
|
||||
channels = 1
|
||||
frame_ms = 20
|
||||
```
|
||||
|
||||
`server_host`는 Mac 수신기의 IP 주소로 바꿉니다.
|
||||
|
||||
초기 버전은 단일 포트 `4860`을 사용합니다. 이후 여러 포트가 필요해지면 `4800-5000` 범위 안에서 배정하는 방향으로 둡니다.
|
||||
|
||||
## 실행
|
||||
|
||||
```bash
|
||||
./build/mic-client --config client.conf
|
||||
```
|
||||
|
||||
종료는 `Ctrl+C`로 합니다.
|
||||
|
||||
## 마이크 확인
|
||||
|
||||
Orange Pi에서 USB 마이크 인식 여부를 먼저 확인합니다.
|
||||
|
||||
```bash
|
||||
arecord -l
|
||||
arecord -L
|
||||
```
|
||||
|
||||
마이크가 `default`로 바로 잡히지 않으면 `client.conf`의 `alsa_device` 값을 `plughw:CARD,DEV` 또는 `hw:CARD,DEV` 형태로 지정합니다.
|
||||
|
||||
## 패킷 형식
|
||||
|
||||
현재 클라이언트는 각 UDP 패킷 앞에 40바이트 헤더를 붙이고, 그 뒤에 PCM payload를 붙입니다.
|
||||
|
||||
헤더 숫자 값은 network byte order로 전송합니다. PCM payload는 signed 16-bit little endian입니다.
|
||||
|
||||
```text
|
||||
magic 4 bytes "MIC1"
|
||||
version u16
|
||||
header_size u16
|
||||
sequence u64
|
||||
timestamp_frames u64
|
||||
sample_rate u32
|
||||
channels u16
|
||||
bits_per_sample u16
|
||||
frame_count u32
|
||||
payload_bytes u32
|
||||
```
|
||||
|
||||
서버 구현은 이 형식에 맞춰 UDP 패킷을 수신하고 PCM payload를 재생하거나 저장하면 됩니다.
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
# Mac receiver address.
|
||||
server_host = 192.168.0.10
|
||||
server_port = 4860
|
||||
|
||||
# ALSA capture device.
|
||||
# Use `arecord -l` and `arecord -L` on Orange Pi to inspect available devices.
|
||||
alsa_device = default
|
||||
|
||||
# Initial capture format.
|
||||
sample_rate = 48000
|
||||
channels = 1
|
||||
frame_ms = 20
|
||||
|
|
@ -0,0 +1,481 @@
|
|||
#include <alsa/asoundlib.h>
|
||||
|
||||
#include <arpa/inet.h>
|
||||
#include <netdb.h>
|
||||
#include <sys/socket.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <cctype>
|
||||
#include <csignal>
|
||||
#include <cstdint>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <fstream>
|
||||
#include <iostream>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr std::uint16_t kProtocolVersion = 1;
|
||||
constexpr std::size_t kHeaderSize = 40;
|
||||
constexpr std::uint16_t kBitsPerSample = 16;
|
||||
constexpr std::size_t kBytesPerSample = kBitsPerSample / 8;
|
||||
|
||||
volatile std::sig_atomic_t g_stop_requested = 0;
|
||||
|
||||
struct Config {
|
||||
std::string server_host = "127.0.0.1";
|
||||
std::uint16_t server_port = 4860;
|
||||
std::string alsa_device = "default";
|
||||
unsigned int sample_rate = 48000;
|
||||
unsigned int channels = 1;
|
||||
unsigned int frame_ms = 20;
|
||||
};
|
||||
|
||||
void handle_signal(int) {
|
||||
g_stop_requested = 1;
|
||||
}
|
||||
|
||||
std::string trim(std::string_view value) {
|
||||
std::size_t begin = 0;
|
||||
while (begin < value.size() && std::isspace(static_cast<unsigned char>(value[begin])) != 0) {
|
||||
++begin;
|
||||
}
|
||||
|
||||
std::size_t end = value.size();
|
||||
while (end > begin && std::isspace(static_cast<unsigned char>(value[end - 1])) != 0) {
|
||||
--end;
|
||||
}
|
||||
|
||||
return std::string(value.substr(begin, end - begin));
|
||||
}
|
||||
|
||||
unsigned int parse_unsigned(const std::string& key, const std::string& value) {
|
||||
try {
|
||||
std::size_t parsed = 0;
|
||||
const unsigned long result = std::stoul(value, &parsed, 10);
|
||||
if (parsed != value.size()) {
|
||||
throw std::invalid_argument("trailing characters");
|
||||
}
|
||||
if (result > static_cast<unsigned long>(UINT32_MAX)) {
|
||||
throw std::out_of_range("value is too large");
|
||||
}
|
||||
return static_cast<unsigned int>(result);
|
||||
} catch (const std::exception& error) {
|
||||
throw std::runtime_error("invalid numeric value for `" + key + "`: `" + value + "`");
|
||||
}
|
||||
}
|
||||
|
||||
void apply_config_value(Config& config, const std::string& key, const std::string& value) {
|
||||
if (key == "server_host") {
|
||||
config.server_host = value;
|
||||
} else if (key == "server_port") {
|
||||
const unsigned int port = parse_unsigned(key, value);
|
||||
if (port == 0 || port > 65535) {
|
||||
throw std::runtime_error("server_port must be between 1 and 65535");
|
||||
}
|
||||
config.server_port = static_cast<std::uint16_t>(port);
|
||||
} else if (key == "alsa_device") {
|
||||
config.alsa_device = value;
|
||||
} else if (key == "sample_rate") {
|
||||
config.sample_rate = parse_unsigned(key, value);
|
||||
} else if (key == "channels") {
|
||||
config.channels = parse_unsigned(key, value);
|
||||
} else if (key == "frame_ms") {
|
||||
config.frame_ms = parse_unsigned(key, value);
|
||||
} else {
|
||||
throw std::runtime_error("unknown config key: `" + key + "`");
|
||||
}
|
||||
}
|
||||
|
||||
bool load_config_file(const std::string& path, Config& config) {
|
||||
std::ifstream input(path);
|
||||
if (!input) {
|
||||
return false;
|
||||
}
|
||||
|
||||
std::string line;
|
||||
unsigned int line_number = 0;
|
||||
while (std::getline(input, line)) {
|
||||
++line_number;
|
||||
|
||||
const std::size_t comment = line.find('#');
|
||||
if (comment != std::string::npos) {
|
||||
line.erase(comment);
|
||||
}
|
||||
|
||||
line = trim(line);
|
||||
if (line.empty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const std::size_t delimiter = line.find('=');
|
||||
if (delimiter == std::string::npos) {
|
||||
throw std::runtime_error(path + ":" + std::to_string(line_number) + ": expected `key = value`");
|
||||
}
|
||||
|
||||
const std::string key = trim(std::string_view(line).substr(0, delimiter));
|
||||
const std::string value = trim(std::string_view(line).substr(delimiter + 1));
|
||||
if (key.empty() || value.empty()) {
|
||||
throw std::runtime_error(path + ":" + std::to_string(line_number) + ": empty key or value");
|
||||
}
|
||||
|
||||
apply_config_value(config, key, value);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
void validate_config(const Config& config) {
|
||||
if (config.server_host.empty()) {
|
||||
throw std::runtime_error("server_host must not be empty");
|
||||
}
|
||||
if (config.alsa_device.empty()) {
|
||||
throw std::runtime_error("alsa_device must not be empty");
|
||||
}
|
||||
if (config.sample_rate < 8000 || config.sample_rate > 192000) {
|
||||
throw std::runtime_error("sample_rate must be between 8000 and 192000");
|
||||
}
|
||||
if (config.channels == 0 || config.channels > 8) {
|
||||
throw std::runtime_error("channels must be between 1 and 8");
|
||||
}
|
||||
if (config.frame_ms == 0 || config.frame_ms > 200) {
|
||||
throw std::runtime_error("frame_ms must be between 1 and 200");
|
||||
}
|
||||
}
|
||||
|
||||
void print_usage(const char* executable) {
|
||||
std::cout
|
||||
<< "Usage: " << executable << " [options]\n"
|
||||
<< "\n"
|
||||
<< "Options:\n"
|
||||
<< " --config PATH Load config file. Default: client.conf\n"
|
||||
<< " --server-host HOST Override receiver host\n"
|
||||
<< " --server-port PORT Override receiver UDP port\n"
|
||||
<< " --alsa-device NAME Override ALSA capture device\n"
|
||||
<< " --sample-rate RATE Override sample rate\n"
|
||||
<< " --channels COUNT Override channel count\n"
|
||||
<< " --frame-ms MS Override frame duration\n"
|
||||
<< " --help Show this help\n";
|
||||
}
|
||||
|
||||
Config parse_args(int argc, char* argv[]) {
|
||||
Config config;
|
||||
std::string config_path = "client.conf";
|
||||
bool explicit_config = false;
|
||||
|
||||
for (int index = 1; index < argc; ++index) {
|
||||
const std::string arg = argv[index];
|
||||
if (arg == "--help") {
|
||||
print_usage(argv[0]);
|
||||
std::exit(0);
|
||||
}
|
||||
if (arg == "--config") {
|
||||
if (index + 1 >= argc) {
|
||||
throw std::runtime_error("--config requires a path");
|
||||
}
|
||||
config_path = argv[++index];
|
||||
explicit_config = true;
|
||||
}
|
||||
}
|
||||
|
||||
const bool loaded = load_config_file(config_path, config);
|
||||
if (!loaded && explicit_config) {
|
||||
throw std::runtime_error("failed to open config file: " + config_path);
|
||||
}
|
||||
if (!loaded) {
|
||||
std::cerr << "warning: config file `" << config_path << "` not found, using defaults\n";
|
||||
}
|
||||
|
||||
for (int index = 1; index < argc; ++index) {
|
||||
const std::string arg = argv[index];
|
||||
auto require_value = [&](const std::string& option) -> std::string {
|
||||
if (index + 1 >= argc) {
|
||||
throw std::runtime_error(option + " requires a value");
|
||||
}
|
||||
return argv[++index];
|
||||
};
|
||||
|
||||
if (arg == "--config") {
|
||||
++index;
|
||||
} else if (arg == "--server-host") {
|
||||
config.server_host = require_value(arg);
|
||||
} else if (arg == "--server-port") {
|
||||
const unsigned int port = parse_unsigned("server_port", require_value(arg));
|
||||
if (port == 0 || port > 65535) {
|
||||
throw std::runtime_error("server_port must be between 1 and 65535");
|
||||
}
|
||||
config.server_port = static_cast<std::uint16_t>(port);
|
||||
} else if (arg == "--alsa-device") {
|
||||
config.alsa_device = require_value(arg);
|
||||
} else if (arg == "--sample-rate") {
|
||||
config.sample_rate = parse_unsigned("sample_rate", require_value(arg));
|
||||
} else if (arg == "--channels") {
|
||||
config.channels = parse_unsigned("channels", require_value(arg));
|
||||
} else if (arg == "--frame-ms") {
|
||||
config.frame_ms = parse_unsigned("frame_ms", require_value(arg));
|
||||
} else if (arg == "--help") {
|
||||
print_usage(argv[0]);
|
||||
std::exit(0);
|
||||
} else {
|
||||
throw std::runtime_error("unknown option: " + arg);
|
||||
}
|
||||
}
|
||||
|
||||
validate_config(config);
|
||||
return config;
|
||||
}
|
||||
|
||||
void write_be16(std::vector<std::uint8_t>& data, std::size_t offset, std::uint16_t value) {
|
||||
data[offset] = static_cast<std::uint8_t>((value >> 8U) & 0xffU);
|
||||
data[offset + 1] = static_cast<std::uint8_t>(value & 0xffU);
|
||||
}
|
||||
|
||||
void write_be32(std::vector<std::uint8_t>& data, std::size_t offset, std::uint32_t value) {
|
||||
data[offset] = static_cast<std::uint8_t>((value >> 24U) & 0xffU);
|
||||
data[offset + 1] = static_cast<std::uint8_t>((value >> 16U) & 0xffU);
|
||||
data[offset + 2] = static_cast<std::uint8_t>((value >> 8U) & 0xffU);
|
||||
data[offset + 3] = static_cast<std::uint8_t>(value & 0xffU);
|
||||
}
|
||||
|
||||
void write_be64(std::vector<std::uint8_t>& data, std::size_t offset, std::uint64_t value) {
|
||||
for (int byte = 7; byte >= 0; --byte) {
|
||||
data[offset + static_cast<std::size_t>(7 - byte)] =
|
||||
static_cast<std::uint8_t>((value >> static_cast<unsigned int>(byte * 8)) & 0xffU);
|
||||
}
|
||||
}
|
||||
|
||||
std::vector<std::uint8_t> make_packet(
|
||||
std::uint64_t sequence,
|
||||
std::uint64_t timestamp_frames,
|
||||
const Config& config,
|
||||
std::uint32_t frame_count,
|
||||
const std::vector<std::uint8_t>& audio_buffer
|
||||
) {
|
||||
const std::size_t payload_bytes =
|
||||
static_cast<std::size_t>(frame_count) * config.channels * kBytesPerSample;
|
||||
if (payload_bytes > audio_buffer.size()) {
|
||||
throw std::runtime_error("audio payload is larger than the capture buffer");
|
||||
}
|
||||
|
||||
std::vector<std::uint8_t> packet(kHeaderSize + payload_bytes);
|
||||
packet[0] = 'M';
|
||||
packet[1] = 'I';
|
||||
packet[2] = 'C';
|
||||
packet[3] = '1';
|
||||
write_be16(packet, 4, kProtocolVersion);
|
||||
write_be16(packet, 6, static_cast<std::uint16_t>(kHeaderSize));
|
||||
write_be64(packet, 8, sequence);
|
||||
write_be64(packet, 16, timestamp_frames);
|
||||
write_be32(packet, 24, config.sample_rate);
|
||||
write_be16(packet, 28, static_cast<std::uint16_t>(config.channels));
|
||||
write_be16(packet, 30, kBitsPerSample);
|
||||
write_be32(packet, 32, frame_count);
|
||||
write_be32(packet, 36, static_cast<std::uint32_t>(payload_bytes));
|
||||
std::memcpy(packet.data() + kHeaderSize, audio_buffer.data(), payload_bytes);
|
||||
return packet;
|
||||
}
|
||||
|
||||
class UdpSender {
|
||||
public:
|
||||
UdpSender(const std::string& host, std::uint16_t port) {
|
||||
addrinfo hints{};
|
||||
hints.ai_family = AF_UNSPEC;
|
||||
hints.ai_socktype = SOCK_DGRAM;
|
||||
|
||||
addrinfo* results = nullptr;
|
||||
const std::string port_text = std::to_string(port);
|
||||
const int lookup_result = ::getaddrinfo(host.c_str(), port_text.c_str(), &hints, &results);
|
||||
if (lookup_result != 0) {
|
||||
throw std::runtime_error("getaddrinfo failed for `" + host + "`: " + gai_strerror(lookup_result));
|
||||
}
|
||||
|
||||
for (addrinfo* candidate = results; candidate != nullptr; candidate = candidate->ai_next) {
|
||||
const int socket_fd = ::socket(candidate->ai_family, candidate->ai_socktype, candidate->ai_protocol);
|
||||
if (socket_fd < 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (candidate->ai_addrlen <= sizeof(address_)) {
|
||||
fd_ = socket_fd;
|
||||
std::memcpy(&address_, candidate->ai_addr, candidate->ai_addrlen);
|
||||
address_length_ = candidate->ai_addrlen;
|
||||
break;
|
||||
}
|
||||
|
||||
::close(socket_fd);
|
||||
}
|
||||
|
||||
::freeaddrinfo(results);
|
||||
|
||||
if (fd_ < 0) {
|
||||
throw std::runtime_error("failed to create UDP socket for " + host + ":" + port_text);
|
||||
}
|
||||
}
|
||||
|
||||
UdpSender(const UdpSender&) = delete;
|
||||
UdpSender& operator=(const UdpSender&) = delete;
|
||||
|
||||
~UdpSender() {
|
||||
if (fd_ >= 0) {
|
||||
::close(fd_);
|
||||
}
|
||||
}
|
||||
|
||||
void send_packet(const std::vector<std::uint8_t>& packet) {
|
||||
while (true) {
|
||||
const ssize_t sent = ::sendto(
|
||||
fd_,
|
||||
packet.data(),
|
||||
packet.size(),
|
||||
0,
|
||||
reinterpret_cast<const sockaddr*>(&address_),
|
||||
address_length_
|
||||
);
|
||||
if (sent < 0) {
|
||||
if (errno == EINTR) {
|
||||
continue;
|
||||
}
|
||||
throw std::runtime_error("send failed: " + std::string(std::strerror(errno)));
|
||||
}
|
||||
|
||||
if (static_cast<std::size_t>(sent) != packet.size()) {
|
||||
throw std::runtime_error("send failed: partial UDP datagram");
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private:
|
||||
int fd_ = -1;
|
||||
sockaddr_storage address_{};
|
||||
socklen_t address_length_ = 0;
|
||||
};
|
||||
|
||||
class AlsaCapture {
|
||||
public:
|
||||
explicit AlsaCapture(const Config& config)
|
||||
: config_(config),
|
||||
frames_per_packet_((static_cast<std::uint64_t>(config.sample_rate) * config.frame_ms) / 1000) {
|
||||
if (frames_per_packet_ == 0) {
|
||||
throw std::runtime_error("frame_ms is too small for the selected sample_rate");
|
||||
}
|
||||
|
||||
const int open_result = ::snd_pcm_open(&handle_, config.alsa_device.c_str(), SND_PCM_STREAM_CAPTURE, 0);
|
||||
if (open_result < 0) {
|
||||
throw std::runtime_error("snd_pcm_open failed for `" + config.alsa_device + "`: " +
|
||||
snd_strerror(open_result));
|
||||
}
|
||||
|
||||
const unsigned int latency_us = config.frame_ms * 1000U * 4U;
|
||||
const int params_result = ::snd_pcm_set_params(
|
||||
handle_,
|
||||
SND_PCM_FORMAT_S16_LE,
|
||||
SND_PCM_ACCESS_RW_INTERLEAVED,
|
||||
config.channels,
|
||||
config.sample_rate,
|
||||
1,
|
||||
latency_us
|
||||
);
|
||||
if (params_result < 0) {
|
||||
throw std::runtime_error("snd_pcm_set_params failed: " + std::string(snd_strerror(params_result)));
|
||||
}
|
||||
}
|
||||
|
||||
AlsaCapture(const AlsaCapture&) = delete;
|
||||
AlsaCapture& operator=(const AlsaCapture&) = delete;
|
||||
|
||||
~AlsaCapture() {
|
||||
if (handle_ != nullptr) {
|
||||
::snd_pcm_close(handle_);
|
||||
}
|
||||
}
|
||||
|
||||
std::size_t frames_per_packet() const {
|
||||
return frames_per_packet_;
|
||||
}
|
||||
|
||||
std::vector<std::uint8_t> make_buffer() const {
|
||||
return std::vector<std::uint8_t>(frames_per_packet_ * config_.channels * kBytesPerSample);
|
||||
}
|
||||
|
||||
std::size_t read_frames(std::vector<std::uint8_t>& buffer) {
|
||||
while (g_stop_requested == 0) {
|
||||
const snd_pcm_sframes_t result =
|
||||
::snd_pcm_readi(handle_, buffer.data(), static_cast<snd_pcm_uframes_t>(frames_per_packet_));
|
||||
|
||||
if (result > 0) {
|
||||
return static_cast<std::size_t>(result);
|
||||
}
|
||||
|
||||
if (result == 0 || result == -EAGAIN) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const int recover_result = ::snd_pcm_recover(handle_, static_cast<int>(result), 1);
|
||||
if (recover_result < 0) {
|
||||
throw std::runtime_error("snd_pcm_readi failed: " + std::string(snd_strerror(recover_result)));
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private:
|
||||
Config config_;
|
||||
snd_pcm_t* handle_ = nullptr;
|
||||
std::size_t frames_per_packet_ = 0;
|
||||
};
|
||||
|
||||
void print_config(const Config& config) {
|
||||
std::cout
|
||||
<< "mic-client\n"
|
||||
<< " server: " << config.server_host << ":" << config.server_port << "\n"
|
||||
<< " alsa_device: " << config.alsa_device << "\n"
|
||||
<< " sample_rate: " << config.sample_rate << "\n"
|
||||
<< " channels: " << config.channels << "\n"
|
||||
<< " frame_ms: " << config.frame_ms << "\n";
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main(int argc, char* argv[]) {
|
||||
try {
|
||||
std::signal(SIGINT, handle_signal);
|
||||
std::signal(SIGTERM, handle_signal);
|
||||
|
||||
const Config config = parse_args(argc, argv);
|
||||
print_config(config);
|
||||
|
||||
UdpSender sender(config.server_host, config.server_port);
|
||||
AlsaCapture capture(config);
|
||||
std::vector<std::uint8_t> audio_buffer = capture.make_buffer();
|
||||
|
||||
std::uint64_t sequence = 0;
|
||||
std::uint64_t timestamp_frames = 0;
|
||||
|
||||
std::cout << "capturing; press Ctrl+C to stop\n";
|
||||
while (g_stop_requested == 0) {
|
||||
const std::size_t frames_read = capture.read_frames(audio_buffer);
|
||||
if (frames_read == 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
const std::vector<std::uint8_t> packet =
|
||||
make_packet(sequence, timestamp_frames, config, static_cast<std::uint32_t>(frames_read), audio_buffer);
|
||||
sender.send_packet(packet);
|
||||
|
||||
++sequence;
|
||||
timestamp_frames += frames_read;
|
||||
}
|
||||
|
||||
std::cout << "stopped\n";
|
||||
return 0;
|
||||
} catch (const std::exception& error) {
|
||||
std::cerr << "error: " << error.what() << "\n";
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,168 @@
|
|||
# 클라이언트 방향성
|
||||
|
||||
이 문서는 Orange Pi Zero 2W에서 실행할 마이크 송신기 클라이언트의 초기 방향을 정리합니다.
|
||||
|
||||
현재 단계의 목표는 복잡한 기능을 넣는 것이 아니라, USB 마이크 입력을 Mac 수신기까지 보내서 `소리가 나온다`는 것을 확인하는 것입니다.
|
||||
|
||||
## 역할
|
||||
|
||||
클라이언트는 Orange Pi Zero 2W에서 실행되며 다음 역할을 맡습니다.
|
||||
|
||||
- USB 마이크를 ALSA 오디오 입력 장치로 인식합니다.
|
||||
- 마이크에서 캡처한 오디오 프레임을 네트워크 패킷으로 나눕니다.
|
||||
- Wi-Fi 공유기를 통해 Mac 수신기/믹서로 오디오를 전송합니다.
|
||||
- 초기 버전에서는 송신기 1대만 검증합니다.
|
||||
|
||||
```text
|
||||
USB 마이크 -> Orange Pi Zero 2W 클라이언트 -> UDP/Wi-Fi -> Mac 수신기
|
||||
```
|
||||
|
||||
## 초기 범위
|
||||
|
||||
초기 클라이언트의 성공 기준은 다음과 같습니다.
|
||||
|
||||
- Orange Pi Zero 2W가 정상 부팅되고 SSH로 접속됩니다.
|
||||
- USB 마이크가 Linux ALSA 장치로 보입니다.
|
||||
- 클라이언트가 마이크 입력을 캡처합니다.
|
||||
- Mac 수신기가 클라이언트의 UDP 패킷을 받습니다.
|
||||
- Mac에서 실제 오디오가 들립니다.
|
||||
|
||||
지연 시간 최적화, 다중 송신기, 자동 검색, 압축, 동기화, UI는 초기 성공 이후에 다룹니다.
|
||||
|
||||
## 마이크 기준
|
||||
|
||||
USB 라발리에 마이크 모델은 고정하지 않습니다.
|
||||
|
||||
첫 테스트는 MATA STUDIO C300 같은 일반 USB 마이크로 진행할 수 있습니다. 단, 마이크마다 지원 포맷이 다를 수 있으므로 클라이언트는 특정 모델이나 ALSA 카드 번호에 강하게 의존하지 않아야 합니다.
|
||||
|
||||
주의할 점은 다음과 같습니다.
|
||||
|
||||
- USB Audio Class 장치로 ALSA에서 인식되는지 확인해야 합니다.
|
||||
- 마이크가 96 kHz/24-bit 같은 고해상도 포맷을 우선 지원할 수 있습니다.
|
||||
- 초기 송신 포맷과 마이크의 실제 캡처 포맷이 다르면 ALSA `plug` 변환 또는 별도 변환이 필요할 수 있습니다.
|
||||
- 마이크 자체의 MUTE, 게인, 볼륨 설정 때문에 캡처가 무음처럼 보일 수 있습니다.
|
||||
- 여러 USB 오디오 장치가 연결되면 ALSA 카드 번호가 바뀔 수 있습니다.
|
||||
|
||||
## 오디오 캡처 방향
|
||||
|
||||
초기 오디오 캡처는 ALSA 기반으로 진행합니다.
|
||||
|
||||
초기 후보 포맷은 다음과 같습니다.
|
||||
|
||||
```text
|
||||
sample_rate = 48000
|
||||
sample_format = signed 16-bit little endian
|
||||
channels = 1
|
||||
frame_ms = 20
|
||||
```
|
||||
|
||||
이 값은 구현 시작점일 뿐입니다. 실제 장치가 지원하지 않으면 44.1 kHz, 96 kHz, 24-bit 등으로 조정하거나 ALSA 변환 장치를 사용합니다.
|
||||
|
||||
ALSA 장치명은 설정 파일 또는 실행 옵션으로 지정할 수 있게 합니다.
|
||||
|
||||
예시:
|
||||
|
||||
```text
|
||||
alsa_device = default
|
||||
```
|
||||
|
||||
테스트 단계에서는 `arecord -l`, `arecord -L`로 장치 목록을 확인하고, 짧은 녹음 테스트로 실제 입력 여부를 확인합니다.
|
||||
|
||||
## 네트워크 전송 방향
|
||||
|
||||
초기 전송은 UDP 기반으로 진행합니다.
|
||||
|
||||
UDP를 우선 검토하는 이유는 다음과 같습니다.
|
||||
|
||||
- 오디오 스트리밍의 최소 경로를 빠르게 확인할 수 있습니다.
|
||||
- TCP보다 지연과 재전송 정책이 단순합니다.
|
||||
- 첫 목표가 완전한 품질보다 오디오 경로 검증이기 때문입니다.
|
||||
|
||||
현재 초기 구현은 간단한 헤더와 PCM payload로 UDP 패킷을 구성합니다.
|
||||
|
||||
헤더 형식:
|
||||
|
||||
```text
|
||||
magic 4 bytes "MIC1"
|
||||
version u16
|
||||
header_size u16
|
||||
sequence u64
|
||||
timestamp_frames u64
|
||||
sample_rate u32
|
||||
channels u16
|
||||
bits_per_sample u16
|
||||
frame_count u32
|
||||
payload_bytes u32
|
||||
```
|
||||
|
||||
헤더 숫자 값은 network byte order로 전송합니다. PCM payload는 signed 16-bit little endian입니다.
|
||||
|
||||
서버 구현은 이 형식에 맞춰 수신 경로를 만들되, 실제 재생 테스트 중 문제가 있으면 클라이언트와 서버를 함께 조정합니다.
|
||||
|
||||
## 수신기 주소 설정
|
||||
|
||||
첫 버전은 Mac 수신기 IP를 설정 파일에서 지정하는 방향을 우선합니다.
|
||||
|
||||
자동 검색은 좋은 확장 기능이지만, 초기 오디오 경로 검증에서는 고정 설정이 더 단순하고 문제 원인을 분리하기 쉽습니다.
|
||||
|
||||
설정 파일 후보:
|
||||
|
||||
```text
|
||||
server_host = 192.168.0.10
|
||||
server_port = 4860
|
||||
alsa_device = default
|
||||
sample_rate = 48000
|
||||
channels = 1
|
||||
frame_ms = 20
|
||||
```
|
||||
|
||||
초기 버전의 `server_port`는 `4860`으로 정합니다.
|
||||
|
||||
작업 중 여러 포트가 필요해지면 `4800-5000` 범위 안에서 배정하는 방향으로 둡니다.
|
||||
|
||||
## 자동 검색 확장 후보
|
||||
|
||||
자동 검색은 초기 성공 이후 다음 방식으로 검토합니다.
|
||||
|
||||
- 클라이언트가 같은 서브넷에 UDP broadcast 또는 multicast 탐색 패킷을 보냅니다.
|
||||
- Mac 수신기가 자신의 주소와 수신 포트를 응답합니다.
|
||||
- 클라이언트는 응답받은 주소로 오디오 송신을 시작합니다.
|
||||
|
||||
공유기, 방화벽, 네트워크 분리 설정에 따라 자동 검색이 실패할 수 있으므로 고정 설정 파일 방식은 계속 유지합니다.
|
||||
|
||||
## 구현 후보
|
||||
|
||||
구현 언어는 C++입니다.
|
||||
|
||||
라이브러리와 도구 후보는 다음과 같습니다.
|
||||
|
||||
- 오디오 캡처: ALSA, `libasound2-dev`
|
||||
- 네트워크: POSIX UDP socket
|
||||
- 설정 파일: 단순 key-value 텍스트 형식
|
||||
- 빌드: CMake 후보
|
||||
|
||||
현재 초기 클라이언트 구현은 `client/`에 있습니다.
|
||||
|
||||
- `client/src/main.cpp`: ALSA 캡처와 UDP 송신
|
||||
- `client/CMakeLists.txt`: CMake 빌드 구성
|
||||
- `client/client.conf.example`: 설정 파일 예시
|
||||
- `client/README.md`: 빌드와 실행 절차
|
||||
|
||||
## 검증 순서
|
||||
|
||||
초기 검증은 다음 순서를 따릅니다.
|
||||
|
||||
1. Orange Pi Zero 2W OS 부팅 확인
|
||||
2. SSH 접속 확인
|
||||
3. Wi-Fi 연결 확인
|
||||
4. USB 마이크 장치 인식 확인
|
||||
5. ALSA 짧은 녹음 테스트 확인
|
||||
6. Mac 수신기의 UDP 수신 확인
|
||||
7. 클라이언트에서 단일 송신기 오디오 전송 확인
|
||||
8. Mac에서 실제 오디오 출력 확인
|
||||
|
||||
## 다음 단계
|
||||
|
||||
다음 작업은 서버 방향성과 최소 UDP 수신 경로를 함께 정한 뒤, 클라이언트와 서버의 패킷 포맷을 맞추는 것입니다.
|
||||
|
||||
다음 작업은 Mac 서버가 현재 클라이언트의 UDP 패킷 형식을 수신하고 PCM payload를 재생할 수 있도록 맞추는 것입니다.
|
||||
|
|
@ -0,0 +1,198 @@
|
|||
# 서버 방향성
|
||||
|
||||
이 문서는 macOS, Ubuntu, Rocky Linux 9, Windows WSL2에서 실행할 수신기/믹서 서버의 초기 방향을 정리합니다.
|
||||
|
||||
현재 단계의 목표는 복잡한 믹서 기능을 만드는 것이 아니라, Orange Pi Zero 2W 클라이언트에서 보낸 오디오가 서버 실행 장비에서 실제로 들리는 최소 경로를 확인하는 것입니다.
|
||||
|
||||
```text
|
||||
Orange Pi Zero 2W 클라이언트 -> UDP/Wi-Fi -> 서버 -> 기본 오디오 출력
|
||||
```
|
||||
|
||||
## 역할
|
||||
|
||||
서버는 수신 장비에서 실행되며 다음 역할을 맡습니다.
|
||||
|
||||
- 고정 UDP 포트에서 클라이언트의 오디오 패킷을 수신합니다.
|
||||
- 수신 패킷의 순서와 기본 메타데이터를 확인합니다.
|
||||
- PCM 오디오 payload를 실행 OS의 오디오 출력 장치로 재생합니다.
|
||||
- 초기 버전에서는 송신기 1대만 검증합니다.
|
||||
- 이후 단계에서 다중 송신기 믹싱, 동기화, UI를 확장 후보로 검토합니다.
|
||||
|
||||
## 초기 범위
|
||||
|
||||
초기 서버의 성공 기준은 다음과 같습니다.
|
||||
|
||||
- 지원 OS에서 서버 프로그램을 실행할 수 있습니다.
|
||||
- 서버가 지정된 UDP 포트에 바인딩됩니다.
|
||||
- Orange Pi Zero 2W 클라이언트가 보낸 UDP 패킷을 수신합니다.
|
||||
- 수신한 PCM 프레임을 실행 OS의 기본 오디오 출력으로 재생합니다.
|
||||
- 패킷 수신 상태, sequence 변화, 간단한 손실 통계를 확인할 수 있습니다.
|
||||
|
||||
다중 송신기 믹싱, 송신기별 음량 조절, 자동 검색, 압축 코덱, 지터 버퍼 고도화, GUI는 초기 성공 이후에 다룹니다.
|
||||
|
||||
## 네트워크 수신 방향
|
||||
|
||||
초기 수신은 UDP 기반으로 진행합니다.
|
||||
|
||||
포트는 다음 값을 초기 후보로 둡니다.
|
||||
|
||||
```text
|
||||
listen_port = 4860
|
||||
```
|
||||
|
||||
서버는 먼저 단일 클라이언트의 패킷만 받는 것을 목표로 합니다. 여러 클라이언트가 같은 포트로 들어오는 구조는 가능하지만, 초기 단계에서는 문제 원인을 줄이기 위해 하나의 송신기만 검증합니다.
|
||||
|
||||
## 포트 운영 방향
|
||||
|
||||
초기 기본값은 서버 UDP 수신 포트 1개입니다.
|
||||
|
||||
마이크가 여러 대가 되더라도 먼저 하나의 UDP 포트로 받고, 패킷 헤더의 송신기 식별자와 sequence, timestamp를 이용해 서버 내부에서 스트림을 나누는 방향을 우선합니다.
|
||||
|
||||
포트를 하나만 쓰는 이유는 다음과 같습니다.
|
||||
|
||||
- OS 방화벽과 공유기 설정이 단순합니다.
|
||||
- 송신기 수가 늘어나도 서버 실행 옵션이 복잡해지지 않습니다.
|
||||
- 자동 검색을 추가할 때 안내할 수신 포트가 하나라 단순합니다.
|
||||
- 서버가 송신기별 지터 버퍼와 믹싱 상태를 한 곳에서 관리하기 쉽습니다.
|
||||
|
||||
마이크 수만큼 포트를 나누는 방식은 다음 상황에서만 후보로 둡니다.
|
||||
|
||||
- 송신기마다 별도 서버 프로세스를 띄워 완전히 분리해 테스트해야 할 때
|
||||
- 특정 네트워크 도구로 송신기별 트래픽을 강하게 분리해서 봐야 할 때
|
||||
- 나중에 운영상 포트 분리가 더 명확하다고 판단될 때
|
||||
|
||||
따라서 현재 방향은 `listen_port = 4860` 하나를 사용하고, 다중 송신기는 패킷 메타데이터로 구분하는 것입니다.
|
||||
|
||||
## 개발 도구와 의존성 방향
|
||||
|
||||
서버는 장기적으로 macOS, Ubuntu, Rocky Linux 9, Windows WSL2에서 실행할 수 있게 설계합니다.
|
||||
|
||||
Homebrew를 개발/실행 준비 기준으로 둡니다. macOS와 Linux 개발 환경에서 같은 방식으로 CMake와 miniaudio 같은 의존성을 설치할 수 있고, Windows는 네이티브 Windows가 아니라 WSL2 Linux 환경을 사용합니다.
|
||||
|
||||
서버 구현 기준은 다음 방향을 우선합니다.
|
||||
|
||||
- 빌드: CMake
|
||||
- 의존성 관리: Homebrew
|
||||
- Windows 지원: WSL2 Linux 환경
|
||||
- 네이티브 Windows 빌드: 초기 범위에서 제외
|
||||
|
||||
WSL2에서 실제 오디오를 출력하려면 WSLg 또는 Linux 오디오 출력 경로가 동작해야 합니다. Windows 네이티브 오디오 API 지원은 초기 범위에 포함하지 않습니다.
|
||||
|
||||
공식 참고 링크:
|
||||
|
||||
- https://docs.brew.sh/Homebrew-on-Linux
|
||||
- https://formulae.brew.sh/formula/miniaudio
|
||||
|
||||
## 오디오 출력 방향
|
||||
|
||||
초기 오디오 출력은 여러 OS에서 C++로 접근 가능한 라이브러리를 후보로 검토합니다.
|
||||
|
||||
후보는 다음과 같습니다.
|
||||
|
||||
- miniaudio: 단일 파일에 가까운 경량 오디오 라이브러리입니다. macOS와 Linux의 기본 오디오 백엔드를 지원하고 Homebrew로 설치할 수 있어 초기 우선 후보로 둡니다.
|
||||
- Core Audio: macOS 기본 오디오 API입니다. 추가 런타임 의존성이 적지만 초기 구현이 다소 길어질 수 있습니다.
|
||||
- PortAudio: 크로스 플랫폼 오디오 입출력 라이브러리입니다. 예제가 많고 초기 재생 경로를 빠르게 만들 수 있습니다.
|
||||
- RtAudio: C++ 인터페이스가 단순한 오디오 입출력 라이브러리입니다.
|
||||
|
||||
첫 구현에서는 안정적인 최소 재생 경로를 우선합니다. 현재 우선 후보는 `miniaudio`입니다.
|
||||
|
||||
## 초기 오디오 포맷 후보
|
||||
|
||||
클라이언트 방향성과 맞춰 초기 포맷 후보는 다음과 같습니다.
|
||||
|
||||
```text
|
||||
sample_rate = 48000
|
||||
sample_format = signed 16-bit little endian
|
||||
channels = 1
|
||||
frame_ms = 10
|
||||
```
|
||||
|
||||
`48 kHz / 16-bit / mono / 10 ms` 기준 payload는 960바이트입니다. UDP/IP 헤더를 더해도 일반적인 MTU 1500 안에 들어가므로, 20 ms 프레임보다 UDP fragmentation 위험이 낮습니다.
|
||||
|
||||
이 값은 시작점입니다. 실제 USB 마이크나 출력 경로에서 다른 포맷이 더 자연스러우면 구현 전에 조정합니다.
|
||||
|
||||
## 패킷 포맷 후보
|
||||
|
||||
초기 패킷은 서버가 지정하는 고정 길이 헤더와 PCM payload로 구성하는 방향을 검토합니다.
|
||||
|
||||
헤더 v1 후보:
|
||||
|
||||
```text
|
||||
magic u32 "MIC1"
|
||||
version u8 1
|
||||
header_len u8 56
|
||||
packet_type u8 1 = audio
|
||||
flags u8 reserved
|
||||
sender_id u16 0 reserved, 1..65535
|
||||
stream_id u16 initial 0
|
||||
session_id u32 random value after sender restart
|
||||
sequence u32 increments per sender/session
|
||||
capture_sample_index u64 first sample index in this packet
|
||||
sender_monotonic_us u64 sender monotonic timestamp
|
||||
sample_rate u32 initial 48000
|
||||
frame_samples u16 initial 480
|
||||
payload_bytes u16 initial 960
|
||||
codec_id u8 1 = PCM_S16LE
|
||||
sample_format u8 1 = S16LE
|
||||
channels u8 initial 1
|
||||
channel_layout u8 0 = mono/default
|
||||
header_crc32 u32 0 allowed in initial implementation
|
||||
reserved u32 future use
|
||||
```
|
||||
|
||||
헤더는 네트워크 바이트 오더를 사용합니다. PCM payload는 초기 후보인 `PCM_S16LE`에 맞춰 little endian으로 둡니다.
|
||||
|
||||
서버는 `sender_id`, `session_id`, `sequence`, `capture_sample_index`를 이용해 송신기 구분, 재시작 감지, 패킷 손실, 순서 역전, 재생 위치를 확인할 수 있어야 합니다. 초기 단계에서는 복잡한 재전송이나 복구를 넣지 않고, 끊김 여부를 관찰하는 데 집중합니다.
|
||||
|
||||
## 지터와 버퍼링 방향
|
||||
|
||||
Wi-Fi UDP 오디오에서는 패킷 도착 간격이 흔들릴 수 있습니다.
|
||||
|
||||
초기 버전은 아주 단순한 짧은 재생 버퍼를 후보로 둡니다.
|
||||
|
||||
- 너무 짧으면 끊김이 늘어납니다.
|
||||
- 너무 길면 지연이 커집니다.
|
||||
- 첫 목표는 지연 시간 최적화가 아니라 소리 경로 확인입니다.
|
||||
|
||||
구체적인 버퍼 길이는 구현 전 테스트 환경과 오디오 라이브러리 선택에 맞춰 정합니다.
|
||||
|
||||
## 설정 방향
|
||||
|
||||
초기 서버 설정 후보는 다음과 같습니다.
|
||||
|
||||
```text
|
||||
listen_port = 4860
|
||||
sample_rate = 48000
|
||||
channels = 1
|
||||
frame_ms = 10
|
||||
audio_output = default
|
||||
```
|
||||
|
||||
송신기별 음량 조절 설정은 현재 범위에서 제외합니다. 초기 목표는 수신한 단일 송신기 오디오를 노트북 스피커로 출력하는 것입니다.
|
||||
|
||||
설정 파일 형식은 단순 key-value 텍스트 형식을 후보로 둡니다. 자동 검색을 추가하더라도 고정 포트와 고정 설정 방식은 유지합니다.
|
||||
|
||||
## 검증 순서
|
||||
|
||||
초기 검증은 다음 순서를 따릅니다.
|
||||
|
||||
1. 지원 OS에서 서버가 UDP 포트를 열 수 있는지 확인합니다.
|
||||
2. 같은 장비 또는 다른 장비에서 테스트 UDP 패킷이 수신되는지 확인합니다.
|
||||
3. Orange Pi Zero 2W와 서버 실행 장비 사이의 Wi-Fi 통신을 확인합니다.
|
||||
4. 클라이언트의 오디오 패킷이 서버에 도착하는지 확인합니다.
|
||||
5. 서버가 수신한 PCM 프레임을 실행 OS의 오디오 출력으로 재생합니다.
|
||||
6. sequence 로그로 패킷 손실과 순서 역전을 관찰합니다.
|
||||
7. 실제 소리 끊김과 지연을 기록합니다.
|
||||
|
||||
## 현재 구현 단계
|
||||
|
||||
서버 구현은 Homebrew, CMake, miniaudio 기준으로 시작합니다.
|
||||
|
||||
현재 서버 구현 범위는 다음과 같습니다.
|
||||
|
||||
- UDP `4860` 바인딩
|
||||
- 서버 지정 56바이트 패킷 헤더 v1 파싱
|
||||
- 단일 활성 `sender_id`의 `PCM_S16LE / mono / 48 kHz / 10 ms` payload 재생
|
||||
- sequence 기반 손실/순서 역전 로그 출력
|
||||
|
||||
다음 작업은 클라이언트 송신 패킷을 서버 지정 헤더 v1에 맞추고, 실제 송신기에서 들어온 오디오가 실행 OS의 기본 출력 장치로 재생되는지 확인하는 것입니다.
|
||||
|
|
@ -0,0 +1,116 @@
|
|||
# 작업 플랜
|
||||
|
||||
- UUID: `4b3baabc-2767-4470-bdd9-ea310403f684`
|
||||
- 작업자: `Codex`
|
||||
- 생성 시각: `2026-06-02 15:41`
|
||||
- 요청 요약: `Orange Pi Zero 2W에서 실행할 클라이언트 준비`
|
||||
|
||||
## 할 일
|
||||
|
||||
- [x] 현재 요청 확인
|
||||
- [x] 필수 문서 확인
|
||||
- [x] 저장소 상태와 관련 파일 목록 확인
|
||||
- [x] 클라이언트 준비 범위 정리
|
||||
- [x] 사용자 답변을 바탕으로 클라이언트 방향성 문서 작성
|
||||
- [x] README 문서 목록 갱신
|
||||
- [x] 사용자의 구현 승인 확인
|
||||
- [x] 승인 후 클라이언트 구현 계획 확정
|
||||
- [x] 승인 후 필요한 파일 생성
|
||||
- [x] 로컬 검토와 빌드 가능 여부 확인
|
||||
- [x] 완료 내용 정리
|
||||
|
||||
## 현재 확인 내용
|
||||
|
||||
- 현재 브랜치는 `main`이며 원격 `origin/main`과 같은 위치입니다.
|
||||
- `client/`와 `server/` 구현 파일은 아직 없습니다.
|
||||
- 프로젝트 규칙상 사용자가 계획을 확정하고 실행을 지시하기 전까지 C++ 소스 코드, 빌드 시스템, 테스트 코드, 구조 변경은 만들지 않습니다.
|
||||
- 클라이언트는 Orange Pi Zero 2W의 USB 라발리에 마이크 입력을 캡처하고 Wi-Fi를 통해 Mac 수신기/믹서로 전송하는 역할입니다.
|
||||
|
||||
## 클라이언트 준비 초안
|
||||
|
||||
### 목표
|
||||
|
||||
Orange Pi Zero 2W에서 USB 라발리에 마이크를 인식하고, C++ 송신기 프로그램으로 Mac 수신기까지 오디오를 전송할 수 있는 최소 클라이언트 경로를 준비합니다.
|
||||
|
||||
### 대상 환경
|
||||
|
||||
- 보드: Orange Pi Zero 2W
|
||||
- OS: Orange Pi 공식 Debian 12 Bookworm Server, Linux 6.1
|
||||
- 언어: C++
|
||||
- 실행 형태: headless SSH 접속 후 실행
|
||||
|
||||
### 우선 확인할 항목
|
||||
|
||||
- OS 부팅과 SSH 접속
|
||||
- Wi-Fi 연결 안정성
|
||||
- USB 라발리에 마이크 인식 여부
|
||||
- ALSA 장치 목록 확인
|
||||
- Mac 수신기 IP와 UDP 수신 가능 여부
|
||||
|
||||
### 라이브러리 후보
|
||||
|
||||
- 오디오 캡처: ALSA `libasound2-dev`
|
||||
- 네트워크 전송: POSIX UDP socket
|
||||
- 빌드: CMake 후보, 단 사용자의 승인 전에는 생성하지 않음
|
||||
|
||||
### 초기 송신 방식 후보
|
||||
|
||||
- 압축 없는 PCM 16-bit mono
|
||||
- 샘플레이트 후보: 48 kHz 또는 44.1 kHz
|
||||
- 프레임 크기 후보: 10 ms 또는 20 ms
|
||||
- 전송: UDP 패킷에 간단한 헤더와 PCM payload 포함
|
||||
|
||||
### 검증 방법 후보
|
||||
|
||||
- Orange Pi에서 마이크 장치 확인
|
||||
- 짧은 ALSA 캡처 동작 확인
|
||||
- Mac에서 UDP 패킷 수신 확인
|
||||
- 단일 송신기 오디오 재생 확인
|
||||
- 지연, 끊김, 패킷 손실 로그 확인
|
||||
|
||||
## 사용자 답변 반영
|
||||
|
||||
### 결정된 초기 범위
|
||||
|
||||
- USB 라발리에 마이크 모델은 고정하지 않습니다.
|
||||
- 첫 테스트 마이크는 MATA STUDIO C300 같은 일반 USB 마이크를 사용합니다.
|
||||
- 초기 목표는 지연 시간 최적화가 아니라 `소리가 나온다`까지입니다.
|
||||
- 첫 버전은 송신기 1대만 검증합니다.
|
||||
- Mac 수신기 포트는 고정 사용을 선호합니다.
|
||||
- Mac 수신기 IP는 고정 설정과 자동 검색 후보를 함께 검토합니다.
|
||||
|
||||
### 반영할 제안
|
||||
|
||||
- 첫 구현은 고정 IP 설정 파일 기반으로 시작합니다.
|
||||
- 자동 검색은 초기 오디오 경로 검증 이후 확장 후보로 둡니다.
|
||||
- 설정 파일에는 Mac 수신기 주소, 포트, ALSA 장치, 샘플레이트, 채널 수, 프레임 길이를 둘 수 있게 설계합니다.
|
||||
- USB 마이크 모델이 바뀔 수 있으므로 ALSA 장치명을 하드코딩하지 않고 설정 또는 실행 옵션으로 지정할 수 있게 합니다.
|
||||
- 마이크가 지원하는 실제 포맷을 먼저 확인한 뒤 캡처 포맷을 정합니다.
|
||||
|
||||
### 우려 사항
|
||||
|
||||
- USB 마이크가 Linux ALSA에서 USB Audio Class 장치로 정상 인식되는지 확인해야 합니다.
|
||||
- 마이크가 96 kHz/24-bit 위주로 동작할 경우, 초기 송신 포맷인 48 kHz/16-bit와 맞추기 위해 ALSA `plug` 변환 또는 별도 변환이 필요할 수 있습니다.
|
||||
- Orange Pi Zero 2W의 USB 연결 방식, 전원, 허브 품질에 따라 마이크 인식이나 안정성이 달라질 수 있습니다.
|
||||
- 마이크 자체의 MUTE, 게인, 모니터링 설정 때문에 캡처는 되지만 무음처럼 보일 수 있습니다.
|
||||
- 여러 USB 오디오 장치가 연결되면 ALSA 카드 번호가 바뀔 수 있으므로 카드 번호만 믿는 설정은 피해야 합니다.
|
||||
|
||||
## 진행 기록
|
||||
|
||||
- `docs/02-client-direction.md`를 추가해 클라이언트 초기 목표, 마이크 기준, ALSA 캡처 방향, UDP 전송 방향, 수신기 주소 설정, 자동 검색 확장 후보, 검증 순서를 정리했습니다.
|
||||
- `README.md`의 문서 목록에 클라이언트 방향성 문서를 추가했습니다.
|
||||
- 사용자의 진행 지시에 따라 `client/`에 C++ 클라이언트 초기 구현을 추가했습니다.
|
||||
- `client/src/main.cpp`는 설정 파일을 읽고 ALSA에서 signed 16-bit little endian PCM을 캡처한 뒤 UDP 패킷으로 송신합니다.
|
||||
- `client/CMakeLists.txt`, `client/client.conf.example`, `client/README.md`를 추가했습니다.
|
||||
- `docs/02-client-direction.md`를 현재 구현된 40바이트 UDP 헤더 형식에 맞춰 갱신했습니다.
|
||||
- 로컬 PowerShell에는 `cmake`와 `g++`가 없고, WSL Ubuntu에도 빌드 도구와 ALSA 개발 패키지가 없어 실제 컴파일은 수행하지 못했습니다.
|
||||
- 실제 빌드 검증은 Orange Pi 또는 ALSA 개발 패키지가 설치된 Linux 환경에서 수행해야 합니다.
|
||||
- 사용자 추가 기준에 따라 초기 단일 UDP 포트를 `4860`으로 변경했습니다.
|
||||
- 여러 포트가 필요할 경우 `4800-5000` 범위 안에서 배정하는 방향을 문서에 반영했습니다.
|
||||
|
||||
## 완료 요약
|
||||
|
||||
- 클라이언트 방향성 문서를 추가하고 README 문서 목록에 연결했습니다.
|
||||
- Orange Pi용 최소 C++ 클라이언트 구조를 추가했습니다.
|
||||
- 첫 버전은 단일 USB 마이크, 단일 Mac 수신기, 고정 설정 파일, UDP PCM 송신 경로에 집중합니다.
|
||||
- 자동 검색, 압축, 다중 송신기, 지연 시간 최적화는 이후 확장 후보로 남겼습니다.
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
# 작업 플랜
|
||||
|
||||
- UUID: `aef651b6-b741-429f-ac7e-215acb633181`
|
||||
- 작업자: `Codex`
|
||||
- 생성 시각: `2026-06-02 16:18`
|
||||
- 요청 요약: `서버 기준 Homebrew 판단, 상세 패킷 헤더, 음량 조절 제외 결정 반영`
|
||||
|
||||
## 할 일
|
||||
|
||||
- [x] 현재 요청 확인
|
||||
- [x] 서버 기준 변경 대상 확인
|
||||
- [x] Homebrew 사용 위치 정리
|
||||
- [x] 서버 지정 패킷 헤더 v1 후보 정리
|
||||
- [x] 음량 조절 제외 결정 반영
|
||||
- [x] 서버 문서와 설정 예시 갱신
|
||||
- [x] 결과 검증
|
||||
- [x] 완료 내용 정리
|
||||
|
||||
## 범위
|
||||
|
||||
- 서버 방향성 문서와 서버 폴더 문서만 수정합니다.
|
||||
- 다른 AI가 작업 중인 `client/`와 클라이언트 구현 파일은 수정하지 않습니다.
|
||||
- C++ 소스 코드, 빌드 시스템, 테스트 코드는 만들지 않습니다.
|
||||
|
||||
## 결정 및 판단
|
||||
|
||||
### Homebrew
|
||||
|
||||
- Windows 네이티브 지원까지 포함하는 공통 기준으로는 Homebrew를 기본 의존성 관리 도구로 두지 않습니다.
|
||||
- macOS, Ubuntu, Rocky Linux 9 같은 Linux 환경에서는 개발 도구 설치 보조 수단으로 사용할 수 있습니다.
|
||||
- 4개 OS 공통 서버 구현 기준은 `CMake`와 의존성 최소화 또는 `vcpkg` 후보를 우선합니다.
|
||||
- 오디오 출력은 외부 설치 부담이 작은 `miniaudio`를 우선 후보로 둡니다.
|
||||
|
||||
### 패킷 헤더
|
||||
|
||||
- 서버가 패킷 형식을 지정합니다.
|
||||
- 초기 헤더는 고정 길이 56바이트 후보로 둡니다.
|
||||
- 여러 송신기는 포트를 나누지 않고 `sender_id`, `session_id`, `sequence`, `capture_sample_index`로 구분합니다.
|
||||
- 헤더는 네트워크 바이트 오더를 사용하고, PCM payload는 `PCM_S16LE`을 기본 후보로 둡니다.
|
||||
|
||||
### 오디오 범위
|
||||
|
||||
- 초기 목표는 수신한 A 송신기 데이터를 노트북 스피커로 출력하는 것입니다.
|
||||
- 송신기별 음량 조절은 현재 범위에서 제외합니다.
|
||||
- 다중 송신기 믹싱, UI, 실시간 gain 조절은 초기 재생 성공 이후 확장 후보로 둡니다.
|
||||
|
||||
## 완료 요약
|
||||
|
||||
- `docs/03-server-direction.md`에 4개 OS 지원 방향, Homebrew의 위치, `miniaudio` 우선 후보를 정리했습니다.
|
||||
- 서버 지정 패킷 헤더 v1 후보를 56바이트 고정 헤더로 정리했습니다.
|
||||
- 초기 프레임 길이를 `10 ms`로 정리하고 `server/server.conf.example`에도 반영했습니다.
|
||||
- 서버 문서에서 Mac 전용 표현을 지원 OS 기준 표현으로 정리했습니다.
|
||||
- 송신기별 음량 조절은 현재 범위에서 제외했습니다.
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
# 작업 플랜
|
||||
|
||||
- UUID: `af182cf0-404d-42f5-93cc-83d54bb4fbe2`
|
||||
- 작업자: `Codex`
|
||||
- 생성 시각: `2026-06-02 16:31`
|
||||
- 요청 요약: `Homebrew와 WSL2 기준으로 서버 계획을 수정하고 서버 구현 시작`
|
||||
|
||||
## 할 일
|
||||
|
||||
- [x] 현재 요청 확인
|
||||
- [x] 저장소 상태와 서버 변경 범위 확인
|
||||
- [x] Homebrew/WSL2 기준 서버 방향 확정
|
||||
- [x] 서버 문서와 README 갱신
|
||||
- [x] C++ 서버 구현 추가
|
||||
- [x] 가능한 빌드/정적 검증 수행
|
||||
- [x] 완료 내용 정리
|
||||
|
||||
## 범위
|
||||
|
||||
- `server/`와 서버 방향성 문서, 서버 작업 플랜을 수정합니다.
|
||||
- 루트 `README.md`에서는 서버 설명과 문서 링크 표현만 서버 기준으로 정리합니다.
|
||||
- 다른 AI가 작업 중인 `client/`와 클라이언트 문서는 수정하지 않습니다.
|
||||
|
||||
## 확정 기준
|
||||
|
||||
- 개발/실행 준비 기준은 Homebrew입니다.
|
||||
- Windows는 네이티브 Windows가 아니라 WSL2 환경을 지원 대상으로 봅니다.
|
||||
- 지원 대상은 macOS, Ubuntu, Rocky Linux 9, Windows의 WSL2 Linux 환경입니다.
|
||||
- 빌드는 CMake를 사용합니다.
|
||||
- 오디오 출력 우선 후보는 Homebrew로 설치 가능한 `miniaudio`입니다.
|
||||
- UDP 수신 포트는 `4860` 하나만 사용합니다.
|
||||
- 초기 오디오 포맷은 `48 kHz / PCM_S16LE / mono / 10 ms`입니다.
|
||||
- 송신기별 음량 조절은 초기 구현 범위에서 제외합니다.
|
||||
|
||||
## 구현 목표
|
||||
|
||||
- 서버 설정 파일을 읽습니다.
|
||||
- UDP `4860`에 바인딩합니다.
|
||||
- 서버가 지정한 56바이트 패킷 헤더 v1을 파싱합니다.
|
||||
- 단일 송신기 PCM payload를 실행 OS의 기본 오디오 출력으로 재생합니다.
|
||||
- sequence 기반의 기본 수신 로그와 손실 카운터를 출력합니다.
|
||||
|
||||
## 진행 기록
|
||||
|
||||
- Homebrew와 WSL2 기준으로 서버 방향성 문서와 README를 갱신했습니다.
|
||||
- `server/CMakeLists.txt`를 추가했습니다.
|
||||
- `server/src/main.cpp`를 추가해 UDP 수신, 패킷 헤더 파싱, miniaudio 기본 출력 재생 경로를 구현했습니다.
|
||||
- `server/server.conf.example`에 `sender_id`, `jitter_buffer_ms`, `log_interval_packets`를 추가했습니다.
|
||||
- PowerShell과 WSL2 Ubuntu 환경에 현재 `brew`, `cmake`, C++ 컴파일러가 없어 실제 빌드는 수행하지 못했습니다.
|
||||
- Homebrew 공식 문서와 Homebrew miniaudio formula를 확인해 WSL2/Linux 기준과 `brew install miniaudio` 방향을 문서에 반영했습니다.
|
||||
|
||||
## 완료 요약
|
||||
|
||||
- 서버 구현을 시작했고, `mic_server` C++ 실행 파일 구성을 추가했습니다.
|
||||
- 현재 구현은 단일 활성 송신기의 56바이트 헤더 v1 UDP 패킷을 받아 기본 오디오 출력으로 재생합니다.
|
||||
- 빌드는 현재 로컬 도구 부재로 미실행 상태입니다. WSL2에 Homebrew, CMake, C++ 컴파일러, miniaudio가 준비되면 빌드 검증이 필요합니다.
|
||||
|
|
@ -0,0 +1,97 @@
|
|||
# 작업 플랜
|
||||
|
||||
- UUID: `b47aca02-f7bc-4df3-aba9-386f09d2e974`
|
||||
- 작업자: `Codex`
|
||||
- 생성 시각: `2026-06-02 15:55`
|
||||
- 요청 요약: `Mac에서 실행할 서버 수신/믹서 준비`
|
||||
|
||||
## 할 일
|
||||
|
||||
- [x] 현재 요청 확인
|
||||
- [x] 필수 문서 확인
|
||||
- [x] 저장소 상태와 서버 관련 파일 목록 확인
|
||||
- [x] 클라이언트 방향성 문서 확인
|
||||
- [x] 서버 준비 범위 정리
|
||||
- [x] 서버 방향성 문서 작성
|
||||
- [x] 서버 폴더 README와 설정 예시 추가
|
||||
- [x] README 문서 목록 갱신
|
||||
- [x] 결과 검증
|
||||
- [x] 완료 내용 정리
|
||||
|
||||
## 현재 확인 내용
|
||||
|
||||
- 현재 브랜치는 `main`이며 원격 `origin/main`과 같은 위치입니다.
|
||||
- 작업 시작 시점의 `server/` 디렉터리는 비어 있었습니다.
|
||||
- 작업 시작 시점에 사용자 또는 이전 작업으로 보이는 변경사항이 있습니다.
|
||||
- `README.md` 수정
|
||||
- `client/` 추가
|
||||
- `docs/02-client-direction.md` 추가
|
||||
- `plans/4b3baabc-2767-4470-bdd9-ea310403f684.md` 추가 또는 수정
|
||||
- 사용자 변경사항은 되돌리지 않고 서버 준비 내용만 추가합니다.
|
||||
- 프로젝트 규칙상 사용자가 계획을 확정하고 실행을 지시하기 전까지 C++ 소스 코드, 빌드 시스템, 테스트 코드, 프로토콜 세부 구현은 만들지 않습니다.
|
||||
|
||||
## 서버 준비 초안
|
||||
|
||||
### 목표
|
||||
|
||||
Mac에서 실행되는 서버가 Orange Pi Zero 2W 클라이언트가 보낸 UDP 오디오 패킷을 수신하고, 단일 송신기의 PCM 오디오를 재생할 수 있는 최소 경로를 준비합니다.
|
||||
|
||||
초기 목표는 믹싱 기능 완성이 아니라 `Mac에서 실제 소리가 나온다`는 것을 확인하는 것입니다.
|
||||
|
||||
### 대상 환경
|
||||
|
||||
- 실행 장비: Mac
|
||||
- 역할: UDP 수신기 및 이후 믹서
|
||||
- 언어: C++
|
||||
- 초기 실행 형태: 터미널에서 실행하는 headless/CLI 프로그램 후보
|
||||
|
||||
### 우선 확인할 항목
|
||||
|
||||
- Mac 로컬 IP 확인
|
||||
- Orange Pi와 Mac이 같은 Wi-Fi 네트워크에서 통신 가능한지 확인
|
||||
- Mac 방화벽 또는 네트워크 권한 확인
|
||||
- UDP 포트 수신 가능 여부 확인
|
||||
- 수신한 PCM 프레임을 Mac 오디오 출력 장치로 재생 가능한지 확인
|
||||
|
||||
### 라이브러리 후보
|
||||
|
||||
- 네트워크 수신: POSIX UDP socket
|
||||
- 오디오 출력: PortAudio, RtAudio, Core Audio 후보
|
||||
- 빌드: CMake 후보, 단 사용자의 구현 승인 전에는 생성하지 않음
|
||||
|
||||
### 초기 수신 방식 후보
|
||||
|
||||
- 고정 UDP 포트에서 단일 클라이언트 패킷 수신
|
||||
- 압축 없는 PCM 16-bit mono payload 처리
|
||||
- 샘플레이트 후보: 48 kHz
|
||||
- 프레임 크기 후보: 10 ms
|
||||
- 서버 지정 패킷 헤더로 sender_id, session_id, sequence, capture_sample_index, timestamp, payload size 확인
|
||||
|
||||
### 검증 방법 후보
|
||||
|
||||
- Mac에서 UDP 포트 바인딩 확인
|
||||
- Orange Pi 또는 로컬 도구에서 테스트 UDP 패킷 수신 확인
|
||||
- 클라이언트가 보낸 PCM 프레임 수신 확인
|
||||
- Mac 기본 출력 장치로 오디오 재생 확인
|
||||
- 패킷 손실, 순서 역전, 수신 지연을 로그로 확인
|
||||
|
||||
## 메모
|
||||
|
||||
현재 단계에서는 코드나 빌드 파일을 만들지 않습니다. 다음 단계는 서버 방향성 문서를 정리하고, 사용자가 서버 구현 계획을 승인하면 C++ 서버 구현 계획을 확정하는 것입니다.
|
||||
|
||||
## 진행 기록
|
||||
|
||||
- `docs/03-server-direction.md`를 추가해 Mac 서버의 초기 목표, UDP 수신 방향, 오디오 출력 후보, 패킷 포맷 후보, 지터/버퍼링 방향, 검증 순서를 정리했습니다.
|
||||
- `server/README.md`를 추가해 서버 폴더의 현재 목적과 구현 전 기준을 정리했습니다.
|
||||
- `server/server.conf.example`을 추가해 초기 설정 후보를 기록했습니다.
|
||||
- `README.md`의 문서 목록에 서버 방향성 문서를 추가했습니다.
|
||||
- 현재 요청에서는 문서화와 설정 예시만 추가했고, C++ 소스 코드, 테스트 코드, 빌드 시스템은 생성하지 않았습니다.
|
||||
|
||||
## 완료 요약
|
||||
|
||||
- 서버 준비 문서와 서버 폴더 안내 파일을 추가했습니다.
|
||||
- 사용자 요청에 따라 서버 초기 포트를 `4860`, 오디오 포맷 후보를 `48 kHz / 16-bit / mono / 10 ms`로 기록했습니다.
|
||||
- 서버 기준으로는 포트 1개를 우선 사용하고, 여러 마이크는 송신기 식별자와 sequence, timestamp로 구분하는 방향을 기록했습니다.
|
||||
- 서버 지정 패킷 헤더 v1 후보와 `miniaudio` 우선 후보를 반영했습니다.
|
||||
- 송신기별 음량 조절은 초기 범위에서 제외했습니다.
|
||||
- 서버 구현 전에는 패킷 포맷, 오디오 출력 라이브러리, 빌드 방식, 검증 절차를 사용자가 승인해야 합니다.
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
# 작업 플랜
|
||||
|
||||
- UUID: `da591ca2-cf11-4f3a-a071-be6fb6b23c26`
|
||||
- 작업자: `Codex`
|
||||
- 생성 시각: `2026-06-02 16:02`
|
||||
- 요청 요약: `서버 기준 UDP 포트를 4860으로 변경하고 포트 운영 방향 정리`
|
||||
|
||||
## 할 일
|
||||
|
||||
- [x] 현재 요청 확인
|
||||
- [x] 저장소 상태와 관련 파일 확인
|
||||
- [x] 서버 기준 변경 대상 확인
|
||||
- [x] 서버 문서와 설정 예시의 포트 확인
|
||||
- [x] 서버 기준 포트 운영 방향 정리
|
||||
- [x] 결과 검증
|
||||
- [x] 완료 내용 정리
|
||||
|
||||
## 범위
|
||||
|
||||
- `server/`와 서버 방향성 문서만 수정합니다.
|
||||
- 다른 AI가 작업 중인 `client/`와 클라이언트 방향성 문서는 수정하지 않습니다.
|
||||
- C++ 소스 코드, 빌드 시스템, 테스트 코드는 만들지 않습니다.
|
||||
|
||||
## 판단
|
||||
|
||||
- 초기 서버는 UDP 포트 `4860` 하나를 수신 포트로 사용합니다.
|
||||
- 마이크가 여러 대가 되더라도 서버 기준으로는 포트 하나를 유지하고, 패킷 헤더의 송신기 식별자와 sequence, timestamp로 송신기를 구분하는 방향이 우선입니다.
|
||||
- 마이크 수만큼 포트를 나누는 방식은 디버깅이나 완전 분리 실행에는 쓸 수 있지만, 방화벽 설정과 운영이 복잡해지므로 초기 기본값으로 두지 않습니다.
|
||||
|
||||
## 완료 요약
|
||||
|
||||
- 서버 방향성 문서에 포트 운영 방향을 정리했습니다.
|
||||
- 서버 README에 단일 포트 원칙을 반영했습니다.
|
||||
- 서버 설정 예시는 `listen_port = 4860` 상태임을 확인했습니다.
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
cmake_minimum_required(VERSION 3.20)
|
||||
|
||||
project(mic_server LANGUAGES CXX)
|
||||
|
||||
set(CMAKE_CXX_STANDARD 17)
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
set(CMAKE_CXX_EXTENSIONS OFF)
|
||||
|
||||
find_package(Threads REQUIRED)
|
||||
find_path(MINIAUDIO_INCLUDE_DIR miniaudio.h REQUIRED)
|
||||
|
||||
add_executable(mic_server
|
||||
src/main.cpp
|
||||
)
|
||||
|
||||
target_include_directories(mic_server PRIVATE
|
||||
${MINIAUDIO_INCLUDE_DIR}
|
||||
)
|
||||
|
||||
target_link_libraries(mic_server PRIVATE
|
||||
Threads::Threads
|
||||
${CMAKE_DL_LIBS}
|
||||
)
|
||||
|
||||
if(APPLE)
|
||||
target_link_libraries(mic_server PRIVATE
|
||||
"-framework CoreAudio"
|
||||
"-framework AudioToolbox"
|
||||
"-framework AudioUnit"
|
||||
"-framework CoreFoundation"
|
||||
)
|
||||
elseif(UNIX)
|
||||
target_link_libraries(mic_server PRIVATE m)
|
||||
endif()
|
||||
|
|
@ -0,0 +1,111 @@
|
|||
# 서버
|
||||
|
||||
이 폴더는 macOS, Ubuntu, Rocky Linux 9, Windows WSL2에서 실행할 수신기/믹서 서버 소프트웨어를 둘 위치입니다.
|
||||
|
||||
현재 서버는 Homebrew로 준비한 CMake와 miniaudio를 기준으로 구현합니다.
|
||||
|
||||
## 초기 목표
|
||||
|
||||
첫 서버의 목표는 Orange Pi Zero 2W 클라이언트가 보낸 UDP 오디오 패킷을 서버 실행 장비에서 받아 실제 소리로 재생하는 것입니다.
|
||||
|
||||
초기 성공 기준은 다음과 같습니다.
|
||||
|
||||
- 지원 OS에서 지정된 UDP 포트를 엽니다.
|
||||
- 단일 Orange Pi 클라이언트의 오디오 패킷을 받습니다.
|
||||
- 수신 패킷의 sequence와 payload 크기를 확인합니다.
|
||||
- PCM 16-bit mono 오디오를 실행 OS의 기본 출력 장치로 재생합니다.
|
||||
- 패킷 손실, 순서 역전, 끊김 여부를 로그로 관찰합니다.
|
||||
- 송신기별 음량 조절은 초기 범위에서 제외합니다.
|
||||
|
||||
## 초기 후보
|
||||
|
||||
```text
|
||||
listen_port = 4860
|
||||
sample_rate = 48000
|
||||
sample_format = signed 16-bit little endian
|
||||
channels = 1
|
||||
frame_ms = 10
|
||||
```
|
||||
|
||||
초기 프레임 길이는 10 ms를 우선합니다. `48 kHz / 16-bit / mono / 10 ms` 기준 payload는 960바이트라 일반적인 MTU 안에 들어가기 쉽습니다.
|
||||
|
||||
## 포트 운영 방향
|
||||
|
||||
초기 서버는 UDP 수신 포트 하나만 사용합니다.
|
||||
|
||||
마이크가 여러 대가 되더라도 기본 방향은 포트를 마이크 수만큼 나누지 않고, 하나의 포트에서 받은 패킷을 송신기 식별자와 sequence, timestamp로 구분하는 것입니다.
|
||||
|
||||
마이크별 포트 분리는 별도 서버 프로세스로 디버깅하거나 트래픽을 강하게 분리해야 할 때만 후보로 둡니다.
|
||||
|
||||
## 오디오 출력 라이브러리 후보
|
||||
|
||||
- miniaudio
|
||||
- Core Audio
|
||||
- PortAudio
|
||||
- RtAudio
|
||||
|
||||
현재 우선 후보는 `miniaudio`입니다. macOS와 Linux 계열 환경에서 Homebrew로 설치할 수 있고 초기 재생 경로를 단순하게 만들 수 있기 때문입니다.
|
||||
|
||||
Homebrew를 개발/실행 준비 기준으로 둡니다. Windows는 네이티브 Windows가 아니라 WSL2 Linux 환경을 사용합니다.
|
||||
|
||||
## 빌드 준비 후보
|
||||
|
||||
```bash
|
||||
brew install cmake miniaudio
|
||||
```
|
||||
|
||||
WSL2 Ubuntu에서 Homebrew를 처음 설치하는 경우, Homebrew 공식 문서의 Linux/WSL2 안내를 따릅니다. Homebrew 설치 전 기본 개발 도구가 필요할 수 있습니다.
|
||||
|
||||
## 빌드
|
||||
|
||||
```bash
|
||||
cd server
|
||||
cmake -S . -B build -DCMAKE_PREFIX_PATH="$(brew --prefix)"
|
||||
cmake --build build
|
||||
```
|
||||
|
||||
## 실행
|
||||
|
||||
```bash
|
||||
./build/mic_server server.conf.example
|
||||
```
|
||||
|
||||
서버는 UDP `4860`을 열고 첫 번째 유효한 `sender_id`를 활성 송신기로 선택합니다. `sender_id`를 고정하고 싶으면 `server.conf.example`의 `sender_id` 값을 1 이상의 값으로 지정합니다.
|
||||
|
||||
현재 구현은 단일 송신기의 `PCM_S16LE / mono / 48 kHz / 10 ms` payload를 기본 오디오 출력 장치로 재생합니다. 여러 송신기 동시 믹싱과 송신기별 음량 조절은 아직 구현하지 않습니다.
|
||||
|
||||
Windows에서는 네이티브 Windows 실행이 아니라 WSL2에서 실행합니다. WSL2에서 실제 소리를 들으려면 WSLg 또는 Linux 오디오 출력 경로가 동작해야 합니다.
|
||||
|
||||
## 패킷 헤더 v1
|
||||
|
||||
서버가 기대하는 UDP 패킷은 56바이트 헤더와 PCM payload로 구성됩니다. 헤더 정수 필드는 네트워크 바이트 오더를 사용하고, PCM payload는 little endian입니다.
|
||||
|
||||
```text
|
||||
magic u32 "MIC1"
|
||||
version u8 1
|
||||
header_len u8 56
|
||||
packet_type u8 1 = audio
|
||||
flags u8 reserved
|
||||
sender_id u16 0 reserved, 1..65535
|
||||
stream_id u16 initial 0
|
||||
session_id u32 random value after sender restart
|
||||
sequence u32 increments per sender/session
|
||||
capture_sample_index u64 first sample index in this packet
|
||||
sender_monotonic_us u64 sender monotonic timestamp
|
||||
sample_rate u32 initial 48000
|
||||
frame_samples u16 initial 480
|
||||
payload_bytes u16 initial 960
|
||||
codec_id u8 1 = PCM_S16LE
|
||||
sample_format u8 1 = S16LE
|
||||
channels u8 initial 1
|
||||
channel_layout u8 0 = mono/default
|
||||
header_crc32 u32 0 allowed in initial implementation
|
||||
reserved u32 future use
|
||||
```
|
||||
|
||||
## 관련 문서
|
||||
|
||||
- `../docs/03-server-direction.md`: 서버 수신/믹서 초기 방향성
|
||||
- `../docs/02-client-direction.md`: Orange Pi 클라이언트 초기 방향성
|
||||
- https://docs.brew.sh/Homebrew-on-Linux
|
||||
- https://formulae.brew.sh/formula/miniaudio
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
# Initial server configuration.
|
||||
# The first implementation receives one active sender and plays it to the default audio output.
|
||||
|
||||
listen_port = 4860
|
||||
sample_rate = 48000
|
||||
channels = 1
|
||||
frame_ms = 10
|
||||
audio_output = default
|
||||
sender_id = 0
|
||||
jitter_buffer_ms = 120
|
||||
log_interval_packets = 100
|
||||
|
|
@ -0,0 +1,584 @@
|
|||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <atomic>
|
||||
#include <cerrno>
|
||||
#include <csignal>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <fstream>
|
||||
#include <iostream>
|
||||
#include <limits>
|
||||
#include <map>
|
||||
#include <mutex>
|
||||
#include <optional>
|
||||
#include <sstream>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include <arpa/inet.h>
|
||||
#include <netinet/in.h>
|
||||
#include <sys/socket.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#define MINIAUDIO_IMPLEMENTATION
|
||||
#include <miniaudio.h>
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr std::size_t kHeaderSize = 56;
|
||||
constexpr std::array<std::uint8_t, 4> kMagic = {'M', 'I', 'C', '1'};
|
||||
constexpr std::uint8_t kVersion = 1;
|
||||
constexpr std::uint8_t kPacketTypeAudio = 1;
|
||||
constexpr std::uint8_t kCodecPcmS16Le = 1;
|
||||
constexpr std::uint8_t kSampleFormatS16Le = 1;
|
||||
constexpr std::uint16_t kDefaultListenPort = 4860;
|
||||
constexpr std::uint32_t kDefaultSampleRate = 48000;
|
||||
constexpr std::uint16_t kDefaultFrameMs = 10;
|
||||
constexpr std::uint16_t kDefaultChannels = 1;
|
||||
constexpr std::uint32_t kDefaultJitterBufferMs = 120;
|
||||
constexpr std::uint32_t kDefaultLogIntervalPackets = 100;
|
||||
|
||||
std::atomic<bool> g_running{true};
|
||||
|
||||
void handle_signal(int) {
|
||||
g_running.store(false);
|
||||
}
|
||||
|
||||
std::string trim(std::string value) {
|
||||
const auto first = value.find_first_not_of(" \t\r\n");
|
||||
if (first == std::string::npos) {
|
||||
return {};
|
||||
}
|
||||
const auto last = value.find_last_not_of(" \t\r\n");
|
||||
return value.substr(first, last - first + 1);
|
||||
}
|
||||
|
||||
std::optional<std::uint32_t> parse_u32(const std::string& value) {
|
||||
try {
|
||||
std::size_t used = 0;
|
||||
const auto parsed = std::stoul(value, &used, 0);
|
||||
if (used != value.size() || parsed > std::numeric_limits<std::uint32_t>::max()) {
|
||||
return std::nullopt;
|
||||
}
|
||||
return static_cast<std::uint32_t>(parsed);
|
||||
} catch (...) {
|
||||
return std::nullopt;
|
||||
}
|
||||
}
|
||||
|
||||
struct Config {
|
||||
std::uint16_t listen_port = kDefaultListenPort;
|
||||
std::uint32_t sample_rate = kDefaultSampleRate;
|
||||
std::uint16_t channels = kDefaultChannels;
|
||||
std::uint16_t frame_ms = kDefaultFrameMs;
|
||||
std::uint16_t sender_id = 0;
|
||||
std::uint32_t jitter_buffer_ms = kDefaultJitterBufferMs;
|
||||
std::uint32_t log_interval_packets = kDefaultLogIntervalPackets;
|
||||
std::string audio_output = "default";
|
||||
};
|
||||
|
||||
bool load_config_file(const std::string& path, Config& config) {
|
||||
std::ifstream file(path);
|
||||
if (!file) {
|
||||
return false;
|
||||
}
|
||||
|
||||
std::string line;
|
||||
std::uint32_t line_number = 0;
|
||||
while (std::getline(file, line)) {
|
||||
++line_number;
|
||||
const auto comment = line.find('#');
|
||||
if (comment != std::string::npos) {
|
||||
line.erase(comment);
|
||||
}
|
||||
|
||||
const auto equals = line.find('=');
|
||||
if (equals == std::string::npos) {
|
||||
if (!trim(line).empty()) {
|
||||
std::cerr << "Ignoring malformed config line " << line_number << ": " << line << '\n';
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const auto key = trim(line.substr(0, equals));
|
||||
const auto value = trim(line.substr(equals + 1));
|
||||
if (key.empty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (key == "listen_port") {
|
||||
const auto parsed = parse_u32(value);
|
||||
if (!parsed || *parsed == 0 || *parsed > 65535) {
|
||||
throw std::runtime_error("Invalid listen_port: " + value);
|
||||
}
|
||||
config.listen_port = static_cast<std::uint16_t>(*parsed);
|
||||
} else if (key == "sample_rate") {
|
||||
const auto parsed = parse_u32(value);
|
||||
if (!parsed || *parsed == 0) {
|
||||
throw std::runtime_error("Invalid sample_rate: " + value);
|
||||
}
|
||||
config.sample_rate = *parsed;
|
||||
} else if (key == "channels") {
|
||||
const auto parsed = parse_u32(value);
|
||||
if (!parsed || *parsed == 0 || *parsed > 8) {
|
||||
throw std::runtime_error("Invalid channels: " + value);
|
||||
}
|
||||
config.channels = static_cast<std::uint16_t>(*parsed);
|
||||
} else if (key == "frame_ms") {
|
||||
const auto parsed = parse_u32(value);
|
||||
if (!parsed || *parsed == 0 || *parsed > 1000) {
|
||||
throw std::runtime_error("Invalid frame_ms: " + value);
|
||||
}
|
||||
config.frame_ms = static_cast<std::uint16_t>(*parsed);
|
||||
} else if (key == "sender_id") {
|
||||
const auto parsed = parse_u32(value);
|
||||
if (!parsed || *parsed > 65535) {
|
||||
throw std::runtime_error("Invalid sender_id: " + value);
|
||||
}
|
||||
config.sender_id = static_cast<std::uint16_t>(*parsed);
|
||||
} else if (key == "jitter_buffer_ms") {
|
||||
const auto parsed = parse_u32(value);
|
||||
if (!parsed || *parsed == 0 || *parsed > 5000) {
|
||||
throw std::runtime_error("Invalid jitter_buffer_ms: " + value);
|
||||
}
|
||||
config.jitter_buffer_ms = *parsed;
|
||||
} else if (key == "log_interval_packets") {
|
||||
const auto parsed = parse_u32(value);
|
||||
if (!parsed || *parsed == 0) {
|
||||
throw std::runtime_error("Invalid log_interval_packets: " + value);
|
||||
}
|
||||
config.log_interval_packets = *parsed;
|
||||
} else if (key == "audio_output") {
|
||||
config.audio_output = value;
|
||||
} else {
|
||||
std::cerr << "Ignoring unknown config key: " << key << '\n';
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
std::string choose_config_path(int argc, char** argv, Config& config) {
|
||||
if (argc > 1) {
|
||||
const std::string path = argv[1];
|
||||
if (!load_config_file(path, config)) {
|
||||
throw std::runtime_error("Could not open config file: " + path);
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
for (const std::string path : {"server.conf", "server/server.conf.example", "server.conf.example"}) {
|
||||
if (load_config_file(path, config)) {
|
||||
return path;
|
||||
}
|
||||
}
|
||||
|
||||
return "built-in defaults";
|
||||
}
|
||||
|
||||
std::uint16_t read_u16_be(const std::uint8_t* data) {
|
||||
return static_cast<std::uint16_t>((static_cast<std::uint16_t>(data[0]) << 8) |
|
||||
static_cast<std::uint16_t>(data[1]));
|
||||
}
|
||||
|
||||
std::uint32_t read_u32_be(const std::uint8_t* data) {
|
||||
return (static_cast<std::uint32_t>(data[0]) << 24) |
|
||||
(static_cast<std::uint32_t>(data[1]) << 16) |
|
||||
(static_cast<std::uint32_t>(data[2]) << 8) |
|
||||
static_cast<std::uint32_t>(data[3]);
|
||||
}
|
||||
|
||||
std::uint64_t read_u64_be(const std::uint8_t* data) {
|
||||
std::uint64_t value = 0;
|
||||
for (int i = 0; i < 8; ++i) {
|
||||
value = (value << 8) | data[i];
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
struct PacketHeader {
|
||||
std::uint8_t version = 0;
|
||||
std::uint8_t header_len = 0;
|
||||
std::uint8_t packet_type = 0;
|
||||
std::uint8_t flags = 0;
|
||||
std::uint16_t sender_id = 0;
|
||||
std::uint16_t stream_id = 0;
|
||||
std::uint32_t session_id = 0;
|
||||
std::uint32_t sequence = 0;
|
||||
std::uint64_t capture_sample_index = 0;
|
||||
std::uint64_t sender_monotonic_us = 0;
|
||||
std::uint32_t sample_rate = 0;
|
||||
std::uint16_t frame_samples = 0;
|
||||
std::uint16_t payload_bytes = 0;
|
||||
std::uint8_t codec_id = 0;
|
||||
std::uint8_t sample_format = 0;
|
||||
std::uint8_t channels = 0;
|
||||
std::uint8_t channel_layout = 0;
|
||||
std::uint32_t header_crc32 = 0;
|
||||
std::uint32_t reserved = 0;
|
||||
};
|
||||
|
||||
std::optional<PacketHeader> parse_header(const std::uint8_t* data, std::size_t size) {
|
||||
if (size < kHeaderSize) {
|
||||
return std::nullopt;
|
||||
}
|
||||
if (!std::equal(kMagic.begin(), kMagic.end(), data)) {
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
PacketHeader header;
|
||||
header.version = data[4];
|
||||
header.header_len = data[5];
|
||||
header.packet_type = data[6];
|
||||
header.flags = data[7];
|
||||
header.sender_id = read_u16_be(data + 8);
|
||||
header.stream_id = read_u16_be(data + 10);
|
||||
header.session_id = read_u32_be(data + 12);
|
||||
header.sequence = read_u32_be(data + 16);
|
||||
header.capture_sample_index = read_u64_be(data + 20);
|
||||
header.sender_monotonic_us = read_u64_be(data + 28);
|
||||
header.sample_rate = read_u32_be(data + 36);
|
||||
header.frame_samples = read_u16_be(data + 40);
|
||||
header.payload_bytes = read_u16_be(data + 42);
|
||||
header.codec_id = data[44];
|
||||
header.sample_format = data[45];
|
||||
header.channels = data[46];
|
||||
header.channel_layout = data[47];
|
||||
header.header_crc32 = read_u32_be(data + 48);
|
||||
header.reserved = read_u32_be(data + 52);
|
||||
return header;
|
||||
}
|
||||
|
||||
class AudioRingBuffer {
|
||||
public:
|
||||
explicit AudioRingBuffer(std::size_t capacity_samples)
|
||||
: samples_(std::max<std::size_t>(capacity_samples, 1), 0) {}
|
||||
|
||||
void write(const std::int16_t* input, std::size_t count) {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
if (count >= samples_.size()) {
|
||||
input += count - samples_.size();
|
||||
count = samples_.size();
|
||||
read_pos_ = 0;
|
||||
write_pos_ = 0;
|
||||
filled_ = 0;
|
||||
++overflow_count_;
|
||||
}
|
||||
|
||||
const auto free_space = samples_.size() - filled_;
|
||||
if (count > free_space) {
|
||||
const auto drop_count = count - free_space;
|
||||
read_pos_ = (read_pos_ + drop_count) % samples_.size();
|
||||
filled_ -= drop_count;
|
||||
++overflow_count_;
|
||||
}
|
||||
|
||||
for (std::size_t i = 0; i < count; ++i) {
|
||||
samples_[write_pos_] = input[i];
|
||||
write_pos_ = (write_pos_ + 1) % samples_.size();
|
||||
}
|
||||
filled_ += count;
|
||||
}
|
||||
|
||||
void read(std::int16_t* output, std::size_t count) {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
const auto available = std::min(count, filled_);
|
||||
for (std::size_t i = 0; i < available; ++i) {
|
||||
output[i] = samples_[read_pos_];
|
||||
read_pos_ = (read_pos_ + 1) % samples_.size();
|
||||
}
|
||||
filled_ -= available;
|
||||
|
||||
if (available < count) {
|
||||
std::fill(output + available, output + count, 0);
|
||||
++underflow_count_;
|
||||
}
|
||||
}
|
||||
|
||||
std::size_t filled() const {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
return filled_;
|
||||
}
|
||||
|
||||
std::uint64_t overflow_count() const {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
return overflow_count_;
|
||||
}
|
||||
|
||||
std::uint64_t underflow_count() const {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
return underflow_count_;
|
||||
}
|
||||
|
||||
private:
|
||||
mutable std::mutex mutex_;
|
||||
std::vector<std::int16_t> samples_;
|
||||
std::size_t read_pos_ = 0;
|
||||
std::size_t write_pos_ = 0;
|
||||
std::size_t filled_ = 0;
|
||||
std::uint64_t overflow_count_ = 0;
|
||||
std::uint64_t underflow_count_ = 0;
|
||||
};
|
||||
|
||||
struct AudioState {
|
||||
AudioRingBuffer* buffer = nullptr;
|
||||
std::uint16_t channels = 1;
|
||||
};
|
||||
|
||||
void audio_callback(ma_device* device, void* output, const void*, ma_uint32 frame_count) {
|
||||
auto* state = static_cast<AudioState*>(device->pUserData);
|
||||
auto* out = static_cast<std::int16_t*>(output);
|
||||
if (state == nullptr || state->buffer == nullptr) {
|
||||
const auto channels = device != nullptr ? device->playback.channels : 1;
|
||||
std::fill(out, out + (static_cast<std::size_t>(frame_count) * channels), 0);
|
||||
return;
|
||||
}
|
||||
state->buffer->read(out, static_cast<std::size_t>(frame_count) * state->channels);
|
||||
}
|
||||
|
||||
class AudioDevice {
|
||||
public:
|
||||
AudioDevice(AudioRingBuffer& buffer, const Config& config) : state_{&buffer, config.channels} {
|
||||
ma_device_config device_config = ma_device_config_init(ma_device_type_playback);
|
||||
device_config.playback.format = ma_format_s16;
|
||||
device_config.playback.channels = config.channels;
|
||||
device_config.sampleRate = config.sample_rate;
|
||||
device_config.dataCallback = audio_callback;
|
||||
device_config.pUserData = &state_;
|
||||
|
||||
const ma_result init_result = ma_device_init(nullptr, &device_config, &device_);
|
||||
if (init_result != MA_SUCCESS) {
|
||||
throw std::runtime_error("ma_device_init failed: " + std::to_string(init_result));
|
||||
}
|
||||
|
||||
initialized_ = true;
|
||||
const ma_result start_result = ma_device_start(&device_);
|
||||
if (start_result != MA_SUCCESS) {
|
||||
ma_device_uninit(&device_);
|
||||
initialized_ = false;
|
||||
throw std::runtime_error("ma_device_start failed: " + std::to_string(start_result));
|
||||
}
|
||||
}
|
||||
|
||||
~AudioDevice() {
|
||||
if (initialized_) {
|
||||
ma_device_uninit(&device_);
|
||||
}
|
||||
}
|
||||
|
||||
AudioDevice(const AudioDevice&) = delete;
|
||||
AudioDevice& operator=(const AudioDevice&) = delete;
|
||||
|
||||
private:
|
||||
ma_device device_{};
|
||||
AudioState state_{};
|
||||
bool initialized_ = false;
|
||||
};
|
||||
|
||||
struct SenderStats {
|
||||
bool initialized = false;
|
||||
std::uint32_t session_id = 0;
|
||||
std::uint32_t expected_sequence = 0;
|
||||
std::uint64_t packets = 0;
|
||||
std::uint64_t dropped = 0;
|
||||
std::uint64_t out_of_order = 0;
|
||||
};
|
||||
|
||||
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) {
|
||||
return false;
|
||||
}
|
||||
if (header.sender_id == 0 || header.codec_id != kCodecPcmS16Le ||
|
||||
header.sample_format != kSampleFormatS16Le) {
|
||||
return false;
|
||||
}
|
||||
if (header.sample_rate != config.sample_rate || header.channels != config.channels) {
|
||||
return false;
|
||||
}
|
||||
if (header.payload_bytes == 0 || kHeaderSize + header.payload_bytes != received_size) {
|
||||
return false;
|
||||
}
|
||||
const auto bytes_per_frame = static_cast<std::uint32_t>(header.channels) * sizeof(std::int16_t);
|
||||
if (bytes_per_frame == 0 || header.payload_bytes % bytes_per_frame != 0) {
|
||||
return false;
|
||||
}
|
||||
const auto expected_frame_samples =
|
||||
static_cast<std::uint32_t>(config.sample_rate) * config.frame_ms / 1000;
|
||||
return header.frame_samples == header.payload_bytes / bytes_per_frame &&
|
||||
header.frame_samples == expected_frame_samples;
|
||||
}
|
||||
|
||||
void update_stats(SenderStats& stats, const PacketHeader& header) {
|
||||
if (!stats.initialized || stats.session_id != header.session_id) {
|
||||
stats.initialized = true;
|
||||
stats.session_id = header.session_id;
|
||||
stats.expected_sequence = header.sequence + 1;
|
||||
stats.packets = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (header.sequence == stats.expected_sequence) {
|
||||
stats.expected_sequence = header.sequence + 1;
|
||||
} else if (header.sequence > stats.expected_sequence) {
|
||||
stats.dropped += header.sequence - stats.expected_sequence;
|
||||
stats.expected_sequence = header.sequence + 1;
|
||||
} else {
|
||||
++stats.out_of_order;
|
||||
}
|
||||
|
||||
++stats.packets;
|
||||
}
|
||||
|
||||
std::vector<std::int16_t> decode_pcm_s16le(const std::uint8_t* payload, std::size_t payload_bytes) {
|
||||
std::vector<std::int16_t> pcm(payload_bytes / sizeof(std::int16_t));
|
||||
for (std::size_t i = 0; i < pcm.size(); ++i) {
|
||||
const std::uint16_t lo = payload[i * 2];
|
||||
const std::uint16_t hi = payload[i * 2 + 1];
|
||||
pcm[i] = static_cast<std::int16_t>((hi << 8) | lo);
|
||||
}
|
||||
return pcm;
|
||||
}
|
||||
|
||||
int make_udp_socket(std::uint16_t listen_port) {
|
||||
const int fd = ::socket(AF_INET, SOCK_DGRAM, 0);
|
||||
if (fd < 0) {
|
||||
throw std::runtime_error("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("setsockopt(SO_REUSEADDR) failed: " + std::string(std::strerror(errno)));
|
||||
}
|
||||
|
||||
sockaddr_in address{};
|
||||
address.sin_family = AF_INET;
|
||||
address.sin_addr.s_addr = htonl(INADDR_ANY);
|
||||
address.sin_port = htons(listen_port);
|
||||
|
||||
if (::bind(fd, reinterpret_cast<sockaddr*>(&address), sizeof(address)) != 0) {
|
||||
::close(fd);
|
||||
throw std::runtime_error("bind failed on UDP port " + std::to_string(listen_port) + ": " +
|
||||
std::string(std::strerror(errno)));
|
||||
}
|
||||
|
||||
return fd;
|
||||
}
|
||||
|
||||
class FileDescriptor {
|
||||
public:
|
||||
explicit FileDescriptor(int fd) : fd_(fd) {}
|
||||
~FileDescriptor() {
|
||||
if (fd_ >= 0) {
|
||||
::close(fd_);
|
||||
}
|
||||
}
|
||||
|
||||
int get() const { return fd_; }
|
||||
|
||||
FileDescriptor(const FileDescriptor&) = delete;
|
||||
FileDescriptor& operator=(const FileDescriptor&) = delete;
|
||||
|
||||
private:
|
||||
int fd_ = -1;
|
||||
};
|
||||
|
||||
void receive_loop(const Config& config, AudioRingBuffer& audio_buffer) {
|
||||
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';
|
||||
if (active_sender_id == 0) {
|
||||
std::cout << "Accepting the first valid sender_id as the active sender\n";
|
||||
} else {
|
||||
std::cout << "Accepting only sender_id " << active_sender_id << '\n';
|
||||
}
|
||||
|
||||
while (g_running.load()) {
|
||||
sockaddr_in peer{};
|
||||
socklen_t peer_len = sizeof(peer);
|
||||
const auto received = ::recvfrom(socket.get(), packet.data(), packet.size(), 0,
|
||||
reinterpret_cast<sockaddr*>(&peer), &peer_len);
|
||||
if (received < 0) {
|
||||
if (errno == EINTR) {
|
||||
continue;
|
||||
}
|
||||
throw std::runtime_error("recvfrom failed: " + std::string(std::strerror(errno)));
|
||||
}
|
||||
|
||||
const auto received_size = static_cast<std::size_t>(received);
|
||||
const auto header = parse_header(packet.data(), received_size);
|
||||
if (!header || !validate_header(*header, config, received_size)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (active_sender_id == 0) {
|
||||
active_sender_id = header->sender_id;
|
||||
std::cout << "Active sender_id selected: " << active_sender_id << '\n';
|
||||
}
|
||||
|
||||
if (header->sender_id != active_sender_id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
auto& stats = stats_by_sender[header->sender_id];
|
||||
update_stats(stats, *header);
|
||||
|
||||
const auto* payload = packet.data() + header->header_len;
|
||||
auto pcm = decode_pcm_s16le(payload, header->payload_bytes);
|
||||
audio_buffer.write(pcm.data(), pcm.size());
|
||||
|
||||
if (stats.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
|
||||
<< " buffered_samples=" << audio_buffer.filled()
|
||||
<< " overflows=" << audio_buffer.overflow_count()
|
||||
<< " underflows=" << audio_buffer.underflow_count()
|
||||
<< '\n';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
std::signal(SIGINT, handle_signal);
|
||||
std::signal(SIGTERM, handle_signal);
|
||||
|
||||
try {
|
||||
Config config;
|
||||
const auto config_path = choose_config_path(argc, argv, config);
|
||||
if (config.channels != 1) {
|
||||
throw std::runtime_error("Initial server implementation supports mono audio only");
|
||||
}
|
||||
if (config.audio_output != "default") {
|
||||
std::cerr << "audio_output selection is not implemented yet; using default output\n";
|
||||
}
|
||||
|
||||
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::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);
|
||||
} catch (const std::exception& error) {
|
||||
std::cerr << "mic_server error: " << error.what() << '\n';
|
||||
return 1;
|
||||
}
|
||||
|
||||
std::cout << "Server stopped\n";
|
||||
return 0;
|
||||
}
|
||||
Loading…
Reference in New Issue