From b8ea35e1e48d236620f8873c5ca8ce175c4ad851 Mon Sep 17 00:00:00 2001 From: Ingo Randolf Date: Thu, 11 Jun 2026 10:21:19 +0200 Subject: [PATCH] add support for Net-Timecode by LaserAnimation --- AppSettings.h | 16 ++ LANetTimecodeInput.h | 286 ++++++++++++++++++++++++++++++++++++ LANetTimecodeOutput.h | 330 ++++++++++++++++++++++++++++++++++++++++++ MainComponent.cpp | 194 +++++++++++++++++++++++-- MainComponent.h | 10 ++ README.md | 2 + TimecodeEngine.h | 92 +++++++++++- 7 files changed, 918 insertions(+), 12 deletions(-) create mode 100644 LANetTimecodeInput.h create mode 100644 LANetTimecodeOutput.h diff --git a/AppSettings.h b/AppSettings.h index e64a03b..15a8ed1 100644 --- a/AppSettings.h +++ b/AppSettings.h @@ -1121,6 +1121,7 @@ struct EngineSettings int artnetInputInterface = 0; int hippotizerInputInterface = 0; int hippotizerTcChannel = 0; // 0=TC1, 1=TC2 + int laNetTCInputInterface = 0; // Generator (internal timecode source) bool generatorClockMode = true; // true = wall clock, false = transport double generatorStartMs = 0.0; // start TC in ms from midnight @@ -1158,6 +1159,7 @@ struct EngineSettings // Output bool mtcOutEnabled = false; bool artnetOutEnabled = false; + bool laNetTCOutEnabled = false; bool ltcOutEnabled = false; bool thruOutEnabled = false; // only meaningful for engine 0 bool tcnetOutEnabled = false; // TCNet timecode layer output @@ -1169,6 +1171,7 @@ struct EngineSettings bool onAirGateEnabled = false; juce::String midiOutputDevice = ""; int artnetOutputInterface = 0; + int laNetTCOutputInterface = 0; juce::String audioOutputDevice = ""; juce::String audioOutputType = ""; int audioOutputChannel = 0; @@ -1206,6 +1209,7 @@ struct EngineSettings // Output offsets (frames, -30 to +30) int mtcOutputOffset = 0; int artnetOutputOffset = 0; + int laNetTCOutputOffset = 0; int ltcOutputOffset = 0; int tcnetOutputOffsetMs = 0; // TCNet offset in milliseconds, -1000 to +1000 @@ -1228,6 +1232,7 @@ struct EngineSettings obj->setProperty("artnetInputInterface", artnetInputInterface); obj->setProperty("hippotizerInputInterface", hippotizerInputInterface); obj->setProperty("hippotizerTcChannel", hippotizerTcChannel); + obj->setProperty("laNetTCInputInterface", laNetTCInputInterface); obj->setProperty("generatorClockMode", generatorClockMode); obj->setProperty("generatorStartMs", generatorStartMs); obj->setProperty("generatorStopMs", generatorStopMs); @@ -1261,6 +1266,7 @@ struct EngineSettings obj->setProperty("mtcOutEnabled", mtcOutEnabled); obj->setProperty("artnetOutEnabled", artnetOutEnabled); + obj->setProperty("laNetTCOutEnabled", laNetTCOutEnabled); obj->setProperty("ltcOutEnabled", ltcOutEnabled); obj->setProperty("thruOutEnabled", thruOutEnabled); obj->setProperty("tcnetOutEnabled", tcnetOutEnabled); @@ -1271,6 +1277,7 @@ struct EngineSettings obj->setProperty("hippotizerDestIp", hippotizerDestIp); obj->setProperty("midiOutputDevice", midiOutputDevice); obj->setProperty("artnetOutputInterface", artnetOutputInterface); + obj->setProperty("laNetTCOutputInterface", laNetTCOutputInterface); obj->setProperty("audioOutputDevice", audioOutputDevice); obj->setProperty("audioOutputType", audioOutputType); obj->setProperty("audioOutputChannel", audioOutputChannel); @@ -1300,6 +1307,7 @@ struct EngineSettings obj->setProperty("mtcOutputOffset", mtcOutputOffset); obj->setProperty("artnetOutputOffset", artnetOutputOffset); + obj->setProperty("laNetTCOutputOffset", laNetTCOutputOffset); obj->setProperty("ltcOutputOffset", ltcOutputOffset); obj->setProperty("tcnetOutputOffsetMs", tcnetOutputOffsetMs); @@ -1342,6 +1350,7 @@ struct EngineSettings artnetInputInterface = getInt("artnetInputInterface", 0); hippotizerInputInterface = getInt("hippotizerInputInterface", 0); hippotizerTcChannel = getInt("hippotizerTcChannel", 0); + laNetTCInputInterface = getInt("laNetTCInputInterface", 0); generatorClockMode = getBool("generatorClockMode", true); generatorStartMs = (double)getInt("generatorStartMs", 0); generatorStopMs = (double)getInt("generatorStopMs", 0); @@ -1389,6 +1398,7 @@ struct EngineSettings mtcOutEnabled = getBool("mtcOutEnabled", false); artnetOutEnabled = getBool("artnetOutEnabled", false); + laNetTCOutEnabled = getBool("laNetTCOutEnabled", false); ltcOutEnabled = getBool("ltcOutEnabled", false); thruOutEnabled = getBool("thruOutEnabled", false); tcnetOutEnabled = getBool("tcnetOutEnabled", false); @@ -1398,6 +1408,7 @@ struct EngineSettings hippotizerDestIp = getString("hippotizerDestIp", "255.255.255.255"); midiOutputDevice = getString("midiOutputDevice"); artnetOutputInterface = getInt("artnetOutputInterface", 0); + laNetTCOutputInterface = getInt("laNetTCOutputInterface", 0); audioOutputDevice = getString("audioOutputDevice"); audioOutputType = getString("audioOutputType"); audioOutputChannel = juce::jlimit(0, 127, getInt("audioOutputChannel", 0)); @@ -1430,6 +1441,7 @@ struct EngineSettings auto clampOffset = [](int val) { return juce::jlimit(-30, 30, val); }; mtcOutputOffset = clampOffset(getInt("mtcOutputOffset", 0)); artnetOutputOffset = clampOffset(getInt("artnetOutputOffset", 0)); + laNetTCOutputOffset = clampOffset(getInt("laNetTCOutputOffset", 0)); ltcOutputOffset = clampOffset(getInt("ltcOutputOffset", 0)); tcnetOutputOffsetMs = juce::jlimit(-1000, 1000, getInt("tcnetOutputOffsetMs", 0)); @@ -1668,6 +1680,7 @@ struct AppSettings es.artnetInputInterface = getInt("artnetInputInterface", 0); es.hippotizerInputInterface = getInt("hippotizerInputInterface", 0); es.hippotizerTcChannel = getInt("hippotizerTcChannel", 0); + es.laNetTCInputInterface = getInt("laNetTCInputInterface", 0); es.generatorClockMode = getBool("generatorClockMode", true); es.generatorStartMs = (double)getInt("generatorStartMs", 0); es.generatorStopMs = (double)getInt("generatorStopMs", 0); @@ -1677,10 +1690,12 @@ struct AppSettings es.mtcOutEnabled = getBool("mtcOutEnabled", false); es.artnetOutEnabled = getBool("artnetOutEnabled", false); + es.laNetTCOutEnabled = getBool("laNetTCOutEnabled", false); es.ltcOutEnabled = getBool("ltcOutEnabled", false); es.thruOutEnabled = getBool("thruOutEnabled", false); es.midiOutputDevice = getString("midiOutputDevice"); es.artnetOutputInterface = getInt("artnetOutputInterface", 0); + es.laNetTCOutputInterface = getInt("laNetTCOutputInterface", 0); es.audioOutputDevice = getString("audioOutputDevice"); es.audioOutputType = getString("audioOutputType"); es.audioOutputChannel = juce::jlimit(0, 127, getInt("audioOutputChannel", 0)); @@ -1712,6 +1727,7 @@ struct AppSettings auto clampOffset = [](int v) { return juce::jlimit(-30, 30, v); }; es.mtcOutputOffset = clampOffset(getInt("mtcOutputOffset", 0)); es.artnetOutputOffset = clampOffset(getInt("artnetOutputOffset", 0)); + es.laNetTCOutputOffset = clampOffset(getInt("laNetTCOutputOffset", 0)); es.ltcOutputOffset = clampOffset(getInt("ltcOutputOffset", 0)); es.tcnetOutputOffsetMs = juce::jlimit(-1000, 1000, getInt("tcnetOutputOffsetMs", 0)); diff --git a/LANetTimecodeInput.h b/LANetTimecodeInput.h new file mode 100644 index 0000000..fe2a1b0 --- /dev/null +++ b/LANetTimecodeInput.h @@ -0,0 +1,286 @@ +// Super Timecode Converter +// Copyright (c) 2026 Fiverecords -- MIT License +// https://github.com/fiverecords/SuperTimecodeConverter + +// LANetTimecodeInput +// Copyright (c) 2026 LaserAnimation Sollinger GmbH, https://www.laseranimation.com +// Written by Ingo Randolf (based on ArtnetInput) + +#pragma once +#include +#include "TimecodeCore.h" +#include "NetworkUtils.h" +#include + +class LANetTimecodeInput : public juce::Thread +{ +public: + LANetTimecodeInput() + : Thread("LA-Net Input") + { + } + + ~LANetTimecodeInput() override + { + stop(); + } + + //============================================================================== + void refreshNetworkInterfaces() + { + availableInterfaces = ::getNetworkInterfaces(); + } + + juce::StringArray getInterfaceNames() const + { + juce::StringArray names; + names.add("ALL INTERFACES (0.0.0.0)"); + for (auto& ni : availableInterfaces) + names.add(ni.name + " (" + ni.ip + ")"); + return names; + } + + int getInterfaceCount() const { return availableInterfaces.size() + 1; } + + juce::String getBindInfo() const { return bindIp + ":" + juce::String(listenPort); } + bool didFallBackToAllInterfaces() const { return bindFellBack.load(std::memory_order_relaxed); } + int getSelectedInterface() const { return selectedInterface; } + + //============================================================================== + bool start(int interfaceIndex = 0, int port = 8201) + { + stop(); + + listenPort = port; + + if (interfaceIndex > 0 && (interfaceIndex - 1) < availableInterfaces.size()) + { + selectedInterface = interfaceIndex; + bindIp = availableInterfaces[interfaceIndex - 1].ip; + } + else + { + selectedInterface = 0; + bindIp = "0.0.0.0"; + } + + socket = std::make_unique(false); + + + // Enable SO_REUSEADDR before binding + auto rawSock = socket->getRawSocketHandle(); + if (rawSock >= 0) + { + const int flag = 1; +#ifdef _WIN32 + setsockopt(rawSock, SOL_SOCKET, SO_REUSEADDR, + (const char*)&flag, sizeof(flag)); +#else + setsockopt(rawSock, SOL_SOCKET, SO_REUSEADDR, + &flag, sizeof(flag)); +#endif + } + + + bool bound = false; + bool fellBack = false; + + if (bindIp != "0.0.0.0") + { + bound = socket->bindToPort(listenPort, bindIp); + } + + if (!bound) + { + bound = socket->bindToPort(listenPort); + if (bound) + { + fellBack = (bindIp != "0.0.0.0"); // only a fallback if we tried a specific IP + bindIp = "0.0.0.0"; // reflect actual bind address + } + } + + bindFellBack.store(fellBack, std::memory_order_relaxed); + + + if (bound) + { + isRunningFlag.store(true, std::memory_order_relaxed); + startThread(); + return true; + } + + socket = nullptr; + return false; + } + + void stop() + { + isRunningFlag.store(false, std::memory_order_relaxed); + bindFellBack.store(false, std::memory_order_relaxed); + + if (socket != nullptr) + socket->shutdown(); + + if (isThreadRunning()) + stopThread(1000); + + socket = nullptr; + } + + bool getIsRunning() const { return isRunningFlag.load(std::memory_order_relaxed); } + int getListenPort() const { return listenPort; } + + //============================================================================== + // True if Art-Net TC packets are actively arriving + bool isReceiving() const + { + double lpt = lastPacketTime.load(std::memory_order_relaxed); + if (lpt == 0.0) + return false; + + double now = juce::Time::getMillisecondCounterHiRes(); + double elapsed = now - lpt; + + // At 24fps a packet arrives every ~41ms, at 30fps ~33ms + return elapsed < kSourceTimeoutMs; + } + + Timecode getCurrentTimecode() const + { + return unpackTimecode(packedTimecode.load(std::memory_order_relaxed)); + } + FrameRate getDetectedFrameRate() const { return detectedFps.load(std::memory_order_relaxed); } + +private: + void run() override + { + uint8_t buffer[1024]; + + while (!threadShouldExit() && isRunningFlag.load(std::memory_order_relaxed)) + { + // Capture local pointer: stop() may nullify `socket` from another thread + // after calling socket->shutdown(). The shutdown unblocks waitUntilReady, + // and then the while-condition will fail on the next iteration. The local + // pointer ensures we don't dereference a null between the check and use. + auto* sock = socket.get(); + if (sock == nullptr) + break; + + // Wait up to 100ms for data -- allows periodic threadShouldExit() checks + // so the thread can shut down cleanly even if no packets are arriving + if (!sock->waitUntilReady(true, 100)) + continue; + + int bytesRead = sock->read(buffer, sizeof(buffer), false); + + if (bytesRead == 28) + parseLANetTimecodePacket(buffer, bytesRead); + } + } + + void parseLANetTimecodePacket(const uint8_t* data, int size) + { + if (size != 28) + { + // invalid data + return; + } + + + const uint32_t* networkData = reinterpret_cast(data); + + // message type + uint32_t message_type = ByteOrder::swapIfLittleEndian(networkData[0]); + + if (message_type != 1) + { + return; + } + + // version parsing + uint32_t version = ByteOrder::swapIfLittleEndian(networkData[1]); + + if (version != 1) + { + return; + } + + uint32_t len = ByteOrder::swapIfLittleEndian(networkData[3]); + + if (len != 12) + { + return; + } + + + uint32_t fps = ByteOrder::swapIfLittleEndian(networkData[4]); + + if (fps == 0) + { + fps = 25; + } + + uint32_t timestamp = ByteOrder::swapIfLittleEndian(networkData[6]); + + if (timestamp == 0xffffffff) + { + return; + } + + // check valid fps + // ignore not supported fps: 50, 60, 100 + if (fps != 24 && fps != 25 && fps != 30) + { + // unsupported fps could be converted + return; + } + + int frames = timestamp % fps; + timestamp -= frames; + + int seconds = (timestamp / fps) % 60; + timestamp -= seconds * fps; + + int minutes = (timestamp / (60 * fps)) % 60; + timestamp -= minutes * fps * 60; + + int hours = (timestamp / (60 * 60 * fps)); + + // Validate ranges -- discard malformed packets + // (lastPacketTime is updated AFTER validation so isReceiving() + // only returns true when we actually accepted valid data) + if (hours > 23 || minutes > 59 || seconds > 59 || frames > 29) + { + return; + } + + lastPacketTime.store(juce::Time::getMillisecondCounterHiRes(), std::memory_order_relaxed); + + switch (fps) + { + case 24: detectedFps.store(FrameRate::FPS_24, std::memory_order_relaxed); break; + case 25: detectedFps.store(FrameRate::FPS_25, std::memory_order_relaxed); break; + case 30: detectedFps.store(FrameRate::FPS_30, std::memory_order_relaxed); break; + default: break; + } + + packedTimecode.store(packTimecode(hours, minutes, seconds, frames), + std::memory_order_relaxed); + } + + std::unique_ptr socket; + juce::String bindIp = "0.0.0.0"; + int listenPort = 8201; + int selectedInterface = 0; + std::atomic isRunningFlag { false }; + std::atomic bindFellBack { false }; + + juce::Array availableInterfaces; + std::atomic lastPacketTime { 0.0 }; + + std::atomic packedTimecode { 0 }; + std::atomic detectedFps { FrameRate::FPS_25 }; + + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(LANetTimecodeInput) +}; diff --git a/LANetTimecodeOutput.h b/LANetTimecodeOutput.h new file mode 100644 index 0000000..d7ab7d6 --- /dev/null +++ b/LANetTimecodeOutput.h @@ -0,0 +1,330 @@ +// Super Timecode Converter +// Copyright (c) 2026 Fiverecords -- MIT License +// https://github.com/fiverecords/SuperTimecodeConverter + +// LANetTimecodeOutput +// Copyright (c) 2026 LaserAnimation Sollinger GmbH, https://www.laseranimation.com +// Written by Ingo Randolf (based on ArtnetOutput) + + +#pragma once +#include +#include "TimecodeCore.h" +#include "NetworkUtils.h" +#include + +#ifdef _WIN32 + #include +#else + #include +#endif + +static const uint8_t laNetPacketHeader[24] = {0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 12, 0, 0, 0, 0, 0, 0, 0, 0}; + +class LANetTimecodeOutput : public juce::HighResolutionTimer +{ +public: + LANetTimecodeOutput() + { + refreshNetworkInterfaces(); + } + + ~LANetTimecodeOutput() override + { + stop(); + } + + //============================================================================== + void refreshNetworkInterfaces() + { + availableInterfaces = ::getNetworkInterfaces(); + } + + juce::StringArray getInterfaceNames() const + { + juce::StringArray names; + for (auto& ni : availableInterfaces) + names.add(ni.name + " (" + ni.ip + ")"); + return names; + } + + int getInterfaceCount() const { return availableInterfaces.size(); } + + juce::String getInterfaceInfo(int index) const + { + if (index >= 0 && index < availableInterfaces.size()) + return availableInterfaces[index].ip + " -> " + availableInterfaces[index].broadcast; + return ""; + } + + //============================================================================== + bool start(int interfaceIndex = -1, int targetPort = 8201) + { + stop(); + + destPort = targetPort; + + if (interfaceIndex >= 0 && interfaceIndex < availableInterfaces.size()) + { + selectedInterface = interfaceIndex; + broadcastIp = availableInterfaces[interfaceIndex].broadcast; + bindIp = availableInterfaces[interfaceIndex].ip; + } + else if (interfaceIndex == -1) + { + selectedInterface = -1; + broadcastIp = "127.0.0.1"; + bindIp = "127.0.0.1"; + } + else + { + selectedInterface = -2; + broadcastIp = "255.255.255.255"; + bindIp = "0.0.0.0"; + } + + + socket = std::make_unique(false); + + if (!socket->bindToPort(0, bindIp)) + { + if (!socket->bindToPort(0)) + { + socket = nullptr; + return false; + } + } + + // Enable SO_BROADCAST so the OS allows sending to broadcast addresses. + // Some systems (especially Linux) reject broadcast sends without this. + auto rawSock = socket->getRawSocketHandle(); + if (rawSock >= 0) + { + int broadcastFlag = 1; +#ifdef _WIN32 + setsockopt(rawSock, SOL_SOCKET, SO_BROADCAST, + (const char*)&broadcastFlag, sizeof(broadcastFlag)); +#else + setsockopt(rawSock, SOL_SOCKET, SO_BROADCAST, + &broadcastFlag, sizeof(broadcastFlag)); +#endif + } + + isRunningFlag.store(true, std::memory_order_relaxed); + paused.store(false, std::memory_order_relaxed); + sendErrors.store(0, std::memory_order_relaxed); + seeded = false; + updateTimerRate(); + return true; + } + + void stop() + { + stopTimer(); + isRunningFlag.store(false, std::memory_order_relaxed); + paused.store(false, std::memory_order_relaxed); + + if (socket != nullptr) + { + socket->shutdown(); + socket = nullptr; + } + } + + bool getIsRunning() const { return isRunningFlag.load(std::memory_order_relaxed); } + juce::String getBroadcastIp() const { return broadcastIp; } + int getSelectedInterface() const { return selectedInterface; } + uint32_t getSendErrors() const { return sendErrors.load(std::memory_order_relaxed); } + + //============================================================================== + void setTimecode(const Timecode& tc) + { + const juce::SpinLock::ScopedLockType lock(tcLock); + timecodeToSend = tc; + } + + // Called from UI thread. startTimer() is internally serialised in JUCE's + // HighResolutionTimer, so calling it from the message thread is safe. + void setFrameRate(FrameRate fps) + { + auto prev = currentFps.load(std::memory_order_relaxed); + if (prev != fps) + { + currentFps.store(fps, std::memory_order_relaxed); + if (isRunningFlag.load(std::memory_order_relaxed) && !paused.load(std::memory_order_relaxed)) + updateTimerRate(); + } + } + + // Pause/resume transmission + void setPaused(bool shouldPause) + { + if (paused.load(std::memory_order_relaxed) == shouldPause) + return; + + paused.store(shouldPause, std::memory_order_relaxed); + + std::cout << "set paused: " << shouldPause << std::endl; + + if (shouldPause) + { + stopTimer(); + } + else if (isRunningFlag.load(std::memory_order_relaxed)) + { + seeded = false; + lastFrameSendTime.store(juce::Time::getMillisecondCounterHiRes(), std::memory_order_relaxed); + updateTimerRate(); + } + } + + bool isPaused() const { return paused.load(std::memory_order_relaxed); } + + /// Force immediate ArtTimeCode frame send. + /// Call on seek/hot cue/track change so receivers update instantly + /// instead of waiting for the next timer tick (up to 1 frame latency). + void forceResync() + { + if (!isRunningFlag.load(std::memory_order_relaxed) + || paused.load(std::memory_order_relaxed) + || socket == nullptr) + return; + seeded = false; + FrameRate fps = currentFps.load(std::memory_order_relaxed); + sendLANetTimecode(fps); + } + + +private: + void hiResTimerCallback() override + { + if (!isRunningFlag.load(std::memory_order_relaxed) + || paused.load(std::memory_order_relaxed) + || socket == nullptr) + { + stopTimer(); // Don't spin at 1000Hz when there's nothing to send + return; + } + + // Single atomic read -- guarantees frame interval and packet rate code are consistent + FrameRate fps = currentFps.load(std::memory_order_relaxed); + + // Fractional accumulator: compare real elapsed time against ideal frame interval + // to eliminate drift caused by integer-ms timer resolution + double now = juce::Time::getMillisecondCounterHiRes(); + // LaserAnimation Net TimeCode is a digital protocol -- always send at nominal frame rate. + // The timecode VALUES advance slower at low pitch (PLL handles that), + // producing repeated frames, which is correct. Scaling the interval + // caused receivers to lose sync or stop at low pitch. + double frameInterval = 1000.0 / frameRateToDouble(fps); + + // Allow up to 2 catch-up sends per callback to handle jitter + int sent = 0; + double lastSend = lastFrameSendTime.load(std::memory_order_relaxed); + while ((now - lastSend) >= frameInterval && sent < 2) + { + sendLANetTimecode(fps); + // Advance by ideal interval (not by 'now') to prevent cumulative drift + lastSend += frameInterval; + sent++; + } + lastFrameSendTime.store(lastSend, std::memory_order_relaxed); + + // If we fell too far behind (>100ms), reset to avoid a burst + if ((now - lastSend) > 100.0) + lastFrameSendTime.store(now, std::memory_order_relaxed); + } + + void sendLANetTimecode(FrameRate fps) + { + Timecode pending; + { + const juce::SpinLock::ScopedLockType lock(tcLock); + pending = timecodeToSend; + } + + // Auto-increment: advance by 1 frame per send. Compare with + // pendingTimecode and only resync on diff > 1 (seek/jump). + // Prevents 1-frame backward jitter from interpolation overshoot. + // Same architectural pattern as the LTC, MTC and ArtNet encoders. + Timecode tc; + if (!seeded) + { + tc = pending; + seeded = true; + } + else + { + tc = incrementFrame(encoderTc, fps); + + int maxFrames = frameRateToInt(fps); + auto toTotal = [maxFrames](const Timecode& t) -> int64_t { + return (int64_t)t.hours * 3600 * maxFrames + + (int64_t)t.minutes * 60 * maxFrames + + (int64_t)t.seconds * maxFrames + + (int64_t)t.frames; + }; + int64_t dayFrames = (int64_t)24 * 3600 * maxFrames; + int64_t rawDiff = toTotal(pending) - toTotal(tc); + int64_t diff = ((rawDiff % dayFrames) + dayFrames) % dayFrames; + if (diff > dayFrames / 2) diff = dayFrames - diff; + if (diff > 1) + tc = pending; + } + encoderTc = tc; + + // Validate ranges -- don't send corrupt data to the network + int maxFrames = frameRateToInt(fps); + if (tc.hours > 23 || tc.minutes > 59 || tc.seconds > 59 || tc.frames >= maxFrames) + return; + + + uint8_t packet[28]; + + std::memcpy(packet, laNetPacketHeader, sizeof(laNetPacketHeader)); + + // timecode format + packet[19] = uint8_t(maxFrames); + + // endian conversion + uint32_t be = ByteOrder::swapIfLittleEndian((int32_t(tc.hours) * 3600 * maxFrames) + + (int32_t(tc.minutes) * 60 * maxFrames) + + (int32_t(tc.seconds) * maxFrames) + + int32_t(tc.frames)); + + std::memcpy(&packet[24], &be, sizeof(uint32_t)); + + + int written = socket->write(broadcastIp, destPort, packet, sizeof(packet)); + if (written < 0) + sendErrors.fetch_add(1, std::memory_order_relaxed); + } + + void updateTimerRate() + { + // Run timer at 1ms fixed rate -- the fractional accumulator in + // hiResTimerCallback handles exact frame timing to avoid drift + lastFrameSendTime.store(juce::Time::getMillisecondCounterHiRes(), std::memory_order_relaxed); + startTimer(1); + } + + std::unique_ptr socket; + juce::String broadcastIp = "255.255.255.255"; + juce::String bindIp = "0.0.0.0"; + int destPort = 8201; + int selectedInterface = -1; + std::atomic isRunningFlag { false }; + std::atomic paused { false }; + + juce::Array availableInterfaces; + + juce::SpinLock tcLock; + Timecode timecodeToSend; // Written by UI thread under tcLock, read by timer thread under tcLock + Timecode encoderTc; // Auto-increment: last sent timecode (timer thread only) + bool seeded = false; // Auto-increment: false until first frame seeds encoderTc + std::atomic currentFps { FrameRate::FPS_25 }; + std::atomic lastFrameSendTime { 0.0 }; + std::atomic sendErrors { 0 }; + + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(LANetTimecodeOutput) +}; diff --git a/MainComponent.cpp b/MainComponent.cpp index 3ca24ac..78817cd 100644 --- a/MainComponent.cpp +++ b/MainComponent.cpp @@ -132,7 +132,7 @@ MainComponent::MainComponent() rightViewport.setScrollBarsShown(true, false); // --- Input buttons --- - for (auto* btn : { &btnMtcIn, &btnArtnetIn, &btnSysTime, &btnLtcIn, &btnProDJLinkIn, &btnStageLinQIn }) + for (auto* btn : { &btnMtcIn, &btnArtnetIn, &btnSysTime, &btnLtcIn, &btnProDJLinkIn, &btnStageLinQIn, &btnLANetTCIn }) { leftContent.addAndMakeVisible(btn); btn->setClickingTogglesState(false); } // HippoNet input hidden pending hardware validation (code preserved in HippotizerInput.h) btnHippoIn.setVisible(false); @@ -149,6 +149,12 @@ MainComponent::MainComponent() if (eng.getActiveInput() == SrcType::ArtNet) { inputConfigExpanded = !inputConfigExpanded; updateDeviceSelectorVisibility(); resized(); } else { inputConfigExpanded = true; eng.setInputSource(SrcType::ArtNet); startCurrentArtnetInput(); updateInputButtonStates(); updateDeviceSelectorVisibility(); resized(); populateFilteredOutputDeviceCombos(); saveSettings(); } }; + btnLANetTCIn.onClick = [this] { + if (syncing || isShowLocked()) return; + auto& eng = currentEngine(); + if (eng.getActiveInput() == SrcType::LANetTC) { inputConfigExpanded = !inputConfigExpanded; updateDeviceSelectorVisibility(); resized(); } + else { inputConfigExpanded = true; eng.setInputSource(SrcType::LANetTC); startCurrentLANetTCInput(); updateInputButtonStates(); updateDeviceSelectorVisibility(); resized(); populateFilteredOutputDeviceCombos(); saveSettings(); } + }; btnSysTime.onClick = [this] { if (syncing || isShowLocked()) return; auto& eng = currentEngine(); @@ -212,11 +218,12 @@ MainComponent::MainComponent() }; // --- Output toggles --- - for (auto* btn : { &btnMtcOut, &btnArtnetOut, &btnLtcOut, &btnThruOut, &btnTcnetOut }) + for (auto* btn : { &btnMtcOut, &btnArtnetOut, &btnLtcOut, &btnThruOut, &btnTcnetOut, &btnLANetTCOut }) rightContent.addAndMakeVisible(btn); styleOutputToggle(btnMtcOut, accentRed); styleOutputToggle(btnArtnetOut, accentOrange); + styleOutputToggle(btnLANetTCOut, juce::Colour{0xFF63D8FB}); styleOutputToggle(btnLtcOut, accentPurple); styleOutputToggle(btnThruOut, accentCyan); styleOutputToggle(btnTcnetOut, juce::Colour(0xFF00CC66)); @@ -244,6 +251,7 @@ MainComponent::MainComponent() auto& eng = currentEngine(); eng.setOutputMtcEnabled(btnMtcOut.getToggleState()); eng.setOutputArtnetEnabled(btnArtnetOut.getToggleState()); + eng.setOutputLANetTCEnabled(btnLANetTCOut.getToggleState()); eng.setOutputLtcEnabled(btnLtcOut.getToggleState()); eng.setOutputThruEnabled(btnThruOut.getToggleState()); eng.setOutputTcnetEnabled(btnTcnetOut.getToggleState()); @@ -274,10 +282,10 @@ MainComponent::MainComponent() resized(); saveSettings(); }; - btnMtcOut.onClick = btnArtnetOut.onClick = btnLtcOut.onClick = btnThruOut.onClick = btnTcnetOut.onClick = outputToggleHandler; + btnMtcOut.onClick = btnArtnetOut.onClick = btnLtcOut.onClick = btnThruOut.onClick = btnTcnetOut.onClick = btnLANetTCOut.onClick = outputToggleHandler; // --- Collapse toggle buttons for outputs --- - for (auto* btn : { &btnCollapseMtcOut, &btnCollapseArtnetOut, &btnCollapseLtcOut, &btnCollapseThruOut }) + for (auto* btn : { &btnCollapseMtcOut, &btnCollapseArtnetOut, &btnCollapseLtcOut, &btnCollapseThruOut, &btnCollapseLANetTCOut }) { rightContent.addAndMakeVisible(btn); styleCollapseButton(*btn); @@ -292,10 +300,12 @@ MainComponent::MainComponent() }; btnCollapseMtcOut.onClick = makeCollapseHandler(mtcOutExpanded, btnCollapseMtcOut); btnCollapseArtnetOut.onClick = makeCollapseHandler(artnetOutExpanded, btnCollapseArtnetOut); + btnCollapseLANetTCOut.onClick = makeCollapseHandler(laNetTCOutExpanded, btnCollapseLANetTCOut); btnCollapseLtcOut.onClick = makeCollapseHandler(ltcOutExpanded, btnCollapseLtcOut); btnCollapseThruOut.onClick = makeCollapseHandler(thruOutExpanded, btnCollapseThruOut); updateCollapseButtonText(btnCollapseMtcOut, mtcOutExpanded); updateCollapseButtonText(btnCollapseArtnetOut, artnetOutExpanded); + updateCollapseButtonText(btnCollapseLANetTCOut, laNetTCOutExpanded); updateCollapseButtonText(btnCollapseLtcOut, ltcOutExpanded); updateCollapseButtonText(btnCollapseThruOut, thruOutExpanded); @@ -437,6 +447,24 @@ MainComponent::MainComponent() } }; + addLabelAndCombo(lblLANetTCInputInterface, cmbLANetTCInputInterface, "LA-NET INPUT DEVICE:"); + cmbLANetTCInputInterface.onChange = [this] + { + if (syncing) return; + if (isShowLockedRevert()) return; + if (currentEngine().getActiveInput() == SrcType::LANetTC) + { + int sel = cmbLANetTCInputInterface.getSelectedId() - 1; + currentEngine().stopLANetTCInput(); + currentEngine().startLANetTCInput(sel); + // If bind fell back, update combo to actual interface before repopulate + int actualId = currentEngine().getLANetTCInput().getSelectedInterface() + 1; + cmbLANetTCInputInterface.setSelectedId(actualId, juce::dontSendNotification); + populateMidiAndNetworkCombos(); // refresh markers (auto-restores all selections) + saveSettings(); + } + }; + addLabelAndCombo(lblHippoInputInterface, cmbHippoInputInterface, "HIPPONET INPUT DEVICE:"); cmbHippoInputInterface.onChange = [this] { @@ -1573,6 +1601,29 @@ MainComponent::MainComponent() rightContent.addAndMakeVisible(lblArtnetOffset); lblArtnetOffset.setText("ART-NET OFFSET:", juce::dontSendNotification); styleLabel(lblArtnetOffset); sldArtnetOffset.onValueChange = [this] { if (!syncing && !isShowLockedRevert()) { currentEngine().setArtnetOutputOffset((int)sldArtnetOffset.getValue()); saveSettings(); } }; + addRightLabelAndCombo(lblLANetTCOutputInterface, cmbLANetTCOutputInterface, "LA-NET OUTPUT DEVICE:"); + cmbLANetTCOutputInterface.onChange = [this] + { + if (syncing) return; + if (isShowLockedRevert()) return; + auto& eng = currentEngine(); + if (eng.isOutputLANetTCEnabled()) + { + int sel = cmbLANetTCOutputInterface.getSelectedId() - 3; + eng.stopLANetTCOutput(); + eng.startLANetTCOutput(sel); + // Update combo to actual interface before repopulate (handles fallback) + int actualId = eng.getLANetTCOutput().getSelectedInterface() + 3; + cmbLANetTCOutputInterface.setSelectedId(actualId, juce::dontSendNotification); + populateMidiAndNetworkCombos(); // refresh markers (auto-restores all selections) + saveSettings(); + } + }; + rightContent.addAndMakeVisible(lblOutputLANetTCStatus); styleLabel(lblOutputLANetTCStatus); lblOutputLANetTCStatus.setColour(juce::Label::textColourId, accentOrange); + rightContent.addAndMakeVisible(sldLANetTCOffset); styleOffsetSlider(sldLANetTCOffset); + rightContent.addAndMakeVisible(lblLANetTCOffset); lblLANetTCOffset.setText("LA-NET OFFSET:", juce::dontSendNotification); styleLabel(lblLANetTCOffset); + sldLANetTCOffset.onValueChange = [this] { if (!syncing && !isShowLockedRevert()) { currentEngine().setLANetTCOutputOffset((int)sldLANetTCOffset.getValue()); saveSettings(); } }; + // TCNet interface combo (shown when TCNET OUT is enabled) addRightLabelAndCombo(lblTcnetInterface, cmbTcnetInterface, "TCNET INTERFACE:"); cmbTcnetInterface.onChange = [this] @@ -1923,10 +1974,12 @@ MainComponent::~MainComponent() eng->getTriggerOutput().disconnectOsc(); eng->stopMtcOutput(); eng->stopArtnetOutput(); + eng->stopLANetTCOutput(); eng->stopLtcOutput(); eng->stopThruOutput(); eng->stopMtcInput(); eng->stopArtnetInput(); + eng->stopLANetTCInput(); eng->stopLtcInput(); } @@ -1993,10 +2046,12 @@ void MainComponent::removeEngine(int index) engines[(size_t)index]->getTriggerOutput().setSharedMidiOutput(nullptr); engines[(size_t)index]->stopMtcOutput(); engines[(size_t)index]->stopArtnetOutput(); + engines[(size_t)index]->stopLANetTCOutput(); engines[(size_t)index]->stopLtcOutput(); engines[(size_t)index]->stopThruOutput(); engines[(size_t)index]->stopMtcInput(); engines[(size_t)index]->stopArtnetInput(); + engines[(size_t)index]->stopLANetTCInput(); engines[(size_t)index]->stopLtcInput(); // Detach waveform views from the engine about to be deleted so we never @@ -2075,7 +2130,7 @@ void MainComponent::selectEngine(int index) selectedEngine = index; inputConfigExpanded = true; - mtcOutExpanded = artnetOutExpanded = ltcOutExpanded = thruOutExpanded = true; + mtcOutExpanded = artnetOutExpanded = ltcOutExpanded = thruOutExpanded = laNetTCOutExpanded = true; // Reset FPS tracking so buttons update immediately for new engine lastDisplayedFps = engines[(size_t)index]->getCurrentFps(); @@ -2209,6 +2264,7 @@ void MainComponent::syncUIFromEngine() // Output toggles btnMtcOut.setToggleState(eng.isOutputMtcEnabled(), juce::dontSendNotification); btnArtnetOut.setToggleState(eng.isOutputArtnetEnabled(), juce::dontSendNotification); + btnLANetTCOut.setToggleState(eng.isOutputLANetTCEnabled(), juce::dontSendNotification); btnLtcOut.setToggleState(eng.isOutputLtcEnabled(), juce::dontSendNotification); // AudioThru toggle: only show for primary engine @@ -2237,6 +2293,7 @@ void MainComponent::syncUIFromEngine() // Offsets sldMtcOffset.setValue(eng.getMtcOutputOffset(), juce::dontSendNotification); sldArtnetOffset.setValue(eng.getArtnetOutputOffset(), juce::dontSendNotification); + sldLANetTCOffset.setValue(eng.getLANetTCOutputOffset(), juce::dontSendNotification); sldLtcOffset.setValue(eng.getLtcOutputOffset(), juce::dontSendNotification); sldTcnetOffset.setValue(eng.getTcnetOutputOffsetMs(), juce::dontSendNotification); @@ -2279,6 +2336,11 @@ void MainComponent::syncUIFromEngine() if (artInId <= cmbArtnetInputInterface.getNumItems()) cmbArtnetInputInterface.setSelectedId(artInId, juce::dontSendNotification); + int laNetTCInId = es.laNetTCInputInterface + 1; // combo id is 1-based (1 = All Interfaces) + if (laNetTCInId < 1) laNetTCInId = 1; // handle legacy -1 default + if (laNetTCInId <= cmbLANetTCInputInterface.getNumItems()) + cmbLANetTCInputInterface.setSelectedId(laNetTCInId, juce::dontSendNotification); + int hippoInId = es.hippotizerInputInterface + 1; if (hippoInId < 1) hippoInId = 1; if (hippoInId <= cmbHippoInputInterface.getNumItems()) @@ -2291,6 +2353,11 @@ void MainComponent::syncUIFromEngine() if (artOutId <= cmbArtnetOutputInterface.getNumItems()) cmbArtnetOutputInterface.setSelectedId(artOutId, juce::dontSendNotification); + int laNetTCOutId = es.laNetTCOutputInterface + 1; + if (laNetTCOutId < 1) laNetTCOutId = 1; + if (laNetTCOutId <= cmbLANetTCOutputInterface.getNumItems()) + cmbLANetTCOutputInterface.setSelectedId(laNetTCOutId, juce::dontSendNotification); + // TCNet interface combo (global setting, not per-engine) int tcnetIfId = settings.tcnetInterface + 2; // -1->1(All), 0->2, 1->3... if (tcnetIfId < 1) tcnetIfId = 1; @@ -2547,6 +2614,13 @@ void MainComponent::startCurrentArtnetInput() eng.startArtnetInput(sel); } +void MainComponent::startCurrentLANetTCInput() +{ + auto& eng = currentEngine(); + int sel = cmbLANetTCInputInterface.getSelectedId() - 1; + eng.startLANetTCInput(sel); +} + void MainComponent::startCurrentLtcInput() { auto& eng = currentEngine(); @@ -3413,6 +3487,13 @@ void MainComponent::startCurrentArtnetOutput() eng.startArtnetOutput(sel); } +void MainComponent::startCurrentLANetTCOutput() +{ + auto& eng = currentEngine(); + int sel = cmbLANetTCOutputInterface.getSelectedId() - 3; + eng.startLANetTCOutput(sel); +} + void MainComponent::startCurrentLtcOutput() { auto& eng = currentEngine(); @@ -3498,6 +3579,9 @@ void MainComponent::updateCurrentOutputStates() if (eng.isOutputArtnetEnabled() && !eng.getArtnetOutput().getIsRunning()) startCurrentArtnetOutput(); else if (!eng.isOutputArtnetEnabled() && eng.getArtnetOutput().getIsRunning()) eng.stopArtnetOutput(); + if (eng.isOutputLANetTCEnabled() && !eng.getLANetTCOutput().getIsRunning()) startCurrentLANetTCOutput(); + else if (!eng.isOutputLANetTCEnabled() && eng.getLANetTCOutput().getIsRunning()) eng.stopLANetTCOutput(); + if (eng.isOutputLtcEnabled() && !eng.getLtcOutput().getIsRunning() && !scannedAudioOutputs.isEmpty()) startCurrentLtcOutput(); else if (!eng.isOutputLtcEnabled() && eng.getLtcOutput().getIsRunning()) eng.stopLtcOutput(); @@ -3940,7 +4024,9 @@ void MainComponent::populateMidiAndNetworkCombos() int savedMidiOut = cmbMidiOutputDevice.getSelectedId(); int savedArtIn = cmbArtnetInputInterface.getSelectedId(); int savedHippoIn = cmbHippoInputInterface.getSelectedId(); + int savedLAIn = cmbLANetTCInputInterface.getSelectedId(); int savedArtOut = cmbArtnetOutputInterface.getSelectedId(); + int savedLAOut = cmbLANetTCOutputInterface.getSelectedId(); int savedArtDmx = cmbArtnetDmxInterface.getSelectedId(); int savedTcnetIf = cmbTcnetInterface.getSelectedId(); @@ -3968,6 +4054,10 @@ void MainComponent::populateMidiAndNetworkCombos() cmbArtnetOutputInterface.clear(juce::dontSendNotification); cmbArtnetDmxInterface.clear(juce::dontSendNotification); + // LaserAnimation Net-Timecode Interfaces + cmbLANetTCInputInterface.clear(juce::dontSendNotification); + cmbLANetTCOutputInterface.clear(juce::dontSendNotification); + // Helper: check if an Art-Net interface combo ID is in use by another engine auto getArtnetMarker = [&](int comboId, bool isInput) -> juce::String { @@ -4000,11 +4090,47 @@ void MainComponent::populateMidiAndNetworkCombos() return currentDot; }; + auto getLANetTCMarker = [&](int comboId, bool isInput) -> juce::String + { + juce::String currentDot; + + for (int i = 0; i < (int)engines.size(); i++) + { + auto& eng = *engines[(size_t)i]; + bool isCurrent = (i == selectedEngine); + + if (isInput && eng.getLANetTCInput().getIsRunning()) + { + int inUseComboId = eng.getLANetTCInput().getSelectedInterface() + 1; + if (inUseComboId == comboId) + { + if (isCurrent) currentDot = juce::String(" ") + juce::String::charToString(0x25CF); + else return " [" + eng.getName() + "]"; + } + } + if (!isInput && eng.getLANetTCOutput().getIsRunning()) + { + int inUseComboId = eng.getLANetTCOutput().getSelectedInterface() + 3; + if (inUseComboId == comboId) + { + if (isCurrent) currentDot = juce::String(" ") + juce::String::charToString(0x25CF); + else return " [" + eng.getName() + "]"; + } + } + } + return currentDot; + }; + cmbArtnetInputInterface.addItem("All Interfaces" + getArtnetMarker(1, true), 1); cmbArtnetOutputInterface.addItem("All Interfaces (Broadcast)" + getArtnetMarker(1, false), 1); cmbArtnetDmxInterface.addItem("All Interfaces (Broadcast)", 1); cmbTcnetInterface.clear(juce::dontSendNotification); cmbTcnetInterface.addItem("All Interfaces (Broadcast)", 1); + + cmbLANetTCInputInterface.addItem("All Interfaces" + getLANetTCMarker(1, true), 1); + cmbLANetTCOutputInterface.addItem("All Interfaces (Broadcast)" + getLANetTCMarker(1, false), 1); + cmbLANetTCOutputInterface.addItem("Localhost (127.0.0.1)" + getLANetTCMarker(2, false), 2); + for (int i = 0; i < nets.size(); i++) { auto label = nets[i].name + " (" + nets[i].ip + ")"; @@ -4012,6 +4138,8 @@ void MainComponent::populateMidiAndNetworkCombos() cmbArtnetOutputInterface.addItem(label + getArtnetMarker(i + 2, false), i + 2); cmbArtnetDmxInterface.addItem(label, i + 2); cmbTcnetInterface.addItem(label, i + 2); + cmbLANetTCInputInterface.addItem(label + getLANetTCMarker(i + 2, true), i + 2); + cmbLANetTCOutputInterface.addItem(label + getLANetTCMarker(i + 3, false), i + 3); } // Pro DJ Link interfaces @@ -4086,6 +4214,11 @@ void MainComponent::populateMidiAndNetworkCombos() cmbTcnetInterface.setSelectedId(savedTcnetIf, juce::dontSendNotification); else if (cmbTcnetInterface.getNumItems() > 0) cmbTcnetInterface.setSelectedId(1, juce::dontSendNotification); // default: All Interfaces + + if (savedLAIn > 0 && savedLAIn <= cmbLANetTCInputInterface.getNumItems()) + cmbLANetTCInputInterface.setSelectedId(savedLAIn, juce::dontSendNotification); + if (savedLAOut > 0 && savedLAOut <= cmbLANetTCOutputInterface.getNumItems()) + cmbLANetTCOutputInterface.setSelectedId(savedLAOut, juce::dontSendNotification); } void MainComponent::populateAudioCombos() @@ -4166,6 +4299,7 @@ void MainComponent::loadAndApplyNonAudioSettings() eng.setOutputMtcEnabled(es.mtcOutEnabled); eng.setOutputArtnetEnabled(es.artnetOutEnabled); + eng.setOutputLANetTCEnabled(es.laNetTCOutEnabled); eng.setOutputLtcEnabled(es.ltcOutEnabled); eng.setOutputThruEnabled(es.thruOutEnabled); eng.setOutputTcnetEnabled(es.tcnetOutEnabled); @@ -4175,6 +4309,7 @@ void MainComponent::loadAndApplyNonAudioSettings() eng.setMtcOutputOffset(es.mtcOutputOffset); eng.setArtnetOutputOffset(es.artnetOutputOffset); + eng.setLANetTCOutputOffset(es.laNetTCOutputOffset); eng.setLtcOutputOffset(es.ltcOutputOffset); eng.setTcnetOutputOffsetMs(es.tcnetOutputOffsetMs); @@ -4228,6 +4363,10 @@ void MainComponent::loadAndApplyNonAudioSettings() DBG("MainComponent: HippoNet input disabled, falling back to Generator"); eng.setInputSource(SrcType::SystemTime); } + else if (src == SrcType::LANetTC) + { + eng.startLANetTCInput(es.laNetTCInputInterface); + } // Generator start/stop TC (applies regardless of current source) eng.setGeneratorClockMode(es.generatorClockMode); @@ -4321,6 +4460,9 @@ void MainComponent::loadAndApplyNonAudioSettings() // HippoNet output disabled in this version (pending hardware validation) // if (es.hippoOutEnabled) // eng.startHippotizerOutput(es.hippotizerDestIp); + + if (es.laNetTCOutEnabled) + eng.startLANetTCOutput(es.laNetTCOutputInterface - 2); // saved as combo-2; LANetTCOutput needs -2=All, -1=Localost, 0=firstNIC } // Start TCNet output if any engine has it enabled @@ -4508,6 +4650,7 @@ void MainComponent::flushSettings() es.mtcOutEnabled = eng.isOutputMtcEnabled(); es.artnetOutEnabled = eng.isOutputArtnetEnabled(); + es.laNetTCOutEnabled = eng.isOutputLANetTCEnabled(); es.ltcOutEnabled = eng.isOutputLtcEnabled(); es.thruOutEnabled = eng.isOutputThruEnabled(); es.tcnetOutEnabled = eng.isOutputTcnetEnabled(); @@ -4520,6 +4663,7 @@ void MainComponent::flushSettings() es.mtcOutputOffset = eng.getMtcOutputOffset(); es.artnetOutputOffset = eng.getArtnetOutputOffset(); + es.laNetTCOutputOffset = eng.getLANetTCOutputOffset(); es.ltcOutputOffset = eng.getLtcOutputOffset(); es.tcnetOutputOffsetMs = eng.getTcnetOutputOffsetMs(); @@ -4535,6 +4679,7 @@ void MainComponent::flushSettings() es.midiInputDevice = stripComboMarker(cmbMidiInputDevice.getText()); es.midiOutputDevice = stripComboMarker(cmbMidiOutputDevice.getText()); es.artnetInputInterface = cmbArtnetInputInterface.getSelectedId() - 1; + es.laNetTCInputInterface = cmbLANetTCInputInterface.getSelectedId() - 1; es.hippotizerInputInterface = cmbHippoInputInterface.getSelectedId() - 1; es.hippotizerTcChannel = cmbHippoTcChannel.getSelectedId() - 1; es.hippotizerDestIp = txtHippoDestIp.getText().trim(); @@ -4543,6 +4688,7 @@ void MainComponent::flushSettings() es.generatorStartMs = eng.getGeneratorStartMs(); es.generatorStopMs = eng.getGeneratorStopMs(); es.artnetOutputInterface = cmbArtnetOutputInterface.getSelectedId() - 1; + es.laNetTCOutputInterface = cmbLANetTCOutputInterface.getSelectedId() - 1; es.trackMapEnabled = eng.isTrackMapEnabled(); es.midiClockEnabled = eng.isMidiClockEnabled(); es.oscBpmForward = eng.isOscForwardEnabled(); @@ -5017,6 +5163,7 @@ void MainComponent::updateDeviceSelectorVisibility() auto input = eng.getActiveInput(); bool showMidiIn = (input == SrcType::MTC) && inputConfigExpanded; bool showArtnetIn = (input == SrcType::ArtNet) && inputConfigExpanded; + bool showLANetTCIn = (input == SrcType::LANetTC) && inputConfigExpanded; bool showHippoIn = (input == SrcType::Hippotizer) && inputConfigExpanded; bool showLtcIn = (input == SrcType::LTC) && inputConfigExpanded; bool showGenerator = (input == SrcType::SystemTime) && inputConfigExpanded; @@ -5029,6 +5176,7 @@ void MainComponent::updateDeviceSelectorVisibility() cmbMidiInputDevice.setVisible(showMidiIn); lblMidiInputDevice.setVisible(showMidiIn); cmbArtnetInputInterface.setVisible(showArtnetIn); lblArtnetInputInterface.setVisible(showArtnetIn); + cmbLANetTCInputInterface.setVisible(showLANetTCIn); lblLANetTCInputInterface.setVisible(showLANetTCIn); cmbHippoInputInterface.setVisible(showHippoIn); lblHippoInputInterface.setVisible(showHippoIn); cmbHippoTcChannel.setVisible(showHippoIn); lblHippoTcChannel.setVisible(showHippoIn); btnGenClock.setVisible(showGenerator); @@ -5252,15 +5400,18 @@ void MainComponent::updateDeviceSelectorVisibility() // Output sections bool showMtcConfig = eng.isOutputMtcEnabled() && mtcOutExpanded; bool showArtnetConfig = eng.isOutputArtnetEnabled() && artnetOutExpanded; + bool showLANetTCConfig = eng.isOutputLANetTCEnabled() && laNetTCOutExpanded; bool showLtcConfig = eng.isOutputLtcEnabled() && ltcOutExpanded; bool showThruConfig = eng.isPrimary() && eng.isOutputThruEnabled() && thruOutExpanded; btnCollapseMtcOut.setVisible(eng.isOutputMtcEnabled()); btnCollapseArtnetOut.setVisible(eng.isOutputArtnetEnabled()); + btnCollapseLANetTCOut.setVisible(eng.isOutputLANetTCEnabled()); btnCollapseLtcOut.setVisible(eng.isOutputLtcEnabled()); btnCollapseThruOut.setVisible(eng.isPrimary() && eng.isOutputThruEnabled()); updateCollapseButtonText(btnCollapseMtcOut, mtcOutExpanded); updateCollapseButtonText(btnCollapseArtnetOut, artnetOutExpanded); + updateCollapseButtonText(btnCollapseLANetTCOut, laNetTCOutExpanded); updateCollapseButtonText(btnCollapseLtcOut, ltcOutExpanded); updateCollapseButtonText(btnCollapseThruOut, thruOutExpanded); @@ -5272,6 +5423,10 @@ void MainComponent::updateDeviceSelectorVisibility() sldArtnetOffset.setVisible(showArtnetConfig); lblArtnetOffset.setVisible(showArtnetConfig); lblOutputArtnetStatus.setVisible(eng.isOutputArtnetEnabled()); + cmbLANetTCOutputInterface.setVisible(showLANetTCConfig); lblLANetTCOutputInterface.setVisible(showLANetTCConfig); + sldLANetTCOffset.setVisible(showLANetTCConfig); lblLANetTCOffset.setVisible(showLANetTCConfig); + lblOutputLANetTCStatus.setVisible(eng.isOutputLANetTCEnabled()); + cmbAudioOutputTypeFilter.setVisible(showAudioOut && (showLtcConfig || showThruConfig)); lblAudioOutputTypeFilter.setVisible(showAudioOut && (showLtcConfig || showThruConfig)); @@ -5306,7 +5461,7 @@ void MainComponent::updateDeviceSelectorVisibility() // On-air gate: only relevant for ProDJLink btnOnAirGate.setVisible(input == SrcType::ProDJLink); - bool anyDevice = (input != SrcType::SystemTime) || eng.isOutputMtcEnabled() || eng.isOutputArtnetEnabled() || eng.isOutputLtcEnabled() || (eng.isPrimary() && eng.isOutputThruEnabled()); + bool anyDevice = (input != SrcType::SystemTime) || eng.isOutputMtcEnabled() || eng.isOutputArtnetEnabled() || eng.isOutputLtcEnabled() || (eng.isPrimary() && eng.isOutputThruEnabled()) || eng.isOutputLANetTCEnabled(); btnRefreshDevices.setVisible(anyDevice); resized(); @@ -5333,6 +5488,11 @@ void MainComponent::updateStatusLabels() else lblOutputArtnetStatus.setText(eng.getArtnetOutStatusText(), juce::dontSendNotification); + if (eng.isOutputLANetTCEnabled() && eng.getLANetTCOutput().getIsRunning()) + lblOutputLANetTCStatus.setText(eng.getLANetTCOutput().isPaused() ? "PAUSED" : eng.getLANetTCOutStatusText(), juce::dontSendNotification); + else + lblOutputLANetTCStatus.setText(eng.getLANetTCOutStatusText(), juce::dontSendNotification); + if (eng.isOutputLtcEnabled()) { if (eng.getLtcOutput().getIsRunning()) @@ -6241,7 +6401,7 @@ void MainComponent::resized() // Input buttons auto& eng = currentEngine(); struct IBI { juce::TextButton* btn; SrcType src; }; - IBI iBtns[] = { {&btnMtcIn,SrcType::MTC}, {&btnArtnetIn,SrcType::ArtNet}, {&btnHippoIn,SrcType::Hippotizer}, {&btnSysTime,SrcType::SystemTime}, {&btnLtcIn,SrcType::LTC}, {&btnProDJLinkIn,SrcType::ProDJLink}, {&btnStageLinQIn,SrcType::StageLinQ} }; + IBI iBtns[] = { {&btnMtcIn,SrcType::MTC}, {&btnArtnetIn,SrcType::ArtNet}, {&btnHippoIn,SrcType::Hippotizer}, {&btnSysTime,SrcType::SystemTime}, {&btnLtcIn,SrcType::LTC}, {&btnProDJLinkIn,SrcType::ProDJLink}, {&btnStageLinQIn,SrcType::StageLinQ}, {&btnLANetTCIn,SrcType::LANetTC}}; for (auto& ib : iBtns) { if (!ib.btn->isVisible()) continue; ib.btn->setBounds(leftPanel.removeFromTop(btnH)); leftPanel.removeFromTop(btnG); } // Section separator after input source buttons @@ -6255,6 +6415,7 @@ void MainComponent::resized() if (cmbArtnetInputInterface.isVisible()) layCombo(lblArtnetInputInterface, cmbArtnetInputInterface, leftPanel); if (cmbHippoInputInterface.isVisible()) layCombo(lblHippoInputInterface, cmbHippoInputInterface, leftPanel); if (cmbHippoTcChannel.isVisible()) layCombo(lblHippoTcChannel, cmbHippoTcChannel, leftPanel); + if (cmbLANetTCInputInterface.isVisible()) layCombo(lblLANetTCInputInterface, cmbLANetTCInputInterface, leftPanel); // Generator clock toggle + transport + start/stop TC if (btnGenClock.isVisible()) @@ -6845,6 +7006,17 @@ void MainComponent::resized() rp.removeFromTop(2); } + // LaserAnimation Net-Timecode OUT + { + auto row = rp.removeFromTop(btnH); + if (btnCollapseLANetTCOut.isVisible()) { btnCollapseLANetTCOut.setBounds(row.removeFromRight(colBtnW)); row.removeFromRight(3); } + btnLANetTCOut.setBounds(row); rp.removeFromTop(2); + if (cmbLANetTCOutputInterface.isVisible()) layCombo(lblLANetTCOutputInterface, cmbLANetTCOutputInterface, rp); + if (sldLANetTCOffset.isVisible()) laySlider(lblLANetTCOffset, sldLANetTCOffset, rp); + if (lblOutputLANetTCStatus.isVisible()) layStatus(lblOutputLANetTCStatus, rp); + rp.removeFromTop(2); + } + if (btnRefreshDevices.isVisible()) { rp.removeFromTop(4); btnRefreshDevices.setBounds(rp.removeFromTop(26)); } @@ -6922,7 +7094,7 @@ void MainComponent::timerCallback() // Tick ALL engines (not just selected). // NOTE: tick() feeds timecode values to the output protocol handlers. - // MTC and ArtNet outputs use their own HighResolutionTimers (1ms) for + // MTC and ArtNet and LA Net-Timecode outputs use their own HighResolutionTimers (1ms) for // actual transmission timing, so the 60Hz UI timer only updates the // target timecode -- it does NOT limit output precision. LTC output // uses its own audio-callback-driven auto-increment, so it's similarly @@ -7156,6 +7328,7 @@ void MainComponent::timerCallback() case SrcType::ProDJLink: artist = "Pro DJ Link"; break; case SrcType::StageLinQ: artist = "StageLinQ"; break; case SrcType::Hippotizer: artist = "HippoNet"; break; + case SrcType::LANetTC: artist = "LA-Net Input"; break; default: artist = "STC"; break; } title = eng.getName(); @@ -7968,7 +8141,7 @@ void MainComponent::timerCallback() mtrThruOutput.setLevel(eng.getSmoothedThruOutLevel()); // Auto-update FPS button states when the frame rate changes - // (e.g. from protocol auto-detection in MTC/ArtNet/LTC inputs) + // (e.g. from protocol auto-detection in MTC/ArtNet/LTC/LA-Net inputs) { FrameRate curFps = eng.getCurrentFps(); FrameRate curOutFps = eng.getEffectiveOutputFps(); @@ -8242,7 +8415,7 @@ void MainComponent::updateInputButtonStates() auto& eng = currentEngine(); auto active = eng.getActiveInput(); struct I { juce::TextButton* b; SrcType s; }; - I bs[] = { {&btnMtcIn,SrcType::MTC}, {&btnArtnetIn,SrcType::ArtNet}, {&btnHippoIn,SrcType::Hippotizer}, {&btnSysTime,SrcType::SystemTime}, {&btnLtcIn,SrcType::LTC}, {&btnProDJLinkIn,SrcType::ProDJLink}, {&btnStageLinQIn,SrcType::StageLinQ} }; + I bs[] = { {&btnMtcIn,SrcType::MTC}, {&btnArtnetIn,SrcType::ArtNet}, {&btnHippoIn,SrcType::Hippotizer}, {&btnSysTime,SrcType::SystemTime}, {&btnLtcIn,SrcType::LTC}, {&btnProDJLinkIn,SrcType::ProDJLink}, {&btnStageLinQIn,SrcType::StageLinQ}, {&btnLANetTCIn,SrcType::LANetTC} }; for (auto& i : bs) styleInputButton(*i.b, active == i.s, getInputColour(i.s)); // Stop shared network inputs when no engine uses them. @@ -8307,6 +8480,7 @@ juce::Colour MainComponent::getInputColour(SrcType s) const case SrcType::ProDJLink: return juce::Colour(0xFF00AAFF); // bright blue case SrcType::StageLinQ: return juce::Colour(0xFF00CC66); // Denon green case SrcType::Hippotizer: return juce::Colour(0xFF66BBAA); // Green Hippo teal + case SrcType::LANetTC: return juce::Colour{0xFF63D8FB}; // LaserAnimation turquois default: return textMid; } } diff --git a/MainComponent.h b/MainComponent.h index 02ffc1b..9e6d678 100644 --- a/MainComponent.h +++ b/MainComponent.h @@ -296,11 +296,13 @@ class MainComponent : public juce::Component, bool artnetOutExpanded = true; bool ltcOutExpanded = true; bool thruOutExpanded = true; + bool laNetTCOutExpanded = true; // --- Input buttons --- juce::TextButton btnMtcIn { "MTC" }; juce::TextButton btnArtnetIn { "ART-NET" }; juce::TextButton btnSysTime { "GENERATOR" }; + juce::TextButton btnLANetTCIn { "LA-NET" }; // Generator controls (visible when input = Generator) juce::ToggleButton btnGenClock { "CLOCK" }; // system clock mode toggle @@ -351,6 +353,7 @@ class MainComponent : public juce::Component, juce::ToggleButton btnArtnetOut { "ART-NET OUT" }; juce::ToggleButton btnLtcOut { "LTC OUT" }; juce::ToggleButton btnThruOut { "AUDIO THRU" }; + juce::ToggleButton btnLANetTCOut { "LA-NET OUT" }; // --- FPS buttons --- juce::TextButton btnFps2398 { "23.976" }; @@ -373,6 +376,7 @@ class MainComponent : public juce::Component, juce::TextButton btnCollapseArtnetOut { "" }; juce::TextButton btnCollapseLtcOut { "" }; juce::TextButton btnCollapseThruOut { "" }; + juce::TextButton btnCollapseLANetTCOut { "" }; // --- Left panel (input config) --- juce::ComboBox cmbAudioInputTypeFilter; juce::Label lblAudioInputTypeFilter; @@ -382,6 +386,7 @@ class MainComponent : public juce::Component, juce::ComboBox cmbArtnetInputInterface; juce::Label lblArtnetInputInterface; juce::ComboBox cmbHippoInputInterface; juce::Label lblHippoInputInterface; juce::ComboBox cmbHippoTcChannel; juce::Label lblHippoTcChannel; + juce::ComboBox cmbLANetTCInputInterface; juce::Label lblLANetTCInputInterface; // Pro DJ Link controls juce::ComboBox cmbProDJLinkInterface; juce::Label lblProDJLinkInterface; juce::ComboBox cmbProDJLinkPlayer; juce::Label lblProDJLinkPlayer; @@ -537,6 +542,7 @@ class MainComponent : public juce::Component, juce::ComboBox cmbAudioOutputTypeFilter; juce::Label lblAudioOutputTypeFilter; juce::ComboBox cmbMidiOutputDevice; juce::Label lblMidiOutputDevice; juce::ComboBox cmbArtnetOutputInterface; juce::Label lblArtnetOutputInterface; + juce::ComboBox cmbLANetTCOutputInterface; juce::Label lblLANetTCOutputInterface; juce::ComboBox cmbAudioOutputDevice; juce::Label lblAudioOutputDevice; juce::ComboBox cmbAudioOutputChannel; juce::Label lblAudioOutputChannel; GainSlider sldLtcOutputGain; juce::Label lblLtcOutputGain; @@ -552,6 +558,8 @@ class MainComponent : public juce::Component, juce::Label lblOutputLtcStatus; GainSlider sldLtcOffset; juce::Label lblLtcOffset; juce::Label lblOutputThruStatus; + juce::Label lblOutputLANetTCStatus; + GainSlider sldLANetTCOffset; juce::Label lblLANetTCOffset; juce::TextButton btnRefreshDevices { "Refresh Devices" }; juce::HyperlinkButton btnGitHub { "github.com/fiverecords/SuperTimecodeConverter", @@ -617,6 +625,7 @@ class MainComponent : public juce::Component, void startCurrentProDJLinkInput(); void startCurrentStageLinQInput(); void startCurrentHippotizerInput(); + void startCurrentLANetTCInput(); void openTrackMapEditor(); void openCuePointEditor(TrackMapEntry* entry); void openMixerMapEditor(); @@ -629,6 +638,7 @@ class MainComponent : public juce::Component, void startCurrentThruOutput(); void startCurrentMtcOutput(); void startCurrentArtnetOutput(); + void startCurrentLANetTCOutput(); void startCurrentLtcOutput(); void startCurrentGenAudio(); void updateCurrentOutputStates(); diff --git a/README.md b/README.md index d657873..4fe1996 100644 --- a/README.md +++ b/README.md @@ -632,6 +632,8 @@ The application is built around a modular, header-only architecture: | `MtcOutput.h` | MIDI Time Code transmitter (high-resolution timer with fractional accumulator) | | `ArtnetInput.h` | Art-Net timecode receiver (UDP) with bind fallback | | `ArtnetOutput.h` | Art-Net timecode and DMX broadcaster (UDP) with drift-free timing | +| `LANetTimecodeInput.h` | LaserAnimation Net-Timecode receiver (UDP) with bind fallback | +| `LANetTimecodeOutput.h` | LaserAnimation Net-Timecode broadcaster (UDP) with drift-free timing | | `TCNetOutput.h` | Full TCNet server: broadcast + unicast with slave discovery, Metrics streaming, Metadata, Artwork | | `HippotizerInput.h` | HippoNet timecode receiver: UDP port 6091, multi-layer (TC1/TC2), auto-discovery on port 9009 | | `StcLogoData.h` | Embedded STC logo JPEG (300x300) for TCNet artwork fallback | diff --git a/TimecodeEngine.h b/TimecodeEngine.h index 0bb1a62..bd24c7f 100644 --- a/TimecodeEngine.h +++ b/TimecodeEngine.h @@ -9,6 +9,8 @@ #include "MtcOutput.h" #include "ArtnetInput.h" #include "ArtnetOutput.h" +#include "LANetTimecodeInput.h" +#include "LANetTimecodeOutput.h" #include "LtcInput.h" #include "LtcOutput.h" #include "ProDJLinkInput.h" @@ -40,7 +42,7 @@ inline constexpr int kMaxEngines = 8; class TimecodeEngine { public: - enum class InputSource { MTC, ArtNet, SystemTime, LTC, ProDJLink, StageLinQ, Hippotizer }; + enum class InputSource { MTC, ArtNet, SystemTime, LTC, ProDJLink, StageLinQ, Hippotizer, LANetTC }; //-------------------------------------------------------------------------- explicit TimecodeEngine(int index, const juce::String& name = {}) @@ -64,11 +66,13 @@ class TimecodeEngine // Shutdown order: outputs first, then inputs stopMtcOutput(); stopArtnetOutput(); + stopLANetTCOutput(); stopLtcOutput(); stopHippotizerOutput(); stopThruOutput(); stopMtcInput(); stopArtnetInput(); + stopLANetTCInput(); stopLtcInput(); stopHippotizerInput(); stopAudioBpm(); @@ -131,6 +135,7 @@ class TimecodeEngine case InputSource::ProDJLink: stopProDJLinkInput(); break; case InputSource::StageLinQ: stopStageLinQInput(); break; case InputSource::Hippotizer: stopHippotizerInput(); break; + case InputSource::LANetTC: stopLANetTCInput(); break; default: break; } @@ -201,6 +206,7 @@ class TimecodeEngine FrameRate outRate = getEffectiveOutputFps(); mtcOutput.setFrameRate(outRate); artnetOutput.setFrameRate(outRate); + laNetTCOutput.setFrameRate(outRate); ltcOutput.setFrameRate(outRate); hippotizerOutput.setFrameRate(outRate); @@ -277,6 +283,7 @@ class TimecodeEngine FrameRate outRate = getEffectiveOutputFps(); mtcOutput.setFrameRate(outRate); artnetOutput.setFrameRate(outRate); + laNetTCOutput.setFrameRate(outRate); ltcOutput.setFrameRate(outRate); hippotizerOutput.setFrameRate(outRate); } @@ -288,12 +295,14 @@ class TimecodeEngine bool isOutputArtnetEnabled() const { return outputArtnetEnabled; } bool isOutputLtcEnabled() const { return outputLtcEnabled; } bool isOutputThruEnabled() const { return outputThruEnabled; } + bool isOutputLANetTCEnabled() const { return outputLANetTCEnabled; } void setOutputMtcEnabled(bool e) { outputMtcEnabled = e; } void setOutputArtnetEnabled(bool e) { outputArtnetEnabled = e; } void setOutputLtcEnabled(bool e) { outputLtcEnabled = e; } void setOutputThruEnabled(bool e) { outputThruEnabled = e; } void setOutputTcnetEnabled(bool e) { outputTcnetEnabled = e; } + void setOutputLANetTCEnabled(bool e) { outputLANetTCEnabled = e; } bool isOutputTcnetEnabled() const { return outputTcnetEnabled; } void setOutputHippoEnabled(bool e) { outputHippoEnabled = e; } bool isOutputHippoEnabled() const { return outputHippoEnabled; } @@ -303,11 +312,13 @@ class TimecodeEngine int getMtcOutputOffset() const { return mtcOutputOffset; } int getArtnetOutputOffset() const { return artnetOutputOffset; } int getLtcOutputOffset() const { return ltcOutputOffset; } + int getLANetTCOutputOffset() const { return laNetTCOutputOffset; } void setMtcOutputOffset(int v) { mtcOutputOffset = v; } void setArtnetOutputOffset(int v) { artnetOutputOffset = v; } void setLtcOutputOffset(int v) { ltcOutputOffset = v; } void setTcnetOutputOffsetMs(int v) { tcnetOutputOffsetMs = juce::jlimit(-1000, 1000, v); } + void setLANetTCOutputOffset(int v) { laNetTCOutputOffset = v; } int getTcnetOutputOffsetMs() const { return tcnetOutputOffsetMs; } @@ -349,6 +360,8 @@ class TimecodeEngine ProDJLinkInput& getProDJLinkInput() { jassert(sharedProDJLink != nullptr); return *sharedProDJLink; } void setSharedProDJLinkInput(ProDJLinkInput* shared) { sharedProDJLink = shared; } StageLinQInput& getStageLinQInput() { jassert(sharedStageLinQ != nullptr); return *sharedStageLinQ; } + LANetTimecodeInput& getLANetTCInput() { return laNetTCInput; } + LANetTimecodeOutput& getLANetTCOutput() { return laNetTCOutput; } void setSharedStageLinQInput(StageLinQInput* shared) { sharedStageLinQ = shared; } void setDbServerClient(DbServerClient* client) { dbClient = client; } int getProDJLinkPlayer() const { return proDJLinkPlayer; } @@ -408,6 +421,24 @@ class TimecodeEngine void stopArtnetInput() { artnetInput.stop(); } + bool startLANetTCInput(int interfaceIndex) + { + stopLANetTCInput(); + if (interfaceIndex < 0) interfaceIndex = 0; + laNetTCInput.refreshNetworkInterfaces(); + if (laNetTCInput.start(interfaceIndex, 8201)) + { + inputStatusText = "RX ON " + laNetTCInput.getBindInfo(); + if (laNetTCInput.didFallBackToAllInterfaces()) + inputStatusText += " [FALLBACK]"; + return true; + } + inputStatusText = "FAILED TO BIND PORT 8201"; + return false; + } + + void stopLANetTCInput() { laNetTCInput.stop(); } + bool startLtcInput(const juce::String& typeName, const juce::String& devName, int ltcChannel, int thruChannel = -1, double sampleRate = 0, int bufferSize = 0) @@ -851,6 +882,22 @@ class TimecodeEngine void stopArtnetOutput() { artnetOutput.stop(); artnetOutStatusText = ""; } + bool startLANetTCOutput(int interfaceIndex) + { + stopLANetTCOutput(); + laNetTCOutput.refreshNetworkInterfaces(); + if (laNetTCOutput.start(interfaceIndex, 8201)) + { + laNetTCOutput.setFrameRate(getEffectiveOutputFps()); + laNetTCOutStatusText = "TX: " + laNetTCOutput.getBroadcastIp() + ":8201"; + return true; + } + laNetTCOutStatusText = "FAILED TO BIND"; + return false; + } + + void stopLANetTCOutput() { laNetTCOutput.stop(); laNetTCOutStatusText = ""; } + bool startLtcOutput(const juce::String& typeName, const juce::String& devName, int channel, double sampleRate = 0, int bufferSize = 0) { @@ -1014,6 +1061,25 @@ class TimecodeEngine else { sourceActive = false; if (statusTextVisible) inputStatusText = "NOT LISTENING"; } break; + case InputSource::LANetTC: + if (laNetTCInput.getIsRunning()) + { + currentTimecode = laNetTCInput.getCurrentTimecode(); + bool rx = laNetTCInput.isReceiving(); + if (rx) + { + auto d = laNetTCInput.getDetectedFrameRate(); + if (d != currentFps) setFrameRate(d); + if (statusTextVisible) + inputStatusText = "RX ON " + laNetTCInput.getBindInfo(); + } + else if (statusTextVisible) + inputStatusText = "PAUSED - " + laNetTCInput.getBindInfo(); + sourceActive = rx; + } + else { sourceActive = false; if (statusTextVisible) inputStatusText = "NOT LISTENING"; } + break; + case InputSource::LTC: if (ltcInput.getIsRunning()) { @@ -1844,6 +1910,7 @@ class TimecodeEngine juce::String getLtcOutStatusText() const { return ltcOutStatusText; } juce::String getThruOutStatusText() const { return thruOutStatusText; } juce::String getHippoOutStatusText() const { return hippoOutStatusText; } + juce::String getLANetTCOutStatusText() const { return laNetTCOutStatusText; } /// Only the currently displayed engine needs to build status text strings. /// Call with true for the selected engine, false for background engines. @@ -2154,6 +2221,7 @@ class TimecodeEngine case InputSource::ProDJLink: return sharedProDJLink != nullptr && sharedProDJLink->getIsRunning(); case InputSource::StageLinQ: return sharedStageLinQ != nullptr && sharedStageLinQ->getIsRunning(); case InputSource::Hippotizer: return hippotizerInput.getIsRunning(); + case InputSource::LANetTC: return laNetTCInput.getIsRunning(); default: return false; } } @@ -2169,6 +2237,7 @@ class TimecodeEngine case InputSource::ProDJLink: return "ProDJLink"; case InputSource::StageLinQ: return "StageLinQ"; case InputSource::Hippotizer: return "HippoNet"; + case InputSource::LANetTC: return "LANetTC"; } return "Generator"; } @@ -2183,6 +2252,7 @@ class TimecodeEngine if (s == "HippoNet" || s == "Hippotizer") return InputSource::Hippotizer; if (s == "TCNet") return InputSource::ProDJLink; // legacy migration if (s == "Generator" || s == "SystemTime") return InputSource::SystemTime; // backward compat + if (s == "LANetTC") return InputSource::LANetTC; return InputSource::SystemTime; } @@ -2197,6 +2267,7 @@ class TimecodeEngine case InputSource::ProDJLink: return "PRO DJ LINK"; case InputSource::StageLinQ: return "STAGELINQ"; case InputSource::Hippotizer: return "HIPPONET"; + case InputSource::LANetTC: return "LA-NET"; default: return "---"; } } @@ -2268,6 +2339,7 @@ class TimecodeEngine // Output state bool outputMtcEnabled = false; bool outputArtnetEnabled = false; + bool outputLANetTCEnabled = false; bool outputLtcEnabled = false; bool outputThruEnabled = false; bool outputTcnetEnabled = false; @@ -2282,6 +2354,7 @@ class TimecodeEngine int mtcOutputOffset = 0; int artnetOutputOffset = 0; + int laNetTCOutputOffset = 0; int ltcOutputOffset = 0; int tcnetOutputOffsetMs = 0; // TCNet offset in milliseconds @@ -2290,6 +2363,8 @@ class TimecodeEngine MtcOutput mtcOutput; ArtnetInput artnetInput; ArtnetOutput artnetOutput; + LANetTimecodeInput laNetTCInput; + LANetTimecodeOutput laNetTCOutput; HippotizerInput hippotizerInput; HippotizerOutput hippotizerOutput; LtcInput ltcInput; @@ -2742,7 +2817,7 @@ class TimecodeEngine // Status juce::String inputStatusText = "SYSTEM CLOCK"; - juce::String mtcOutStatusText, artnetOutStatusText, ltcOutStatusText, thruOutStatusText, hippoOutStatusText; + juce::String mtcOutStatusText, artnetOutStatusText, ltcOutStatusText, thruOutStatusText, hippoOutStatusText, laNetTCOutStatusText; bool statusTextVisible = true; // only build inputStatusText when engine is displayed // VU meter smoothed state @@ -3653,6 +3728,11 @@ class TimecodeEngine artnetOutput.setTimecode(offsetTimecode(baseTc, artnetOutputOffset, outRate)); artnetOutput.setPaused(false); } + if (outputLANetTCEnabled && laNetTCOutput.getIsRunning()) + { + laNetTCOutput.setTimecode(offsetTimecode(baseTc, laNetTCOutputOffset, outRate)); + laNetTCOutput.setPaused(false); + } if (outputLtcEnabled && ltcOutput.getIsRunning()) { ltcOutput.setTimecode(offsetTimecode(baseTc, ltcOutputOffset, outRate)); @@ -3678,6 +3758,8 @@ class TimecodeEngine mtcOutput.forceResync(); if (outputArtnetEnabled && artnetOutput.getIsRunning()) artnetOutput.forceResync(); + if (outputLANetTCEnabled && laNetTCOutput.getIsRunning()) + laNetTCOutput.forceResync(); if (outputLtcEnabled && ltcOutput.getIsRunning()) ltcOutput.reseed(); if (outputHippoEnabled && hippotizerOutput.getIsRunning()) @@ -3703,6 +3785,11 @@ class TimecodeEngine artnetOutput.setTimecode(offsetTimecode(baseTc, artnetOutputOffset, outRate)); artnetOutput.forceResync(); } + if (outputLANetTCEnabled && laNetTCOutput.getIsRunning()) + { + laNetTCOutput.setTimecode(offsetTimecode(baseTc, laNetTCOutputOffset, outRate)); + laNetTCOutput.forceResync(); + } // LTC: set final timecode so encoder finishes current frame cleanly if (outputLtcEnabled && ltcOutput.getIsRunning()) ltcOutput.setTimecode(offsetTimecode(baseTc, ltcOutputOffset, outRate)); @@ -3718,6 +3805,7 @@ class TimecodeEngine if (outputMtcEnabled && mtcOutput.getIsRunning()) mtcOutput.setPaused(true); if (outputArtnetEnabled && artnetOutput.getIsRunning()) artnetOutput.setPaused(true); + if (outputLANetTCEnabled && laNetTCOutput.getIsRunning()) laNetTCOutput.setPaused(true); if (outputLtcEnabled && ltcOutput.getIsRunning()) ltcOutput.setPaused(true); if (outputHippoEnabled && hippotizerOutput.getIsRunning()) hippotizerOutput.setPaused(true); }