Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Large diffs are not rendered by default.

16 changes: 12 additions & 4 deletions modules/tracktion_engine/audio_files/tracktion_LoudnessMeter.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,19 @@ namespace loudness_utils
return -0.691 + 10.0 * std::log10 (std::max (1.0e-12, power));
}

/** BS.1770 channel weights: 1.0 for left/right/centre, 1.41 for surrounds.
The LFE should be excluded, but plain buffers carry no layout info.
/** BS.1770 channel weights for a buffer in the usual ITU order
(L, R, C, LFE, Ls, Rs...): 1.0 for the front channels, 1.41 for the
surrounds, and the LFE excluded from the measurement entirely.

Plain buffers carry no layout information, so the LFE has to be found by
position. Only the 5.1 and 7.1 layouts have one, and in both it sits at
index 3; 5.0 and below are taken to have no LFE.
*/
static double channelWeight (int channelIndex)
static double channelWeight (int channelIndex, int totalChannels)
{
if (totalChannels >= 6 && channelIndex == 3)
return 0.0;

return channelIndex >= 3 ? 1.41 : 1.0;
}
}
Expand Down Expand Up @@ -163,7 +171,7 @@ void LoudnessMeter::processChunk (const float* const* channelData, int numChanne
const double sample = channelData[ch][startSample + i];
const double filtered = state.highpass.process (state.shelf.process (sample));

weightedSquares += channelWeight (ch) * filtered * filtered;
weightedSquares += channelWeight (ch, numChannelsToUse) * filtered * filtered;
}

blockEnergy += weightedSquares;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,73 @@ TEST_SUITE ("tracktion_engine")
CHECK (readings.integratedValid);
}

TEST_CASE ("LoudnessMeter channel weighting excludes the LFE")
{
using namespace loudness_meter_tests;

// A 5.1 buffer (L, R, C, LFE, Ls, Rs) with a 997Hz tone on whichever
// channels the case asks for, so the weights can be read off the result
auto measure = [] (const std::vector<int>& tonedChannels, float lfeAmplitude)
{
const auto numSamples = 4 * (int) testSampleRate;
juce::AudioBuffer<float> buffer (6, numSamples);
buffer.clear();

for (auto ch : tonedChannels)
for (int i = 0; i < numSamples; ++i)
buffer.setSample (ch, i, sine (i, 997.0, ch == 3 ? lfeAmplitude : 0.5f));

LoudnessMeter meter;
meter.prepare (testSampleRate, 6, 8192);
processInBlocks (meter, buffer, { 512 });

return meter.getReadings();
};

// Two channels at 1.0 weight each: 3.01dB above the -9.03 LUFS a single
// channel of this tone measures
const auto stereoOnly = measure ({ 0, 1 }, 0.0f);
REQUIRE (stereoOnly.integratedValid);
CHECK_EQ (stereoOnly.integratedLufs, doctest::Approx (-6.02).epsilon (0.01));

// Adding a full-scale LFE mustn't move the loudness at all
const auto withLFE = measure ({ 0, 1, 3 }, 1.0f);
CHECK_EQ (withLFE.integratedLufs, doctest::Approx (stereoOnly.integratedLufs).epsilon (0.0001));
CHECK_EQ (withLFE.shortTermLufs, doctest::Approx (stereoOnly.shortTermLufs).epsilon (0.0001));
CHECK_EQ (withLFE.momentaryLufs, doctest::Approx (stereoOnly.momentaryLufs).epsilon (0.0001));

// ...but the channel is genuinely being fed in: peak is measured across
// every channel, so a full-scale LFE still shows up there
CHECK_EQ (withLFE.samplePeakDb, doctest::Approx (0.0).epsilon (0.01));
CHECK_GT (withLFE.samplePeakDb, stereoOnly.samplePeakDb + 3.0f);

// A surround at the same level as the front channels adds its 1.41
// weight, which keeps this from passing if every weight became zero
const auto withSurround = measure ({ 0, 1, 4 }, 0.0f);
CHECK_EQ (withSurround.integratedLufs, doctest::Approx (-3.70).epsilon (0.01));
CHECK_GT (withSurround.integratedLufs, stereoOnly.integratedLufs + 2.0f);

// 5.0 has no LFE, so index 3 is a surround there and does count
const auto fiveOh = []
{
const auto numSamples = 4 * (int) testSampleRate;
juce::AudioBuffer<float> buffer (5, numSamples);
buffer.clear();

for (auto ch : { 0, 1, 3 })
for (int i = 0; i < numSamples; ++i)
buffer.setSample (ch, i, sine (i, 997.0, 0.5f));

LoudnessMeter meter;
meter.prepare (testSampleRate, 5, 8192);
processInBlocks (meter, buffer, { 512 });

return meter.getReadings();
}();

CHECK_EQ (fiveOh.integratedLufs, doctest::Approx (-3.70).epsilon (0.01));
}

TEST_CASE ("LoudnessMeter is invariant to the block sizes it's fed")
{
using namespace loudness_meter_tests;
Expand Down
20 changes: 2 additions & 18 deletions modules/tracktion_engine/model/export/tracktion_RenderOptions.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -269,22 +269,6 @@ void RenderOptions::valueTreePropertyChanged (juce::ValueTree& v, const juce::Id
}

//==============================================================================
static juce::StringPairArray getMetadata (Edit& edit)
{
juce::StringPairArray metadataList;
auto metadata = edit.getEditMetadata();

if (metadata.album.isNotEmpty()) metadataList.set ("id3album", metadata.album);
if (metadata.artist.isNotEmpty()) metadataList.set ("id3artist", metadata.artist);
if (metadata.comment.isNotEmpty()) metadataList.set ("id3comment", metadata.comment);
if (metadata.date.isNotEmpty()) metadataList.set ("id3date", metadata.date);
if (metadata.genre.isNotEmpty()) metadataList.set ("id3genre", metadata.genre);
if (metadata.title.isNotEmpty()) metadataList.set ("id3title", metadata.title);
if (metadata.trackNumber.isNotEmpty()) metadataList.set ("id3trackNumber", metadata.trackNumber);

return metadataList;
}

ChannelConfiguration RenderOptions::getChannelConfiguration() const
{
auto s = channelConfigStr.get().trim();
Expand Down Expand Up @@ -404,7 +388,7 @@ Renderer::Parameters RenderOptions::getRenderParameters (Edit& edit, SelectionMa
params.tracksToDo.setRange (0, allTracks.size(), true);

if (addMetadata)
params.metadata = getMetadata (edit);
params.metadata = createTagMetadata (edit);

if (addAcidMetadata)
params.metadata.addArray (createAcidInfo (edit, params.time));
Expand Down Expand Up @@ -804,7 +788,7 @@ std::unique_ptr<RenderOptions> RenderOptions::forGeneralExporter (Edit& edit)
for (auto t : getAllTracks (edit))
ro->tracks.add (t->itemID);

ro->addMetadata = getMetadata (edit).size() != 0;
ro->addMetadata = hasTagMetadata (edit);
ro->updateDefaultFilename (&edit);
ro->updateHash();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,9 @@ void RenderQueue::startNextJob()
destFile.existsAsFile() && job.planned.params.edit != nullptr)
AudioFile (job.planned.params.edit->engine, destFile).deleteFile();

// N.B. the delete above has already happened by the time this runs, so a
// callback that cancels the job leaves any pre-existing file at destFile
// gone with nothing rendered to replace it
if (onJobStarted != nullptr)
onJobStarted (job);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,118 @@ juce::String toString (RenderFormat f)
return std::string (magic_enum::enum_name (f));
}

//==============================================================================
juce::StringPairArray createTagMetadata (Edit& edit)
{
juce::StringPairArray metadataList;
auto metadata = edit.getEditMetadata();

if (metadata.album.isNotEmpty()) metadataList.set ("id3album", metadata.album);
if (metadata.artist.isNotEmpty()) metadataList.set ("id3artist", metadata.artist);
if (metadata.comment.isNotEmpty()) metadataList.set ("id3comment", metadata.comment);
if (metadata.date.isNotEmpty()) metadataList.set ("id3date", metadata.date);
if (metadata.genre.isNotEmpty()) metadataList.set ("id3genre", metadata.genre);
if (metadata.title.isNotEmpty()) metadataList.set ("id3title", metadata.title);
if (metadata.trackNumber.isNotEmpty()) metadataList.set ("id3trackNumber", metadata.trackNumber);

return metadataList;
}

bool hasTagMetadata (Edit& edit)
{
// Not createTagMetadata().size(): getEditMetadata() fills the date in with
// the current year even for an Edit that has never been given a tag, so
// that would answer yes for every Edit. What counts is whether any tag has
// actually been stored
auto meta = edit.state.getChildWithName (IDs::ID3VORBISMETADATA);

if (! meta.isValid())
return false;

for (auto& id : { IDs::album, IDs::artist, IDs::comment, IDs::date,
IDs::genre, IDs::title, IDs::trackNumber })
if (meta[id].toString().isNotEmpty())
return true;

return false;
}

bool formatSupportsTagMetadata (RenderFormat f)
{
switch (f)
{
case RenderFormat::wav:
case RenderFormat::ogg:
case RenderFormat::mp3: return true;

case RenderFormat::aiff:
case RenderFormat::flac:
case RenderFormat::midi: break;
}

return false;
}

/** The RIFF INFO key the WAV writer wants for a canonical id3 tag key, or
nullptr if that tag has no INFO equivalent. The WAV writer has no ID3
support at all, so this is the only way a .wav carries tags. ICMT is the
comment field readers expect (JUCE's riffInfoComment is the rarer CMNT) and
ICRD the date one.
*/
static const char* getRiffInfoKeyForTag (const juce::String& id3Key)
{
if (id3Key == "id3album") return juce::WavAudioFormat::riffInfoProductName;
if (id3Key == "id3artist") return juce::WavAudioFormat::riffInfoArtist;
if (id3Key == "id3comment") return juce::WavAudioFormat::riffInfoComment2;
if (id3Key == "id3date") return juce::WavAudioFormat::riffInfoDateCreated;
if (id3Key == "id3genre") return juce::WavAudioFormat::riffInfoGenre;
if (id3Key == "id3title") return juce::WavAudioFormat::riffInfoTitle;
if (id3Key == "id3trackNumber") return juce::WavAudioFormat::riffInfoTrackNumber;

return nullptr;
}

juce::StringPairArray translateMetadataForFormat (const juce::StringPairArray& metadata, RenderFormat format)
{
juce::StringPairArray result;
auto& keys = metadata.getAllKeys();
auto& values = metadata.getAllValues();

for (int i = 0; i < metadata.size(); ++i)
{
const auto& key = keys[i];

// Anything that isn't a tag - ACID, BWAV - belongs to the format's own
// chunks and passes straight through
if (! key.startsWith ("id3"))
{
result.set (key, values[i]);
continue;
}

switch (format)
{
case RenderFormat::ogg:
case RenderFormat::mp3:
result.set (key, values[i]);
break;

case RenderFormat::wav:
if (auto riffKey = getRiffInfoKeyForTag (key))
result.set (riffKey, values[i]);

break;

case RenderFormat::aiff:
case RenderFormat::flac:
case RenderFormat::midi:
break;
}
}

return result;
}

namespace render_spec_utils
{
inline constexpr double maxWrapRemainderTailSeconds = 30.0;
Expand Down Expand Up @@ -102,6 +214,7 @@ juce::var RenderSpecification::toJSON() const
obj->setProperty ("tracks", EditItemID::listToString (tracks));
obj->setProperty ("mutedTracks", EditItemID::listToString (mutedTracks));
obj->setProperty ("includeSourceTracks", includeSourceTracks);
obj->setProperty ("clips", EditItemID::listToString (clips));

if (time)
{
Expand Down Expand Up @@ -143,7 +256,7 @@ juce::var RenderSpecification::toJSON() const

RenderSpecification RenderSpecification::fromJSON (const juce::var& v, juce::StringArray* unknownKeys)
{
static const juce::StringArray knownKeys { "tracks", "mutedTracks", "includeSourceTracks", "startTime", "endTime",
static const juce::StringArray knownKeys { "tracks", "mutedTracks", "includeSourceTracks", "clips", "startTime", "endTime",
"wrapRemainder", "destination", "format", "sampleRate",
"bitDepth", "quality", "channelLayout", "normalise",
"normaliseByRMS", "normaliseByLUFS", "normaliseToLevelDb",
Expand All @@ -170,6 +283,7 @@ RenderSpecification RenderSpecification::fromJSON (const juce::var& v, juce::Str
spec.tracks = EditItemID::parseStringList (get ("tracks", juce::String()));
spec.mutedTracks = EditItemID::parseStringList (get ("mutedTracks", juce::String()));
spec.includeSourceTracks = get ("includeSourceTracks", spec.includeSourceTracks);
spec.clips = EditItemID::parseStringList (get ("clips", juce::String()));
spec.wrapRemainder = get ("wrapRemainder", spec.wrapRemainder);
spec.destination = juce::File (get ("destination", juce::String()).toString());
spec.format = renderFormatFromString (get ("format", toString (spec.format)).toString())
Expand Down Expand Up @@ -225,8 +339,39 @@ juce::Result validateRenderSpecification (Edit& edit, const RenderSpecification&

if (! isMidiFormat (spec.format))
{
if (spec.bitDepth != 16 && spec.bitDepth != 24 && spec.bitDepth != 32)
return juce::Result::fail (TRANS("Invalid bit depth"));
// Ask the format what it can do rather than hard coding it here, so this keeps
// up as formats gain support. Without it an unsupported combination gets as far
// as createWriterFor() returning nullptr, which surfaces as "Couldn't write to
// target file" - a filesystem complaint about a settings problem - or, worse,
// is silently ignored: MP3 tops out at 48kHz and simply encodes at that rate.
auto* audioFormat = getFormat (edit.engine, spec.format);

auto listOf = [] (const juce::Array<int>& values)
{
juce::StringArray strings;

for (auto v : values)
strings.add (juce::String (v));

return strings.joinIntoString (", ");
};

if (auto rates = audioFormat->getPossibleSampleRates();
! rates.isEmpty() && ! rates.contains (juce::roundToInt (spec.sampleRate)))
return juce::Result::fail (TRANS("XZZX doesn't support a sample rate of YZZY")
.replace ("XZZX", toString (spec.format))
.replace ("YZZY", juce::String (spec.sampleRate, 0))
+ ". " + TRANS("Supported rates: XZZX").replace ("XZZX", listOf (rates)));

// A format offering a single depth fixes its own and ignores whatever it is
// handed - Ogg reports 32 and MP3 16, but both encode the same file whatever
// is asked for - so there is nothing to validate in that case
if (auto depths = audioFormat->getPossibleBitDepths();
depths.size() > 1 && ! depths.contains (spec.bitDepth))
return juce::Result::fail (TRANS("XZZX doesn't support YZZY bit")
.replace ("XZZX", toString (spec.format))
.replace ("YZZY", juce::String (spec.bitDepth))
+ ". " + TRANS("Supported bit depths: XZZX").replace ("XZZX", listOf (depths)));

if (! isKnownChannelLayout (spec.channelLayout))
return juce::Result::fail (TRANS("Unknown channel layout: ") + spec.channelLayout);
Expand All @@ -236,6 +381,10 @@ juce::Result validateRenderSpecification (Edit& edit, const RenderSpecification&
|| resolveTracks (edit, spec.mutedTracks).size() != spec.mutedTracks.size())
return juce::Result::fail (TRANS("The specification contains tracks which aren't in this Edit"));

for (auto id : spec.clips)
if (findClipForID (edit, id) == nullptr)
return juce::Result::fail (TRANS("The specification contains clips which aren't in this Edit"));

if (spec.time ? spec.time->isEmpty() : (edit.getLength() == TimeDuration()))
return juce::Result::fail (TRANS("There is nothing to render in the time range"));

Expand Down Expand Up @@ -354,7 +503,7 @@ std::optional<PlannedRenderJob> createRenderJob (Edit& edit, const RenderSpecifi
params.truePeakCeilingDb = spec.truePeakCeilingDb;
params.trimSilenceAtEnds = spec.trimSilence && ! spec.wrapRemainder;
params.ditheringEnabled = spec.dither;
params.metadata = spec.metadata;
params.metadata = translateMetadataForFormat (spec.metadata, spec.format);

if (spec.channelLayout == "mono") params.mustRenderInMono = true;
else if (spec.channelLayout == "stereo") params.channelConfig = ChannelConfiguration::stereo();
Expand All @@ -373,6 +522,10 @@ std::optional<PlannedRenderJob> createRenderJob (Edit& edit, const RenderSpecifi
for (auto [track, index] : resolved)
params.tracksToDo.setBit (index);

for (auto id : spec.clips)
if (auto clip = findClipForID (edit, id))
params.allowedClips.add (clip);

auto mutedTracks = spec.mutedTracks;

if (spec.includeSourceTracks && ! spec.tracks.isEmpty())
Expand Down
Loading