From a5207214f4a278ed534c6f522b9b77c58ff2a2b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=84=9C?= Date: Tue, 2 Jun 2026 16:35:08 +0900 Subject: [PATCH] =?UTF-8?q?server,=20client=20=EA=B8=B0=EB=B3=B8=20?= =?UTF-8?q?=EC=9E=91=EC=97=85=20=EC=99=84=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 9 +- client/CMakeLists.txt | 18 + client/README.md | 96 +++ client/client.conf.example | 12 + client/src/main.cpp | 481 +++++++++++++++ docs/02-client-direction.md | 168 +++++ docs/03-server-direction.md | 198 ++++++ plans/4b3baabc-2767-4470-bdd9-ea310403f684.md | 116 ++++ plans/aef651b6-b741-429f-ac7e-215acb633181.md | 53 ++ plans/af182cf0-404d-42f5-93cc-83d54bb4fbe2.md | 56 ++ plans/b47aca02-f7bc-4df3-aba9-386f09d2e974.md | 97 +++ plans/da591ca2-cf11-4f3a-a071-be6fb6b23c26.md | 34 + server/CMakeLists.txt | 34 + server/README.md | 111 ++++ server/server.conf.example | 11 + server/src/main.cpp | 584 ++++++++++++++++++ 16 files changed, 2075 insertions(+), 3 deletions(-) create mode 100644 client/CMakeLists.txt create mode 100644 client/README.md create mode 100644 client/client.conf.example create mode 100644 client/src/main.cpp create mode 100644 docs/02-client-direction.md create mode 100644 docs/03-server-direction.md create mode 100644 plans/4b3baabc-2767-4470-bdd9-ea310403f684.md create mode 100644 plans/aef651b6-b741-429f-ac7e-215acb633181.md create mode 100644 plans/af182cf0-404d-42f5-93cc-83d54bb4fbe2.md create mode 100644 plans/b47aca02-f7bc-4df3-aba9-386f09d2e974.md create mode 100644 plans/da591ca2-cf11-4f3a-a071-be6fb6b23c26.md create mode 100644 server/CMakeLists.txt create mode 100644 server/README.md create mode 100644 server/server.conf.example create mode 100644 server/src/main.cpp diff --git a/README.md b/README.md index b8ed565..d160c3c 100644 --- a/README.md +++ b/README.md @@ -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`: 플랜 파일 작성 형식 diff --git a/client/CMakeLists.txt b/client/CMakeLists.txt new file mode 100644 index 0000000..92167f6 --- /dev/null +++ b/client/CMakeLists.txt @@ -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}) diff --git a/client/README.md b/client/README.md new file mode 100644 index 0000000..32009a7 --- /dev/null +++ b/client/README.md @@ -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를 재생하거나 저장하면 됩니다. diff --git a/client/client.conf.example b/client/client.conf.example new file mode 100644 index 0000000..3006587 --- /dev/null +++ b/client/client.conf.example @@ -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 diff --git a/client/src/main.cpp b/client/src/main.cpp new file mode 100644 index 0000000..151a9be --- /dev/null +++ b/client/src/main.cpp @@ -0,0 +1,481 @@ +#include + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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(value[begin])) != 0) { + ++begin; + } + + std::size_t end = value.size(); + while (end > begin && std::isspace(static_cast(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(UINT32_MAX)) { + throw std::out_of_range("value is too large"); + } + return static_cast(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(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(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& data, std::size_t offset, std::uint16_t value) { + data[offset] = static_cast((value >> 8U) & 0xffU); + data[offset + 1] = static_cast(value & 0xffU); +} + +void write_be32(std::vector& data, std::size_t offset, std::uint32_t value) { + data[offset] = static_cast((value >> 24U) & 0xffU); + data[offset + 1] = static_cast((value >> 16U) & 0xffU); + data[offset + 2] = static_cast((value >> 8U) & 0xffU); + data[offset + 3] = static_cast(value & 0xffU); +} + +void write_be64(std::vector& data, std::size_t offset, std::uint64_t value) { + for (int byte = 7; byte >= 0; --byte) { + data[offset + static_cast(7 - byte)] = + static_cast((value >> static_cast(byte * 8)) & 0xffU); + } +} + +std::vector make_packet( + std::uint64_t sequence, + std::uint64_t timestamp_frames, + const Config& config, + std::uint32_t frame_count, + const std::vector& audio_buffer +) { + const std::size_t payload_bytes = + static_cast(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 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(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(config.channels)); + write_be16(packet, 30, kBitsPerSample); + write_be32(packet, 32, frame_count); + write_be32(packet, 36, static_cast(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& packet) { + while (true) { + const ssize_t sent = ::sendto( + fd_, + packet.data(), + packet.size(), + 0, + reinterpret_cast(&address_), + address_length_ + ); + if (sent < 0) { + if (errno == EINTR) { + continue; + } + throw std::runtime_error("send failed: " + std::string(std::strerror(errno))); + } + + if (static_cast(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(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 make_buffer() const { + return std::vector(frames_per_packet_ * config_.channels * kBytesPerSample); + } + + std::size_t read_frames(std::vector& buffer) { + while (g_stop_requested == 0) { + const snd_pcm_sframes_t result = + ::snd_pcm_readi(handle_, buffer.data(), static_cast(frames_per_packet_)); + + if (result > 0) { + return static_cast(result); + } + + if (result == 0 || result == -EAGAIN) { + continue; + } + + const int recover_result = ::snd_pcm_recover(handle_, static_cast(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 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 packet = + make_packet(sequence, timestamp_frames, config, static_cast(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; + } +} diff --git a/docs/02-client-direction.md b/docs/02-client-direction.md new file mode 100644 index 0000000..c99ef4d --- /dev/null +++ b/docs/02-client-direction.md @@ -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를 재생할 수 있도록 맞추는 것입니다. diff --git a/docs/03-server-direction.md b/docs/03-server-direction.md new file mode 100644 index 0000000..43cc8e9 --- /dev/null +++ b/docs/03-server-direction.md @@ -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의 기본 출력 장치로 재생되는지 확인하는 것입니다. diff --git a/plans/4b3baabc-2767-4470-bdd9-ea310403f684.md b/plans/4b3baabc-2767-4470-bdd9-ea310403f684.md new file mode 100644 index 0000000..845a101 --- /dev/null +++ b/plans/4b3baabc-2767-4470-bdd9-ea310403f684.md @@ -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 송신 경로에 집중합니다. +- 자동 검색, 압축, 다중 송신기, 지연 시간 최적화는 이후 확장 후보로 남겼습니다. diff --git a/plans/aef651b6-b741-429f-ac7e-215acb633181.md b/plans/aef651b6-b741-429f-ac7e-215acb633181.md new file mode 100644 index 0000000..3ac750a --- /dev/null +++ b/plans/aef651b6-b741-429f-ac7e-215acb633181.md @@ -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 기준 표현으로 정리했습니다. +- 송신기별 음량 조절은 현재 범위에서 제외했습니다. diff --git a/plans/af182cf0-404d-42f5-93cc-83d54bb4fbe2.md b/plans/af182cf0-404d-42f5-93cc-83d54bb4fbe2.md new file mode 100644 index 0000000..7fb2d62 --- /dev/null +++ b/plans/af182cf0-404d-42f5-93cc-83d54bb4fbe2.md @@ -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가 준비되면 빌드 검증이 필요합니다. diff --git a/plans/b47aca02-f7bc-4df3-aba9-386f09d2e974.md b/plans/b47aca02-f7bc-4df3-aba9-386f09d2e974.md new file mode 100644 index 0000000..9eda461 --- /dev/null +++ b/plans/b47aca02-f7bc-4df3-aba9-386f09d2e974.md @@ -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` 우선 후보를 반영했습니다. +- 송신기별 음량 조절은 초기 범위에서 제외했습니다. +- 서버 구현 전에는 패킷 포맷, 오디오 출력 라이브러리, 빌드 방식, 검증 절차를 사용자가 승인해야 합니다. diff --git a/plans/da591ca2-cf11-4f3a-a071-be6fb6b23c26.md b/plans/da591ca2-cf11-4f3a-a071-be6fb6b23c26.md new file mode 100644 index 0000000..dfdf8f4 --- /dev/null +++ b/plans/da591ca2-cf11-4f3a-a071-be6fb6b23c26.md @@ -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` 상태임을 확인했습니다. diff --git a/server/CMakeLists.txt b/server/CMakeLists.txt new file mode 100644 index 0000000..8a8dfc8 --- /dev/null +++ b/server/CMakeLists.txt @@ -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() diff --git a/server/README.md b/server/README.md new file mode 100644 index 0000000..d75b5f8 --- /dev/null +++ b/server/README.md @@ -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 diff --git a/server/server.conf.example b/server/server.conf.example new file mode 100644 index 0000000..282fa92 --- /dev/null +++ b/server/server.conf.example @@ -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 diff --git a/server/src/main.cpp b/server/src/main.cpp new file mode 100644 index 0000000..8617be1 --- /dev/null +++ b/server/src/main.cpp @@ -0,0 +1,584 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +#define MINIAUDIO_IMPLEMENTATION +#include + +namespace { + +constexpr std::size_t kHeaderSize = 56; +constexpr std::array 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 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 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::max()) { + return std::nullopt; + } + return static_cast(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(*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(*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(*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(*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((static_cast(data[0]) << 8) | + static_cast(data[1])); +} + +std::uint32_t read_u32_be(const std::uint8_t* data) { + return (static_cast(data[0]) << 24) | + (static_cast(data[1]) << 16) | + (static_cast(data[2]) << 8) | + static_cast(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 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(capacity_samples, 1), 0) {} + + void write(const std::int16_t* input, std::size_t count) { + std::lock_guard 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 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 lock(mutex_); + return filled_; + } + + std::uint64_t overflow_count() const { + std::lock_guard lock(mutex_); + return overflow_count_; + } + + std::uint64_t underflow_count() const { + std::lock_guard lock(mutex_); + return underflow_count_; + } + +private: + mutable std::mutex mutex_; + std::vector 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(device->pUserData); + auto* out = static_cast(output); + if (state == nullptr || state->buffer == nullptr) { + const auto channels = device != nullptr ? device->playback.channels : 1; + std::fill(out, out + (static_cast(frame_count) * channels), 0); + return; + } + state->buffer->read(out, static_cast(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(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(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 decode_pcm_s16le(const std::uint8_t* payload, std::size_t payload_bytes) { + std::vector 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((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(&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 packet(65536); + std::map 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(&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(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(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; +}