Skip to content

Commit 72bfea4

Browse files
authored
Update V1.9.9
1 parent 86a3c1d commit 72bfea4

5 files changed

Lines changed: 141 additions & 8 deletions

File tree

GeneratorAudioPlayer.h

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -351,6 +351,11 @@ class GeneratorAudioPlayer : private juce::AudioIODeviceCallback
351351

352352
/// Seek to position in seconds, relative to the start of the audio file.
353353
/// When looping, positions beyond file length are folded back via fmod.
354+
/// If the player was supposed to be playing (shouldPlay set, not paused)
355+
/// but the transport had auto-stopped (typically because it reached EOF
356+
/// before this seek), the transport is re-engaged so audio resumes from
357+
/// the new position. start() is a no-op when the transport is already
358+
/// playing, so the in-flight seek-while-playing case is unaffected.
354359
void seekSeconds(double seconds)
355360
{
356361
if (! hasFileLoaded()) return;
@@ -368,6 +373,13 @@ class GeneratorAudioPlayer : private juce::AudioIODeviceCallback
368373
}
369374

370375
transport.setPosition(seconds);
376+
377+
if (shouldPlay.load(std::memory_order_acquire)
378+
&& ! userPaused.load(std::memory_order_acquire)
379+
&& deviceOpen.load(std::memory_order_relaxed))
380+
{
381+
transport.start();
382+
}
371383
}
372384

373385
double getCurrentPositionSeconds() const { return transport.getCurrentPosition(); }

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.8"; }
14+
const juce::String getApplicationVersion() override { return "1.9.9"; }
1515
bool moreThanOneInstanceAllowed() override { return false; }
1616

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

README.md

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -390,7 +390,7 @@ The sections below are for developers who want to build STC from source.
390390
3. **Create a `CMakeLists.txt`** in the project root:
391391
```cmake
392392
cmake_minimum_required(VERSION 3.22)
393-
project(SuperTimecodeConverter VERSION 1.9.8)
393+
project(SuperTimecodeConverter VERSION 1.9.9)
394394
395395
set(CMAKE_CXX_STANDARD 17)
396396
set(CMAKE_CXX_STANDARD_REQUIRED ON)
@@ -400,7 +400,7 @@ The sections below are for developers who want to build STC from source.
400400
juce_add_gui_app(SuperTimecodeConverter
401401
PRODUCT_NAME "Super Timecode Converter"
402402
COMPANY_NAME "Fiverecords"
403-
VERSION "1.9.8"
403+
VERSION "1.9.9"
404404
HARDENED_RUNTIME_ENABLED TRUE
405405
HARDENED_RUNTIME_OPTIONS com.apple.security.device.audio-input
406406
MICROPHONE_PERMISSION_ENABLED TRUE
@@ -572,6 +572,19 @@ This affects any software using these ports, including the official PRO DJ LINK
572572

573573
On macOS, the DJM-900NXS2 / DJM-A9 / DJM-V10 may occasionally fail to deliver mixer fader data on the first connection after the DJM is powered on. This is a timing issue in the subscribe handshake. Workaround: restart STC or toggle the Pro DJ Link interface off and on. A delayed-subscribe fix is planned for a future release.
574574

575+
### StageLinQ: Playhead Does Not Advance
576+
577+
The StageLinQ playhead is driven by the device's BeatInfo stream. If STC connects to a Denon player but the playhead stays at zero (BPM and track name may still appear, but position never moves), the BeatInfo stream is not arriving from the device. This has nothing to do with the StateMap subscription -- those two channels are independent on the player side.
578+
579+
Field-reported conditions that silence BeatInfo on SC5000 / SC6000:
580+
581+
- **Deck has no track loaded, or is paused / not playing.** BeatInfo only streams while a track is actively playing.
582+
- **The physical LINK button on the player is on.** Linking decks via the player itself disables BeatInfo. Turn LINK off on every player on the network.
583+
- **Decks are linked from Engine DJ.** Same effect as the physical LINK button. Unlink the decks from Engine DJ, or close Engine DJ entirely if it is not needed.
584+
- **Player was already running when STC started.** Some firmware revisions only emit BeatInfo if a consumer was already listening at the time the player booted. Power-cycling the player after STC is running is enough.
585+
586+
Walk through the four conditions in that order before assuming a network or firmware issue. If the playhead still does not move after all four are satisfied, a Wireshark capture of STC connecting to the player would help diagnose; please open an issue and attach the `.pcapng`.
587+
575588
### rekordbox Cannot Run Simultaneously with STC
576589

577590
STC and rekordbox use the same UDP ports for Pro DJ Link communication (50000, 50001, 50002). Running both applications at the same time on the same machine causes port conflicts: CDJ discovery fails, status packets are lost, and neither application works correctly. This is the same limitation that affects the official PRO DJ LINK Bridge.

StageLinQInput.h

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,13 @@ namespace StageLinQ
5454
// StateMap sub-types (bytes 8-11 inside smaa block)
5555
static constexpr uint32_t kSmaaStateEmit = 0x00000000; // device -> us: state value
5656
static constexpr uint32_t kSmaaEmitResponse = 0x000007D1; // device -> us: subscription ack
57-
static constexpr uint32_t kSmaaSubscribe = 0x000007D2; // us -> device: subscribe request
57+
// 0x07D2 is used in both directions:
58+
// - us -> device: subscribe request, with min interval (ms) appended after the path
59+
// - device -> us: periodic catalogue announcement of the paths the device offers
60+
// (no JSON value, just path + 4-byte interval). Some firmware revisions
61+
// re-broadcast their catalogue on this opcode every few seconds; we
62+
// silently consume them since we already know what we asked for.
63+
static constexpr uint32_t kSmaaSubscribe = 0x000007D2; // bidirectional, see above
5864

5965
// TCP message IDs (first 4 bytes of TCP messages)
6066
static constexpr uint32_t kMsgServiceAnnounce = 0x00000000;
@@ -439,6 +445,25 @@ namespace StageLinQ
439445
//==========================================================================
440446
// Build a BeatInfo start-stream frame
441447
//==========================================================================
448+
//
449+
// Handshake is exactly 8 bytes: length=4 (BE), magic=0x00000000 (BE).
450+
// Adding any payload (e.g. our token) is a known way to make the device
451+
// silently refuse to stream -- the connection stays open but no packets
452+
// ever arrive. Independent third-party Python implementations against
453+
// SC5000 have reproduced this; keep the frame minimal.
454+
//
455+
// BeatInfo silence conditions (field-reported, not all firmware-confirmed):
456+
// - The deck has no track loaded, or is not in playback.
457+
// - Some firmware will only emit if the consumer was already listening
458+
// when the device booted; power-cycling the device after STC has
459+
// started restores the stream.
460+
// - The physical LINK button on the SC5000 is on. Linking decks at
461+
// the device disables BeatInfo on the device side.
462+
// - Decks are linked from Engine DJ (same effect as the physical LINK
463+
// button).
464+
//
465+
// If a user reports "StageLinQ playhead never moves", these are the four
466+
// things to walk through before assuming a bug in this code path.
442467
inline std::vector<uint8_t> buildBeatInfoStart()
443468
{
444469
std::vector<uint8_t> frame;
@@ -2552,9 +2577,12 @@ class StageLinQInput : public juce::Thread
25522577
}
25532578
}
25542579
}
2555-
// kSmaaEmitResponse (0x7D1) -- subscription ack, ignore
2580+
// kSmaaEmitResponse (0x7D1) -- subscription ack, ignore.
2581+
// kSmaaSubscribe (0x7D2) from device direction -- periodic
2582+
// catalogue announce (path + interval, no JSON), ignore.
25562583
#if JUCE_DEBUG
2557-
else if (subType != StageLinQ::kSmaaEmitResponse)
2584+
else if (subType != StageLinQ::kSmaaEmitResponse
2585+
&& subType != StageLinQ::kSmaaSubscribe)
25582586
{
25592587
DBG("StageLinQ: Unknown smaa subtype 0x"
25602588
+ juce::String::toHexString((int)subType)

TimecodeEngine.h

Lines changed: 82 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,10 @@ class TimecodeEngine
137137
userOverrodeLtcFps = false;
138138
activeInput = source;
139139
sourceActive = false;
140+
// Switching the input source is an explicit user transition; any
141+
// pending natural-EOF marker from the Generator path is no longer
142+
// relevant and must not survive the round-trip.
143+
genEndedAtEof = false;
140144

141145
// Releasing the Generator audio output device when the engine
142146
// switches to a non-Generator input is important for shows where
@@ -1857,6 +1861,10 @@ class TimecodeEngine
18571861
{
18581862
if (genState == GeneratorState::Playing) return;
18591863

1864+
// Explicit user transition; any pending natural-EOF marker is no
1865+
// longer relevant to the next click on the timeline.
1866+
genEndedAtEof = false;
1867+
18601868
const bool wasStopped = (genState == GeneratorState::Stopped);
18611869
if (wasStopped)
18621870
{
@@ -1898,6 +1906,9 @@ class TimecodeEngine
18981906
void generatorStop()
18991907
{
19001908
genState = GeneratorState::Stopped;
1909+
// Explicit user transition; clear the natural-EOF marker so a
1910+
// subsequent click on the timeline does NOT auto-resume.
1911+
genEndedAtEof = false;
19011912
genCurrentMs = genStartMs;
19021913
// Reset the crossing-based cue cursor too -- the next play will
19031914
// start from genStartMs and we want the first tick's (prev, now]
@@ -1984,6 +1995,10 @@ class TimecodeEngine
19841995
/// Stopped -> Paused (preserves the seeked position; if we stayed in
19851996
/// Stopped, the next generatorPlay() would reset
19861997
/// genCurrentMs to genStartMs and discard the seek)
1998+
/// Stopped + genEndedAtEof -> Playing (the player just finished a
1999+
/// track naturally; clicking the timeline is
2000+
/// interpreted as "play from here", consistent
2001+
/// with consumer media players)
19872002
///
19882003
/// The caller is responsible for any clamping (e.g. against Stop TC or
19892004
/// the audio file's length); this method only enforces newMs >= 0.
@@ -2000,14 +2015,36 @@ class TimecodeEngine
20002015
// on the lower bound.
20012016
lastCueCheckMs = (uint32_t) juce::jmax(0.0, genCurrentMs);
20022017

2018+
// Decide the post-seek state. Stopped + genEndedAtEof means we
2019+
// just finished the track on its own; treat the click as a
2020+
// "resume from here" instead of a cue. Any other Stopped becomes
2021+
// Paused (preserves previous behaviour for cold scrub / post-Stop
2022+
// scrub). Playing / Paused are not modified.
2023+
const bool resumeFromEof = (genState == GeneratorState::Stopped
2024+
&& genEndedAtEof);
20032025
if (genState == GeneratorState::Stopped)
2004-
genState = GeneratorState::Paused;
2026+
genState = resumeFromEof ? GeneratorState::Playing
2027+
: GeneratorState::Paused;
2028+
2029+
// Consume the flag once acted on so the same click never
2030+
// resumes twice. Cleared regardless of whether we used it
2031+
// (a click while Playing/Paused also implies the user has
2032+
// moved on from the natural-end state).
2033+
genEndedAtEof = false;
20052034

20062035
if (activeInput == InputSource::SystemTime)
20072036
currentTimecode = wallClockToTimecode(genCurrentMs, currentFps);
20082037

20092038
const double audioPosSec = juce::jmax(0.0, (genCurrentMs - genStartMs) / 1000.0);
20102039
generatorAudioPlayer.seekSeconds(audioPosSec);
2040+
2041+
// For the EOF-resume case, the audio player still has shouldPlay
2042+
// cleared (stopAndReset was called when the EOF auto-stop fired),
2043+
// so seekSeconds alone will not have restarted the transport.
2044+
// Arm the play intent now; play() also calls transport.start()
2045+
// at the just-seeked position.
2046+
if (resumeFromEof)
2047+
generatorAudioPlayer.play();
20112048
}
20122049

20132050
double getGeneratorStartMs() const { return genStartMs; }
@@ -2057,10 +2094,18 @@ class TimecodeEngine
20572094
void setGeneratorAudioFile(const juce::File& file, bool shouldLoop)
20582095
{
20592096
const juce::File f = (file == juce::File() || ! file.existsAsFile()) ? juce::File() : file;
2097+
// New file (or unload) invalidates the natural-EOF marker -- a
2098+
// click on the timeline of a freshly loaded track should cue,
2099+
// not auto-resume from the previous track's EOF intent.
2100+
genEndedAtEof = false;
20602101
generatorAudioPlayer.requestLoad(f, shouldLoop);
20612102
}
20622103

2063-
void clearGeneratorAudioFile() { generatorAudioPlayer.requestLoad(juce::File(), false); }
2104+
void clearGeneratorAudioFile()
2105+
{
2106+
genEndedAtEof = false;
2107+
generatorAudioPlayer.requestLoad(juce::File(), false);
2108+
}
20642109

20652110
bool hasGeneratorAudioFile() const { return generatorAudioPlayer.hasFileLoaded(); }
20662111
juce::File getGeneratorAudioFile() const { return generatorAudioPlayer.getCurrentFile(); }
@@ -2203,6 +2248,14 @@ class TimecodeEngine
22032248
double genStopMs = 0.0; // stop TC in ms (0 = freerun)
22042249
double genCurrentMs = 0.0; // current position in ms
22052250
double genLastTickTime = 0.0; // hiRes ms for delta calculation
2251+
// Set when the generator stops automatically because the loaded audio
2252+
// file reached EOF (as opposed to a user-initiated stop). Consumed by
2253+
// setGeneratorPosition() so that a click on the timeline immediately
2254+
// after the track ends both seeks and resumes audio in one action,
2255+
// matching the muscle memory of consumer media players. Cleared by
2256+
// any explicit user transition (Play / Stop / file change / input
2257+
// source change), so it never survives a context switch.
2258+
bool genEndedAtEof = false;
22062259

22072260
// Generator audio playback (per-engine, optional)
22082261
GeneratorAudioPlayer generatorAudioPlayer;
@@ -3262,6 +3315,33 @@ class TimecodeEngine
32623315
genState = GeneratorState::Stopped;
32633316
generatorAudioPlayer.stopAndReset();
32643317
}
3318+
// Auto-stop when the loaded audio file has played to its end.
3319+
// Without this, the SMPTE would keep advancing past the file's
3320+
// last sample (the audio transport silently auto-stops at EOF
3321+
// but nothing was relaying that back to the generator state).
3322+
// Looping files fold around inside seekSeconds() so they do
3323+
// not reach this branch; a user-defined stop TC takes
3324+
// precedence (handled by the prior branch).
3325+
//
3326+
// genEndedAtEof is set so that a subsequent click on the
3327+
// waveform timeline (setGeneratorPosition) treats the seek
3328+
// as a "resume from new position" gesture instead of just
3329+
// cueing. See setGeneratorPosition for the consumer side.
3330+
else
3331+
{
3332+
const double fileLenSec = generatorAudioPlayer.getFileLengthSeconds();
3333+
if (fileLenSec > 0.0 && ! generatorAudioPlayer.isLooping())
3334+
{
3335+
const double endMs = genStartMs + fileLenSec * 1000.0;
3336+
if (genCurrentMs >= endMs)
3337+
{
3338+
genCurrentMs = endMs;
3339+
genState = GeneratorState::Stopped;
3340+
genEndedAtEof = true;
3341+
generatorAudioPlayer.stopAndReset();
3342+
}
3343+
}
3344+
}
32653345

32663346
// Check armed cue points against the current generated TC.
32673347
// Uses the crossing-based variant: a cue fires whenever the

0 commit comments

Comments
 (0)