// SPDX-License-Identifier: GPL-2.0-or-later // Copyright (c) 2026 OokamiKunTV #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include "bounded_spsc_queue.hpp" #pragma comment(lib, "bcrypt.lib") #ifndef LUMI_BRIDGE_VERSION #define LUMI_BRIDGE_VERSION "0.1.0-development" #endif OBS_DECLARE_MODULE() OBS_MODULE_USE_DEFAULT_LOCALE("lumi-obs-bridge", "en-US") namespace { using json = nlohmann::json; constexpr uint8_t protocol_version = 1; constexpr size_t max_json_bytes = 64 * 1024; constexpr size_t max_input_frames = 4096; constexpr uint32_t output_sample_rate = 16000; constexpr char bridge_version[] = LUMI_BRIDGE_VERSION; struct raw_audio_packet { std::array mono{}; std::array source_uuid{}; uint64_t timestamp_ns = 0; uint32_t frames = 0; uint32_t sample_rate = 48000; bool active = false; bool muted = false; }; struct source_description { std::string uuid; std::string name; bool program_active = false; }; static lumi::bounded_spsc_queue audio_queue; static std::condition_variable worker_signal; static std::mutex worker_signal_mutex; static std::thread worker_thread; static std::atomic_bool stopping{false}; static std::atomic_bool source_list_dirty{true}; static std::atomic_bool obs_state_dirty{true}; static std::atomic_uint32_t audio_sequence{0}; static obs_source_t *captured_source = nullptr; static std::mutex captured_source_mutex; static std::string wide_to_utf8(const std::wstring &value) { if (value.empty()) return {}; const int bytes = WideCharToMultiByte(CP_UTF8, 0, value.data(), static_cast(value.size()), nullptr, 0, nullptr, nullptr); std::string output(static_cast(bytes), '\0'); WideCharToMultiByte(CP_UTF8, 0, value.data(), static_cast(value.size()), output.data(), bytes, nullptr, nullptr); return output; } static std::wstring environment_value(const wchar_t *name) { const DWORD needed = GetEnvironmentVariableW(name, nullptr, 0); if (!needed) return {}; std::wstring value(needed - 1, L'\0'); GetEnvironmentVariableW(name, value.data(), needed); return value; } static std::string current_user_hash() { const auto identity = wide_to_utf8(environment_value(L"USERDOMAIN") + L"\\" + environment_value(L"USERNAME")); BCRYPT_ALG_HANDLE algorithm = nullptr; BCRYPT_HASH_HANDLE hash = nullptr; std::array digest{}; if (BCryptOpenAlgorithmProvider(&algorithm, BCRYPT_SHA256_ALGORITHM, nullptr, 0) < 0) return {}; DWORD object_size = 0, result_size = 0; BCryptGetProperty(algorithm, BCRYPT_OBJECT_LENGTH, reinterpret_cast(&object_size), sizeof(object_size), &result_size, 0); std::vector object(object_size); const bool ok = BCryptCreateHash(algorithm, &hash, object.data(), object_size, nullptr, 0, 0) >= 0 && BCryptHashData(hash, reinterpret_cast(const_cast(identity.data())), static_cast(identity.size()), 0) >= 0 && BCryptFinishHash(hash, digest.data(), static_cast(digest.size()), 0) >= 0; if (hash) BCryptDestroyHash(hash); BCryptCloseAlgorithmProvider(algorithm, 0); if (!ok) return {}; constexpr char hex[] = "0123456789ABCDEF"; std::string output; output.reserve(16); for (size_t index = 0; index < 8; ++index) { output.push_back(hex[digest[index] >> 4]); output.push_back(hex[digest[index] & 0x0f]); } return output; } static bool parse_uuid(const std::string &value, std::array &output) { std::string hex; for (const auto character : value) if (character != '-') hex.push_back(character); if (hex.size() != 32) return false; const auto nibble = [](char value) -> int { if (value >= '0' && value <= '9') return value - '0'; if (value >= 'a' && value <= 'f') return value - 'a' + 10; if (value >= 'A' && value <= 'F') return value - 'A' + 10; return -1; }; for (size_t index = 0; index < output.size(); ++index) { const auto high = nibble(hex[index * 2]); const auto low = nibble(hex[index * 2 + 1]); if (high < 0 || low < 0) return false; output[index] = static_cast((high << 4) | low); } return true; } static void write_u16(std::vector &value, size_t offset, uint16_t input) { value[offset] = static_cast(input); value[offset + 1] = static_cast(input >> 8); } static void write_u32(std::vector &value, size_t offset, uint32_t input) { for (size_t index = 0; index < 4; ++index) value[offset + index] = static_cast(input >> (index * 8)); } static void write_u64(std::vector &value, size_t offset, uint64_t input) { for (size_t index = 0; index < 8; ++index) value[offset + index] = static_cast(input >> (index * 8)); } static bool write_exact(HANDLE pipe, const uint8_t *data, size_t size) { while (size > 0) { DWORD written = 0; const auto chunk = static_cast(std::min(size, 64 * 1024)); if (!WriteFile(pipe, data, chunk, &written, nullptr) || written == 0) return false; data += written; size -= written; } return true; } static bool read_exact(HANDLE pipe, uint8_t *data, size_t size) { while (size > 0) { DWORD read = 0; if (!ReadFile(pipe, data, static_cast(size), &read, nullptr) || read == 0) return false; data += read; size -= read; } return true; } static bool write_frame(HANDLE pipe, const uint8_t *data, size_t size) { std::array prefix{ static_cast(size), static_cast(size >> 8), static_cast(size >> 16), static_cast(size >> 24)}; return write_exact(pipe, prefix.data(), prefix.size()) && write_exact(pipe, data, size); } static bool write_json(HANDLE pipe, const json &message) { const auto body = message.dump(); return body.size() <= max_json_bytes && write_frame(pipe, reinterpret_cast(body.data()), body.size()); } static bool scene_item_active(obs_scene_t *, obs_sceneitem_t *item, void *data); static void collect_scene_sources(obs_source_t *scene_source, std::set &active, std::set &visited) { if (!scene_source) return; const char *uuid = obs_source_get_uuid(scene_source); if (uuid && !visited.insert(uuid).second) return; obs_scene_t *scene = obs_scene_from_source(scene_source); if (!scene) scene = obs_group_from_source(scene_source); if (scene) { std::pair values{&active, &visited}; obs_scene_enum_items(scene, scene_item_active, &values); } } static bool scene_item_active(obs_scene_t *, obs_sceneitem_t *item, void *data) { if (!obs_sceneitem_visible(item)) return true; auto &sets = *static_cast *, std::set *> *>(data); obs_source_t *source = obs_sceneitem_get_source(item); if (!source) return true; if (const char *uuid = obs_source_get_uuid(source)) sets.first->insert(uuid); collect_scene_sources(source, *sets.first, *sets.second); return true; } static std::set program_sources() { std::set active, visited; obs_source_t *scene = obs_frontend_get_current_scene(); collect_scene_sources(scene, active, visited); if (scene) obs_source_release(scene); return active; } static bool enumerate_source(void *data, obs_source_t *source) { if (!source || !(obs_source_get_output_flags(source) & OBS_SOURCE_AUDIO)) return true; auto &values = *static_cast *, const std::set *> *>(data); const char *uuid = obs_source_get_uuid(source); if (!uuid || !*uuid) return true; values.first->push_back({uuid, obs_source_get_name(source) ? obs_source_get_name(source) : "OBS source", values.second->contains(uuid)}); return true; } static std::vector enumerate_sources() { const auto active = program_sources(); std::vector sources; std::pair values{&sources, &active}; obs_enum_sources(enumerate_source, &values); std::sort(sources.begin(), sources.end(), [](const auto &left, const auto &right) { return left.name < right.name; }); return sources; } static void on_audio(void *, obs_source_t *source, const audio_data *audio, bool muted) { if (!audio || !source || audio->frames == 0 || audio->frames > max_input_frames) return; obs_audio_info info{}; if (!obs_get_audio_info(&info) || !info.samples_per_sec) return; const auto channels = std::clamp(get_audio_channels(info.speakers), 1, MAX_AUDIO_CHANNELS); raw_audio_packet packet; packet.frames = audio->frames; packet.sample_rate = info.samples_per_sec; packet.timestamp_ns = audio->timestamp; packet.active = obs_source_active(source); packet.muted = muted || obs_source_muted(source); const char *uuid = obs_source_get_uuid(source); if (!uuid || !parse_uuid(uuid, packet.source_uuid)) return; for (uint32_t frame = 0; frame < audio->frames; ++frame) { float sample = 0.0f; for (uint32_t channel = 0; channel < channels; ++channel) { if (audio->data[channel]) sample += reinterpret_cast(audio->data[channel])[frame]; } packet.mono[frame] = sample / static_cast(channels); } if (audio_queue.try_push(std::move(packet))) worker_signal.notify_one(); } static void detach_source() { std::scoped_lock lock(captured_source_mutex); if (!captured_source) return; obs_source_remove_audio_capture_callback(captured_source, on_audio, nullptr); obs_source_release(captured_source); captured_source = nullptr; } static bool select_source(const std::string &uuid) { detach_source(); if (uuid.empty()) return true; obs_source_t *source = obs_get_source_by_uuid(uuid.c_str()); if (!source || !(obs_source_get_output_flags(source) & OBS_SOURCE_AUDIO)) { if (source) obs_source_release(source); return false; } { std::scoped_lock lock(captured_source_mutex); captured_source = source; obs_source_add_audio_capture_callback(captured_source, on_audio, nullptr); } return true; } static std::vector encode_audio(const raw_audio_packet &packet) { const auto output_frames = std::min(3200, static_cast((static_cast(packet.frames) * output_sample_rate) / packet.sample_rate)); std::vector frame(64 + static_cast(output_frames) * 2, 0); std::memcpy(frame.data(), "LACP", 4); frame[4] = protocol_version; frame[5] = static_cast((packet.active ? 1 : 0) | (packet.muted ? 2 : 0)); write_u16(frame, 6, 64); write_u32(frame, 8, audio_sequence.fetch_add(1, std::memory_order_relaxed)); write_u64(frame, 12, packet.timestamp_ns / 1000); std::copy(packet.source_uuid.begin(), packet.source_uuid.end(), frame.begin() + 36); write_u32(frame, 52, output_sample_rate); write_u16(frame, 56, 1); write_u16(frame, 58, 16); write_u32(frame, 60, output_frames * 2); for (uint32_t index = 0; index < output_frames; ++index) { const auto source_position = std::min(packet.frames - 1, static_cast((static_cast(index) * packet.sample_rate) / output_sample_rate)); const auto sample = static_cast(std::lrint(std::clamp(packet.mono[source_position], -1.0f, 1.0f) * 32767.0f)); write_u16(frame, 64 + static_cast(index) * 2, static_cast(sample)); } return frame; } static json source_list_message() { json sources = json::array(); for (const auto &source : enumerate_sources()) { sources.push_back({{"source_uuid", source.uuid}, {"display_name", source.name}, {"program_active", source.program_active}, {"source_missing", false}}); } return {{"type", "source_list"}, {"protocol_version", protocol_version}, {"sources", std::move(sources)}}; } static json obs_state_message() { return {{"type", "obs_state"}, {"protocol_version", protocol_version}, {"version", obs_get_version_string()}, {"streaming", obs_frontend_streaming_active()}, {"recording", obs_frontend_recording_active()}}; } static bool output_caption(const std::string &text, double display_seconds) { if (!obs_frontend_streaming_active() || text.empty()) return false; obs_output_t *output = obs_frontend_get_streaming_output(); if (!output) return false; obs_output_output_caption_text2(output, text.c_str(), std::clamp(display_seconds, 0.5, 10.0)); obs_output_release(output); return true; } static std::optional handle_command(const json &message) { const auto type = message.value("type", ""); if (type == "select_sources") { const auto uuid = message.value("primary_source_uuid", ""); const auto installed = select_source(uuid); source_list_dirty.store(true, std::memory_order_release); blog(installed ? LOG_INFO : LOG_WARNING, "[Lumi Companion] %s selected OBS audio source %s", installed ? "Attached" : "Could not attach", uuid.c_str()); return json{{"type", "selection_state"}, {"protocol_version", protocol_version}, {"source_uuid", uuid}, {"attached", installed}}; } else if (type == "caption" && message.contains("payload")) { const auto &payload = message["payload"]; const auto text = payload.value("stable_text", ""); const auto duration = payload.value("display_seconds", 2.0); output_caption(text, duration); } return std::nullopt; } static bool read_available_command(HANDLE pipe) { DWORD available = 0; if (!PeekNamedPipe(pipe, nullptr, 0, nullptr, &available, nullptr)) return false; if (available < 4) return true; std::array prefix{}; if (!read_exact(pipe, prefix.data(), prefix.size())) return false; const uint32_t size = static_cast(prefix[0]) | (static_cast(prefix[1]) << 8) | (static_cast(prefix[2]) << 16) | (static_cast(prefix[3]) << 24); if (size == 0 || size > max_json_bytes) return false; std::vector body(size); if (!read_exact(pipe, body.data(), body.size())) return false; try { const auto response = handle_command(json::parse(body.begin(), body.end())); if (response && !write_json(pipe, *response)) return false; } catch (const std::exception &error) { blog(LOG_WARNING, "[Lumi Companion] Ignored invalid IPC command: %s", error.what()); } return true; } static HANDLE connect_pipe() { const auto hash = current_user_hash(); if (hash.empty()) return INVALID_HANDLE_VALUE; const auto name = L"\\\\.\\pipe\\Lumi.Companion.ObsBridge.v1." + std::wstring(hash.begin(), hash.end()); return CreateFileW(name.c_str(), GENERIC_READ | GENERIC_WRITE, 0, nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr); } static void run_pipe_worker() { while (!stopping.load(std::memory_order_acquire)) { HANDLE pipe = connect_pipe(); if (pipe == INVALID_HANDLE_VALUE) { std::unique_lock lock(worker_signal_mutex); worker_signal.wait_for(lock, std::chrono::seconds(2)); continue; } blog(LOG_INFO, "[Lumi Companion] connected to the same-user Companion IPC endpoint"); bool connected = write_json(pipe, {{"type", "hello"}, {"protocol_version", protocol_version}, {"obs_version", obs_get_version_string()}, {"bridge_version", bridge_version}}); source_list_dirty.store(true, std::memory_order_release); obs_state_dirty.store(true, std::memory_order_release); auto health_sent = std::chrono::steady_clock::now(); while (connected && !stopping.load(std::memory_order_acquire)) { connected = read_available_command(pipe); if (connected && source_list_dirty.exchange(false, std::memory_order_acq_rel)) connected = write_json(pipe, source_list_message()); if (connected && obs_state_dirty.exchange(false, std::memory_order_acq_rel)) connected = write_json(pipe, obs_state_message()); while (connected) { auto packet = audio_queue.try_pop(); if (!packet) break; const auto encoded = encode_audio(*packet); connected = write_frame(pipe, encoded.data(), encoded.size()); } const auto now = std::chrono::steady_clock::now(); if (connected && now - health_sent >= std::chrono::seconds(10)) { connected = write_json(pipe, {{"type", "health"}, {"protocol_version", protocol_version}, {"status", "healthy"}, {"audio_frames_dropped", audio_queue.dropped()}}); health_sent = now; } std::unique_lock lock(worker_signal_mutex); worker_signal.wait_for(lock, std::chrono::milliseconds(10)); } CloseHandle(pipe); blog(LOG_INFO, "[Lumi Companion] disconnected from Companion IPC; retrying safely"); } } static void frontend_event(obs_frontend_event event, void *) { switch (event) { case OBS_FRONTEND_EVENT_STREAMING_STARTED: case OBS_FRONTEND_EVENT_STREAMING_STOPPED: case OBS_FRONTEND_EVENT_RECORDING_STARTED: case OBS_FRONTEND_EVENT_RECORDING_STOPPED: obs_state_dirty.store(true, std::memory_order_release); break; case OBS_FRONTEND_EVENT_SCENE_CHANGED: case OBS_FRONTEND_EVENT_SCENE_LIST_CHANGED: case OBS_FRONTEND_EVENT_SCENE_COLLECTION_CHANGED: case OBS_FRONTEND_EVENT_FINISHED_LOADING: source_list_dirty.store(true, std::memory_order_release); break; default: break; } worker_signal.notify_one(); } } // namespace const char *obs_module_description(void) { return "Companion-managed Lumi audio and native-caption bridge"; } bool obs_module_load(void) { const char *version = obs_get_version_string(); const int major = version ? std::atoi(version) : 0; if (major < 31) { blog(LOG_ERROR, "[Lumi Companion] OBS %s is unsupported; version 31 or newer is required", version ? version : "unknown"); return false; } stopping.store(false, std::memory_order_release); obs_frontend_add_event_callback(frontend_event, nullptr); worker_thread = std::thread(run_pipe_worker); blog(LOG_INFO, "[Lumi Companion] bridge %s loaded", bridge_version); return true; } void obs_module_unload(void) { obs_frontend_remove_event_callback(frontend_event, nullptr); detach_source(); stopping.store(true, std::memory_order_release); worker_signal.notify_all(); if (worker_thread.joinable()) worker_thread.join(); blog(LOG_INFO, "[Lumi Companion] bridge unloaded"); }