From 51a1b1b2c8767c2d9bba3c4ed0daa28727c3c0a5 Mon Sep 17 00:00:00 2001 From: itten Date: Thu, 25 Jun 2026 12:43:53 +0300 Subject: [PATCH 01/15] looks like i forgot about commits --- .vscode/c_cpp_properties.json | 17 +++ CMakeLists.txt | 7 ++ nodes/ndiin/CMakeLists.txt | 11 ++ nodes/ndiin/main.cpp | 192 ++++++++++++++++++++++++++++++++++ nodes/ndiout/CMakeLists.txt | 11 ++ nodes/ndiout/main.cpp | 132 +++++++++++++++++++++++ shared/NDIHelper.hpp | 72 +++++++++++++ studio-manager/main.cpp | 6 +- 8 files changed, 445 insertions(+), 3 deletions(-) create mode 100644 .vscode/c_cpp_properties.json create mode 100644 nodes/ndiin/CMakeLists.txt create mode 100644 nodes/ndiin/main.cpp create mode 100644 nodes/ndiout/CMakeLists.txt create mode 100644 nodes/ndiout/main.cpp create mode 100644 shared/NDIHelper.hpp diff --git a/.vscode/c_cpp_properties.json b/.vscode/c_cpp_properties.json new file mode 100644 index 0000000..fb5ba7c --- /dev/null +++ b/.vscode/c_cpp_properties.json @@ -0,0 +1,17 @@ +{ + "configurations": [ + { + "name": "Linux", + "includePath": [ + "${workspaceFolder}/**", + "${HOME}/SDK/NDI/include" + ], + "defines": [], + "compilerPath": "/usr/bin/clang", + "cStandard": "c17", + "cppStandard": "c++17", + "intelliSenseMode": "linux-clang-x64" + } + ], + "version": 4 +} \ No newline at end of file diff --git a/CMakeLists.txt b/CMakeLists.txt index 4087745..61d8ed0 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -80,4 +80,11 @@ target_link_libraries(dmf-shared INTERFACE mxl nlohmann_json::nlohmann_json) add_subdirectory(nodes/testpattern) add_subdirectory(nodes/fakesink) + +set(NDI_SDK_DIR "" CACHE PATH "Path to NDI SDK root") +if(NDI_SDK_DIR) + add_subdirectory(nodes/ndiout) + add_subdirectory(nodes/ndiin) +endif() + add_subdirectory(studio-manager) diff --git a/nodes/ndiin/CMakeLists.txt b/nodes/ndiin/CMakeLists.txt new file mode 100644 index 0000000..c449c31 --- /dev/null +++ b/nodes/ndiin/CMakeLists.txt @@ -0,0 +1,11 @@ +add_executable(dmf-node-ndiin main.cpp) +target_compile_features(dmf-node-ndiin PRIVATE cxx_std_20) +target_link_libraries(dmf-node-ndiin PRIVATE dmf-shared) +install(TARGETS dmf-node-ndiin RUNTIME DESTINATION bin) + +set(NDI_INCLUDE "${NDI_SDK_DIR}/include") +target_include_directories(dmf-node-ndiin PRIVATE + "${NDI_INCLUDE}" +) +find_library(NDI_LIB NAMES ndi PATHS "${NDI_SDK_DIR}/lib/${CMAKE_SYSTEM_PROCESSOR}-linux-gnu" "${NDI_SDK_DIR}/lib/x64" "${NDI_SDK_DIR}/lib" NO_DEFAULT_PATH) +target_link_libraries(dmf-node-ndiin PRIVATE ${NDI_LIB}) diff --git a/nodes/ndiin/main.cpp b/nodes/ndiin/main.cpp new file mode 100644 index 0000000..ddf1bd1 --- /dev/null +++ b/nodes/ndiin/main.cpp @@ -0,0 +1,192 @@ +#include +#include +#include +#include "NodeBase.hpp" +#include "FlowDef.hpp" +#include "V210.hpp" +#include "NDIHelper.hpp" +#include + +class NDIInNode : public dmf::NodeBase { + void run() override { + try { + dmf::NDIHelper ndi_helper; + } catch (const std::runtime_error& e) { + log("Error: %s", e.what()); + return; + } + + // Create a finder + NDIlib_find_instance_t pNDI_find = NDIlib_find_create_v2(); + if (!pNDI_find) { + log("Cannot create NDI find instance"); + return; + } + + // Wait until there is one source + uint32_t NDI_sources_amount = 0; + const NDIlib_source_t* p_sources = NULL; + while (!NDI_sources_amount) { + // Wait until the sources on the network have changed + log("Looking for NDI sources ..."); + NDIlib_find_wait_for_sources(pNDI_find, 1000/* One second */); + p_sources = NDIlib_find_get_current_sources(pNDI_find, &NDI_sources_amount); + } + + log("Found %i NDI sources:", NDI_sources_amount); + for (int i = 0; i < NDI_sources_amount; ++i){ + log("%i) %s (%s)", i + 1, p_sources[i].p_ndi_name, p_sources[i].p_url_address); + } + + NDIlib_recv_instance_t pNDI_recv = NDIlib_recv_create_v3(); + if (!pNDI_recv) { + log("Cannot create NDI recieve instance"); + return; + } + + NDIlib_recv_connect(pNDI_recv, p_sources + 0); + // Destroy the NDI finder. We needed to have access to the pointers to p_sources[0] + NDIlib_find_destroy(pNDI_find); + + // Recieve first frame to get info about NDI source data + NDIlib_video_frame_v2_t NDI_video_frame; + NDIlib_frame_type_e NDI_frame_type = + NDIlib_recv_capture_v2(pNDI_recv, &NDI_video_frame, nullptr, nullptr, 1000); + if (NDI_frame_type == NDIlib_frame_type_none) { + log("Can't recieve NDI frame data"); + NDIlib_recv_destroy(pNDI_recv); + NDIlib_destroy(); + return; + } else if (NDI_frame_type == NDIlib_frame_type_audio) { + log("Audio still not supported"); + NDIlib_recv_destroy(pNDI_recv); + NDIlib_destroy(); + return; + } + + log("NDI params: %i %i", NDI_video_frame.frame_rate_N, NDI_video_frame.frame_rate_D); + + char fourcc_str[5]; + uint32_t fourcc = (uint32_t)NDI_video_frame.FourCC; + fourcc_str[0] = (fourcc >> 0) & 0xFF; + fourcc_str[1] = (fourcc >> 8) & 0xFF; + fourcc_str[2] = (fourcc >> 16) & 0xFF; + fourcc_str[3] = (fourcc >> 24) & 0xFF; + fourcc_str[4] = '\0'; + log("NDI fourCC=%s (0x%08x)", fourcc_str, fourcc); + + const auto flow_info = config().at("flow_id"); + const auto flow_id = flow_info.at("id").get(); + const int width = flow_info.value("width", NDI_video_frame.xres); + const int height = flow_info.value("height", NDI_video_frame.yres); + // const int fps_num = flow_info.value("fps_num", NDI_video_frame.frame_rate_N); + // const int fps_den = flow_info.value("fps_den", NDI_video_frame.frame_rate_D); + const int fps_num = flow_info.value("fps_num", 25); + const int fps_den = flow_info.value("fps_den", 1); + + const uint32_t ndi_stride = NDI_video_frame.line_stride_in_bytes > 0 + ? static_cast(NDI_video_frame.line_stride_in_bytes) + : static_cast(width * 2); + + NDIlib_recv_free_video_v2(pNDI_recv, &NDI_video_frame); + + log("flow=%s %dx%d @ %d/%d fps", flow_id.c_str(), width, height, fps_num, fps_den); + + const std::string flow_def = + dmf::make_video_flow_def(flow_id, node_id(), width, height, fps_num, fps_den); + + mxlFlowWriter writer{}; + mxlFlowConfigInfo cfg_info{}; + bool created = false; + + mxlStatus st = mxlCreateFlowWriter( + instance(), flow_def.c_str(), nullptr, &writer, &cfg_info, &created); + if (st != MXL_STATUS_OK) { + log("mxlCreateFlowWriter failed (status=%d)", st); + return; + } + + const uint32_t stride = cfg_info.discrete.sliceSizes[0]; + log("stride=%u B/line grain=%u B ring=%u grains", + stride, stride * static_cast(height), cfg_info.discrete.grainCount); + + const mxlRational rate = {fps_num, fps_den}; + uint64_t index = mxlGetCurrentIndex(&rate); + log("start index=%llu", index); + + // in case of P216 + NDIlib_video_frame_v2_t NDI_video_frame_10bit; + NDI_video_frame_10bit.xres = width; + NDI_video_frame_10bit.yres = height; + NDI_video_frame_10bit.FourCC = (NDIlib_FourCC_video_type_e)NDI_LIB_FOURCC('V', '2', '1', '0'); + NDI_video_frame_10bit.line_stride_in_bytes = stride; + + // for UYVY and 1 FPS ndi static frames + const size_t frame_bytes = ndi_stride * height; + uint8_t* latest_buffer = (uint8_t*)malloc(frame_bytes); + bool last_ndi_frame_valid = false; + + while (dmf::g_running.load(std::memory_order_relaxed)) { + if (NDIlib_recv_capture_v2(pNDI_recv, &NDI_video_frame, nullptr, nullptr, 5) == NDIlib_frame_type_video) { + memcpy(latest_buffer, NDI_video_frame.p_data, frame_bytes); + NDIlib_recv_free_video_v2(pNDI_recv, &NDI_video_frame); + last_ndi_frame_valid = true; + } + mxlGrainInfo grain{}; + uint8_t* buf = nullptr; + + st = mxlFlowWriterOpenGrain(writer, index, &grain, &buf); + if (st != MXL_STATUS_OK) { + log("OpenGrain failed (status=%d), skipping index=%llu", st, index); + index++; + continue; + } + if (last_ndi_frame_valid) { + log("stride: %i ndi_stride: %i", stride, ndi_stride); + // UYVY -> v210 + const uint8_t* src = latest_buffer; + uint8_t* dst = buf; + const int blocks = width / 6; + + for (int y = 0; y < height; y++) { + for (int b = 0; b < blocks; b++) { + const uint8_t* mp = src + b * 12; // 3 macropixels = 12 bytes + // mp[0]=U0, mp[1]=Y0, mp[2]=V0, mp[3]=Y1 + // mp[4]=U1, mp[5]=Y2, mp[6]=V1, mp[7]=Y3 + // mp[8]=U2, mp[9]=Y4, mp[10]=V2, mp[11]=Y5 + + dmf::v210::pack_block(dst + b * 16, + {0, (uint16_t)(mp[0]<<2), (uint16_t)(mp[2]<<2)}, (uint16_t)(mp[1]<<2), (uint16_t)(mp[3]<<2), + {0, (uint16_t)(mp[4]<<2), (uint16_t)(mp[6]<<2)}, (uint16_t)(mp[5]<<2), (uint16_t)(mp[7]<<2), + {0, (uint16_t)(mp[8]<<2), (uint16_t)(mp[10]<<2)}, (uint16_t)(mp[9]<<2), (uint16_t)(mp[11]<<2) + ); + } + src += ndi_stride; + dst += stride; + } + grain.flags = 0; + } else { + grain.flags = MXL_GRAIN_FLAG_INVALID; + } + + grain.validSlices = grain.totalSlices; // mark grain complete so readers can consume it + mxlFlowWriterCommitGrain(writer, &grain); + + const uint64_t ns = mxlGetNsUntilIndex(index + 1, &rate); + if (ns > 0 && ns < 2'000'000'000ULL) mxlSleepForNs(ns); + index++; + } + + log("stopped at index=%llu", index); + free(latest_buffer); + mxlReleaseFlowWriter(instance(), writer); + NDIlib_recv_destroy(pNDI_recv); + NDIlib_destroy(); + } +}; + +int main() +{ + NDIInNode node; + return node.execute(); +} \ No newline at end of file diff --git a/nodes/ndiout/CMakeLists.txt b/nodes/ndiout/CMakeLists.txt new file mode 100644 index 0000000..a54c2b6 --- /dev/null +++ b/nodes/ndiout/CMakeLists.txt @@ -0,0 +1,11 @@ +add_executable(dmf-node-ndiout main.cpp) +target_compile_features(dmf-node-ndiout PRIVATE cxx_std_20) +target_link_libraries(dmf-node-ndiout PRIVATE dmf-shared) +install(TARGETS dmf-node-ndiout RUNTIME DESTINATION bin) + +set(NDI_INCLUDE "${NDI_SDK_DIR}/include") +target_include_directories(dmf-node-ndiout PRIVATE + "${NDI_INCLUDE}" +) +find_library(NDI_LIB NAMES ndi PATHS "${NDI_SDK_DIR}/lib/${CMAKE_SYSTEM_PROCESSOR}-linux-gnu" "${NDI_SDK_DIR}/lib/x64" "${NDI_SDK_DIR}/lib" NO_DEFAULT_PATH) +target_link_libraries(dmf-node-ndiout PRIVATE ${NDI_LIB}) diff --git a/nodes/ndiout/main.cpp b/nodes/ndiout/main.cpp new file mode 100644 index 0000000..72a83eb --- /dev/null +++ b/nodes/ndiout/main.cpp @@ -0,0 +1,132 @@ +#include +#include +#include +#include "NodeBase.hpp" +#include "FlowDef.hpp" +#include "V210.hpp" +#include + +class NDIOutNode : public dmf::NodeBase { + void run() override { + const auto flow_info = config().at("flow_id"); + const auto flow_id = flow_info.at("id").get(); + const int width = flow_info.value("width", 1920); + const int height = flow_info.value("height", 1080); + const int fps_num = flow_info.value("fps_num", 25); + const int fps_den = flow_info.value("fps_den", 1); + + log("flow=%s", flow_id.c_str()); + + log("waiting for flow to become active..."); + bool active = false; + while (!active && dmf::g_running.load(std::memory_order_relaxed)) { + mxlIsFlowActive(instance(), flow_id.c_str(), &active); + if (!active) mxlSleepForNs(100'000'000); + } + if (!dmf::g_running) return; + log("flow active — starting read"); + + mxlFlowReader reader{}; + mxlStatus st = mxlCreateFlowReader(instance(), flow_id.c_str(), nullptr, &reader); + if (st != MXL_STATUS_OK) { + log("mxlCreateFlowReader failed (status=%d)", st); + return; + } + mxlFlowConfigInfo cfg_info{}; + mxlFlowReaderGetConfigInfo(reader, &cfg_info); + const uint32_t mxl_stride = cfg_info.discrete.sliceSizes[0]; + + const mxlRational rate = {fps_num, fps_den}; + + uint64_t index = mxlGetCurrentIndex(&rate); + uint64_t frame_count = 0; + uint64_t invalid_count = 0; + uint64_t late_count = 0; + auto wall_start = std::chrono::steady_clock::now(); + auto last_log_time = wall_start; + + // NDI part + if (!NDIlib_initialize()) { + // Cannot run NDI. Most likely because the CPU is not sufficient (see SDK documentation). + log("Cannot run NDI"); + if (!NDIlib_is_supported_CPU()) { + log("CPU is not sufficient for NDI"); + } + return; + } + + NDIlib_send_create_t NDI_send_create_desc; + NDI_send_create_desc.p_ndi_name = flow_id.c_str(); + + NDIlib_send_instance_t pNDI_send = NDIlib_send_create(&NDI_send_create_desc); + if (!pNDI_send) { + log("Cannot create NDI send instance"); + return; + } + + NDIlib_video_frame_v2_t NDI_video_frame_10bit; + NDI_video_frame_10bit.xres = width; + NDI_video_frame_10bit.yres = height; + NDI_video_frame_10bit.FourCC = (NDIlib_FourCC_video_type_e)NDI_LIB_FOURCC('V', '2', '1', '0'); + + NDI_video_frame_10bit.line_stride_in_bytes = mxl_stride; + NDI_video_frame_10bit.p_data = (uint8_t*)malloc(NDI_video_frame_10bit.line_stride_in_bytes * NDI_video_frame_10bit.yres); + + NDIlib_video_frame_v2_t NDI_video_frame_16bit; + NDI_video_frame_16bit.xres = NDI_video_frame_10bit.xres; + NDI_video_frame_16bit.yres = NDI_video_frame_10bit.yres; + + NDI_video_frame_16bit.line_stride_in_bytes = NDI_video_frame_16bit.xres * sizeof(uint16_t); + NDI_video_frame_16bit.p_data = (uint8_t*)malloc(NDI_video_frame_16bit.line_stride_in_bytes * 2 * NDI_video_frame_16bit.yres); + // + uint64_t ndi_frame_counter = 0; + while (dmf::g_running.load(std::memory_order_relaxed)) { + mxlGrainInfo grain{}; + uint8_t* buf = nullptr; + + st = mxlFlowReaderGetGrainNonBlocking(reader, index, &grain, &buf); + + if (st == MXL_STATUS_OK) { + frame_count++; + if (grain.flags & MXL_GRAIN_FLAG_INVALID) invalid_count++; + index++; + if (NDIlib_send_get_no_connections(pNDI_send,0) == 0) { + ndi_frame_counter = 0; + continue; + } + memcpy(NDI_video_frame_10bit.p_data, buf, mxl_stride * height); + NDIlib_util_V210_to_P216(&NDI_video_frame_10bit, &NDI_video_frame_16bit); + NDIlib_send_send_video_v2(pNDI_send, &NDI_video_frame_16bit); + ndi_frame_counter++; + if (ndi_frame_counter == 1) { + log("NDI reciever got feed"); + } + } else if (st == MXL_ERR_OUT_OF_RANGE_TOO_EARLY) { + mxlSleepForNs(1'000'000); // 1 ms poll + + } else if (st == MXL_ERR_OUT_OF_RANGE_TOO_LATE) { + late_count++; + // Jump to the most recent frame in the ring buffer + mxlFlowRuntimeInfo ri{}; + mxlFlowReaderGetRuntimeInfo(reader, &ri); + index = ri.headIndex; + } else { + log("unexpected status=%d on index=%llu", st, index); + break; + } + } + + log("stopped — total frames=%llu invalid=%llu late=%llu", + frame_count, invalid_count, late_count); + mxlReleaseFlowReader(instance(), reader); + free(NDI_video_frame_10bit.p_data); + free(NDI_video_frame_16bit.p_data); + NDIlib_send_destroy(pNDI_send); + NDIlib_destroy(); + } +}; + +int main() { + NDIOutNode node; + return node.execute(); +} \ No newline at end of file diff --git a/shared/NDIHelper.hpp b/shared/NDIHelper.hpp new file mode 100644 index 0000000..dff91da --- /dev/null +++ b/shared/NDIHelper.hpp @@ -0,0 +1,72 @@ +#include +#include +#include +#include +#include + +namespace dmf { +class NDIHelper { + public: + NDIHelper() { + if (!NDIlib_initialize()) { + throw std::runtime_error("NDI lib init failed"); + if (!NDIlib_is_supported_CPU()) { + throw std::runtime_error("CPU is not sufficient for NDI"); + } + } + } + + ~NDIHelper() { + NDIlib_destroy(); + NDIlib_recv_destroy(pNDI_recv); + } + + void find_sources(std::vector* sources, u_int32_t timeout_ms) { + pNDI_find = NDIlib_find_create_v2(); + if (!pNDI_find) { + throw std::runtime_error("Cannot create NDI finder"); + } + + while (!sources_amount) { + NDIlib_find_wait_for_sources(pNDI_find, timeout_ms); + p_sources = NDIlib_find_get_current_sources(pNDI_find, &sources_amount); + } + + for (int i = 0; i < sources_amount; ++i){ + sources->push_back(p_sources[i].p_ndi_name); + } + NDIlib_find_destroy(pNDI_find); + } + + void select_source(uint32_t source_num) { + if (sources_amount == 0) { + throw std::runtime_error("0 sources found"); + } else if (source_num > sources_amount) { + throw std::runtime_error("Source_num bigger that sources amount"); + } + + pNDI_recv = NDIlib_recv_create_v3(); + if (!pNDI_recv) { + NDIlib_recv_destroy(pNDI_recv); + throw std::runtime_error("Cannot create NDI recieve instance"); + } + NDIlib_recv_connect(pNDI_recv, p_sources + source_num); + } + + private: + // receive + NDIlib_find_instance_t pNDI_find = nullptr; + uint32_t sources_amount = 0; + const NDIlib_source_t* p_sources = NULL; + NDIlib_recv_instance_t pNDI_recv = nullptr; + + void get_source_info(uint32_t source_num) { + NDIlib_video_frame_v2_t video_frame; + uint8_t frames = 0; + while (frames < 2) { + NDIlib_recv_capture_v2(pNDI_recv, &video_frame, nullptr, nullptr, 1000); + NDIlib_recv_free_video_v2(pNDI_recv, &video_frame); + } + } +}; +} \ No newline at end of file diff --git a/studio-manager/main.cpp b/studio-manager/main.cpp index fa02837..7180c6a 100644 --- a/studio-manager/main.cpp +++ b/studio-manager/main.cpp @@ -39,11 +39,11 @@ static std::string gen_uuid() { static dmf::FlowGraph build_graph() { dmf::FlowGraph g; g.nodes = { - { "testpattern", "testpattern", {} }, - { "fakesink", "fakesink", {} }, + { "ndiin", "ndiin", {} }, + { "fakesink", "fakesink", {}}, }; g.edges = { - { gen_uuid(), "testpattern", "flow_id", "fakesink", "flow_id", + { gen_uuid(), "ndiin", "flow_id", "fakesink", "flow_id", { {"kind","video"}, {"width",1920}, {"height",1080}, {"fps_num",25}, {"fps_den",1} } }, }; return g; From 19ea14a61c00b7d9270c9894d6022a8ef860a043 Mon Sep 17 00:00:00 2001 From: itten Date: Wed, 1 Jul 2026 11:55:04 +0300 Subject: [PATCH 02/15] NDI in node looks almost done --- nodes/ndiin/main.cpp | 131 ++++++--------------------------- shared/NDIHelper.hpp | 170 +++++++++++++++++++++++++++++++++++++------ shared/V210.hpp | 24 ++++++ 3 files changed, 192 insertions(+), 133 deletions(-) diff --git a/nodes/ndiin/main.cpp b/nodes/ndiin/main.cpp index ddf1bd1..c33f691 100644 --- a/nodes/ndiin/main.cpp +++ b/nodes/ndiin/main.cpp @@ -6,89 +6,32 @@ #include "V210.hpp" #include "NDIHelper.hpp" #include +#include class NDIInNode : public dmf::NodeBase { void run() override { + dmf::NDIHelper ndi_helper; try { - dmf::NDIHelper ndi_helper; + std::vector ndi_sources; + ndi_helper.find_sources(&ndi_sources, 5000); + log("Available NDI sources:"); + for (const auto& source_name : ndi_sources){ + log("%s", source_name.c_str()); + } + uint32_t source_num = 0; + ndi_helper.select_source(source_num); + ndi_helper.get_source_info(source_num); } catch (const std::runtime_error& e) { log("Error: %s", e.what()); return; } - - // Create a finder - NDIlib_find_instance_t pNDI_find = NDIlib_find_create_v2(); - if (!pNDI_find) { - log("Cannot create NDI find instance"); - return; - } - - // Wait until there is one source - uint32_t NDI_sources_amount = 0; - const NDIlib_source_t* p_sources = NULL; - while (!NDI_sources_amount) { - // Wait until the sources on the network have changed - log("Looking for NDI sources ..."); - NDIlib_find_wait_for_sources(pNDI_find, 1000/* One second */); - p_sources = NDIlib_find_get_current_sources(pNDI_find, &NDI_sources_amount); - } - - log("Found %i NDI sources:", NDI_sources_amount); - for (int i = 0; i < NDI_sources_amount; ++i){ - log("%i) %s (%s)", i + 1, p_sources[i].p_ndi_name, p_sources[i].p_url_address); - } - - NDIlib_recv_instance_t pNDI_recv = NDIlib_recv_create_v3(); - if (!pNDI_recv) { - log("Cannot create NDI recieve instance"); - return; - } - - NDIlib_recv_connect(pNDI_recv, p_sources + 0); - // Destroy the NDI finder. We needed to have access to the pointers to p_sources[0] - NDIlib_find_destroy(pNDI_find); - - // Recieve first frame to get info about NDI source data - NDIlib_video_frame_v2_t NDI_video_frame; - NDIlib_frame_type_e NDI_frame_type = - NDIlib_recv_capture_v2(pNDI_recv, &NDI_video_frame, nullptr, nullptr, 1000); - if (NDI_frame_type == NDIlib_frame_type_none) { - log("Can't recieve NDI frame data"); - NDIlib_recv_destroy(pNDI_recv); - NDIlib_destroy(); - return; - } else if (NDI_frame_type == NDIlib_frame_type_audio) { - log("Audio still not supported"); - NDIlib_recv_destroy(pNDI_recv); - NDIlib_destroy(); - return; - } - - log("NDI params: %i %i", NDI_video_frame.frame_rate_N, NDI_video_frame.frame_rate_D); - - char fourcc_str[5]; - uint32_t fourcc = (uint32_t)NDI_video_frame.FourCC; - fourcc_str[0] = (fourcc >> 0) & 0xFF; - fourcc_str[1] = (fourcc >> 8) & 0xFF; - fourcc_str[2] = (fourcc >> 16) & 0xFF; - fourcc_str[3] = (fourcc >> 24) & 0xFF; - fourcc_str[4] = '\0'; - log("NDI fourCC=%s (0x%08x)", fourcc_str, fourcc); const auto flow_info = config().at("flow_id"); const auto flow_id = flow_info.at("id").get(); - const int width = flow_info.value("width", NDI_video_frame.xres); - const int height = flow_info.value("height", NDI_video_frame.yres); - // const int fps_num = flow_info.value("fps_num", NDI_video_frame.frame_rate_N); - // const int fps_den = flow_info.value("fps_den", NDI_video_frame.frame_rate_D); - const int fps_num = flow_info.value("fps_num", 25); - const int fps_den = flow_info.value("fps_den", 1); - - const uint32_t ndi_stride = NDI_video_frame.line_stride_in_bytes > 0 - ? static_cast(NDI_video_frame.line_stride_in_bytes) - : static_cast(width * 2); - - NDIlib_recv_free_video_v2(pNDI_recv, &NDI_video_frame); + const int width = flow_info.value("width", ndi_helper.xres); + const int height = flow_info.value("height", ndi_helper.yres); + const int fps_num = flow_info.value("fps_num", ndi_helper.frame_N); + const int fps_den = flow_info.value("fps_den", ndi_helper.frame_D); log("flow=%s %dx%d @ %d/%d fps", flow_id.c_str(), width, height, fps_num, fps_den); @@ -114,26 +57,18 @@ class NDIInNode : public dmf::NodeBase { uint64_t index = mxlGetCurrentIndex(&rate); log("start index=%llu", index); - // in case of P216 - NDIlib_video_frame_v2_t NDI_video_frame_10bit; - NDI_video_frame_10bit.xres = width; - NDI_video_frame_10bit.yres = height; - NDI_video_frame_10bit.FourCC = (NDIlib_FourCC_video_type_e)NDI_LIB_FOURCC('V', '2', '1', '0'); - NDI_video_frame_10bit.line_stride_in_bytes = stride; - - // for UYVY and 1 FPS ndi static frames - const size_t frame_bytes = ndi_stride * height; + // because NDI can drop to 1 FPS for static frames, even if source is 29.97p + const size_t frame_bytes = stride * height; uint8_t* latest_buffer = (uint8_t*)malloc(frame_bytes); bool last_ndi_frame_valid = false; while (dmf::g_running.load(std::memory_order_relaxed)) { - if (NDIlib_recv_capture_v2(pNDI_recv, &NDI_video_frame, nullptr, nullptr, 5) == NDIlib_frame_type_video) { - memcpy(latest_buffer, NDI_video_frame.p_data, frame_bytes); - NDIlib_recv_free_video_v2(pNDI_recv, &NDI_video_frame); + if (ndi_helper.getV210_video_frame(0, latest_buffer, stride)) { last_ndi_frame_valid = true; } + + uint8_t* buf = nullptr; mxlGrainInfo grain{}; - uint8_t* buf = nullptr; st = mxlFlowWriterOpenGrain(writer, index, &grain, &buf); if (st != MXL_STATUS_OK) { @@ -141,29 +76,9 @@ class NDIInNode : public dmf::NodeBase { index++; continue; } + if (last_ndi_frame_valid) { - log("stride: %i ndi_stride: %i", stride, ndi_stride); - // UYVY -> v210 - const uint8_t* src = latest_buffer; - uint8_t* dst = buf; - const int blocks = width / 6; - - for (int y = 0; y < height; y++) { - for (int b = 0; b < blocks; b++) { - const uint8_t* mp = src + b * 12; // 3 macropixels = 12 bytes - // mp[0]=U0, mp[1]=Y0, mp[2]=V0, mp[3]=Y1 - // mp[4]=U1, mp[5]=Y2, mp[6]=V1, mp[7]=Y3 - // mp[8]=U2, mp[9]=Y4, mp[10]=V2, mp[11]=Y5 - - dmf::v210::pack_block(dst + b * 16, - {0, (uint16_t)(mp[0]<<2), (uint16_t)(mp[2]<<2)}, (uint16_t)(mp[1]<<2), (uint16_t)(mp[3]<<2), - {0, (uint16_t)(mp[4]<<2), (uint16_t)(mp[6]<<2)}, (uint16_t)(mp[5]<<2), (uint16_t)(mp[7]<<2), - {0, (uint16_t)(mp[8]<<2), (uint16_t)(mp[10]<<2)}, (uint16_t)(mp[9]<<2), (uint16_t)(mp[11]<<2) - ); - } - src += ndi_stride; - dst += stride; - } + std::memcpy(buf, latest_buffer, frame_bytes); grain.flags = 0; } else { grain.flags = MXL_GRAIN_FLAG_INVALID; @@ -180,8 +95,6 @@ class NDIInNode : public dmf::NodeBase { log("stopped at index=%llu", index); free(latest_buffer); mxlReleaseFlowWriter(instance(), writer); - NDIlib_recv_destroy(pNDI_recv); - NDIlib_destroy(); } }; diff --git a/shared/NDIHelper.hpp b/shared/NDIHelper.hpp index dff91da..6db5a89 100644 --- a/shared/NDIHelper.hpp +++ b/shared/NDIHelper.hpp @@ -1,24 +1,28 @@ #include #include #include -#include #include +#include "V210.hpp" namespace dmf { class NDIHelper { public: + + int xres = 0, yres = 0, frame_D = 0, frame_N = 0, stride = 0; + NDIHelper() { + if (!NDIlib_is_supported_CPU()) { + throw std::runtime_error("CPU is not sufficient for NDI"); + } if (!NDIlib_initialize()) { throw std::runtime_error("NDI lib init failed"); - if (!NDIlib_is_supported_CPU()) { - throw std::runtime_error("CPU is not sufficient for NDI"); - } } } ~NDIHelper() { + if (pNDI_recv) NDIlib_recv_destroy(pNDI_recv); + if (pNDI_find) NDIlib_find_destroy(pNDI_find); NDIlib_destroy(); - NDIlib_recv_destroy(pNDI_recv); } void find_sources(std::vector* sources, u_int32_t timeout_ms) { @@ -27,46 +31,164 @@ class NDIHelper { throw std::runtime_error("Cannot create NDI finder"); } - while (!sources_amount) { + const int max_attempts = 10; + uint32_t sources_amount = 0; + const NDIlib_source_t* p_sources = nullptr; + for (int attempt = 0; !sources_amount && attempt < max_attempts; ++attempt) { NDIlib_find_wait_for_sources(pNDI_find, timeout_ms); p_sources = NDIlib_find_get_current_sources(pNDI_find, &sources_amount); } - - for (int i = 0; i < sources_amount; ++i){ - sources->push_back(p_sources[i].p_ndi_name); + if (!sources_amount) { + NDIlib_find_destroy(pNDI_find); + pNDI_find = nullptr; + throw std::runtime_error("No NDI sources found after timeout"); } + + // Copy while finder is alive: p_sources points into finder-owned memory + for (uint32_t i = 0; i < sources_amount; ++i) { + sources->push_back(p_sources[i].p_ndi_name); + cached_names.emplace_back(p_sources[i].p_ndi_name); + cached_urls.emplace_back(p_sources[i].p_url_address); + } + cached_sources.reserve(cached_names.size()); + for (uint32_t i = 0; i < cached_names.size(); ++i) { + cached_sources.push_back({cached_names[i].c_str(), cached_urls[i].c_str()}); + } + NDIlib_find_destroy(pNDI_find); + pNDI_find = nullptr; } void select_source(uint32_t source_num) { - if (sources_amount == 0) { + if (cached_sources.empty()) { throw std::runtime_error("0 sources found"); - } else if (source_num > sources_amount) { + } else if (source_num >= cached_sources.size()) { throw std::runtime_error("Source_num bigger that sources amount"); } pNDI_recv = NDIlib_recv_create_v3(); if (!pNDI_recv) { - NDIlib_recv_destroy(pNDI_recv); throw std::runtime_error("Cannot create NDI recieve instance"); } - NDIlib_recv_connect(pNDI_recv, p_sources + source_num); + NDIlib_recv_connect(pNDI_recv, &cached_sources[source_num]); + } + + void get_source_info(uint32_t source_num) { + NDIlib_video_frame_v2_t video_frame; + NDIlib_frame_type_e frame_type; + bool is_got_info = false; + while(!is_got_info) + { + frame_type = NDIlib_recv_capture_v3(pNDI_recv, &video_frame, nullptr, nullptr, 1000); + switch(frame_type) + { + case NDIlib_frame_type_video: + is_got_info = true; + xres = video_frame.xres; + yres = video_frame.yres; + frame_D = video_frame.frame_rate_D; + frame_N = video_frame.frame_rate_N; + fourCC = video_frame.FourCC; + stride = video_frame.line_stride_in_bytes; + if (stride == 0) { + stride = xres * get_bytes_per_pixel(fourCC); + } + break; + case NDIlib_frame_type_error: + is_got_info = true; + throw std::runtime_error("Selected NDI source is lost"); + break; + } + } + NDIlib_recv_free_video_v2(pNDI_recv, &video_frame); + } + + int get_bytes_per_pixel(NDIlib_FourCC_video_type_e fourCC) { + switch (fourCC) { + case NDIlib_FourCC_video_type_UYVY: // Standard 8-bit YUV 4:2:2 + case NDIlib_FourCC_video_type_YV12: // 8-bit YUV 4:2:0 + case NDIlib_FourCC_video_type_I420: // 8-bit YUV 4:2:0 + case NDIlib_FourCC_video_type_NV12: // 8-bit YUV 4:2:0 + // These are 4:2:2 or 4:2:0 formats. + // On average, they use 2 bytes (16 bits) per pixel across the macroblock. + return 2; + + case NDIlib_FourCC_video_type_BGRA: // 8-bit RGB with Alpha + case NDIlib_FourCC_video_type_RGBA: // 8-bit RGB with Alpha + // 4 channels (Red, Green, Blue, Alpha) * 1 byte each + return 4; + + case NDIlib_FourCC_video_type_BGRX: // 8-bit RGB (Padding) + case NDIlib_FourCC_video_type_RGBX: // 8-bit RGB (Padding) + // 4 channels (Red, Green, Blue, Empty) * 1 byte each + return 4; + + case NDIlib_FourCC_video_type_UYVA: // 8-bit YUV 4:2:2 + Alpha channel + // 2 bytes for YUV + 1 byte for Alpha split + return 3; + + case NDIlib_FourCC_video_type_P216: // 16-bit YUV 4:2:2 (High bit depth) + // 2 channels packed at 2 bytes (16-bits) per sample = 4 bytes per pixel + return 4; + + case NDIlib_FourCC_video_type_PA16: // 16-bit YUV 4:2:2 + 16-bit Alpha + return 6; + + default: + return 2; // Safe NDI default fallback + } + } + + std::string fourCCtoStr() { + char fourcc_str[5]; + uint32_t fourcc = (uint32_t)fourCC; + fourcc_str[0] = (fourcc >> 0) & 0xFF; + fourcc_str[1] = (fourcc >> 8) & 0xFF; + fourcc_str[2] = (fourcc >> 16) & 0xFF; + fourcc_str[3] = (fourcc >> 24) & 0xFF; + fourcc_str[4] = '\0'; + return std::string(fourcc_str); + } + + bool getV210_video_frame(uint32_t source_num, uint8_t* frame_buffer, uint32_t frame_stride) { + NDIlib_video_frame_v2_t video_frame; + NDIlib_frame_type_e frame_type; + frame_type = NDIlib_recv_capture_v3(pNDI_recv, &video_frame, nullptr, nullptr, 5); + switch(frame_type) + { + case NDIlib_frame_type_error: + throw std::runtime_error("NDI source lost"); + case NDIlib_frame_type_status_change: + throw std::runtime_error("NDI source resolution or framerate are changed"); + + } + if (frame_type != NDIlib_frame_type_video) { + return false; + } + switch(fourCC) + { + case NDIlib_FourCC_type_UYVY: + v210::UYVYtoV210(video_frame.p_data, frame_buffer, xres, yres, stride, frame_stride); + break; + case NDIlib_FourCC_type_P216: + NDIlib_video_frame_v2_t video_frame_10bit; + NDIlib_util_P216_to_V210(&video_frame, &video_frame_10bit); + frame_buffer = video_frame.p_data; + default: + throw std::runtime_error("Color format is not supported yet"); + } + NDIlib_recv_free_video_v2(pNDI_recv, &video_frame); + return true; } private: // receive NDIlib_find_instance_t pNDI_find = nullptr; - uint32_t sources_amount = 0; - const NDIlib_source_t* p_sources = NULL; NDIlib_recv_instance_t pNDI_recv = nullptr; - - void get_source_info(uint32_t source_num) { - NDIlib_video_frame_v2_t video_frame; - uint8_t frames = 0; - while (frames < 2) { - NDIlib_recv_capture_v2(pNDI_recv, &video_frame, nullptr, nullptr, 1000); - NDIlib_recv_free_video_v2(pNDI_recv, &video_frame); - } - } + NDIlib_FourCC_type_e fourCC; + // owned copies so finder can be destroyed early + std::vector cached_names; + std::vector cached_urls; + std::vector cached_sources; }; } \ No newline at end of file diff --git a/shared/V210.hpp b/shared/V210.hpp index f4ee4e8..e3e9fd4 100644 --- a/shared/V210.hpp +++ b/shared/V210.hpp @@ -90,4 +90,28 @@ inline void fill_frame(uint8_t* buf, int width, int height, uint32_t stride) } } +inline void UYVYtoV210(uint8_t* src_buf, uint8_t* dst_buf, int width, int height, uint32_t src_stride, uint32_t dst_stride) +{ + const uint8_t* src = src_buf; + uint8_t* dst = dst_buf; + const int blocks = width / 6; + + for (int y = 0; y < height; y++) { + for (int b = 0; b < blocks; b++) { + const uint8_t* mp = src + b * 12; // 3 macropixels = 12 bytes + // mp[0]=U0, mp[1]=Y0, mp[2]=V0, mp[3]=Y1 + // mp[4]=U1, mp[5]=Y2, mp[6]=V1, mp[7]=Y3 + // mp[8]=U2, mp[9]=Y4, mp[10]=V2, mp[11]=Y5 + + dmf::v210::pack_block(dst + b * 16, + {0, (uint16_t)(mp[0]<<2), (uint16_t)(mp[2]<<2)}, (uint16_t)(mp[1]<<2), (uint16_t)(mp[3]<<2), + {0, (uint16_t)(mp[4]<<2), (uint16_t)(mp[6]<<2)}, (uint16_t)(mp[5]<<2), (uint16_t)(mp[7]<<2), + {0, (uint16_t)(mp[8]<<2), (uint16_t)(mp[10]<<2)}, (uint16_t)(mp[9]<<2), (uint16_t)(mp[11]<<2) + ); + } + src += src_stride; + dst += dst_stride; + } +} + } // namespace dmf::v210 From 7a32fa20af099953d5d4e721f2e68021e5387058 Mon Sep 17 00:00:00 2001 From: JohannesItten Date: Wed, 1 Jul 2026 12:07:45 +0300 Subject: [PATCH 03/15] fix: NDI node correctness and robustness fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NDIHelper: - find_sources: check g_running each attempt to avoid 50s block on shutdown - getV210_video_frame: fix P216 branch (fall-through + local pointer reassignment bug) - getV210_video_frame: status_change re-queries source info instead of throwing - select_source: fix typo "recieve" → "receive" ndiout: - set frame_rate_N/frame_rate_D on the NDI send frame (was 0/0) - add per-second stats logging (matching fakesink pattern) - add missing include - fix typo "reciever" → "receiver" ndiin: - read source_num from NODE_CONFIG (key: "source_num", default 0) - wrap hot-loop NDI call in try/catch so source-lost terminates cleanly - pass source_num through to getV210_video_frame Co-Authored-By: Claude Sonnet 4.6 --- nodes/ndiin/main.cpp | 16 ++++++++++------ nodes/ndiout/main.cpp | 34 +++++++++++++++++++++------------ shared/NDIHelper.hpp | 44 +++++++++++++++++++++++++++---------------- 3 files changed, 60 insertions(+), 34 deletions(-) diff --git a/nodes/ndiin/main.cpp b/nodes/ndiin/main.cpp index c33f691..fd106c8 100644 --- a/nodes/ndiin/main.cpp +++ b/nodes/ndiin/main.cpp @@ -10,15 +10,15 @@ class NDIInNode : public dmf::NodeBase { void run() override { + const uint32_t source_num = config().value("source_num", 0); + dmf::NDIHelper ndi_helper; try { std::vector ndi_sources; ndi_helper.find_sources(&ndi_sources, 5000); log("Available NDI sources:"); - for (const auto& source_name : ndi_sources){ - log("%s", source_name.c_str()); - } - uint32_t source_num = 0; + for (const auto& source_name : ndi_sources) + log(" %s", source_name.c_str()); ndi_helper.select_source(source_num); ndi_helper.get_source_info(source_num); } catch (const std::runtime_error& e) { @@ -63,8 +63,12 @@ class NDIInNode : public dmf::NodeBase { bool last_ndi_frame_valid = false; while (dmf::g_running.load(std::memory_order_relaxed)) { - if (ndi_helper.getV210_video_frame(0, latest_buffer, stride)) { - last_ndi_frame_valid = true; + try { + if (ndi_helper.getV210_video_frame(source_num, latest_buffer, stride)) + last_ndi_frame_valid = true; + } catch (const std::runtime_error& e) { + log("NDI error: %s — stopping", e.what()); + break; } uint8_t* buf = nullptr; diff --git a/nodes/ndiout/main.cpp b/nodes/ndiout/main.cpp index 72a83eb..51fb3d7 100644 --- a/nodes/ndiout/main.cpp +++ b/nodes/ndiout/main.cpp @@ -1,3 +1,4 @@ +#include #include #include #include @@ -38,12 +39,12 @@ class NDIOutNode : public dmf::NodeBase { const mxlRational rate = {fps_num, fps_den}; - uint64_t index = mxlGetCurrentIndex(&rate); - uint64_t frame_count = 0; - uint64_t invalid_count = 0; - uint64_t late_count = 0; - auto wall_start = std::chrono::steady_clock::now(); - auto last_log_time = wall_start; + uint64_t index = mxlGetCurrentIndex(&rate); + uint64_t frame_count = 0; + uint64_t invalid_count = 0; + uint64_t late_count = 0; + auto wall_start = std::chrono::steady_clock::now(); + auto last_log_time = wall_start; // NDI part if (!NDIlib_initialize()) { @@ -73,9 +74,10 @@ class NDIOutNode : public dmf::NodeBase { NDI_video_frame_10bit.p_data = (uint8_t*)malloc(NDI_video_frame_10bit.line_stride_in_bytes * NDI_video_frame_10bit.yres); NDIlib_video_frame_v2_t NDI_video_frame_16bit; - NDI_video_frame_16bit.xres = NDI_video_frame_10bit.xres; - NDI_video_frame_16bit.yres = NDI_video_frame_10bit.yres; - + NDI_video_frame_16bit.xres = NDI_video_frame_10bit.xres; + NDI_video_frame_16bit.yres = NDI_video_frame_10bit.yres; + NDI_video_frame_16bit.frame_rate_N = fps_num; + NDI_video_frame_16bit.frame_rate_D = fps_den; NDI_video_frame_16bit.line_stride_in_bytes = NDI_video_frame_16bit.xres * sizeof(uint16_t); NDI_video_frame_16bit.p_data = (uint8_t*)malloc(NDI_video_frame_16bit.line_stride_in_bytes * 2 * NDI_video_frame_16bit.yres); // @@ -98,9 +100,8 @@ class NDIOutNode : public dmf::NodeBase { NDIlib_util_V210_to_P216(&NDI_video_frame_10bit, &NDI_video_frame_16bit); NDIlib_send_send_video_v2(pNDI_send, &NDI_video_frame_16bit); ndi_frame_counter++; - if (ndi_frame_counter == 1) { - log("NDI reciever got feed"); - } + if (ndi_frame_counter == 1) + log("NDI receiver connected"); } else if (st == MXL_ERR_OUT_OF_RANGE_TOO_EARLY) { mxlSleepForNs(1'000'000); // 1 ms poll @@ -114,6 +115,15 @@ class NDIOutNode : public dmf::NodeBase { log("unexpected status=%d on index=%llu", st, index); break; } + + auto now = std::chrono::steady_clock::now(); + if (std::chrono::duration(now - last_log_time).count() >= 1.0) { + const double elapsed = std::chrono::duration(now - wall_start).count(); + log("frames=%llu invalid=%llu late=%llu avg=%.2f fps", + frame_count, invalid_count, late_count, + static_cast(frame_count) / elapsed); + last_log_time = now; + } } log("stopped — total frames=%llu invalid=%llu late=%llu", diff --git a/shared/NDIHelper.hpp b/shared/NDIHelper.hpp index 6db5a89..50ca5e3 100644 --- a/shared/NDIHelper.hpp +++ b/shared/NDIHelper.hpp @@ -2,6 +2,7 @@ #include #include #include +#include "Signal.hpp" #include "V210.hpp" namespace dmf { @@ -35,6 +36,11 @@ class NDIHelper { uint32_t sources_amount = 0; const NDIlib_source_t* p_sources = nullptr; for (int attempt = 0; !sources_amount && attempt < max_attempts; ++attempt) { + if (!dmf::g_running.load(std::memory_order_relaxed)) { + NDIlib_find_destroy(pNDI_find); + pNDI_find = nullptr; + throw std::runtime_error("Interrupted while searching for NDI sources"); + } NDIlib_find_wait_for_sources(pNDI_find, timeout_ms); p_sources = NDIlib_find_get_current_sources(pNDI_find, &sources_amount); } @@ -68,7 +74,7 @@ class NDIHelper { pNDI_recv = NDIlib_recv_create_v3(); if (!pNDI_recv) { - throw std::runtime_error("Cannot create NDI recieve instance"); + throw std::runtime_error("Cannot create NDI receive instance"); } NDIlib_recv_connect(pNDI_recv, &cached_sources[source_num]); } @@ -154,28 +160,34 @@ class NDIHelper { NDIlib_video_frame_v2_t video_frame; NDIlib_frame_type_e frame_type; frame_type = NDIlib_recv_capture_v3(pNDI_recv, &video_frame, nullptr, nullptr, 5); - switch(frame_type) - { - case NDIlib_frame_type_error: - throw std::runtime_error("NDI source lost"); - case NDIlib_frame_type_status_change: - throw std::runtime_error("NDI source resolution or framerate are changed"); - } - if (frame_type != NDIlib_frame_type_video) { + if (frame_type == NDIlib_frame_type_error) + throw std::runtime_error("NDI source lost"); + + if (frame_type == NDIlib_frame_type_status_change) { + // Source changed resolution or framerate — refresh internal info, repeat last frame + get_source_info(source_num); return false; } - switch(fourCC) - { + + if (frame_type != NDIlib_frame_type_video) + return false; + + switch (fourCC) { case NDIlib_FourCC_type_UYVY: v210::UYVYtoV210(video_frame.p_data, frame_buffer, xres, yres, stride, frame_stride); break; - case NDIlib_FourCC_type_P216: - NDIlib_video_frame_v2_t video_frame_10bit; - NDIlib_util_P216_to_V210(&video_frame, &video_frame_10bit); - frame_buffer = video_frame.p_data; + case NDIlib_FourCC_type_P216: { + // P216→V210: write directly into caller's buffer via a wrapper frame + NDIlib_video_frame_v2_t dst{}; + dst.p_data = frame_buffer; + dst.line_stride_in_bytes = frame_stride; + NDIlib_util_P216_to_V210(&video_frame, &dst); + break; + } default: - throw std::runtime_error("Color format is not supported yet"); + NDIlib_recv_free_video_v2(pNDI_recv, &video_frame); + throw std::runtime_error("Unsupported NDI color format: " + fourCCtoStr()); } NDIlib_recv_free_video_v2(pNDI_recv, &video_frame); return true; From 12c87db4e25c4934284cf00736c6de0534024704 Mon Sep 17 00:00:00 2001 From: JohannesItten Date: Wed, 1 Jul 2026 12:13:36 +0300 Subject: [PATCH 04/15] =?UTF-8?q?refactor:=20NDIHelper=20=E2=86=92=20NDIRe?= =?UTF-8?q?ceiver=20with=20cleaner=20API=20and=20naming?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NDIReceiver (shared/NDIReceiver.hpp): - Rename class NDIHelper → NDIReceiver, file NDIHelper.hpp → NDIReceiver.hpp - Add SourceInfo struct (width, height, fps_num, fps_den, stride, fourcc) replacing raw public member variables (xres, yres, frame_N, frame_D, stride) - find_sources() now returns std::vector instead of output pointer - select_source() + get_source_info() → connect() + probe() (cleaner sequence, probe() returns SourceInfo and stores it internally for capture_v210) - getV210_video_frame() → capture_v210() — removes unused source_num parameter - get_bytes_per_pixel, fourCCtoStr → private static bytes_per_pixel, fourcc_str - u_int32_t → uint32_t; (uint32_t) casts → static_cast - probe() checks g_running to avoid hanging if source never sends video ndiin/main.cpp: - Update to new NDIReceiver API - malloc/free latest_buffer → std::vector latest_frame - Remove unused #include and V210.hpp - memcpy uses latest_frame.size() instead of separate frame_bytes variable Co-Authored-By: Claude Sonnet 4.6 --- nodes/ndiin/main.cpp | 60 ++++++------ shared/NDIHelper.hpp | 206 ----------------------------------------- shared/NDIReceiver.hpp | 191 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 219 insertions(+), 238 deletions(-) delete mode 100644 shared/NDIHelper.hpp create mode 100644 shared/NDIReceiver.hpp diff --git a/nodes/ndiin/main.cpp b/nodes/ndiin/main.cpp index fd106c8..0e1388f 100644 --- a/nodes/ndiin/main.cpp +++ b/nodes/ndiin/main.cpp @@ -1,37 +1,36 @@ +#include #include +#include #include #include #include "NodeBase.hpp" #include "FlowDef.hpp" -#include "V210.hpp" -#include "NDIHelper.hpp" -#include -#include +#include "NDIReceiver.hpp" class NDIInNode : public dmf::NodeBase { void run() override { - const uint32_t source_num = config().value("source_num", 0); + const auto source_num = static_cast(config().value("source_num", 0)); - dmf::NDIHelper ndi_helper; + dmf::NDIReceiver ndi; + dmf::NDIReceiver::SourceInfo src; try { - std::vector ndi_sources; - ndi_helper.find_sources(&ndi_sources, 5000); + auto sources = ndi.find_sources(5000); log("Available NDI sources:"); - for (const auto& source_name : ndi_sources) - log(" %s", source_name.c_str()); - ndi_helper.select_source(source_num); - ndi_helper.get_source_info(source_num); + for (const auto& name : sources) + log(" %s", name.c_str()); + ndi.connect(source_num); + src = ndi.probe(); } catch (const std::runtime_error& e) { log("Error: %s", e.what()); return; } - + const auto flow_info = config().at("flow_id"); const auto flow_id = flow_info.at("id").get(); - const int width = flow_info.value("width", ndi_helper.xres); - const int height = flow_info.value("height", ndi_helper.yres); - const int fps_num = flow_info.value("fps_num", ndi_helper.frame_N); - const int fps_den = flow_info.value("fps_den", ndi_helper.frame_D); + const int width = flow_info.value("width", src.width); + const int height = flow_info.value("height", src.height); + const int fps_num = flow_info.value("fps_num", src.fps_num); + const int fps_den = flow_info.value("fps_den", src.fps_den); log("flow=%s %dx%d @ %d/%d fps", flow_id.c_str(), width, height, fps_num, fps_den); @@ -56,23 +55,22 @@ class NDIInNode : public dmf::NodeBase { const mxlRational rate = {fps_num, fps_den}; uint64_t index = mxlGetCurrentIndex(&rate); log("start index=%llu", index); - - // because NDI can drop to 1 FPS for static frames, even if source is 29.97p - const size_t frame_bytes = stride * height; - uint8_t* latest_buffer = (uint8_t*)malloc(frame_bytes); - bool last_ndi_frame_valid = false; + + // NDI can drop to 1fps for static content — hold last valid frame + std::vector latest_frame(stride * height); + bool have_frame = false; while (dmf::g_running.load(std::memory_order_relaxed)) { try { - if (ndi_helper.getV210_video_frame(source_num, latest_buffer, stride)) - last_ndi_frame_valid = true; + if (ndi.capture_v210(latest_frame.data(), stride)) + have_frame = true; } catch (const std::runtime_error& e) { log("NDI error: %s — stopping", e.what()); break; } - uint8_t* buf = nullptr; mxlGrainInfo grain{}; + uint8_t* buf = nullptr; st = mxlFlowWriterOpenGrain(writer, index, &grain, &buf); if (st != MXL_STATUS_OK) { @@ -81,14 +79,14 @@ class NDIInNode : public dmf::NodeBase { continue; } - if (last_ndi_frame_valid) { - std::memcpy(buf, latest_buffer, frame_bytes); + if (have_frame) { + std::memcpy(buf, latest_frame.data(), latest_frame.size()); grain.flags = 0; } else { grain.flags = MXL_GRAIN_FLAG_INVALID; } - grain.validSlices = grain.totalSlices; // mark grain complete so readers can consume it + grain.validSlices = grain.totalSlices; mxlFlowWriterCommitGrain(writer, &grain); const uint64_t ns = mxlGetNsUntilIndex(index + 1, &rate); @@ -97,13 +95,11 @@ class NDIInNode : public dmf::NodeBase { } log("stopped at index=%llu", index); - free(latest_buffer); mxlReleaseFlowWriter(instance(), writer); } }; -int main() -{ +int main() { NDIInNode node; return node.execute(); -} \ No newline at end of file +} diff --git a/shared/NDIHelper.hpp b/shared/NDIHelper.hpp deleted file mode 100644 index 50ca5e3..0000000 --- a/shared/NDIHelper.hpp +++ /dev/null @@ -1,206 +0,0 @@ -#include -#include -#include -#include -#include "Signal.hpp" -#include "V210.hpp" - -namespace dmf { -class NDIHelper { - public: - - int xres = 0, yres = 0, frame_D = 0, frame_N = 0, stride = 0; - - NDIHelper() { - if (!NDIlib_is_supported_CPU()) { - throw std::runtime_error("CPU is not sufficient for NDI"); - } - if (!NDIlib_initialize()) { - throw std::runtime_error("NDI lib init failed"); - } - } - - ~NDIHelper() { - if (pNDI_recv) NDIlib_recv_destroy(pNDI_recv); - if (pNDI_find) NDIlib_find_destroy(pNDI_find); - NDIlib_destroy(); - } - - void find_sources(std::vector* sources, u_int32_t timeout_ms) { - pNDI_find = NDIlib_find_create_v2(); - if (!pNDI_find) { - throw std::runtime_error("Cannot create NDI finder"); - } - - const int max_attempts = 10; - uint32_t sources_amount = 0; - const NDIlib_source_t* p_sources = nullptr; - for (int attempt = 0; !sources_amount && attempt < max_attempts; ++attempt) { - if (!dmf::g_running.load(std::memory_order_relaxed)) { - NDIlib_find_destroy(pNDI_find); - pNDI_find = nullptr; - throw std::runtime_error("Interrupted while searching for NDI sources"); - } - NDIlib_find_wait_for_sources(pNDI_find, timeout_ms); - p_sources = NDIlib_find_get_current_sources(pNDI_find, &sources_amount); - } - if (!sources_amount) { - NDIlib_find_destroy(pNDI_find); - pNDI_find = nullptr; - throw std::runtime_error("No NDI sources found after timeout"); - } - - // Copy while finder is alive: p_sources points into finder-owned memory - for (uint32_t i = 0; i < sources_amount; ++i) { - sources->push_back(p_sources[i].p_ndi_name); - cached_names.emplace_back(p_sources[i].p_ndi_name); - cached_urls.emplace_back(p_sources[i].p_url_address); - } - cached_sources.reserve(cached_names.size()); - for (uint32_t i = 0; i < cached_names.size(); ++i) { - cached_sources.push_back({cached_names[i].c_str(), cached_urls[i].c_str()}); - } - - NDIlib_find_destroy(pNDI_find); - pNDI_find = nullptr; - } - - void select_source(uint32_t source_num) { - if (cached_sources.empty()) { - throw std::runtime_error("0 sources found"); - } else if (source_num >= cached_sources.size()) { - throw std::runtime_error("Source_num bigger that sources amount"); - } - - pNDI_recv = NDIlib_recv_create_v3(); - if (!pNDI_recv) { - throw std::runtime_error("Cannot create NDI receive instance"); - } - NDIlib_recv_connect(pNDI_recv, &cached_sources[source_num]); - } - - void get_source_info(uint32_t source_num) { - NDIlib_video_frame_v2_t video_frame; - NDIlib_frame_type_e frame_type; - bool is_got_info = false; - while(!is_got_info) - { - frame_type = NDIlib_recv_capture_v3(pNDI_recv, &video_frame, nullptr, nullptr, 1000); - switch(frame_type) - { - case NDIlib_frame_type_video: - is_got_info = true; - xres = video_frame.xres; - yres = video_frame.yres; - frame_D = video_frame.frame_rate_D; - frame_N = video_frame.frame_rate_N; - fourCC = video_frame.FourCC; - stride = video_frame.line_stride_in_bytes; - if (stride == 0) { - stride = xres * get_bytes_per_pixel(fourCC); - } - break; - case NDIlib_frame_type_error: - is_got_info = true; - throw std::runtime_error("Selected NDI source is lost"); - break; - } - } - NDIlib_recv_free_video_v2(pNDI_recv, &video_frame); - } - - int get_bytes_per_pixel(NDIlib_FourCC_video_type_e fourCC) { - switch (fourCC) { - case NDIlib_FourCC_video_type_UYVY: // Standard 8-bit YUV 4:2:2 - case NDIlib_FourCC_video_type_YV12: // 8-bit YUV 4:2:0 - case NDIlib_FourCC_video_type_I420: // 8-bit YUV 4:2:0 - case NDIlib_FourCC_video_type_NV12: // 8-bit YUV 4:2:0 - // These are 4:2:2 or 4:2:0 formats. - // On average, they use 2 bytes (16 bits) per pixel across the macroblock. - return 2; - - case NDIlib_FourCC_video_type_BGRA: // 8-bit RGB with Alpha - case NDIlib_FourCC_video_type_RGBA: // 8-bit RGB with Alpha - // 4 channels (Red, Green, Blue, Alpha) * 1 byte each - return 4; - - case NDIlib_FourCC_video_type_BGRX: // 8-bit RGB (Padding) - case NDIlib_FourCC_video_type_RGBX: // 8-bit RGB (Padding) - // 4 channels (Red, Green, Blue, Empty) * 1 byte each - return 4; - - case NDIlib_FourCC_video_type_UYVA: // 8-bit YUV 4:2:2 + Alpha channel - // 2 bytes for YUV + 1 byte for Alpha split - return 3; - - case NDIlib_FourCC_video_type_P216: // 16-bit YUV 4:2:2 (High bit depth) - // 2 channels packed at 2 bytes (16-bits) per sample = 4 bytes per pixel - return 4; - - case NDIlib_FourCC_video_type_PA16: // 16-bit YUV 4:2:2 + 16-bit Alpha - return 6; - - default: - return 2; // Safe NDI default fallback - } - } - - std::string fourCCtoStr() { - char fourcc_str[5]; - uint32_t fourcc = (uint32_t)fourCC; - fourcc_str[0] = (fourcc >> 0) & 0xFF; - fourcc_str[1] = (fourcc >> 8) & 0xFF; - fourcc_str[2] = (fourcc >> 16) & 0xFF; - fourcc_str[3] = (fourcc >> 24) & 0xFF; - fourcc_str[4] = '\0'; - return std::string(fourcc_str); - } - - bool getV210_video_frame(uint32_t source_num, uint8_t* frame_buffer, uint32_t frame_stride) { - NDIlib_video_frame_v2_t video_frame; - NDIlib_frame_type_e frame_type; - frame_type = NDIlib_recv_capture_v3(pNDI_recv, &video_frame, nullptr, nullptr, 5); - - if (frame_type == NDIlib_frame_type_error) - throw std::runtime_error("NDI source lost"); - - if (frame_type == NDIlib_frame_type_status_change) { - // Source changed resolution or framerate — refresh internal info, repeat last frame - get_source_info(source_num); - return false; - } - - if (frame_type != NDIlib_frame_type_video) - return false; - - switch (fourCC) { - case NDIlib_FourCC_type_UYVY: - v210::UYVYtoV210(video_frame.p_data, frame_buffer, xres, yres, stride, frame_stride); - break; - case NDIlib_FourCC_type_P216: { - // P216→V210: write directly into caller's buffer via a wrapper frame - NDIlib_video_frame_v2_t dst{}; - dst.p_data = frame_buffer; - dst.line_stride_in_bytes = frame_stride; - NDIlib_util_P216_to_V210(&video_frame, &dst); - break; - } - default: - NDIlib_recv_free_video_v2(pNDI_recv, &video_frame); - throw std::runtime_error("Unsupported NDI color format: " + fourCCtoStr()); - } - NDIlib_recv_free_video_v2(pNDI_recv, &video_frame); - return true; - } - - private: - // receive - NDIlib_find_instance_t pNDI_find = nullptr; - NDIlib_recv_instance_t pNDI_recv = nullptr; - NDIlib_FourCC_type_e fourCC; - // owned copies so finder can be destroyed early - std::vector cached_names; - std::vector cached_urls; - std::vector cached_sources; -}; -} \ No newline at end of file diff --git a/shared/NDIReceiver.hpp b/shared/NDIReceiver.hpp new file mode 100644 index 0000000..aea4654 --- /dev/null +++ b/shared/NDIReceiver.hpp @@ -0,0 +1,191 @@ +#pragma once + +#include +#include +#include +#include +#include "Signal.hpp" +#include "V210.hpp" + +namespace dmf { + +class NDIReceiver { +public: + struct SourceInfo { + int width = 0; + int height = 0; + int fps_num = 0; + int fps_den = 0; + int stride = 0; + NDIlib_FourCC_type_e fourcc{}; + }; + + NDIReceiver() { + if (!NDIlib_is_supported_CPU()) + throw std::runtime_error("CPU is not sufficient for NDI"); + if (!NDIlib_initialize()) + throw std::runtime_error("NDI lib init failed"); + } + + ~NDIReceiver() { + if (recv_) NDIlib_recv_destroy(recv_); + if (find_) NDIlib_find_destroy(find_); + NDIlib_destroy(); + } + + // Discovers NDI sources on the network. Polls up to max_attempts times, + // each waiting timeout_ms milliseconds. Respects g_running. + std::vector find_sources(uint32_t timeout_ms, int max_attempts = 10) { + find_ = NDIlib_find_create_v2(); + if (!find_) + throw std::runtime_error("Cannot create NDI finder"); + + uint32_t count = 0; + const NDIlib_source_t* p_sources = nullptr; + for (int i = 0; !count && i < max_attempts; ++i) { + if (!dmf::g_running.load(std::memory_order_relaxed)) { + NDIlib_find_destroy(find_); + find_ = nullptr; + throw std::runtime_error("Interrupted while searching for NDI sources"); + } + NDIlib_find_wait_for_sources(find_, timeout_ms); + p_sources = NDIlib_find_get_current_sources(find_, &count); + } + if (!count) { + NDIlib_find_destroy(find_); + find_ = nullptr; + throw std::runtime_error("No NDI sources found after timeout"); + } + + // Copy while finder is alive: p_sources points into finder-owned memory + std::vector names; + for (uint32_t i = 0; i < count; ++i) { + names.emplace_back(p_sources[i].p_ndi_name); + source_names_.emplace_back(p_sources[i].p_ndi_name); + source_urls_.emplace_back(p_sources[i].p_url_address); + } + sources_.reserve(source_names_.size()); + for (size_t i = 0; i < source_names_.size(); ++i) + sources_.push_back({source_names_[i].c_str(), source_urls_[i].c_str()}); + + NDIlib_find_destroy(find_); + find_ = nullptr; + return names; + } + + // Connects to a discovered source by index. + void connect(uint32_t source_num) { + if (sources_.empty()) + throw std::runtime_error("No sources available — call find_sources() first"); + if (source_num >= static_cast(sources_.size())) + throw std::runtime_error("source_num exceeds available source count"); + + recv_ = NDIlib_recv_create_v3(); + if (!recv_) + throw std::runtime_error("Cannot create NDI receive instance"); + NDIlib_recv_connect(recv_, &sources_[source_num]); + } + + // Captures the first video frame to determine resolution, frame rate, and format. + // Stores the result internally for use by capture_v210(). Respects g_running. + SourceInfo probe() { + while (dmf::g_running.load(std::memory_order_relaxed)) { + NDIlib_video_frame_v2_t frame; + auto type = NDIlib_recv_capture_v3(recv_, &frame, nullptr, nullptr, 1000); + if (type == NDIlib_frame_type_error) + throw std::runtime_error("NDI source lost during probe"); + if (type != NDIlib_frame_type_video) + continue; + + info_.width = frame.xres; + info_.height = frame.yres; + info_.fps_num = frame.frame_rate_N; + info_.fps_den = frame.frame_rate_D; + info_.fourcc = static_cast(frame.FourCC); + info_.stride = frame.line_stride_in_bytes; + if (info_.stride == 0) + info_.stride = info_.width * bytes_per_pixel(info_.fourcc); + + NDIlib_recv_free_video_v2(recv_, &frame); + return info_; + } + throw std::runtime_error("Interrupted during probe"); + } + + // Captures one video frame and converts it to V210 in frame_buffer. + // Returns false if no frame was available this tick (caller should repeat last frame). + // Throws on source lost or unsupported format. + bool capture_v210(uint8_t* frame_buffer, uint32_t frame_stride) { + NDIlib_video_frame_v2_t frame; + auto type = NDIlib_recv_capture_v3(recv_, &frame, nullptr, nullptr, 5); + + if (type == NDIlib_frame_type_error) + throw std::runtime_error("NDI source lost"); + + if (type == NDIlib_frame_type_status_change) { + info_ = probe(); // source changed resolution or framerate — refresh + return false; + } + + if (type != NDIlib_frame_type_video) + return false; + + switch (info_.fourcc) { + case NDIlib_FourCC_type_UYVY: + v210::UYVYtoV210(frame.p_data, frame_buffer, + info_.width, info_.height, info_.stride, frame_stride); + break; + case NDIlib_FourCC_type_P216: { + NDIlib_video_frame_v2_t dst{}; + dst.p_data = frame_buffer; + dst.line_stride_in_bytes = frame_stride; + NDIlib_util_P216_to_V210(&frame, &dst); + break; + } + default: + NDIlib_recv_free_video_v2(recv_, &frame); + throw std::runtime_error("Unsupported NDI color format: " + fourcc_str(info_.fourcc)); + } + NDIlib_recv_free_video_v2(recv_, &frame); + return true; + } + +private: + NDIlib_find_instance_t find_ = nullptr; + NDIlib_recv_instance_t recv_ = nullptr; + SourceInfo info_; + std::vector source_names_; + std::vector source_urls_; + std::vector sources_; + + static int bytes_per_pixel(NDIlib_FourCC_type_e fc) { + switch (fc) { + case NDIlib_FourCC_video_type_UYVY: + case NDIlib_FourCC_video_type_YV12: + case NDIlib_FourCC_video_type_I420: + case NDIlib_FourCC_video_type_NV12: return 2; + case NDIlib_FourCC_video_type_BGRA: + case NDIlib_FourCC_video_type_RGBA: + case NDIlib_FourCC_video_type_BGRX: + case NDIlib_FourCC_video_type_RGBX: return 4; + case NDIlib_FourCC_video_type_UYVA: return 3; + case NDIlib_FourCC_video_type_P216: return 4; + case NDIlib_FourCC_video_type_PA16: return 6; + default: return 2; + } + } + + static std::string fourcc_str(NDIlib_FourCC_type_e fc) { + uint32_t v = static_cast(fc); + char s[5] = { + static_cast((v >> 0) & 0xFF), + static_cast((v >> 8) & 0xFF), + static_cast((v >> 16) & 0xFF), + static_cast((v >> 24) & 0xFF), + '\0' + }; + return s; + } +}; + +} // namespace dmf From e6d72ab4312608b7cffa38a6acd396f79c9e111a Mon Sep 17 00:00:00 2001 From: JohannesItten Date: Wed, 1 Jul 2026 12:15:44 +0300 Subject: [PATCH 05/15] =?UTF-8?q?fix:=20ndiout=20=E2=80=94=20RAII=20for=20?= =?UTF-8?q?NDI=20lifecycle,=20vectors=20instead=20of=20malloc?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - NDIContext struct handles NDIlib_initialize/send_create/send_destroy/destroy so NDIlib_destroy() is guaranteed even if send_create fails (was leaked before) - malloc/free for 10-bit and 16-bit frame buffers → std::vector - NDI frame structs point into vector data, no manual lifetime management - static_cast for FourCC instead of C-style cast - Tidy: ndi_frame_count replaces ndi_frame_counter, ++prefix form Co-Authored-By: Claude Sonnet 4.6 --- nodes/ndiout/main.cpp | 114 ++++++++++++++++++++++-------------------- 1 file changed, 61 insertions(+), 53 deletions(-) diff --git a/nodes/ndiout/main.cpp b/nodes/ndiout/main.cpp index 51fb3d7..a0ed70a 100644 --- a/nodes/ndiout/main.cpp +++ b/nodes/ndiout/main.cpp @@ -1,5 +1,8 @@ #include +#include +#include #include +#include #include #include #include "NodeBase.hpp" @@ -7,6 +10,29 @@ #include "V210.hpp" #include +// RAII wrapper: init NDI, create sender, destroy both on scope exit. +struct NDIContext { + NDIlib_send_instance_t sender = nullptr; + + explicit NDIContext(const char* ndi_name) { + if (!NDIlib_is_supported_CPU()) + throw std::runtime_error("CPU not sufficient for NDI"); + if (!NDIlib_initialize()) + throw std::runtime_error("NDI lib init failed"); + NDIlib_send_create_t desc{}; + desc.p_ndi_name = ndi_name; + sender = NDIlib_send_create(&desc); + if (!sender) { + NDIlib_destroy(); + throw std::runtime_error("Cannot create NDI send instance"); + } + } + ~NDIContext() { + NDIlib_send_destroy(sender); + NDIlib_destroy(); + } +}; + class NDIOutNode : public dmf::NodeBase { void run() override { const auto flow_info = config().at("flow_id"); @@ -37,51 +63,36 @@ class NDIOutNode : public dmf::NodeBase { mxlFlowReaderGetConfigInfo(reader, &cfg_info); const uint32_t mxl_stride = cfg_info.discrete.sliceSizes[0]; - const mxlRational rate = {fps_num, fps_den}; + NDIContext ndi(flow_id.c_str()); + // V210 (10-bit) intermediate and P216 (16-bit) send buffers + std::vector buf_10bit(mxl_stride * height); + std::vector buf_16bit(width * sizeof(uint16_t) * 2 * height); + + NDIlib_video_frame_v2_t ndi_frame_10bit{}; + ndi_frame_10bit.xres = width; + ndi_frame_10bit.yres = height; + ndi_frame_10bit.FourCC = static_cast(NDI_LIB_FOURCC('V','2','1','0')); + ndi_frame_10bit.line_stride_in_bytes = mxl_stride; + ndi_frame_10bit.p_data = buf_10bit.data(); + + NDIlib_video_frame_v2_t ndi_frame_16bit{}; + ndi_frame_16bit.xres = width; + ndi_frame_16bit.yres = height; + ndi_frame_16bit.frame_rate_N = fps_num; + ndi_frame_16bit.frame_rate_D = fps_den; + ndi_frame_16bit.line_stride_in_bytes = width * static_cast(sizeof(uint16_t)); + ndi_frame_16bit.p_data = buf_16bit.data(); + + const mxlRational rate = {fps_num, fps_den}; uint64_t index = mxlGetCurrentIndex(&rate); uint64_t frame_count = 0; uint64_t invalid_count = 0; uint64_t late_count = 0; + uint64_t ndi_frame_count = 0; auto wall_start = std::chrono::steady_clock::now(); auto last_log_time = wall_start; - // NDI part - if (!NDIlib_initialize()) { - // Cannot run NDI. Most likely because the CPU is not sufficient (see SDK documentation). - log("Cannot run NDI"); - if (!NDIlib_is_supported_CPU()) { - log("CPU is not sufficient for NDI"); - } - return; - } - - NDIlib_send_create_t NDI_send_create_desc; - NDI_send_create_desc.p_ndi_name = flow_id.c_str(); - - NDIlib_send_instance_t pNDI_send = NDIlib_send_create(&NDI_send_create_desc); - if (!pNDI_send) { - log("Cannot create NDI send instance"); - return; - } - - NDIlib_video_frame_v2_t NDI_video_frame_10bit; - NDI_video_frame_10bit.xres = width; - NDI_video_frame_10bit.yres = height; - NDI_video_frame_10bit.FourCC = (NDIlib_FourCC_video_type_e)NDI_LIB_FOURCC('V', '2', '1', '0'); - - NDI_video_frame_10bit.line_stride_in_bytes = mxl_stride; - NDI_video_frame_10bit.p_data = (uint8_t*)malloc(NDI_video_frame_10bit.line_stride_in_bytes * NDI_video_frame_10bit.yres); - - NDIlib_video_frame_v2_t NDI_video_frame_16bit; - NDI_video_frame_16bit.xres = NDI_video_frame_10bit.xres; - NDI_video_frame_16bit.yres = NDI_video_frame_10bit.yres; - NDI_video_frame_16bit.frame_rate_N = fps_num; - NDI_video_frame_16bit.frame_rate_D = fps_den; - NDI_video_frame_16bit.line_stride_in_bytes = NDI_video_frame_16bit.xres * sizeof(uint16_t); - NDI_video_frame_16bit.p_data = (uint8_t*)malloc(NDI_video_frame_16bit.line_stride_in_bytes * 2 * NDI_video_frame_16bit.yres); - // - uint64_t ndi_frame_counter = 0; while (dmf::g_running.load(std::memory_order_relaxed)) { mxlGrainInfo grain{}; uint8_t* buf = nullptr; @@ -92,25 +103,26 @@ class NDIOutNode : public dmf::NodeBase { frame_count++; if (grain.flags & MXL_GRAIN_FLAG_INVALID) invalid_count++; index++; - if (NDIlib_send_get_no_connections(pNDI_send,0) == 0) { - ndi_frame_counter = 0; - continue; + + if (NDIlib_send_get_no_connections(ndi.sender, 0) > 0) { + std::memcpy(ndi_frame_10bit.p_data, buf, mxl_stride * height); + NDIlib_util_V210_to_P216(&ndi_frame_10bit, &ndi_frame_16bit); + NDIlib_send_send_video_v2(ndi.sender, &ndi_frame_16bit); + if (++ndi_frame_count == 1) + log("NDI receiver connected"); + } else { + ndi_frame_count = 0; } - memcpy(NDI_video_frame_10bit.p_data, buf, mxl_stride * height); - NDIlib_util_V210_to_P216(&NDI_video_frame_10bit, &NDI_video_frame_16bit); - NDIlib_send_send_video_v2(pNDI_send, &NDI_video_frame_16bit); - ndi_frame_counter++; - if (ndi_frame_counter == 1) - log("NDI receiver connected"); + } else if (st == MXL_ERR_OUT_OF_RANGE_TOO_EARLY) { - mxlSleepForNs(1'000'000); // 1 ms poll + mxlSleepForNs(1'000'000); } else if (st == MXL_ERR_OUT_OF_RANGE_TOO_LATE) { late_count++; - // Jump to the most recent frame in the ring buffer mxlFlowRuntimeInfo ri{}; mxlFlowReaderGetRuntimeInfo(reader, &ri); index = ri.headIndex; + } else { log("unexpected status=%d on index=%llu", st, index); break; @@ -129,14 +141,10 @@ class NDIOutNode : public dmf::NodeBase { log("stopped — total frames=%llu invalid=%llu late=%llu", frame_count, invalid_count, late_count); mxlReleaseFlowReader(instance(), reader); - free(NDI_video_frame_10bit.p_data); - free(NDI_video_frame_16bit.p_data); - NDIlib_send_destroy(pNDI_send); - NDIlib_destroy(); } }; int main() { NDIOutNode node; return node.execute(); -} \ No newline at end of file +} From 10d12820590ecce4d7cbdffb7fdef458a1de5ce7 Mon Sep 17 00:00:00 2001 From: JohannesItten Date: Wed, 1 Jul 2026 12:18:12 +0300 Subject: [PATCH 06/15] feat: fan-out ndiin video flow to both fakesink and ndiout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Share a single UUID across both edges so both readers connect to the same MXL flow. MXL supports multiple readers per flow natively — each reader has its own read pointer into the ring buffer. Co-Authored-By: Claude Sonnet 4.6 --- studio-manager/main.cpp | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/studio-manager/main.cpp b/studio-manager/main.cpp index 7180c6a..d124143 100644 --- a/studio-manager/main.cpp +++ b/studio-manager/main.cpp @@ -38,12 +38,16 @@ static std::string gen_uuid() { static dmf::FlowGraph build_graph() { dmf::FlowGraph g; + const std::string video_flow = gen_uuid(); g.nodes = { - { "ndiin", "ndiin", {} }, - { "fakesink", "fakesink", {}}, + { "ndiin", "ndiin", {} }, + { "fakesink", "fakesink", {} }, + { "ndiout", "ndiout", {} }, }; g.edges = { - { gen_uuid(), "ndiin", "flow_id", "fakesink", "flow_id", + { video_flow, "ndiin", "flow_id", "fakesink", "flow_id", + { {"kind","video"}, {"width",1920}, {"height",1080}, {"fps_num",25}, {"fps_den",1} } }, + { video_flow, "ndiin", "flow_id", "ndiout", "flow_id", { {"kind","video"}, {"width",1920}, {"height",1080}, {"fps_num",25}, {"fps_den",1} } }, }; return g; From ee47d85cbdb1fffee2178237c1e0601dda014e82 Mon Sep 17 00:00:00 2001 From: JohannesItten Date: Wed, 1 Jul 2026 12:27:06 +0300 Subject: [PATCH 07/15] feat: exit node on NDI source format change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On status_change, probe() the new format and compare against the format used to create the MXL flow. If resolution or fps changed, throw with a descriptive message so the caller exits cleanly. The existing catch in NDIInNode::run() logs the reason and breaks out of the loop, causing the process to exit. Studio-manager detects the exit and logs it — operator can restart to pick up the new format. Same-format status changes (e.g. metadata only) still return false and repeat the last frame as before. Co-Authored-By: Claude Sonnet 4.6 --- shared/NDIReceiver.hpp | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/shared/NDIReceiver.hpp b/shared/NDIReceiver.hpp index aea4654..accc306 100644 --- a/shared/NDIReceiver.hpp +++ b/shared/NDIReceiver.hpp @@ -123,7 +123,18 @@ public: throw std::runtime_error("NDI source lost"); if (type == NDIlib_frame_type_status_change) { - info_ = probe(); // source changed resolution or framerate — refresh + const SourceInfo old = info_; + info_ = probe(); + if (info_.width != old.width || info_.height != old.height || + info_.fps_num != old.fps_num || info_.fps_den != old.fps_den) { + throw std::runtime_error( + "source format changed: " + + std::to_string(old.width) + "x" + std::to_string(old.height) + + " @" + std::to_string(old.fps_num) + "/" + std::to_string(old.fps_den) + + " -> " + + std::to_string(info_.width) + "x" + std::to_string(info_.height) + + " @" + std::to_string(info_.fps_num) + "/" + std::to_string(info_.fps_den)); + } return false; } From cc1011cced95b8c10bee577cd1dcef12f5294670 Mon Sep 17 00:00:00 2001 From: JohannesItten Date: Wed, 1 Jul 2026 12:39:35 +0300 Subject: [PATCH 08/15] feat: NDI in node receives and writes audio flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - FlowDef.hpp: add make_audio_flow_def (NMOS IS-04 audio/Lnn flow) - NDIReceiver: replace capture_v210 with capture() — single recv call dispatches to FrameKind::{Video,Audio,None}; AudioInfo struct carries sample_rate/channels/samples/channel_stride - ndiin: optional audio writer when graph wires audio_flow_id; single loop with 5ms NDI poll; audio grains written immediately on arrival; video grains written when mxlGetCurrentIndex reaches video_index - build_graph: rename ndiin video port flow_id → video_flow_id; add audio_flow_id edge (no sink yet — wired to ndiin only) Co-Authored-By: Claude Sonnet 4.6 --- nodes/ndiin/main.cpp | 153 +++++++++++++++++++++++++++------------- shared/FlowDef.hpp | 32 +++++++++ shared/NDIReceiver.hpp | 76 +++++++++++++------- studio-manager/main.cpp | 15 ++-- 4 files changed, 196 insertions(+), 80 deletions(-) diff --git a/nodes/ndiin/main.cpp b/nodes/ndiin/main.cpp index 0e1388f..54d395d 100644 --- a/nodes/ndiin/main.cpp +++ b/nodes/ndiin/main.cpp @@ -1,3 +1,4 @@ +#include #include #include #include @@ -25,77 +26,129 @@ class NDIInNode : public dmf::NodeBase { return; } - const auto flow_info = config().at("flow_id"); - const auto flow_id = flow_info.at("id").get(); - const int width = flow_info.value("width", src.width); - const int height = flow_info.value("height", src.height); - const int fps_num = flow_info.value("fps_num", src.fps_num); - const int fps_den = flow_info.value("fps_den", src.fps_den); + // --- video flow --- + const auto video_flow_info = config().at("video_flow_id"); + const auto video_flow_id = video_flow_info.at("id").get(); + const int width = video_flow_info.value("width", src.width); + const int height = video_flow_info.value("height", src.height); + const int fps_num = video_flow_info.value("fps_num", src.fps_num); + const int fps_den = video_flow_info.value("fps_den", src.fps_den); - log("flow=%s %dx%d @ %d/%d fps", flow_id.c_str(), width, height, fps_num, fps_den); + log("video flow=%s %dx%d @ %d/%d fps", video_flow_id.c_str(), width, height, fps_num, fps_den); - const std::string flow_def = - dmf::make_video_flow_def(flow_id, node_id(), width, height, fps_num, fps_den); - - mxlFlowWriter writer{}; - mxlFlowConfigInfo cfg_info{}; + mxlFlowWriter video_writer{}; + mxlFlowConfigInfo video_cfg{}; bool created = false; - mxlStatus st = mxlCreateFlowWriter( - instance(), flow_def.c_str(), nullptr, &writer, &cfg_info, &created); - if (st != MXL_STATUS_OK) { - log("mxlCreateFlowWriter failed (status=%d)", st); - return; + instance(), + dmf::make_video_flow_def(video_flow_id, node_id(), width, height, fps_num, fps_den).c_str(), + nullptr, &video_writer, &video_cfg, &created); + if (st != MXL_STATUS_OK) { log("video mxlCreateFlowWriter failed (status=%d)", st); return; } + + const uint32_t video_stride = video_cfg.discrete.sliceSizes[0]; + log("video stride=%u B/line grain=%u B ring=%u grains", + video_stride, video_stride * static_cast(height), video_cfg.discrete.grainCount); + + // --- audio flow (optional — only created when graph wires audio_flow_id) --- + mxlFlowWriter audio_writer{}; + mxlFlowConfigInfo audio_cfg{}; + int sample_rate = 0; + int channels = 0; + int bit_depth = 32; + bool has_audio = config().contains("audio_flow_id"); + + if (has_audio) { + const auto audio_flow_info = config().at("audio_flow_id"); + const auto audio_flow_id = audio_flow_info.at("id").get(); + sample_rate = audio_flow_info.value("sample_rate", 48000); + channels = audio_flow_info.value("channels", 2); + bit_depth = audio_flow_info.value("bit_depth", 32); + + log("audio flow=%s %d Hz %dch %d-bit", audio_flow_id.c_str(), sample_rate, channels, bit_depth); + + mxlStatus ast = mxlCreateFlowWriter( + instance(), + dmf::make_audio_flow_def(audio_flow_id, node_id(), sample_rate, channels, bit_depth, + fps_num, fps_den).c_str(), + nullptr, &audio_writer, &audio_cfg, &created); + if (ast != MXL_STATUS_OK) { + log("audio mxlCreateFlowWriter failed (status=%d) — continuing without audio", ast); + has_audio = false; + } else { + log("audio grain=%u B", audio_cfg.discrete.sliceSizes[0]); + } } - const uint32_t stride = cfg_info.discrete.sliceSizes[0]; - log("stride=%u B/line grain=%u B ring=%u grains", - stride, stride * static_cast(height), cfg_info.discrete.grainCount); + // --- main loop --- + const mxlRational video_rate = {fps_num, fps_den}; + const mxlRational audio_rate = {sample_rate, 1}; - const mxlRational rate = {fps_num, fps_den}; - uint64_t index = mxlGetCurrentIndex(&rate); - log("start index=%llu", index); + uint64_t video_index = mxlGetCurrentIndex(&video_rate); + uint64_t audio_index = has_audio ? mxlGetCurrentIndex(&audio_rate) : 0; + log("start video_index=%llu", video_index); - // NDI can drop to 1fps for static content — hold last valid frame - std::vector latest_frame(stride * height); - bool have_frame = false; + std::vector latest_video(video_stride * height); + bool have_video = false; + std::vector audio_buf; + dmf::NDIReceiver::AudioInfo audio_info; while (dmf::g_running.load(std::memory_order_relaxed)) { + dmf::NDIReceiver::FrameKind kind; try { - if (ndi.capture_v210(latest_frame.data(), stride)) - have_frame = true; + kind = ndi.capture(latest_video.data(), video_stride, audio_buf, audio_info); } catch (const std::runtime_error& e) { log("NDI error: %s — stopping", e.what()); break; } - mxlGrainInfo grain{}; - uint8_t* buf = nullptr; - - st = mxlFlowWriterOpenGrain(writer, index, &grain, &buf); - if (st != MXL_STATUS_OK) { - log("OpenGrain failed (status=%d), skipping index=%llu", st, index); - index++; - continue; + if (kind == dmf::NDIReceiver::FrameKind::Video) { + have_video = true; + } else if (kind == dmf::NDIReceiver::FrameKind::Audio && has_audio) { + mxlGrainInfo grain{}; + uint8_t* buf = nullptr; + if (mxlFlowWriterOpenGrain(audio_writer, audio_index, &grain, &buf) == MXL_STATUS_OK) { + // Convert float32 planar → int32 interleaved PCM + const int audio_grain_size = audio_cfg.discrete.sliceSizes[0]; + const int capacity = audio_grain_size / (channels * static_cast(sizeof(int32_t))); + const int to_write = std::min(audio_info.samples, capacity); + auto* out = reinterpret_cast(buf); + for (int s = 0; s < to_write; ++s) + for (int c = 0; c < audio_info.channels; ++c) { + float f = audio_buf[c * audio_info.channel_stride + s]; + f = std::max(-1.0f, std::min(1.0f, f)); + *out++ = static_cast(f * 2147483647.0f); + } + grain.validSlices = grain.totalSlices; + mxlFlowWriterCommitGrain(audio_writer, &grain); + } + audio_index += audio_info.samples; } - if (have_frame) { - std::memcpy(buf, latest_frame.data(), latest_frame.size()); - grain.flags = 0; - } else { - grain.flags = MXL_GRAIN_FLAG_INVALID; + // Write video grain whenever the MXL clock has reached video_index + const uint64_t current = mxlGetCurrentIndex(&video_rate); + if (current >= video_index) { + mxlGrainInfo grain{}; + uint8_t* buf = nullptr; + st = mxlFlowWriterOpenGrain(video_writer, video_index, &grain, &buf); + if (st == MXL_STATUS_OK) { + if (have_video) { + std::memcpy(buf, latest_video.data(), latest_video.size()); + grain.flags = 0; + } else { + grain.flags = MXL_GRAIN_FLAG_INVALID; + } + grain.validSlices = grain.totalSlices; + mxlFlowWriterCommitGrain(video_writer, &grain); + } else { + log("video OpenGrain failed (status=%d) at index=%llu", st, video_index); + } + video_index = current + 1; } - - grain.validSlices = grain.totalSlices; - mxlFlowWriterCommitGrain(writer, &grain); - - const uint64_t ns = mxlGetNsUntilIndex(index + 1, &rate); - if (ns > 0 && ns < 2'000'000'000ULL) mxlSleepForNs(ns); - index++; } - log("stopped at index=%llu", index); - mxlReleaseFlowWriter(instance(), writer); + log("stopped at video_index=%llu", video_index); + mxlReleaseFlowWriter(instance(), video_writer); + if (has_audio) mxlReleaseFlowWriter(instance(), audio_writer); } }; diff --git a/shared/FlowDef.hpp b/shared/FlowDef.hpp index c6ee717..4af016c 100644 --- a/shared/FlowDef.hpp +++ b/shared/FlowDef.hpp @@ -36,4 +36,36 @@ inline std::string make_video_flow_def( }.dump(); } +// Generates a minimal but valid NMOS IS-04 flow definition JSON string +// for a raw PCM audio flow. grain_rate_num/den sets the grain delivery rate +// (default 25/1 = one grain per video frame). MXL computes grain size as +// sample_rate / grain_rate samples per grain. +inline std::string make_audio_flow_def( + const std::string& flow_id, + const std::string& label, + int sample_rate, + int channels, + int bit_depth = 32, + int grain_rate_num = 25, + int grain_rate_den = 1) +{ + using json = nlohmann::json; + static const char* ch_labels[] = {"L","R","C","LFE","Ls","Rs","Lss","Rss"}; + json ch_arr = json::array(); + for (int i = 0; i < channels; ++i) + ch_arr.push_back({{"label", i < 8 ? ch_labels[i] : ("Ch" + std::to_string(i + 1))}}); + return json{ + {"id", flow_id}, + {"format", "urn:x-nmos:format:audio"}, + {"label", label}, + {"description", label + " MXL Audio Flow"}, + {"media_type", "audio/L" + std::to_string(bit_depth)}, + {"parents", json::array()}, + {"grain_rate", {{"numerator", grain_rate_num}, {"denominator", grain_rate_den}}}, + {"sample_rate", {{"numerator", sample_rate}, {"denominator", 1}}}, + {"channels", ch_arr}, + {"bit_depth", bit_depth}, + }.dump(); +} + } // namespace dmf diff --git a/shared/NDIReceiver.hpp b/shared/NDIReceiver.hpp index accc306..691acd0 100644 --- a/shared/NDIReceiver.hpp +++ b/shared/NDIReceiver.hpp @@ -112,12 +112,24 @@ public: throw std::runtime_error("Interrupted during probe"); } - // Captures one video frame and converts it to V210 in frame_buffer. - // Returns false if no frame was available this tick (caller should repeat last frame). - // Throws on source lost or unsupported format. - bool capture_v210(uint8_t* frame_buffer, uint32_t frame_stride) { - NDIlib_video_frame_v2_t frame; - auto type = NDIlib_recv_capture_v3(recv_, &frame, nullptr, nullptr, 5); + enum class FrameKind { None, Video, Audio }; + + struct AudioInfo { + int sample_rate = 0; + int channels = 0; + int samples = 0; + int channel_stride = 0; // floats between channel planes (NDI planar layout) + }; + + // Receives one NDI frame. On video: converts to V210 in frame_buffer/frame_stride. + // On audio: copies float32 planar samples into audio_out and fills audio_info. + // Returns FrameKind::None on timeout or non-A/V frames. + // Throws on source lost or video format change. + FrameKind capture(uint8_t* frame_buffer, uint32_t frame_stride, + std::vector& audio_out, AudioInfo& audio_info) { + NDIlib_video_frame_v2_t video_frame{}; + NDIlib_audio_frame_v3_t audio_frame{}; + auto type = NDIlib_recv_capture_v3(recv_, &video_frame, &audio_frame, nullptr, 5); if (type == NDIlib_frame_type_error) throw std::runtime_error("NDI source lost"); @@ -135,30 +147,42 @@ public: std::to_string(info_.width) + "x" + std::to_string(info_.height) + " @" + std::to_string(info_.fps_num) + "/" + std::to_string(info_.fps_den)); } - return false; + return FrameKind::None; } - if (type != NDIlib_frame_type_video) - return false; - - switch (info_.fourcc) { - case NDIlib_FourCC_type_UYVY: - v210::UYVYtoV210(frame.p_data, frame_buffer, - info_.width, info_.height, info_.stride, frame_stride); - break; - case NDIlib_FourCC_type_P216: { - NDIlib_video_frame_v2_t dst{}; - dst.p_data = frame_buffer; - dst.line_stride_in_bytes = frame_stride; - NDIlib_util_P216_to_V210(&frame, &dst); - break; + if (type == NDIlib_frame_type_video) { + switch (info_.fourcc) { + case NDIlib_FourCC_type_UYVY: + v210::UYVYtoV210(video_frame.p_data, frame_buffer, + info_.width, info_.height, info_.stride, frame_stride); + break; + case NDIlib_FourCC_type_P216: { + NDIlib_video_frame_v2_t dst{}; + dst.p_data = frame_buffer; + dst.line_stride_in_bytes = frame_stride; + NDIlib_util_P216_to_V210(&video_frame, &dst); + break; + } + default: + NDIlib_recv_free_video_v2(recv_, &video_frame); + throw std::runtime_error("Unsupported NDI color format: " + fourcc_str(info_.fourcc)); } - default: - NDIlib_recv_free_video_v2(recv_, &frame); - throw std::runtime_error("Unsupported NDI color format: " + fourcc_str(info_.fourcc)); + NDIlib_recv_free_video_v2(recv_, &video_frame); + return FrameKind::Video; } - NDIlib_recv_free_video_v2(recv_, &frame); - return true; + + if (type == NDIlib_frame_type_audio) { + audio_info.sample_rate = audio_frame.sample_rate; + audio_info.channels = audio_frame.no_channels; + audio_info.samples = audio_frame.no_samples; + audio_info.channel_stride = audio_frame.channel_stride_in_bytes / sizeof(float); + const int total = audio_frame.no_channels * audio_info.channel_stride; + audio_out.assign(audio_frame.p_data, audio_frame.p_data + total); + NDIlib_recv_free_audio_v3(recv_, &audio_frame); + return FrameKind::Audio; + } + + return FrameKind::None; } private: diff --git a/studio-manager/main.cpp b/studio-manager/main.cpp index d124143..43cc912 100644 --- a/studio-manager/main.cpp +++ b/studio-manager/main.cpp @@ -39,16 +39,23 @@ static std::string gen_uuid() { static dmf::FlowGraph build_graph() { dmf::FlowGraph g; const std::string video_flow = gen_uuid(); + const std::string audio_flow = gen_uuid(); g.nodes = { { "ndiin", "ndiin", {} }, { "fakesink", "fakesink", {} }, { "ndiout", "ndiout", {} }, }; + const nlohmann::json video_fmt = { + {"kind","video"}, {"width",1920}, {"height",1080}, {"fps_num",25}, {"fps_den",1} + }; + const nlohmann::json audio_fmt = { + {"kind","audio"}, {"sample_rate",48000}, {"channels",2}, {"bit_depth",32} + }; g.edges = { - { video_flow, "ndiin", "flow_id", "fakesink", "flow_id", - { {"kind","video"}, {"width",1920}, {"height",1080}, {"fps_num",25}, {"fps_den",1} } }, - { video_flow, "ndiin", "flow_id", "ndiout", "flow_id", - { {"kind","video"}, {"width",1920}, {"height",1080}, {"fps_num",25}, {"fps_den",1} } }, + { video_flow, "ndiin", "video_flow_id", "fakesink", "flow_id", video_fmt }, + { video_flow, "ndiin", "video_flow_id", "ndiout", "flow_id", video_fmt }, + // audio_flow_id wired to ndiin only — no sink node yet, readers added later + { audio_flow, "ndiin", "audio_flow_id", "", "", audio_fmt }, }; return g; } From fe3b5969ca23d2d39d7a6343239bd64cac367566 Mon Sep 17 00:00:00 2001 From: JohannesItten Date: Wed, 1 Jul 2026 12:47:40 +0300 Subject: [PATCH 09/15] fix: add required grouphint tag to make_audio_flow_def MXL FlowParser requires urn:x-nmos:tag:grouphint/v1.0 in the tags object of every flow definition. make_audio_flow_def was missing tags entirely, causing FlowParser.cpp:192 "Invalid group hint tag". Co-Authored-By: Claude Sonnet 4.6 --- shared/FlowDef.hpp | 3 +++ 1 file changed, 3 insertions(+) diff --git a/shared/FlowDef.hpp b/shared/FlowDef.hpp index 4af016c..ffd7a1f 100644 --- a/shared/FlowDef.hpp +++ b/shared/FlowDef.hpp @@ -65,6 +65,9 @@ inline std::string make_audio_flow_def( {"sample_rate", {{"numerator", sample_rate}, {"denominator", 1}}}, {"channels", ch_arr}, {"bit_depth", bit_depth}, + {"tags", { + {"urn:x-nmos:tag:grouphint/v1.0", json::array({label + ":Audio"})} + }}, }.dump(); } From 72a554a17202b93f09bc2b3046bbddc2da01c0cd Mon Sep 17 00:00:00 2001 From: JohannesItten Date: Wed, 1 Jul 2026 12:51:13 +0300 Subject: [PATCH 10/15] fix: add channel_count to make_audio_flow_def MXL reads channel_count (plain integer) to determine grain buffer geometry, defaulting to 1 if absent. The NMOS IS-04 channels array is ignored by MXL. Without channel_count, stereo audio grains were half the required size. Also: MXL only accepts bit_depth 32 or 64 for audio flows. Co-Authored-By: Claude Sonnet 4.6 --- shared/FlowDef.hpp | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/shared/FlowDef.hpp b/shared/FlowDef.hpp index ffd7a1f..cf42119 100644 --- a/shared/FlowDef.hpp +++ b/shared/FlowDef.hpp @@ -63,8 +63,9 @@ inline std::string make_audio_flow_def( {"parents", json::array()}, {"grain_rate", {{"numerator", grain_rate_num}, {"denominator", grain_rate_den}}}, {"sample_rate", {{"numerator", sample_rate}, {"denominator", 1}}}, - {"channels", ch_arr}, - {"bit_depth", bit_depth}, + {"channels", ch_arr}, // NMOS IS-04 metadata (ignored by MXL) + {"channel_count", channels}, // MXL uses this for grain buffer geometry + {"bit_depth", bit_depth}, // must be 32 or 64 {"tags", { {"urn:x-nmos:tag:grouphint/v1.0", json::array({label + ":Audio"})} }}, From aa39a821dbe3b0c63c18e031905457775e338b5d Mon Sep 17 00:00:00 2001 From: JohannesItten Date: Wed, 1 Jul 2026 12:56:55 +0300 Subject: [PATCH 11/15] fix: use continuous sample API for audio flow in ndiin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audio flows in MXL use mxlFlowWriterOpenSamples/CommitSamples, not the grain API (mxlFlowWriterOpenGrain is for discrete video flows). Using the grain API silently did nothing — hence "last published index: 0". MXL audio is float32 planar: each channel occupies its own ring buffer region accessed at base.fragments[*].pointer + c * slice.stride. Two fragments handle ring buffer wraparound. No int32 conversion needed. Also log continuous.channelCount/bufferLength instead of the discrete sliceSizes field (wrong union member for audio). Co-Authored-By: Claude Sonnet 4.6 --- nodes/ndiin/main.cpp | 37 ++++++++++++++++++++----------------- 1 file changed, 20 insertions(+), 17 deletions(-) diff --git a/nodes/ndiin/main.cpp b/nodes/ndiin/main.cpp index 54d395d..efc2f74 100644 --- a/nodes/ndiin/main.cpp +++ b/nodes/ndiin/main.cpp @@ -1,4 +1,3 @@ -#include #include #include #include @@ -75,7 +74,8 @@ class NDIInNode : public dmf::NodeBase { log("audio mxlCreateFlowWriter failed (status=%d) — continuing without audio", ast); has_audio = false; } else { - log("audio grain=%u B", audio_cfg.discrete.sliceSizes[0]); + log("audio channels=%u buffer=%u samples", + audio_cfg.continuous.channelCount, audio_cfg.continuous.bufferLength); } } @@ -104,22 +104,25 @@ class NDIInNode : public dmf::NodeBase { if (kind == dmf::NDIReceiver::FrameKind::Video) { have_video = true; } else if (kind == dmf::NDIReceiver::FrameKind::Audio && has_audio) { - mxlGrainInfo grain{}; - uint8_t* buf = nullptr; - if (mxlFlowWriterOpenGrain(audio_writer, audio_index, &grain, &buf) == MXL_STATUS_OK) { - // Convert float32 planar → int32 interleaved PCM - const int audio_grain_size = audio_cfg.discrete.sliceSizes[0]; - const int capacity = audio_grain_size / (channels * static_cast(sizeof(int32_t))); - const int to_write = std::min(audio_info.samples, capacity); - auto* out = reinterpret_cast(buf); - for (int s = 0; s < to_write; ++s) - for (int c = 0; c < audio_info.channels; ++c) { - float f = audio_buf[c * audio_info.channel_stride + s]; - f = std::max(-1.0f, std::min(1.0f, f)); - *out++ = static_cast(f * 2147483647.0f); + mxlMutableWrappedMultiBufferSlice slice{}; + if (mxlFlowWriterOpenSamples(audio_writer, audio_index, + static_cast(audio_info.samples), &slice) == MXL_STATUS_OK) { + // MXL audio is float32 planar: each channel occupies its own ring buffer + // region, accessed at base + c * stride. Fragments handle ring wraparound. + const size_t frag0 = slice.base.fragments[0].size / sizeof(float); + const size_t frag1 = slice.base.fragments[1].size / sizeof(float); + for (int c = 0; c < audio_info.channels; ++c) { + const float* src = &audio_buf[c * audio_info.channel_stride]; + auto* dst0 = reinterpret_cast( + static_cast(slice.base.fragments[0].pointer) + c * slice.stride); + std::memcpy(dst0, src, frag0 * sizeof(float)); + if (frag1 > 0) { + auto* dst1 = reinterpret_cast( + static_cast(slice.base.fragments[1].pointer) + c * slice.stride); + std::memcpy(dst1, src + frag0, frag1 * sizeof(float)); } - grain.validSlices = grain.totalSlices; - mxlFlowWriterCommitGrain(audio_writer, &grain); + } + mxlFlowWriterCommitSamples(audio_writer); } audio_index += audio_info.samples; } From 31b57f7a3382966565d4de6db7402ae004330dd7 Mon Sep 17 00:00:00 2001 From: JohannesItten Date: Wed, 1 Jul 2026 13:08:04 +0300 Subject: [PATCH 12/15] fix: cast NDI p_data to float* before copying audio samples MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NDIlib_audio_frame_v3_t.p_data is uint8_t*, not float*. Calling vector::assign(uint8_t*, uint8_t*+N) converted each individual byte (0-255) into a separate float instead of reinterpreting 4 bytes as one float sample — producing complete noise on playback. Co-Authored-By: Claude Sonnet 4.6 --- shared/NDIReceiver.hpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/shared/NDIReceiver.hpp b/shared/NDIReceiver.hpp index 691acd0..638fcb7 100644 --- a/shared/NDIReceiver.hpp +++ b/shared/NDIReceiver.hpp @@ -177,7 +177,8 @@ public: audio_info.samples = audio_frame.no_samples; audio_info.channel_stride = audio_frame.channel_stride_in_bytes / sizeof(float); const int total = audio_frame.no_channels * audio_info.channel_stride; - audio_out.assign(audio_frame.p_data, audio_frame.p_data + total); + const auto* fdata = reinterpret_cast(audio_frame.p_data); + audio_out.assign(fdata, fdata + total); NDIlib_recv_free_audio_v3(recv_, &audio_frame); return FrameKind::Audio; } From 42452843823c8c0a8814c3a3ebdb441ea683bdc8 Mon Sep 17 00:00:00 2001 From: JohannesItten Date: Wed, 1 Jul 2026 13:19:16 +0300 Subject: [PATCH 13/15] fix: skip non-FLTP audio frames in NDIReceiver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NDIlib_audio_frame_v3_t carries a FourCC. Only FLTP (float32 planar) is handled — skip and free any other format rather than misinterpreting compressed or integer audio bytes as floats. Co-Authored-By: Claude Sonnet 4.6 --- shared/NDIReceiver.hpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/shared/NDIReceiver.hpp b/shared/NDIReceiver.hpp index 638fcb7..848f247 100644 --- a/shared/NDIReceiver.hpp +++ b/shared/NDIReceiver.hpp @@ -172,6 +172,10 @@ public: } if (type == NDIlib_frame_type_audio) { + if (audio_frame.FourCC != NDIlib_FourCC_audio_type_FLTP) { + NDIlib_recv_free_audio_v3(recv_, &audio_frame); + return FrameKind::None; // compressed or unknown format — skip + } audio_info.sample_rate = audio_frame.sample_rate; audio_info.channels = audio_frame.no_channels; audio_info.samples = audio_frame.no_samples; From 42507886fed743a6938118d728aefb5f5b44eff2 Mon Sep 17 00:00:00 2001 From: JohannesItten Date: Wed, 1 Jul 2026 13:24:06 +0300 Subject: [PATCH 14/15] feat: multiple test patterns in testpattern node V210.hpp: add fill_colorbars, fill_ire_ramp, fill_black, fill_white. Shared fill_solid helper stamps the first line across all rows via memcpy. Generic write_palette_line template handles both SMPTE and IRE bar palettes. fill_frame kept as a backward-compat alias. testpattern/main.cpp: read "pattern" from node config and dispatch to the appropriate fill function (bars/ire/black/white). build_graph: pass {"pattern","bars"} to testpattern node params. Co-Authored-By: Claude Sonnet 4.6 --- nodes/testpattern/main.cpp | 14 ++++++--- shared/V210.hpp | 64 +++++++++++++++++++++++++++++++++++--- studio-manager/main.cpp | 7 +++-- 3 files changed, 74 insertions(+), 11 deletions(-) diff --git a/nodes/testpattern/main.cpp b/nodes/testpattern/main.cpp index 5752a5d..954620d 100644 --- a/nodes/testpattern/main.cpp +++ b/nodes/testpattern/main.cpp @@ -30,9 +30,11 @@ class TestPatternNode : public dmf::NodeBase { return; } - const uint32_t stride = cfg_info.discrete.sliceSizes[0]; - log("stride=%u B/line grain=%u B ring=%u grains", - stride, stride * static_cast(height), cfg_info.discrete.grainCount); + const uint32_t stride = cfg_info.discrete.sliceSizes[0]; + const auto pattern = config().value("pattern", "bars"); + log("stride=%u B/line grain=%u B ring=%u grains pattern=%s", + stride, stride * static_cast(height), cfg_info.discrete.grainCount, + pattern.c_str()); const mxlRational rate = {fps_num, fps_den}; uint64_t index = mxlGetCurrentIndex(&rate); @@ -49,7 +51,11 @@ class TestPatternNode : public dmf::NodeBase { continue; } - dmf::v210::fill_frame(buf, width, height, stride); + if (pattern == "bars") dmf::v210::fill_colorbars(buf, width, height, stride); + else if (pattern == "ire") dmf::v210::fill_ire_ramp (buf, width, height, stride); + else if (pattern == "black") dmf::v210::fill_black (buf, width, height, stride); + else if (pattern == "white") dmf::v210::fill_white (buf, width, height, stride); + else dmf::v210::fill_colorbars (buf, width, height, stride); grain.flags = 0; grain.validSlices = grain.totalSlices; // mark grain complete so readers can consume it mxlFlowWriterCommitGrain(writer, &grain); diff --git a/shared/V210.hpp b/shared/V210.hpp index e3e9fd4..4dc48c5 100644 --- a/shared/V210.hpp +++ b/shared/V210.hpp @@ -2,6 +2,7 @@ #include #include #include +#include namespace dmf::v210 { @@ -82,12 +83,67 @@ inline void write_bar_line(uint8_t* line, int width, uint32_t /*stride*/) } } -// Fill an entire frame buffer with color bars. +// Write one horizontal line of an arbitrary bar palette. +template +inline void write_palette_line(uint8_t* line, int width, const std::array& palette) +{ + const int n = static_cast(N); + const int blocks = width / 6; + for (int b = 0; b < blocks; b++) { + int x = b * 6; + auto color = [&](int px) -> const Color& { + return palette[static_cast(px * n / width)]; + }; + const Color& c01 = color(x); + const Color& c23 = color(x + 2); + const Color& c45 = color(x + 4); + pack_block(line + b * 16, + c01, c01.y, color(x+1).y, + c23, c23.y, color(x+3).y, + c45, c45.y, color(x+5).y); + } +} + +// Fill a frame with a solid color. +inline void fill_solid(uint8_t* buf, int width, int height, uint32_t stride, Color c) +{ + const int blocks = width / 6; + for (int b = 0; b < blocks; b++) + pack_block(buf + b * 16, c, c.y, c.y, c, c.y, c.y, c, c.y, c.y); + for (int y = 1; y < height; y++) + std::memcpy(buf + static_cast(y) * stride, buf, blocks * 16); +} + +// SMPTE 75% color bars. +inline void fill_colorbars(uint8_t* buf, int width, int height, uint32_t stride) +{ + for (int y = 0; y < height; y++) + write_palette_line(buf + static_cast(y) * stride, width, SMPTE_BARS); +} + +// IRE 11-step greyscale ramp. +inline void fill_ire_ramp(uint8_t* buf, int width, int height, uint32_t stride) +{ + for (int y = 0; y < height; y++) + write_palette_line(buf + static_cast(y) * stride, width, IRE_BARS); +} + +// 10-bit limited black (Y=64, Cb=Cr=512). +inline void fill_black(uint8_t* buf, int width, int height, uint32_t stride) +{ + fill_solid(buf, width, height, stride, {64, 512, 512}); +} + +// 10-bit limited white (Y=940, Cb=Cr=512). +inline void fill_white(uint8_t* buf, int width, int height, uint32_t stride) +{ + fill_solid(buf, width, height, stride, {940, 512, 512}); +} + +// Kept for backward compatibility. inline void fill_frame(uint8_t* buf, int width, int height, uint32_t stride) { - for (int y = 0; y < height; y++) { - write_bar_line(buf + static_cast(y) * stride, width, stride); - } + fill_colorbars(buf, width, height, stride); } inline void UYVYtoV210(uint8_t* src_buf, uint8_t* dst_buf, int width, int height, uint32_t src_stride, uint32_t dst_stride) diff --git a/studio-manager/main.cpp b/studio-manager/main.cpp index 43cc912..582ff64 100644 --- a/studio-manager/main.cpp +++ b/studio-manager/main.cpp @@ -41,9 +41,10 @@ static dmf::FlowGraph build_graph() { const std::string video_flow = gen_uuid(); const std::string audio_flow = gen_uuid(); g.nodes = { - { "ndiin", "ndiin", {} }, - { "fakesink", "fakesink", {} }, - { "ndiout", "ndiout", {} }, + { "testpattern", "testpattern", {{"pattern", "bars"}} }, + { "ndiin", "ndiin", {} }, + { "fakesink", "fakesink", {} }, + { "ndiout", "ndiout", {} }, }; const nlohmann::json video_fmt = { {"kind","video"}, {"width",1920}, {"height",1080}, {"fps_num",25}, {"fps_den",1} From bb4d527cbe78fcf2fa187bc6fcb3a5f3b4753c9d Mon Sep 17 00:00:00 2001 From: JohannesItten Date: Wed, 1 Jul 2026 13:26:22 +0300 Subject: [PATCH 15/15] feat: sine tone audio in testpattern node testpattern now optionally writes an audio flow when audio_flow_id is wired in the graph. Each channel generates a sine wave at (c+1)*1000 Hz (ch0=1kHz, ch1=2kHz, ...) at -18 dBFS broadcast reference level. Phase is derived from the absolute audio_index so there are no clicks at frame boundaries. One audio chunk per video frame (1920 samples at 48kHz/25fps), written via mxlFlowWriterOpenSamples with ring-buffer fragment and channel stride handling matching ndiin. build_graph: separate UUIDs for testpattern and ndiin flows; wire testpattern audio_flow_id (no sink yet). Co-Authored-By: Claude Sonnet 4.6 --- nodes/testpattern/main.cpp | 110 +++++++++++++++++++++++++++++-------- studio-manager/main.cpp | 14 +++-- 2 files changed, 96 insertions(+), 28 deletions(-) diff --git a/nodes/testpattern/main.cpp b/nodes/testpattern/main.cpp index 954620d..a8f43e7 100644 --- a/nodes/testpattern/main.cpp +++ b/nodes/testpattern/main.cpp @@ -1,3 +1,5 @@ +#include +#include #include #include #include @@ -7,43 +9,77 @@ class TestPatternNode : public dmf::NodeBase { void run() override { + // --- video flow --- const auto flow_info = config().at("flow_id"); const auto flow_id = flow_info.at("id").get(); const int width = flow_info.value("width", 1920); const int height = flow_info.value("height", 1080); const int fps_num = flow_info.value("fps_num", 25); const int fps_den = flow_info.value("fps_den", 1); + const auto pattern = config().value("pattern", "bars"); - log("flow=%s %dx%d @ %d/%d fps", flow_id.c_str(), width, height, fps_num, fps_den); - - const std::string flow_def = - dmf::make_video_flow_def(flow_id, node_id(), width, height, fps_num, fps_den); + log("flow=%s %dx%d @ %d/%d fps pattern=%s", + flow_id.c_str(), width, height, fps_num, fps_den, pattern.c_str()); mxlFlowWriter writer{}; mxlFlowConfigInfo cfg_info{}; bool created = false; - mxlStatus st = mxlCreateFlowWriter( - instance(), flow_def.c_str(), nullptr, &writer, &cfg_info, &created); - if (st != MXL_STATUS_OK) { - log("mxlCreateFlowWriter failed (status=%d)", st); - return; + instance(), + dmf::make_video_flow_def(flow_id, node_id(), width, height, fps_num, fps_den).c_str(), + nullptr, &writer, &cfg_info, &created); + if (st != MXL_STATUS_OK) { log("video mxlCreateFlowWriter failed (status=%d)", st); return; } + + const uint32_t stride = cfg_info.discrete.sliceSizes[0]; + log("video stride=%u B/line grain=%u B ring=%u grains", + stride, stride * static_cast(height), cfg_info.discrete.grainCount); + + // --- audio flow (optional) --- + mxlFlowWriter audio_writer{}; + mxlFlowConfigInfo audio_cfg{}; + int sample_rate = 48000; + int channels = 2; + bool has_audio = config().contains("audio_flow_id"); + + if (has_audio) { + const auto audio_info = config().at("audio_flow_id"); + const auto audio_id = audio_info.at("id").get(); + sample_rate = audio_info.value("sample_rate", 48000); + channels = audio_info.value("channels", 2); + + log("audio flow=%s %d Hz %dch", audio_id.c_str(), sample_rate, channels); + + mxlStatus ast = mxlCreateFlowWriter( + instance(), + dmf::make_audio_flow_def(audio_id, node_id(), sample_rate, channels, 32, + fps_num, fps_den).c_str(), + nullptr, &audio_writer, &audio_cfg, &created); + if (ast != MXL_STATUS_OK) { + log("audio mxlCreateFlowWriter failed (status=%d) — continuing without audio", ast); + has_audio = false; + } else { + log("audio channels=%u buffer=%u samples", + audio_cfg.continuous.channelCount, audio_cfg.continuous.bufferLength); + } } - const uint32_t stride = cfg_info.discrete.sliceSizes[0]; - const auto pattern = config().value("pattern", "bars"); - log("stride=%u B/line grain=%u B ring=%u grains pattern=%s", - stride, stride * static_cast(height), cfg_info.discrete.grainCount, - pattern.c_str()); + // --- main loop --- + const mxlRational video_rate = {fps_num, fps_den}; + const mxlRational audio_rate = {sample_rate, 1}; + const size_t samples_per_frame = + static_cast(sample_rate) * static_cast(fps_den) / static_cast(fps_num); - const mxlRational rate = {fps_num, fps_den}; - uint64_t index = mxlGetCurrentIndex(&rate); - log("start index=%llu", index); + // -18 dBFS broadcast reference level + const float amplitude = static_cast(std::pow(10.0, -18.0 / 20.0)); + + uint64_t index = mxlGetCurrentIndex(&video_rate); + uint64_t audio_index = has_audio ? mxlGetCurrentIndex(&audio_rate) : 0; + log("start video_index=%llu", index); while (dmf::g_running.load(std::memory_order_relaxed)) { + // --- video grain --- mxlGrainInfo grain{}; uint8_t* buf = nullptr; - st = mxlFlowWriterOpenGrain(writer, index, &grain, &buf); if (st != MXL_STATUS_OK) { log("OpenGrain failed (status=%d), skipping index=%llu", st, index); @@ -56,17 +92,47 @@ class TestPatternNode : public dmf::NodeBase { else if (pattern == "black") dmf::v210::fill_black (buf, width, height, stride); else if (pattern == "white") dmf::v210::fill_white (buf, width, height, stride); else dmf::v210::fill_colorbars (buf, width, height, stride); - grain.flags = 0; - grain.validSlices = grain.totalSlices; // mark grain complete so readers can consume it + grain.flags = 0; + grain.validSlices = grain.totalSlices; mxlFlowWriterCommitGrain(writer, &grain); - const uint64_t ns = mxlGetNsUntilIndex(index + 1, &rate); + // --- audio: sine tones, channel c = (c+1) * 1000 Hz --- + if (has_audio) { + mxlMutableWrappedMultiBufferSlice slice{}; + if (mxlFlowWriterOpenSamples(audio_writer, audio_index, + samples_per_frame, &slice) == MXL_STATUS_OK) { + const size_t frag0 = slice.base.fragments[0].size / sizeof(float); + const size_t frag1 = slice.base.fragments[1].size / sizeof(float); + for (int c = 0; c < channels; ++c) { + const double freq = 1000.0 * (c + 1); + const double period = static_cast(sample_rate) / freq; + auto write_samples = [&](float* dst, size_t count, size_t offset) { + for (size_t s = 0; s < count; ++s) + dst[s] = amplitude * static_cast( + std::sin(2.0 * std::numbers::pi * (audio_index + offset + s) / period)); + }; + auto* dst0 = reinterpret_cast( + static_cast(slice.base.fragments[0].pointer) + c * slice.stride); + write_samples(dst0, frag0, 0); + if (frag1 > 0) { + auto* dst1 = reinterpret_cast( + static_cast(slice.base.fragments[1].pointer) + c * slice.stride); + write_samples(dst1, frag1, frag0); + } + } + mxlFlowWriterCommitSamples(audio_writer); + } + audio_index += samples_per_frame; + } + + const uint64_t ns = mxlGetNsUntilIndex(index + 1, &video_rate); if (ns > 0 && ns < 2'000'000'000ULL) mxlSleepForNs(ns); index++; } - log("stopped at index=%llu", index); + log("stopped at video_index=%llu", index); mxlReleaseFlowWriter(instance(), writer); + if (has_audio) mxlReleaseFlowWriter(instance(), audio_writer); } }; diff --git a/studio-manager/main.cpp b/studio-manager/main.cpp index 582ff64..5d37016 100644 --- a/studio-manager/main.cpp +++ b/studio-manager/main.cpp @@ -38,8 +38,10 @@ static std::string gen_uuid() { static dmf::FlowGraph build_graph() { dmf::FlowGraph g; - const std::string video_flow = gen_uuid(); - const std::string audio_flow = gen_uuid(); + const std::string tp_video_flow = gen_uuid(); + const std::string tp_audio_flow = gen_uuid(); + const std::string ndi_video_flow = gen_uuid(); + const std::string ndi_audio_flow = gen_uuid(); g.nodes = { { "testpattern", "testpattern", {{"pattern", "bars"}} }, { "ndiin", "ndiin", {} }, @@ -53,10 +55,10 @@ static dmf::FlowGraph build_graph() { {"kind","audio"}, {"sample_rate",48000}, {"channels",2}, {"bit_depth",32} }; g.edges = { - { video_flow, "ndiin", "video_flow_id", "fakesink", "flow_id", video_fmt }, - { video_flow, "ndiin", "video_flow_id", "ndiout", "flow_id", video_fmt }, - // audio_flow_id wired to ndiin only — no sink node yet, readers added later - { audio_flow, "ndiin", "audio_flow_id", "", "", audio_fmt }, + { tp_video_flow, "testpattern", "flow_id", "fakesink", "flow_id", video_fmt }, + { tp_audio_flow, "testpattern", "audio_flow_id", "", "", audio_fmt }, + { ndi_video_flow, "ndiin", "video_flow_id", "ndiout", "flow_id", video_fmt }, + { ndi_audio_flow, "ndiin", "audio_flow_id", "", "", audio_fmt }, }; return g; }