Super Timecode Converter V1.5.3
Focus: Multi-Engine Stability & Live Performance Hardening
This release is entirely focused on stability and efficiency when running multiple engines simultaneously in a live environment. No new features -- just a more robust, leaner runtime for demanding show setups.
Fixes
DJM Fader Data Not Working on Windows (Critical)
Fixed a regression where the DJM-900NXS2 stopped sending mixer fader data (0x39 packets) to STC on Windows, while macOS continued to work. Two related issues, both involving multi-NIC configurations:
Issue 1 -- Keepalive socket bound to INADDR_ANY on Windows: The keepalive socket (port 50000) was the only socket not bound to the selected interface IP. On Windows systems with two NICs on the same subnet, the OS routing table could send the 54B bridge keepalive broadcast out the wrong interface. The DJM sees the broadcast arriving from a MAC address that doesn't match the one in the protocol payload, and rejects the bridge identity.
Fix: keepaliveSock now binds to bindIp on Windows (like beat, status, and bridge sockets). On macOS, it remains bound to INADDR_ANY because macOS does not deliver broadcast packets to sockets bound to a specific IP -- binding to bindIp would break device discovery entirely.
Issue 2 -- MAC address lookup by adapter name: The getMacAddress() function on Windows matched network adapters by FriendlyName using substring matching. If the adapter name changed -- due to a Windows update, driver update, user renaming, or regional language settings -- the match failed silently and STC used a fallback MAC address (02:00:00:00:00:05). The DJM firmware compares the MAC in the protocol payload against the Ethernet frame source MAC; when they don't match, it rejects the bridge.
Fix: getMacAddress() on Windows now matches primarily by IP address (which STC knows exactly from the bind). Name-based matching is kept as a fallback only.
DJM-A9 Support (Feature)
Full DJM-A9 support, tested and confirmed on real hardware. STC now auto-detects the connected DJM model and adapts the interface accordingly.
The Mixer Map editor includes a three-way model selector (DJM-900NXS2 / DJM-A9 / DJM-V10) that shows only the parameters available on your mixer. The ProDJLinkView mixer panel renders dual CUE A/B buttons when an A9 or V10 is connected. Beat FX names in the GUI match the A9's effect list (including Triplet Filter, Triplet Roll, and Mobius).
A9-specific features supported: Dual CUE A/B, Master CUE B, Headphone B, Booth EQ, Multi I/O (with A9 routing options: CH1-4, MIC, XF-A, XF-B, Master), Bluetooth and USB input sources.
Note: The A9 mic effects section (Echo, Pitch, Megaphone, Reverb) is processed internally by the mixer and is not available for forwarding via the Pro DJ Link protocol.
OpenGL Rendering Removed (Critical -- Windows)
Removed juce::OpenGLContext from both the main window and the ProDJLinkView window. When attached, JUCE's OpenGL renderer calls paint() for all child components on the GL thread, while timerCallback() writes data on the message thread. Every juce::String read in paint() -- artist, title, playState, sourceName, label text, etc. -- is reference-counted, and a concurrent read during write corrupts the refcount, leading to a crash or heap corruption. This affects TimecodeDisplay, ProDJLinkView, all juce::Label instances, and any component that reads String data in paint().
JUCE's OpenGL renderer paints into software images on the CPU and only uses the GPU for the final texture upload and composite. With the waveform image cache, HiDPI deck image cache, and targeted dirty-rect repainting already in place, the performance difference is negligible. Windows DWM already hardware-accelerates the native GDI composite path.
For a live performance application, eliminating the entire class of GL-thread data races is worth more than the marginal compositing speedup.
Requires: The juce_opengl module can remain enabled in Projucer -- it is simply no longer used at runtime.
LTC Encoder Thread Safety (Critical)
Fixed a data race between the audio thread and the message thread in the LTC encoder. The needNewFrame and encoderSeeded flags were plain bool variables written by reseed() on the message thread and read/written by the audio callback at sample rate. On x86 this was benign by coincidence, but on ARM (Apple Silicon) it could produce a corrupted LTC frame after pause/resume transitions.
Fix: Both flags are now std::atomic<bool> with explicit load()/store() operations in all code paths: reseed(), resetEncoder(), packFrame(), and the audio callback.
MIDI Clock Timer Thread Safety (Critical)
Fixed a data race on the midiOut pointer inside the MIDI Clock timer. The pointer was a plain juce::MidiOutput* read at 1000Hz by the HighResolutionTimer thread and written by the message thread via start(), stop(), and updateOutput(). This is undefined behavior in C++ and could crash on Apple Silicon when the pointer write is not atomically visible to the timer thread.
Fix: The pointer is now std::atomic<juce::MidiOutput*>. The timer callback loads it once per invocation via atomic::load(), and all message-thread writes use atomic::store().
Status Text Allocation Reduction (Performance)
Each engine built ~25 juce::String status text values every tick via concatenation, regardless of whether the engine was currently displayed in the UI. With 8 engines at 60Hz, this produced ~12,000 heap allocations per second purely for strings that were never read.
Fix: A new statusTextVisible flag on each engine is set to true only for the currently selected engine. All inputStatusText assignments in tick() are gated behind this flag. Background engines skip all string construction while continuing to process timecode and drive outputs normally.
Lightweight Metadata Cache Lookup (Performance)
getActiveTrackInfo() is called every frame (60Hz) and was copying a full TrackMetadata struct from the DbServerClient cache, including the color waveform std::vector<uint8_t> (~3600 bytes per track). This triggered a heap allocation and memcpy every frame for data that was never used in this code path.
Fix: New getCachedMetadataLightById() method returns a lightweight struct with only text fields and IDs (artist, title, key, BPM, artworkId, duration). All three call sites in TimecodeEngine that only need text metadata now use the light lookup. The full getCachedMetadataByTrackId() remains available for code paths that need waveform data.
TrackMap Double Lookup in PDL View (Performance)
The ProDJLinkView timerCallback() called TrackMap::find() twice per deck per frame with identical arguments -- once for the timecode offset and again for the BPM multiplier. At 30Hz with 4 decks, this was 240 redundant string lookups per second.
Fix: The first find() result is cached in a local tmEntry pointer and reused for both offset and BPM multiplier extraction.
TrackMap Save Without callAsync (Robustness)
The auto-fill TrackMap save used juce::MessageManager::callAsync() capturing a raw TrackMap* pointer. If the application closed between the async post and execution, the pointer would dangle. Since tick() already runs on the message thread (timerCallback), the save is now called directly.
Clean Shutdown and Engine Removal Pointer Nullification (Robustness)
During shutdown and engine removal, engines now have their shared pointers (TrackMap, MixerMap, ProDJLinkInput, DbServerClient) explicitly set to nullptr before output protocols are stopped and before engine objects are destroyed. Previously, removeEngine() stopped protocols but left shared pointers intact, which could cause stale access if a HighResolutionTimer callback fired during the destruction sequence. Both ~MainComponent() and removeEngine() now follow the same cleanup pattern: disconnect shared state first, stop MIDI clock, then stop all I/O.
Zero-Allocation OSC Float Sending (Performance)
The mixer forwarding hot path (sendOscFloat) went through sendFloat() which converted the float to a juce::String, then send() tokenized it back with StringArray::fromTokens and parsed the float again with getFloatValue(). Each call produced ~10 heap allocations for a 40-byte UDP packet. With mixer forwarding active and a DJ moving faders, this generated 100-200 unnecessary allocations per second.
Fix: New OscSender::sendFloatDirect() builds the OSC packet in a 256-byte stack buffer with zero heap allocations: address string padded to 4-byte boundary, type tag ",f", float as big-endian IEEE 754. A single SpinLock acquisition protects the socket write. TriggerOutput::sendOscFloat() now calls sendFloatDirect() directly, also eliminating a redundant double-lock (isConnected() + sendFloat() previously acquired the lock twice).
Waveform Loading Loop Fix (Bug)
After a track change, if the waveform was not yet in the DbServerClient cache (normal -- waveform queries are async and arrive 1-3 seconds after metadata), the main engine panel entered a tight loop that executed every frame at 60Hz: clear waveform, set displayedWaveformTrackId = 0, take the cache SpinLock, copy the full TrackMetadata struct including waveform vector, find no waveform, repeat. The retry else if path was dead code because the first if condition always matched when displayedWaveformTrackId was 0.
Fix: displayedWaveformTrackId is now set to the track ID immediately on track change (marking "attempted"), so the clear only happens once. The retry path checks !waveformDisplay.hasWaveformData() to detect when the async waveform arrives. Eliminates ~60 redundant cache lookups per second during the 1-3 second waveform loading window.
Download
Grab the latest build from the Releases page.