server, client 기본 작업 완료

This commit is contained in:
김민서 2026-06-02 16:35:08 +09:00
parent 282d02cbc2
commit a5207214f4
16 changed files with 2075 additions and 3 deletions

View File

@ -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`: 플랜 파일 작성 형식

18
client/CMakeLists.txt Normal file
View File

@ -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})

96
client/README.md Normal file
View File

@ -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를 재생하거나 저장하면 됩니다.

View File

@ -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

481
client/src/main.cpp Normal file
View File

@ -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;
}
}

168
docs/02-client-direction.md Normal file
View File

@ -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를 재생할 수 있도록 맞추는 것입니다.

198
docs/03-server-direction.md Normal file
View File

@ -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의 기본 출력 장치로 재생되는지 확인하는 것입니다.

View File

@ -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 송신 경로에 집중합니다.
- 자동 검색, 압축, 다중 송신기, 지연 시간 최적화는 이후 확장 후보로 남겼습니다.

View File

@ -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 기준 표현으로 정리했습니다.
- 송신기별 음량 조절은 현재 범위에서 제외했습니다.

View File

@ -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가 준비되면 빌드 검증이 필요합니다.

View File

@ -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` 우선 후보를 반영했습니다.
- 송신기별 음량 조절은 초기 범위에서 제외했습니다.
- 서버 구현 전에는 패킷 포맷, 오디오 출력 라이브러리, 빌드 방식, 검증 절차를 사용자가 승인해야 합니다.

View File

@ -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` 상태임을 확인했습니다.

34
server/CMakeLists.txt Normal file
View File

@ -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()

111
server/README.md Normal file
View File

@ -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

View File

@ -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

584
server/src/main.cpp Normal file
View File

@ -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;
}