diff --git a/README.md b/README.md index ed360253..0a5be8e5 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,8 @@ App icon by Valery Davletbaev, distributed under [CC-BY](https://creativecommons Built-in [metronome sounds](https://stash.reaper.fm/40824/Metronomes.zip) are recorded by Ludwig Peter Müller, [CC0](https://creativecommons.org/publicdomain/zero/1.0/). +The built-in SoundFont player instument is based on [SFZero](https://github.com/stevefolta/SFZero), written by Steve Folta and [extended](https://github.com/cognitone/SFZeroMT) by Leo Olivers and Cognitone. + All documentation, translations and logotypes are distributed under [CC-BY](https://creativecommons.org/licenses/by/4.0/). ### Translation and proofreading credits diff --git a/Source/Core/Audio/BuiltIn/SoundFont/README.md b/Source/Core/Audio/BuiltIn/SoundFont/README.md new file mode 100644 index 00000000..52044453 --- /dev/null +++ b/Source/Core/Audio/BuiltIn/SoundFont/README.md @@ -0,0 +1,10 @@ +The SoundFont synth implementation in this folder is largely based on SFZero by Steve Folta, extended by Leo Olivers and Cognitone, all distributed under MIT license: + +Original code copyright (C) 2012 Steve Folta +https://github.com/stevefolta/SFZero + +Converted to Juce module (C) 2016 Leo Olivers +https://github.com/altalogix/SFZero + +Extended for multi-timbral operation (C) 2017 Cognitone +https://github.com/cognitone/SFZeroMT diff --git a/Source/Core/Audio/BuiltIn/SoundFont/SoundFont2Sound.cpp b/Source/Core/Audio/BuiltIn/SoundFont/SoundFont2Sound.cpp new file mode 100644 index 00000000..0ed07bec --- /dev/null +++ b/Source/Core/Audio/BuiltIn/SoundFont/SoundFont2Sound.cpp @@ -0,0 +1,1156 @@ +/* + This file is part of Helio Workstation. + + Helio is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Helio is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Helio. If not, see . + + This SoundFont implementation is based on SFZero, + written by Steve Folta and extended by Leo Olivers and Cognitone, + distributed under MIT license, see README.md for details. +*/ + +#include "Common.h" +#include "SoundFont2Sound.h" +#include "SoundFontSample.h" +#include "SoundFontRegion.h" + +#include +#include + +typedef char sf2fourcc[4]; +typedef unsigned char sf2byte; +typedef unsigned long sf2dword; +typedef unsigned short sf2word; +typedef char sf2string[20]; + +#define FourCCEquals(value1, value2) \ + (value1[0] == value2[0] && \ + value1[1] == value2[1] && \ + value1[2] == value2[2] && \ + value1[3] == value2[3]) + +struct RIFFChunk final +{ + enum class Type + { + RIFF, + LIST, + Custom + }; + + bool isTypeOf(sf2fourcc chunkName) const + { + return this->id[0] == chunkName[0] && + this->id[1] == chunkName[1] && + this->id[2] == chunkName[2] && + this->id[3] == chunkName[3]; + }; + + sf2fourcc id; + sf2dword size; + Type type; + int64 start; + + void readFrom(InputStream &file) + { + file.read(&this->id, sizeof(sf2fourcc)); + this->size = static_cast(file.readInt()); + this->start = file.getPosition(); + + if (FourCCEquals(id, "RIFF")) + { + this->type = Type::RIFF; + file.read(&this->id, sizeof(sf2fourcc)); + this->start += sizeof(sf2fourcc); + this->size -= sizeof(sf2fourcc); + } + else if (FourCCEquals(id, "LIST")) + { + this->type = Type::LIST; + file.read(&this->id, sizeof(sf2fourcc)); + this->start += sizeof(sf2fourcc); + this->size -= sizeof(sf2fourcc); + } + else + { + type = Type::Custom; + } + } + + void seek(InputStream &file) + { + file.setPosition(this->start); + } + + void seekAfter(InputStream &file) + { + int64 next = this->start + this->size; + + if (next % 2 != 0) + { + next += 1; + } + + file.setPosition(next); + } + + int64 end() + { + return (this->start + this->size); + } + + String readString(InputStream &file) + { + return file.readEntireStreamAsString(); + } + + JUCE_LEAK_DETECTOR(RIFFChunk) +}; + +namespace SF2 +{ +struct rangesType final +{ + sf2byte lo, hi; +}; + +union genAmountType +{ + rangesType range; + short shortAmount; + sf2word wordAmount; +}; + +struct iver final +{ + sf2word major; + sf2word minor; + + void readFrom(InputStream &file); + + JUCE_LEAK_DETECTOR(iver) +}; + +struct phdr final +{ + sf2string presetName; + sf2word preset; + sf2word bank; + sf2word presetBagNdx; + sf2dword library; + sf2dword genre; + sf2dword morphology; + + void readFrom(InputStream &file); + + static const int sizeInFile = 38; + + JUCE_LEAK_DETECTOR(phdr) +}; + +struct pbag final +{ + sf2word genNdx; + sf2word modNdx; + + void readFrom(InputStream &file); + + static const int sizeInFile = 4; + + JUCE_LEAK_DETECTOR(pbag) +}; + +struct pmod final +{ + sf2word modSrcOper; + sf2word modDestOper; + short modAmount; + sf2word modAmtSrcOper; + sf2word modTransOper; + + void readFrom(InputStream &file); + + static const int sizeInFile = 10; + + JUCE_LEAK_DETECTOR(pmod) +}; + +struct pgen final +{ + sf2word genOper; + genAmountType genAmount; + + void readFrom(InputStream &file); + + static const int sizeInFile = 4; + + JUCE_LEAK_DETECTOR(pgen) +}; + +struct inst final +{ + sf2string instName; + sf2word instBagNdx; + void readFrom(InputStream &file); + + static const int sizeInFile = 22; + + JUCE_LEAK_DETECTOR(inst) +}; + +struct ibag final +{ + sf2word instGenNdx; + sf2word instModNdx; + + void readFrom(InputStream &file); + + static const int sizeInFile = 4; + + JUCE_LEAK_DETECTOR(ibag) +}; + +struct imod final +{ + sf2word modSrcOper; + sf2word modDestOper; + short modAmount; + sf2word modAmtSrcOper; + sf2word modTransOper; + + void readFrom(InputStream &file); + + static const int sizeInFile = 10; + + JUCE_LEAK_DETECTOR(imod) +}; + +struct igen final +{ + sf2word genOper; + genAmountType genAmount; + void readFrom(InputStream &file); + + static const int sizeInFile = 4; + + JUCE_LEAK_DETECTOR(igen) +}; + +struct shdr final +{ + sf2string sampleName; + sf2dword start; + sf2dword end; + sf2dword startLoop; + sf2dword endLoop; + sf2dword sampleRate; + sf2byte originalPitch; + char pitchCorrection; + sf2word sampleLink; + sf2word sampleType; + + void readFrom(InputStream &file); + + static const int sizeInFile = 46; + + JUCE_LEAK_DETECTOR(shdr) +}; + +struct Hydra final +{ + std::vector presetHeaderList; + std::vector pbagItems; + std::vector pmodItems; + std::vector pgenItems; + std::vector instItems; + std::vector ibagItems; + std::vector imodItems; + std::vector igenItems; + std::vector shdrItems; + + template + void readChunkItems(const RIFFChunk &chunk, + std::vector &chunkItems, + InputStream &file) + { + int numItems = (int)chunk.size / T::sizeInFile; + for (int i = 0; i < numItems; ++i) + { + T t; + t.readFrom(file); + chunkItems.push_back(t); + } + } + + void readFrom(InputStream &file, int64 pdtaChunkEnd); + bool isComplete(); + + JUCE_LEAK_DETECTOR(Hydra) +}; +} // namespace SF2 + +void SF2::iver::readFrom(InputStream &file) +{ + this->major = (sf2word)file.readShort(); + this->minor = (sf2word)file.readShort(); +} + +void SF2::phdr::readFrom(InputStream &file) +{ + file.read(this->presetName, 20); + this->preset = (sf2word)file.readShort(); + this->bank = (sf2word)file.readShort(); + this->presetBagNdx = (sf2word)file.readShort(); + this->library = (sf2dword)file.readInt(); + this->genre = (sf2dword)file.readInt(); + this->morphology = (sf2dword)file.readInt(); +} + +void SF2::pbag::readFrom(InputStream &file) +{ + this->genNdx = (sf2word)file.readShort(); + this->modNdx = (sf2word)file.readShort(); +} + +void SF2::pmod::readFrom(InputStream &file) +{ + this->modSrcOper = (sf2word)file.readShort(); + this->modDestOper = (sf2word)file.readShort(); + this->modAmount = file.readShort(); + this->modAmtSrcOper = (sf2word)file.readShort(); + this->modTransOper = (sf2word)file.readShort(); +} + +void SF2::pgen::readFrom(InputStream &file) +{ + this->genOper = (sf2word)file.readShort(); + this->genAmount.shortAmount = file.readShort(); +} + +void SF2::inst::readFrom(InputStream &file) +{ + file.read(this->instName, 20); + this->instBagNdx = (sf2word)file.readShort(); +} + +void SF2::ibag::readFrom(InputStream &file) +{ + this->instGenNdx = (sf2word)file.readShort(); + this->instModNdx = (sf2word)file.readShort(); +} + +void SF2::imod::readFrom(InputStream &file) +{ + this->modSrcOper = (sf2word)file.readShort(); + this->modDestOper = (sf2word)file.readShort(); + this->modAmount = file.readShort(); + this->modAmtSrcOper = (sf2word)file.readShort(); + this->modTransOper = (sf2word)file.readShort(); +} + +void SF2::igen::readFrom(InputStream &file) +{ + this->genOper = (sf2word)file.readShort(); + this->genAmount.shortAmount = file.readShort(); +} + +void SF2::shdr::readFrom(InputStream &file) +{ + file.read(this->sampleName, 20); + this->start = (sf2dword)file.readInt(); + this->end = (sf2dword)file.readInt(); + this->startLoop = (sf2dword)file.readInt(); + this->endLoop = (sf2dword)file.readInt(); + this->sampleRate = (sf2dword)file.readInt(); + this->originalPitch = (sf2byte)file.readByte(); + this->pitchCorrection = file.readByte(); + this->sampleLink = (sf2word)file.readShort(); + this->sampleType = (sf2word)file.readShort(); +} + +void SF2::Hydra::readFrom(InputStream &file, int64 pdtaChunkEnd) +{ + auto check = [](RIFFChunk &chunk, sf2fourcc chunkName) + { + sf2fourcc &chunkID = chunk.id; + return chunkID[0] == chunkName[0] && + chunkID[1] == chunkName[1] && + chunkID[2] == chunkName[2] && + chunkID[3] == chunkName[3]; + }; + + sf2fourcc phdrType = {'p', 'h', 'd', 'r'}; + sf2fourcc pbagType = {'p', 'b', 'a', 'g'}; + sf2fourcc pmodType = {'p', 'm', 'o', 'd'}; + sf2fourcc pgenType = {'p', 'g', 'e', 'n'}; + sf2fourcc instType = {'i', 'n', 's', 't'}; + sf2fourcc ibagType = {'i', 'b', 'a', 'g'}; + sf2fourcc imodType = {'i', 'm', 'o', 'd'}; + sf2fourcc igenType = {'i', 'g', 'e', 'n'}; + sf2fourcc shdrType = {'s', 'h', 'd', 'r'}; + + while (file.getPosition() < pdtaChunkEnd) + { + RIFFChunk chunk; + chunk.readFrom(file); + + if (check(chunk, phdrType)) + { + this->readChunkItems(chunk, this->presetHeaderList, file); + } + else if (check(chunk, pbagType)) + { + this->readChunkItems(chunk, this->pbagItems, file); + } + else if (check(chunk, pmodType)) + { + this->readChunkItems(chunk, this->pmodItems, file); + } + else if (check(chunk, pgenType)) + { + this->readChunkItems(chunk, this->pgenItems, file); + } + else if (check(chunk, instType)) + { + this->readChunkItems(chunk, this->instItems, file); + } + else if (check(chunk, ibagType)) + { + this->readChunkItems(chunk, this->ibagItems, file); + } + else if (check(chunk, imodType)) + { + this->readChunkItems(chunk, this->imodItems, file); + } + else if (check(chunk, igenType)) + { + this->readChunkItems(chunk, this->igenItems, file); + } + else if (check(chunk, shdrType)) + { + this->readChunkItems(chunk, this->shdrItems, file); + } + + chunk.seekAfter(file); + } +} + +bool SF2::Hydra::isComplete() +{ + return !this->presetHeaderList.empty() && + !this->pbagItems.empty() && + !this->pmodItems.empty() && + !this->pgenItems.empty() && + !this->instItems.empty() && + !this->ibagItems.empty() && + !this->imodItems.empty() && + !this->igenItems.empty() && + !this->shdrItems.empty(); +} + +//===----------------------------------------------------------------------===// +// SoundFont2Generator +//===----------------------------------------------------------------------===// + +struct SoundFont2Generator final +{ + enum Type + { + Word, + Short, + Range + }; + + const char *name; + Type type; + + enum + { + startAddrsOffset, + endAddrsOffset, + startloopAddrsOffset, + endloopAddrsOffset, + startAddrsCoarseOffset, + modLfoToPitch, + vibLfoToPitch, + modEnvToPitch, + initialFilterFc, + initialFilterQ, + modLfoToFilterFc, + modEnvToFilterFc, + endAddrsCoarseOffset, + modLfoToVolume, + unused1, + chorusEffectsSend, + reverbEffectsSend, + pan, + unused2, + unused3, + unused4, + delayModLFO, + freqModLFO, + delayVibLFO, + freqVibLFO, + delayModEnv, + attackModEnv, + holdModEnv, + decayModEnv, + sustainModEnv, + releaseModEnv, + keynumToModEnvHold, + keynumToModEnvDecay, + delayVolEnv, + attackVolEnv, + holdVolEnv, + decayVolEnv, + sustainVolEnv, + releaseVolEnv, + keynumToVolEnvHold, + keynumToVolEnvDecay, + instrument, + reserved1, + keyRange, + velRange, + startloopAddrsCoarseOffset, + keynum, + velocity, + initialAttenuation, + reserved2, + endloopAddrsCoarseOffset, + coarseTune, + fineTune, + sampleID, + sampleModes, + reserved3, + scaleTuning, + exclusiveClass, + overridingRootKey, + unused5, + endOper + }; + + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(SoundFont2Generator) +}; + +static const SoundFont2Generator generators[] = { + { "startAddrsOffset", SoundFont2Generator::Short }, + { "endAddrsOffset", SoundFont2Generator::Short }, + { "startloopAddrsOffset", SoundFont2Generator::Short }, + { "endloopAddrsOffset", SoundFont2Generator::Short }, + { "startAddrsCoarseOffset", SoundFont2Generator::Short }, + { "modLfoToPitch", SoundFont2Generator::Short }, + { "vibLfoToPitch", SoundFont2Generator::Short }, + { "modEnvToPitch", SoundFont2Generator::Short }, + { "initialFilterFc", SoundFont2Generator::Short }, + { "initialFilterQ", SoundFont2Generator::Short }, + { "modLfoToFilterFc", SoundFont2Generator::Short }, + { "modEnvToFilterFc", SoundFont2Generator::Short }, + { "endAddrsCoarseOffset", SoundFont2Generator::Short }, + { "modLfoToVolume", SoundFont2Generator::Short }, + { "unused1", SoundFont2Generator::Short }, + { "chorusEffectsSend", SoundFont2Generator::Short }, + { "reverbEffectsSend", SoundFont2Generator::Short }, + { "pan", SoundFont2Generator::Short }, + { "unused2", SoundFont2Generator::Short }, + { "unused3", SoundFont2Generator::Short }, + { "unused4", SoundFont2Generator::Short }, + { "delayModLFO", SoundFont2Generator::Short }, + { "freqModLFO", SoundFont2Generator::Short }, + { "delayVibLFO", SoundFont2Generator::Short }, + { "freqVibLFO", SoundFont2Generator::Short }, + { "delayModEnv", SoundFont2Generator::Short }, + { "attackModEnv", SoundFont2Generator::Short }, + { "holdModEnv", SoundFont2Generator::Short }, + { "decayModEnv", SoundFont2Generator::Short }, + { "sustainModEnv", SoundFont2Generator::Short }, + { "releaseModEnv", SoundFont2Generator::Short }, + { "keynumToModEnvHold", SoundFont2Generator::Short }, + { "keynumToModEnvDecay", SoundFont2Generator::Short }, + { "delayVolEnv", SoundFont2Generator::Short }, + { "attackVolEnv", SoundFont2Generator::Short }, + { "holdVolEnv", SoundFont2Generator::Short }, + { "decayVolEnv", SoundFont2Generator::Short }, + { "sustainVolEnv", SoundFont2Generator::Short }, + { "releaseVolEnv", SoundFont2Generator::Short }, + { "keynumToVolEnvHold", SoundFont2Generator::Short }, + { "keynumToVolEnvDecay", SoundFont2Generator::Short }, + { "instrument", SoundFont2Generator::Word }, + { "reserved1", SoundFont2Generator::Short }, + { "keyRange", SoundFont2Generator::Range }, + { "velRange", SoundFont2Generator::Range }, + { "startloopAddrsCoarseOffset", SoundFont2Generator::Short }, + { "keynum", SoundFont2Generator::Short }, + { "velocity", SoundFont2Generator::Short }, + { "initialAttenuation", SoundFont2Generator::Short }, + { "reserved2", SoundFont2Generator::Short }, + { "endloopAddrsCoarseOffset", SoundFont2Generator::Short }, + { "coarseTune", SoundFont2Generator::Short }, + { "fineTune", SoundFont2Generator::Short }, + { "sampleID", SoundFont2Generator::Word }, + { "sampleModes", SoundFont2Generator::Word }, + { "reserved3", SoundFont2Generator::Short }, + { "scaleTuning", SoundFont2Generator::Short }, + { "exclusiveClass", SoundFont2Generator::Short }, + { "overridingRootKey", SoundFont2Generator::Short }, + { "unused5", SoundFont2Generator::Short }, + { "endOper", SoundFont2Generator::Short } +}; + +const SoundFont2Generator *GeneratorFor(int index) +{ + static const int numGenerators = sizeof(generators) / sizeof(generators[0]); + + if (index >= numGenerators) + { + return nullptr; + } + + return &generators[index]; +} + +//===----------------------------------------------------------------------===// +// SoundFont2Reader +//===----------------------------------------------------------------------===// + +class SoundFont2Reader final +{ +public: + + SoundFont2Reader(SoundFont2Sound &sound, const File &file); + + void read(); + + SharedAudioSampleBuffer::Ptr readSamples(); + +private: + + SoundFont2Sound &sf2Sound; + + UniquePointer fileInputStream; + + void addGeneratorToRegion(sf2word genOper, SF2::genAmountType *amount, SoundFontRegion *region); + + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(SoundFont2Reader) +}; + +SoundFont2Reader::SoundFont2Reader(SoundFont2Sound &soundIn, const File &fileIn) : + sf2Sound(soundIn), + fileInputStream(fileIn.createInputStream()) {} + +void SoundFont2Reader::read() +{ + if (this->fileInputStream == nullptr) + { + this->sf2Sound.addError("Couldn't open file."); + return; + } + + this->fileInputStream->setPosition(0); + RIFFChunk riffChunk; + riffChunk.readFrom(*this->fileInputStream); + + // Read the hydra. + SF2::Hydra hydra; + while (this->fileInputStream->getPosition() < riffChunk.end()) + { + RIFFChunk chunk; + chunk.readFrom(*this->fileInputStream); + if (FourCCEquals(chunk.id, "pdta")) + { + hydra.readFrom(*this->fileInputStream, chunk.end()); + break; + } + chunk.seekAfter(*this->fileInputStream); + } + + if (!hydra.isComplete()) + { + this->sf2Sound.addError("Invalid SF2 file (missing or incomplete hydra)."); + return; + } + + // Read each preset. + for (size_t whichPreset = 0; whichPreset < hydra.presetHeaderList.size() - 1; ++whichPreset) + { + SF2::phdr *presetHeader = &hydra.presetHeaderList[whichPreset]; + auto preset = make(presetHeader->presetName, presetHeader->bank, presetHeader->preset); + + // Zones. + //*** TODO: Handle global zone (modulators only). + int zoneEnd = hydra.presetHeaderList[whichPreset + 1].presetBagNdx; + for (int whichZone = presetHeader->presetBagNdx; whichZone < zoneEnd; ++whichZone) + { + SF2::pbag *presetZone = &hydra.pbagItems[whichZone]; + SoundFontRegion presetRegion; + presetRegion.clearForRelativeSF2(); + + // Generators. + int genEnd = hydra.pbagItems[whichZone + 1].genNdx; + for (int whichGen = presetZone->genNdx; whichGen < genEnd; ++whichGen) + { + SF2::pgen *presetGenerator = &hydra.pgenItems[whichGen]; + + // Instrument. + if (presetGenerator->genOper == SoundFont2Generator::instrument) + { + sf2word whichInst = presetGenerator->genAmount.wordAmount; + if (whichInst < hydra.instItems.size()) + { + SoundFontRegion instRegion; + instRegion.clearForSF2(); + // Preset generators are supposed to be "relative" modifications of + // the instrument settings, but that makes no sense for ranges. + // For those, we'll have the instrument's generator take + // precedence, though that may not be correct. + instRegion.lokey = presetRegion.lokey; + instRegion.hikey = presetRegion.hikey; + instRegion.lovel = presetRegion.lovel; + instRegion.hivel = presetRegion.hivel; + + SF2::inst *inst = &hydra.instItems[whichInst]; + int firstZone = inst->instBagNdx; + int zoneEnd2 = inst[1].instBagNdx; + for (int whichZone2 = firstZone; whichZone2 < zoneEnd2; ++whichZone2) + { + SF2::ibag *ibag = &hydra.ibagItems[whichZone2]; + + // Generators. + SoundFontRegion zoneRegion = instRegion; + bool hadSampleID = false; + int genEnd2 = ibag[1].instGenNdx; + for (int whichGen2 = ibag->instGenNdx; whichGen2 < genEnd2; ++whichGen2) + { + SF2::igen *igen = &hydra.igenItems[whichGen2]; + if (igen->genOper == SoundFont2Generator::sampleID) + { + int whichSample = igen->genAmount.wordAmount; + SF2::shdr *shdr = &hydra.shdrItems[whichSample]; + zoneRegion.addForSF2(&presetRegion); + zoneRegion.sf2ToSFZ(); + zoneRegion.offset += shdr->start; + zoneRegion.end += shdr->end; + zoneRegion.loopStart += shdr->startLoop; + zoneRegion.loopEnd += shdr->endLoop; + if (shdr->endLoop > 0) + { + zoneRegion.loopEnd -= 1; + } + if (zoneRegion.pitchKeyCenter == -1) + { + zoneRegion.pitchKeyCenter = shdr->originalPitch; + } + zoneRegion.tune += shdr->pitchCorrection; + + // Pin initialAttenuation to max +6dB. + if (zoneRegion.volume > 6.0) + { + zoneRegion.volume = 6.0; + this->sf2Sound.addUnsupportedOpcode("extreme gain in initialAttenuation"); + } + + // SoundFontRegion *newRegion = new SoundFontRegion(); + auto newRegion = make(); + *newRegion = zoneRegion; + newRegion->sample = this->sf2Sound.getSampleFor(shdr->sampleRate).get(); + preset->addRegion(move(newRegion)); + hadSampleID = true; + } + else + { + this->addGeneratorToRegion(igen->genOper, &igen->genAmount, &zoneRegion); + } + } + + // Handle instrument's global zone. + if ((whichZone2 == firstZone) && !hadSampleID) + { + instRegion = zoneRegion; + } + + // Modulators. + int modEnd = ibag[1].instModNdx; + int whichMod = ibag->instModNdx; + if (whichMod < modEnd) + { + this->sf2Sound.addUnsupportedOpcode("any modulator"); + } + } + } + else + { + this->sf2Sound.addError("Instrument out of range."); + } + } + // Other generators. + else + { + addGeneratorToRegion(presetGenerator->genOper, &presetGenerator->genAmount, &presetRegion); + } + } + + // Modulators. + int modEnd = presetZone[1].modNdx; + int whichMod = presetZone->modNdx; + if (whichMod < modEnd) + { + this->sf2Sound.addUnsupportedOpcode("any modulator"); + } + } + + this->sf2Sound.addPreset(move(preset)); + } +} + +SharedAudioSampleBuffer::Ptr SoundFont2Reader::readSamples() +{ + static const int bufferSize = 32768; + + if (this->fileInputStream == nullptr) + { + this->sf2Sound.addError("Couldn't open file."); + return nullptr; + } + + // Find the "sdta" chunk. + this->fileInputStream->setPosition(0); + RIFFChunk riffChunk; + riffChunk.readFrom(*this->fileInputStream); + bool found = false; + RIFFChunk chunk; + + while (this->fileInputStream->getPosition() < riffChunk.end()) + { + chunk.readFrom(*this->fileInputStream); + if (FourCCEquals(chunk.id, "sdta")) + { + found = true; + break; + } + chunk.seekAfter(*this->fileInputStream); + } + + int64 sdtaEnd = chunk.end(); + found = false; + while (this->fileInputStream->getPosition() < sdtaEnd) + { + chunk.readFrom(*this->fileInputStream); + if (FourCCEquals(chunk.id, "smpl")) + { + found = true; + break; + } + chunk.seekAfter(*this->fileInputStream); + } + + if (!found) + { + this->sf2Sound.addError("SF2 is missing its \"smpl\" chunk."); + return nullptr; + } + + const int numSamples = (int)chunk.size / sizeof(short); + SharedAudioSampleBuffer::Ptr sampleBuffer(new SharedAudioSampleBuffer(1, numSamples)); + + // Read and convert. + HeapBlock buffer(bufferSize); + int samplesLeft = numSamples; + float *out = sampleBuffer->getWritePointer(0); + while (samplesLeft > 0) + { + // Read <= 32768 bytes at a time from the buffer + int samplesToRead = bufferSize; + if (samplesToRead > samplesLeft) + { + samplesToRead = samplesLeft; + } + + this->fileInputStream->read(buffer.getData(), samplesToRead * sizeof(short)); + + // Convert from signed 16-bit to float. + int samplesToConvert = samplesToRead; + short *in = buffer.getData(); + for (; samplesToConvert > 0; --samplesToConvert) + { + // If we ever need to compile for big-endian platforms, we'll need to + // byte-swap here. + *out++ = *in++ / 32767.0f; + } + + samplesLeft -= samplesToRead; + } + + return sampleBuffer; +} + +void SoundFont2Reader::addGeneratorToRegion(sf2word genOper, SF2::genAmountType *amount, SoundFontRegion *region) +{ + switch (genOper) + { + case SoundFont2Generator::startAddrsOffset: + region->offset += amount->shortAmount; + break; + + case SoundFont2Generator::endAddrsOffset: + region->end += amount->shortAmount; + break; + + case SoundFont2Generator::startloopAddrsOffset: + region->loopStart += amount->shortAmount; + break; + + case SoundFont2Generator::endloopAddrsOffset: + region->loopEnd += amount->shortAmount; + break; + + case SoundFont2Generator::startAddrsCoarseOffset: + region->offset += amount->shortAmount * 32768; + break; + + case SoundFont2Generator::endAddrsCoarseOffset: + region->end += amount->shortAmount * 32768; + break; + + case SoundFont2Generator::pan: + region->pan = amount->shortAmount * (2.0f / 10.0f); + break; + + case SoundFont2Generator::delayVolEnv: + region->ampeg.delay = amount->shortAmount; + break; + + case SoundFont2Generator::attackVolEnv: + region->ampeg.attack = amount->shortAmount; + break; + + case SoundFont2Generator::holdVolEnv: + region->ampeg.hold = amount->shortAmount; + break; + + case SoundFont2Generator::decayVolEnv: + region->ampeg.decay = amount->shortAmount; + break; + + case SoundFont2Generator::sustainVolEnv: + region->ampeg.sustain = amount->shortAmount; + break; + + case SoundFont2Generator::releaseVolEnv: + region->ampeg.release = amount->shortAmount; + break; + + case SoundFont2Generator::keyRange: + region->lokey = amount->range.lo; + region->hikey = amount->range.hi; + break; + + case SoundFont2Generator::velRange: + region->lovel = amount->range.lo; + region->hivel = amount->range.hi; + break; + + case SoundFont2Generator::startloopAddrsCoarseOffset: + region->loopStart += amount->shortAmount * 32768; + break; + + case SoundFont2Generator::initialAttenuation: + // The spec says "initialAttenuation" is in centibels. But everyone + // seems to treat it as millibels. + region->volume += -amount->shortAmount / 100.0f; + break; + + case SoundFont2Generator::endloopAddrsCoarseOffset: + region->loopEnd += amount->shortAmount * 32768; + break; + + case SoundFont2Generator::coarseTune: + region->transpose += amount->shortAmount; + break; + + case SoundFont2Generator::fineTune: + region->tune += amount->shortAmount; + break; + + case SoundFont2Generator::sampleModes: { + SoundFontRegion::LoopMode loopModes[] = { + SoundFontRegion::LoopMode::noLoop, + SoundFontRegion::LoopMode::loopContinuous, + SoundFontRegion::LoopMode::noLoop, + SoundFontRegion::LoopMode::loopSustain}; + + region->loopMode = loopModes[amount->wordAmount & 0x03]; + } + break; + + case SoundFont2Generator::scaleTuning: + region->pitchKeyTrack = amount->shortAmount; + break; + + case SoundFont2Generator::exclusiveClass: + region->offBy = amount->wordAmount; + region->group = static_cast(region->offBy); + break; + + case SoundFont2Generator::overridingRootKey: + region->pitchKeyCenter = amount->shortAmount; + break; + + case SoundFont2Generator::endOper: + // Ignore. + break; + + case SoundFont2Generator::modLfoToPitch: + case SoundFont2Generator::vibLfoToPitch: + case SoundFont2Generator::modEnvToPitch: + case SoundFont2Generator::initialFilterFc: + case SoundFont2Generator::initialFilterQ: + case SoundFont2Generator::modLfoToFilterFc: + case SoundFont2Generator::modEnvToFilterFc: + case SoundFont2Generator::modLfoToVolume: + case SoundFont2Generator::unused1: + case SoundFont2Generator::chorusEffectsSend: + case SoundFont2Generator::reverbEffectsSend: + case SoundFont2Generator::unused2: + case SoundFont2Generator::unused3: + case SoundFont2Generator::unused4: + case SoundFont2Generator::delayModLFO: + case SoundFont2Generator::freqModLFO: + case SoundFont2Generator::delayVibLFO: + case SoundFont2Generator::freqVibLFO: + case SoundFont2Generator::delayModEnv: + case SoundFont2Generator::attackModEnv: + case SoundFont2Generator::holdModEnv: + case SoundFont2Generator::decayModEnv: + case SoundFont2Generator::sustainModEnv: + case SoundFont2Generator::releaseModEnv: + case SoundFont2Generator::keynumToModEnvHold: + case SoundFont2Generator::keynumToModEnvDecay: + case SoundFont2Generator::keynumToVolEnvHold: + case SoundFont2Generator::keynumToVolEnvDecay: + case SoundFont2Generator::instrument: + // Only allowed in certain places, where we already special-case it. + case SoundFont2Generator::reserved1: + case SoundFont2Generator::keynum: + case SoundFont2Generator::velocity: + case SoundFont2Generator::reserved2: + case SoundFont2Generator::sampleID: + // Only allowed in certain places, where we already special-case it. + case SoundFont2Generator::reserved3: + case SoundFont2Generator::unused5: { + const SoundFont2Generator *generator = GeneratorFor(static_cast(genOper)); + this->sf2Sound.addUnsupportedOpcode(generator->name); + } + break; + } +} + +//===----------------------------------------------------------------------===// +// SoundFont2Sound +//===----------------------------------------------------------------------===// + +SoundFont2Sound::SoundFont2Sound(const File &file) : SoundFontSound(file) {} + +SoundFont2Sound::~SoundFont2Sound() = default; + +class PresetComparator final +{ +public: + + static int compareElements(const SoundFont2Sound::Preset *first, const SoundFont2Sound::Preset *second) + { + const auto bankDiff = first->bank - second->bank; + if (bankDiff != 0) + { + return bankDiff; + } + + return first->preset - second->preset; + } +}; + +void SoundFont2Sound::loadRegions() +{ + SoundFont2Reader reader(*this, this->file); + reader.read(); + + PresetComparator comparator; + this->presets.sort(comparator); + + this->setSelectedPreset(0); +} + +void SoundFont2Sound::loadSamples(AudioFormatManager &formatManager) +{ + /* + each SoundFont2Sound is given a File as the source of the actual sample data when they're created + this reader adds any errors encountered while reading to the SoundFont2Sound object + */ + SoundFont2Reader reader(*this, this->file); + const auto buffer = reader.readSamples(); + + if (buffer) + { + // All the SFZSamples will share the buffer. + for (auto &sample : this->samplesByRate) + { + sample.second->setBuffer(buffer); + } + } +} + +void SoundFont2Sound::addPreset(UniquePointer &&preset) +{ + this->presets.add(preset.release()); +} + +int SoundFont2Sound::getNumPresets() const +{ + return this->presets.size(); +} + +String SoundFont2Sound::getPresetName(int whichSubsound) const +{ + Preset *preset = this->presets[whichSubsound]; + String result; + + if (preset->bank != 0) + { + result += preset->bank; + result += "/"; + } + result += preset->preset; + result += ": "; + result += preset->name; + return result; +} + +void SoundFont2Sound::setSelectedPreset(int whichPreset) +{ + this->selectedPreset = whichPreset; + this->regions.clear(); + this->regions.addArray(this->presets[whichPreset]->regions); +} + +int SoundFont2Sound::getSelectedPreset() const +{ + return this->selectedPreset; +} + +WeakReference SoundFont2Sound::getSampleFor(double sampleRate) +{ + if (!this->samplesByRate.contains(int(sampleRate))) + { + this->samplesByRate[int(sampleRate)] = make(sampleRate); + } + + return this->samplesByRate[int(sampleRate)].get(); +} diff --git a/Source/Core/Audio/BuiltIn/SoundFont/SoundFont2Sound.h b/Source/Core/Audio/BuiltIn/SoundFont/SoundFont2Sound.h new file mode 100644 index 00000000..0d9ea64a --- /dev/null +++ b/Source/Core/Audio/BuiltIn/SoundFont/SoundFont2Sound.h @@ -0,0 +1,55 @@ +/* + This file is part of Helio Workstation. + + Helio is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Helio is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Helio. If not, see . + + This SoundFont implementation is based on SFZero, + written by Steve Folta and extended by Leo Olivers and Cognitone, + distributed under MIT license, see README.md for details. +*/ + +#pragma once + +#include "SoundFontSound.h" + +class SoundFont2Sound final : public SoundFontSound +{ +public: + + explicit SoundFont2Sound(const File &file); + ~SoundFont2Sound() override; + + void loadRegions() override; + void loadSamples(AudioFormatManager &formatManager) override; + + int getNumPresets() const override; + String getPresetName(int whichPreset) const override; + void setSelectedPreset(int whichPreset) override; + int getSelectedPreset() const override; + + WeakReference getSampleFor(double sampleRate); + +private: + + friend class SoundFont2Reader; + void addPreset(UniquePointer &&preset); + + OwnedArray presets; + + FlatHashMap> samplesByRate; + + int selectedPreset = 0; + + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(SoundFont2Sound) +}; diff --git a/Source/Core/Audio/BuiltIn/SoundFont/SoundFontRegion.h b/Source/Core/Audio/BuiltIn/SoundFont/SoundFontRegion.h new file mode 100644 index 00000000..d860b1a5 --- /dev/null +++ b/Source/Core/Audio/BuiltIn/SoundFont/SoundFontRegion.h @@ -0,0 +1,212 @@ +/* + This file is part of Helio Workstation. + + Helio is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Helio is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Helio. If not, see . + + This SoundFont implementation is based on SFZero, + written by Steve Folta and extended by Leo Olivers and Cognitone, + distributed under MIT license, see README.md for details. +*/ + +#pragma once + +#include "SoundFontSample.h" + +// SoundFontRegion is designed to be able to be bitwise-copied. + +struct EGParameters final +{ + float delay = 0.f; + float start = 0.f; + float attack = 0.f; + float hold = 0.f; + float decay = 0.f; + float sustain = 0.f; + float release = 0.f; + + void clear() + { + this->delay = this->start = this->attack = this->hold = this->decay = this->release = 0.0; + this->sustain = 100.0; + } + + void clearMod() + { + // Clear for velocity or other modification. + this->delay = this->start = this->attack = this->hold = this->decay = this->sustain = this->release = 0.0; + } +}; + +struct SoundFontRegion final +{ + enum class Trigger + { + attack, + release, + first, + legato + }; + + enum class LoopMode + { + sampleLoop, + noLoop, + oneShot, + loopContinuous, + loopSustain + }; + + enum class OffMode + { + fast, + normal + }; + + SoundFontRegion() + { + this->clear(); + } + + void clear() + { + memset(this, 0, sizeof(*this)); + this->hikey = 127; + this->hivel = 127; + this->pitchKeyCenter = 60; // C4 + this->pitchKeyTrack = 100; + this->bendUp = 200; + this->bendDown = -200; + this->volume = this->pan = 0.0; + this->ampVelTrack = 100.0; + this->ampeg.clear(); + this->ampegVelTrack.clearMod(); + } + + void clearForSF2() + { + this->clear(); + this->pitchKeyCenter = -1; + this->loopMode = LoopMode::noLoop; + + // SF2 defaults in timecents. + this->ampeg.delay = -12000.0; + this->ampeg.attack = -12000.0; + this->ampeg.hold = -12000.0; + this->ampeg.decay = -12000.0; + this->ampeg.sustain = 0.0; + this->ampeg.release = -12000.0; + } + + void clearForRelativeSF2() + { + this->clear(); + this->pitchKeyTrack = 0; + this->ampVelTrack = 0.0; + this->ampeg.sustain = 0.0; + } + + void addForSF2(SoundFontRegion *other) + { + this->offset += other->offset; + this->end += other->end; + this->loopStart += other->loopStart; + this->loopEnd += other->loopEnd; + this->transpose += other->transpose; + this->tune += other->tune; + this->pitchKeyTrack += other->pitchKeyTrack; + this->volume += other->volume; + this->pan += other->pan; + + this->ampeg.delay += other->ampeg.delay; + this->ampeg.attack += other->ampeg.attack; + this->ampeg.hold += other->ampeg.hold; + this->ampeg.decay += other->ampeg.decay; + this->ampeg.sustain += other->ampeg.sustain; + this->ampeg.release += other->ampeg.release; + } + + void sf2ToSFZ() + { + const auto timeCents2Secs = [](int timeCents) + { + return static_cast(pow(2.0, timeCents / 1200.0)); + }; + + // EG times need to be converted from timecents to seconds. + this->ampeg.delay = timeCents2Secs(int(this->ampeg.delay)); + this->ampeg.attack = timeCents2Secs(int(this->ampeg.attack)); + this->ampeg.hold = timeCents2Secs(int(this->ampeg.hold)); + this->ampeg.decay = timeCents2Secs(int(this->ampeg.decay)); + if (this->ampeg.sustain < 0.0f) + { + this->ampeg.sustain = 0.0f; + } + this->ampeg.sustain = 100.0f * Decibels::decibelsToGain(-this->ampeg.sustain / 10.0f); + this->ampeg.release = timeCents2Secs(int(this->ampeg.release)); + + // Pin very short EG segments. Timecents don't get to zero, and our EG is + // happier with zero values. + this->ampeg.delay = jmax(this->ampeg.delay, 0.01f); + this->ampeg.attack = jmax(this->ampeg.attack, 0.01f); + this->ampeg.hold = jmax(this->ampeg.hold, 0.01f); + this->ampeg.decay = jmax(this->ampeg.decay, 0.01f); + this->ampeg.release = jmax(this->ampeg.release, 0.01f); + + // Pin values to their ranges. + this->pan = jlimit(-100.f, 100.f, this->pan); + } + + String dump() + { + String info = String::formatted("%d - %d, vel %d - %d", lokey, hikey, lovel, hivel); + if (sample) + { + info << sample->getShortName(); + } + info << "\n"; + return info; + } + + bool matches(int note, int velocity, Trigger trig) const + { + return (note >= this->lokey && note <= this->hikey && velocity >= this->lovel && velocity <= this->hivel && + (trig == this->trigger || (this->trigger == Trigger::attack && (trig == Trigger::first || trig == Trigger::legato)))); + } + + WeakReference sample; + + int lokey, hikey; + int lovel, hivel; + Trigger trigger; + int group; + int64 offBy; + OffMode offMode; + + int64 offset; + int64 end; + bool negativeEnd; + LoopMode loopMode; + int64 loopStart, loopEnd; + int transpose; + int tune; + int pitchKeyCenter, pitchKeyTrack; + int bendUp, bendDown; + + float volume, pan; + float ampVelTrack; + + EGParameters ampeg, ampegVelTrack; + + JUCE_LEAK_DETECTOR(SoundFontRegion) +}; diff --git a/Source/Core/Audio/BuiltIn/SoundFont/SoundFontSample.h b/Source/Core/Audio/BuiltIn/SoundFont/SoundFontSample.h new file mode 100644 index 00000000..46a952af --- /dev/null +++ b/Source/Core/Audio/BuiltIn/SoundFont/SoundFontSample.h @@ -0,0 +1,111 @@ +/* + This file is part of Helio Workstation. + + Helio is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Helio is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Helio. If not, see . + + This SoundFont implementation is based on SFZero, + written by Steve Folta and extended by Leo Olivers and Cognitone, + distributed under MIT license, see README.md for details. +*/ + +#pragma once + +class SharedAudioSampleBuffer final : public ReferenceCountedObject, public AudioSampleBuffer +{ +public: + + using Ptr = ReferenceCountedObjectPtr; + + explicit SharedAudioSampleBuffer(int numChannels, int numSamples) : + AudioSampleBuffer(numChannels, numSamples) {} + + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(SharedAudioSampleBuffer) +}; + +class SoundFontSample final +{ +public: + + explicit SoundFontSample(const File &fileIn) : + file(fileIn) {} + + explicit SoundFontSample(double sampleRateIn) : + sampleRate(sampleRateIn) {} + + File getFile() const noexcept { return this->file; } + String getShortName() const noexcept { return this->file.getFileName(); } + + const AudioSampleBuffer *getBuffer() const noexcept { return this->buffer.get(); } + void setBuffer(SharedAudioSampleBuffer::Ptr newBuffer) + { + this->buffer = newBuffer; + if (this->buffer != nullptr) + { + this->sampleLength = this->buffer->getNumSamples(); + } + else + { + this->sampleLength = 0; + } + } + + double getSampleRate() const noexcept { return this->sampleRate; } + uint64 getSampleLength() const noexcept { return this->sampleLength; } + uint64 getLoopStart() const noexcept { return this->loopStart; } + uint64 getLoopEnd() const noexcept { return this->loopEnd; } + + bool load(AudioFormatManager &formatManager) + { + UniquePointer reader(formatManager.createReaderFor(this->file)); + if (reader == nullptr) + { + return false; + } + + this->sampleRate = reader->sampleRate; + this->sampleLength = reader->lengthInSamples; + + // Read some extra samples, which will be filled with zeros, so interpolation + // can be done without having to check for the edge all the time. + jassert(this->sampleLength < std::numeric_limits::max()); + + this->buffer = new SharedAudioSampleBuffer(reader->numChannels, static_cast(this->sampleLength + 4)); + reader->read(this->buffer.get(), 0, static_cast(this->sampleLength + 4), 0, true, true); + + const auto *metadata = &reader->metadataValues; + const int numLoops = metadata->getValue("NumSampleLoops", "0").getIntValue(); + if (numLoops > 0) + { + this->loopStart = metadata->getValue("Loop0Start", "0").getLargeIntValue(); + this->loopEnd = metadata->getValue("Loop0End", "0").getLargeIntValue(); + } + + return true; + } + +private: + + File file; + + // all samples share the single buffer: + SharedAudioSampleBuffer::Ptr buffer; + + double sampleRate = 0.0; + uint64 sampleLength = 0; + uint64 loopStart = 0; + uint64 loopEnd = 0; + + JUCE_DECLARE_WEAK_REFERENCEABLE(SoundFontSample) + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(SoundFontSample) +}; diff --git a/Source/Core/Audio/BuiltIn/SoundFont/SoundFontSound.cpp b/Source/Core/Audio/BuiltIn/SoundFont/SoundFontSound.cpp new file mode 100644 index 00000000..c204ced9 --- /dev/null +++ b/Source/Core/Audio/BuiltIn/SoundFont/SoundFontSound.cpp @@ -0,0 +1,821 @@ +/* + This file is part of Helio Workstation. + + Helio is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Helio is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Helio. If not, see . + + This SoundFont implementation is based on SFZero, + written by Steve Folta and extended by Leo Olivers and Cognitone, + distributed under MIT license, see README.md for details. +*/ + +#include "Common.h" +#include "SoundFontSound.h" +#include "SoundFontRegion.h" +#include "SoundFontSample.h" + +class SoundFontReader final +{ +public: + + explicit SoundFontReader(SoundFontSound *sound) : sound(sound) {} + ~SoundFontReader() = default; + + void read(const File &file); + void read(const char *text, unsigned int length); + +private: + + const char *handleLineEnd(const char *p); + const char *readPathInto(String *pathOut, const char *p, const char *end); + int parseKeyValue(const String &str); + void finishRegion(SoundFontRegion ®ionToCopyFrom); + void addError(const String &message); + + static SoundFontRegion::Trigger parseTriggerValue(const String &str); + static SoundFontRegion::LoopMode parseLoopModeValue(const String &str); + + SoundFontSound *sound = nullptr; + int line = 1; + + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(SoundFontReader) +}; + +class StringSlice final +{ +public: + + StringSlice(const char *startIn, const char *endIn) : start(startIn), end(endIn) {} + + unsigned int length() { return static_cast(this->end - this->start); } + bool operator==(const char *other) { return (strncmp(this->start, other, length()) == 0); } + bool operator!=(const char *other) { return (strncmp(this->start, other, length()) != 0); } + const char *getStart() const { return this->start; } + const char *getEnd() const { return this->end; } + +private: + + const char *start = nullptr; + const char *end = nullptr; + + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(StringSlice) +}; + +void SoundFontReader::read(const File &file) +{ + MemoryBlock contents; + + if (!file.loadFileAsData(contents)) + { + this->sound->addError("Couldn't read \"" + file.getFullPathName() + "\""); + return; + } + + this->read(static_cast(contents.getData()), static_cast(contents.getSize())); +} + +void SoundFontReader::read(const char *text, unsigned int length) +{ + const char *p = text; + const char *end = text + length; + char c = 0; + + SoundFontRegion currentGroup; + SoundFontRegion currentRegion; + SoundFontRegion *buildingRegion = nullptr; + bool inControl = false; + String defaultPath; + + while (p < end) + { + // We're at the start of a line; skip any whitespace. + while (p < end) + { + c = *p; + if ((c != ' ') && (c != '\t')) + { + break; + } + p += 1; + } + if (p >= end) + { + break; + } + + // Check if it's a comment line. + if (c == '/') + { + // Skip to end of line. + while (p < end) + { + c = *++p; + if ((c == '\n') || (c == '\r')) + { + break; + } + } + p = this->handleLineEnd(p); + continue; + } + + // Check if it's a blank line. + if ((c == '\r') || (c == '\n')) + { + p = this->handleLineEnd(p); + continue; + } + + // Handle elements on the line. + while (p < end) + { + c = *p; + + // Tag. + if (c == '<') + { + p += 1; + const char *tagStart = p; + while (p < end) + { + c = *p++; + if ((c == '\n') || (c == '\r')) + { + this->addError("Unterminated tag"); + goto fatalError; + } + else if (c == '>') + { + break; + } + } + if (p >= end) + { + this->addError("Unterminated tag"); + goto fatalError; + } + StringSlice tag(tagStart, p - 1); + if (tag == "region") + { + if (buildingRegion && (buildingRegion == ¤tRegion)) + { + this->finishRegion(currentRegion); + } + currentRegion = currentGroup; + buildingRegion = ¤tRegion; + inControl = false; + } + else if (tag == "group") + { + if (buildingRegion && (buildingRegion == ¤tRegion)) + { + this->finishRegion(currentRegion); + } + currentGroup.clear(); + buildingRegion = ¤tGroup; + inControl = false; + } + else if (tag == "control") + { + if (buildingRegion && (buildingRegion == ¤tRegion)) + { + this->finishRegion(currentRegion); + } + currentGroup.clear(); + buildingRegion = nullptr; + inControl = true; + } + else + { + this->addError("Illegal tag"); + } + } + // Comment. + else if (c == '/') + { + // Skip to end of line. + while (p < end) + { + c = *p; + if ((c == '\r') || (c == '\n')) + { + break; + } + p += 1; + } + } + // Parameter. + else + { + // Get the parameter name. + const char *parameterStart = p; + while (p < end) + { + c = *p++; + if ((c == '=') || (c == ' ') || (c == '\t') || (c == '\r') || (c == '\n')) + { + break; + } + } + if ((p >= end) || (c != '=')) + { + this->addError("Malformed parameter"); + goto nextElement; + } + StringSlice opcode(parameterStart, p - 1); + if (inControl) + { + if (opcode == "default_path") + { + p = this->readPathInto(&defaultPath, p, end); + } + else + { + const char *valueStart = p; + while (p < end) + { + c = *p; + if ((c == ' ') || (c == '\t') || (c == '\n') || (c == '\r')) + { + break; + } + p++; + } + String value(valueStart, p - valueStart); + String fauxOpcode = String(opcode.getStart(), opcode.length()) + " (in )"; + this->sound->addUnsupportedOpcode(fauxOpcode); + } + } + else if (opcode == "sample") + { + String path; + p = this->readPathInto(&path, p, end); + if (!path.isEmpty()) + { + if (buildingRegion) + { + buildingRegion->sample = this->sound->addSample(path, defaultPath); + } + else + { + this->addError("Adding sample outside a group or region"); + } + } + else + { + this->addError("Empty sample path"); + } + } + else + { + const char *valueStart = p; + while (p < end) + { + c = *p; + if ((c == ' ') || (c == '\t') || (c == '\n') || (c == '\r')) + { + break; + } + p++; + } + String value(valueStart, p - valueStart); + if (buildingRegion == nullptr) + { + this->addError("Setting a parameter outside a region or group"); + } + else if (opcode == "lokey") + { + buildingRegion->lokey = this->parseKeyValue(value); + } + else if (opcode == "hikey") + { + buildingRegion->hikey = this->parseKeyValue(value); + } + else if (opcode == "key") + { + buildingRegion->hikey = buildingRegion->lokey = buildingRegion->pitchKeyCenter = parseKeyValue(value); + } + else if (opcode == "lovel") + { + buildingRegion->lovel = value.getIntValue(); + } + else if (opcode == "hivel") + { + buildingRegion->hivel = value.getIntValue(); + } + else if (opcode == "trigger") + { + buildingRegion->trigger = this->parseTriggerValue(value); + } + else if (opcode == "group") + { + buildingRegion->group = static_cast(value.getLargeIntValue()); + } + else if (opcode == "off_by") + { + buildingRegion->offBy = value.getLargeIntValue(); + } + else if (opcode == "offset") + { + buildingRegion->offset = value.getLargeIntValue(); + } + else if (opcode == "end") + { + int64 end2 = value.getLargeIntValue(); + if (end2 < 0) + { + buildingRegion->negativeEnd = true; + } + else + { + buildingRegion->end = end2; + } + } + else if (opcode == "loop_mode") + { + bool modeIsSupported = value == "no_loop" || value == "one_shot" || value == "loop_continuous"; + if (modeIsSupported) + { + buildingRegion->loopMode = this->parseLoopModeValue(value); + } + else + { + const auto fauxOpcode = String(opcode.getStart(), opcode.length()) + "=" + value; + this->sound->addUnsupportedOpcode(fauxOpcode); + } + } + else if (opcode == "loop_start") + { + buildingRegion->loopStart = value.getLargeIntValue(); + } + else if (opcode == "loop_end") + { + buildingRegion->loopEnd = value.getLargeIntValue(); + } + else if (opcode == "transpose") + { + buildingRegion->transpose = value.getIntValue(); + } + else if (opcode == "tune") + { + buildingRegion->tune = value.getIntValue(); + } + else if (opcode == "pitch_keycenter") + { + buildingRegion->pitchKeyCenter = parseKeyValue(value); + } + else if (opcode == "pitch_keytrack") + { + buildingRegion->pitchKeyTrack = value.getIntValue(); + } + else if (opcode == "bthis->endup") + { + buildingRegion->bendUp = value.getIntValue(); + } + else if (opcode == "bthis->enddown") + { + buildingRegion->bendDown = value.getIntValue(); + } + else if (opcode == "volume") + { + buildingRegion->volume = value.getFloatValue(); + } + else if (opcode == "pan") + { + buildingRegion->pan = value.getFloatValue(); + } + else if (opcode == "amp_veltrack") + { + buildingRegion->ampVelTrack = value.getFloatValue(); + } + else if (opcode == "ampeg_delay") + { + buildingRegion->ampeg.delay = value.getFloatValue(); + } + else if (opcode == "ampeg_start") + { + buildingRegion->ampeg.start = value.getFloatValue(); + } + else if (opcode == "ampeg_attack") + { + buildingRegion->ampeg.attack = value.getFloatValue(); + } + else if (opcode == "ampeg_hold") + { + buildingRegion->ampeg.hold = value.getFloatValue(); + } + else if (opcode == "ampeg_decay") + { + buildingRegion->ampeg.decay = value.getFloatValue(); + } + else if (opcode == "ampeg_sustain") + { + buildingRegion->ampeg.sustain = value.getFloatValue(); + } + else if (opcode == "ampeg_release") + { + buildingRegion->ampeg.release = value.getFloatValue(); + } + else if (opcode == "ampeg_vel2delay") + { + buildingRegion->ampegVelTrack.delay = value.getFloatValue(); + } + else if (opcode == "ampeg_vel2attack") + { + buildingRegion->ampegVelTrack.attack = value.getFloatValue(); + } + else if (opcode == "ampeg_vel2hold") + { + buildingRegion->ampegVelTrack.hold = value.getFloatValue(); + } + else if (opcode == "ampeg_vel2decay") + { + buildingRegion->ampegVelTrack.decay = value.getFloatValue(); + } + else if (opcode == "ampeg_vel2sustain") + { + buildingRegion->ampegVelTrack.sustain = value.getFloatValue(); + } + else if (opcode == "ampeg_vel2release") + { + buildingRegion->ampegVelTrack.release = value.getFloatValue(); + } + else if (opcode == "default_path") + { + this->addError("\"default_path\" outside of tag"); + } + else + { + this->sound->addUnsupportedOpcode(String(opcode.getStart(), opcode.length())); + } + } + } + + // Skip to next element. + nextElement: + c = 0; + while (p < end) + { + c = *p; + if ((c != ' ') && (c != '\t')) + { + break; + } + p += 1; + } + if ((c == '\r') || (c == '\n')) + { + p = this->handleLineEnd(p); + break; + } + } + } + +fatalError: + + if (buildingRegion && (buildingRegion == ¤tRegion)) + { + this->finishRegion(currentRegion); + } +} + +const char *SoundFontReader::handleLineEnd(const char *p) +{ + // Check for DOS-style line ending. + char lineEndChar = *p++; + + if ((lineEndChar == '\r') && (*p == '\n')) + { + p += 1; + } + + this->line += 1; + return p; +} + +const char *SoundFontReader::readPathInto(String *pathOut, const char *pIn, const char *endIn) +{ + // Paths are kind of funny to parse because they can contain whitespace. + const char *p = pIn; + const char *end = endIn; + const char *pathStart = p; + const char *potentialEnd = nullptr; + + while (p < end) + { + char c = *p; + if (c == ' ') + { + // Is this space part of the path? Or the start of the next opcode? We + // don't know yet. + potentialEnd = p; + p += 1; + // Skip any more spaces. + while (p < end && *p == ' ') + { + p += 1; + } + } + else if ((c == '\n') || (c == '\r') || (c == '\t')) + { + break; + } + else if (c == '=') + { + // We've been looking at an opcode; we need to rewind to + // potentialEnd. + p = potentialEnd; + break; + } + p += 1; + } + if (p > pathStart) + { + // Can't do this: + // String path(CharPointer_UTF8(pathStart), CharPointer_UTF8(p)); + // It won't compile for some unfathomable reason. + CharPointer_UTF8 end2(p); + String path(CharPointer_UTF8(pathStart), end2); + *pathOut = path; + } + else + { + *pathOut = String(); + } + return p; +} + +int SoundFontReader::parseKeyValue(const String &str) +{ + auto chars = str.toRawUTF8(); + + char c = chars[0]; + + if ((c >= '0') && (c <= '9')) + { + return str.getIntValue(); + } + + int note = 0; + static const int notes[] = { + 12 + 0, + 12 + 2, + 3, + 5, + 7, + 8, + 10, + }; + + if ((c >= 'A') && (c <= 'G')) + { + note = notes[c - 'A']; + } + else if ((c >= 'a') && (c <= 'g')) + { + note = notes[c - 'a']; + } + + int octaveStart = 1; + + c = chars[1]; + if ((c == 'b') || (c == '#')) + { + octaveStart += 1; + if (c == 'b') + { + note -= 1; + } + else + { + note += 1; + } + } + + const int octave = str.substring(octaveStart).getIntValue(); + // A3 == 57. + return octave * 12 + note + (57 - 4 * 12); +} + +SoundFontRegion::Trigger SoundFontReader::parseTriggerValue(const String &str) +{ + if (str == "release") + { + return SoundFontRegion::Trigger::release; + } + if (str == "first") + { + return SoundFontRegion::Trigger::first; + } + if (str == "legato") + { + return SoundFontRegion::Trigger::legato; + } + + return SoundFontRegion::Trigger::attack; +} + +SoundFontRegion::LoopMode SoundFontReader::parseLoopModeValue(const String &str) +{ + if (str == "no_loop") + { + return SoundFontRegion::LoopMode::noLoop; + } + if (str == "one_shot") + { + return SoundFontRegion::LoopMode::oneShot; + } + if (str == "loop_continuous") + { + return SoundFontRegion::LoopMode::loopContinuous; + } + if (str == "loop_sustain") + { + return SoundFontRegion::LoopMode::loopSustain; + } + + return SoundFontRegion::LoopMode::sampleLoop; +} + +void SoundFontReader::finishRegion(SoundFontRegion ®ionToCopyFrom) +{ + auto newRegion = make(); + *newRegion = regionToCopyFrom; + this->sound->addRegion(move(newRegion)); +} + +void SoundFontReader::addError(const String &message) +{ + const auto fullMessage = message + " (line " + String(this->line) + ")."; + this->sound->addError(fullMessage); +} + +//===----------------------------------------------------------------------===// +// SoundFontSound +//===----------------------------------------------------------------------===// + +SoundFontSound::SoundFontSound(const File &fileIn) : file(fileIn) +{ + this->preset = make(); +} + +SoundFontSound::~SoundFontSound() = default; + +bool SoundFontSound::appliesToNote(int /*midiNoteNumber*/) +{ + // Just say yes; we can't truly know unless we're told the velocity as well. + return true; +} + +bool SoundFontSound::appliesToChannel(int /*midiChannel*/) { return true; } + +void SoundFontSound::addRegion(UniquePointer &®ion) +{ + this->regions.add(region.get()); + this->preset->addRegion(move(region)); +} + +WeakReference SoundFontSound::addSample(String path, String defaultPath) +{ + path = path.replaceCharacter('\\', '/'); + defaultPath = defaultPath.replaceCharacter('\\', '/'); + + File sampleFile; + if (defaultPath.isEmpty()) + { + sampleFile = this->file.getSiblingFile(path); + } + else + { + const auto defaultDir = this->file.getSiblingFile(defaultPath); + sampleFile = defaultDir.getChildFile(path); + } + + const auto samplePath = sampleFile.getFullPathName(); + if (!this->samples.contains(samplePath)) + { + this->samples[samplePath] = make(sampleFile); + } + + return this->samples[samplePath].get(); +} + +void SoundFontSound::addError(const String &message) { this->errors.add(message); } + +void SoundFontSound::addUnsupportedOpcode(const String &opcode) +{ + if (!this->unsupportedOpcodes.contains(opcode)) + { + this->unsupportedOpcodes[opcode] = opcode; + String warning = "unsupported opcode: "; + warning << opcode; + this->warnings.add(warning); + } +} + +void SoundFontSound::loadRegions() +{ + SoundFontReader reader(this); + reader.read(this->file); +} + +void SoundFontSound::loadSamples(AudioFormatManager &formatManager) +{ + for (auto &it : this->samples) + { + const bool ok = it.second->load(formatManager); + if (!ok) + { + this->addError("Couldn't load sample \"" + it.second->getShortName() + "\""); + } + } +} + +SoundFontRegion *SoundFontSound::getRegionFor(int note, int velocity, SoundFontRegion::Trigger trigger) const +{ + for (auto *region : this->regions) + { + if (region->matches(note, velocity, trigger)) + { + return region; + } + } + + return nullptr; +} + +int SoundFontSound::getNumRegions() const { return this->regions.size(); } + +SoundFontRegion *SoundFontSound::regionAt(int index) { return this->regions[index]; } + +int SoundFontSound::getNumPresets() const { return 1; } + +String SoundFontSound::getPresetName(int) const { return this->preset->name; } + +void SoundFontSound::setSelectedPreset(int) {} + +int SoundFontSound::getSelectedPreset() const { return 0; } + +String SoundFontSound::dump() +{ + String info; + auto &errors = this->getErrors(); + if (errors.size() > 0) + { + info << errors.size() << " errors: \n"; + info << errors.joinIntoString("\n"); + info << "\n"; + } + else + { + info << "no errors.\n\n"; + } + + auto &warnings = this->getWarnings(); + if (warnings.size() > 0) + { + info << warnings.size() << " warnings: \n"; + info << warnings.joinIntoString("\n"); + } + else + { + info << "no warnings.\n"; + } + + if (this->regions.size() > 0) + { + info << this->regions.size() << " regions: \n"; + for (int i = 0; i < this->regions.size(); ++i) + { + info << this->regions[i]->dump(); + } + } + else + { + info << "no regions.\n"; + } + + if (this->samples.size() > 0) + { + info << this->samples.size() << " samples: \n"; + for (const auto &it : this->samples) + { + info << it.first << "\n"; + } + } + else + { + info << "no samples.\n"; + } + return info; +} diff --git a/Source/Core/Audio/BuiltIn/SoundFont/SoundFontSound.h b/Source/Core/Audio/BuiltIn/SoundFont/SoundFontSound.h new file mode 100644 index 00000000..7b460b40 --- /dev/null +++ b/Source/Core/Audio/BuiltIn/SoundFont/SoundFontSound.h @@ -0,0 +1,102 @@ +/* + This file is part of Helio Workstation. + + Helio is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Helio is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Helio. If not, see . + + This SoundFont implementation is based on SFZero, + written by Steve Folta and extended by Leo Olivers and Cognitone, + distributed under MIT license, see README.md for details. +*/ + +#pragma once + +#include "SoundFontRegion.h" + +class SoundFontSample; + +class SoundFontSound : public SynthesiserSound +{ +public: + + explicit SoundFontSound(const File &file); + ~SoundFontSound() override; + + using Ptr = ReferenceCountedObjectPtr; + + bool appliesToNote(int midiNoteNumber) override; + bool appliesToChannel(int midiChannel) override; + + virtual void loadRegions(); + virtual void loadSamples(AudioFormatManager &formatManager); + + SoundFontRegion *getRegionFor(int note, int velocity, + SoundFontRegion::Trigger trigger = SoundFontRegion::Trigger::attack) const; + + int getNumRegions() const; + SoundFontRegion *regionAt(int index); + + const StringArray &getErrors() const { return this->errors; } + const StringArray &getWarnings() const { return this->warnings; } + + virtual int getNumPresets() const; + virtual String getPresetName(int whichSubsound) const; + virtual void setSelectedPreset(int whichSubsound); + virtual int getSelectedPreset() const; + + void addError(const String &message); + void addUnsupportedOpcode(const String &opcode); + String dump(); + + struct Preset final + { + const String name; + const int bank = 0; + const int preset = 0; + + OwnedArray regions; + + Preset() = default; + Preset(String nameIn, int bankIn, int presetIn) : + name(nameIn), bank(bankIn), preset(presetIn) {} + + void addRegion(UniquePointer &®ion) + { + this->regions.add(move(region)); + } + + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(Preset) + }; + +protected: + + File file; + + Array regions; + +private: + + friend class SoundFontReader; + void addRegion(UniquePointer &®ion); + WeakReference addSample(String path, String defaultPath = String()); + + UniquePointer preset; // a single virtual "preset" to own the regions + + FlatHashMap> samples; + + StringArray errors; + StringArray warnings; + FlatHashMap unsupportedOpcodes; + + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(SoundFontSound) +}; diff --git a/Source/Core/Audio/BuiltIn/SoundFont/SoundFontSynth.cpp b/Source/Core/Audio/BuiltIn/SoundFont/SoundFontSynth.cpp new file mode 100644 index 00000000..7b749aa9 --- /dev/null +++ b/Source/Core/Audio/BuiltIn/SoundFont/SoundFontSynth.cpp @@ -0,0 +1,839 @@ +/* + This file is part of Helio Workstation. + + Helio is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Helio is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Helio. If not, see . + + This SoundFont implementation is based on SFZero, + written by Steve Folta and extended by Leo Olivers and Cognitone, + distributed under MIT license, see README.md for details. +*/ + +#include "Common.h" +#include "SoundFontSynth.h" +#include "SoundFontSound.h" +#include "SoundFontRegion.h" +#include "SoundFontSample.h" +#include "SerializationKeys.h" +//#include + +class SoundFontEnvelope final +{ +public: + + SoundFontEnvelope() = default; + + void setExponentialDecay(bool newExponentialDecay); + void startNote(const EGParameters *parameters, + float floatVelocity, double sampleRate, + const EGParameters *velMod = nullptr); + + void nextSegment(); + void noteOff(); + void fastRelease(); + bool isDone() const { return (this->segment == Segment::Done); } + bool isReleasing() const { return (this->segment == Segment::Release); } + int segmentIndex() const { return static_cast(this->segment); } + float getLevel() const { return this->level; } + void setLevel(float v) { this->level = v; } + float getSlope() const { return this->slope; } + void setSlope(float v) { this->slope = v; } + int getSamplesUntilNextSegment() const { return this->samplesUntilNextSegment; } + void setSamplesUntilNextSegment(int v) { this->samplesUntilNextSegment = v; } + bool getSegmentIsExponential() const { return this->segmentIsExponential; } + void setSegmentIsExponential(bool v) { this->segmentIsExponential = v; } + +private: + + enum class Segment + { + Delay, + Attack, + Hold, + Decay, + Sustain, + Release, + Done + }; + + void startDelay(); + void startAttack(); + void startHold(); + void startDecay(); + void startSustain(); + void startRelease(); + + Segment segment = Segment::Done; + EGParameters parameters; + double sampleRate = 0.0; + bool exponentialDecay = false; + float level = 0.f; + float slope = 0.f; + int samplesUntilNextSegment = 0; + bool segmentIsExponential = false; + static const float BottomLevel; + + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(SoundFontEnvelope) +}; + +static const float fastReleaseTime = 0.01f; + +void SoundFontEnvelope::setExponentialDecay(bool newExponentialDecay) +{ + this->exponentialDecay = newExponentialDecay; +} + +void SoundFontEnvelope::startNote(const EGParameters *newParameters, float floatVelocity, double newSampleRate, + const EGParameters *velMod) +{ + this->parameters = *newParameters; + if (velMod) + { + this->parameters.delay += floatVelocity * velMod->delay; + this->parameters.attack += floatVelocity * velMod->attack; + this->parameters.hold += floatVelocity * velMod->hold; + this->parameters.decay += floatVelocity * velMod->decay; + this->parameters.sustain += floatVelocity * velMod->sustain; + if (this->parameters.sustain < 0.0) + { + this->parameters.sustain = 0.0; + } + else if (this->parameters.sustain > 100.0) + { + this->parameters.sustain = 100.0; + } + this->parameters.release += floatVelocity * velMod->release; + } + + this->sampleRate = newSampleRate; + this->startDelay(); +} + +void SoundFontEnvelope::nextSegment() +{ + switch (this->segment) + { + case Segment::Delay: + this->startAttack(); + break; + case Segment::Attack: + this->startHold(); + break; + case Segment::Hold: + this->startDecay(); + break; + case Segment::Decay: + this->startSustain(); + break; + case Segment::Sustain: + jassertfalse; + break; + case Segment::Release: + default: + this->segment = Segment::Done; + break; + } +} + +void SoundFontEnvelope::noteOff() +{ + this->startRelease(); +} + +void SoundFontEnvelope::fastRelease() +{ + this->segment = Segment::Release; + this->samplesUntilNextSegment = int(fastReleaseTime * this->sampleRate); + this->slope = -this->level / this->samplesUntilNextSegment; + this->segmentIsExponential = false; +} + +void SoundFontEnvelope::startDelay() +{ + if (this->parameters.delay <= 0) + { + this->startAttack(); + } + else + { + this->segment = Segment::Delay; + this->level = 0.0; + this->slope = 0.0; + this->samplesUntilNextSegment = int(this->parameters.delay * this->sampleRate); + this->segmentIsExponential = false; + } +} + +void SoundFontEnvelope::startAttack() +{ + if (this->parameters.attack <= 0) + { + this->startHold(); + } + else + { + this->segment = Segment::Attack; + this->level = this->parameters.start / 100.0f; + this->samplesUntilNextSegment = static_cast(this->parameters.attack * this->sampleRate); + this->slope = 1.0f / this->samplesUntilNextSegment; + this->segmentIsExponential = false; + } +} + +void SoundFontEnvelope::startHold() +{ + if (this->parameters.hold <= 0) + { + this->level = 1.0; + this->startDecay(); + } + else + { + this->segment = Segment::Hold; + this->samplesUntilNextSegment = static_cast(this->parameters.hold * this->sampleRate); + this->level = 1.0; + this->slope = 0.0; + this->segmentIsExponential = false; + } +} + +void SoundFontEnvelope::startDecay() +{ + if (this->parameters.decay <= 0) + { + this->startSustain(); + } + else + { + this->segment = Segment::Decay; + this->samplesUntilNextSegment = static_cast(this->parameters.decay * this->sampleRate); + this->level = 1.0; + if (this->exponentialDecay) + { + // I don't truly understand this; just following what LinuxSampler does. + float mysterySlope = -9.226f / this->samplesUntilNextSegment; + this->slope = exp(mysterySlope); + this->segmentIsExponential = true; + if (this->parameters.sustain > 0.0) + { + // Again, this is following LinuxSampler's example, which is similar to + // SF2-style decay, where "decay" specifies the time it would take to + // get to zero, not to the sustain level. The SFZ spec is not that + // specific about what "decay" means, so perhaps it's really supposed + // to specify the time to reach the sustain level. + this->samplesUntilNextSegment = static_cast(log((this->parameters.sustain / 100.0) / this->level) / mysterySlope); + if (this->samplesUntilNextSegment <= 0) + { + this->startSustain(); + } + } + } + else + { + this->slope = (this->parameters.sustain / 100.0f - 1.0f) / this->samplesUntilNextSegment; + this->segmentIsExponential = false; + } + } +} + +void SoundFontEnvelope::startSustain() +{ + if (this->parameters.sustain <= 0) + { + this->startRelease(); + } + else + { + this->segment = Segment::Sustain; + this->level = this->parameters.sustain / 100.0f; + this->slope = 0.0; + this->samplesUntilNextSegment = 0x7FFFFFFF; + this->segmentIsExponential = false; + } +} + +void SoundFontEnvelope::startRelease() +{ + float release = this->parameters.release; + if (release <= 0) + { + // Enforce a short release, to prevent clicks. + release = fastReleaseTime; + } + + this->segment = Segment::Release; + this->samplesUntilNextSegment = static_cast(release * this->sampleRate); + if (this->exponentialDecay) + { + // I don't truly understand this; just following what LinuxSampler does. + const float mysterySlope = -9.226f / this->samplesUntilNextSegment; + this->slope = exp(mysterySlope); + this->segmentIsExponential = true; + } + else + { + this->slope = -this->level / this->samplesUntilNextSegment; + this->segmentIsExponential = false; + } +} + +const float SoundFontEnvelope::BottomLevel = 0.001f; + +//===----------------------------------------------------------------------===// +// SoundFontVoice +//===----------------------------------------------------------------------===// + +class SoundFontVoice final : public SynthesiserVoice +{ +public: + + SoundFontVoice() + { + this->envelope.setExponentialDecay(true); + } + + bool canPlaySound(SynthesiserSound *sound) override; + void startNote(int midiNoteNumber, float velocity, SynthesiserSound *sound, int currentPitchWheelPosition) override; + void stopNote(float velocity, bool allowTailOff) override; + void stopNoteForGroup(); + void stopNoteQuick(); + void pitchWheelMoved(int newValue) override; + void controllerMoved(int controllerNumber, int newValue) override {} + void renderNextBlock(AudioSampleBuffer &outputBuffer, int startSample, int numSamples) override; + bool isPlayingNoteDown(); + bool isPlayingOneShot(); + + int getGroup(); + uint64 getOffBy(); + + // Set the region to be used by the next startNote(). + void setRegion(SoundFontRegion *nextRegion); + + String infoString(); + +private: + + SoundFontRegion *region = nullptr; + int trigger = 0; + int currentMidiNote = 0; + int currentPitchWheel = 0; + double pitchRatio = 0.0; + float noteGainLeft = 0.f; + float noteGainRight = 0.f; + double sourceSamplePosition = 0.0; + SoundFontEnvelope envelope; + int64 sampleEnd = 0; + int64 loopStart = 0; + int64 loopEnd = 0; + + // Info only. + int numLoops = 0; + int currentVelocity = 0; + + void calcPitchRatio(); + void killNote(); + + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(SoundFontVoice) +}; + +static const float globalGain = -1.0; + +bool SoundFontVoice::canPlaySound(SynthesiserSound *sound) +{ + return dynamic_cast(sound) != nullptr; +} + +void SoundFontVoice::startNote(int midiNoteNumber, float floatVelocity, SynthesiserSound *soundIn, + int currentPitchWheelPosition) +{ + const auto *sound = dynamic_cast(soundIn); + + if (sound == nullptr) + { + this->killNote(); + return; + } + + const int velocity = static_cast(floatVelocity * 127.0); + this->currentVelocity = velocity; + if (this->region == nullptr) + { + this->region = sound->getRegionFor(midiNoteNumber, velocity); + } + + if ((this->region == nullptr) || (this->region->sample == nullptr) || (this->region->sample->getBuffer() == nullptr)) + { + this->killNote(); + return; + } + + if (this->region->negativeEnd) + { + this->killNote(); + return; + } + + // Pitch. + this->currentMidiNote = midiNoteNumber; + this->currentPitchWheel = currentPitchWheelPosition; + this->calcPitchRatio(); + + // Gain. + double noteGainDB = globalGain + this->region->volume; + // Thanks to for explaining the + // velocity curve in a way that I could understand, although they mean + // "log10" when they say "log". + double velocityGainDB = -20.0 * log10((127.0 * 127.0) / (velocity * velocity)); + velocityGainDB *= this->region->ampVelTrack / 100.0; + noteGainDB += velocityGainDB; + this->noteGainLeft = this->noteGainRight = static_cast(Decibels::decibelsToGain(noteGainDB)); + // The SFZ spec is silent about the pan curve, but a 3dB pan law seems + // common. This sqrt() curve matches what Dimension LE does; Alchemy Free + // seems closer to sin(adjustedPan * pi/2). + const double adjustedPan = (this->region->pan + 100.0) / 200.0; + this->noteGainLeft *= static_cast(sqrt(1.0 - adjustedPan)); + this->noteGainRight *= static_cast(sqrt(adjustedPan)); + this->envelope.startNote(&this->region->ampeg, floatVelocity, + this->getSampleRate(), &this->region->ampegVelTrack); + + // Offset/end. + this->sourceSamplePosition = static_cast(this->region->offset); + this->sampleEnd = this->region->sample->getSampleLength(); + if ((this->region->end > 0) && (this->region->end < this->sampleEnd)) + { + this->sampleEnd = this->region->end + 1; + } + + // Loop. + this->loopStart = this->loopEnd = 0; + auto loopMode = this->region->loopMode; + if (loopMode == SoundFontRegion::LoopMode::sampleLoop) + { + if (this->region->sample->getLoopStart() < this->region->sample->getLoopEnd()) + { + loopMode = SoundFontRegion::LoopMode::loopContinuous; + } + else + { + loopMode = SoundFontRegion::LoopMode::noLoop; + } + } + if ((loopMode != SoundFontRegion::LoopMode::noLoop) && (loopMode != SoundFontRegion::LoopMode::oneShot)) + { + if (this->region->loopStart < this->region->loopEnd) + { + this->loopStart = this->region->loopStart; + this->loopEnd = this->region->loopEnd; + } + else + { + this->loopStart = this->region->sample->getLoopStart(); + this->loopEnd = this->region->sample->getLoopEnd(); + } + } + + this->numLoops = 0; +} + +void SoundFontVoice::stopNote(float /*velocity*/, bool allowTailOff) +{ + if (!allowTailOff || (this->region == nullptr)) + { + this->killNote(); + return; + } + + if (this->region->loopMode != SoundFontRegion::LoopMode::oneShot) + { + this->envelope.noteOff(); + } + if (this->region->loopMode == SoundFontRegion::LoopMode::loopSustain) + { + // Continue playing, but stop looping. + this->loopEnd = this->loopStart; + } +} + +void SoundFontVoice::stopNoteForGroup() +{ + if (this->region->offMode == SoundFontRegion::OffMode::fast) + { + this->envelope.fastRelease(); + } + else + { + this->envelope.noteOff(); + } +} + +void SoundFontVoice::stopNoteQuick() +{ + this->envelope.fastRelease(); +} + +void SoundFontVoice::pitchWheelMoved(int newValue) +{ + if (this->region == nullptr) + { + return; + } + + this->currentPitchWheel = newValue; + this->calcPitchRatio(); +} + +void SoundFontVoice::renderNextBlock(AudioSampleBuffer &outputBuffer, int startSample, int numSamples) +{ + if (this->region == nullptr) + { + return; + } + + const auto *buffer = this->region->sample->getBuffer(); + if (buffer == nullptr) + { + jassertfalse; + return; + } + + const float *inL = buffer->getReadPointer(0, 0); + const float *inR = buffer->getNumChannels() > 1 ? buffer->getReadPointer(1, 0) : nullptr; + + float *outL = outputBuffer.getWritePointer(0, startSample); + float *outR = outputBuffer.getNumChannels() > 1 ? outputBuffer.getWritePointer(1, startSample) : nullptr; + + const int bufferNumSamples = buffer->getNumSamples(); + + // Cache some values, to give them at least some chance of ending up in registers. + double sourceSamplePosition = this->sourceSamplePosition; + float ampegGain = this->envelope.getLevel(); + float ampegSlope = this->envelope.getSlope(); + int samplesUntilNextAmpSegment = this->envelope.getSamplesUntilNextSegment(); + bool ampSegmentIsExponential = this->envelope.getSegmentIsExponential(); + + const float loopStart = float(this->loopStart); + const float loopEnd = float(this->loopEnd); + const float sampleEnd = float(this->sampleEnd); + + while (--numSamples >= 0) + { + int pos = int(sourceSamplePosition); + jassert(pos >= 0 && pos < bufferNumSamples); + float alpha = float(sourceSamplePosition - pos); + float invAlpha = 1.0f - alpha; + int nextPos = pos + 1; + if ((loopStart < loopEnd) && (nextPos > loopEnd)) + { + nextPos = int(loopStart); + } + + // Simple linear interpolation with buffer overrun check + float nextL = nextPos < bufferNumSamples ? inL[nextPos] : inL[pos]; + float nextR = inR ? (nextPos < bufferNumSamples ? inR[nextPos] : inR[pos]) : nextL; + float l = (inL[pos] * invAlpha + nextL * alpha); + float r = inR ? (inR[pos] * invAlpha + nextR * alpha) : l; + + float gainLeft = this->noteGainLeft * ampegGain; + float gainRight = this->noteGainRight * ampegGain; + l *= gainLeft; + r *= gainRight; + // Shouldn't we dither here? + + if (outR) + { + *outL++ += l; + *outR++ += r; + } + else + { + *outL++ += (l + r) * 0.5f; + } + + // Next sample. + sourceSamplePosition += this->pitchRatio; + if ((loopStart < loopEnd) && (sourceSamplePosition > loopEnd)) + { + sourceSamplePosition = loopStart; + this->numLoops += 1; + } + + // Update EG. + if (ampSegmentIsExponential) + { + ampegGain *= ampegSlope; + } + else + { + ampegGain += ampegSlope; + } + if (--samplesUntilNextAmpSegment < 0) + { + this->envelope.setLevel(ampegGain); + this->envelope.nextSegment(); + ampegGain = this->envelope.getLevel(); + ampegSlope = this->envelope.getSlope(); + samplesUntilNextAmpSegment = this->envelope.getSamplesUntilNextSegment(); + ampSegmentIsExponential = this->envelope.getSegmentIsExponential(); + } + + if ((sourceSamplePosition >= sampleEnd) || this->envelope.isDone()) + { + this->killNote(); + break; + } + } + + this->sourceSamplePosition = sourceSamplePosition; + this->envelope.setLevel(ampegGain); + this->envelope.setSamplesUntilNextSegment(samplesUntilNextAmpSegment); +} + +bool SoundFontVoice::isPlayingNoteDown() +{ + return this->region && this->region->trigger != SoundFontRegion::Trigger::release; +} + +bool SoundFontVoice::isPlayingOneShot() +{ + return this->region && this->region->loopMode == SoundFontRegion::LoopMode::oneShot; +} + +int SoundFontVoice::getGroup() +{ + return this->region ? this->region->group : 0; +} + +uint64 SoundFontVoice::getOffBy() +{ + return this->region ? this->region->offBy : 0; +} + +void SoundFontVoice::setRegion(SoundFontRegion *nextRegion) +{ + this->region = nextRegion; +} + +String SoundFontVoice::infoString() +{ + const char *egSegmentNames[] = {"delay", "attack", "hold", "decay", "sustain", "release", "done"}; + + const static int numEGSegments(sizeof(egSegmentNames) / sizeof(egSegmentNames[0])); + + const char *egSegmentName = "-Invalid-"; + int egSegmentIndex = this->envelope.segmentIndex(); + if ((egSegmentIndex >= 0) && (egSegmentIndex < numEGSegments)) + { + egSegmentName = egSegmentNames[egSegmentIndex]; + } + + String info; + info << "note: " << this->currentMidiNote << ", vel: " << this->currentVelocity + << ", pan: " << this->region->pan << ", eg: " << egSegmentName + << ", loops: " << this->numLoops; + + return info; +} + +void SoundFontVoice::calcPitchRatio() +{ + double note = this->currentMidiNote; + + note += this->region->transpose; + note += this->region->tune / 100.0; + + double adjustedPitch = this->region->pitchKeyCenter + + (note - this->region->pitchKeyCenter) * (this->region->pitchKeyTrack / 100.0); + + if (this->currentPitchWheel != 8192) + { + double wheel = ((2.0 * this->currentPitchWheel / 16383.0) - 1.0); + if (wheel > 0) + { + adjustedPitch += wheel * this->region->bendUp / 100.0; + } + else + { + adjustedPitch += wheel * this->region->bendDown / -100.0; + } + } + + // todo someday: support equal temperaments like the default synth does? + const auto fractionalMidiNoteInHz = [](double note, double freqOfA = 440.0) { + // Like MidiMessage::getMidiNoteInHertz(), but with a float note. + note -= 69; + // Now 0 = A + return freqOfA * pow(2.0, note / 12.0); + }; + + const double targetFreq = fractionalMidiNoteInHz(adjustedPitch); + const double naturalFreq = MidiMessage::getMidiNoteInHertz(this->region->pitchKeyCenter); + this->pitchRatio = (targetFreq * this->region->sample->getSampleRate()) / (naturalFreq * getSampleRate()); +} + +void SoundFontVoice::killNote() +{ + this->region = nullptr; + this->clearCurrentNote(); +} + +//===----------------------------------------------------------------------===// +// SoundFontSynth +//===----------------------------------------------------------------------===// + +void SoundFontSynth::noteOn(int midiChannel, int midiNoteNumber, float velocity) +{ + int i; + + const ScopedLock locker(this->lock); + + int midiVelocity = static_cast(velocity * 127); + + // First, stop any currently-playing sounds in the group. + // Currently, this only pays attention to the first matching region. + int group = 0; + auto *sound = dynamic_cast(this->getSound(0).get()); + if (sound != nullptr) + { + if (auto *region = sound->getRegionFor(midiNoteNumber, midiVelocity)) + { + group = region->group; + } + } + + if (group != 0) + { + for (auto *v : this->voices) + { + jassert(dynamic_cast(v)); + auto *voice = static_cast(v); + if (voice->getOffBy() == group) + { + voice->stopNoteForGroup(); + } + } + } + + // Are any notes playing? (Needed for first/legato trigger handling.) + // Also stop any voices still playing this note. + bool anyNotesPlaying = false; + for (auto *v : this->voices) + { + jassert(dynamic_cast(v)); + auto *voice = static_cast(v); + + if (voice->isPlayingChannel(midiChannel) && voice->isPlayingNoteDown()) + { + if (voice->getCurrentlyPlayingNote() == midiNoteNumber) + { + if (!voice->isPlayingOneShot()) + { + voice->stopNoteQuick(); + } + } + else + { + anyNotesPlaying = true; + } + } + } + + // Play *all* matching regions. + if (sound != nullptr) + { + const auto trigger = anyNotesPlaying ? SoundFontRegion::Trigger::legato : SoundFontRegion::Trigger::first; + for (i = 0; i < sound->getNumRegions(); ++i) + { + auto *region = sound->regionAt(i); + if (region->matches(midiNoteNumber, midiVelocity, trigger)) + { + if (auto *voice = dynamic_cast(this->findFreeVoice(sound, + midiNoteNumber, midiChannel, this->isNoteStealingEnabled()))) + { + voice->setRegion(region); + this->startVoice(voice, sound, midiChannel, midiNoteNumber, velocity); + } + } + } + } + + this->noteVelocities[midiNoteNumber] = midiVelocity; +} + +void SoundFontSynth::noteOff(int midiChannel, int midiNoteNumber, float velocity, bool allowTailOff) +{ + const ScopedLock locker(this->lock); + + Synthesiser::noteOff(midiChannel, midiNoteNumber, velocity, allowTailOff); + + // Start release region. + if (auto *sound = dynamic_cast(this->getSound(0).get())) + { + if (auto *region = sound->getRegionFor(midiNoteNumber, this->noteVelocities[midiNoteNumber], SoundFontRegion::Trigger::release)) + { + if (auto *voice = dynamic_cast(this->findFreeVoice(sound, midiNoteNumber, midiChannel, false))) + { + // Synthesiser is too locked-down (ivars are private rt protected), so + // we have to use a "setRegion()" mechanism. + voice->setRegion(region); + this->startVoice(voice, sound, midiChannel, midiNoteNumber, this->noteVelocities[midiNoteNumber] / 127.0f); + } + } + } +} + +//===----------------------------------------------------------------------===// +// Presets +//===----------------------------------------------------------------------===// + +int SoundFontSynth::getNumPrograms() const +{ + if (auto *sound = dynamic_cast(this->getSound(0).get())) + { + return sound->getNumPresets(); + } + + return 0; +} + +int SoundFontSynth::getCurrentProgram() const +{ + if (auto *sound = dynamic_cast(this->getSound(0).get())) + { + return sound->getSelectedPreset(); + } + + return 0; +} + +void SoundFontSynth::setCurrentProgram(int index) +{ + if (auto *sound = dynamic_cast(this->getSound(0).get())) + { + return sound->setSelectedPreset(index < this->getNumPrograms() ? index : 0); + } +} + +const String SoundFontSynth::getProgramName(int index) const +{ + if (auto *sound = dynamic_cast(this->getSound(0).get())) + { + return sound->getPresetName(index); + } + + return {}; +} + +void SoundFontSynth::changeProgramName(int index, const String &newName) +{ + jassertfalse; +} diff --git a/Source/Core/Audio/BuiltIn/SoundFont/SoundFontSynth.h b/Source/Core/Audio/BuiltIn/SoundFont/SoundFontSynth.h new file mode 100644 index 00000000..9cd7ed7a --- /dev/null +++ b/Source/Core/Audio/BuiltIn/SoundFont/SoundFontSynth.h @@ -0,0 +1,49 @@ +/* + This file is part of Helio Workstation. + + Helio is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Helio is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Helio. If not, see . + + This SoundFont implementation is based on SFZero, + written by Steve Folta and extended by Leo Olivers and Cognitone, + distributed under MIT license, see README.md for details. +*/ + +#pragma once + +class SoundFontSynth final : public Synthesiser +{ +public: + + SoundFontSynth() = default; + + void noteOn(int midiChannel, int midiNoteNumber, float velocity) override; + void noteOff(int midiChannel, int midiNoteNumber, float velocity, bool allowTailOff) override; + + + //===------------------------------------------------------------------===// + // Presets + //===------------------------------------------------------------------===// + + int getNumPrograms() const; + int getCurrentProgram() const; + void setCurrentProgram(int index); + const String getProgramName(int index) const; + void changeProgramName(int index, const String &newName); + +private: + + int noteVelocities[128]; + + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(SoundFontSynth) +};