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
126 changes: 104 additions & 22 deletions src/mp_audio/Convolver.cpp
Original file line number Diff line number Diff line change
@@ -1,38 +1,102 @@
#include "Convolver.h"

#include <juce_audio_formats/juce_audio_formats.h>

namespace mp {

void Convolver::prepare(const juce::dsp::ProcessSpec& spec) {
convolution_.prepare(spec);
fromLeft_.prepare(spec);
fromRight_.prepare(spec);
sampleRate_ = spec.sampleRate;
// The dry path has to be kept whole so the mix is a real crossfade rather
// than "wet plus whatever survived".
// than "wet plus whatever survived". The second buffer carries the right
// input through its own engine for a true-stereo IR.
dry_.setSize(static_cast<int>(spec.numChannels),
static_cast<int>(spec.maximumBlockSize), false, true, true);
right_.setSize(2, static_cast<int>(spec.maximumBlockSize), false, true, true);
prepared_ = true;
}

void Convolver::reset() { convolution_.reset(); }
void Convolver::reset() {
fromLeft_.reset();
fromRight_.reset();
}

juce::File Convolver::fileForRate(const juce::File& irFile, double rate) {
if (rate <= 0.0) return irFile;
const juce::String name = irFile.getFileNameWithoutExtension();
// "<room>-48000Hz": replace the rate, keep everything before it.
const int dash = name.lastIndexOfChar('-');
if (dash < 0 || !name.endsWithIgnoreCase("Hz")) return irFile;
const juce::String stem = name.substring(0, dash);
const auto sibling = irFile.getSiblingFile(
stem + "-" + juce::String(juce::roundToInt(rate)) + "Hz" +
irFile.getFileExtension());
return sibling.existsAsFile() ? sibling : irFile;
}

bool Convolver::loadImpulseResponse(const juce::File& chosen) {
if (!chosen.existsAsFile()) return false;
const juce::File irFile = fileForRate(chosen, sampleRate_);

juce::AudioFormatManager formats;
formats.registerBasicFormats();
std::unique_ptr<juce::AudioFormatReader> reader(formats.createReaderFor(irFile));
if (reader == nullptr || reader->lengthInSamples <= 0) return false;

bool Convolver::loadImpulseResponse(const juce::File& irFile) {
if (!irFile.existsAsFile()) return false;
const int channels = static_cast<int>(reader->numChannels);
const int length = static_cast<int>(reader->lengthInSamples);
juce::AudioBuffer<float> all(channels, length);
reader->read(&all, 0, length, 0, true, true);

convolution_.loadImpulseResponse(
irFile,
// Keep the IR's own sample rate handling to JUCE; resampling a room
// impulse badly is audible as a change of room size.
juce::dsp::Convolution::Stereo::yes,
juce::dsp::Convolution::Trim::yes,
0, // 0 = use the whole file
juce::dsp::Convolution::Normalise::yes);
// One gain for every path, so a true-stereo room keeps its own balance
// between them; normalising each engine separately would not. By energy,
// not by peak: a room's loudness is the whole of its tail, and a two-second
// tail normalised to its peak came out more than 20 dB too hot. The scale
// is the one JUCE's own normalisation uses, so a room sits where it did.
double energy = 0.0;
for (int ch = 0; ch < channels; ++ch) {
const float* p = all.getReadPointer(ch);
double e = 0.0;
for (int i = 0; i < length; ++i) e += static_cast<double>(p[i]) * p[i];
energy = juce::jmax(energy, e);
}
const float gain = energy > 0.0 ? static_cast<float>(0.125 / std::sqrt(energy)) : 1.0f;

auto pair = [&](int a, int b) {
juce::AudioBuffer<float> ir(2, length);
ir.copyFrom(0, 0, all, juce::jmin(a, channels - 1), 0, length);
ir.copyFrom(1, 0, all, juce::jmin(b, channels - 1), 0, length);
ir.applyGain(gain);
return ir;
};

// Four channels is true stereo, in Hauptwerk's order: left input to the
// left and right outputs, then right input to the left and right outputs.
// Two is an ordinary stereo IR; one is mono, used for both sides.
trueStereo_ = channels >= 4;
const double rate = reader->sampleRate;
using C = juce::dsp::Convolution;
if (trueStereo_) {
fromLeft_.loadImpulseResponse(pair(0, 1), rate, C::Stereo::yes, C::Trim::yes,
C::Normalise::no);
fromRight_.loadImpulseResponse(pair(2, 3), rate, C::Stereo::yes, C::Trim::yes,
C::Normalise::no);
} else {
fromLeft_.loadImpulseResponse(pair(0, channels > 1 ? 1 : 0), rate, C::Stereo::yes,
C::Trim::yes, C::Normalise::no);
}

irName_ = irFile.getFileNameWithoutExtension();
irName_ = chosen.getFileNameWithoutExtension();
loaded_ = true;
return true;
}

void Convolver::clear() {
convolution_.reset();
fromLeft_.reset();
fromRight_.reset();
loaded_ = false;
trueStereo_ = false;
irName_ = {};
}

Expand All @@ -44,17 +108,35 @@ void Convolver::process(juce::AudioBuffer<float>& buffer) {
if (numCh <= 0 || numSamples <= 0) return;
if (mix_ <= 0.0f) return;

// Hold the dry signal aside. dry_ was sized at prepare(); a host handing us
// a larger block than it promised falls back to bypass rather than
// allocating on the audio thread.
if (dry_.getNumChannels() < numCh || dry_.getNumSamples() < numSamples)
// Hold the dry signal aside. The buffers were sized at prepare(); a host
// handing us a larger block than it promised falls back to bypass rather
// than allocating on the audio thread.
if (dry_.getNumChannels() < numCh || dry_.getNumSamples() < numSamples ||
right_.getNumSamples() < numSamples)
return;
for (int ch = 0; ch < numCh; ++ch)
dry_.copyFrom(ch, 0, buffer, ch, 0, numSamples);

juce::dsp::AudioBlock<float> block(buffer);
juce::dsp::ProcessContextReplacing<float> ctx(block);
convolution_.process(ctx);
if (trueStereo_ && numCh >= 2) {
// The right input, doubled, through its own engine gives right-to-left
// and right-to-right. The left input, doubled, through the other gives
// left-to-left and left-to-right. Their sum is the room.
right_.copyFrom(0, 0, buffer, 1, 0, numSamples);
right_.copyFrom(1, 0, buffer, 1, 0, numSamples);
buffer.copyFrom(1, 0, buffer, 0, 0, numSamples);

juce::dsp::AudioBlock<float> leftBlock(buffer.getArrayOfWritePointers(), 2,
static_cast<size_t>(numSamples));
fromLeft_.process(juce::dsp::ProcessContextReplacing<float>(leftBlock));
juce::dsp::AudioBlock<float> rightBlock(right_.getArrayOfWritePointers(), 2,
static_cast<size_t>(numSamples));
fromRight_.process(juce::dsp::ProcessContextReplacing<float>(rightBlock));
buffer.addFrom(0, 0, right_, 0, 0, numSamples);
buffer.addFrom(1, 0, right_, 1, 0, numSamples);
} else {
juce::dsp::AudioBlock<float> block(buffer);
fromLeft_.process(juce::dsp::ProcessContextReplacing<float>(block));
}

// Equal-gain crossfade. The wet signal is the same material through a room,
// so it correlates with the dry: equal-power would push the level up.
Expand Down
20 changes: 19 additions & 1 deletion src/mp_audio/Convolver.h
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,26 @@ class Convolver {
// so it is safe to call unconditionally from processBlock.
void process(juce::AudioBuffer<float>& buffer);

// A Hauptwerk impulse-response package ships the same room once per
// sample rate, as "<name>-44100Hz.wav", "-48000Hz.wav" and so on. Given any
// one of them, the sibling recorded at `rate`, or the file itself if there
// is none. Resampling a room is audible as a change of its size.
static juce::File fileForRate(const juce::File& irFile, double rate);

private:
juce::dsp::Convolution convolution_;
// Non-uniform partitioning: a short head at the audio block size keeps the
// reverb at zero latency, and the long tail is done in large partitions.
// Uniform partitioning at an ASIO-sized block (32 or 64 samples) spent
// most of a core on a two-second room, and live that is a crackle --
// reported as "just scratching sound" in #29.
static constexpr int kHeadSize = 256;
// Two engines, for a true-stereo IR: one carries the left input to both
// outputs, the other the right. A two-channel IR uses only the first.
juce::dsp::Convolution fromLeft_{juce::dsp::Convolution::NonUniform{kHeadSize}};
juce::dsp::Convolution fromRight_{juce::dsp::Convolution::NonUniform{kHeadSize}};
bool trueStereo_ = false;
double sampleRate_ = 0.0;
juce::AudioBuffer<float> right_;
juce::AudioBuffer<float> dry_;
bool enabled_ = false;
bool loaded_ = false;
Expand Down
113 changes: 108 additions & 5 deletions src/mp_audio/MasterpieceProcessor.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1185,6 +1185,8 @@ bool MasterpieceProcessor::writeGlobalFile() const {
text << "reopenlast " << (reopenLastOrgan_ ? 1 : 0) << "\n";
text << "loadticks "
<< (loadTicks_.load(std::memory_order_acquire) ? 1 : 0) << "\n";
for (const auto& lib : libraries_)
text << "library " << lib.getFullPathName() << "\n";
if (cacheDir_.getFullPathName().isNotEmpty())
text << "cachedir " << cacheDir_.getFullPathName() << "\n";
if (lastOrgan_.getFullPathName().isNotEmpty())
Expand Down Expand Up @@ -1229,6 +1231,11 @@ bool MasterpieceProcessor::loadGlobalDefaults() {
reopenLastOrgan_ = val.getIntValue() != 0;
} else if (key == "loadticks") {
loadTicks_.store(val.getIntValue() != 0, std::memory_order_release);
} else if (key == "library") {
const juce::File dir(val);
if (val.isNotEmpty() &&
std::find(libraries_.begin(), libraries_.end(), dir) == libraries_.end())
libraries_.push_back(dir);
} else if (key == "cachedir") {
// A path, taken whole: the sample cache can be gigabytes, and a player
// with a small fast disk and a large slow one wants to choose which of
Expand Down Expand Up @@ -1304,6 +1311,38 @@ void MasterpieceProcessor::setReopenLastOrgan(bool on) {
writeGlobalFile();
}

// Does one of the known libraries hold the packages this organ names? The
// matching itself lives in the core, where it can be tested without a
// processor writing to anyone's settings.
juce::File MasterpieceProcessor::libraryHolding(const OrganModel& model) const {
std::vector<std::string> roots;
for (const auto& lib : libraries_) roots.push_back(lib.getFullPathName().toStdString());
const std::string found = mp::findLibraryHolding(roots, model);
return found.empty() ? juce::File() : juce::File(found);
}

// The place a Hauptwerk installation keeps its libraries, so the first load
// after installing Masterpiece already knows where to look. Added only if it
// is really there.
void MasterpieceProcessor::seedSampleLibraries() {
const auto standard =
juce::File::getSpecialLocation(juce::File::userHomeDirectory)
.getChildFile("Hauptwerk")
.getChildFile("HauptwerkSampleSetsAndComponents");
if (standard.getChildFile("OrganInstallationPackages").isDirectory() &&
std::find(libraries_.begin(), libraries_.end(), standard) == libraries_.end())
libraries_.push_back(standard);
}

void MasterpieceProcessor::rememberSampleLibrary(const juce::File& root) {
if (!root.isDirectory()) return;
if (!root.getChildFile("OrganInstallationPackages").isDirectory()) return;
if (std::find(libraries_.begin(), libraries_.end(), root) != libraries_.end())
return;
libraries_.push_back(root);
writeGlobalFile();
}

juce::File MasterpieceProcessor::defaultCacheDirectory() {
return juce::File::getSpecialLocation(juce::File::userApplicationDataDirectory)
.getChildFile("Masterpiece")
Expand Down Expand Up @@ -1642,6 +1681,23 @@ bool MasterpieceProcessor::startPipeLayers(const Pipe& pipe, Id rankId,
static_cast<float>(layer.gainDb), -100.0f) *
layerLevel(layer);

// How hard the key was struck. The organ states the attenuation at the
// softest touch; full velocity is unattenuated. Inverted, the sense
// swaps. Applied here and not per sample: a pipe keeps the level it
// began with until the next strike, which is what an organ does.
//
// The MAGNITUDE is the attenuation: every set stores one constant for
// its whole pipework, and while some write it +5 dB others write -5 or
// -6 (Alessandria +5, Giubiasco -6, Cracow -10). Read as a signed gain
// the negative sets would get LOUDER when played softly, which no
// tracker organ does; the field's own name is MaxAttenuation.
if (layer.velSensMaxAttenDb != 0.0) {
const double v01 = juce::jlimit(0.0, 1.0, static_cast<double>(velocity) / 127.0);
const double attn = layer.invertVelocitySens ? v01 : 1.0 - v01;
vs.gain *= juce::Decibels::decibelsToGain(
static_cast<float>(-std::fabs(layer.velSensMaxAttenDb) * attn), -100.0f);
}

// The player's own voicing, on top of what the organ declares.
// Gain and tuning only: they are a multiply and a ratio at note-on
// and cost nothing per sample, so they apply even with the DSP
Expand Down Expand Up @@ -1677,7 +1733,9 @@ bool MasterpieceProcessor::startPipeLayers(const Pipe& pipe, Id rankId,

// Which tremulant reaches this pipe, and how far it moves it. The
// organ states the depth per pipe, so a flute and a reed on the
// same chest wobble by different amounts.
// same chest wobble by different amounts — and the LAYER trims that
// depth again, which is how one stop on a chest can be left nearly
// steady while its neighbour shakes.
const auto tm = model_.tremulantPipes.find(pipe.pipeId);
if (tm != model_.tremulantPipes.end()) {
const auto ti = tremIndexOf_.find(tm->second.tremulantId);
Expand All @@ -1686,9 +1744,12 @@ bool MasterpieceProcessor::startPipeLayers(const Pipe& pipe, Id rankId,
// Decibels to a linear swing about unity, and percent of a
// semitone to semitones.
vs.tremAmpDepth = static_cast<float>(
juce::Decibels::decibelsToGain(tm->second.ampDepthDb, -60.0) -
juce::Decibels::decibelsToGain(
tm->second.ampDepthDb + layer.tremAmpDepthAdjustDb, -60.0) -
1.0);
vs.tremPitchDepth = tm->second.pitchDepthPct / 100.0;
vs.tremPitchDepth =
tm->second.pitchDepthPct / 100.0 *
juce::jlimit(0.0, 4.0, layer.tremPitchDepthAdjustPct / 100.0);
}
}
}
Expand Down Expand Up @@ -2163,8 +2224,11 @@ void MasterpieceProcessor::buildPalletIndex() {
}
if (palletPipes_.empty()) return; // nothing to open: keys stay plain keys

for (const auto& [switchId, key] : model_.keyboardKeys)
keySwitchIds_.clear();
for (const auto& [switchId, key] : model_.keyboardKeys) {
keySwitchByKey_[static_cast<int>(key.keyboardId) * 256 + key.midiNote] = switchId;
keySwitchIds_.insert(switchId);
}
palletNotes_.reserve(palletPipes_.size());
heldKeySwitches_.reserve(256);
}
Expand Down Expand Up @@ -2213,10 +2277,20 @@ void MasterpieceProcessor::triggerNoiseFor(Id switchId, bool engaged) {
if (!engaged && rank.pipes.size() > 1) index = 1;
const Pipe& pipe = rank.pipes[index];

// A key-action noise is the sound of the strike, so it takes the strike's
// velocity; a stop or blower noise is a mechanical event at a medium
// touch. The sets state a velocity response for the former and this is
// the only place their figures can act — the noise is not played by a
// key, so startPipeLayers never sees it.
const int noiseVelocity =
keySwitchIds_.count(switchId) != 0
? juce::jlimit(1, 127, palletVelocity_)
: 100;

const uint64_t noteId = nextNoteId_++;
for (const auto& layer : pipe.layers) {
NoteStrike strike;
strike.velocity = 100;
strike.velocity = noiseVelocity;
const int attackIndex = selectAttack(layer, strike);
if (attackIndex < 0) continue;

Expand All @@ -2232,6 +2306,14 @@ void MasterpieceProcessor::triggerNoiseFor(Id switchId, bool engaged) {
vs.gain = juce::Decibels::decibelsToGain(
static_cast<float>(layer.gainDb), -100.0f) *
layerLevel(layer);
// The organ's velocity response reaches noises too, and on every set
// that declares one it is the NOISE layers that carry it.
if (layer.velSensMaxAttenDb != 0.0) {
const double v01 = static_cast<double>(noiseVelocity) / 127.0;
const double attn = layer.invertVelocitySens ? v01 : 1.0 - v01;
vs.gain *= juce::Decibels::decibelsToGain(
static_cast<float>(-std::fabs(layer.velSensMaxAttenDb) * attn), -100.0f);
}
// A noise is a one-shot; looping it would leave the console rattling.
vs.oneShot = true;
vs.busIndex = busForPipe(pipe.pipeId);
Expand Down Expand Up @@ -2474,6 +2556,24 @@ MasterpieceProcessor::LoadResult MasterpieceProcessor::loadOrgan(

// Publish the model before the audio, so a note-on during loading resolves
// pipes that simply have no sound yet rather than reading a half-built map.
// The definition parsed, so its package ids are known. If the root worked
// out from the path does not hold them -- both standard folders linked to
// unrelated drives is the reported case, and no path can bridge that -- ask
// the libraries this machine knows about.
if (!organRootOverride_.isDirectory()) {
const juce::File derived(opts.organRootDir);
const auto packages = derived.getChildFile("OrganInstallationPackages");
if (!packages.isDirectory()) {
seedSampleLibraries();
const juce::File lib = libraryHolding(loaded);
if (lib.isDirectory()) {
opts.organRootDir = lib.getFullPathName().toStdString();
juce::Logger::writeToLog("load: packages found in a known library: " +
lib.getFullPathName());
}
}
}

model_ = std::move(loaded);
organRootDir_ = opts.organRootDir;
loadedOdf_ = odfFile;
Expand Down Expand Up @@ -2872,6 +2972,9 @@ MasterpieceProcessor::LoadResult MasterpieceProcessor::loadOrgan(
// Only now, having got this far: an organ that failed to load is not one
// worth reopening on the next start.
setLastOrgan(odfFile);
// And the library it came from, so a definition moved away from its audio
// later can still be matched to it.
if (!graphicsOnly) rememberSampleLibrary(juce::File(organRootDir_));

result.stopsEngaged = 0;
// Only now: starting the organ above moves switches on this thread, and
Expand Down
Loading
Loading