Skip to content

Commit b03257e

Browse files
authored
Update V1.9.3
1 parent 43be160 commit b03257e

9 files changed

Lines changed: 361 additions & 44 deletions

AppSettings.h

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -699,13 +699,20 @@ class TrackMap
699699
// Path segments skip ROOT (same as listRekordboxPlaylists).
700700
juce::XmlElement* playlistNode = nullptr;
701701
{
702-
// Split path into segments: "Shows / Saturday" → ["Shows", "Saturday"]
702+
// Split path into segments on the " / " SUBSTRING.
703+
// (Do NOT use addTokens — it treats the second arg as a set
704+
// of break CHARACTERS, which would split on every space.)
703705
juce::StringArray segments;
704-
if (playlistName.contains(" / "))
705-
segments.addTokens(playlistName, " / ", "");
706-
else
707-
segments.add(playlistName);
708-
// Remove empty tokens from split
706+
juce::String remaining = playlistName;
707+
int pos = remaining.indexOf(" / ");
708+
while (pos >= 0)
709+
{
710+
segments.add(remaining.substring(0, pos));
711+
remaining = remaining.substring(pos + 3);
712+
pos = remaining.indexOf(" / ");
713+
}
714+
segments.add(remaining);
715+
// Drop any empty segments (shouldn't happen but defensive)
709716
for (int i = segments.size() - 1; i >= 0; --i)
710717
if (segments[i].isEmpty()) segments.remove(i);
711718

@@ -994,6 +1001,9 @@ struct EngineSettings
9941001
int tcnetLayer = 0; // TCNet layer index 0-3 (Layer 1-4)
9951002
bool hippoOutEnabled = false; // Hippotizer timecode output
9961003
juce::String hippotizerDestIp = "255.255.255.255"; // Hippotizer destination IP
1004+
1005+
// On-air gate: engine only active when CDJ is flagged on-air by the DJM
1006+
bool onAirGateEnabled = false;
9971007
juce::String midiOutputDevice = "";
9981008
int artnetOutputInterface = 0;
9991009
juce::String audioOutputDevice = "";
@@ -1083,6 +1093,8 @@ struct EngineSettings
10831093
obj->setProperty("thruOutEnabled", thruOutEnabled);
10841094
obj->setProperty("tcnetOutEnabled", tcnetOutEnabled);
10851095
obj->setProperty("tcnetLayer", tcnetLayer);
1096+
if (onAirGateEnabled)
1097+
obj->setProperty("onAirGateEnabled", onAirGateEnabled);
10861098
obj->setProperty("hippoOutEnabled", hippoOutEnabled);
10871099
obj->setProperty("hippotizerDestIp", hippotizerDestIp);
10881100
obj->setProperty("midiOutputDevice", midiOutputDevice);
@@ -1195,6 +1207,7 @@ struct EngineSettings
11951207
thruOutEnabled = getBool("thruOutEnabled", false);
11961208
tcnetOutEnabled = getBool("tcnetOutEnabled", false);
11971209
tcnetLayer = juce::jlimit(0, 3, getInt("tcnetLayer", 0));
1210+
onAirGateEnabled = getBool("onAirGateEnabled", false);
11981211
hippoOutEnabled = getBool("hippoOutEnabled", false);
11991212
hippotizerDestIp = getString("hippotizerDestIp", "255.255.255.255");
12001213
midiOutputDevice = getString("midiOutputDevice");

DbServerClient.h

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,12 @@ class DbServerClient : private juce::Thread
209209
// Close all connections
210210
for (auto& conn : connections)
211211
conn.close();
212+
213+
// Clear any pending invalidation requests
214+
{
215+
const juce::ScopedLock sl(pendingInvalidateLock);
216+
pendingInvalidateIPs.clear();
217+
}
212218
}
213219

214220
bool getIsRunning() const { return isRunningFlag.load(std::memory_order_relaxed); }
@@ -436,11 +442,12 @@ class DbServerClient : private juce::Thread
436442
++it;
437443
}
438444
}
439-
// Close connection to this player
440-
for (auto& conn : connections)
445+
// Queue this player for connection close by the worker thread.
446+
// Closing the socket directly from here would race against the worker
447+
// which may be in the middle of a TCP query using that socket.
441448
{
442-
if (conn.playerIP == playerIP)
443-
conn.close();
449+
const juce::ScopedLock sl(pendingInvalidateLock);
450+
pendingInvalidateIPs.addIfNotAlreadyThere(playerIP);
444451
}
445452
}
446453

@@ -2236,6 +2243,28 @@ class DbServerClient : private juce::Thread
22362243

22372244
if (threadShouldExit()) break;
22382245

2246+
// Process pending invalidations — close connections for players
2247+
// that were reported lost by ProDJLink. Safe to do here because
2248+
// we're between TCP queries on this thread.
2249+
{
2250+
juce::StringArray ips;
2251+
{
2252+
const juce::ScopedLock sl(pendingInvalidateLock);
2253+
ips.swapWith(pendingInvalidateIPs);
2254+
}
2255+
for (auto& ip : ips)
2256+
{
2257+
for (auto& conn : connections)
2258+
{
2259+
if (conn.playerIP == ip)
2260+
{
2261+
DBG("DbServerClient: closing connection to lost player " + ip);
2262+
conn.close();
2263+
}
2264+
}
2265+
}
2266+
}
2267+
22392268
// Close idle connections — prevents zombie TCP connections from
22402269
// holding CDJ NFS slots (CDJ-2000NXS2 has limited slots).
22412270
{
@@ -2628,6 +2657,12 @@ class DbServerClient : private juce::Thread
26282657
// TCP connections (one per CDJ, max 6)
26292658
std::array<PlayerConnection, kMaxConnections> connections;
26302659

2660+
// Deferred invalidation: external threads (e.g. ProDJLink onPlayerLost)
2661+
// queue IPs here; the worker thread closes the matching connections
2662+
// between TCP queries. Avoids closing sockets while they are in use.
2663+
juce::StringArray pendingInvalidateIPs;
2664+
juce::CriticalSection pendingInvalidateLock;
2665+
26312666
// Metadata cache (protected by SpinLock)
26322667
mutable juce::SpinLock cacheLock;
26332668
std::unordered_map<uint64_t, TrackMetadata> metadataCache;

Main.cpp

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ class SuperTimecodeConverterApplication : public juce::JUCEApplication
1111
SuperTimecodeConverterApplication() {}
1212

1313
const juce::String getApplicationName() override { return "Super Timecode Converter"; }
14-
const juce::String getApplicationVersion() override { return "1.9.2"; }
14+
const juce::String getApplicationVersion() override { return "1.9.3"; }
1515
bool moreThanOneInstanceAllowed() override { return false; }
1616

1717
void initialise(const juce::String&) override

MainComponent.cpp

Lines changed: 52 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,22 @@ MainComponent::MainComponent()
193193
styleOutputToggle(btnThruOut, accentCyan);
194194
styleOutputToggle(btnTcnetOut, juce::Colour(0xFF00CC66));
195195

196+
// On-air gate lives in the ProDJLink input panel (it gates input activity,
197+
// not a particular output). Use the small tick-style toggle like btnLink
198+
// to match the look of the ProDJLink panel controls, not the big output
199+
// toggles on the right side.
200+
leftContent.addAndMakeVisible(btnOnAirGate);
201+
btnOnAirGate.setColour(juce::ToggleButton::textColourId, textMid);
202+
btnOnAirGate.setColour(juce::ToggleButton::tickColourId, juce::Colour(0xFFE040FB));
203+
204+
btnOnAirGate.onClick = [this]
205+
{
206+
if (syncing) return;
207+
if (isShowLockedToggle(btnOnAirGate)) return;
208+
currentEngine().setOnAirGateEnabled(btnOnAirGate.getToggleState());
209+
saveSettings();
210+
};
211+
196212
auto outputToggleHandler = [this]
197213
{
198214
if (syncing) return;
@@ -1673,18 +1689,25 @@ MainComponent::~MainComponent()
16731689

16741690
settings.save();
16751691

1676-
// 7. Stop metadata client (before ProDJLink -- it queries player IPs)
1677-
sharedDbClient.stop();
1678-
1679-
// 8. Stop ProDJLink receiver
1692+
// 7. Stop ProDJLink receiver FIRST. This joins its thread, so no more
1693+
// gcPlayers() calls can fire onPlayerLost after this returns.
16801694
sharedProDJLinkInput.stop();
16811695

1682-
// 9. Stop StageLinQ database client (before receiver)
1683-
sharedStageLinQDb.stop();
1696+
// 8. Now it's safe to disconnect the callback and stop DbClient.
1697+
// Reassigning std::function from another thread while the ProDJLink
1698+
// thread might be invoking it would be a race (std::function is not
1699+
// atomic). Doing it after stop() ensures the callback isn't running.
1700+
sharedProDJLinkInput.onPlayerLost = nullptr;
1701+
sharedDbClient.stop();
16841702

1685-
// 10. Stop StageLinQ receiver
1703+
// 9. Stop StageLinQ receiver first to join its thread, so the callbacks
1704+
// (onMetadataRequest, onFileTransferAvailable) can't fire into a
1705+
// stopped StageLinQ db client.
16861706
sharedStageLinQInput.stop();
16871707

1708+
// 10. Now stop StageLinQ database client
1709+
sharedStageLinQDb.stop();
1710+
16881711
// 10. Explicitly shut down each engine (timers, threads, sockets)
16891712
// BEFORE engines.clear() destroys the objects, so all HighResolutionTimer
16901713
// threads are stopped while the message manager is still alive.
@@ -1971,6 +1994,9 @@ void MainComponent::syncUIFromEngine()
19711994
if (selectedEngine < (int)settings.engines.size())
19721995
txtHippoDestIp.setText(settings.engines[(size_t)selectedEngine].hippotizerDestIp, false);
19731996

1997+
// On-air gate
1998+
btnOnAirGate.setToggleState(eng.isOnAirGateEnabled(), juce::dontSendNotification);
1999+
19742000
// Generator TC fields
19752001
btnGenClock.setToggleState(eng.getGeneratorClockMode(), juce::dontSendNotification);
19762002
txtGenStartTC.setText(msToTimecodeString(eng.getGeneratorStartMs(), eng.getCurrentFps()), false);
@@ -3750,6 +3776,7 @@ void MainComponent::loadAndApplyNonAudioSettings()
37503776
eng.setOutputTcnetEnabled(es.tcnetOutEnabled);
37513777
eng.setTcnetLayer(es.tcnetLayer);
37523778
eng.setOutputHippoEnabled(es.hippoOutEnabled);
3779+
eng.setOnAirGateEnabled(es.onAirGateEnabled);
37533780

37543781
eng.setMtcOutputOffset(es.mtcOutputOffset);
37553782
eng.setArtnetOutputOffset(es.artnetOutputOffset);
@@ -4059,6 +4086,7 @@ void MainComponent::flushSettings()
40594086
es.tcnetOutEnabled = eng.isOutputTcnetEnabled();
40604087
es.tcnetLayer = eng.getTcnetLayer();
40614088
es.hippoOutEnabled = eng.isOutputHippoEnabled();
4089+
es.onAirGateEnabled = eng.isOnAirGateEnabled();
40624090
es.generatorClockMode = eng.getGeneratorClockMode();
40634091
es.generatorStartMs = eng.getGeneratorStartMs();
40644092
es.generatorStopMs = eng.getGeneratorStopMs();
@@ -4773,6 +4801,9 @@ void MainComponent::updateDeviceSelectorVisibility()
47734801
txtHippoDestIp.setVisible(false); lblHippoDestIp.setVisible(false);
47744802
lblHippoOutStatus.setVisible(false);
47754803

4804+
// On-air gate: only relevant for ProDJLink
4805+
btnOnAirGate.setVisible(input == SrcType::ProDJLink);
4806+
47764807
bool anyDevice = (input != SrcType::SystemTime) || eng.isOutputMtcEnabled() || eng.isOutputArtnetEnabled() || eng.isOutputLtcEnabled() || (eng.isPrimary() && eng.isOutputThruEnabled());
47774808
btnRefreshDevices.setVisible(anyDevice);
47784809

@@ -5598,6 +5629,13 @@ void MainComponent::resized()
55985629
{
55995630
layCombo(lblProDJLinkPlayer, cmbProDJLinkPlayer, leftPanel);
56005631

5632+
// ON-AIR ONLY toggle (gate engine output by DJM on-air flag)
5633+
if (btnOnAirGate.isVisible())
5634+
{
5635+
btnOnAirGate.setBounds(leftPanel.removeFromTop(22));
5636+
leftPanel.removeFromTop(3);
5637+
}
5638+
56015639
// BPM Multiplier row (5 equal buttons: 1x x2 x4 /2 /4)
56025640
if (btnBpmOff.isVisible())
56035641
{
@@ -7137,14 +7175,19 @@ void MainComponent::updateInputButtonStates()
71377175
if (!anySlq && sharedStageLinQInput.getIsRunning())
71387176
{
71397177
DBG("MainComponent: no engine uses StageLinQ — stopping shared input");
7140-
sharedStageLinQDb.stop();
7178+
// Stop the receiver first to join its thread and prevent further
7179+
// onMetadataRequest / onFileTransferAvailable callbacks firing into
7180+
// a stopped StageLinQ db client.
71417181
sharedStageLinQInput.stop();
7182+
sharedStageLinQDb.stop();
71427183
}
71437184
if (!anyPdl && sharedProDJLinkInput.getIsRunning())
71447185
{
71457186
DBG("MainComponent: no engine uses ProDJLink — stopping shared input");
7146-
sharedDbClient.stop();
7187+
// Stop ProDJLink first to join its thread and prevent further
7188+
// onPlayerLost callbacks firing into a stopped DbClient.
71477189
sharedProDJLinkInput.stop();
7190+
sharedDbClient.stop();
71487191
}
71497192
}
71507193

MainComponent.h

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -433,6 +433,11 @@ class MainComponent : public juce::Component,
433433
juce::TextEditor txtHippoDestIp; juce::Label lblHippoDestIp;
434434
juce::Label lblHippoOutStatus;
435435

436+
// On-air gate (Pro DJ Link only): require the CDJ to be flagged on-air
437+
// by the DJM before the engine produces active timecode. Lets the DJ
438+
// preview tracks on a deck without triggering output.
439+
juce::ToggleButton btnOnAirGate { "ON-AIR ONLY" };
440+
436441
int showLockFlashCountdown = 0; // ticks remaining for flash feedback
437442

438443
/// Returns true if Show Lock is active and the action should be blocked.

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -367,7 +367,7 @@ The sections below are for developers who want to build STC from source.
367367
3. **Create a `CMakeLists.txt`** in the project root:
368368
```cmake
369369
cmake_minimum_required(VERSION 3.22)
370-
project(SuperTimecodeConverter VERSION 1.9.2)
370+
project(SuperTimecodeConverter VERSION 1.9.3)
371371
372372
set(CMAKE_CXX_STANDARD 17)
373373
set(CMAKE_CXX_STANDARD_REQUIRED ON)
@@ -377,7 +377,7 @@ The sections below are for developers who want to build STC from source.
377377
juce_add_gui_app(SuperTimecodeConverter
378378
PRODUCT_NAME "Super Timecode Converter"
379379
COMPANY_NAME "Fiverecords"
380-
VERSION "1.9.2"
380+
VERSION "1.9.3"
381381
HARDENED_RUNTIME_ENABLED TRUE
382382
HARDENED_RUNTIME_OPTIONS com.apple.security.device.audio-input
383383
MICROPHONE_PERMISSION_ENABLED TRUE

RELEASE_NOTES_v1.9.3.md

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
# Super Timecode Converter v1.9.3 -- Release Notes
2+
3+
**Release date:** TBD
4+
**Built on:** v1.9.2
5+
6+
---
7+
8+
## New Feature — On-Air Gate
9+
10+
A new **ON-AIR ONLY** toggle in the output panel makes the engine only produce active timecode when the current CDJ is flagged on-air by the DJM mixer. This lets the DJ load, cue and preview tracks on a deck without triggering downstream timecode until they bring the deck in.
11+
12+
The on-air flag is computed by the DJM itself from the full mixer state (channel fader, cross-fader, EQ kill, mute), transmitted to each CDJ, and is what lights up the ON AIR indicator on the CDJ display. Using it here means STC follows exactly what the DJ sees — no threshold or channel mapping needed.
13+
14+
Only available when the input source is **Pro DJ Link**.
15+
16+
---
17+
18+
## New Feature — Sortable Track Map Columns
19+
20+
The Track Map Editor now has a sortable **#** column showing each track's position in the imported playlist order (or "--" for tracks not in any playlist). Plus **Artist**, **Title**, and **Offset** columns are clickable to sort — just like Finder / Explorer. Click a column header to sort ascending; click again to sort descending.
21+
22+
The default view sorts by playlist position ascending, so when you import a playlist the tracks appear in setlist order. To return to that view after sorting by any other column, click the **#** header.
23+
24+
Artist sort uses Title as tiebreak. BPM, Trig, Cues and Notes remain non-sortable.
25+
26+
---
27+
28+
## Bug Fix — Frame rate reset to 30 fps on every restart when using ProDJLink / StageLinQ
29+
30+
When using ProDJLink or StageLinQ as the input source, the configured frame rate (e.g. 25 fps) was silently overwritten with 30 fps every time STC started up, or when settings were restored from a backup.
31+
32+
The cause was a line in `startProDJLinkInput` / `startStageLinQInput` that set `currentFps = outputFps` on every start. Since `outputFps` defaults to 30 fps and is only updated when the user explicitly enables FPS Conversion, the engine's current frame rate was being reset to 30 every time the input source was started — which happens automatically at boot.
33+
34+
The offending line has been removed. `currentFps` now stays at the value loaded from settings (or the user's button selection).
35+
36+
---
37+
38+
## Bug Fix — Playlist import failed when folder names contain spaces
39+
40+
When importing a rekordbox XML playlist located inside a folder whose name contained a space, the import returned no tracks. The path parser used `juce::StringArray::addTokens` which treats its second argument as a set of separator **characters**, not as a substring — so " / " was interpreted as "separate on space OR slash", splitting "My Folder / Saturday" into four tokens instead of two.
41+
42+
Replaced the character-based split with a proper substring search using `indexOf(" / ")`. Playlists in folders with spaces in their names now import correctly.
43+
44+
---
45+
46+
## Bug Fix — Race condition in Pro DJ Link player invalidation
47+
48+
Fixed a latent race condition between the ProDJLink thread and the DbServerClient worker thread. When a CDJ disappeared from the network, the `onPlayerLost` callback (running on the ProDJLink thread) would close TCP sockets directly while the DbServerClient worker thread could be in the middle of a `socket->write()` using those same sockets — a classic use-after-free that could cause crashes when players drop off the network during active metadata queries.
49+
50+
Invalidation is now deferred: the ProDJLink thread queues the player IP in a lock-protected list, and the DbServerClient worker thread processes the queue between TCP queries (at the start of each worker loop iteration, max ~500ms latency). Since connection close and query execution now happen on the same thread, there is no race.
51+
52+
This was a rare bug — it required a CDJ to disconnect at the exact moment of an active TCP query — but when it did happen, it could crash STC. Most users would never have hit it, but it's the kind of bug that manifests during long shows with unstable network hardware.
53+
54+
---
55+
56+
## Bug Fix — Safer shutdown thread ordering
57+
58+
Reordered shutdown sequences (application exit, input source switching, and auto-stop of unused shared inputs) so the packet receiver threads (ProDJLink, StageLinQ) are joined **before** their corresponding database clients are stopped or their callbacks are cleared. Prevents a `std::function` race that could fire when a player disconnect coincided with a shutdown.
59+
60+
---
61+
62+
## Bug Fix — Track Map import preserves existing entries
63+
64+
When importing a rekordbox XML collection, the "Import Tracks" flow no longer overwrites existing Track Map entries. Previously, if a user manually selected a duplicate track in the Import Preview dialog, the import would reset that track's cues, triggers, offsets, and notes. Now existing entries are preserved — the import only adds new tracks. The summary count reflects only the actual additions.
65+
66+
This aligns with the "Apply Playlist Order" behavior introduced in v1.9.2: imports never destroy user configuration.
67+
68+
---
69+
70+
## Files Changed
71+
72+
- `TimecodeEngine.h` — On-air gate state + `isOnAirGateOpen()` helper + hook into `sourceActive`; removed `currentFps = outputFps` overwrite in `startProDJLinkInput` / `startStageLinQInput`
73+
- `AppSettings.h` — On-air gate serialization; playlist path split fix (indexOf instead of addTokens)
74+
- `DbServerClient.h` — Deferred invalidation via `pendingInvalidateIPs` queue
75+
- `MainComponent.h` / `MainComponent.cpp` — On-air gate toggle in output panel; reordered shutdown sequences
76+
- `TrackMapEditor.h` — Sortable Artist/Title/Offset columns; Import Preview preserves existing entries; corrected summary count
77+
- `Main.cpp` — Version bump to 1.9.3
78+
- `README.md` — Version bump

0 commit comments

Comments
 (0)