From be2e72b3aa7b8fb90f6f6b1d24554f2f0c822675 Mon Sep 17 00:00:00 2001 From: pandoli365 Date: Tue, 2 Jun 2026 17:22:23 +0900 Subject: [PATCH] =?UTF-8?q?=EA=B0=84=EB=8B=A8=ED=95=9C=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EB=B0=8F=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .DS_Store | Bin 0 -> 6148 bytes client/README.md | 21 ++ client/client.conf.example | 6 + client/src/main.cpp | 213 +++++++++++++- docs/02-client-direction.md | 30 +- docs/03-server-direction.md | 43 ++- plans/5ee51f9b-4ba2-4474-8d6b-b7ad3306857b.md | 79 ++++++ plans/7aaa0cce-3da2-4ae4-8eda-c956e96c5af3.md | 267 ++++++++++++++++++ plans/README.md | 24 +- plans/bbaba3dd-0931-47bd-92af-5c38892f9040.md | 47 +++ server/README.md | 15 +- 11 files changed, 727 insertions(+), 18 deletions(-) create mode 100644 .DS_Store create mode 100644 plans/5ee51f9b-4ba2-4474-8d6b-b7ad3306857b.md create mode 100644 plans/7aaa0cce-3da2-4ae4-8eda-c956e96c5af3.md create mode 100644 plans/bbaba3dd-0931-47bd-92af-5c38892f9040.md diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..6c1daaa8066faec4ec4f255a724531e28a19c7ac GIT binary patch literal 6148 zcmeHK&2G~`5T142#BPO@1C@GQ!8K}|7Ex8bxNdT&1RQDvhk}A#M_}Q4Bim^XQ6!(? z#-niJNq7KW1ZH-(lI_q-4+up&((JcC-|Wwq?5-C8m_LgL06hS3&?4EnA{EzBDxwv(sW1u{1^zb$XzzC58d6AL0?qwPhk}p3_gk8rx(zW1=={j9!+j$>+67!%8%UR)v`4yF33z0^5`7nBuOy=X><8zUfVUkU9 z8IZ&iq`Z5ZWU-k0VwS~)j15$S<2vrRx4T#z4o>>~XmGsj^To;WpwFKjJzp+eXXoku z>E-xydXtGSID0gPC#bYX8t>pMW@;0>P6N9O6tmm5NX{;2gaA0y|>_?Bx^b3W_(XpOOIxtV6 zNsR(Vfu;f*s@S9Ze{}!*zZqmoMggP1S}DNnVKDS@N&0SGSsdN99Q^}28SyHGG6WNS i9m@<|#k=Uzu#Hm&(bHHd#2Pg7As}TiiBaI6D)0vr2A+8U literal 0 HcmV?d00001 diff --git a/client/README.md b/client/README.md index 32009a7..df1977c 100644 --- a/client/README.md +++ b/client/README.md @@ -9,6 +9,7 @@ Orange Pi Zero 2W에서 실행할 USB 마이크 송신기입니다. - C++17 - ALSA 기반 캡처 - UDP 송신 +- 로컬 WAV 백업 녹음 - 단일 송신기 - 무압축 signed 16-bit little endian PCM - 설정 파일 기반 Mac 수신기 주소 지정 @@ -49,12 +50,16 @@ alsa_device = default sample_rate = 48000 channels = 1 frame_ms = 20 +record_enabled = true +record_dir = recordings ``` `server_host`는 Mac 수신기의 IP 주소로 바꿉니다. 초기 버전은 단일 포트 `4860`을 사용합니다. 이후 여러 포트가 필요해지면 `4800-5000` 범위 안에서 배정하는 방향으로 둡니다. +`record_enabled`의 기본값은 `true`입니다. 저장 위치는 실행 위치 기준 `recordings/`이며, 클라이언트가 실행될 때마다 새 WAV 파일을 만듭니다. + ## 실행 ```bash @@ -63,6 +68,22 @@ frame_ms = 20 종료는 `Ctrl+C`로 합니다. +## 로컬 녹음 + +클라이언트는 Wi-Fi UDP 송신과 동시에 로컬 WAV 파일을 저장합니다. + +파일명은 녹음 시작 일자와 실행 시각을 기준으로 자동 생성됩니다. + +```text +recordings/mic-client-20260602-165000.wav +``` + +파일 분할은 하지 않습니다. 클라이언트가 재부팅되거나 재실행되면 새 파일을 만듭니다. + +녹음 파일 생성, 디렉터리 생성, 파일 쓰기, 종료 시 WAV 헤더 갱신에 실패해도 클라이언트는 종료되지 않습니다. 이 경우 경고를 출력하고 로컬 저장 없이 Wi-Fi 송신을 계속합니다. + +저장 용량은 `48 kHz / 16-bit / mono` 기준으로 1시간에 약 330 MB입니다. + ## 마이크 확인 Orange Pi에서 USB 마이크 인식 여부를 먼저 확인합니다. diff --git a/client/client.conf.example b/client/client.conf.example index 3006587..5051e42 100644 --- a/client/client.conf.example +++ b/client/client.conf.example @@ -10,3 +10,9 @@ alsa_device = default sample_rate = 48000 channels = 1 frame_ms = 20 + +# Local backup recording. +# Recording is enabled by default. If file creation or writing fails, +# the client keeps streaming over Wi-Fi without local recording. +record_enabled = true +record_dir = recordings diff --git a/client/src/main.cpp b/client/src/main.cpp index 151a9be..162df5c 100644 --- a/client/src/main.cpp +++ b/client/src/main.cpp @@ -6,12 +6,18 @@ #include #include +#include #include #include #include +#include #include +#include #include +#include #include +#include +#include #include #include #include @@ -33,6 +39,8 @@ struct Config { unsigned int sample_rate = 48000; unsigned int channels = 1; unsigned int frame_ms = 20; + bool record_enabled = true; + std::string record_dir = "recordings"; }; void handle_signal(int) { @@ -69,6 +77,23 @@ unsigned int parse_unsigned(const std::string& key, const std::string& value) { } } +bool parse_bool(const std::string& key, const std::string& value) { + std::string normalized; + normalized.reserve(value.size()); + for (const char character : value) { + normalized.push_back(static_cast(std::tolower(static_cast(character)))); + } + + if (normalized == "true" || normalized == "yes" || normalized == "on" || normalized == "1") { + return true; + } + if (normalized == "false" || normalized == "no" || normalized == "off" || normalized == "0") { + return false; + } + + throw std::runtime_error("invalid boolean 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; @@ -86,6 +111,10 @@ void apply_config_value(Config& config, const std::string& key, const std::strin config.channels = parse_unsigned(key, value); } else if (key == "frame_ms") { config.frame_ms = parse_unsigned(key, value); + } else if (key == "record_enabled") { + config.record_enabled = parse_bool(key, value); + } else if (key == "record_dir") { + config.record_dir = value; } else { throw std::runtime_error("unknown config key: `" + key + "`"); } @@ -145,6 +174,9 @@ void validate_config(const Config& config) { if (config.frame_ms == 0 || config.frame_ms > 200) { throw std::runtime_error("frame_ms must be between 1 and 200"); } + if (config.record_enabled && config.record_dir.empty()) { + throw std::runtime_error("record_dir must not be empty when record_enabled is true"); + } } void print_usage(const char* executable) { @@ -159,6 +191,9 @@ void print_usage(const char* executable) { << " --sample-rate RATE Override sample rate\n" << " --channels COUNT Override channel count\n" << " --frame-ms MS Override frame duration\n" + << " --record-enabled BOOL\n" + << " Enable local WAV recording. Default: true\n" + << " --record-dir DIR Override local recording directory\n" << " --help Show this help\n"; } @@ -217,6 +252,10 @@ Config parse_args(int argc, char* argv[]) { 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 == "--record-enabled") { + config.record_enabled = parse_bool("record_enabled", require_value(arg)); + } else if (arg == "--record-dir") { + config.record_dir = require_value(arg); } else if (arg == "--help") { print_usage(argv[0]); std::exit(0); @@ -248,6 +287,24 @@ void write_be64(std::vector& data, std::size_t offset, std::uint64 } } +void write_le16(std::ostream& output, std::uint16_t value) { + const char bytes[] = { + static_cast(value & 0xffU), + static_cast((value >> 8U) & 0xffU), + }; + output.write(bytes, sizeof(bytes)); +} + +void write_le32(std::ostream& output, std::uint32_t value) { + const char bytes[] = { + static_cast(value & 0xffU), + static_cast((value >> 8U) & 0xffU), + static_cast((value >> 16U) & 0xffU), + static_cast((value >> 24U) & 0xffU), + }; + output.write(bytes, sizeof(bytes)); +} + std::vector make_packet( std::uint64_t sequence, std::uint64_t timestamp_frames, @@ -430,6 +487,155 @@ private: std::size_t frames_per_packet_ = 0; }; +class WavRecorder { +public: + explicit WavRecorder(const Config& config) + : config_(config) { + if (!config.record_enabled) { + std::cerr << "recording disabled by config\n"; + return; + } + + const std::filesystem::path directory(config.record_dir); + std::error_code error; + std::filesystem::create_directories(directory, error); + if (error) { + disable("failed to create recording directory `" + directory.string() + "`: " + error.message()); + return; + } + + const std::filesystem::path path = make_recording_path(directory); + output_.open(path, std::ios::binary | std::ios::out); + if (!output_) { + disable("failed to open recording file `" + path.string() + "`"); + return; + } + + path_ = path.string(); + write_header(config, 0); + if (!output_) { + disable("failed to write WAV header to `" + path_ + "`"); + return; + } + + active_ = true; + std::cout << "recording: " << path_ << "\n"; + } + + WavRecorder(const WavRecorder&) = delete; + WavRecorder& operator=(const WavRecorder&) = delete; + + ~WavRecorder() { + finalize(); + } + + void write_audio(const std::vector& audio_buffer, std::size_t bytes) { + if (!active_) { + return; + } + if (bytes > audio_buffer.size()) { + disable("recording buffer size mismatch"); + return; + } + + output_.write(reinterpret_cast(audio_buffer.data()), static_cast(bytes)); + if (!output_) { + disable("failed to write audio to `" + path_ + "`; Wi-Fi streaming will continue"); + return; + } + + data_bytes_ += bytes; + } + +private: + std::filesystem::path make_recording_path(const std::filesystem::path& directory) const { + const auto now = std::chrono::system_clock::now(); + const std::time_t now_time = std::chrono::system_clock::to_time_t(now); + std::tm local_time{}; + localtime_r(&now_time, &local_time); + + std::ostringstream base_name; + base_name << "mic-client-" << std::put_time(&local_time, "%Y%m%d-%H%M%S"); + + for (unsigned int suffix = 0; suffix < 1000; ++suffix) { + std::ostringstream filename; + filename << base_name.str(); + if (suffix > 0) { + filename << "-" << suffix; + } + filename << ".wav"; + + std::error_code error; + std::filesystem::path candidate = directory / filename.str(); + if (!std::filesystem::exists(candidate, error)) { + return candidate; + } + } + + return directory / (base_name.str() + "-overflow.wav"); + } + + void write_header(const Config& config, std::uint64_t data_bytes) { + const std::uint32_t wav_data_bytes = clamp_wav_size(data_bytes); + const std::uint32_t riff_size = wav_data_bytes > std::numeric_limits::max() - 36U + ? std::numeric_limits::max() + : wav_data_bytes + 36U; + const std::uint16_t block_align = static_cast(config.channels * kBytesPerSample); + const std::uint32_t byte_rate = config.sample_rate * block_align; + + output_.write("RIFF", 4); + write_le32(output_, riff_size); + output_.write("WAVE", 4); + output_.write("fmt ", 4); + write_le32(output_, 16); + write_le16(output_, 1); + write_le16(output_, static_cast(config.channels)); + write_le32(output_, config.sample_rate); + write_le32(output_, byte_rate); + write_le16(output_, block_align); + write_le16(output_, kBitsPerSample); + output_.write("data", 4); + write_le32(output_, wav_data_bytes); + } + + std::uint32_t clamp_wav_size(std::uint64_t data_bytes) const { + if (data_bytes > std::numeric_limits::max()) { + return std::numeric_limits::max(); + } + return static_cast(data_bytes); + } + + void finalize() { + if (!active_) { + return; + } + + output_.seekp(0, std::ios::beg); + if (output_) { + write_header(config_, data_bytes_); + } + if (!output_) { + std::cerr << "warning: failed to finalize WAV header for `" << path_ << "`\n"; + } + output_.close(); + active_ = false; + } + + void disable(const std::string& message) { + std::cerr << "warning: " << message << "\n"; + active_ = false; + if (output_.is_open()) { + output_.close(); + } + } + + Config config_{}; + std::ofstream output_; + std::string path_; + std::uint64_t data_bytes_ = 0; + bool active_ = false; +}; + void print_config(const Config& config) { std::cout << "mic-client\n" @@ -437,7 +643,9 @@ void print_config(const Config& config) { << " alsa_device: " << config.alsa_device << "\n" << " sample_rate: " << config.sample_rate << "\n" << " channels: " << config.channels << "\n" - << " frame_ms: " << config.frame_ms << "\n"; + << " frame_ms: " << config.frame_ms << "\n" + << " record: " << (config.record_enabled ? "true" : "false") << "\n" + << " record_dir: " << config.record_dir << "\n"; } } // namespace @@ -452,6 +660,7 @@ int main(int argc, char* argv[]) { UdpSender sender(config.server_host, config.server_port); AlsaCapture capture(config); + WavRecorder recorder(config); std::vector audio_buffer = capture.make_buffer(); std::uint64_t sequence = 0; @@ -467,6 +676,8 @@ int main(int argc, char* argv[]) { const std::vector packet = make_packet(sequence, timestamp_frames, config, static_cast(frames_read), audio_buffer); sender.send_packet(packet); + const std::size_t payload_bytes = frames_read * config.channels * kBytesPerSample; + recorder.write_audio(audio_buffer, payload_bytes); ++sequence; timestamp_frames += frames_read; diff --git a/docs/02-client-direction.md b/docs/02-client-direction.md index c99ef4d..e08f7cf 100644 --- a/docs/02-client-direction.md +++ b/docs/02-client-direction.md @@ -11,6 +11,7 @@ - USB 마이크를 ALSA 오디오 입력 장치로 인식합니다. - 마이크에서 캡처한 오디오 프레임을 네트워크 패킷으로 나눕니다. - Wi-Fi 공유기를 통해 Mac 수신기/믹서로 오디오를 전송합니다. +- 통신 문제에 대비해 같은 캡처 오디오를 로컬 WAV 파일로 저장합니다. - 초기 버전에서는 송신기 1대만 검증합니다. ```text @@ -26,6 +27,7 @@ USB 마이크 -> Orange Pi Zero 2W 클라이언트 -> UDP/Wi-Fi -> Mac 수신기 - 클라이언트가 마이크 입력을 캡처합니다. - Mac 수신기가 클라이언트의 UDP 패킷을 받습니다. - Mac에서 실제 오디오가 들립니다. +- Orange Pi 내부 저장장치에 로컬 백업 녹음본이 남습니다. 지연 시간 최적화, 다중 송신기, 자동 검색, 압축, 동기화, UI는 초기 성공 이후에 다룹니다. @@ -114,6 +116,8 @@ alsa_device = default sample_rate = 48000 channels = 1 frame_ms = 20 +record_enabled = true +record_dir = recordings ``` 초기 버전의 `server_port`는 `4860`으로 정합니다. @@ -143,11 +147,34 @@ frame_ms = 20 현재 초기 클라이언트 구현은 `client/`에 있습니다. -- `client/src/main.cpp`: ALSA 캡처와 UDP 송신 +- `client/src/main.cpp`: ALSA 캡처, UDP 송신, 로컬 WAV 백업 녹음 - `client/CMakeLists.txt`: CMake 빌드 구성 - `client/client.conf.example`: 설정 파일 예시 - `client/README.md`: 빌드와 실행 절차 +## 로컬 백업 녹음 방향 + +클라이언트는 실행 중 Wi-Fi UDP 송신과 동시에 로컬 WAV 파일을 저장합니다. + +초기 기준은 다음과 같습니다. + +```text +record_enabled = true +record_dir = recordings +``` + +녹음 파일은 실행 위치 기준 `recordings/`에 생성합니다. 파일명은 녹음 시작 일자와 실행 시각을 사용합니다. + +예시: + +```text +recordings/mic-client-20260602-165000.wav +``` + +파일 분할은 하지 않습니다. 클라이언트가 재부팅 또는 재실행되면 새 파일을 만듭니다. + +로컬 녹음은 방송 중 통신 문제로 음성이 깨졌을 때 후작업에 사용할 백업입니다. 따라서 녹음 파일 생성, 디렉터리 생성, 파일 쓰기, 종료 시 WAV 헤더 갱신에 실패해도 클라이언트 프로그램은 종료하지 않습니다. 이 경우 경고를 출력하고 로컬 저장 없이 Wi-Fi 송신을 계속합니다. + ## 검증 순서 초기 검증은 다음 순서를 따릅니다. @@ -160,6 +187,7 @@ frame_ms = 20 6. Mac 수신기의 UDP 수신 확인 7. 클라이언트에서 단일 송신기 오디오 전송 확인 8. Mac에서 실제 오디오 출력 확인 +9. Orange Pi의 `recordings/`에 WAV 백업 파일 생성 확인 ## 다음 단계 diff --git a/docs/03-server-direction.md b/docs/03-server-direction.md index 43cc8e9..224a8cc 100644 --- a/docs/03-server-direction.md +++ b/docs/03-server-direction.md @@ -2,7 +2,7 @@ 이 문서는 macOS, Ubuntu, Rocky Linux 9, Windows WSL2에서 실행할 수신기/믹서 서버의 초기 방향을 정리합니다. -현재 단계의 목표는 복잡한 믹서 기능을 만드는 것이 아니라, Orange Pi Zero 2W 클라이언트에서 보낸 오디오가 서버 실행 장비에서 실제로 들리는 최소 경로를 확인하는 것입니다. +현재 단계의 목표는 복잡한 믹서 기능을 만드는 것이 아니라, Orange Pi Zero 2W 클라이언트에서 보낸 오디오가 서버 실행 장비에서 실제로 들리는 최소 경로를 확인하고, 이후 2대에서 16대까지 확장할 수 있는 기반을 잡는 것입니다. ```text Orange Pi Zero 2W 클라이언트 -> UDP/Wi-Fi -> 서버 -> 기본 오디오 출력 @@ -15,8 +15,9 @@ Orange Pi Zero 2W 클라이언트 -> UDP/Wi-Fi -> 서버 -> 기본 오디오 출 - 고정 UDP 포트에서 클라이언트의 오디오 패킷을 수신합니다. - 수신 패킷의 순서와 기본 메타데이터를 확인합니다. - PCM 오디오 payload를 실행 OS의 오디오 출력 장치로 재생합니다. -- 초기 버전에서는 송신기 1대만 검증합니다. -- 이후 단계에서 다중 송신기 믹싱, 동기화, UI를 확장 후보로 검토합니다. +- 첫 bring-up에서는 송신기 1대로 오디오 경로를 확인합니다. +- 실제 목표 규모는 최소 2대, 최대 16대 송신기입니다. +- 이후 단계에서 2대 이상의 다중 송신기 믹싱, 동기화, UI를 확장 후보로 검토합니다. ## 초기 범위 @@ -28,7 +29,28 @@ Orange Pi Zero 2W 클라이언트 -> UDP/Wi-Fi -> 서버 -> 기본 오디오 출 - 수신한 PCM 프레임을 실행 OS의 기본 오디오 출력으로 재생합니다. - 패킷 수신 상태, sequence 변화, 간단한 손실 통계를 확인할 수 있습니다. -다중 송신기 믹싱, 송신기별 음량 조절, 자동 검색, 압축 코덱, 지터 버퍼 고도화, GUI는 초기 성공 이후에 다룹니다. +다중 송신기 믹싱은 1대 오디오 경로 확인 이후의 핵심 확장 목표입니다. 송신기별 음량 조절, 자동 검색, 압축 코덱, 지터 버퍼 고도화, GUI는 초기 성공 이후에 다룹니다. + +## 목표 송신기 규모 + +현재 목표 규모는 다음과 같습니다. + +```text +minimum_senders = 2 +maximum_senders = 16 +``` + +첫 구현과 첫 실기 검증은 문제 원인을 줄이기 위해 송신기 1대로 시작합니다. 이후 검증은 2대, 4대, 8대, 16대 순서로 늘리는 방향을 우선합니다. + +현재 무압축 `PCM_S16LE / mono / 48 kHz / 10 ms` 기준으로 송신기 1대는 UDP/IP 포함 대략 0.8 Mbps 이상을 사용합니다. 16대는 단순 계산으로 13 Mbps 이상입니다. 권장 네트워크는 2.4 GHz Wi-Fi로 두되, 실제 2.4 GHz 환경에서는 MAC 오버헤드, 주변 간섭, 재전송, airtime 경쟁, 공유기 성능 때문에 단순 대역폭 계산보다 훨씬 보수적으로 검증해야 합니다. + +따라서 16대 목표를 달성하려면 단순 대역폭보다 다음 항목을 중요하게 봅니다. + +- 2.4 GHz Wi-Fi 기준 검증 +- 송신기별 sequence 손실과 순서 역전 통계 확인 +- 송신기 수 증가에 따른 지터 버퍼 underflow와 overflow 관찰 +- 10 ms 프레임 유지가 어려울 경우 20 ms 프레임 후보 검토 +- 16대 검증 전 압축 코덱 도입 여부 재검토 ## 네트워크 수신 방향 @@ -40,7 +62,7 @@ Orange Pi Zero 2W 클라이언트 -> UDP/Wi-Fi -> 서버 -> 기본 오디오 출 listen_port = 4860 ``` -서버는 먼저 단일 클라이언트의 패킷만 받는 것을 목표로 합니다. 여러 클라이언트가 같은 포트로 들어오는 구조는 가능하지만, 초기 단계에서는 문제 원인을 줄이기 위해 하나의 송신기만 검증합니다. +서버는 먼저 단일 클라이언트의 패킷만 받아 오디오 경로를 확인합니다. 이후 같은 포트로 들어오는 여러 클라이언트를 `sender_id`, `session_id`, `sequence`, `capture_sample_index`로 구분해 최소 2대, 최대 16대까지 단계적으로 검증합니다. ## 포트 운영 방향 @@ -168,7 +190,7 @@ frame_ms = 10 audio_output = default ``` -송신기별 음량 조절 설정은 현재 범위에서 제외합니다. 초기 목표는 수신한 단일 송신기 오디오를 노트북 스피커로 출력하는 것입니다. +송신기별 음량 조절 설정은 현재 범위에서 제외합니다. 초기 목표는 수신한 단일 송신기 오디오를 노트북 스피커로 출력하는 것이고, 다음 목표는 2대 이상 송신기의 동시 수신과 믹싱입니다. 설정 파일 형식은 단순 key-value 텍스트 형식을 후보로 둡니다. 자동 검색을 추가하더라도 고정 포트와 고정 설정 방식은 유지합니다. @@ -179,10 +201,11 @@ audio_output = default 1. 지원 OS에서 서버가 UDP 포트를 열 수 있는지 확인합니다. 2. 같은 장비 또는 다른 장비에서 테스트 UDP 패킷이 수신되는지 확인합니다. 3. Orange Pi Zero 2W와 서버 실행 장비 사이의 Wi-Fi 통신을 확인합니다. -4. 클라이언트의 오디오 패킷이 서버에 도착하는지 확인합니다. -5. 서버가 수신한 PCM 프레임을 실행 OS의 오디오 출력으로 재생합니다. +4. 클라이언트 1대의 오디오 패킷이 서버에 도착하는지 확인합니다. +5. 서버가 수신한 단일 송신기 PCM 프레임을 실행 OS의 오디오 출력으로 재생합니다. 6. sequence 로그로 패킷 손실과 순서 역전을 관찰합니다. -7. 실제 소리 끊김과 지연을 기록합니다. +7. 송신기를 2대, 4대, 8대, 16대 순서로 늘리며 손실, 순서 역전, 지터 버퍼 underflow와 overflow를 기록합니다. +8. 실제 소리 끊김과 지연을 기록합니다. ## 현재 구현 단계 @@ -195,4 +218,4 @@ audio_output = default - 단일 활성 `sender_id`의 `PCM_S16LE / mono / 48 kHz / 10 ms` payload 재생 - sequence 기반 손실/순서 역전 로그 출력 -다음 작업은 클라이언트 송신 패킷을 서버 지정 헤더 v1에 맞추고, 실제 송신기에서 들어온 오디오가 실행 OS의 기본 출력 장치로 재생되는지 확인하는 것입니다. +다음 작업은 클라이언트 송신 패킷을 서버 지정 헤더 v1에 맞추고, 실제 송신기에서 들어온 오디오가 실행 OS의 기본 출력 장치로 재생되는지 확인하는 것입니다. 그 다음 서버 작업은 단일 활성 송신기 제한을 풀고, 2대에서 16대까지 송신기별 버퍼와 믹싱을 검증하는 방향으로 진행합니다. diff --git a/plans/5ee51f9b-4ba2-4474-8d6b-b7ad3306857b.md b/plans/5ee51f9b-4ba2-4474-8d6b-b7ad3306857b.md new file mode 100644 index 0000000..a8bf8af --- /dev/null +++ b/plans/5ee51f9b-4ba2-4474-8d6b-b7ad3306857b.md @@ -0,0 +1,79 @@ +# 작업 플랜 + +- UUID: `5ee51f9b-4ba2-4474-8d6b-b7ad3306857b` +- 작업자: `Codex` +- 생성 시각: `2026-06-02 16:50 KST` +- 요청 요약: `클라이언트 실행 중 Wi-Fi 송신과 별도 로컬 녹음 저장을 동시에 수행하는 기능 검토` + +## 할 일 + +- [x] 현재 요청 확인 +- [x] 기능 목표 정리 +- [x] server 작업영역을 수정하지 않는 범위 확인 +- [x] 구현 계획 사용자 확인 +- [x] 사용자 실행 지시 후 client 구현 +- [x] 빌드 또는 가능한 범위의 검증 +- [x] 완료 내용 정리 + +## 범위 + +- `client/` 작업만 대상으로 합니다. +- `server/` 작업영역은 수정하지 않습니다. +- 사용자가 계획을 확정하고 실행을 지시하기 전까지 C++ 코드는 수정하지 않습니다. + +## 기능 목표 + +- 클라이언트가 실행되면 USB 마이크 입력을 기존처럼 Wi-Fi UDP로 전송합니다. +- 동시에 같은 캡처 오디오를 Orange Pi 내부 저장장치에 녹음 파일로 저장합니다. +- 방송 중 네트워크 끊김, 패킷 손실, 음성 깨짐이 발생해도 로컬 녹음본으로 후작업할 수 있게 합니다. + +## 초기 구현 방향 후보 + +- 저장 형식은 무압축 WAV를 우선 검토합니다. +- 저장 데이터는 현재 송신 payload와 같은 signed 16-bit little endian PCM을 사용합니다. +- 파일명은 녹음을 시작한 일자와 실행 시각 기반으로 자동 생성하는 방식을 우선 검토합니다. +- 분할 저장은 하지 않고, 클라이언트 실행 1회마다 새 녹음 파일 1개를 만듭니다. +- 클라이언트가 재부팅 또는 재실행되면 새 파일을 생성합니다. +- 설정 파일에 로컬 녹음 활성화 여부와 저장 경로를 추가하는 방식을 우선 검토합니다. +- 디스크 쓰기 실패가 UDP 송신 전체를 즉시 망가뜨리지 않도록 오류 처리 방식을 정해야 합니다. + +## 확인할 설계 쟁점 + +- 저장 파일은 실행 1회 기준 단일 WAV로 둡니다. +- 저장 위치 기본값은 실행 위치 기준 `recordings/`로 둡니다. +- 디스크 용량 부족이나 파일 쓰기 실패가 발생해도 프로그램은 종료하지 않고 Wi-Fi 송신을 계속합니다. + +## 사용자 확정 내용 + +- 녹음 파일 분할은 하지 않습니다. +- 녹음을 시작한 일자 기준으로 파일을 만듭니다. +- 클라이언트가 재부팅 또는 재실행되면 신규 파일을 만듭니다. +- 로컬 녹음은 항상 켜진 상태를 기본값으로 둡니다. +- 녹음 제어는 이후 서버에서 수행할 예정입니다. +- 저장 위치 기본값은 `recordings/`로 둡니다. +- 어떠한 경우라도 녹음 파일 문제 때문에 클라이언트 프로그램이 종료되면 안 됩니다. +- 녹음 파일 생성 또는 쓰기에 실패하면 예외를 처리하고, 저장 없이 Wi-Fi 전송만 계속합니다. + +## 구현 기준 + +- `record_enabled` 기본값은 `true`로 둡니다. +- `record_dir` 기본값은 `recordings`로 둡니다. +- WAV 파일 열기, 디렉터리 생성, 파일 쓰기, 헤더 갱신 실패는 치명 오류로 처리하지 않습니다. +- 녹음 실패 상태는 로그로 알리고 UDP 송신 루프는 계속 유지합니다. +- 녹음이 비활성화되거나 실패한 상태에서도 ALSA 캡처와 UDP 송신은 계속 진행합니다. + +## 완료 요약 + +- `client/src/main.cpp`에 로컬 WAV 백업 녹음 기능을 추가했습니다. +- 클라이언트 실행 시 `recordings/mic-client-YYYYMMDD-HHMMSS.wav` 형식의 파일을 생성합니다. +- 파일 분할은 하지 않고, 실행 1회마다 파일 1개를 생성합니다. +- 녹음 파일 생성, 디렉터리 생성, 파일 쓰기, 종료 시 WAV 헤더 갱신 실패는 경고만 출력하고 UDP 송신은 계속합니다. +- `client/client.conf.example`에 `record_enabled = true`, `record_dir = recordings`를 추가했습니다. +- `client/README.md`와 `docs/02-client-direction.md`에 로컬 백업 녹음 동작을 문서화했습니다. +- `server/` 작업영역은 수정하지 않았습니다. + +## 검증 결과 + +- `cmake -S client -B client/build`를 실행했으나, 현재 macOS 로컬 환경에 ALSA 헤더와 라이브러리가 없어 구성 단계에서 실패했습니다. +- 실패 원인: `Could NOT find ALSA (missing: ALSA_LIBRARY ALSA_INCLUDE_DIR)` +- 실제 빌드 검증은 Orange Pi 또는 `libasound2-dev`가 설치된 Linux 환경에서 수행해야 합니다. diff --git a/plans/7aaa0cce-3da2-4ae4-8eda-c956e96c5af3.md b/plans/7aaa0cce-3da2-4ae4-8eda-c956e96c5af3.md new file mode 100644 index 0000000..99de1f4 --- /dev/null +++ b/plans/7aaa0cce-3da2-4ae4-8eda-c956e96c5af3.md @@ -0,0 +1,267 @@ +# 작업 플랜 + +- UUID: `7aaa0cce-3da2-4ae4-8eda-c956e96c5af3` +- 작업자: `Codex` +- 생성 시각: `2026-06-02 16:41` +- 요청 요약: `README와 필수 문서를 읽고 server 작업을 이어받을 준비` + +## 할 일 + +- [x] 현재 요청 확인 +- [x] README와 협업 규칙 확인 +- [x] 저장소 상태와 작업 범위 확인 +- [x] server 관련 문서와 이전 플랜 확인 +- [x] server 구현 상태와 로컬 빌드 준비 상태 확인 +- [x] 이어받을 작업 지점 정리 + +## 범위 + +- 이번 준비 작업은 `server/`, `docs/`, `plans/` 맥락 확인에 집중합니다. +- `client/` 작업영역은 수정하지 않습니다. +- 사용자가 서버 구현 계획을 확정하고 실행을 지시하기 전까지 코드 변경은 하지 않습니다. + +## 현재 확인 내용 + +- 현재 브랜치: `main` +- 작업 시작 시점의 Git 변경사항: `.DS_Store` untracked +- 이전 server 작업은 `Homebrew + CMake + miniaudio` 기준의 C++ 서버 구현을 추가한 상태입니다. +- 이전 기록상 빌드 검증은 도구 부재로 완료되지 않았습니다. +- 현재 환경에는 `/usr/bin/c++`가 있지만 `cmake` 명령은 PATH에서 확인되지 않았습니다. +- Homebrew는 `/opt/homebrew`에 있으나 `cmake`와 `miniaudio`의 실제 설치 파일은 `/opt/homebrew/Cellar`와 `/opt/homebrew/opt`에서 확인되지 않았습니다. +- 따라서 server 작업의 다음 지점은 Homebrew로 `cmake miniaudio`를 준비한 뒤 `server` 빌드 검증을 수행하는 것입니다. + +## 완료 요약 + +- 필수 문서, server 방향 문서, server README, 이전 작업 플랜을 확인했습니다. +- `client/` 작업영역은 수정하지 않았습니다. +- 이번 준비 과정에서 추가한 파일은 이 플랜 파일뿐입니다. +- 코드 수정 없이 server 이어받기 지점을 정리했습니다. + +## 추가 진행 기록 + +- 사용자 요청에 따라 Homebrew로 `cmake 4.3.3`과 `miniaudio 0.11.25`를 설치했습니다. +- 설치 및 Homebrew 자동 cleanup 기록을 `docs/99-mac.md`에 추가했습니다. +- `miniaudio.h`가 `/opt/homebrew/opt/miniaudio/include/miniaudio/miniaudio.h`에 있어 `MINIAUDIO_INCLUDE_DIR`를 명시해 server CMake configure를 수행했습니다. +- `cmake --build server/build`로 `server/build/mic_server` 빌드 성공을 확인했습니다. +- client 없이 `server/build/mic_server server/server.conf.example`를 실행해 오디오 장치 초기화와 UDP `4860` 바인딩을 확인했습니다. +- localhost에서 서버 지정 56바이트 `MIC1` 헤더와 960바이트 무음 PCM payload를 가진 테스트 UDP 패킷 100개를 보내 수신 처리를 확인했습니다. +- 서버 로그에서 `sender_id=1`, `packets=100`, `dropped=0`, `out_of_order=0`을 확인했습니다. +- `SIGTERM` 후 `recvfrom` 대기를 깨우기 위해 빈 UDP 패킷 하나를 보내 `Server stopped` 정상 종료를 확인했습니다. +- 사용자와 논의해 목표 송신기 규모를 최소 2대, 최대 16대로 조정했습니다. +- `docs/03-server-direction.md`와 `server/README.md`에 첫 bring-up은 1대, 이후 2대, 4대, 8대, 16대 순서로 검증하는 방향을 반영했습니다. +- 사용자 요청에 따라 권장 네트워크 기준을 5 GHz 우선에서 2.4 GHz Wi-Fi 기준 검증으로 변경했습니다. + +## 추가 요청 - `2026-06-02 17:13` + +- 요청 요약: `server 제어용 Web UI 도입 플랜 작성` + +### 할 일 + +- [x] 현재 요청 확인 +- [x] 기존 server 방향과 목표 송신기 규모 확인 +- [x] Web UI 목표와 범위 정리 +- [x] 구현 단계 후보 정리 +- [x] 사용자 승인 전 코드 변경 없음 확인 + +### Web UI 도입 목표 + +server는 C++ CLI 프로그램으로 유지하고, 브라우저에서 접속하는 Web UI를 통해 수신 상태와 기본 제어를 확인하는 방향을 우선합니다. + +초기 Web UI의 목표는 예쁜 화면보다 2대에서 16대 송신기 상태를 빠르게 확인하고, 실전 중 문제가 생겼을 때 어떤 송신기에서 문제가 나는지 식별하는 것입니다. + +### 대상 환경 + +- server 실행 환경: macOS, Ubuntu, Rocky Linux 9, Windows WSL2 +- 권장 네트워크: 2.4 GHz Wi-Fi 기준 검증 +- 송신기 목표 규모: 최소 2대, 최대 16대 +- 서버 구현 언어: C++ +- UI 접근 방식: 같은 장비 또는 같은 네트워크의 브라우저 + +### 권장 구조 후보 + +```text +C++ mic_server + - UDP audio receive + - sender별 상태 관리 + - jitter buffer + - mixer + - audio output + - stats collector + - HTTP/WebSocket control endpoint + +Web UI + - 정적 HTML/CSS/JS 또는 가벼운 프론트엔드 + - sender 상태 대시보드 + - sender별 제어 + - 실시간 통계 표시 +``` + +초기에는 별도 Web 서버 프로세스를 두기보다 `mic_server` 안에 작은 HTTP 서버를 붙이는 후보를 우선 검토합니다. 이유는 실행 절차를 단순하게 유지하고, server 내부 상태를 별도 IPC 없이 직접 노출할 수 있기 때문입니다. + +### C++ HTTP/WebSocket 라이브러리 후보 + +아래는 후보이며 아직 확정하지 않습니다. + +- `cpp-httplib`: 단일 헤더 방식에 가까워 초기 HTTP API와 정적 파일 제공에 단순합니다. WebSocket은 별도 고려가 필요합니다. +- `Boost.Beast`: HTTP/WebSocket 모두 가능하지만 초기 구현 복잡도가 큽니다. +- `uWebSockets`: WebSocket 성능이 좋지만 빌드와 의존성 관리가 더 무거울 수 있습니다. +- `CivetWeb` 또는 `Mongoose`: embedded web server 용도에 맞지만 라이선스와 배포 조건을 확인해야 합니다. + +초기 단계에서는 HTTP polling으로 시작하고, 실시간성이 부족할 때 WebSocket을 추가하는 방향이 가장 보수적입니다. + +### 초기 Web UI 기능 + +1단계 Web UI는 관찰 중심으로 둡니다. + +```text +- server 실행 상태 +- listen_port +- sample_rate / channels / frame_ms +- active sender count +- sender_id별 packets +- sender_id별 dropped +- sender_id별 out_of_order +- sender_id별 jitter buffer underflow / overflow +- sender_id별 last packet time +``` + +2단계에서 제어 기능을 추가합니다. + +```text +- sender별 mute +- sender별 gain +- sender별 display name +- 전체 output mute +- 통계 reset +- active sender 제거 또는 재선택 +``` + +3단계에서 믹서 기능을 확장합니다. + +```text +- 2대 이상 sender 동시 믹싱 상태 표시 +- sender별 간단한 level meter +- clipping indicator +- network health summary +- 2 / 4 / 8 / 16대 검증용 dashboard +``` + +### API 초안 후보 + +구체적인 URL과 payload는 구현 전에 확정합니다. 초기 후보는 다음과 같습니다. + +```text +GET /api/status +GET /api/senders +POST /api/senders/{sender_id}/mute +POST /api/senders/{sender_id}/gain +POST /api/stats/reset +GET /api/events 또는 WebSocket /ws +``` + +초기에는 `GET /api/status`, `GET /api/senders`만 구현해도 충분합니다. + +### UI 화면 구성 후보 + +첫 화면은 dashboard 하나로 시작합니다. + +```text +상단: + server 상태, 포트, 오디오 포맷, 활성 sender 수 + +중앙: + sender 1-16 목록 테이블 + sender_id, name, packets, dropped, out_of_order, underflow, overflow, last_seen + +하단: + 전체 로그 또는 최근 이벤트 +``` + +16대 목표이므로 카드형 화면보다 조밀한 테이블형 화면을 우선합니다. + +### 구현 단계 + +1. server 내부 상태 구조 정리 + - sender별 stats를 UI/API에서 읽을 수 있게 정리합니다. + - 오디오 스레드와 HTTP 스레드가 동시에 접근하므로 mutex 또는 snapshot 구조를 정합니다. + +2. HTTP status API 추가 + - `GET /api/status` + - `GET /api/senders` + - JSON 응답 후보를 정합니다. + +3. 정적 Web UI 제공 + - `server/web/` 또는 embedded asset 방식을 검토합니다. + - 초기 화면은 sender 상태 테이블 하나로 시작합니다. + +4. 실시간 갱신 + - 처음에는 500 ms 또는 1 s polling을 사용합니다. + - 필요하면 WebSocket으로 전환합니다. + +5. 제어 기능 추가 + - mute, gain, stats reset 같은 조작은 관찰 기능이 안정된 뒤 추가합니다. + +6. 16대 검증 화면 보강 + - sender별 문제 상태를 색상이나 간단한 상태값으로 표시합니다. + - dropped/out_of_order/underflow 증가 추이를 확인합니다. + +### 검증 방법 + +- server를 실행하고 브라우저에서 dashboard가 열리는지 확인합니다. +- client 없이 테스트 UDP 패킷으로 sender 1-16 상태가 표시되는지 확인합니다. +- sender별 sequence gap을 만든 테스트 패킷으로 dropped 증가가 표시되는지 확인합니다. +- out_of_order 패킷을 보내 UI 표시가 갱신되는지 확인합니다. +- server 오디오 수신/재생 루프가 Web UI 요청 때문에 끊기지 않는지 확인합니다. + +### 아직 확정하지 않을 항목 + +- HTTP/WebSocket 라이브러리 +- Web UI 파일 위치 +- 정적 파일을 디스크에서 읽을지 바이너리에 embedded할지 +- UDP 바인딩 실패 시 프로세스를 종료할지, Web UI만 살려 오류를 표시할지 + +### 사용자 확정 내용 + +- 접속 범위는 `localhost` 전용으로 제한합니다. +- 같은 Wi-Fi 안의 다른 장비에서 접속하는 기능은 현재 범위에 넣지 않습니다. +- 초기 Web UI는 상태 보기와 기본 제어를 함께 포함합니다. +- UI는 순수 HTML/CSS/JS로 구현합니다. +- Web 서버는 `mic_server` 안에 내장합니다. +- UDP 서버 또는 오디오 수신 경로에 문제가 생기면 Web UI에서도 알 수 있게 합니다. +- 별도 비밀번호나 인증은 두지 않습니다. +- 보안은 인증이 아니라 `127.0.0.1` 바인딩으로 외부 노출을 막는 방식으로 처리합니다. + +### 확정 후 Web UI 구현 기준 + +Web UI HTTP 서버는 기본적으로 `127.0.0.1`에만 바인딩합니다. 기본 후보 포트는 다음과 같습니다. + +```text +web_host = 127.0.0.1 +web_port = 4861 +``` + +`0.0.0.0` 또는 LAN IP 바인딩은 현재 범위에서 제외합니다. 나중에 원격 접속이 필요해지면 설정으로 명시적으로 켜는 방식만 후보로 둡니다. + +초기 Web UI에는 다음 제어를 포함합니다. + +```text +- 전체 output mute +- sender별 mute +- sender별 gain +- sender별 display name +- 통계 reset +``` + +UDP 서버와 오디오 수신 경로 문제는 다음 상태로 표시합니다. + +```text +- udp_state: starting / listening / error / stopped +- udp_error: bind 실패, recvfrom 실패 등 마지막 오류 메시지 +- audio_state: starting / running / error / stopped +- audio_error: 오디오 장치 초기화 또는 시작 실패 메시지 +- last_packet_time +- sender별 packets/sec +- sender별 dropped / out_of_order / underflow / overflow +``` + +server가 UDP 바인딩에 실패하면 현재처럼 프로세스를 바로 종료할지, Web UI만 살려서 오류를 보여줄지는 구현 전에 결정해야 합니다. 사용자가 Web UI에서 UDP 서버 문제를 보길 원하므로, Web UI를 먼저 띄우고 UDP 수신을 별도 상태로 관리하는 구조를 우선 검토합니다. diff --git a/plans/README.md b/plans/README.md index cb74d2e..2c4e8ac 100644 --- a/plans/README.md +++ b/plans/README.md @@ -2,13 +2,20 @@ 이 폴더는 AI가 작업을 진행하기 전에 자신의 작업 계획을 기록하는 곳입니다. -각 AI는 작업 단위마다 임시 고유 UUID를 만들고, 아래 형식의 파일을 생성합니다. +각 AI는 하나의 대화 또는 스레드마다 임시 고유 UUID를 하나만 만들고, 아래 형식의 파일을 생성합니다. ```text plans/{uuid}.md ``` -플랜 파일에는 자신이 할 일 목록을 작성하고, 작업을 순차적으로 진행하면서 상태를 계속 갱신합니다. +같은 대화 안에서 새 요청이 이어질 때는 새 `.md` 파일을 만들지 않습니다. 처음 만든 `plans/{uuid}.md` 파일 하나에 새 섹션을 추가하고, 그 안에서 할 일 목록과 진행 상태를 계속 갱신합니다. + +기본 원칙: + +- 현재 대화에서 이미 사용 중인 `plans/{uuid}.md`가 있으면 반드시 그 파일만 수정합니다. +- 기존 플랜 파일이 없을 때만 새 UUID를 생성합니다. +- 새 요청이 들어오면 같은 파일에 `## 추가 요청 - {YYYY-MM-DD HH:mm}` 섹션을 추가합니다. +- 같은 대화 안에서 여러 개의 `plans/{uuid}.md` 파일을 만들지 않습니다. 예시: @@ -28,6 +35,18 @@ plans/00000000-0000-0000-0000-000000000000.md ## 할 일 +- [ ] 현재 요청 확인 +- [ ] 관련 문서 확인 +- [ ] 작업 수행 +- [ ] 결과 검증 +- [ ] 완료 내용 정리 + +## 추가 요청 - `{YYYY-MM-DD HH:mm}` + +- 요청 요약: `{이어진 사용자 요청 요약}` + +### 할 일 + - [ ] 현재 요청 확인 - [ ] 관련 문서 확인 - [ ] 작업 수행 @@ -36,4 +55,3 @@ plans/00000000-0000-0000-0000-000000000000.md ``` 작업이 진행되면 완료된 항목을 `[x]`로 바꾸고, 필요한 메모를 같은 파일에 남깁니다. - diff --git a/plans/bbaba3dd-0931-47bd-92af-5c38892f9040.md b/plans/bbaba3dd-0931-47bd-92af-5c38892f9040.md new file mode 100644 index 0000000..3484a46 --- /dev/null +++ b/plans/bbaba3dd-0931-47bd-92af-5c38892f9040.md @@ -0,0 +1,47 @@ +# 작업 플랜 + +- UUID: `bbaba3dd-0931-47bd-92af-5c38892f9040` +- 작업자: `Codex` +- 생성 시각: `2026-06-02 16:41 KST` +- 요청 요약: `README와 필수 문서를 읽고 client 작업을 이어받을 준비` + +## 할 일 + +- [x] 현재 요청 확인 +- [x] `README.md` 확인 +- [x] `docs/00-ai-collaboration-rules.md` 확인 +- [x] Git 상태와 현재 브랜치 확인 +- [x] `client/` 관련 문서와 구현 상태 확인 +- [x] 이전 플랜에서 client 작업 흐름 확인 +- [x] server 작업영역을 수정하지 않는 범위 정리 +- [x] 이어받을 작업 지점 정리 + +## 범위 + +- 이번 준비 작업은 `client/`, `docs/`, `plans/` 맥락 확인에 집중합니다. +- 사용자의 지시에 따라 `server/` 작업영역은 수정하지 않습니다. +- 사용자가 계획을 확정하고 실행을 지시하기 전까지 C++ 코드, 빌드 시스템, 테스트 코드는 만들거나 수정하지 않습니다. + +## 현재 확인 내용 + +- 현재 브랜치: `main` +- 작업 시작 시점의 Git 변경사항: `.DS_Store` untracked +- `client/`에는 C++17 기반 ALSA 캡처와 UDP 송신 초기 구현이 있습니다. +- `client/src/main.cpp`는 설정 파일과 CLI 옵션을 읽고, ALSA에서 signed 16-bit little endian PCM을 캡처한 뒤 UDP 패킷으로 송신합니다. +- `client/CMakeLists.txt`, `client/client.conf.example`, `client/README.md`가 있습니다. +- 이전 client 플랜 기록상 로컬 환경에는 빌드 도구와 ALSA 개발 패키지가 없어 실제 컴파일 검증은 완료되지 않았습니다. +- 현재 client 패킷 형식은 40바이트 `MIC1` 헤더입니다. +- 최근 server 방향 기록에는 56바이트 헤더 v1과 `sender_id`, `session_id`, `capture_sample_index` 같은 추가 메타데이터가 언급되어 있어, 다음 client 작업에서는 서버와 맞출 패킷 포맷을 먼저 확정해야 합니다. + +## 이어받을 때 주의할 점 + +- `server/` 파일은 수정하지 않습니다. +- client 변경이 필요해도 먼저 계획을 제시하고 사용자 실행 지시를 받은 뒤 진행합니다. +- 서버와 패킷 포맷을 맞추는 작업은 client 쪽 변경만으로 가능한지 먼저 분리해서 판단합니다. +- 실제 빌드 검증은 Orange Pi 또는 ALSA 개발 패키지가 설치된 Linux 환경에서 수행하는 것이 우선입니다. + +## 완료 요약 + +- README, 필수 협업 규칙, client 방향성 문서, client 구현 파일, 이전 client 플랜을 확인했습니다. +- 현재 상태와 다음 작업 진입점을 정리했습니다. +- 이번 준비 작업에서는 `server/` 작업영역을 수정하지 않았습니다. diff --git a/server/README.md b/server/README.md index d75b5f8..f629a25 100644 --- a/server/README.md +++ b/server/README.md @@ -6,15 +6,16 @@ ## 초기 목표 -첫 서버의 목표는 Orange Pi Zero 2W 클라이언트가 보낸 UDP 오디오 패킷을 서버 실행 장비에서 받아 실제 소리로 재생하는 것입니다. +첫 서버의 목표는 Orange Pi Zero 2W 클라이언트가 보낸 UDP 오디오 패킷을 서버 실행 장비에서 받아 실제 소리로 재생하는 것입니다. 첫 bring-up은 송신기 1대로 진행하지만, 실제 목표 규모는 최소 2대에서 최대 16대입니다. 초기 성공 기준은 다음과 같습니다. - 지원 OS에서 지정된 UDP 포트를 엽니다. -- 단일 Orange Pi 클라이언트의 오디오 패킷을 받습니다. +- 단일 Orange Pi 클라이언트의 오디오 패킷을 먼저 받습니다. - 수신 패킷의 sequence와 payload 크기를 확인합니다. - PCM 16-bit mono 오디오를 실행 OS의 기본 출력 장치로 재생합니다. - 패킷 손실, 순서 역전, 끊김 여부를 로그로 관찰합니다. +- 이후 2대, 4대, 8대, 16대 순서로 송신기 수를 늘려 동시 수신과 믹싱을 검증합니다. - 송신기별 음량 조절은 초기 범위에서 제외합니다. ## 초기 후보 @@ -37,6 +38,14 @@ frame_ms = 10 마이크별 포트 분리는 별도 서버 프로세스로 디버깅하거나 트래픽을 강하게 분리해야 할 때만 후보로 둡니다. +## 목표 송신기 규모 + +현재 목표는 최소 2대, 최대 16대 송신기입니다. + +초기 무압축 포맷인 `48 kHz / 16-bit / mono / 10 ms` 기준으로 송신기 1대는 UDP/IP 포함 대략 0.8 Mbps 이상을 사용합니다. 16대는 단순 계산으로 13 Mbps 이상입니다. 권장 네트워크는 2.4 GHz Wi-Fi이며, 실제 환경에서는 MAC 오버헤드, 주변 간섭, 재전송, airtime 경쟁 때문에 단순 대역폭 계산보다 보수적으로 검증해야 합니다. + +따라서 다중 송신기 검증은 2.4 GHz Wi-Fi 기준으로 진행하고, 2대, 4대, 8대, 16대 순서로 패킷 손실, 순서 역전, 지터 버퍼 underflow와 overflow를 확인합니다. + ## 오디오 출력 라이브러리 후보 - miniaudio @@ -72,7 +81,7 @@ cmake --build build 서버는 UDP `4860`을 열고 첫 번째 유효한 `sender_id`를 활성 송신기로 선택합니다. `sender_id`를 고정하고 싶으면 `server.conf.example`의 `sender_id` 값을 1 이상의 값으로 지정합니다. -현재 구현은 단일 송신기의 `PCM_S16LE / mono / 48 kHz / 10 ms` payload를 기본 오디오 출력 장치로 재생합니다. 여러 송신기 동시 믹싱과 송신기별 음량 조절은 아직 구현하지 않습니다. +현재 구현은 단일 송신기의 `PCM_S16LE / mono / 48 kHz / 10 ms` payload를 기본 오디오 출력 장치로 재생합니다. 목표 규모는 2대에서 16대이지만, 여러 송신기 동시 믹싱과 송신기별 음량 조절은 아직 구현하지 않습니다. Windows에서는 네이티브 Windows 실행이 아니라 WSL2에서 실행합니다. WSL2에서 실제 소리를 들으려면 WSLg 또는 Linux 오디오 출력 경로가 동작해야 합니다.