GitHub Viewer
// Super Timecode Converter
// Copyright (c) 2026 Fiverecords -- MIT License
// https://github.com/fiverecords/SuperTimecodeConverter
#pragma once
#include
#include
// Several protocol handlers use std::atomic for cross-thread timing.
// Verify the platform provides lock-free atomics for double so we don't
// inadvertently introduce mutex contention on the audio or timer threads.
// This requires a 64-bit platform (x86_64, ARM64, etc.).
static_assert(std::atomic::is_always_lock_free,
"This project requires a 64-bit platform for lock-free atomic");
enum class FrameRate
{
FPS_2398 = 0, // 23.976 (24000/1001) -- cinema/digital workflows
FPS_24 = 1,
FPS_25 = 2,
FPS_2997 = 3,
FPS_30 = 4
};
// std::atomic is used in several protocol handlers for cross-thread
// frame rate updates. Verify it is lock-free (guaranteed on 64-bit platforms
// for any enum backed by a 4-byte int, but worth asserting explicitly).
static_assert(std::atomic::is_always_lock_free,
"This project requires lock-free atomic");
struct Timecode
{
int hours = 0; // 0-23
int minutes = 0; // 0-59
int seconds = 0; // 0-59
int frames = 0; // 0-29 depending on fps
juce::String toString() const
{
return juce::String::formatted("%02d:%02d:%02d.%02d", hours, minutes, seconds, frames);
}
// SMPTE-standard display: uses ';' as frame separator for drop-frame,
// ':' for non-drop-frame (broadcast convention per SMPTE ST 12-1)
// Clamps values to valid SMPTE ranges to prevent garbled display from
// corrupt or uninitialised data.
juce::String toDisplayString(FrameRate /*fps*/) const
{
int h = juce::jlimit(0, 23, hours);
int m = juce::jlimit(0, 59, minutes);
int s = juce::jlimit(0, 59, seconds);
int f = juce::jlimit(0, 29, frames);
// Use '.' as the frame separator for all frame rates.
// This visually distinguishes the frame count from the HH:MM:SS time
// fields (which always use ':').
// Note: SMPTE ST 12-1 recommends ';' for drop-frame, but this app
// intentionally uses '.' for visual clarity in all modes.
return juce::String::formatted("%02d:%02d:%02d.%02d", h, m, s, f);
}
};
inline double frameRateToDouble(FrameRate fps)
{
switch (fps)
{
case FrameRate::FPS_2398: return 24000.0 / 1001.0; // exact 23.976023976... (not truncated 23.976)
case FrameRate::FPS_24: return 24.0;
case FrameRate::FPS_25: return 25.0;
case FrameRate::FPS_2997: return 30000.0 / 1001.0; // exact 29.970029970... (consistent with DF math)
case FrameRate::FPS_30: return 30.0;
default: return 30.0;
}
}
inline int frameRateToInt(FrameRate fps)
{
switch (fps)
{
case FrameRate::FPS_2398: return 24;
case FrameRate::FPS_24: return 24;
case FrameRate::FPS_25: return 25;
case FrameRate::FPS_2997: return 30;
case FrameRate::FPS_30: return 30;
default: return 30;
}
}
inline juce::String frameRateToString(FrameRate fps)
{
switch (fps)
{
case FrameRate::FPS_2398: return "23.976";
case FrameRate::FPS_24: return "24";
case FrameRate::FPS_25: return "25";
case FrameRate::FPS_2997: return "29.97";
case FrameRate::FPS_30: return "30";
default: return "30";
}
}
//==============================================================================
// Increment a timecode by one frame, wrapping at 24h.
// For 29.97 drop-frame: skips frames 0 and 1 at the start of each
// minute that is NOT a multiple of 10 (SMPTE 12M standard).
// NOTE: 23.976fps (FPS_2398) is always non-drop-frame. There is no
// SMPTE-standard drop-frame variant for 23.976; the ~0.1% drift vs.
// wall-clock is accepted in cinema/digital workflows.
//==============================================================================
inline Timecode incrementFrame(const Timecode& tc, FrameRate fps)
{
int maxFrames = frameRateToInt(fps);
// Clamp input -- during FPS conversion frames may be out of range
// for the target rate (e.g. frame 29 from 30fps -> 25fps output).
Timecode r = tc;
if (r.frames < 0) r.frames = 0;
if (r.frames >= maxFrames) r.frames = maxFrames - 1;
if (r.seconds < 0 || r.seconds >= 60) r.seconds = 0;
if (r.minutes < 0 || r.minutes >= 60) r.minutes = 0;
if (r.hours < 0 || r.hours >= 24) r.hours = 0;
r.frames++;
if (r.frames >= maxFrames) { r.frames = 0; r.seconds++; }
if (r.seconds >= 60) { r.seconds = 0; r.minutes++; }
if (r.minutes >= 60) { r.minutes = 0; r.hours++; }
if (r.hours >= 24) { r.hours = 0; }
// Drop-frame: skip frames 0 and 1 at the start of each minute
// except every 10th minute (00, 10, 20, 30, 40, 50)
if (fps == FrameRate::FPS_2997
&& r.frames == 0
&& r.seconds == 0
&& (r.minutes % 10) != 0)
{
r.frames = 2;
}
return r;
}
//==============================================================================
// Source activity timeout: if no data arrives within this window,
// the source is considered paused. MTC at 24fps sends QF every ~10ms,
// Art-Net at 30fps sends a packet every ~33ms, LTC frames arrive every
// ~33-42ms. 150ms covers several missed frames with margin.
//==============================================================================
inline constexpr double kSourceTimeoutMs = 150.0;
//==============================================================================
// Atomic-safe pack/unpack -- fits H:M:S:F into a single uint64_t
//==============================================================================
inline uint64_t packTimecode(int h, int m, int s, int f)
{
return ((uint64_t)(h & 0xFF) > 16) & 0xFF);
tc.seconds = (int)((packed >> 8) & 0xFF);
tc.frames = (int)( packed & 0xFF);
return tc;
}
//==============================================================================
// Apply a frame offset (+/-) to a Timecode, wrapping at 24h.
// Note: this uses a linear frame-count model (maxFrames per second) rather
// than true SMPTE DF counting. The DF correction at the end patches any
// landing on skipped frame numbers 0-1. This is exact for small offsets
// (the +/-30 frame range used by output offsets) because DF skips only occur
// at minute boundaries, which are always >30 frames apart.
//==============================================================================
inline Timecode offsetTimecode(const Timecode& tc, int offsetFrames, FrameRate fps)
{
if (offsetFrames == 0) return tc;
// The linear-frame arithmetic below is exact only for small offsets.
// Drop-frame timecode has non-uniform frame distribution, so converting
// linear->DF->linear for large offsets accumulates error. The UI sliders
// are constrained to +/-30 frames; assert here to catch any future misuse.
jassert(std::abs(offsetFrames) should be frame 1)
int64_t totalFrames = (int64_t)(msSinceMidnight / 1000.0 * exactFps + 1e-9);
// SMPTE drop-frame algorithm:
// In DF counting, every minute (except every 10th) drops 2 frame numbers.
// D = frames per 10-minute block = 17982 (10*60*30 - 9*2)
// d = frames per 1-minute block = 1798 (60*30 - 2)
const int64_t framesPerTenMin = 17982;
const int64_t framesPerMin = 1798;
int64_t tenMinBlocks = totalFrames / framesPerTenMin;
int64_t remainder = totalFrames % framesPerTenMin;
// First minute of each 10-min block is NOT dropped (has 1800 frames)
int64_t minutesSinceBlock;
if (remainder < 1800)
minutesSinceBlock = 0;
else
minutesSinceBlock = 1 + (remainder - 1800) / framesPerMin;
// Convert back to a frame number in 30fps space
int64_t frameNumber = totalFrames + 18 * tenMinBlocks + 2 * minutesSinceBlock;
Timecode tc;
tc.frames = (int)(frameNumber % 30);
tc.seconds = (int)((frameNumber / 30) % 60);
tc.minutes = (int)((frameNumber / 1800) % 60);
tc.hours = (int)((frameNumber / 108000) % 24);
return tc;
}
else
{
// Non-drop-frame: split into integer seconds + fractional frame.
// This correctly handles 23.976fps where 24 frames span slightly
// more than 1 wall-clock second (1001/1000 s). Using a total
// frame count with % maxFrames would drift vs second boundaries.
//
// Precision note: double has ~15 significant digits. At 24h
// (86400s), the fractional part retains ~10 digits of precision --
// far more than needed for sub-frame accuracy at any supported rate.
double fpsVal = frameRateToDouble(fps);
int maxFrames = frameRateToInt(fps);
double secondsTotal = msSinceMidnight / 1000.0;
Timecode tc;
int64_t totalSeconds = (int64_t)secondsTotal;
double fractional = secondsTotal - (double)totalSeconds;
tc.hours = (int)((totalSeconds / 3600) % 24);
tc.minutes = (int)((totalSeconds / 60) % 60);
tc.seconds = (int)(totalSeconds % 60);
// Guard against floating-point truncation at frame boundaries:
// e.g. at 30fps, 33.333ms -> fractional*30 = 0.99999... truncates to 0
// instead of 1. An epsilon of 1e-9 (~1ns) fixes boundary rounding
// without risk of pushing legitimate values past the next frame.
tc.frames = (int)(fractional * fpsVal + 1e-9) % maxFrames;
return tc;
}
}
//==============================================================================
// Convert a Timecode back to milliseconds since midnight.
// Inverse of wallClockToTimecode(). For 29.97 drop-frame, converts
// the DF frame numbering back to a linear frame count before computing
// real elapsed time using the exact 30000/1001 rate.
//==============================================================================
inline double timecodeToMs(const Timecode& tc, FrameRate fps)
{
if (fps == FrameRate::FPS_2997)
{
// Drop-frame: frame numbers 0 and 1 are skipped at the start
// of each minute except every 10th minute. To recover the true
// linear frame count, subtract the total dropped frame numbers.
int totalMinutes = tc.hours * 60 + tc.minutes;
int tenMinBlocks = totalMinutes / 10;
// Frame number in 30fps space (as written in the TC display)
int64_t frameNumber = (int64_t)tc.hours * 108000 // 30 * 3600
+ (int64_t)tc.minutes * 1800 // 30 * 60
+ (int64_t)tc.seconds * 30
+ (int64_t)tc.frames;
// Total dropped frame numbers up to this point:
// 2 per minute, except every 10th minute (which has no drops)
int64_t droppedFrames = 2 * (totalMinutes - tenMinBlocks);
int64_t actualFrames = frameNumber - droppedFrames;
double exactFps = 30000.0 / 1001.0;
return (double)actualFrames / exactFps * 1000.0;
}
else
{
double fpsVal = frameRateToDouble(fps);
return (tc.hours * 3600.0 + tc.minutes * 60.0 + tc.seconds) * 1000.0
+ ((double)tc.frames / fpsVal) * 1000.0;
}
}
//==============================================================================
// Apply a large timecode offset (for TrackMap -- no +/-30 frame limit).
// Adds offset HH:MM:SS:FF to the input timecode, wrapping at 24h.
// Uses milliseconds as intermediate representation for exact drop-frame
// arithmetic (same proven approach as convertTimecodeRate).
//
// tcFps -- frame rate of the input timecode and of the result
// offsetFps -- frame rate used to interpret the offset fields
// (may differ if the offset was authored at a different rate)
//==============================================================================
inline Timecode applyTimecodeOffset(const Timecode& tc, FrameRate tcFps,
int offH, int offM, int offS, int offF,
FrameRate offsetFps)
{
double tcMs = timecodeToMs(tc, tcFps);
Timecode offTc { offH, offM, offS, offF };
double offMs = timecodeToMs(offTc, offsetFps);
double totalMs = tcMs + offMs;
// Wrap at 24 hours
constexpr double kMsPerDay = 24.0 * 3600.0 * 1000.0;
totalMs = std::fmod(totalMs, kMsPerDay);
if (totalMs < 0.0) totalMs += kMsPerDay;
return wallClockToTimecode(totalMs, tcFps);
}
//==============================================================================
// Convert a Timecode from one frame rate to another.
// Uses milliseconds as the intermediate representation so the same
// point in real time maps correctly between any pair of rates,
// including drop-frame non-drop-frame conversions.
//==============================================================================
inline Timecode convertTimecodeRate(const Timecode& tc, FrameRate fromFps, FrameRate toFps)
{
if (fromFps == toFps) return tc;
double ms = timecodeToMs(tc, fromFps);
return wallClockToTimecode(ms, toFps);
}
//==============================================================================
// SMPTE rate code (shared by MTC and Art-Net)
// 0 = 24fps, 1 = 25fps, 2 = 29.97df, 3 = 30fps
//==============================================================================
inline int fpsToRateCode(FrameRate fps)
{
switch (fps)
{
case FrameRate::FPS_2398: return 0; // Transmitted as 24fps rate code (no dedicated SMPTE code)
case FrameRate::FPS_24: return 0;
case FrameRate::FPS_25: return 1;
case FrameRate::FPS_2997: return 2;
case FrameRate::FPS_30: return 3;
default: return 1;
}
}
//==============================================================================
// Audio device entry with device type information
//==============================================================================
struct AudioDeviceEntry
{
juce::String typeName; // JUCE type name ("Windows Audio", "ASIO", etc.)
juce::String deviceName; // Raw device name
juce::String displayName; // "WASAPI: Device Name" for UI
// Convert JUCE internal type name to short UI-friendly prefix
static juce::String shortenTypeName(const juce::String& name)
{
// Exact matches first
if (name == "Windows Audio") return "WASAPI";
if (name == "ASIO") return "ASIO";
if (name == "DirectSound") return "DirectSound";
if (name == "CoreAudio") return "";
// WASAPI variants -- JUCE may use different parenthetical suffixes
// e.g. "Windows Audio (Exclusive Mode)", "Windows Audio (Exclusive)",
// "Windows Audio (Low Latency)"
if (name.startsWith("Windows Audio"))
{
if (name.contains("("))
{
auto paren = name.fromFirstOccurrenceOf("(", false, false)
.upToFirstOccurrenceOf(")", false, false).trim();
if (paren.containsIgnoreCase("Exclusive")) return "WAS.Excl";
if (paren.containsIgnoreCase("Low")) return "WAS.LowLat";
return "WAS." + paren;
}
return "WASAPI";
}
// Unknown type -- use full name
return name;
}
static juce::String makeDisplayName(const juce::String& typeName, const juce::String& deviceName)
{
auto prefix = shortenTypeName(typeName);
return prefix.isEmpty() ? deviceName : (prefix + ": " + deviceName);
}
};